@sailingnaturali/signalk-dsc 0.5.7 → 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 +44 -0
- package/index.js +68 -0
- package/lib/dscwatch.js +48 -0
- package/package.json +2 -2
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
|
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;
|
package/lib/dscwatch.js
ADDED
|
@@ -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.
|
|
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.
|
|
52
|
+
"@sailingnaturali/signalk-distress-core": "^0.5.1"
|
|
53
53
|
}
|
|
54
54
|
}
|