@sailingnaturali/signalk-dsc 0.1.2 → 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 +13 -0
- package/index.js +29 -5
- package/lib/dsc.js +21 -1
- package/lib/format.js +1 -0
- package/lib/snapshot.js +98 -0
- package/package.json +1 -1
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({
|
|
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
|
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sailingnaturali/signalk-dsc",
|
|
3
|
-
"version": "0.
|
|
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": {
|