@sailingnaturali/signalk-dsc 0.1.3 → 0.2.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
 
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;
@@ -152,6 +163,7 @@ module.exports = function makePlugin(app) {
152
163
  }
153
164
 
154
165
  async function postLogbook(event) {
166
+ const observations = buildObservations(event.ownShip);
155
167
  const res = await fetch(options.logbookUrl, {
156
168
  method: 'POST',
157
169
  headers: {
@@ -161,7 +173,14 @@ module.exports = function makePlugin(app) {
161
173
  Authorization: `Bearer ${options.logbookToken}`,
162
174
  Cookie: `JAUTHENTICATION=${options.logbookToken}`,
163
175
  },
164
- body: JSON.stringify({ text: buildLogbookText(event, messageContext(event)), ago: 0, category: 'radio' }),
176
+ body: JSON.stringify({
177
+ text: buildLogbookText(event, messageContext(event)),
178
+ ago: 0,
179
+ category: 'radio',
180
+ // DSC is received on VHF channel 70 by definition.
181
+ vhf: '70',
182
+ ...(observations ? { observations } : {}),
183
+ }),
165
184
  });
166
185
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
167
186
  }
@@ -194,6 +213,11 @@ module.exports = function makePlugin(app) {
194
213
  return duplicate;
195
214
  }
196
215
 
216
+ // Own-ship context at receive time — the forensic record of the moment
217
+ // the call arrived. First receipt only: repeats keep the original.
218
+ const ownShip = captureOwnShip(app, options.snapshotPaths);
219
+ if (ownShip) event.ownShip = ownShip;
220
+
197
221
  store.add(event);
198
222
  notify(event);
199
223
  if (shouldLogbook(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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingnaturali/signalk-dsc",
3
- "version": "0.1.3",
3
+ "version": "0.2.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": {