@sailingnaturali/signalk-dsc 0.5.3 → 0.5.4
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/index.js +9 -4
- package/lib/dsc.js +2 -31
- package/package.json +4 -1
- package/lib/format.js +0 -102
- package/lib/geo.js +0 -45
- package/lib/markers.js +0 -80
- package/lib/snapshot.js +0 -98
- package/lib/store.js +0 -102
package/index.js
CHANGED
|
@@ -27,10 +27,15 @@ const path = require('node:path');
|
|
|
27
27
|
const { parseDsc } = require('./lib/dsc');
|
|
28
28
|
const { parseDse, refinePosition } = require('./lib/dse');
|
|
29
29
|
const { normalizePgn129808 } = require('./lib/pgn129808');
|
|
30
|
-
const {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const {
|
|
31
|
+
EventStore,
|
|
32
|
+
buildMarkerResourceSets,
|
|
33
|
+
buildMessage,
|
|
34
|
+
buildLogbookText,
|
|
35
|
+
captureOwnShip,
|
|
36
|
+
buildObservations,
|
|
37
|
+
unwrap,
|
|
38
|
+
} = require('@sailingnaturali/signalk-distress-core');
|
|
34
39
|
|
|
35
40
|
const DSC_PGN = 129808;
|
|
36
41
|
const NOTIFICATION_STATES = { distress: 'emergency', urgency: 'alarm', safety: 'alert' };
|
package/lib/dsc.js
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* 10: expansion flag — 'E' means a $--DSE sentence follows
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
const { NATURES, deviceBeaconFor } = require('@sailingnaturali/signalk-distress-core');
|
|
26
|
+
|
|
25
27
|
const FORMATS = {
|
|
26
28
|
'02': 'area',
|
|
27
29
|
'12': 'distressAlert',
|
|
@@ -38,40 +40,9 @@ const CATEGORIES = {
|
|
|
38
40
|
'12': 'distress',
|
|
39
41
|
};
|
|
40
42
|
|
|
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
43
|
// Telecommand 21 on a non-distress call = "ship position" — field 5 holds a position.
|
|
57
44
|
const TELECOMMAND_SHIP_POSITION = '21';
|
|
58
45
|
|
|
59
|
-
// AIS device-beacon MMSI prefixes (ITU 97x). A modern SART / MOB / EPIRB can
|
|
60
|
-
// send a DSC distress directly; the prefix lets a consumer correlate it with
|
|
61
|
-
// the matching AIS target.
|
|
62
|
-
const DEVICE_BEACONS = {
|
|
63
|
-
'970': 'sart',
|
|
64
|
-
'972': 'mob',
|
|
65
|
-
'974': 'epirb',
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// Tag the AIS device class (sart/mob/epirb) for a 97x device-beacon MMSI, else
|
|
69
|
-
// undefined. Shared with the N2K (PGN 129808) path so both transports agree.
|
|
70
|
-
function deviceBeaconFor(mmsi) {
|
|
71
|
-
if (typeof mmsi !== 'string') return undefined;
|
|
72
|
-
return DEVICE_BEACONS[mmsi.substring(0, 3)];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
46
|
// Resolve a nature-of-distress code to its name. The code arrives over the air
|
|
76
47
|
// unsanitised: only a clean 1–2 digit numeric code may reach the NATURES lookup
|
|
77
48
|
// or the code-NN fallback. A key like "__proto__" would otherwise resolve to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sailingnaturali/signalk-dsc",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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": {
|
|
@@ -47,5 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
49
|
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@sailingnaturali/signalk-distress-core": "^0.1.0"
|
|
50
53
|
}
|
|
51
54
|
}
|
package/lib/format.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
const lead = event.relay ? 'DSC distress relay' : 'DSC distress alert';
|
|
68
|
-
return `${lead}: ${who}, ${nature}, ${where}. Monitor channel 16.`;
|
|
69
|
-
}
|
|
70
|
-
const kind = event.category === 'unknown' ? 'call' : `${event.category} call`;
|
|
71
|
-
return `DSC ${kind}: ${who}, ${where}.`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function buildLogbookText(event, { ownPosition, vesselName } = {}) {
|
|
75
|
-
const parts = [];
|
|
76
|
-
const name = vesselName ? `${vesselName} (MMSI ${event.mmsi || 'unknown'})` : `MMSI ${event.mmsi || 'unknown'}`;
|
|
77
|
-
if (event.category === 'distress') {
|
|
78
|
-
const nature = NATURE_TEXT[event.natureOfDistress] || event.natureOfDistress || 'undesignated distress';
|
|
79
|
-
if (event.relay) {
|
|
80
|
-
// Name the casualty (distressedMmsi) and the relaying station (name).
|
|
81
|
-
const casualty = event.distressedMmsi ? `MMSI ${event.distressedMmsi}` : 'an unidentified vessel';
|
|
82
|
-
parts.push(`DISTRESS RELAY from ${name} reporting ${casualty}: ${nature}`);
|
|
83
|
-
} else {
|
|
84
|
-
parts.push(`DISTRESS alert from ${name}: ${nature}`);
|
|
85
|
-
}
|
|
86
|
-
} else {
|
|
87
|
-
parts.push(`${event.category} call from ${name}`);
|
|
88
|
-
}
|
|
89
|
-
if (event.position) {
|
|
90
|
-
let pos = `position ${formatPosition(event.position)}`;
|
|
91
|
-
if (event.utcTime) pos += ` at ${event.utcTime} UTC`;
|
|
92
|
-
if (ownPosition) pos += `, ${wherePhrase(event, ownPosition, { spoken: false })}`;
|
|
93
|
-
parts.push(pos);
|
|
94
|
-
} else if (event.utcTime) {
|
|
95
|
-
parts.push(`reported at ${event.utcTime} UTC`);
|
|
96
|
-
}
|
|
97
|
-
if (event.workingChannel) parts.push(`proposed working channel ${event.workingChannel}`);
|
|
98
|
-
if (event.source) parts.push(`via ${event.source}`);
|
|
99
|
-
return `[DSC] ${parts.join('. ')}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
module.exports = { buildMessage, buildLogbookText, formatPosition };
|
package/lib/geo.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
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/markers.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
// Freeboard's isResourceSet() requires this exact discriminator, else it
|
|
69
|
-
// filters the resource out and the chart layer renders nothing.
|
|
70
|
-
type: 'ResourceSet',
|
|
71
|
-
name: `DSC — ${category}`,
|
|
72
|
-
description: `DSC ${category} calls heard on channel 70`,
|
|
73
|
-
styles: { default: { width: 2, stroke: color, fill: color } },
|
|
74
|
-
values: { type: 'FeatureCollection', features },
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
return out;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
module.exports = { buildMarkerResourceSets, CATEGORY_COLORS };
|
package/lib/snapshot.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
/** 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
|
-
|
|
90
|
-
/** Newest event matching `predicate` received within `windowMs` of `nowMs`. */
|
|
91
|
-
findRecent(predicate, nowMs, windowMs) {
|
|
92
|
-
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
93
|
-
const e = this.events[i];
|
|
94
|
-
const age = nowMs - Date.parse(e.receivedAt);
|
|
95
|
-
if (age > windowMs) return undefined;
|
|
96
|
-
if (age >= 0 && predicate(e)) return e;
|
|
97
|
-
}
|
|
98
|
-
return undefined;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
module.exports = { EventStore };
|