Receiver trust and maritimeEdit on GitHubSource: docs/MARINE-INTEGRATIONS.md

Marine integrations: the trust score on the bridge

Kshana's maritime trust score (MARITIME-TRUST.md) is reachable from software boaters already run: a Signal K server plugin and OpenCPN through gate-mode NMEA over TCP. A reference build puts it on a small computer next to the receiver.

Advisory software. Kshana is not type-approved navigation equipment (IEC 61108, IEC 61162) and has not been assessed against any performance standard. Everything on this page is an aid to the navigator's judgement. The operator stays responsible for the safe navigation of the vessel and for any decision to rely on, or to stop relying on, a fix. Nothing here transmits, and nothing here makes a fix more accurate.

Piece Where Status in 0.35
Signal K plugin integrations/signalk/ shipped, unit-tested on recorded synthetic output; not published to npm
OpenCPN via gate-mode NMEA over TCP integrations/opencpn/ shipped and tested (consumer-side replay); the OpenCPN user interface itself is not driven by any test
OpenCPN native score-panel plugin integrations/opencpn/plugin/ built, logic unit-tested, and run inside OpenCPN 5.8.4 under a virtual display (evidence in integrations/opencpn/evidence/); not packaged for the plugin manager; ships only after review
Reference build deploy/reference-build/ parts, OS steps, systemd units (dry-run checked), a container option; not tested on a vessel

All of it reads one thing: the output of kshana receiver-trust live (JSON lines, the $PKSHT sentence, the gate). The wire formats are in MARITIME-TRUST.md. The Signal K plugin keeps its knowledge of them in one file, integrations/signalk/lib/adapter.js; a format change is a one-file edit.

Signal K plugin#

No npm dependencies. The plugin itself runs on Node 18 or newer; current signalk-server releases need Node 22 or newer, so in practice use 22. Copy or link integrations/signalk/ into the server's node_modules (for example ~/.signalk/node_modules/signalk-kshana-trust), restart the server and enable Kshana GNSS trust under Server, Plugin Config.

Where the epochs come from (source)#

