@sailingnaturali/signalk-dsc 0.5.6 → 0.6.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/README.md CHANGED
@@ -105,6 +105,50 @@ For every DSC call heard by a connected radio:
105
105
  | `logbookToken` | _empty_ | SignalK access token; logbook writes are skipped without one (plugin routes are auth-gated). |
106
106
  | `snapshotPaths` | `[]` | Extra `{ field, path }` pairs added to the `ownShip` snapshot on each stored call (position, course, heading, speed, wind, pressure, sea state, visibility and cloud coverage are always attempted). |
107
107
 
108
+ ## DSCWatch reporting
109
+
110
+ DSCWatch ([dscwatch.com](https://dscwatch.com)) is a crowdsourced network that
111
+ aggregates received DSC traffic from stations around the world. Participation is
112
+ **opt-in and off by default** — enabling it sends each received call to the network,
113
+ including your receiver's position, to help build coverage maps and distress logs.
114
+
115
+ ### Configuration
116
+
117
+ | Option | Default | Notes |
118
+ | --- | --- | --- |
119
+ | `dscwatchEnabled` | `false` | Must be set to `true` to send any reports. No data leaves the boat until this is on. |
120
+ | `dscwatchReceiverKey` | _empty_ | Leave blank to use an auto-generated UUID that persists in the plugin data directory (`dscwatch-receiver-key`). Enter your station's 9-digit MMSI if you want your reports attributed to a licensed station. |
121
+ | `dscwatchUrl` | `https://dscwatch.com/api/v1/report` | Override for local testing only; the receiver key is appended automatically. |
122
+
123
+ ### What leaves the boat
124
+
125
+ Each report contains only the call's parsed fields and raw sentence or PGN payload:
126
+ `receivedAt`, `source`, `category`, `format`, `raw`, `mmsi`, `position`,
127
+ `positionResolution`, `utcTime`, `natureOfDistress`, `distressedMmsi`,
128
+ `workingChannel`, `acknowledgement`, `deviceBeacon`, and the boolean flags `relay`,
129
+ `expansion`, `self`, `positionRefined` (only when `true`). The receiver's position
130
+ at the moment the call arrived is added as `ownPosition` when the server has a fix.
131
+
132
+ ### What never leaves the boat
133
+
134
+ The local-only fields — `ownShip` weather and sea-state snapshots, spoken message
135
+ text, `repeats`, `lastReceivedAt`, `clearedAt`, and internal event IDs — are never
136
+ sent.
137
+
138
+ ### Submission model
139
+
140
+ Every radio reception — including DSC auto-repeats and every `$DSE` position
141
+ refinement — is submitted as its own POST. The DSCWatch backend deduplicates repeated
142
+ transmissions of the same call; sending each one lets the network track propagation
143
+ and timing.
144
+
145
+ Undelivered reports (connectivity loss, server restart during delivery) queue on disk
146
+ at `dscwatch-queue.jsonl` in the plugin data directory and are retried when
147
+ connectivity returns. An HTTP 404 response (receiver key unknown to the service, or a
148
+ misconfigured `dscwatchUrl`) sets the plugin status once; subsequent reports are still
149
+ attempted and dropped until the receiver key or endpoint URL is corrected — a fixed
150
+ configuration heals automatically without a restart.
151
+
108
152
  ## Trying it without a radio
109
153
 
110
154
  ### Quick test script
@@ -212,6 +256,15 @@ broadcasts on behalf of a real, licensed MMSI). Until such hardware is common
212
256
  this stays out of scope and the plugin remains a passive receiver. If you have a
213
257
  radio that exposes a transmit interface, open an issue.
214
258
 
259
+ ## See also
260
+
261
+ DSC is the *alerting* signal — it tells you a vessel is in distress and roughly
262
+ where. For *finding* the casualty, pair this with the companion
263
+ [`@sailingnaturali/signalk-ais-distress`](https://github.com/sailingnaturali/signalk-ais-distress),
264
+ which alarms on AIS survival beacons (SART / MOB / EPIRB) and gives you a
265
+ position stream to home on. Both share the same 97x identity classes and build on
266
+ [`@sailingnaturali/signalk-distress-core`](https://github.com/sailingnaturali/signalk-distress-core).
267
+
215
268
  ## License
216
269
 
217
270
  MIT
package/index.js CHANGED
@@ -37,8 +37,13 @@ const {
37
37
  createNotifier,
38
38
  unwrap,
39
39
  writeLogbookEntry,
40
+ createReporter,
41
+ loadOrCreateReceiverKey,
40
42
  } = require('@sailingnaturali/signalk-distress-core');
41
43
 
44
+ const { buildReport } = require('./lib/dscwatch');
45
+ const { version } = require('./package.json');
46
+
42
47
  const DSC_PGN = 129808;
43
48
  const NOTIFICATION_STATES = { distress: 'emergency', urgency: 'alarm', safety: 'alert' };
44
49
  const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
@@ -107,10 +112,31 @@ module.exports = function makePlugin(app) {
107
112
  },
108
113
  },
109
114
  },
115
+ dscwatchEnabled: {
116
+ type: 'boolean',
117
+ title: 'Report received calls to DSCWatch.com',
118
+ description:
119
+ 'Opt-in: submit every received DSC call — including your receiver position — to the DSCWatch crowdsourced receiver network. Undelivered reports queue on disk and catch up when connectivity returns.',
120
+ default: false,
121
+ },
122
+ dscwatchReceiverKey: {
123
+ type: 'string',
124
+ title: 'DSCWatch receiver key',
125
+ description:
126
+ 'Leave blank to use an auto-generated station UUID (persisted in the plugin data directory), or enter this station\'s 9-digit MMSI for attribution.',
127
+ default: '',
128
+ },
129
+ dscwatchUrl: {
130
+ type: 'string',
131
+ title: 'DSCWatch endpoint',
132
+ description: 'Base report URL; the receiver key is appended. Override for testing only.',
133
+ default: 'https://dscwatch.com/api/v1/report',
134
+ },
110
135
  },
