@sailingnaturali/signalk-dsc 0.1.3 → 0.3.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 +26 -0
- package/index.js +50 -5
- package/lib/dsc.js +21 -1
- package/lib/format.js +1 -0
- package/lib/snapshot.js +98 -0
- package/lib/store.js +11 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -50,10 +50,22 @@ For every DSC call heard by a connected radio:
|
|
|
50
50
|
- **Remote-vessel deltas** — the caller's `navigation.position` (and a distress
|
|
51
51
|
notification) under `vessels.urn:mrn:imo:mmsi:<caller>`, so chartplotters can
|
|
52
52
|
show where the call came from.
|
|
53
|
+
- Every stored call carries an `ownShip` snapshot of the moment it arrived —
|
|
54
|
+
position, course, heading, speed, wind, pressure, and (when a source publishes them)
|
|
55
|
+
sea state, visibility, and cloud coverage. Absent sensor, absent field.
|
|
56
|
+
- Logbook entries are written with `vhf: "70"` (DSC is received on channel 70
|
|
57
|
+
by definition) plus structured `observations`; non-distress calls that
|
|
58
|
+
propose a working channel get it in the entry text and on the stored event
|
|
59
|
+
as `workingChannel`.
|
|
53
60
|
- **Optional ship's-log entries** via
|
|
54
61
|
[signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook) — a
|
|
55
62
|
GMDSS-style radio log of received distress/urgency/safety traffic.
|
|
56
63
|
|
|
64
|
+
> **Note:** Visibility on `environment.outside.visibility` is read as meters
|
|
65
|
+
> and converted to the logbook 0–9 fog scale; an integer value ≤ 9 is assumed
|
|
66
|
+
> to already be a fog-scale code, so sub-10-meter metric readings would be
|
|
67
|
+
> misread.
|
|
68
|
+
|
|
57
69
|
## Transports
|
|
58
70
|
|
|
59
71
|
- **NMEA 0183**: registers custom `DSC` and `DSE` sentence parsers (these replace
|
|
@@ -73,6 +85,7 @@ For every DSC call heard by a connected radio:
|
|
|
73
85
|
| `logbookRoutine` | `false` | Also log routine calls. |
|
|
74
86
|
| `logbookUrl` | `http://localhost:3000/plugins/signalk-logbook/logs` | |
|
|
75
87
|
| `logbookToken` | _empty_ | SignalK access token; logbook writes are skipped without one (plugin routes are auth-gated). |
|
|
88
|
+
| `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). |
|
|
76
89
|
|
|
77
90
|
## Trying it without a radio
|
|
78
91
|
|
|
@@ -129,6 +142,19 @@ $CDDSC,12,3380400790,12,05,00,1423108312,2019,,,S,E*69
|
|
|
129
142
|
$CDDSE,1,1,A,3380400790,00,45894494*1B
|
|
130
143
|
```
|
|
131
144
|
|
|
145
|
+
### Clearing an alarm
|
|
146
|
+
|
|
147
|
+
A received distress/urgency/safety call raises `notifications.dsc.<category>` and is
|
|
148
|
+
re-raised for up to an hour across server restarts. To clear an active alarm — dropping
|
|
149
|
+
the live notification and stopping the restart re-raise:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
SIGNALK_TOKEN=<readwrite-token> npm run clear-dsc -- --category distress
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`--category all` clears all three. Clearing is a write, so it needs a readwrite token
|
|
156
|
+
(the same one used to fire a test MOB). A new incoming call still alarms normally.
|
|
157
|
+
|
|
132
158
|
## Limitations
|
|
133
159
|
|
|
134
160
|
- Distress relays, acknowledgements, and cancellations are stored (with
|
package/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const { parseDse, refinePosition } = require('./lib/dse');
|
|
|
29
29
|
const { normalizePgn129808 } = require('./lib/pgn129808');
|
|
30
30
|
const { EventStore } = require('./lib/store');
|
|
31
31
|
const { buildMessage, buildLogbookText } = require('./lib/format');
|
|
32
|
+
const { captureOwnShip, buildObservations, unwrap } = require('./lib/snapshot');
|
|
32
33
|
|
|
33
34
|
const DSC_PGN = 129808;
|
|
34
35
|
const NOTIFICATION_STATES = { distress: 'emergency', urgency: 'alarm', safety: 'alert' };
|
|
@@ -77,6 +78,20 @@ module.exports = function makePlugin(app) {
|
|
|
77
78
|
description: 'Plugin routes are auth-gated; without a token the logbook write is skipped.',
|
|
78
79
|
default: '',
|
|
79
80
|
},
|
|
81
|
+
snapshotPaths: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
title: 'Extra own-ship paths to snapshot on each call',
|
|
84
|
+
description:
|
|
85
|
+
'Each entry adds a field to the stored event\'s ownShip block (position, course, speed, wind, pressure, sea state, visibility and cloud coverage are always attempted).',
|
|
86
|
+
default: [],
|
|
87
|
+
items: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
field: { type: 'string', title: 'Field name in ownShip' },
|
|
91
|
+
path: { type: 'string', title: 'SignalK self path' },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
80
95
|
},
|
|
81
96
|
};
|
|
82
97
|
|
|
@@ -85,10 +100,6 @@ module.exports = function makePlugin(app) {
|
|
|
85
100
|
let started = false;
|
|
86
101
|
let reannounceTimer = null;
|
|
87
102
|
|
|
88
|
-
function unwrap(node) {
|
|
89
|
-
return node && typeof node === 'object' && 'value' in node ? node.value : node;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
103
|
function selfMmsi() {
|
|
93
104
|
const value = unwrap(app.getSelfPath('mmsi'));
|
|
94
105
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -142,6 +153,15 @@ module.exports = function makePlugin(app) {
|
|
|
142
153
|
});
|
|
143
154
|
}
|
|
144
155
|
|
|
156
|
+
/** Clear an active DSC alarm: drop the live notification from our own source
|
|
157
|
+
* and stamp the stored events so the restart reannounce skips them. */
|
|
158
|
+
function clearCategory(category) {
|
|
159
|
+
app.handleMessage(plugin.id, {
|
|
160
|
+
updates: [{ values: [{ path: `notifications.dsc.${category}`, value: null }] }],
|
|
161
|
+
});
|
|
162
|
+
store.markCleared((e) => e.category === category, new Date().toISOString());
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
function shouldLogbook(event) {
|
|
146
166
|
if (options.logbookEnabled === false) return false;
|
|
147
167
|
if (!options.logbookToken) return false;
|
|
@@ -152,6 +172,7 @@ module.exports = function makePlugin(app) {
|
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
async function postLogbook(event) {
|
|
175
|
+
const observations = buildObservations(event.ownShip);
|
|
155
176
|
const res = await fetch(options.logbookUrl, {
|
|
156
177
|
method: 'POST',
|
|
157
178
|
headers: {
|
|
@@ -161,7 +182,14 @@ module.exports = function makePlugin(app) {
|
|
|
161
182
|
Authorization: `Bearer ${options.logbookToken}`,
|
|
162
183
|
Cookie: `JAUTHENTICATION=${options.logbookToken}`,
|
|
163
184
|
},
|
|
164
|
-
body: JSON.stringify({
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
text: buildLogbookText(event, messageContext(event)),
|
|
187
|
+
ago: 0,
|
|
188
|
+
category: 'radio',
|
|
189
|
+
// DSC is received on VHF channel 70 by definition.
|
|
190
|
+
vhf: '70',
|
|
191
|
+
...(observations ? { observations } : {}),
|
|
192
|
+
}),
|
|
165
193
|
});
|
|
166
194
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
167
195
|
}
|
|
@@ -194,6 +222,11 @@ module.exports = function makePlugin(app) {
|
|
|
194
222
|
return duplicate;
|
|
195
223
|
}
|
|
196
224
|
|
|
225
|
+
// Own-ship context at receive time — the forensic record of the moment
|
|
226
|
+
// the call arrived. First receipt only: repeats keep the original.
|
|
227
|
+
const ownShip = captureOwnShip(app, options.snapshotPaths);
|
|
228
|
+
if (ownShip) event.ownShip = ownShip;
|
|
229
|
+
|
|
197
230
|
store.add(event);
|
|
198
231
|
notify(event);
|
|
199
232
|
if (shouldLogbook(event)) {
|
|
@@ -330,6 +363,17 @@ module.exports = function makePlugin(app) {
|
|
|
330
363
|
app.emitPropertyValue('nmea0183sentenceParser', { sentence: 'DSE', parser: dseParser });
|
|
331
364
|
app.on('N2KAnalyzerOut', onPgn);
|
|
332
365
|
|
|
366
|
+
// Let an operator clear an active DSC alarm: a PUT to the notification path
|
|
367
|
+
// drops the live alert and marks the stored call(s) so a restart will not
|
|
368
|
+
// re-raise it. The readwrite device token authorizes this write.
|
|
369
|
+
for (const category of Object.keys(NOTIFICATION_STATES)) {
|
|
370
|
+
// Value ignored: any PUT to these paths means "clear" — no partial-update semantics.
|
|
371
|
+
app.registerPutHandler('vessels.self', `notifications.dsc.${category}`, () => {
|
|
372
|
+
clearCategory(category);
|
|
373
|
+
return { state: 'COMPLETED', statusCode: 200 };
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
333
377
|
started = true;
|
|
334
378
|
|
|
335
379
|
// Survive server restarts mid-incident: re-raise the newest alert per
|
|
@@ -345,6 +389,7 @@ module.exports = function makePlugin(app) {
|
|
|
345
389
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
346
390
|
const event = events[i];
|
|
347
391
|
if (!NOTIFICATION_STATES[event.category] || reannounced.has(event.category)) continue;
|
|
392
|
+
if (event.clearedAt) continue; // operator-cleared: never resurrect
|
|
348
393
|
const at = Date.parse(event.lastReceivedAt || event.receivedAt);
|
|
349
394
|
if (now - at <= REANNOUNCE_WINDOW_MS) {
|
|
350
395
|
notify(event);
|
package/lib/dsc.js
CHANGED
|
@@ -88,6 +88,23 @@ function parseUtcTime(raw) {
|
|
|
88
88
|
return `${t.substring(0, 2)}:${t.substring(2, 4)}`;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// VHF channel plausibility: 1–99 (international) or the 4-digit simplex
|
|
92
|
+
// forms (10NN / 20NN). The field arrives over the air unvalidated — anything
|
|
93
|
+
// that does not normalize to one of these shapes is dropped.
|
|
94
|
+
function parseChannel(raw) {
|
|
95
|
+
if (typeof raw !== 'string' || !/^\d{1,6}$/.test(raw.trim())) return undefined;
|
|
96
|
+
const t = raw.trim();
|
|
97
|
+
// ITU-R M.493 frequency field: a leading 9 on a 6-digit value marks
|
|
98
|
+
// "channel number follows", zero-padded (900072 = channel 72).
|
|
99
|
+
const digits = /^9\d{5}$/.test(t) ? t.slice(1) : t;
|
|
100
|
+
const n = Number(digits);
|
|
101
|
+
if (!Number.isInteger(n)) return undefined;
|
|
102
|
+
if ((n >= 1 && n <= 99) || (n >= 1001 && n <= 1099) || (n >= 2001 && n <= 2099)) {
|
|
103
|
+
return String(n);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
/**
|
|
92
109
|
* Parse the comma-split fields of a $--DSC sentence (sentence id and checksum
|
|
93
110
|
* already stripped) into a partial DSC event. Tolerant by design: anything we
|
|
@@ -127,6 +144,9 @@ function parseDsc(parts) {
|
|
|
127
144
|
if (distress || field(parts, 3) === TELECOMMAND_SHIP_POSITION) {
|
|
128
145
|
event.position = parsePosition(field(parts, 5));
|
|
129
146
|
event.utcTime = parseUtcTime(field(parts, 6));
|
|
147
|
+
} else {
|
|
148
|
+
const channel = parseChannel(field(parts, 5));
|
|
149
|
+
if (channel) event.workingChannel = channel;
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
const ack = field(parts, 9);
|
|
@@ -136,4 +156,4 @@ function parseDsc(parts) {
|
|
|
136
156
|
return event;
|
|
137
157
|
}
|
|
138
158
|
|
|
139
|
-
module.exports = { parseDsc, parsePosition, parseMmsi, parseUtcTime, NATURES };
|
|
159
|
+
module.exports = { parseDsc, parsePosition, parseMmsi, parseUtcTime, parseChannel, NATURES };
|
package/lib/format.js
CHANGED
|
@@ -87,6 +87,7 @@ function buildLogbookText(event, { ownPosition, vesselName } = {}) {
|
|
|
87
87
|
} else if (event.utcTime) {
|
|
88
88
|
parts.push(`reported at ${event.utcTime} UTC`);
|
|
89
89
|
}
|
|
90
|
+
if (event.workingChannel) parts.push(`proposed working channel ${event.workingChannel}`);
|
|
90
91
|
if (event.source) parts.push(`via ${event.source}`);
|
|
91
92
|
return `[DSC] ${parts.join('. ')}`;
|
|
92
93
|
}
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Own-ship context snapshot, taken at DSC call receive time.
|
|
5
|
+
*
|
|
6
|
+
* The stored event is the forensic record of the moment a call arrived; the
|
|
7
|
+
* snapshot answers "what was our situation when we heard it". Values are
|
|
8
|
+
* stored exactly as SignalK provides them (rad, m/s, Pa, meters) — absent
|
|
9
|
+
* sensor, absent field, never fabricated. Conversion to logbook units
|
|
10
|
+
* happens only in buildObservations.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SNAPSHOT_FIELDS = [
|
|
14
|
+
{ field: 'position', path: 'navigation.position' },
|
|
15
|
+
{ field: 'cog', path: 'navigation.courseOverGroundTrue' },
|
|
16
|
+
{ field: 'sog', path: 'navigation.speedOverGround' },
|
|
17
|
+
{ field: 'heading', path: 'navigation.headingTrue' },
|
|
18
|
+
{ field: 'wind.speed', path: 'environment.wind.speedOverGround' },
|
|
19
|
+
{ field: 'wind.direction', path: 'environment.wind.directionTrue' },
|
|
20
|
+
{ field: 'pressure', path: 'environment.outside.pressure' },
|
|
21
|
+
// signalk-logbook conventions (no SignalK spec paths exist for these).
|
|
22
|
+
{ field: 'seaState', path: 'environment.water.swell.state' },
|
|
23
|
+
{ field: 'visibility', path: 'environment.outside.visibility' },
|
|
24
|
+
{ field: 'cloudCoverage', path: 'environment.outside.cloudCoverage' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const UNSAFE_KEY = /^(__proto__|constructor|prototype)$/;
|
|
28
|
+
|
|
29
|
+
function unwrap(node) {
|
|
30
|
+
return node && typeof node === 'object' && 'value' in node ? node.value : node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read the default + configured paths off the data model. Best-effort:
|
|
34
|
+
* a throwing read skips that field. Returns undefined when empty. */
|
|
35
|
+
function captureOwnShip(app, extraFields = []) {
|
|
36
|
+
const snapshot = {};
|
|
37
|
+
const fields = DEFAULT_SNAPSHOT_FIELDS.concat(Array.isArray(extraFields) ? extraFields : []);
|
|
38
|
+
for (const entry of fields) {
|
|
39
|
+
if (!entry || typeof entry.field !== 'string' || typeof entry.path !== 'string') continue;
|
|
40
|
+
const keys = entry.field.split('.');
|
|
41
|
+
if (keys.some((k) => !k || UNSAFE_KEY.test(k))) continue;
|
|
42
|
+
let value;
|
|
43
|
+
try {
|
|
44
|
+
value = unwrap(app.getSelfPath(entry.path));
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Decouple from the live data model and refuse anything that could
|
|
49
|
+
// break the JSON store on the alarm-critical receive path.
|
|
50
|
+
try {
|
|
51
|
+
value = JSON.parse(JSON.stringify(value));
|
|
52
|
+
} catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (value === undefined || value === null) continue;
|
|
56
|
+
let target = snapshot;
|
|
57
|
+
for (const key of keys.slice(0, -1)) {
|
|
58
|
+
if (typeof target[key] !== 'object' || target[key] === null) target[key] = {};
|
|
59
|
+
target = target[key];
|
|
60
|
+
}
|
|
61
|
+
target[keys[keys.length - 1]] = value;
|
|
62
|
+
}
|
|
63
|
+
return Object.keys(snapshot).length ? snapshot : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Upper bounds (meters, exclusive) for fog-scale codes 0–8; ≥50 km is 9.
|
|
67
|
+
const FOG_SCALE_METERS = [50, 200, 500, 1000, 2000, 4000, 10000, 20000, 50000];
|
|
68
|
+
|
|
69
|
+
/** Meters → logbook fog-scale 0–9. A small integer (≤9) is assumed to
|
|
70
|
+
* already be a fog-scale code and passes through. */
|
|
71
|
+
function visibilityToFogScale(value) {
|
|
72
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
|
|
73
|
+
if (Number.isInteger(value) && value <= 9) return value;
|
|
74
|
+
const idx = FOG_SCALE_METERS.findIndex((limit) => value < limit);
|
|
75
|
+
return idx === -1 ? 9 : idx;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** signalk-logbook observations block from a snapshot. Only keys with valid
|
|
79
|
+
* values; undefined when there are none. */
|
|
80
|
+
function buildObservations(ownShip) {
|
|
81
|
+
if (!ownShip) return undefined;
|
|
82
|
+
const obs = {};
|
|
83
|
+
if (Number.isInteger(ownShip.seaState) && ownShip.seaState >= 0 && ownShip.seaState <= 9) {
|
|
84
|
+
obs.seaState = ownShip.seaState;
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
Number.isInteger(ownShip.cloudCoverage) &&
|
|
88
|
+
ownShip.cloudCoverage >= 0 &&
|
|
89
|
+
ownShip.cloudCoverage <= 8
|
|
90
|
+
) {
|
|
91
|
+
obs.cloudCoverage = ownShip.cloudCoverage;
|
|
92
|
+
}
|
|
93
|
+
const visibility = visibilityToFogScale(ownShip.visibility);
|
|
94
|
+
if (visibility !== undefined) obs.visibility = visibility;
|
|
95
|
+
return Object.keys(obs).length ? obs : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { captureOwnShip, buildObservations, visibilityToFogScale, unwrap, DEFAULT_SNAPSHOT_FIELDS };
|
package/lib/store.js
CHANGED
|
@@ -76,6 +76,17 @@ class EventStore {
|
|
|
76
76
|
return this.events.find((e) => e.id === id);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Stamp `clearedAt` on every matching event not already cleared. Returns the
|
|
80
|
+
* count newly stamped; compacts once. */
|
|
81
|
+
markCleared(predicate, at) {
|
|
82
|
+
let touched = 0;
|
|
83
|
+
for (const e of this.events) {
|
|
84
|
+
if (predicate(e) && !e.clearedAt) { e.clearedAt = at; touched += 1; }
|
|
85
|
+
}
|
|
86
|
+
if (touched) this._compact();
|
|
87
|
+
return touched;
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
/** Newest event matching `predicate` received within `windowMs` of `nowMs`. */
|
|
80
91
|
findRecent(predicate, nowMs, windowMs) {
|
|
81
92
|
for (let i = this.events.length - 1; i >= 0; i--) {
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sailingnaturali/signalk-dsc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
7
7
|
"test": "node --test",
|
|
8
|
-
"send-test-dsc": "node scripts/send-test-dsc.js"
|
|
8
|
+
"send-test-dsc": "node scripts/send-test-dsc.js",
|
|
9
|
+
"clear-dsc": "node scripts/clear-dsc-alarm.js"
|
|
9
10
|
},
|
|
10
11
|
"keywords": [
|
|
11
12
|
"signalk-node-server-plugin",
|