diff --git a/PLAN.md b/PLAN.md index a58b7c4..8afd71c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -202,6 +202,7 @@ No registration step. The plugin's identity is an Ed25519 keypair generated on f #### Plugin behavior - **Collect**: `app.on('nmea0183')` for receivers on NMEA 0183, and `app.on('N2KAnalyzerOut')` for NMEA 2000 AIS PGNs (129038/129039/129041/129794/129809/129810), which are re-encoded to `!AIVDM`/`!AIVDO` with `ggencoder` and tagged `s:n2k` (the same re-encode `signalk-n2kais-to-nmea0183` does, built in so no second plugin is needed; `nmea0183out` is deliberately not listened to, since `signalk-vessels-to-ais` re-emits network-injected targets there). Regex `^(\\[^\\]*\\)?[!$]..VD[MO],`. `VDM` always (when sharing is on), `VDO` unless own-ship sharing is switched off. Verbatim except for the TAG block; aiscast decodes, dedupes, and archives. Listeners are removed on the same emitter in `stop()`; `start()` calls `stop()` first. +- **Own ship when an AIS transponder is not available** (`share.position`): what `@signalk/aisreporter` does for MarineTraffic, built in. Class B type 18 from `navigation.position`/SOG/COG/heading (AIS not-available sentinels, never zeros) every 60 s while the position changes, type 24 A/B from `name`/`communication.callsignVhf`/`design.*`/`sensors.gps.*` every 6 min, encoded with `ggencoder` as `!AIVDO` and tagged `s:self`. Silent for 5 min after any real `!AIVDO` (class B transmits at least every 3 min), without an MMSI, without a fix, or at Null Island. aiscast marks TAG `s:self` events `synthesized: true`: out of the AISHub feed (their terms forbid synthesized data), distinguishable downstream, and `events24h` counts only the bare `v1:` station so they never promote a key's tier. This is the reciprocal of receive mode: the boat that needs aiscast to see traffic is the boat nobody else could see. - **Send**: online, each sentence goes out as received, one publish frame per sentence (multipart fragments share a frame), so aiscast's live stream sees the boat's receptions with no added latency; `permessage-deflate` keeps the per-frame cost small. Frames aiscast does not `ack` within 30 s, and anything collected while the socket is down, go to the disk queue. - **Queue**: `/queue/.json` files of up to 500 sentences each; on reconnect they drain oldest-first, one frame in flight, deleted on `ack`, before live sending resumes. Cap 100 MB, oldest dropped and counted. Per-sentence `c:` carries the real receive time, so aiscast's 60 s rule sorts live from replay without a flag. - **Socket**: `ws` (Node 20 has no global `WebSocket`), `permessage-deflate` on (no separate gzip), exponential backoff 5 s → 5 min with ±20 % jitter, 5 → 30 min after a 429/403 close, reset on the first `ack`/`event`; silence watchdog: no frame or pong for 60 s → terminate and reconnect. @@ -210,8 +211,8 @@ No registration step. The plugin's identity is an Ed25519 keypair generated on f - **Bbox**: own position from `app.getSelfPath('navigation.position')` polled every 10 s (no `streambundle`), ± `receive.radiusNm` (default 50, max 200); re-subscribe when the boat has moved more than a quarter radius or the radius changed. No position → no subscription, status "waiting for position"; an empty bbox means the whole world on `/v1/stream` and must never be sent. - **Inject**: each event's `nmea` sentences go through `@signalk/nmea0183-signalk`'s `Parser`, the server's own AIS parser, so contexts, paths, and value types are identical to VHF-received AIS (this is what avoids the bare-string `name` memory leak and the `eta` type that freezes Freeboard). Each delta gets `$source: "signalk-aiscast.net"` and `timestamp` from aiscast event's canonical `time`. Dropped before injection: own MMSI, MMSI 0, events whose `msg_type` is a position report but carry no `lat`/`lon` (server rejected the position), own echoes. Stale targets expire via the server's `pruneContextsMinutes`; the plugin has no TTL of its own. - **Status**: one line, refreshed at most every 5 s: `key 3f9a… ↑ 42 msg/min (queue 0) ↓ 118 targets server ok 2 s ago`; `setPluginError` when the token is refused, the server has no personal issuer and no token is configured, or a queue has been draining for more than an hour. -- **Config** (JSON schema, no webapp): `share.targets` (default on), `share.ownShip` (default on: the plugin ships disabled, so enabling it is the consent; its own checkbox says where the data goes, that it is public, and how to stop), `receive.mode`, `receive.radiusNm`; under an `advanced` section, `server` (base URL, default `https://ais.openwaters.io`) and `token` (optional operator-issued token, password widget). Everything else is a constant. -- **Package**: TypeScript + vitest like `signalk-tides`; deps `ws`, `@signalk/nmea0183-signalk`; dev `@signalk/server-api`, `typescript`, `vitest`, `@types/ws`. `engines.node >= 20`. Keywords `signalk-node-server-plugin`, `signalk-category-ais`; `signalk-plugin-enabled-by-default` unset. Files: `src/index.ts` (plugin, schema, status), `src/identity.ts` (keys, sign, verify), `src/uplink.ts` (collect, queue, socket, backoff), `src/downlink.ts` (bbox, subscribe, inject), `test/` with a fake server (`ws` server) and a fake `app`. Released to npm by [`.github/workflows/release.yml`](.github/workflows/release.yml) via trusted publishing (OIDC, no token) when a GitHub release is created with a `signalk-plugin-v*` tag; the package on npmjs.com needs the repo and workflow file name registered as its trusted publisher once. +- **Config** (JSON schema, no webapp): `share.targets` (default on), `share.ownShip` (default on: the plugin ships disabled, so enabling it is the consent; its help text says where the data goes, that it is public, and how to stop), `share.position` (checked by default in the form — sharing is the plugin's point, unchecking is the opt-out — but absent from a config saved before the setting existed, which reads as off, so an upgrade never enables it silently; the checkbox is disabled with a note until an MMSI is set), `receive.mode`, `receive.radiusNm`; under an `advanced` section, `server` (base URL, default `https://ais.openwaters.io`) and `token` (optional operator-issued token, password widget). Everything else is a constant. +- **Package**: TypeScript + vitest like `signalk-tides`; deps `ws`, `@signalk/nmea0183-signalk`; dev `@signalk/server-api`, `typescript`, `vitest`, `@types/ws`. `engines.node >= 20`. Keywords `signalk-node-server-plugin`, `signalk-category-ais`; `signalk-plugin-enabled-by-default` unset. Files: `src/index.ts` (plugin, schema, status), `src/identity.ts` (keys, sign, verify), `src/uplink.ts` (collect, queue, socket, backoff), `src/downlink.ts` (bbox, subscribe, inject), `src/ownship.ts` (own position without a transponder), `test/` with a fake server (`ws` server) and a fake `app`. Released to npm by [`.github/workflows/release.yml`](.github/workflows/release.yml) via trusted publishing (OIDC, no token) when a GitHub release is created with a `signalk-plugin-v*` tag; the package on npmjs.com needs the repo and workflow file name registered as its trusted publisher once. #### aiscast work for this stage diff --git a/README.md b/README.md index 48641b4..b384d98 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ If you run an AIS receiver, send it here and it is re-served to everyone, dedupl - **AIS-catcher** (preferred: authenticated HTTP, works behind any NAT): get a token at [openwatersio.github.io/aiscast/token.html](https://openwatersio.github.io/aiscast/token.html) (one click, stays in your browser), then `AIS-catcher ... -H https://ais.openwaters.io/v1/receive USERPWD x: GZIP on INTERVAL 15`. Your data appears as `source: http:`. Named stations with higher limits: ask. - **UDP** (no token): AIS-catcher `-u ais.openwaters.io 10110`, [docker-shipfeeder](https://github.com/sdr-enthusiasts/docker-shipfeeder) with host `ais.openwaters.io` port `10110`, or any NMEA forwarder sending plain `!AIVDM` / `!AIVDO` sentences (TAG blocks welcome). Your station appears as `udp:`, a keyed hash of your address, never the address itself; a sender whose `!AIVDO` sentences identify the vessel is keyed by that MMSI instead. -- **Signal K**: add a UDP target `ais.openwaters.io:10110` in [`ais-forwarder`](https://github.com/hkapanen/ais-forwarder) (forward AIVDM and AIVDO). Or install the [`signalk-aiscast`](signalk-plugin/README.md) plugin: no token to paste, shares what your receiver hears (and your own position), and shows aiscast traffic when you have no receiver. +- **Signal K**: add a UDP target `ais.openwaters.io:10110` in [`ais-forwarder`](https://github.com/hkapanen/ais-forwarder) (forward AIVDM and AIVDO). Or install the [`signalk-aiscast`](signalk-plugin/README.md) plugin: no token to paste, shares what your receiver hears (and your own position, from the transponder or built from Signal K when an AIS transponder is not available), and shows aiscast traffic when you have no receiver. Your station page is the [map](https://openwatersio.github.io/aiscast/) with `?station=`: vessels heard, coverage extent, message counts, how many were heard elsewhere first; the same numbers are at `GET /v1/stations/{id}`. Feeders get the deduplicated raw stream back on `wss://ais.openwaters.io/v1/nmea`. diff --git a/docs/API.md b/docs/API.md index 08de3cc..984a002 100644 --- a/docs/API.md +++ b/docs/API.md @@ -110,11 +110,11 @@ aiscast → client, one frame per decoded message after deduplication: - `id`: content id, not an event id: hex of the first 16 bytes of SHA-256 over the decoded payload bits (one byte per bit, fill bits dropped) followed by the channel letter. Identical payloads share an id, whether that is the same transmission heard late by a second station or a static message (Type 5/24) rebroadcast unchanged every few minutes. Use `(id, time)` as the event key; dedupe on `id` alone drops the rebroadcasts. - `time`: canonical time: the source's timestamp when it is within 30 s of our receive time, else our receive time. -- `source`: `kystverket`, `digitraffic`, `aishub`, `aisstream`, `http:`, `udp:`, `mmsi:` (a UDP sender identified by its own AIVDO), `v1:`. `station` refines it (Kystverket base station id). `channel` is `A`/`B`, or empty for synthesized events. -- `nmea`: the sentences as received, or a re-encoded `!AIVDM` for synthesized events. +- `source`: `kystverket`, `digitraffic`, `aishub`, `aisstream`, `http:`, `udp:`, `mmsi:` (a UDP sender identified by its own AIVDO), `v1:`. `station` refines it (Kystverket base station id). `channel` is `A`/`B`, or empty for events rebuilt from a non-NMEA source. +- `nmea`: the sentences as received, or a re-encoded `!AIVDM` for events rebuilt from a non-NMEA source (self-reported `s:self` events keep their as-received `!AIVDO`). - `lat`/`lon`: the vessel's last known position from the cache (present for static messages too); absent until a position has been heard. - `msg_type`: aisstream type name; `message`: go-ais decoded struct. -- `synthesized`: `true` when the message was rebuilt from a non-NMEA source (Digitraffic JSON, AISHub rows, aisstream envelopes). +- `synthesized`: `true` when the message was not heard over VHF: rebuilt from a non-NMEA source (Digitraffic JSON, AISHub rows, aisstream envelopes), or an own-ship report a vessel built from its GPS (`signalk-aiscast` with TAG `s:self`, station `v1:/self`). Never fed to AISHub. Other frames: `{"type":"error","error":"invalid token"}` followed by close 1008 for a bad token; `{"type":"error","error":"bad frame"}` / `"unknown type"` for malformed input; `"concurrent connections per key exceeded"` then close. Frames in are limited to 256 KB. Slow clients are closed with 1008 "client too slow". diff --git a/server/aishub_test.go b/server/aishub_test.go index 62bad7f..bf2c330 100644 --- a/server/aishub_test.go +++ b/server/aishub_test.go @@ -69,6 +69,25 @@ func TestFeedableExcludesPublicSources(t *testing.T) { } } +func TestSelfReportedOwnShipIsSynthesized(t *testing.T) { + p := testPipeline(t) + sub := p.subscribe() + p.Ingest(Reception{Source: "v1:ed25519:k", Station: "v1:ed25519:k", RecvTime: time.Now(), Body: `\s:self*55\!AIVDO,1,1,,A,B1mg=5@3wh this.box.radiusNm * RESUBSCRIBE_FRACTION || @@ -134,9 +122,12 @@ export class Downlink { if (f.type !== "event") return; const ev = f as unknown as AisEvent; if (!Array.isArray(ev.nmea) || ev.nmea.length === 0) return; - for (const s of ev.nmea) this.opts.onReceived?.(s); + // Own-vessel echoes (our publishes, or another station hearing our transmission) skip the loop guard: + // marking them seen would swallow our own future uplink of an identical payload (re-synthesized s:self + // position, type 24 rebroadcast unchanged every few minutes). Self is never injected, so there is no loop. if (this.opts.selfSource && ev.source === this.opts.selfSource) return; if (!ev.mmsi || String(ev.mmsi) === this.selfMmsi) return; + for (const s of ev.nmea) this.opts.onReceived?.(s); if (POSITION_TYPES.has(ev.msg_type ?? "") && (ev.lat == null || ev.lon == null)) return; // aiscast rejected the position let delta: Delta | null = null; diff --git a/signalk-plugin/src/index.ts b/signalk-plugin/src/index.ts index a256e49..73b0938 100644 --- a/signalk-plugin/src/index.ts +++ b/signalk-plugin/src/index.ts @@ -10,6 +10,7 @@ import { import { Link } from "./link.js"; import { n2kToSentence } from "./n2k.js"; import { isAis, isOwnShip } from "./nmea.js"; +import { OwnShip, SOURCE_TAG } from "./ownship.js"; import { Uplink } from "./uplink.js"; export const PLUGIN_ID = "signalk-aiscast"; @@ -20,7 +21,7 @@ const TOKEN_RETRY_MIN = 60_000; // without one (network not up yet at boot, serv const TOKEN_RETRY_MAX = 30 * 60_000; export interface Config { - share?: { targets?: boolean; ownShip?: boolean }; + share?: { targets?: boolean; ownShip?: boolean; position?: boolean }; receive?: { mode?: ReceiveMode; radiusNm?: number }; advanced?: { server?: string; token?: string }; // Pre-"Advanced" layout, still honoured when read. @@ -47,12 +48,17 @@ export default function (app: ServerAPI): Plugin { properties: { targets: { type: "boolean", - title: "Other vessels (NMEA 0183 !AIVDM and NMEA 2000 AIS)", + title: "Share AIS targets I receive", default: true, }, ownShip: { type: "boolean", - title: "Own ship (!AIVDO)", + title: "Share my own ship's AIS transponder data", + default: true, + }, + position: { + type: "boolean", + title: "Fallback to self-reported AIS position", default: true, }, }, @@ -100,10 +106,32 @@ export default function (app: ServerAPI): Plugin { }, }, }, - uiSchema: { + // A function so the MMSI check runs fresh on every config-page load. + uiSchema: () => ({ "ui:order": ["share", "receive", "advanced"], + share: { + // the checkbox widget puts schema descriptions above the label; ui:help renders below + targets: { + "ui:help": + "Other vessels visible from a connected NMEA 0183/2000 AIS receiver.", + }, + ownShip: { + "ui:help": + "Forward what the AIS transponder broadcasts; it is already public on VHF.", + }, + position: app.getSelfPath("mmsi") + ? { + "ui:help": + "When an AIS transponder is not available, but GPS position is, synthesize class B AIS reports from Signal K data.", + } + : { + "ui:disabled": true, + "ui:help": "Needs an MMSI in Vessel settings.", + }, + }, + receive: { mode: { "ui:widget": "radio" } }, advanced: { token: { "ui:widget": "password" } }, - }, + }), start(config: object) { const gen = ++generation; @@ -130,6 +158,9 @@ export default function (app: ServerAPI): Plugin { const wsBase = server.replace(/^http/, "ws"); const shareTargets = config.share?.targets ?? true; const shareOwn = config.share?.ownShip ?? true; + // Absent from a config saved before the setting existed: off, so an upgrade never enables it silently. + const sharePosition = config.share?.position ?? false; + if (sharePosition && !app.getSelfPath("mmsi")) app.debug("self-reported position is on but no MMSI is set; nothing will be synthesized"); const mode = config.receive?.mode ?? "auto"; const radiusNm = Math.min(200, Math.max(5, config.receive?.radiusNm ?? 50)); const dir = app.getDataDirPath(); @@ -182,6 +213,7 @@ export default function (app: ServerAPI): Plugin { log, ); const up = new Uplink(l, dir, log); + const own = new OwnShip(app, (s, now) => up.hear(s, now, SOURCE_TAG)); const down = new Downlink( app, l, @@ -195,7 +227,7 @@ export default function (app: ServerAPI): Plugin { log, ); const canShare = () => - token !== null && !publishRefused && (shareTargets || shareOwn); + token !== null && !publishRefused && (shareTargets || shareOwn || sharePosition); up.enabled = canShare(); l.on("open", () => { @@ -237,12 +269,15 @@ export default function (app: ServerAPI): Plugin { } down.start(); l.start(); + if (sharePosition) own.start(); const onSentence = (sentence: unknown, source?: string) => { if (typeof sentence !== "string" || !isAis(sentence)) return; - const own = isOwnShip(sentence); - if (!own) down.localHeard(); - if (own ? shareOwn : shareTargets) up.hear(sentence, Date.now(), source); + const now = Date.now(); + const isOwn = isOwnShip(sentence); + if (isOwn) own.heard(now); + else down.localHeard(now); + if (isOwn ? shareOwn : shareTargets) up.hear(sentence, now, source); }; const onNmea = (sentence: unknown) => onSentence(sentence); const onN2k = (msg: unknown) => { @@ -322,6 +357,7 @@ export default function (app: ServerAPI): Plugin { if (tokenTimer) clearTimeout(tokenTimer); events.removeListener("nmea0183", onNmea); events.removeListener("N2KAnalyzerOut", onN2k); + own.stop(); down.stop(); await up.stop(); l.stop(); @@ -338,7 +374,10 @@ export default function (app: ServerAPI): Plugin { // fetch's "fetch failed" hides the real reason (ECONNREFUSED, ENOTFOUND, a TLS error) in `cause`. function describe(err: unknown): string { - const e = err as { message?: string; cause?: { code?: string; message?: string } }; + const e = err as { + message?: string; + cause?: { code?: string; message?: string }; + }; const cause = e.cause?.code ?? e.cause?.message; return cause ? `${e.message} (${cause})` : (e.message ?? String(err)); } diff --git a/signalk-plugin/src/ownship.ts b/signalk-plugin/src/ownship.ts new file mode 100644 index 0000000..95b76d0 --- /dev/null +++ b/signalk-plugin/src/ownship.ts @@ -0,0 +1,145 @@ +// Own-ship reports built from Signal K when an AIS transponder is not available, tagged s:self so aiscast marks them self-reported. +// Adapted from @signalk/aisreporter (Apache-2.0): https://github.com/SignalK/aisreporter +import type { ServerAPI } from "@signalk/server-api"; +import ggencoder from "ggencoder"; + +const { AisEncode } = ggencoder; // CommonJS without static named exports + +export const SOURCE_TAG = "self"; +const REPORT_EVERY = 60_000; +const STATIC_EVERY = 6 * 60_000; +const TRANSPONDER_QUIET = 5 * 60_000; // a class B transponder sends at least every 3 min; longer silence means none +// AIS "not available" sentinels; ggencoder would otherwise encode 0 (stopped, heading north). +const SOG_NA_KN = 102.3; +const COG_NA_DEG = 360; +const HDG_NA = 511; + +export interface Position { + latitude: number; + longitude: number; +} + +export function ownPosition(app: ServerAPI): Position | null { + const p = app.getSelfPath("navigation.position") as { value?: Position } | Position | undefined; + const v = p && "value" in p ? p.value : (p as Position | undefined); + if (!v || typeof v.latitude !== "number" || typeof v.longitude !== "number") return null; + if (Math.abs(v.latitude) < 1e-6 && Math.abs(v.longitude) < 1e-6) return null; // Null Island + return v; +} + +export class OwnShip { + private lastHeard = 0; + private lastStatic = 0; + private lastLat?: number; + private lastLon?: number; + private timer: NodeJS.Timeout | null = null; + + constructor( + private readonly app: ServerAPI, + private readonly emit: (sentence: string, now: number) => void, + ) {} + + start(): void { + this.timer = setInterval(() => this.tick(), REPORT_EVERY); + this.tick(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + // A real !AIVDO came through: the boat has a transponder, so nothing is synthesized for a while. + heard(now = Date.now()): void { + this.lastHeard = now; + } + + tick(now = Date.now()): void { + if (now - this.lastHeard < TRANSPONDER_QUIET) return; + const mmsi = this.app.getSelfPath("mmsi") as string | number | undefined; + if (!mmsi) return; + const pos = ownPosition(this.app); + if (!pos) return; + // Positions are skipped while unchanged (stuck GPS, tree re-emitting the same value); static is due regardless. + if (pos.latitude !== this.lastLat || pos.longitude !== this.lastLon) { + const sentence = this.position(mmsi, pos); + if (sentence) this.emit(sentence, now); + this.lastLat = pos.latitude; + this.lastLon = pos.longitude; + } + if (now - this.lastStatic >= STATIC_EVERY) { + this.lastStatic = now; + for (const s of this.static(mmsi)) this.emit(s, now); + } + } + + private position(mmsi: string | number, pos: Position): string | null { + const sog = this.num("navigation.speedOverGround.value"); + const cog = this.num("navigation.courseOverGroundTrue.value"); + let hdg = this.num("navigation.headingTrue.value"); + if (hdg === undefined) { + const mag = this.num("navigation.headingMagnetic.value"); + const variation = this.num("navigation.magneticVariation.value"); // east-positive, so true = magnetic + variation + if (mag !== undefined && variation !== undefined) hdg = mag + variation; + } + return encode({ + aistype: 18, + repeat: 0, + own: true, + mmsi, + lat: pos.latitude, + lon: pos.longitude, + accuracy: 0, + // ggencoder truncates to the field resolution (0.1 kn, 0.1°, 1°), so round first; a course near due + // north must wrap to 0, not round up to the 360 "not available" sentinel + sog: sog === undefined ? SOG_NA_KN : Math.round(sog * 19.438444924574) / 10, + cog: cog === undefined ? COG_NA_DEG : (Math.round(deg(cog) * 10) % 3600) / 10, + hdg: hdg === undefined ? HDG_NA : Math.round(deg(hdg)) % 360, + }); + } + + private static(mmsi: string | number): string[] { + const out: string[] = []; + const name = this.app.getSelfPath("name"); + if (typeof name === "string" && name) { + const partA = encode({ aistype: 24, repeat: 0, own: true, part: 0, mmsi, shipname: name }); + if (partA) out.push(partA); + } + const cargo = this.num("design.aisShipType.value.id"); + const callsign = this.app.getSelfPath("communication.callsignVhf"); + const length = this.num("design.length.value.overall"); + const beam = this.num("design.beam.value"); + const partB: Record = {}; + if (cargo !== undefined) partB.cargo = cargo; + if (typeof callsign === "string" && callsign) partB.callsign = callsign; + if (length !== undefined && beam !== undefined) { + const fromBow = this.num("sensors.gps.fromBow.value") ?? length / 2; + const fromCenter = this.num("sensors.gps.fromCenter.value") ?? 0; // Signal K: positive to port + partB.dimA = Math.round(fromBow); + partB.dimB = Math.round(length - fromBow); + partB.dimC = Math.round(beam / 2 - fromCenter); // dimC is to port, dimD to starboard + partB.dimD = Math.round(beam / 2 + fromCenter); + } + if (Object.keys(partB).length > 0) { + const sentence = encode({ aistype: 24, repeat: 0, own: true, part: 1, mmsi, ...partB }); + if (sentence) out.push(sentence); + } + return out; + } + + private num(path: string): number | undefined { + const v = this.app.getSelfPath(path); + return typeof v === "number" && Number.isFinite(v) ? v : undefined; + } +} + +// ggencoder leaves nmea as [] and sets valid=false for a message shape it does not implement. +function encode(msg: Record): string | null { + const enc = new AisEncode(msg); + return enc.valid ? enc.nmea : null; +} + +// Radians → degrees in [0, 360); a magnetic + variation sum can fall outside one turn. +function deg(rad: number): number { + return ((((rad * 180) / Math.PI) % 360) + 360) % 360; +} diff --git a/signalk-plugin/test/ownship.test.ts b/signalk-plugin/test/ownship.test.ts new file mode 100644 index 0000000..9dbde54 --- /dev/null +++ b/signalk-plugin/test/ownship.test.ts @@ -0,0 +1,126 @@ +import { Parser } from "@signalk/nmea0183-signalk"; +import { describe, expect, it } from "vitest"; +import { OwnShip } from "../src/ownship.js"; +import { fakeApp } from "./fake-app.js"; + +const T = 1_700_000_000_000; +const MIN = 60_000; +const POS = { "navigation.position": { value: { latitude: 59.9, longitude: 10.7 } } }; + +// Decoded through the server's own AIS parser: { path: value }, with the path-less mmsi/name objects merged in. +function decode(sentence: string): Record { + const d = new Parser().parse(sentence) as { updates: { values: { path: string; value: unknown }[] }[] }; + const out: Record = {}; + for (const { path, value } of d.updates[0].values) { + if (path === "") Object.assign(out, value); + else out[path] = value; + } + return out; +} + +function setup(self: Record) { + const app = fakeApp(self); + const out: string[] = []; + const own = new OwnShip(app, (s) => out.push(s)); + return { app, out, own }; +} + +describe("own ship", () => { + it("builds a class B position report and name from Signal K, leaving unknown fields as not available", () => { + const { out, own } = setup({ ...POS, name: "TEST" }); + own.tick(T); + expect(out).toHaveLength(2); + expect(out[0]).toMatch(/^!AIVDO,/); + const pos = decode(out[0]); + expect(pos.mmsi).toBe("123456789"); + expect(pos["navigation.position"]).toEqual({ latitude: 59.9, longitude: 10.7 }); + expect(pos["sensors.ais.class"]).toBe("B"); + expect(pos["navigation.speedOverGround"]).toBeUndefined(); + expect(pos["navigation.courseOverGroundTrue"]).toBeUndefined(); + expect(pos["navigation.headingTrue"]).toBeUndefined(); + expect(decode(out[1]).name).toBe("TEST"); + }); + + it("converts units and derives true heading from magnetic heading and variation", () => { + const { out, own } = setup({ + ...POS, + "navigation.speedOverGround.value": 2.6751, + "navigation.courseOverGroundTrue.value": Math.PI / 2, + "navigation.headingMagnetic.value": 1.5, + "navigation.magneticVariation.value": 0.15, + }); + own.tick(T); + const pos = decode(out[0]); + expect(pos["navigation.speedOverGround"]).toBeCloseTo(2.6751, 1); + expect(pos["navigation.courseOverGroundTrue"]).toBeCloseTo(Math.PI / 2, 2); + expect(pos["navigation.headingTrue"]).toBeCloseTo(1.65, 1); + }); + + it("sends static part B with callsign, type, and dimensions when the vessel is described", () => { + const { out, own } = setup({ + ...POS, + "communication.callsignVhf": "WDK1234", + "design.aisShipType.value.id": 36, + "design.length.value.overall": 11, + "design.beam.value": 4, + "sensors.gps.fromBow.value": 5, + "sensors.gps.fromCenter.value": 1, // Signal K: 1 m to port → dimC (to port) 1, dimD (to starboard) 3 + }); + own.tick(T); + expect(out).toHaveLength(2); // no name: part A skipped + const b = decode(out[1]); + expect(b.communication).toEqual({ callsignVhf: "WDK1234" }); + expect(b["design.aisShipType"]).toMatchObject({ id: 36 }); + expect(b["design.length"]).toEqual({ overall: 11 }); + expect(b["design.beam"]).toBe(4); + expect(b["sensors.ais.fromBow"]).toBe(5); + // the parser derives fromCenter as beam/2 - dimD, signed opposite to the schema: -1 here means dimD is 3 + expect(b["sensors.ais.fromCenter"]).toBe(-1); + }); + + it("repeats only when the position changes, with static data every six minutes", () => { + const { app, out, own } = setup({ ...POS, name: "TEST" }); + own.tick(T); + own.tick(T + MIN); + expect(out).toHaveLength(2); + app.self["navigation.position"] = { value: { latitude: 59.91, longitude: 10.7 } }; + own.tick(T + 2 * MIN); + expect(out).toHaveLength(3); + app.self["navigation.position"] = { value: { latitude: 59.92, longitude: 10.7 } }; + own.tick(T + 3 * MIN); + expect(out).toHaveLength(4); + own.tick(T + 6 * MIN); // position unchanged: skipped, but static is still due + expect(out).toHaveLength(5); + expect(decode(out[4]).name).toBe("TEST"); + }); + + it("wraps a course near due north to 0 instead of the 360 not-available sentinel", () => { + const rad = (deg: number) => (deg * Math.PI) / 180; + const { out, own } = setup({ + ...POS, + "navigation.courseOverGroundTrue.value": rad(359.97), + "navigation.headingTrue.value": rad(359.7), + }); + own.tick(T); + const pos = decode(out[0]); + expect(pos["navigation.courseOverGroundTrue"]).toBeCloseTo(0, 5); + expect(pos["navigation.headingTrue"]).toBeCloseTo(0, 5); + }); + + it("stays quiet while a transponder is heard, without an MMSI, or without a fix", () => { + const { out, own } = setup(POS); + own.heard(T); + own.tick(T + MIN); + expect(out).toHaveLength(0); + own.tick(T + 5 * MIN); + expect(out).toHaveLength(1); + + const noMmsi = setup({ ...POS, mmsi: undefined }); + noMmsi.own.tick(T); + expect(noMmsi.out).toHaveLength(0); + + const nullIsland = setup({ "navigation.position": { value: { latitude: 0, longitude: 0 } } }); + nullIsland.own.tick(T); + expect(nullIsland.out).toHaveLength(0); + }); +}); diff --git a/signalk-plugin/test/plugin.test.ts b/signalk-plugin/test/plugin.test.ts index 48bf73b..30abd47 100644 --- a/signalk-plugin/test/plugin.test.ts +++ b/signalk-plugin/test/plugin.test.ts @@ -83,6 +83,12 @@ describe("uplink", () => { expect((f.nmea as string[]).map((s) => s.replace(/^\\[^\\]*\\/, ""))).toEqual([VDM]); }); + it("shares a position report built from Signal K when asked, tagged s:self, independent of the transponder switch", async () => { + await start({ share: { ownShip: false, position: true } }); + const f = await server.waitForFrame((f) => f.type === "publish"); + expect((f.nmea as string[])[0]).toMatch(/^\\s:self,c:\d{13}\*[0-9A-F]{2}\\!AIVDO,/); + }); + it("queues to disk while offline and replays oldest-first on reconnect", async () => { await start(); const port = Number(new URL(server.url).port); @@ -164,6 +170,18 @@ describe("uplink", () => { }); }); +describe("config UI", () => { + it("disables the GPS position option without an MMSI and says why", () => { + type Ui = { share: { position: Record } }; + const ui = (createPlugin(app).uiSchema as () => Ui)(); + expect(ui.share.position["ui:disabled"]).toBeUndefined(); + const bare = fakeApp({ mmsi: undefined }); + const noMmsi = (createPlugin(bare).uiSchema as () => Ui)(); + expect(noMmsi.share.position["ui:disabled"]).toBe(true); + expect(noMmsi.share.position["ui:help"]).toMatch(/MMSI/); + }); +}); + describe("downlink", () => { it("auto mode subscribes around the vessel while no AIS is heard locally, and unsubscribes when it is", async () => { await start();