Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<sub>` 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**: `<dataDir>/queue/<ms>.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.
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<token> GZIP on INTERVAL 15`. Your data appears as `source: http:<station id>`. 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:<id>`, 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=<your id>`: 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`.

Expand Down
6 changes: 3 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<station>`, `udp:<hash>`, `mmsi:<n>` (a UDP sender identified by its own AIVDO), `v1:<sub>`. `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:<station>`, `udp:<hash>`, `mmsi:<n>` (a UDP sender identified by its own AIVDO), `v1:<sub>`. `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:<sub>/self`). Never fed to AISHub.
Comment thread
bkeepers marked this conversation as resolved.

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".

Expand Down
19 changes: 19 additions & 0 deletions server/aishub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<?d@8TIb3Q3wv00000,0*39`})
ev := <-sub.ch
if !ev.Synthesized || ev.Station != "v1:ed25519:k/self" {
t.Errorf("synthesized=%v station=%q", ev.Synthesized, ev.Station)
}
if feedable(ev) {
t.Error("self-reported own ship fed to AISHub")
}
// The tag only marks own-ship sentences: a received !AIVDM carrying s:self stays a real reception.
p.Ingest(Reception{Source: "v1:ed25519:k", Station: "v1:ed25519:k", RecvTime: time.Now(), Body: `\s:self*55\!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23`})
ev = <-sub.ch
if ev.Synthesized {
t.Error("received VDM misclassified as synthesized")
}
}

func TestAishubPacing(t *testing.T) {
p := testPipeline(t)
st := &aishubState{lastTime: map[uint32]string{}, lastStatic: map[uint32]string{}}
Expand Down
4 changes: 3 additions & 1 deletion server/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ func (p *Pipeline) ingestLine(rx Reception) {
}
p.mu.Unlock()
}
p.emit(&Event{Time: t, Source: source, Station: station, Channel: ch, Payload: pkt.Payload, Packet: pkt.Packet, Sentences: sentences})
// TAG s:self on an own-ship sentence is signalk-aiscast building reports from GPS on a boat with no
// transponder: not a VHF reception. VDO-only, so the tag cannot mislabel received traffic as synthesized.
p.emit(&Event{Time: t, Source: source, Station: station, Channel: ch, Payload: pkt.Payload, Packet: pkt.Packet, Sentences: sentences, Synthesized: vdm.Type == "VDO" && vdm.TagBlock.Source == "self"})
}

// ingestPacket takes an already-decoded message from a non-NMEA source (Digitraffic JSON, a peer's structs).
Expand Down
5 changes: 5 additions & 0 deletions signalk-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.2.0

- New Share setting: *Fallback to self-reported AIS position*. When an AIS transponder is not available, the plugin builds class B position and static reports from Signal K (like `@signalk/aisreporter`) and publishes them as `!AIVDO` tagged `s:self`, so a boat with only a GPS shows up on aiscast as self-reported. Synthesis pauses while a real `!AIVDO` is heard. Checked by default when the config form is saved; configs saved before this setting existed stay off until re-saved. The checkbox is disabled until an MMSI is set in Vessel settings.
- Each Share setting explains itself below its label, and Receive mode renders as radio buttons instead of a dropdown.

## 0.1.4