111
136
  };
112
137
 
113
138
  let store = null;
139
+ let reporter = null;
114
140
  let options = {};
115
141
  let started = false;
116
142
  let reannounceTimer = null;
@@ -143,6 +169,14 @@ module.exports = function makePlugin(app) {
143
169
  };
144
170
  }
145
171
 
172
+ // Fire-behind submission to DSCWatch (crowdsourced DSC receiver network).
173
+ // Called before/independent of the store dedupe: the API wants every radio
174
+ // repeat and every DSE refinement as its own POST — the backend dedupes.
175
+ function reportToDscwatch(event, extra) {
176
+ if (!reporter) return;
177
+ reporter.report(buildReport({ ...event, ...extra }, { ownPosition: selfPosition() }));
178
+ }
179
+
146
180
  // Notification plumbing (raise/clear/reannounce) is shared with
147
181
  // signalk-ais-distress via signalk-distress-core. A non-alarming category
148
182
  // (routine/unknown, not in NOTIFICATION_STATES) makes raise a no-op.
@@ -203,6 +237,8 @@ module.exports = function makePlugin(app) {
203
237
  };
204
238
  if (event.mmsi && event.mmsi === selfMmsi()) event.self = true;
205
239
 
240
+ reportToDscwatch(event);
241
+
206
242
  // Re-transmission of the same call (distress alerts auto-repeat until
207
243
  // acknowledged): bump the stored call, do not re-alarm. This matches on
208
244
  // mmsi+category+nature and ignores `clearedAt`, so an operator-cleared call
