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.
- Zig
- Go
- Rust
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);
}
package main
import lk "github.com/beetlebugorg/lookout-marine/sdk/go/lookout"
// One connection: the plugin's extra columns, and the state its parser keeps.
type feed struct {
WebSocket bool `lk:"websocket" label:"WebSocket" desc:"Connect with a websocket instead of a plain TCP stream." default:"false"`
partial []byte
}
var servers = lk.Connections(lk.ConnOpts{
Key: "servers",
Group: "Signal K servers",
AddLabel: "Add Server",
StatusEmpty: "no servers",
RateNoun: "delta",
State: func() any { return &feed{} },
})
type signalK struct{}
func init() { lk.Register(&signalK{}) }
func main() {}
// OnData is bytes from one connection's socket.
func (p *signalK) OnData(conn *lk.Conn, data []byte) {
s := conn.State.(*feed)
s.partial = append(s.partial, data...)
conn.Count(1)
}
use lookout as lk;
lk::columns! {
pub struct SkColumns {
websocket: Flag {
label: "WebSocket",
desc: "Connect with a websocket instead of a plain TCP stream.",
default: false,
},
}
}
struct Servers;
impl lk::ConnSpec for Servers {
type Columns = SkColumns; // beyond the four every list carries
type State = Vec<u8>; // per-connection parse state: the partial line
const OPTS: lk::ConnOpts = lk::ConnOpts {
key: "servers",
group: "Signal K servers",
add_label: "Add Server",
status_empty: "no servers",
rate_noun: "delta",
..lk::ConnOpts::DEFAULT
};
}
#[derive(Default)]
struct SignalK;
lk::plugin!(SignalK, connections: Servers);
impl lk::Plugin for SignalK {
// It publishes and draws nothing, so it asks for no draw timer.
const DRAW_RATE_MS: i64 = 0;
}
impl lk::Source<Servers> for SignalK {
/// Bytes from one connection's socket.
fn on_data(&mut self, conn: &mut lk::Connection<Servers>, bytes: &[u8]) {
conn.state.extend_from_slice(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) = .{} },
});
| Option | Default | What it does |
|---|---|---|
key | required | the config key the connection list arrives under |
group | required | the section heading in the settings window |
tab | .connections | which settings tab the group lands on |
footer, empty, add_label | empty | the list's own wording in the settings window |
columns | the SDK's wording | words the four standard fields and sets the port's range |
Extra | struct {} | fields beyond the four, declared like a settings group |
State | struct {} | per-connection state the plugin keeps: a framer, a parser, an identity |
reconnect_ms | 2_000 | delay before a dropped connection is retried |
unreachable_after | 3 | failed connects in a row before a connection reads as unreachable |
status_ms | 2_000 | how 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
| Hook | When |
|---|---|
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.
| Field | Type | What it is |
|---|---|---|
id | lk.Str(32) | Lookout's id for the connection. It survives an edit |
name | lk.Str(48) | what the mariner calls it. May be empty |
host | lk.Str(128) | |
port | u16 | |
enabled | bool | false means paused |
cols | the Extra values | f64, bool or a fixed string per field |
state | the State struct | the 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.
| Call | What 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.