source What the plugin does
spawn-signalk-nmea (default) Runs kshana receiver-trust live <sessionFile> and writes every raw NMEA 0183 sentence the server receives (the server's nmea0183 event) to its standard input. Restarts it, with back-off, if it exits | Use it only on a server with a single NMEA source: it feeds every sentence from every provider, interleaved, so a second GNSS receiver or AIS looks like one receiver whose position jumps and can be scored as spoofing. With more than one source use spawn-args with that receiver's own --tcp stream, or tcp-pksht from a gate.
spawn-args Runs kshana receiver-trust live <sessionFile> <inputArgs...>, for example ["--tcp","192.0.2.10:10110"] or ["--udp","10110"]
tcp-json Connects to a feed of the JSON lines kshana writes (for example from the relay in integrations/opencpn/)
tcp-pksht Connects to an NMEA stream (such as the gate output of the service or container in the reference build, port 10110) and reads only its $PKSHT sentences

A stream that is already being spoofed when the monitor first connects has no clean baseline (the first calibration_s seconds are never scored). Start it before the voyage.

Published paths#

Every epoch updates (source kshana-trust):

Path Value
navigation.gnss.kshana.band calibrating, nominal, degraded or untrusted
navigation.gnss.kshana.score 0 to 100, or null while calibrating
navigation.gnss.kshana.reasons [{monitor, points}], largest first: which checks reduced the score
navigation.gnss.kshana.alarms the monitors currently over their threshold
navigation.gnss.kshana.gate off, passed or withheld (when a gate is running elsewhere; null from $PKSHT when absent)
navigation.gnss.kshana.reportedPosition {latitude, longitude, altitude}: the position the receiver under test reported at that epoch (JSON schema 1.1 or later; not published when the stream carries none, for example from $PKSHT or an older kshana). Trust context only: the plugin never writes navigation.position, so the vessel's own position path is untouched
notifications.navigation.gnss.kshana.trust raised when the state changes: {state, method, message}

The plugin also sends Signal K metadata for these paths once at start. score is Kshana's 0 to 100 trust score, a score and not a ratio or a probability, so it carries no SI unit; its metadata gives the range, a description and zones that follow the thresholds below. reasons[].points are score points taken off that 0 to 100 score.

The notification is normal, alert, warn or alarm. It is cleared by publishing state normal, not null: a consumer that only treats null as cleared should treat normal as cleared too. By default the degraded band gives warn and the untrusted band gives alarm, with method: ["visual","sound"]. It is lowered after clearAfterEpochs epochs at a better state. If no epoch arrives for staleAfterS seconds it warns that the score is not being updated. Calibration never raises a notification. The $PKSHT source carries no alarms list (the sentence has the band, score and the top two reasons only).

Configuration (every threshold)#

Key Default Meaning
source spawn-signalk-nmea see above
command kshana the executable (spawn modes). It runs any executable as the server's user: only an administrator of the Signal K server should be able to change plugin configuration
sessionFile /etc/kshana/session.toml the vessel's limits, thresholds and score weights (spawn modes)
inputArgs [] arguments added to kshana receiver-trust live <sessionFile> in both spawn modes: the input for spawn-args, extra flags such as --replay (a stored log fed faster than real time) for spawn-signalk-nmea. The plugin refuses --gate, --listen, --json and --pksht (it reads kshana's JSON on standard output), and for spawn-signalk-nmea also the input flags (--stdin, --file, --tcp, --udp, --follow, --from-end)
host, port 127.0.0.1, 10111 TCP modes (the gate serves on 10110: set the port for tcp-pksht)
thresholdMode band band: the band Kshana reports, with the edges in the session file. score: the two scores below
warnBelowScore 90 score mode: below this the state is degradedState (Kshana's default edge for nominal)
alarmBelowScore 55 score mode: below this the state is alarm (Kshana's default edge for degraded)
degradedState warn state for the degraded band: normal, alert or warn
raiseAfterEpochs 1 consecutive epochs at a worse state before the notification is raised
clearAfterEpochs 10 consecutive epochs at a better state before it is lowered
staleAfterS 10 warn when no epoch arrives for this long; 0 turns it off
method ["visual","sound"] notification methods

The plugin does not gate: it never alters a fix. Marking a fix invalid for a chart system is the gate, below.

Tests#

cd integrations/signalk && npm test

node:test, no dependencies, no network beyond 127.0.0.1. The tests parse and replay a 66-epoch excerpt of the JSON lines and $PKSHT sentences that kshana receiver-trust live wrote for the synthetic Baltic demo (made-up data, text only), check agreement between the two formats, the hold and clear behaviour, score mode, staleness, and the three input paths against a local feed and a stand-in child process. Those tests do not start a real Signal K server; a separate scripted run does, see below.

Run in a real Signal K server#

integrations/signalk/evidence/ holds a script that loads the plugin into the pinned signalk-server 2.33.0 package (in a private loopback-only network namespace) and records what the server's own REST and WebSocket APIs show. The trust paths appear, the notification goes warn, alarm, normal (after the hold) and warn again when the data stops, and with the real kshana fed by the server's own NMEA input it also published reportedPosition, never navigation.position. One server version, one run, no real vessel; limits and reproduction in the evidence README.

OpenCPN#

OpenCPN reads NMEA from the network, and kshana receiver-trust live --gate can serve the gated stream on a TCP port itself.

Recommended: the direct connection.

some-nmea-source | kshana receiver-trust live session.toml --gate --listen tcp:10110

(some-nmea-source is the receiver's serial port read with stty and cat, a multiplexer, or the --tcp/--udp inputs of kshana itself; the reference build's kshana-gate.service does this under systemd.) --listen tcp:<port> binds the loopback address; any number of read-only clients may connect, and one that cannot keep up is dropped without delaying the others.

Alternative: the relay. integrations/opencpn/nmea-tcp-relay.mjs is a dependency-free Node fan-out that does the same for a kshana without --listen, or serves any other line stream (the container option uses it for the JSON epochs):

some-nmea-source | kshana receiver-trust live session.toml --gate --json trust.jsonl \
  | node integrations/opencpn/nmea-tcp-relay.mjs --listen 10110

It forwards bytes unchanged, whole lines only, and drops a client that does not read. Prefer the direct connection: one process fewer in the chain.

In OpenCPN: Options, Connections, Add Connection, Network, protocol TCP, address 127.0.0.1, port 10110, direction Input. While trust is not collapsed the stream is the receiver's own, plus a $PKSHT sentence per cycle. While it is collapsed the forwarded GGA has quality 0, RMC status V, and the mode indicators say no fix, with checksums recomputed: the equipment sees the receiver itself declare the fix invalid and raises its own alarm. The $PKSHT sentences are visible in OpenCPN's NMEA debug window (Options, Connections, "NMEA debug window"). The position fields are left as received.

Read "Gate mode" in MARITIME-TRUST.md before relying on this: it is opt-in, adds latency (a cycle is held until its epoch is scored), is a single point in the chain, and can withhold a good fix as well as pass a counterfeit one.

Tests#

cd integrations/opencpn && npm test

test/relay.test.mjs replays a recorded excerpt of the synthetic gated stream (186 sentences around the moment trust collapses, written by kshana receiver-trust live --gate) through the relay over a real TCP socket and checks what a consumer receives: the stream byte for byte under arbitrary chunking; valid checksums on every sentence, rewritten ones included; GGA and RMC agreeing, valid and then invalid with no flicker; one $PKSHT per cycle with its gate field W exactly where the fix is invalid; two consumers identical; a partial line never forwarded. This checks the stream OpenCPN is given, not OpenCPN's own screen.

An end-to-end check with the real binary, not part of the default tests (it needs a built kshana):

KSHANA_BIN=target/release/kshana npm run e2e            # kshana --gate --listen | TCP consumer; the consumer's bytes must equal the gate's stdout
KSHANA_BIN=target/release/kshana npm run e2e -- relay   # kshana --gate | relay | TCP consumer

Both run the one-hour synthetic demo and check the fix goes valid, then invalid, with a $PKSHT per epoch.

Native plugin#

integrations/opencpn/plugin/ is a score panel for OpenCPN (score, band, gate, top reasons, an alert when trust becomes untrusted) that reads $PKSHT from OpenCPN's own NMEA stream, so it needs no connection beyond the one above. It is built with CMake against the OpenCPN plugin API header (vendored, with its source commit) and wxWidgets; ctest runs the wx-free logic tests and checks that the library exports the entry points OpenCPN looks up. It has been run inside OpenCPN 5.8.4 under a virtual display on the synthetic gated stream (OpenCPN handed $PKSHT to the plugin, the panel went red, and OpenCPN's own position froze when the gate engaged; screenshots, log and script in integrations/opencpn/evidence/), but not on a real desktop, other versions or platforms, and it is not packaged for OpenCPN's plugin manager. See the plugin's README.md. It is licensed GPL-3.0-or-later, unlike the rest of the repository (LICENSING.md).

Screenshots from that run, in integrations/opencpn/evidence/ (one run, OpenCPN 5.8.4 under a virtual display, synthetic data; README.md gives the limits):

The OpenCPN chart window running the Kshana score panel plugin on the synthetic gated stream, before the trust collapse: no panel is open (it opens itself on the alarm), the ship position in the status bar reads 54 32.4659 N 018 46.1670 E with speed 16.48 kn and course 088, and the fix indicator at top right shows three bars. The same OpenCPN window after the collapse: the Kshana panel is open in red with the score 47 and the word UNTRUSTED, the lines gate: fix marked invalid, cn0-spread -30.0, speed-log -21.0 and age 2 s, and the statement that this is advisory software, not type-approved navigation equipment and that the operator stays responsible. The status bar still reads 54 32.4672 N 018 46.2227 E, and the fix indicator has dropped to two bars. OpenCPN fed directly by kshana receiver-trust live --gate --listen: the Kshana panel is open in red with the score 44 and UNTRUSTED, the lines gate: fix marked invalid, cn0-spread -30.0, speed-log -18.8 and age 1 s, and the advisory statement. This run does not support the position-freeze observation: OpenCPN was still working through the initial burst of epochs.

These show the plumbing and the gated fix reaching OpenCPN, not that any alarm is timely or correct for a real vessel. Advisory software, not type-approved equipment.

Reference build#

deploy/reference-build/: a generic parts list, OS setup, two systemd units (the advisory monitor and the opt-in gate), a container option and the Signal K wiring. deploy/reference-build/check-units.sh runs systemd-analyze verify on the units without starting anything.

Limits#

  • The score is only as good as the checks behind it, which look at a ship's physics and its other sensors; a self-consistent counterfeit with a matching clock is not seen by NMEA-only monitors (MARITIME-TRUST.md). Absence of an alarm is not evidence of a good fix.
  • No figure on this page is a measured detection or false-alarm rate. The tests above check the plumbing on synthetic text; they say nothing about how any monitor does against real interference.
  • Not type-approved; the operator remains responsible.