Skip to main content

Connecting to instruments

Capabilities: net.tcp-client, or net.ws with the hosts named, plus vessel.publish and ais.publish for what you put in the stores.

A connection is a TCP or WebSocket link to an instrument on the network. The mariner adds connections in the settings window and switches them on and off there. Lookout opens each socket, reconnects when it drops, and shows its status. The plugin parses what arrives.

One declaration gives the plugin the whole surface: the settings section the mariner fills in, a socket per connection, the reconnect clock, the failure count behind "unreachable", the pause switch, the per-connection status and the plugin's own status line.

const lk = @import("lk2");

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

pub const Connections = lk.connections(.{
.key = "servers",
.group = "Signal K servers",
.add_label = "Add Server",
.status_empty = "no servers",
.rate_noun = "delta",
.Extra = struct {
websocket: lk.Flag = .{
.label = "WebSocket",
.desc = "Connect with a websocket instead of a plain TCP stream.",
.default = false,
},
},
// Per-connection parse state: the partial line.
.State = struct { partial: lk.Str(512) = .{} },
});

/// Bytes from one connection's socket.
pub fn onData(conn: *Connections.Connection, bytes: []const u8) void {
conn.state.partial.append(bytes);
conn.count(1);
}

Every connection has a name, an address, a port and an on switch, and Lookout owns those fields. The websocket field above is the plugin's own, and so is the parse state each connection keeps between reads.

The declaration

pub const Connections = lk.connections(.{
.key = "gateways",
.group = "NMEA gateways",
.footer = "Give the address of your instrument network's gateway.",
.empty = "No gateways yet.",
.add_label = "Add Gateway",
.status_empty = "no gateways",
.rate_noun = "msg",
.columns = .{
.port = .{
.label = "Port",
.desc = "Most WiFi gateways serve NMEA 0183 on port 10110.",
.min = 1,
.max = 65535,
.default = 10110,
},
},
.Extra = struct {
websocket: lk.Flag = .{ .label = "WebSocket", .default = false },
},
.State = struct { line: lk.Str(96) = .{} },
});
OptionDefaultWhat it does
keyrequiredthe config key the connection list arrives under
grouprequiredthe section heading in the settings window
tab.connectionswhich settings tab the group lands on
footer, empty, add_labelemptythe list's own wording in the settings window
columnsthe SDK's wordingwords the four standard fields and sets the port's range
Extrastruct {}fields beyond the four, declared like a settings group
Statestruct {}per-connection state the plugin keeps: a framer, a parser, an identity
reconnect_ms2_000delay before a dropped connection is retried
unreachable_after3failed connects in a row before a connection reads as unreachable
status_ms2_000how often the status is rebuilt, and the window a rate is averaged over
rate_noun"msg"what conn.count counts, for the status: 42 msg/s
status_empty"nothing configured"the plugin's detail when the mariner has added no connections
no_answer_detail"check the address"what a connection says once it reads as unreachable
refused_detail"the host refused this address"what a connection says when Lookout would not dial it

The Zig SDK keeps its buffers fixed, so a connection list holds up to 8 connections, more than a boat's instrument network needs. Each connection is matched to its socket by an id Lookout assigns, so editing one never disturbs another's stream. Only an address change, a column change, a pause or a delete closes a socket.

The extra columns are declared like settings fields: lk.Flag is a switch, lk.Num a number with a range, lk.Text a text field. lk.Text carries label, desc, default and optional; optional means no default.

The hooks

HookWhen
onData(conn, bytes)bytes from one connection's socket. Required
onOpen(conn)a stream came up. Send a subscription here
onClose(conn)a stream ended
connectionNote(conn)a phrase to add after the connection's rate
endpoint(conn)where to dial, when it is not the connection's host and port

endpoint returns an lk.Endpoint: .{ .tcp = .{ .host = …, .port = … } }, .{ .ws = url }, or .{ .refused = "why" }. A refused connection stops retrying and shows that sentence as its status.

pub fn endpoint(conn: *Connections.Connection) lk.Endpoint {
if (!conn.cols.websocket) return .{ .tcp = .{ .host = conn.host.text(), .port = conn.port } };
return .{ .ws = buildUrl(conn) };
}

The connection object

Every hook receives the same connection object. Its fields are what the mariner filled in plus your own columns and state; its methods talk to the socket and the status line.

FieldTypeWhat it is
idlk.Str(32)Lookout's id for the connection. It survives an edit
namelk.Str(48)what the mariner calls it. May be empty
hostlk.Str(128)
portu16
enabledboolfalse means paused
colsthe Extra valuesf64, bool or a fixed string per field
statethe State structthe plugin's own, reset when the connection changes address

lk.Str(n) is a fixed string: .text() reads it, .set and .append write it and cut at the capacity, .clear() empties it, and .full() says whether a write was cut.

CallWhat it does
conn.label()the mariner's name, or the address
conn.connected()true while the stream is up
conn.send(bytes)write to this connection's stream
conn.count(n)count n of whatever this connection carries, for the rate
conn.setDetail(fmt, args)add a phrase to this connection's status line
Connections.all()every connection the mariner has, in the order the settings window shows
Connections.byId(id)one connection, or null

Lookout posts the plugin's status line and one status item per connection: connected, reconnecting, unreachable, paused, no_address or refused. The plugin's own line counts what is up: 2 of 3 connected, 44 msg/s. conn.count is what feeds the rate.

Reference: lists and status items.