@@ -313,6 +349,11 @@ module.exports = function makePlugin(app) {
313
349
 
314
350
  const refined = refinePosition(target.position, ext);
315
351
  store.update(target.id, { position: refined, positionResolution: 'enhanced' });
352
+ reportToDscwatch(target, {
353
+ receivedAt: new Date(now).toISOString(),
354
+ raw: input.sentence,
355
+ positionRefined: true,
356
+ });
316
357
  return {
317
358
  context: callerContext(target.category, ext.mmsi),
318
359
  updates: [{ values: [{ path: 'navigation.position', value: refined }] }],
@@ -340,6 +381,9 @@ module.exports = function makePlugin(app) {
340
381
  logbookRoutine: false,
341
382
  logbookUrl: 'http://localhost:3000/plugins/signalk-logbook/logs',
342
383
  logbookToken: '',
384
+ dscwatchEnabled: false,
385
+ dscwatchReceiverKey: '',
386
+ dscwatchUrl: 'https://dscwatch.com/api/v1/report',
343
387
  ...opts,
344
388
  };
345
389
 
@@ -348,6 +392,26 @@ module.exports = function makePlugin(app) {
348
392
  maxEvents: options.maxEvents,
349
393
  });
350
394
 
395
+ if (options.dscwatchEnabled) {
396
+ try {
397
+ const receiverKey =
398
+ options.dscwatchReceiverKey.trim() ||
399
+ loadOrCreateReceiverKey(path.join(app.getDataDirPath(), 'dscwatch-receiver-key'));
400
+ reporter = createReporter({
401
+ url: `${options.dscwatchUrl.replace(/\/+$/, '')}/${receiverKey}`,
402
+ userAgent: `signalk-dsc/${version}`,
403
+ queueFile: path.join(app.getDataDirPath(), 'dscwatch-queue.jsonl'),
404
+ log: (msg) => app.debug(msg),
405
+ onPermanentError: (status) =>
406
+ app.setPluginStatus(`DSCWatch: receiver key rejected (HTTP ${status}) — check configuration`),
407
+ });
408
+ reporter.start();
409
+ } catch (err) {
410
+ app.error(`signalk-dsc: DSCWatch reporting disabled: ${err.message}`);
411
+ reporter = null;
412
+ }
413
+ }
414
+
351
415
  app.registerResourceProvider({
352
416
  type: 'dsc-calls',
353
417
  methods: {
@@ -432,6 +496,10 @@ module.exports = function makePlugin(app) {
432
496
  started = false;
433
497
  clearTimeout(reannounceTimer);
434
498
  app.removeListener('N2KAnalyzerOut', onPgn);
499
+ if (reporter) {
500
+ reporter.stop();
501
+ reporter = null;
502
+ }
435
503
  };
436
504
 
437
505
  return plugin;
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Map a normalized DSC event onto a DSCWatch report body
5
+ * (POST https://dscwatch.com/api/v1/report/<receiver-key>).
6
+ *
7
+ * Pure field-picker: send what we have, omit what we don't. The pick-list is
8
+ * also the privacy boundary — local-only fields (id, message, repeats,
9
+ * lastReceivedAt, ownShip, clearedAt) are never sent because they are never
10
+ * picked. Boolean flags go out only when true; parseDsc always materializes
11
+ * `expansion` as false and the API treats absence as false.
12
+ */
13
+
14
+ const FIELDS = [
15
+ 'receivedAt',
16
+ 'source',
17
+ 'category',
18
+ 'format',
19
+ 'raw',
20
+ 'mmsi',
21
+ 'position',
22
+ 'positionResolution',
23
+ 'utcTime',
24
+ 'natureOfDistress',
25
+ 'distressedMmsi',
26
+ 'deviceBeacon',
27
+ 'workingChannel',
28
+ 'acknowledgement',
29
+ ];
30
+
31
+ const FLAGS = ['relay', 'expansion', 'self', 'positionRefined'];
32
+
33
+ function buildReport(event, { ownPosition } = {}) {
34
+ const body = {};
35
+ for (const key of FIELDS) {
36
+ if (event[key] !== undefined) body[key] = event[key];
37
+ }
38
+ for (const key of FLAGS) {
39
+ if (event[key] === true) body[key] = true;
40
+ }
41
+ if (!body.format) body.format = 'unknown';
42
+ if (ownPosition && typeof ownPosition.latitude === 'number') {
43
+ body.ownPosition = { latitude: ownPosition.latitude, longitude: ownPosition.longitude };
44
+ }
45
+ return body;
46
+ }
47
+
48
+ module.exports = { buildReport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingnaturali/signalk-dsc",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
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
5
  "main": "index.js",
6
6
  "scripts": {
@@ -49,6 +49,6 @@
49
49
  "node": ">=18"
50
50
  },
51
51
  "dependencies": {
52
- "@sailingnaturali/signalk-distress-core": "^0.3.0"
52
+ "@sailingnaturali/signalk-distress-core": "^0.5.1"
53
53
  }
54
54
  }