@sailingnaturali/signalk-dsc 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,88 @@
1
+ # signalk-dsc
2
+
3
+ SignalK plugin that receives, logs, and alerts on **DSC** (VHF digital selective
4
+ calling) traffic — distress, urgency, safety, and routine calls — from both
5
+ **NMEA 0183** (`$--DSC`/`$--DSE`) and **NMEA 2000** (PGN 129808).
6
+
7
+ ## Why
8
+
9
+ When a vessel hits the red button, its radio broadcasts a DSC burst on channel 70
10
+ with MMSI, position, and nature of distress — perfectly readable even when the
11
+ follow-up voice call on 16 is not. Stock SignalK mostly drops this data: the 0183
12
+ hook misses common sentence variants and persists nothing, and the N2K converter
13
+ has **no** PGN 129808 mapping at all. If you might be the nearest boat, you want
14
+ every received alert stored with its position and surfaced as an alarm — that is
15
+ this plugin.
16
+
17
+ ## What you get
18
+
19
+ For every DSC call heard by a connected radio:
20
+
21
+ - **A persistent call log** — JSONL on disk, served at
22
+ `GET /signalk/v2/api/resources/dsc-calls` (anonymously readable when the server
23
+ allows read-only access). Raw sentence/PGN is always kept alongside the parsed
24
+ fields: time, MMSI, category, nature of distress, position, UTC time.
25
+ - **Alarms under your own vessel** — `notifications.dsc.distress` (state
26
+ `emergency`), `notifications.dsc.urgency` (`alarm`), `notifications.dsc.safety`
27
+ (`alert`). Routine calls never alarm. Repeated re-transmissions of the same
28
+ alert (DSC auto-repeats until acknowledged) update the stored call instead of
29
+ re-alarming. Alerts received within the last hour are **re-raised after a
30
+ server restart** — notifications are in-memory, and a received MAYDAY must
31
+ not vanish because the server bounced.
32
+ - **A voice-sized message** — the notification message is deliberately minimal
33
+ (type, vessel, situation, range and direction from own position, action):
34
+ > DSC distress alert: vessel Wind Chaser, sinking, 2.3 nautical miles
35
+ > northwest. Monitor channel 16.
36
+
37
+ Full detail (MMSI, coordinates, reported time, transport) goes to the call
38
+ log and the logbook entry instead, so TTS pipelines stay terse.
39
+ - **Remote-vessel deltas** — the caller's `navigation.position` (and a distress
40
+ notification) under `vessels.urn:mrn:imo:mmsi:<caller>`, so chartplotters can
41
+ show where the call came from.
42
+ - **Optional ship's-log entries** via
43
+ [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook) — a
44
+ GMDSS-style radio log of received distress/urgency/safety traffic.
45
+
46
+ ## Transports
47
+
48
+ - **NMEA 0183**: registers custom `DSC` and `DSE` sentence parsers (these replace
49
+ the server's stock DSC hook with a superset: tolerant of sparse distress alerts
50
+ some radios emit — see [nmea0183-signalk#217](https://github.com/SignalK/nmea0183-signalk/issues/217)
51
+ — and with `DSE` position refinement from ±1 NM to ten-thousandths of a minute,
52
+ which the stock parser ignores entirely).
53
+ - **NMEA 2000**: listens to the server's analyzer stream (`N2KAnalyzerOut`) for
54
+ PGN 129808, since `n2k-signalk` produces no delta for it.
55
+
56
+ ## Configuration
57
+
58
+ | Option | Default | Notes |
59
+ | --- | --- | --- |
60
+ | `maxEvents` | `1000` | Oldest calls dropped beyond this. |
61
+ | `logbookEnabled` | `true` | Requires signalk-logbook and a token. |
62
+ | `logbookRoutine` | `false` | Also log routine calls. |
63
+ | `logbookUrl` | `http://localhost:3000/plugins/signalk-logbook/logs` | |
64
+ | `logbookToken` | _empty_ | SignalK access token; logbook writes are skipped without one (plugin routes are auth-gated). |
65
+
66
+ ## Trying it without a radio
67
+
68
+ Feed sentences through any NMEA 0183 connection (TCP, UDP, file playback):
69
+
70
+ ```
71
+ $CDDSC,12,3380400790,12,05,00,1423108312,2019,,,S,E*69
72
+ $CDDSE,1,1,A,3380400790,00,45894494*1B
73
+ ```
74
+
75
+ …then `GET /signalk/v2/api/resources/dsc-calls`.
76
+
77
+ ## Limitations
78
+
79
+ - Distress relays, acknowledgements, and cancellations are stored (with
80
+ `acknowledgement`/`distressedMmsi` fields) but don't yet clear or transform the
81
+ original alarm.
82
+ - Multi-sentence `DSE` groups are ignored (single-sentence covers Class-D gear).
83
+ - A raised distress notification stays active until cleared from the server —
84
+ deliberate: a received MAYDAY should not silently expire.
85
+
86
+ ## License
87
+
88
+ MIT
package/index.js ADDED
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * signalk-dsc
5
+ *
6
+ * Receive, log, and alert on DSC (VHF digital selective calling) calls.
7
+ *
8
+ * A DSC-equipped radio that hears traffic on channel 70 re-emits it digitally:
9
+ * as $--DSC/$--DSE sentences on NMEA 0183, or as PGN 129808 on NMEA 2000.
10
+ * SignalK's stock pipeline handles neither well — the 0183 hook drops sparse
11
+ * sentences and persists nothing, and n2k-signalk has no 129808 mapping at all.
12
+ *
13
+ * This plugin listens on both transports and, for every call received:
14
+ * - appends it to an on-disk JSONL log (forensics: raw input always kept)
15
+ * - serves the call history at /signalk/v2/api/resources/dsc-calls
16
+ * (anonymously readable under allow_readonly)
17
+ * - raises notifications.dsc.<category> under *self* — distress → emergency,
18
+ * urgency → alarm, safety → alert — so the vessel's own alarm chain fires
19
+ * - optionally writes a GMDSS-style radio-log entry via signalk-logbook
20
+ *
21
+ * Distress alerts repeat every few minutes until acknowledged; repeats inside
22
+ * a 5-minute window update the stored call instead of re-alarming.
23
+ */
24
+
25
+ const path = require('node:path');
26
+
27
+ const { parseDsc } = require('./lib/dsc');
28
+ const { parseDse, refinePosition } = require('./lib/dse');
29
+ const { normalizePgn129808 } = require('./lib/pgn129808');
30
+ const { EventStore } = require('./lib/store');
31
+ const { buildMessage, buildLogbookText } = require('./lib/format');
32
+
33
+ const DSC_PGN = 129808;
34
+ const NOTIFICATION_STATES = { distress: 'emergency', urgency: 'alarm', safety: 'alert' };
35
+ const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
36
+ const DSE_PAIR_WINDOW_MS = 2 * 60 * 1000;
37
+ // Notifications are in-memory on the server: a restart silently drops an
38
+ // active alarm. On start, re-raise any alert this recent.
39
+ const REANNOUNCE_WINDOW_MS = 60 * 60 * 1000;
40
+
41
+ module.exports = function makePlugin(app) {
42
+ const plugin = {
43
+ id: 'signalk-dsc',
44
+ name: 'DSC call logger',
45
+ description:
46
+ 'Receive, log, and alert on DSC (VHF digital selective calling) calls from NMEA 0183 ($CDDSC/$CDDSE) and NMEA 2000 (PGN 129808).',
47
+ };
48
+
49
+ plugin.schema = {
50
+ type: 'object',
51
+ properties: {
52
+ maxEvents: {
53
+ type: 'number',
54
+ title: 'Calls to keep',
55
+ description: 'Oldest calls are dropped beyond this count.',
56
+ default: 1000,
57
+ },
58
+ logbookEnabled: {
59
+ type: 'boolean',
60
+ title: 'Write received calls to the ship\'s log (signalk-logbook)',
61
+ default: true,
62
+ },
63
+ logbookRoutine: {
64
+ type: 'boolean',
65
+ title: 'Also log routine calls',
66
+ description: 'By default only distress/urgency/safety traffic is logged, GMDSS-style.',
67
+ default: false,
68
+ },
69
+ logbookUrl: {
70
+ type: 'string',
71
+ title: 'Logbook API URL',
72
+ default: 'http://localhost:3000/plugins/signalk-logbook/logs',
73
+ },
74
+ logbookToken: {
75
+ type: 'string',
76
+ title: 'SignalK access token for logbook writes',
77
+ description: 'Plugin routes are auth-gated; without a token the logbook write is skipped.',
78
+ default: '',
79
+ },
80
+ },
81
+ };
82
+
83
+ let store = null;
84
+ let options = {};
85
+ let started = false;
86
+ let reannounceTimer = null;
87
+
88
+ function unwrap(node) {
89
+ return node && typeof node === 'object' && 'value' in node ? node.value : node;
90
+ }
91
+
92
+ function selfMmsi() {
93
+ const value = unwrap(app.getSelfPath('mmsi'));
94
+ return typeof value === 'string' ? value : undefined;
95
+ }
96
+
97
+ function selfPosition() {
98
+ const value = unwrap(app.getSelfPath('navigation.position'));
99
+ return value && typeof value.latitude === 'number' ? value : undefined;
100
+ }
101
+
102
+ /** Vessel name from the data model (AIS static data), if we have heard it. */
103
+ function vesselName(mmsi) {
104
+ if (!mmsi || typeof app.getPath !== 'function') return undefined;
105
+ try {
106
+ const value = unwrap(app.getPath(`vessels.urn:mrn:imo:mmsi:${mmsi}.name`));
107
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ function messageContext(event) {
114
+ return {
115
+ ownPosition: selfPosition(),
116
+ vesselName: vesselName(event.distressedMmsi || event.mmsi),
117
+ };
118
+ }
119
+
120
+ function notify(event) {
121
+ const state = NOTIFICATION_STATES[event.category];
122
+ if (!state) return;
123
+ app.handleMessage(plugin.id, {
124
+ updates: [
125
+ {
126
+ values: [
127
+ {
128
+ path: `notifications.dsc.${event.category}`,
129
+ value: {
130
+ state,
131
+ method: ['visual', 'sound'],
132
+ // Kept terse on purpose: this string gets spoken by the
133
+ // voice pipeline. Full detail lives in the resource store
134
+ // and the logbook entry.
135
+ message: buildMessage(event, messageContext(event)),
136
+ timestamp: event.receivedAt,
137
+ },
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ });
143
+ }
144
+
145
+ function shouldLogbook(event) {
146
+ if (options.logbookEnabled === false) return false;
147
+ if (!options.logbookToken) return false;
148
+ if (event.category === 'routine' || event.category === 'unknown') {
149
+ return Boolean(options.logbookRoutine);
150
+ }
151
+ return true;
152
+ }
153
+
154
+ async function postLogbook(event) {
155
+ const res = await fetch(options.logbookUrl, {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ // signalk-server's auth gate reads the Authorization header; the
160
+ // logbook plugin reads the author from the JAUTHENTICATION cookie.
161
+ Authorization: `Bearer ${options.logbookToken}`,
162
+ Cookie: `JAUTHENTICATION=${options.logbookToken}`,
163
+ },
164
+ body: JSON.stringify({ text: buildLogbookText(event, messageContext(event)), ago: 0 }),
165
+ });
166
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
167
+ }
168
+
169
+ /** Store a normalized call, alarm on it, and log it. Returns the stored event. */
170
+ function record(parsed, { source, raw, receivedAt }) {
171
+ const event = {
172
+ receivedAt: receivedAt || new Date().toISOString(),
173
+ source,
174
+ raw,
175
+ ...parsed,
176
+ };
177
+ if (event.mmsi && event.mmsi === selfMmsi()) event.self = true;
178
+
179
+ // Re-transmission of the same call (distress alerts auto-repeat until
180
+ // acknowledged): bump the stored call, do not re-alarm.
181
+ const duplicate = store.findRecent(
182
+ (e) =>
183
+ e.mmsi === event.mmsi &&
184
+ e.category === event.category &&
185
+ e.natureOfDistress === event.natureOfDistress,
186
+ Date.parse(event.receivedAt),
187
+ DEDUPE_WINDOW_MS
188
+ );
189
+ if (duplicate) {
190
+ store.update(duplicate.id, {
191
+ repeats: (duplicate.repeats || 0) + 1,
192
+ lastReceivedAt: event.receivedAt,
193
+ });
194
+ return duplicate;
195
+ }
196
+
197
+ store.add(event);
198
+ notify(event);
199
+ if (shouldLogbook(event)) {
200
+ postLogbook(event).catch((err) =>
201
+ app.error(`signalk-dsc: logbook write failed: ${err.message}`)
202
+ );
203
+ }
204
+ if (typeof app.setPluginStatus === 'function') {
205
+ app.setPluginStatus(
206
+ `${event.category} call from MMSI ${event.mmsi || 'unknown'} at ${event.receivedAt}`
207
+ );
208
+ }
209
+ return event;
210
+ }
211
+
212
+ function remoteContext(mmsi) {
213
+ return `vessels.urn:mrn:imo:mmsi:${mmsi}`;
214
+ }
215
+
216
+ // Custom sentence parsers override the stock hooks (a superset of the
217
+ // upstream DSC hook's behavior, plus DSE support it lacks).
218
+ function dscParser(input) {
219
+ if (!started) return null;
220
+ try {
221
+ const parsed = parseDsc(input.parts);
222
+ if (!parsed) return null;
223
+ if (parsed.position) parsed.positionResolution = 'minute';
224
+
225
+ const event = record(parsed, {
226
+ source: 'nmea0183',
227
+ raw: input.sentence,
228
+ receivedAt: input.tags && input.tags.timestamp,
229
+ });
230
+
231
+ // Upstream-compatible delta under the sender's context so chartplotters
232
+ // and AIS-style consumers see the caller's position.
233
+ if (parsed.mmsi) {
234
+ const values = [];
235
+ if (parsed.position) {
236
+ values.push({ path: 'navigation.position', value: parsed.position });
237
+ }
238
+ if (parsed.category === 'distress') {
239
+ values.push({
240
+ path: `notifications.${parsed.natureOfDistress}`,
241
+ value: {
242
+ state: 'emergency',
243
+ method: ['visual', 'sound'],
244
+ message: buildMessage(event, messageContext(event)),
245
+ },
246
+ });
247
+ }
248
+ if (values.length) {
249
+ return { context: remoteContext(parsed.mmsi), updates: [{ values }] };
250
+ }
251
+ }
252
+ } catch (err) {
253
+ app.error(`signalk-dsc: DSC parse failed: ${err.message}`);
254
+ }
255
+ return null;
256
+ }
257
+
258
+ function dseParser(input) {
259
+ if (!started) return null;
260
+ try {
261
+ const ext = parseDse(input.parts);
262
+ if (!ext) return null;
263
+ const now = (input.tags && Date.parse(input.tags.timestamp)) || Date.now();
264
+ const target = store.findRecent(
265
+ (e) => e.mmsi === ext.mmsi && e.position && e.positionResolution === 'minute',
266
+ now,
267
+ DSE_PAIR_WINDOW_MS
268
+ );
269
+ if (!target) return null;
270
+
271
+ const refined = refinePosition(target.position, ext);
272
+ store.update(target.id, { position: refined, positionResolution: 'enhanced' });
273
+ return {
274
+ context: remoteContext(ext.mmsi),
275
+ updates: [{ values: [{ path: 'navigation.position', value: refined }] }],
276
+ };
277
+ } catch (err) {
278
+ app.error(`signalk-dsc: DSE parse failed: ${err.message}`);
279
+ }
280
+ return null;
281
+ }
282
+
283
+ function onPgn(pgnData) {
284
+ if (!started || !pgnData || pgnData.pgn !== DSC_PGN) return;
285
+ try {
286
+ record(normalizePgn129808(pgnData), { source: 'n2k', raw: pgnData.fields });
287
+ } catch (err) {
288
+ app.error(`signalk-dsc: PGN 129808 handling failed: ${err.message}`);
289
+ }
290
+ }
291
+
292
+ plugin.start = function (opts) {
293
+ options = {
294
+ maxEvents: 1000,
295
+ logbookEnabled: true,
296
+ logbookRoutine: false,
297
+ logbookUrl: 'http://localhost:3000/plugins/signalk-logbook/logs',
298
+ logbookToken: '',
299
+ ...opts,
300
+ };
301
+
302
+ store = new EventStore({
303
+ filePath: path.join(app.getDataDirPath(), 'dsc-calls.jsonl'),
304
+ maxEvents: options.maxEvents,
305
+ });
306
+
307
+ app.registerResourceProvider({
308
+ type: 'dsc-calls',
309
+ methods: {
310
+ async listResources() {
311
+ const out = {};
312
+ for (const event of store.list()) out[event.id] = event;
313
+ return out;
314
+ },
315
+ async getResource(id) {
316
+ const event = store.get(id);
317
+ if (!event) throw new Error(`No such DSC call: ${id}`);
318
+ return event;
319
+ },
320
+ setResource() {
321
+ throw new Error('dsc-calls is read-only');
322
+ },
323
+ deleteResource() {
324
+ throw new Error('dsc-calls is read-only');
325
+ },
326
+ },
327
+ });
328
+
329
+ app.emitPropertyValue('nmea0183sentenceParser', { sentence: 'DSC', parser: dscParser });
330
+ app.emitPropertyValue('nmea0183sentenceParser', { sentence: 'DSE', parser: dseParser });
331
+ app.on('N2KAnalyzerOut', onPgn);
332
+
333
+ started = true;
334
+
335
+ // Survive server restarts mid-incident: re-raise the newest alert per
336
+ // category that is still fresh (a received MAYDAY must not vanish just
337
+ // because the server bounced). Delayed so position providers are up and
338
+ // the spoken message can say "N miles <direction>" instead of raw
339
+ // coordinates.
340
+ reannounceTimer = setTimeout(() => {
341
+ if (!started) return;
342
+ const now = Date.now();
343
+ const reannounced = new Set();
344
+ const events = store.list();
345
+ for (let i = events.length - 1; i >= 0; i--) {
346
+ const event = events[i];
347
+ if (!NOTIFICATION_STATES[event.category] || reannounced.has(event.category)) continue;
348
+ const at = Date.parse(event.lastReceivedAt || event.receivedAt);
349
+ if (now - at <= REANNOUNCE_WINDOW_MS) {
350
+ notify(event);
351
+ reannounced.add(event.category);
352
+ }
353
+ }
354
+ }, options.reannounceDelayMs ?? 30000);
355
+ };
356
+
357
+ plugin.stop = function () {
358
+ started = false;
359
+ clearTimeout(reannounceTimer);
360
+ app.removeListener('N2KAnalyzerOut', onPgn);
361
+ };
362
+
363
+ return plugin;
364
+ };
package/lib/dsc.js ADDED
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * $--DSC sentence parsing (ITU-R M.493 datagram as flattened by NMEA 0183).
5
+ *
6
+ * Field reference: http://continuouswave.com/whaler/reference/DSC_Datagrams.html
7
+ *
8
+ * 0 1 2 3 4 5 6 7 8 9 10
9
+ * | | | | | | | | | | |
10
+ * $--DSC,XX,XXXXXXXXXX,XX,XX,XX,XXXXXXXXXX,XXXX,,,A,C*hh
11
+ *
12
+ * 0: format specifier (ITU symbol minus the leading 1: 12 = 112 distress alert)
13
+ * 1: address — MMSI * 10 (trailing zero). Distress alert: vessel in distress.
14
+ * 2: category (00 routine / 08 safety / 10 urgency / 12 distress)
15
+ * 3: nature of distress, or 1st telecommand for non-distress calls
16
+ * 4: subsequent comms / 2nd telecommand
17
+ * 5: position (quadrant + ddmm + dddmm, truncated toward zero) or channel/number
18
+ * 6: UTC time hhmm (8888 = unavailable)
19
+ * 7: MMSI of vessel in distress (relays/acknowledgements/cancellations)
20
+ * 8: nature of distress (relays)
21
+ * 9: acknowledgement (R/B/S)
22
+ * 10: expansion flag — 'E' means a $--DSE sentence follows
23
+ */
24
+
25
+ const FORMATS = {
26
+ '02': 'area',
27
+ '12': 'distressAlert',
28
+ '14': 'group',
29
+ '16': 'allShips',
30
+ '20': 'individual',
31
+ '23': 'autoService',
32
+ };
33
+
34
+ const CATEGORIES = {
35
+ '00': 'routine',
36
+ '08': 'safety',
37
+ '10': 'urgency',
38
+ '12': 'distress',
39
+ };
40
+
41
+ const NATURES = {
42
+ '00': 'fire',
43
+ '01': 'flooding',
44
+ '02': 'collision',
45
+ '03': 'grounding',
46
+ '04': 'listing',
47
+ '05': 'sinking',
48
+ '06': 'adrift',
49
+ '07': 'undesignated',
50
+ '08': 'abandon',
51
+ '09': 'piracy',
52
+ '10': 'mob',
53
+ '12': 'epirb',
54
+ };
55
+
56
+ // Telecommand 21 on a non-distress call = "ship position" — field 5 holds a position.
57
+ const TELECOMMAND_SHIP_POSITION = '21';
58
+
59
+ function field(parts, i) {
60
+ const v = parts[i];
61
+ return typeof v === 'string' ? v.trim() : '';
62
+ }
63
+
64
+ function parseMmsi(raw) {
65
+ if (typeof raw !== 'string') return undefined;
66
+ const digits = raw.trim();
67
+ if (!/^\d{9,10}$/.test(digits)) return undefined;
68
+ // DSC sentences carry the MMSI with a trailing zero (MMSI * 10).
69
+ return digits.length === 10 ? digits.substring(0, 9) : digits;
70
+ }
71
+
72
+ function parsePosition(raw) {
73
+ if (typeof raw !== 'string' || !/^\d{10}$/.test(raw)) return undefined;
74
+ if (raw === '9999999999') return undefined; // "position not available"
75
+ const quadrant = Number(raw[0]); // 0 NE, 1 NW, 2 SE, 3 SW
76
+ if (quadrant > 3) return undefined;
77
+ let latitude = Number(raw.substring(1, 3)) + Number(raw.substring(3, 5)) / 60;
78
+ let longitude = Number(raw.substring(5, 8)) + Number(raw.substring(8, 10)) / 60;
79
+ if (quadrant === 1 || quadrant === 3) longitude = -longitude;
80
+ if (quadrant === 2 || quadrant === 3) latitude = -latitude;
81
+ return { latitude, longitude };
82
+ }
83
+
84
+ function parseUtcTime(raw) {
85
+ if (typeof raw !== 'string' || !/^\d{4}$/.test(raw.trim())) return undefined;
86
+ const t = raw.trim();
87
+ if (t === '8888') return undefined; // "time not available"
88
+ return `${t.substring(0, 2)}:${t.substring(2, 4)}`;
89
+ }
90
+
91
+ /**
92
+ * Parse the comma-split fields of a $--DSC sentence (sentence id and checksum
93
+ * already stripped) into a partial DSC event. Tolerant by design: anything we
94
+ * cannot interpret stays undefined and the caller keeps the raw sentence.
95
+ * Returns null only when there is nothing usable at all.
96
+ */
97
+ function parseDsc(parts) {
98
+ if (!Array.isArray(parts) || parts.length < 2) return null;
99
+
100
+ const formatCode = field(parts, 0);
101
+ let categoryCode = field(parts, 2);
102
+ // Some radios omit the category on distress alerts — it is implied by
103
+ // format 112 (see SignalK/nmea0183-signalk#217).
104
+ if (!categoryCode && formatCode === '12') categoryCode = '12';
105
+
106
+ const event = {
107
+ format: FORMATS[formatCode] || 'unknown',
108
+ category: CATEGORIES[categoryCode] || 'unknown',
109
+ mmsi: parseMmsi(parts[1]),
110
+ };
111
+
112
+ const distress = event.category === 'distress';
113
+ if (distress) {
114
+ const natureCode = field(parts, 3);
115
+ event.natureOfDistress =
116
+ NATURES[natureCode] || (natureCode ? `code-${natureCode}` : 'undesignated');
117
+ event.distressedMmsi = parseMmsi(parts[7]);
118
+ }
119
+
120
+ if (distress || field(parts, 3) === TELECOMMAND_SHIP_POSITION) {
121
+ event.position = parsePosition(field(parts, 5));
122
+ event.utcTime = parseUtcTime(field(parts, 6));
123
+ }
124
+
125
+ const ack = field(parts, 9);
126
+ if (ack) event.acknowledgement = ack;
127
+ event.expansion = field(parts, 10) === 'E';
128
+
129
+ return event;
130
+ }
131
+
132
+ module.exports = { parseDsc, parsePosition, parseMmsi, parseUtcTime, NATURES };
package/lib/dse.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * $--DSE sentence parsing — the expansion that can follow a $--DSC sentence,
5
+ * refining its position from whole minutes to ten-thousandths of a minute.
6
+ *
7
+ * 0 1 2 3 4 5
8
+ * $--DSE,t,n,A,XXXXXXXXXX,00,llllyyyy*hh
9
+ *
10
+ * 0: total sentences in this group
11
+ * 1: sentence number
12
+ * 2: query flag ('A' = automatic/unsolicited)
13
+ * 3: address — MMSI * 10, same convention as DSC
14
+ * 4+: repeated (code, data) pairs; code 00 = enhanced position resolution,
15
+ * data = 4 digits lat + 4 digits lon, in 1/10000 of a minute
16
+ */
17
+
18
+ const { parseMmsi } = require('./dsc');
19
+
20
+ const CODE_ENHANCED_POSITION = '00';
21
+
22
+ /**
23
+ * Returns { mmsi, latMinuteFraction, lonMinuteFraction } for single-sentence
24
+ * DSE groups carrying a position extension, else null.
25
+ */
26
+ function parseDse(parts) {
27
+ if (!Array.isArray(parts) || parts.length < 6) return null;
28
+ // Multi-sentence groups are vanishingly rare on Class-D gear; skip them.
29
+ if (parts[0].trim() !== '1' || parts[1].trim() !== '1') return null;
30
+
31
+ const mmsi = parseMmsi(parts[3]);
32
+ if (!mmsi) return null;
33
+
34
+ for (let i = 4; i + 1 < parts.length; i += 2) {
35
+ const code = (parts[i] || '').trim();
36
+ const data = (parts[i + 1] || '').trim();
37
+ if (code === CODE_ENHANCED_POSITION && /^\d{8}$/.test(data)) {
38
+ return {
39
+ mmsi,
40
+ latMinuteFraction: Number(data.substring(0, 4)) / 10000,
41
+ lonMinuteFraction: Number(data.substring(4, 8)) / 10000,
42
+ };
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Apply a DSE extension to a DSC position. DSC truncates coordinates toward
50
+ * zero, so the fractional minutes always extend the magnitude (sign-preserving).
51
+ */
52
+ function refinePosition(position, ext) {
53
+ const extend = (value, minuteFraction) => {
54
+ const sign = value < 0 ? -1 : 1;
55
+ return sign * (Math.abs(value) + minuteFraction / 60);
56
+ };
57
+ return {
58
+ latitude: extend(position.latitude, ext.latMinuteFraction),
59
+ longitude: extend(position.longitude, ext.lonMinuteFraction),
60
+ };
61
+ }
62
+
63
+ module.exports = { parseDse, refinePosition };
package/lib/format.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Two renderings of a DSC event:
5
+ *
6
+ * - buildMessage: the notification message. This ends up SPOKEN by the voice
7
+ * pipeline, so it is deliberately minimal — type, vessel, situation, range
8
+ * and direction from us, action. Nothing else.
9
+ * - buildLogbookText: the ship's-log entry — full detail (MMSI, coordinates,
10
+ * reported time, transport), GMDSS radio-log style.
11
+ */
12
+
13
+ const { distanceNm, bearingDegrees, compassWord } = require('./geo');
14
+
15
+ const NATURE_TEXT = {
16
+ fire: 'fire and explosion',
17
+ flooding: 'flooding',
18
+ collision: 'collision',
19
+ grounding: 'grounding',
20
+ listing: 'listing, in danger of capsize',
21
+ sinking: 'sinking',
22
+ adrift: 'disabled and adrift',
23
+ undesignated: 'undesignated distress',
24
+ abandon: 'abandoning ship',
25
+ piracy: 'piracy attack',
26
+ mob: 'man overboard',
27
+ epirb: 'EPIRB emission',
28
+ };
29
+
30
+ function formatCoordinate(value, axis) {
31
+ const hemisphere = axis === 'lat' ? (value < 0 ? 'S' : 'N') : value < 0 ? 'W' : 'E';
32
+ const abs = Math.abs(value);
33
+ const degrees = Math.floor(abs);
34
+ const minutes = (abs - degrees) * 60;
35
+ return `${degrees}°${minutes.toFixed(3)}′${hemisphere}`;
36
+ }
37
+
38
+ function formatPosition(position) {
39
+ return `${formatCoordinate(position.latitude, 'lat')} ${formatCoordinate(position.longitude, 'lon')}`;
40
+ }
41
+
42
+ // Spoken form. No MMSI fallback here on purpose: TTS reads 366123456 as
43
+ // "three hundred sixty-six million...". The MMSI stays in the call log and
44
+ // the logbook entry.
45
+ function vesselPhrase(event, vesselName) {
46
+ return vesselName ? `vessel ${vesselName}` : 'unidentified vessel';
47
+ }
48
+
49
+ /** "2.3 nautical miles northwest" | "position 48°47.700′N ..." | "position unknown" */
50
+ function wherePhrase(event, ownPosition, { spoken }) {
51
+ if (event.position && ownPosition) {
52
+ const range = distanceNm(ownPosition, event.position);
53
+ const direction = compassWord(bearingDegrees(ownPosition, event.position));
54
+ const unit = spoken ? 'nautical miles' : 'NM';
55
+ const suffix = spoken ? '' : ' of us';
56
+ return `${range.toFixed(1)} ${unit} ${direction}${suffix}`;
57
+ }
58
+ if (event.position) return `position ${formatPosition(event.position)}`;
59
+ return 'position unknown';
60
+ }
61
+
62
+ function buildMessage(event, { ownPosition, vesselName } = {}) {
63
+ const who = vesselPhrase(event, vesselName);
64
+ const where = wherePhrase(event, ownPosition, { spoken: true });
65
+ if (event.category === 'distress') {
66
+ const nature = NATURE_TEXT[event.natureOfDistress] || event.natureOfDistress || 'undesignated distress';
67
+ return `DSC distress alert: ${who}, ${nature}, ${where}. Monitor channel 16.`;
68
+ }
69
+ const kind = event.category === 'unknown' ? 'call' : `${event.category} call`;
70
+ return `DSC ${kind}: ${who}, ${where}.`;
71
+ }
72
+
73
+ function buildLogbookText(event, { ownPosition, vesselName } = {}) {
74
+ const parts = [];
75
+ const name = vesselName ? `${vesselName} (MMSI ${event.mmsi || 'unknown'})` : `MMSI ${event.mmsi || 'unknown'}`;
76
+ if (event.category === 'distress') {
77
+ const nature = NATURE_TEXT[event.natureOfDistress] || event.natureOfDistress || 'undesignated distress';
78
+ parts.push(`DISTRESS alert from ${name}: ${nature}`);
79
+ } else {
80
+ parts.push(`${event.category} call from ${name}`);
81
+ }
82
+ if (event.position) {
83
+ let pos = `position ${formatPosition(event.position)}`;
84
+ if (event.utcTime) pos += ` at ${event.utcTime} UTC`;
85
+ if (ownPosition) pos += `, ${wherePhrase(event, ownPosition, { spoken: false })}`;
86
+ parts.push(pos);
87
+ } else if (event.utcTime) {
88
+ parts.push(`reported at ${event.utcTime} UTC`);
89
+ }
90
+ if (event.source) parts.push(`via ${event.source}`);
91
+ return `[DSC] ${parts.join('. ')}`;
92
+ }
93
+
94
+ module.exports = { buildMessage, buildLogbookText, formatPosition };
package/lib/geo.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /* Range and bearing from own ship to a reported position, for voice alerts. */
4
+
5
+ const EARTH_RADIUS_NM = 3440.065;
6
+
7
+ function toRad(deg) {
8
+ return (deg * Math.PI) / 180;
9
+ }
10
+
11
+ function distanceNm(from, to) {
12
+ const dLat = toRad(to.latitude - from.latitude);
13
+ const dLon = toRad(to.longitude - from.longitude);
14
+ const a =
15
+ Math.sin(dLat / 2) ** 2 +
16
+ Math.cos(toRad(from.latitude)) * Math.cos(toRad(to.latitude)) * Math.sin(dLon / 2) ** 2;
17
+ return 2 * Math.asin(Math.sqrt(a)) * EARTH_RADIUS_NM;
18
+ }
19
+
20
+ function bearingDegrees(from, to) {
21
+ const phi1 = toRad(from.latitude);
22
+ const phi2 = toRad(to.latitude);
23
+ const dLon = toRad(to.longitude - from.longitude);
24
+ const y = Math.sin(dLon) * Math.cos(phi2);
25
+ const x =
26
+ Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon);
27
+ return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
28
+ }
29
+
30
+ const COMPASS_WORDS = [
31
+ 'north',
32
+ 'northeast',
33
+ 'east',
34
+ 'southeast',
35
+ 'south',
36
+ 'southwest',
37
+ 'west',
38
+ 'northwest',
39
+ ];
40
+
41
+ function compassWord(bearing) {
42
+ return COMPASS_WORDS[Math.round(((bearing % 360) + 360) % 360 / 45) % 8];
43
+ }
44
+
45
+ module.exports = { distanceNm, bearingDegrees, compassWord };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * NMEA 2000 PGN 129808 "DSC (Distress) Call Information" → canonical DSC event.
5
+ *
6
+ * canboatjs (useCamelCompat) resolves lookups to their names; the distress
7
+ * variant uses dscFormat/dscCategory/natureOfDistress, the general variant
8
+ * dscFormatSymbol/dscCategorySymbol. When a lookup cannot be resolved the raw
9
+ * ITU symbol number (1xx) comes through instead — handle both.
10
+ */
11
+
12
+ // Lookup-name → canonical (lowercased keys); ITU symbols as numeric fallback.
13
+ const FORMATS = new Map([
14
+ ['geographical area', 'area'],
15
+ ['distress', 'distressAlert'],
16
+ ['common interest', 'group'],
17
+ ['group call', 'group'],
18
+ ['all ships', 'allShips'],
19
+ ['individual stations', 'individual'],
20
+ ['individual station automatic', 'autoService'],
21
+ [102, 'area'],
22
+ [112, 'distressAlert'],
23
+ [114, 'group'],
24
+ [116, 'allShips'],
25
+ [120, 'individual'],
26
+ [123, 'autoService'],
27
+ ]);
28
+
29
+ const CATEGORIES = new Map([
30
+ ['routine', 'routine'],
31
+ ['safety', 'safety'],
32
+ ['urgency', 'urgency'],
33
+ ['distress', 'distress'],
34
+ [100, 'routine'],
35
+ [108, 'safety'],
36
+ [110, 'urgency'],
37
+ [112, 'distress'],
38
+ ]);
39
+
40
+ const NATURES = new Map([
41
+ ['fire, explosion', 'fire'],
42
+ ['flooding', 'flooding'],
43
+ ['collision', 'collision'],
44
+ ['grounding', 'grounding'],
45
+ ['listing, in danger of capsizing', 'listing'],
46
+ ['sinking', 'sinking'],
47
+ ['disabled and adrift', 'adrift'],
48
+ ['undesignated distress', 'undesignated'],
49
+ ['abandoning ship', 'abandon'],
50
+ ['piracy/armed robbery attack', 'piracy'],
51
+ ['man overboard', 'mob'],
52
+ ['epirb emission', 'epirb'],
53
+ [0, 'fire'],
54
+ [1, 'flooding'],
55
+ [2, 'collision'],
56
+ [3, 'grounding'],
57
+ [4, 'listing'],
58
+ [5, 'sinking'],
59
+ [6, 'adrift'],
60
+ [7, 'undesignated'],
61
+ [8, 'abandon'],
62
+ [9, 'piracy'],
63
+ [10, 'mob'],
64
+ [12, 'epirb'],
65
+ ]);
66
+
67
+ function lookup(map, value) {
68
+ if (value === undefined || value === null) return undefined;
69
+ if (typeof value === 'string') {
70
+ const hit = map.get(value.trim().toLowerCase());
71
+ if (hit) return hit;
72
+ const asNumber = Number(value);
73
+ if (!Number.isNaN(asNumber)) return map.get(asNumber);
74
+ return undefined;
75
+ }
76
+ return map.get(value);
77
+ }
78
+
79
+ function normalizeMmsi(value) {
80
+ if (value === undefined || value === null || value === '') return undefined;
81
+ const digits = String(value).trim();
82
+ if (!/^\d{1,9}$/.test(digits)) return undefined;
83
+ if (Number(digits) === 0) return undefined;
84
+ return digits.padStart(9, '0');
85
+ }
86
+
87
+ function normalizeTime(value) {
88
+ if (typeof value === 'string') {
89
+ const m = value.match(/^(\d{2}):(\d{2})/);
90
+ if (m) return `${m[1]}:${m[2]}`;
91
+ return undefined;
92
+ }
93
+ if (typeof value === 'number' && value >= 0 && value < 86400) {
94
+ // Seconds since midnight.
95
+ const h = String(Math.floor(value / 3600)).padStart(2, '0');
96
+ const min = String(Math.floor((value % 3600) / 60)).padStart(2, '0');
97
+ return `${h}:${min}`;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ /** Normalize a canboatjs-parsed PGN 129808 object into a canonical DSC event. */
103
+ function normalizePgn129808(pgnData) {
104
+ const f = (pgnData && pgnData.fields) || {};
105
+
106
+ const event = {
107
+ format: lookup(FORMATS, f.dscFormat ?? f.dscFormatSymbol) || 'unknown',
108
+ category: lookup(CATEGORIES, f.dscCategory ?? f.dscCategorySymbol) || 'unknown',
109
+ mmsi: normalizeMmsi(f.dscMessageAddress),
110
+ };
111
+
112
+ if (event.category === 'distress') {
113
+ const nature = f.natureOfDistress ?? f['1stTelecommand'];
114
+ event.natureOfDistress = lookup(NATURES, nature) || 'undesignated';
115
+ }
116
+
117
+ const lat = f.latitudeOfVesselReported;
118
+ const lon = f.longitudeOfVesselReported;
119
+ if (typeof lat === 'number' && typeof lon === 'number') {
120
+ event.position = { latitude: lat, longitude: lon };
121
+ }
122
+
123
+ const time = normalizeTime(f.timeOfPosition);
124
+ if (time) event.utcTime = time;
125
+
126
+ const distressed = normalizeMmsi(f.mmsiOfShipInDistress);
127
+ if (distressed) event.distressedMmsi = distressed;
128
+
129
+ return event;
130
+ }
131
+
132
+ module.exports = { normalizePgn129808 };
package/lib/store.js ADDED
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /*
7
+ * Append-only JSONL store for received DSC calls. DSC traffic is rare (a busy
8
+ * day in range of a coast station might see dozens of calls), so synchronous
9
+ * I/O and full-file compaction are entirely adequate — and the simplest thing
10
+ * that survives a power cut mid-write (a torn last line is skipped on load).
11
+ */
12
+ class EventStore {
13
+ constructor({ filePath, maxEvents = 1000 }) {
14
+ this.filePath = filePath;
15
+ this.maxEvents = maxEvents;
16
+ this.events = [];
17
+ this._load();
18
+ }
19
+
20
+ _load() {
21
+ let raw;
22
+ try {
23
+ raw = fs.readFileSync(this.filePath, 'utf8');
24
+ } catch {
25
+ return; // no log yet
26
+ }
27
+ for (const line of raw.split('\n')) {
28
+ if (!line.trim()) continue;
29
+ try {
30
+ this.events.push(JSON.parse(line));
31
+ } catch {
32
+ // torn/corrupt line — skip it, keep the rest
33
+ }
34
+ }
35
+ if (this.events.length > this.maxEvents) {
36
+ this.events = this.events.slice(-this.maxEvents);
37
+ this._compact();
38
+ }
39
+ }
40
+
41
+ _compact() {
42
+ const tmp = `${this.filePath}.tmp`;
43
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
44
+ fs.writeFileSync(tmp, this.events.map((e) => JSON.stringify(e)).join('\n') + '\n');
45
+ fs.renameSync(tmp, this.filePath);
46
+ }
47
+
48
+ add(event) {
49
+ if (!event.id) {
50
+ event.id = `${event.receivedAt || new Date().toISOString()}-${event.mmsi || 'unknown'}`;
51
+ }
52
+ this.events.push(event);
53
+ if (this.events.length > this.maxEvents) {
54
+ this.events = this.events.slice(-this.maxEvents);
55
+ this._compact();
56
+ } else {
57
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
58
+ fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n');
59
+ }
60
+ return event;
61
+ }
62
+
63
+ update(id, patch) {
64
+ const event = this.get(id);
65
+ if (!event) return undefined;
66
+ Object.assign(event, patch);
67
+ this._compact();
68
+ return event;
69
+ }
70
+
71
+ list() {
72
+ return this.events;
73
+ }
74
+
75
+ get(id) {
76
+ return this.events.find((e) => e.id === id);
77
+ }
78
+
79
+ /** Newest event matching `predicate` received within `windowMs` of `nowMs`. */
80
+ findRecent(predicate, nowMs, windowMs) {
81
+ for (let i = this.events.length - 1; i >= 0; i--) {
82
+ const e = this.events[i];
83
+ const age = nowMs - Date.parse(e.receivedAt);
84
+ if (age > windowMs) return undefined;
85
+ if (age >= 0 && predicate(e)) return e;
86
+ }
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ module.exports = { EventStore };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@sailingnaturali/signalk-dsc",
3
+ "version": "0.1.0",
4
+ "description": "Receive, log, and alert on DSC (VHF digital selective calling) calls — distress, urgency, safety, routine — from NMEA 0183 ($CDDSC/$CDDSE) and NMEA 2000 (PGN 129808).",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "keywords": [
10
+ "signalk-node-server-plugin",
11
+ "signalk-category-utility",
12
+ "dsc",
13
+ "gmdss",
14
+ "vhf",
15
+ "distress",
16
+ "marine"
17
+ ],
18
+ "author": "Bryan Clark",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/sailingnaturali/signalk-dsc.git"
23
+ },
24
+ "homepage": "https://github.com/sailingnaturali/signalk-dsc#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/sailingnaturali/signalk-dsc/issues"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "lib"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }