@sailingnaturali/signalk-dsc 0.3.1 → 0.5.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
@@ -33,6 +33,16 @@ For every DSC call heard by a connected radio:
33
33
  `GET /signalk/v2/api/resources/dsc-calls` (anonymously readable when the server
34
34
  allows read-only access). Raw sentence/PGN is always kept alongside the parsed
35
35
  fields: time, MMSI, category, nature of distress, position, UTC time.
36
+ - **A chart-marker layer** — `GET /signalk/v2/api/resources/dsc-call-markers`
37
+ serves logged calls as [Freeboard-SK](https://github.com/SignalK/freeboard-sk)
38
+ ResourceSets, one per category (distress/urgency/safety/routine), each a
39
+ GeoJSON `FeatureCollection` of Point markers whose popup carries the nature,
40
+ caller name/MMSI, and times. To use it in Freeboard: Settings → Resources
41
+ (Custom) → add resource type `dsc-call-markers`, reload, then toggle the
42
+ per-category layers. Non-distress calls drop off after `markerWindowHours`
43
+ (default 24); an active (un-cleared) distress call stays until you clear the alarm.
44
+ This is the *detail* layer — distinct from the prominent live SaR marker a
45
+ distress call also draws via the `sar.` context (see Remote-vessel deltas).
36
46
  - **Alarms under your own vessel** — `notifications.dsc.distress` (state
37
47
  `emergency`), `notifications.dsc.urgency` (`alarm`), `notifications.dsc.safety`
38
48
  (`alert`). Routine calls never alarm. Repeated re-transmissions of the same
@@ -48,8 +58,12 @@ For every DSC call heard by a connected radio:
48
58
  Full detail (MMSI, coordinates, reported time, transport) goes to the call
49
59
  log and the logbook entry instead, so TTS pipelines stay terse.
50
60
  - **Remote-vessel deltas** — the caller's `navigation.position` (and a distress
51
- notification) under `vessels.urn:mrn:imo:mmsi:<caller>`, so chartplotters can
52
- show where the call came from.
61
+ notification) under the caller's context, so chartplotters can show where the
62
+ call came from. A **distress** caller is emitted under the Search-and-Rescue
63
+ context `sar.urn:mrn:imo:mmsi:<caller>` — which plotters like
64
+ [Freeboard-SK](https://github.com/SignalK/freeboard-sk) render as a distress
65
+ (SaR) target rather than an ordinary AIS vessel; every other category stays
66
+ under `vessels.urn:mrn:imo:mmsi:<caller>`.
53
67
  - Every stored call carries an `ownShip` snapshot of the moment it arrived —
54
68
  position, course, heading, speed, wind, pressure, and (when a source publishes them)
55
69
  sea state, visibility, and cloud coverage. Absent sensor, absent field.
@@ -81,6 +95,7 @@ For every DSC call heard by a connected radio:
81
95
  | Option | Default | Notes |
82
96
  | --- | --- | --- |
83
97
  | `maxEvents` | `1000` | Oldest calls dropped beyond this. |
98
+ | `markerWindowHours` | `24` | Non-distress calls leave the `dsc-call-markers` chart layer after this many hours; active distress stays until cleared. |
84
99
  | `logbookEnabled` | `true` | Requires signalk-logbook and a token. |
85
100
  | `logbookRoutine` | `false` | Also log routine calls. |
86
101
  | `logbookUrl` | `http://localhost:3000/plugins/signalk-logbook/logs` | |
package/index.js CHANGED
@@ -28,6 +28,7 @@ const { parseDsc } = require('./lib/dsc');
28
28
  const { parseDse, refinePosition } = require('./lib/dse');
29
29
  const { normalizePgn129808 } = require('./lib/pgn129808');
30
30
  const { EventStore } = require('./lib/store');
31
+ const { buildMarkerResourceSets } = require('./lib/markers');
31
32
  const { buildMessage, buildLogbookText } = require('./lib/format');
32
33
  const { captureOwnShip, buildObservations, unwrap } = require('./lib/snapshot');
33
34
 
@@ -56,6 +57,13 @@ module.exports = function makePlugin(app) {
56
57
  description: 'Oldest calls are dropped beyond this count.',
57
58
  default: 1000,
58
59
  },
60
+ markerWindowHours: {
61
+ type: 'number',
62
+ title: 'Chart marker window (hours)',
63
+ description:
64
+ 'Non-distress calls drop off the dsc-call-markers chart layer after this many hours. Active (un-cleared) distress calls always remain.',
65
+ default: 24,
66
+ },
59
67
  logbookEnabled: {
60
68
  type: 'boolean',
61
69
  title: 'Write received calls to the ship\'s log (signalk-logbook)',
@@ -244,8 +252,14 @@ module.exports = function makePlugin(app) {
244
252
  return event;
245
253
  }
246
254
 
247
- function remoteContext(mmsi) {
248
- return `vessels.urn:mrn:imo:mmsi:${mmsi}`;
255
+ // The SignalK context the caller's position is emitted under. A distress
256
+ // caller is a vessel in extremis, so it goes under the Search-and-Rescue
257
+ // context (`sar.`) — which chartplotters (e.g. Freeboard-SK) render as a
258
+ // distress/SaR target rather than an ordinary AIS vessel. Every other
259
+ // category stays under `vessels.` like any AIS contact.
260
+ function callerContext(category, mmsi) {
261
+ const prefix = category === 'distress' ? 'sar' : 'vessels';
262
+ return `${prefix}.urn:mrn:imo:mmsi:${mmsi}`;
249
263
  }
250
264
 
251
265
  // Custom sentence parsers override the stock hooks (a superset of the
@@ -263,8 +277,8 @@ module.exports = function makePlugin(app) {
263
277
  receivedAt: input.tags && input.tags.timestamp,
264
278
  });
265
279
 
266
- // Upstream-compatible delta under the sender's context so chartplotters
267
- // and AIS-style consumers see the caller's position.
280
+ // Delta under the caller's context so chartplotters and AIS-style
281
+ // consumers see the caller's position (distress → SaR target).
268
282
  if (parsed.mmsi) {
269
283
  const values = [];
270
284
  if (parsed.position) {
@@ -281,7 +295,10 @@ module.exports = function makePlugin(app) {
281
295
  });
282
296
  }
283
297
  if (values.length) {
284
- return { context: remoteContext(parsed.mmsi), updates: [{ values }] };
298
+ return {
299
+ context: callerContext(parsed.category, parsed.mmsi),
300
+ updates: [{ values }],
301
+ };
285
302
  }
286
303
  }
287
304
  } catch (err) {
@@ -306,7 +323,7 @@ module.exports = function makePlugin(app) {
306
323
  const refined = refinePosition(target.position, ext);
307
324
  store.update(target.id, { position: refined, positionResolution: 'enhanced' });
308
325
  return {
309
- context: remoteContext(ext.mmsi),
326
+ context: callerContext(target.category, ext.mmsi),
310
327
  updates: [{ values: [{ path: 'navigation.position', value: refined }] }],
311
328
  };
312
329
  } catch (err) {
@@ -327,6 +344,7 @@ module.exports = function makePlugin(app) {
327
344
  plugin.start = function (opts) {
328
345
  options = {
329
346
  maxEvents: 1000,
347
+ markerWindowHours: 24,
330
348
  logbookEnabled: true,
331
349
  logbookRoutine: false,
332
350
  logbookUrl: 'http://localhost:3000/plugins/signalk-logbook/logs',
@@ -361,6 +379,33 @@ module.exports = function makePlugin(app) {
361
379
  },
362
380
  });
363
381
 
382
+ const buildSets = () =>
383
+ buildMarkerResourceSets(store.list(), {
384
+ now: Date.now(),
385
+ windowHours: options.markerWindowHours,
386
+ nameFor: vesselName,
387
+ });
388
+
389
+ app.registerResourceProvider({
390
+ type: 'dsc-call-markers',
391
+ methods: {
392
+ async listResources() {
393
+ return buildSets();
394
+ },
395
+ async getResource(id) {
396
+ const sets = buildSets();
397
+ if (!sets[id]) throw new Error(`No DSC calls in category: ${id}`);
398
+ return sets[id];
399
+ },
400
+ setResource() {
401
+ throw new Error('dsc-call-markers is read-only');
402
+ },
403
+ deleteResource() {
404
+ throw new Error('dsc-call-markers is read-only');
405
+ },
406
+ },
407
+ });
408
+
364
409
  app.emitPropertyValue('nmea0183sentenceParser', { sentence: 'DSC', parser: dscParser });
365
410
  app.emitPropertyValue('nmea0183sentenceParser', { sentence: 'DSE', parser: dseParser });
366
411
  app.on('N2KAnalyzerOut', onPgn);
package/lib/markers.js ADDED
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // Per-category marker colour, emitted as the ResourceSet `styles.default`.
4
+ // Freeboard styles a feature from styles[properties.styleRef] and falls back to
5
+ // styles.default, so one default per category colours all of that set's markers.
6
+ const CATEGORY_COLORS = {
7
+ distress: 'rgba(211,47,47,1)', // red
8
+ urgency: 'rgba(245,124,0,1)', // orange
9
+ safety: 'rgba(251,192,45,1)', // amber
10
+ routine: 'rgba(117,117,117,1)', // grey
11
+ };
12
+
13
+ const HOUR_MS = 60 * 60 * 1000;
14
+
15
+ // Should this call appear at `now`? Un-cleared distress is always shown — a
16
+ // MAYDAY must not age off the chart. Everything else (and cleared distress)
17
+ // must fall within `windowHours` of receipt.
18
+ function withinWindow(event, now, windowHours) {
19
+ if (event.category === 'distress' && !event.clearedAt) return true;
20
+ const received = Date.parse(event.receivedAt);
21
+ if (Number.isNaN(received)) return false;
22
+ return now - received <= windowHours * HOUR_MS;
23
+ }
24
+
25
+ function toFeature(event, nameFor) {
26
+ const mmsi = event.distressedMmsi || event.mmsi;
27
+ const vesselName = nameFor ? nameFor(mmsi) : undefined;
28
+ const properties = {
29
+ name: event.natureOfDistress
30
+ ? `${event.category}: ${event.natureOfDistress}`
31
+ : event.category,
32
+ category: event.category,
33
+ mmsi,
34
+ utcTime: event.utcTime,
35
+ receivedAt: event.receivedAt,
36
+ };
37
+ if (event.natureOfDistress) properties.natureOfDistress = event.natureOfDistress;
38
+ if (vesselName) properties.vesselName = vesselName;
39
+ return {
40
+ type: 'Feature',
41
+ geometry: {
42
+ type: 'Point',
43
+ coordinates: [event.position.longitude, event.position.latitude],
44
+ },
45
+ properties,
46
+ };
47
+ }
48
+
49
+ // Turn stored DSC call events into freeboard ResourceSets keyed by category.
50
+ // Empty categories are omitted.
51
+ function buildMarkerResourceSets(events, { now, windowHours = 24, nameFor } = {}) {
52
+ const buckets = {};
53
+ for (const event of events) {
54
+ if (
55
+ !event.position ||
56
+ typeof event.position.latitude !== 'number' ||
57
+ typeof event.position.longitude !== 'number'
58
+ )
59
+ continue;
60
+ if (!withinWindow(event, now, windowHours)) continue;
61
+ const category = event.category || 'routine';
62
+ (buckets[category] = buckets[category] || []).push(toFeature(event, nameFor));
63
+ }
64
+ const out = {};
65
+ for (const [category, features] of Object.entries(buckets)) {
66
+ const color = CATEGORY_COLORS[category] || CATEGORY_COLORS.routine;
67
+ out[category] = {
68
+ name: `DSC — ${category}`,
69
+ description: `DSC ${category} calls heard on channel 70`,
70
+ styles: { default: { width: 2, stroke: color, fill: color } },
71
+ values: { type: 'FeatureCollection', features },
72
+ };
73
+ }
74
+ return out;
75
+ }
76
+
77
+ module.exports = { buildMarkerResourceSets, CATEGORY_COLORS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingnaturali/signalk-dsc",
3
- "version": "0.3.1",
3
+ "version": "0.5.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": {