@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 +21 -0
- package/README.md +88 -0
- package/index.js +364 -0
- package/lib/dsc.js +132 -0
- package/lib/dse.js +63 -0
- package/lib/format.js +94 -0
- package/lib/geo.js +45 -0
- package/lib/pgn129808.js +132 -0
- package/lib/store.js +91 -0
- package/package.json +38 -0
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 };
|
package/lib/pgn129808.js
ADDED
|
@@ -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
|
+
}
|