@sailingnaturali/signalk-ais-distress 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bryan Clark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # signalk-ais-distress
2
+
3
+ Alert on **AIS distress beacons** — SART, MOB, and EPIRB survival devices — the
4
+ moment they start transmitting.
5
+
6
+ AIS-SART, AIS-MOB, and AIS-EPIRB locating beacons (MMSI prefixes `970`, `972`,
7
+ `974`) broadcast their GNSS position over AIS to every receiver in range.
8
+ SignalK decodes them into vessel targets, but nothing flags them — an active
9
+ survival beacon just appears as another boat on the chart. This plugin watches
10
+ the position stream and turns a beacon into a real emergency.
11
+
12
+ For every 97x beacon heard, it:
13
+
14
+ - raises `notifications.ais.distress.<sart|mob|epirb>` under *self* at **emergency**, so the vessel's own alarm chain fires;
15
+ - serves the beacon history at `/signalk/v2/api/resources/ais-distress`;
16
+ - serves a chart-marker layer at `/signalk/v2/api/resources/ais-distress-markers`;
17
+ - keeps an on-disk JSONL forensic log;
18
+ - optionally writes a GMDSS-style ship's-log entry via [`signalk-logbook`](https://github.com/meri-imperiumi/signalk-logbook).
19
+
20
+ A beacon repeats its position several times a minute; repeats within a 5-minute
21
+ window update the stored event instead of re-alarming. Active beacons
22
+ re-announce after a server restart. A PUT to the notification path clears the
23
+ alarm.
24
+
25
+ ## Why AIS, not just DSC
26
+
27
+ DSC distress (VHF Ch 70) is an *alerting* signal — see the companion
28
+ [`signalk-dsc`](https://github.com/sailingnaturali/signalk-dsc). AIS beacons are
29
+ about *finding* the casualty: a position stream you can home on. They share the
30
+ same 97x identity classes, and both are built on
31
+ [`@sailingnaturali/signalk-distress-core`](https://github.com/sailingnaturali/signalk-distress-core).
32
+
33
+ ## License
34
+
35
+ MIT
package/index.js ADDED
@@ -0,0 +1,327 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * signalk-ais-distress
5
+ *
6
+ * AIS survival-beacon monitoring. SART, MOB, and EPIRB locating devices (MMSI
7
+ * prefixes 970 / 972 / 974) broadcast their GNSS position over AIS to every
8
+ * receiver in range. SignalK decodes them into vessel targets, but nothing
9
+ * flags them — an active survival beacon appears as just another boat.
10
+ *
11
+ * This plugin subscribes to the position stream, and for every 97x beacon it
12
+ * hears:
13
+ * - appends it to an on-disk JSONL log (forensics: the full record is kept)
14
+ * - serves the beacon history at /signalk/v2/api/resources/ais-distress
15
+ * - serves a chart-marker layer at /resources/ais-distress-markers
16
+ * - raises notifications.ais.distress.<sart|mob|epirb> under *self* at
17
+ * emergency, so the vessel's own alarm chain fires
18
+ * - optionally writes a ship's-log entry via signalk-logbook
19
+ *
20
+ * A beacon repeats its position several times a minute; repeats inside a
21
+ * 5-minute window update the stored event instead of re-alarming.
22
+ */
23
+
24
+ const path = require('node:path');
25
+
26
+ const {
27
+ EventStore,
28
+ buildMarkerResourceSets,
29
+ buildMessage,
30
+ buildLogbookText,
31
+ captureOwnShip,
32
+ buildObservations,
33
+ createNotifier,
34
+ writeLogbookEntry,
35
+ } = require('@sailingnaturali/signalk-distress-core');
36
+
37
+ const { buildBeaconEvent } = require('./lib/detect');
38
+
39
+ const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
40
+ const REANNOUNCE_WINDOW_MS = 60 * 60 * 1000;
41
+
42
+ // One red family for every survival beacon — this is always an emergency.
43
+ const BEACON_COLORS = {
44
+ sart: 'rgba(211,47,47,1)',
45
+ mob: 'rgba(211,47,47,1)',
46
+ epirb: 'rgba(211,47,47,1)',
47
+ };
48
+ const BEACON_LABEL = { sart: 'AIS SART', mob: 'AIS MOB', epirb: 'AIS EPIRB' };
49
+
50
+ module.exports = function makePlugin(app) {
51
+ const plugin = {
52
+ id: 'signalk-ais-distress',
53
+ name: 'AIS Distress',
54
+ description:
55
+ 'Alert on AIS distress beacons (SART / MOB / EPIRB, MMSI 970/972/974): notifications, chart markers, forensic log, and ship\'s-log entries.',
56
+ };
57
+
58
+ plugin.schema = {
59
+ type: 'object',
60
+ properties: {
61
+ maxEvents: {
62
+ type: 'number',
63
+ title: 'Beacons to keep',
64
+ description: 'Oldest beacon events are dropped beyond this count.',
65
+ default: 1000,
66
+ },
67
+ markerWindowHours: {
68
+ type: 'number',
69
+ title: 'Chart marker window (hours)',
70
+ description:
71
+ 'Beacons drop off the ais-distress-markers chart layer after this many hours. Active (un-cleared) beacons always remain.',
72
+ default: 24,
73
+ },
74
+ logbookEnabled: {
75
+ type: 'boolean',
76
+ title: 'Write beacons to the ship\'s log (signalk-logbook)',
77
+ default: true,
78
+ },
79
+ logbookUrl: {
80
+ type: 'string',
81
+ title: 'Logbook API URL',
82
+ default: 'http://localhost:3000/plugins/signalk-logbook/logs',
83
+ },
84
+ logbookToken: {
85
+ type: 'string',
86
+ title: 'SignalK access token for logbook writes',
87
+ description: 'Plugin routes are auth-gated; without a token the logbook write is skipped.',
88
+ default: '',
89
+ },
90
+ snapshotPaths: {
91
+ type: 'array',
92
+ title: 'Extra own-ship paths to snapshot on each beacon',
93
+ default: [],
94
+ items: {
95
+ type: 'object',
96
+ properties: {
97
+ field: { type: 'string', title: 'Field name in ownShip' },
98
+ path: { type: 'string', title: 'SignalK self path' },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ };
104
+
105
+ let options = {};
106
+ let store = null;
107
+ let started = false;
108
+ let reannounceTimer = null;
109
+ let positionUnsub = null;
110
+
111
+ const notifier = createNotifier({
112
+ app,
113
+ pluginId: plugin.id,
114
+ pathFor: (event) => `notifications.ais.distress.${event.deviceBeacon}`,
115
+ stateFor: () => 'emergency',
116
+ });
117
+
118
+ function selfPosition() {
119
+ const p = app.getSelfPath && app.getSelfPath('navigation.position');
120
+ return p && p.value ? p.value : p;
121
+ }
122
+
123
+ function vesselName(mmsi) {
124
+ try {
125
+ const node = app.getPath(`vessels.urn:mrn:imo:mmsi:${mmsi}.name`);
126
+ return node && typeof node === 'object' ? node.value : node;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ function readState(context) {
133
+ try {
134
+ const node = app.getPath(`${context}.navigation.state`);
135
+ return node && typeof node === 'object' ? node.value : node;
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ function messageContext(event) {
142
+ return { ownPosition: selfPosition(), vesselName: vesselName(event.mmsi) };
143
+ }
144
+
145
+ function notify(event) {
146
+ // Terse on purpose: this string gets spoken. Full detail lives in the
147
+ // resource store and the logbook entry.
148
+ event.message = buildMessage(event, messageContext(event));
149
+ notifier.raise(event);
150
+ }
151
+
152
+ /** Clear an active beacon alarm: drop the live notification and stamp the
153
+ * stored events so a restart reannounce skips them. */
154
+ function clearBeacon(beacon) {
155
+ notifier.clear(`notifications.ais.distress.${beacon}`);
156
+ store.markCleared((e) => e.deviceBeacon === beacon, new Date().toISOString());
157
+ }
158
+
159
+ function shouldLogbook() {
160
+ return options.logbookEnabled !== false && Boolean(options.logbookToken);
161
+ }
162
+
163
+ async function postLogbook(event) {
164
+ await writeLogbookEntry({
165
+ url: options.logbookUrl,
166
+ token: options.logbookToken,
167
+ text: buildLogbookText(event, messageContext(event)),
168
+ observations: buildObservations(event.ownShip),
169
+ });
170
+ }
171
+
172
+ /** Store a beacon, alarm on it, and log it. Returns the stored event. */
173
+ function record(event) {
174
+ event.receivedAt = event.receivedAt || new Date().toISOString();
175
+
176
+ // A beacon repeats its position several times a minute: bump the stored
177
+ // event, do not re-alarm. Matches on mmsi and ignores clearedAt, so an
178
+ // operator-cleared beacon that keeps transmitting stays silent.
179
+ const duplicate = store.findRecent(
180
+ (e) => e.mmsi === event.mmsi,
181
+ Date.parse(event.receivedAt),
182
+ DEDUPE_WINDOW_MS
183
+ );
184
+ if (duplicate) {
185
+ store.update(duplicate.id, {
186
+ position: event.position || duplicate.position,
187
+ state: event.state || duplicate.state,
188
+ repeats: (duplicate.repeats || 0) + 1,
189
+ lastReceivedAt: event.receivedAt,
190
+ });
191
+ return duplicate;
192
+ }
193
+
194
+ event.ownShip = captureOwnShip(app, options.snapshotPaths);
195
+ const stored = store.add(event);
196
+ notify(stored);
197
+ if (shouldLogbook()) {
198
+ postLogbook(stored).catch((err) => app.error(`ais-distress logbook write failed: ${err.message}`));
199
+ }
200
+ return stored;
201
+ }
202
+
203
+ function onPositionDelta(delta) {
204
+ if (!delta || !delta.value || typeof delta.value.latitude !== 'number') return;
205
+ const event = buildBeaconEvent({
206
+ context: delta.context,
207
+ position: delta.value,
208
+ state: readState(delta.context),
209
+ now: Date.now(),
210
+ });
211
+ if (event) record(event);
212
+ }
213
+
214
+ const buildSets = () =>
215
+ buildMarkerResourceSets(store.list(), {
216
+ now: Date.now(),
217
+ windowHours: options.markerWindowHours,
218
+ nameFor: vesselName,
219
+ bucketOf: (e) => e.deviceBeacon,
220
+ colors: BEACON_COLORS,
221
+ label: (b) => BEACON_LABEL[b] || `AIS ${b}`,
222
+ describe: (b) => `${BEACON_LABEL[b] || b} distress beacons heard on AIS`,
223
+ });
224
+
225
+ plugin.start = function (opts) {
226
+ options = {
227
+ maxEvents: 1000,
228
+ markerWindowHours: 24,
229
+ logbookEnabled: true,
230
+ logbookUrl: 'http://localhost:3000/plugins/signalk-logbook/logs',
231
+ logbookToken: '',
232
+ ...opts,
233
+ };
234
+
235
+ store = new EventStore({
236
+ filePath: path.join(app.getDataDirPath(), 'ais-distress.jsonl'),
237
+ maxEvents: options.maxEvents,
238
+ });
239
+
240
+ app.registerResourceProvider({
241
+ type: 'ais-distress',
242
+ methods: {
243
+ async listResources() {
244
+ const out = {};
245
+ for (const event of store.list()) out[event.id] = event;
246
+ return out;
247
+ },
248
+ async getResource(id) {
249
+ const event = store.get(id);
250
+ if (!event) throw new Error(`No such AIS distress beacon: ${id}`);
251
+ return event;
252
+ },
253
+ setResource() {
254
+ throw new Error('ais-distress is read-only');
255
+ },
256
+ deleteResource() {
257
+ throw new Error('ais-distress is read-only');
258
+ },
259
+ },
260
+ });
261
+
262
+ app.registerResourceProvider({
263
+ type: 'ais-distress-markers',
264
+ methods: {
265
+ async listResources() {
266
+ return buildSets();
267
+ },
268
+ async getResource(id) {
269
+ const sets = buildSets();
270
+ if (!sets[id]) throw new Error(`No AIS distress beacons of type: ${id}`);
271
+ return sets[id];
272
+ },
273
+ setResource() {
274
+ throw new Error('ais-distress-markers is read-only');
275
+ },
276
+ deleteResource() {
277
+ throw new Error('ais-distress-markers is read-only');
278
+ },
279
+ },
280
+ });
281
+
282
+ positionUnsub = app.streambundle
283
+ .getBus('navigation.position')
284
+ .onValue(onPositionDelta);
285
+
286
+ // Let an operator clear an active beacon alarm: a PUT to the notification
287
+ // path drops the live alert and marks the stored beacon(s) so a restart
288
+ // will not re-raise it.
289
+ for (const beacon of ['sart', 'mob', 'epirb']) {
290
+ app.registerPutHandler('vessels.self', `notifications.ais.distress.${beacon}`, () => {
291
+ clearBeacon(beacon);
292
+ return { state: 'COMPLETED', statusCode: 200 };
293
+ });
294
+ }
295
+
296
+ started = true;
297
+
298
+ // Survive server restarts mid-incident: re-raise the newest still-fresh
299
+ // beacon per device type. Delayed so position providers are up and the
300
+ // spoken message can say "N miles <direction>".
301
+ reannounceTimer = setTimeout(() => {
302
+ if (!started) return;
303
+ const now = Date.now();
304
+ const reannounced = new Set();
305
+ const events = store.list();
306
+ for (let i = events.length - 1; i >= 0; i--) {
307
+ const event = events[i];
308
+ if (reannounced.has(event.deviceBeacon)) continue;
309
+ if (event.clearedAt) continue;
310
+ const at = Date.parse(event.lastReceivedAt || event.receivedAt);
311
+ if (now - at <= REANNOUNCE_WINDOW_MS) {
312
+ notify(event);
313
+ reannounced.add(event.deviceBeacon);
314
+ }
315
+ }
316
+ }, options.reannounceDelayMs ?? 30000);
317
+ };
318
+
319
+ plugin.stop = function () {
320
+ started = false;
321
+ clearTimeout(reannounceTimer);
322
+ if (positionUnsub) positionUnsub();
323
+ positionUnsub = null;
324
+ };
325
+
326
+ return plugin;
327
+ };
package/lib/detect.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const { deviceBeaconFor } = require('@sailingnaturali/signalk-distress-core');
4
+
5
+ // Pull the MMSI out of a SignalK vessel context, e.g.
6
+ // "vessels.urn:mrn:imo:mmsi:974321098" → "974321098". Non-MMSI contexts
7
+ // (uuid-identified targets) yield undefined.
8
+ function mmsiFromContext(context) {
9
+ if (typeof context !== 'string') return undefined;
10
+ const m = context.match(/mmsi:(\d+)$/);
11
+ return m ? m[1] : undefined;
12
+ }
13
+
14
+ // 974 EPIRB / 972 MOB → a named nature of distress; 970 SART has no nature
15
+ // (it is a locating device), the deviceBeacon carries the meaning.
16
+ const BEACON_NATURE = { epirb: 'epirb', mob: 'mob' };
17
+
18
+ // Build a canonical AIS distress event from a position delta on a vessel
19
+ // context, or null if the context is not a 97x survival-beacon MMSI.
20
+ function buildBeaconEvent({ context, position, state, now }) {
21
+ const mmsi = mmsiFromContext(context);
22
+ const deviceBeacon = deviceBeaconFor(mmsi);
23
+ if (!deviceBeacon) return null;
24
+ return {
25
+ source: 'ais',
26
+ category: 'distress',
27
+ deviceBeacon,
28
+ mmsi,
29
+ natureOfDistress: BEACON_NATURE[deviceBeacon],
30
+ state,
31
+ position,
32
+ receivedAt: new Date(now).toISOString(),
33
+ };
34
+ }
35
+
36
+ module.exports = { mmsiFromContext, buildBeaconEvent };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@sailingnaturali/signalk-ais-distress",
3
+ "version": "0.1.0",
4
+ "description": "Alert on AIS distress beacons — SART, MOB, and EPIRB survival devices (MMSI 970/972/974) — with SignalK notifications, chart markers, a forensic log, and a ship's-log entry.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "keywords": [
10
+ "signalk-node-server-plugin",
11
+ "signalk-category-safety",
12
+ "ais",
13
+ "sart",
14
+ "epirb",
15
+ "mob",
16
+ "distress",
17
+ "marine"
18
+ ],
19
+ "author": "Bryan Clark",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/sailingnaturali/signalk-ais-distress.git"
24
+ },
25
+ "homepage": "https://github.com/sailingnaturali/signalk-ais-distress#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/sailingnaturali/signalk-ais-distress/issues"
28
+ },
29
+ "signalk": {
30
+ "displayName": "AIS Distress",
31
+ "recommends": [
32
+ "@meri-imperiumi/signalk-logbook"
33
+ ]
34
+ },
35
+ "files": [
36
+ "index.js",
37
+ "lib"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "dependencies": {
46
+ "@sailingnaturali/signalk-distress-core": "^0.2.0"
47
+ }
48
+ }