@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 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({ text: buildLogbookText(event, messageContext(event)), ago: 0, category: 'radio' }),
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
  }
@@ -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.1.3",
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",