- Personal tokens minted by aiscast no longer expire; the plugin keeps a cached token with no expiry instead of minting a new one every start.
Expand Down
10 changes: 6 additions & 4 deletions signalk-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Signal K plugin for [aiscast](https://github.com/openwatersio/aiscast), the open AIS network from [Open Waters](https://openwaters.io). It does two things over one connection:

- **Share**: every AIS sentence your receiver hears (`!AIVDM` on NMEA 0183, or NMEA 2000 AIS PGNs re-encoded as sentences) is sent to aiscast as it arrives, so the places only boats can hear get coverage. Your own transponder's position (`!AIVDO`) is shared too; it has its own switch if you would rather share only what you hear from others.
- **Share**: every AIS sentence your receiver hears (`!AIVDM` on NMEA 0183, or NMEA 2000 AIS PGNs re-encoded as sentences) is sent to aiscast as it arrives, so the places only boats can hear get coverage. Your own transponder's position (`!AIVDO`) is shared too, and when an AIS transponder is not available the plugin falls back to class B reports built from the Signal K position, marked self-reported. Each part has its own checkbox.
- **Receive**: when the boat hears no AIS of its own (no receiver, receiver off, server running ashore), the plugin subscribes to aiscast around your position and injects the traffic as Signal K targets with `$source` `signalk-aiscast.net`, so Freeboard and friends show them. `Always` mode also fills in beyond VHF range; locally heard targets win.

No account. On first start the plugin generates an Ed25519 keypair in its data directory and requests its own access token from aiscast (sent as an `Authorization: Bearer` header); receptions are credited to that key. Paste an operator-issued token into the config to publish as a named station with higher limits.
Expand All @@ -15,8 +15,9 @@ Signal K App Store → `signalk-aiscast`, or `npm install signalk-aiscast` in `~

| Setting | Default | Meaning |
|---|---|---|
| Share → Other vessels | on | publish AIS heard by the receiver (NMEA 0183 `!AIVDM`, NMEA 2000 AIS) |
| Share → Own ship | on | publish `!AIVDO`; your position becomes public open data on aiscast (it is already broadcast on VHF) |
| Share → AIS targets I receive | on | publish AIS heard by the receiver (NMEA 0183 `!AIVDM`, NMEA 2000 AIS) |
| Share → My own ship's AIS transponder data | on | forward what the transponder broadcasts (`!AIVDO`); your position becomes public open data on aiscast (it is already broadcast on VHF) |
| Share → Fallback to self-reported AIS position | on | when an AIS transponder is not available, build class B reports from Signal K: position every 60 s while moving, static data every 6 min, tagged `s:self`; paused for 5 min after any real `!AIVDO`. Disabled until an MMSI is set in Vessel settings |
| Receive → Show traffic from aiscast | auto | `Off`, `Auto` (only while nothing is heard locally for 90 s), `Always` (also beyond local VHF range; local reception wins per target) |
| Receive → Radius | 50 nm | subscription box around the vessel (5–200) |
| Advanced → Server | `https://ais.openwaters.io` | aiscast base URL |
Expand All @@ -28,10 +29,11 @@ Signal K App Store → `signalk-aiscast`, or `npm install signalk-aiscast` in `~
- Reconnects with jittered backoff (5 s → 5 min; 30 min after a refusal) and a 60 s silence watchdog. Status line in Plugin Config shows the key prefix, send rate, queue depth, targets, and link state.
- Injected targets come through the server's own AIS parser, so they are shaped exactly like VHF-received ones; the server's *Maximum age of inactive vessels* setting expires them. Your own vessel and echoes of your own receptions are never injected, and payloads received from aiscast are never published back.
- NMEA 2000 AIS needs nothing extra: PGNs 129038/129039/129041/129794/129809/129810 are re-encoded to `!AIVDM` (own ship to `!AIVDO`) and tagged `s:n2k`, since N2K carries decoded fields rather than the VHF bits. `signalk-n2kais-to-nmea0183` is not required and not listened to.
- The self-reported position fallback is what `@signalk/aisreporter` does for MarineTraffic, aimed at aiscast: position from `navigation.position` with SOG, COG, and heading (true, or magnetic plus variation) when present and the AIS "not available" values when not; name, callsign, ship type, and dimensions from `name`, `communication.callsignVhf`, `design.aisShipType`, `design.length`/`design.beam`, and `sensors.gps.fromBow`/`fromCenter`. aiscast marks these events `synthesized: true`, keeps them out of its AISHub feed, and shows them apart from VHF receptions. Nothing is sent without a fix, while the GPS sits at Null Island, or while the position has not changed.

## Data and licensing

What you share is published by aiscast under its open data terms (see the [aiscast README](https://github.com/openwatersio/aiscast#readme)). The plugin never shares anything other than AIS sentences and, if you enable it, your own AIS position. Removal requests and the privacy policy are on the aiscast site.
What you share is published by aiscast under its open data terms (see the [aiscast README](https://github.com/openwatersio/aiscast#readme)). The plugin never shares anything other than AIS sentences and, if you enable it, your own position (from the transponder, or built from Signal K when an AIS transponder is not available). Removal requests and the privacy policy are on the aiscast site.

## Development

Expand Down
Loading
Loading