Skip to main content

The plugin SDK

Your plugin declares what it reads. It draws on the chart, publishes values into the store, or does both. Lookout does the rest: it delivers each declared input, calls your draw function on a timer, puts your scene on the chart, dials your connections, and stores your settings.

These pages are the reference. Recipes is the same surface arranged by what you are trying to do, and Build your first plugin walks one plugin from an empty directory to a chart with a line on it.

Which way the data flows

Everything a plugin does moves in one of two directions, and the vessel store sits in the middle.

In: inputs are values your plugin reads from the store: the current position, the wind, the AIS targets. Declaring an input subscribes your plugin to it. A connection brings bytes in from the network. onEvent brings in anything else you asked Lookout for, such as an HTTP response or a file the mariner opened.

Out: your draw function puts your scene on the chart. publish and the AIS upsert write values into the store. The status line and alerts go to the person at the helm.

The store connects plugins to each other: the position nmea0183 publishes is the position ownship reads as an input. One plugin can read an instrument and publish what it hears, another can read the store and draw, and one plugin can do both.

The Zig SDK, plugins/common/lk2.zig, defines the API. When these pages and its doc comments disagree, the code is correct. The Go and Rust SDKs implement the same API with the same names, in each language's own style.

What a plugin declares

lk.plugin reads your module and wires only what it finds, so a module with nothing else registers, starts and does nothing. Every declaration is optional, and the names are exact: Lookout looks each one up by name, so a typo like Setting is not an error, it is a plugin with no settings.

DeclarationWhat it doesDocumented in
inputssubscribes the plugin to store valuesSubscribing to data
draw(c)describes the scene, on a timerDrawing on the chart
draw_rate_mshow often draw runs, default 1000Drawing on the chart
onUpdate()runs when an input has a new value or expires, and fills any tableSubscribing to data
lk.table(…)a dialog the mariner opens from a menuSubscribing to data
Settingssettings the mariner can changeAdding settings
onSettings()runs after a settings changeAdding settings
Connectionsa connection listConnecting to instruments
onData(conn, bytes)bytes from one connection's socketConnecting to instruments
onOpen, onClose, connectionNote, endpointthe other connection hooksConnecting to instruments
onStart(s)runs once at startupbelow
onEvent(e)every event the SDK did not consumeHandling events
onShutdown()runs once at shutdownbelow

Registering the plugin

const lk = @import("lk2");

comptime {
lk.plugin(@This());
}

In Go, register from init or from a package-level variable. main never runs, and package main still needs an empty one to compile. In Rust the instance is built with Default on the first call into the module.

Starting and stopping

You rarely need either hook. Declare pub fn onStart(s: lk.raw.Start) !void to run something once, after the wiring and before the first event; return an error and the plugin does not start. Declare pub fn onShutdown() void for the last word before the plugin stops. After it returns, Lookout drops every overlay object the plugin drew, so there is nothing to clean up on the chart.

A complete plugin

const lk = @import("lk2");

comptime {
lk.plugin(@This());
}

pub const inputs = struct {
pub const boat = lk.subscribePosition("navigation.position", .{});
pub const twd = lk.subscribeNumber("environment.wind.directionTrue", .{ .label = "wind" });
};

pub fn draw(c: *lk.Chart) void {
const from = inputs.boat.get();
// The wind direction is where the wind blows FROM, so downwind is the
// reciprocal.
const to = from.destination(inputs.twd.get() + 180, lk.nm(1));
c.line("windline", &.{ from, to }, .{ .color = .warning, .dash = true });
}

That is a complete plugin. The plugin subscribes to both paths. Lookout records and ages what arrives, calls your draw function once a second (the default; see Drawing on the chart), and sends the difference between this scene and the last. When either value passes its 5 s window the line comes off the chart and the status reads no position, no wind.

The windline example is available in each language: plugins/windline/, sdk/go/examples/windline/ and sdk/rust/examples/windline/.

The names in Zig, Go and Rust

What it doesZigGoRust
Registerlk.plugin(@This())lk.Register(&p{})lk::plugin!(P)
A number inputlk.subscribeNumber(path, .{})lk.SubscribeNumber(path)lk::subscribe_number(path)
A position inputlk.subscribePosition(path, .{})lk.SubscribePosition(path)lk::subscribe_position(path)
The AIS setlk.subscribeAis(.{})lk.SubscribeAIS()lk::subscribe_ais(max)
Read a valuein.get(), in.fresh()in.Get(), in.Fresh()in.get(), in.fresh()
The draw hookpub fn draw(c)Draw(*lk.Chart)fn draw(&mut self, c)
Draw a linec.line(id, pts, style)c.Line(id, pts, style)c.line(id, pts, style)
The status linec.status(fmt, args)c.Status(format, a…)c.status(&text)
The update hookpub fn onUpdate()OnUpdate()fn on_update(&mut self)
Declare a tablelk.table(.{})lk.NewTable(opts)lk::TableSpec
Write a rowT.upsert(.{ … })t.Row(id)…Done()t.row(id)…done()
Settings valueslk.settings(G)the Settings fieldG::get()
A connection listlk.connections(.{})lk.Connections(opts)impl lk::ConnSpec
The data hookpub fn onData(conn, b)OnData(*lk.Conn, []byte)fn on_data(&mut self, …)
Publish valueslk.Publish.begin()lk.NewPublish()lk::Publish::begin()
Raise an alarmlk.alert(sev, t, b)lk.Alert(sev, t, b)lk::alert(sev, t, b)
Raise an alarm about one vessellk.alertKeyed(key, sev, t, b)lk.AlertKeyed(key, sev, t, b)lk::alert_keyed(key, sev, t, b)

Four differences between the languages are not cosmetic.

  • Zig catches a misused optional input at compile time. get() on an optional input is a compile error naming the two ways out. Rust encodes the same thing in the type. Go has no way to say it, so Get() answers the last value whether or not it is stale.
  • Zig catches a misdeclared table cell at compile time. A row field that names no column is a compile error. Rust has one method for a text cell and one for a number, so the column type is checked where you write it. Go takes any value and answers a mismatch with a dash on screen and one log line.
  • Zig's limits are fixed arrays. The scene batch is 64 KiB, an overlay id is kept to 48 bytes and a connection list holds 8 connections. Go and Rust grow instead, so a scene or a connection count that Zig drops still goes out from them.
  • Only Zig is checked by zig build test. A Go plugin's manifest check is a go test you run, and a Rust plugin's is a cargo test.

The full listings for each language are in sdk/go/ENTRYPOINTS.md and sdk/rust/ENTRYPOINTS.md.