signalk-siparu 0.1.2 → 0.1.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/package.json +5 -1
- package/plugin/dist/config.js +17 -0
- package/plugin/dist/index.js +75 -10
- package/plugin/dist/live.js +427 -0
- package/plugin/dist/metrics.js +172 -28
- package/plugin/dist/query.js +88 -0
- package/plugin/dist/rest.js +5 -0
- package/plugin/dist/rollup.js +60 -2
- package/plugin/dist/uplink.js +28 -0
- package/public/assets/{Logbook-Bf6IAjhz.js → Logbook-CTTfsXYY.js} +1 -1
- package/public/assets/{Map-D_WYyHPC.js → Map-VUnmgyUz.js} +1 -1
- package/public/assets/{Voyage-TvNQPRR8.js → Voyage-DZARsTUN.js} +1 -1
- package/public/assets/index-4Gh87-xZ.js +21 -0
- package/public/assets/index-DcLYXN7O.css +1 -0
- package/public/assets/{style-DG0JTBaO.js → style-CMKlLfwY.js} +1 -1
- package/public/favicon.svg +11 -5
- package/public/index.html +2 -2
- package/public/manifest.webmanifest +1 -1
- package/public/assets/index-BGstqOHG.css +0 -1
- package/public/assets/index-DY-sE4Xu.js +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-siparu",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in PWA.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"signalk-node-server-plugin",
|
|
@@ -37,10 +37,14 @@
|
|
|
37
37
|
"build:all": "npm run build && npm run build:webapp",
|
|
38
38
|
"ci:build": "npm --prefix webapp ci && npm run build:all"
|
|
39
39
|
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"ws": "^8.21.0"
|
|
42
|
+
},
|
|
40
43
|
"devDependencies": {
|
|
41
44
|
"@signalk/server-api": "^2.30.0",
|
|
42
45
|
"@types/express": "^4.17.21",
|
|
43
46
|
"@types/node": "^20.19.0",
|
|
47
|
+
"@types/ws": "^8.18.1",
|
|
44
48
|
"typescript": "^5.9.0",
|
|
45
49
|
"vitest": "^4.1.0"
|
|
46
50
|
}
|
package/plugin/dist/config.js
CHANGED
|
@@ -34,6 +34,23 @@ exports.INTERNAL = {
|
|
|
34
34
|
samplePeriodMs: 2000,
|
|
35
35
|
/** A path value older than this loses priority in fallback merges (TWS, depth). */
|
|
36
36
|
staleMs: 30_000,
|
|
37
|
+
/**
|
|
38
|
+
* On the recording path only: a value whose source last spoke longer ago than
|
|
39
|
+
* this is written as null rather than standing in for a measurement nobody
|
|
40
|
+
* took. The live screen keeps showing it (last-known-wins) and ages it there.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not staleMs: 30s is the fallback-priority threshold, and at the
|
|
43
|
+
* default snapshotSeconds of 60 it would null a barometer that is merely
|
|
44
|
+
* unhurried. Two minutes is generous on purpose, because the server offers no
|
|
45
|
+
* guarantee to be precise against: subscribing with policy 'fixed' does not
|
|
46
|
+
* republish the last value every period - it buffers whatever actually arrives
|
|
47
|
+
* (bufferWithTime), so a silent source stays silent on the wire. What makes a
|
|
48
|
+
* healthy path keep reporting is the sensor itself resending its reading, which
|
|
49
|
+
* NMEA hardware does and a change-of-state source may not. So this is a
|
|
50
|
+
* deliberately loose assumption about instruments, not a tight read of a
|
|
51
|
+
* protocol: it is set to catch the sensor that died, and to let the slow one be.
|
|
52
|
+
*/
|
|
53
|
+
fabricationHorizonMs: 120_000,
|
|
37
54
|
/** Track segments implying speed above this are excluded from rollup distance. */
|
|
38
55
|
rollupSpeedGuardKn: 80,
|
|
39
56
|
/** No delta for this long -> /health reports degraded. */
|
package/plugin/dist/index.js
CHANGED
|
@@ -39,10 +39,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
39
39
|
* PUT requests. It subscribes to self paths, records history to
|
|
40
40
|
* NDJSON + materialized rollups, and serves a read-only REST API.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* One runtime dependency: ws, for the live uplink, and it is one on purpose. It carries no
|
|
43
|
+
* install script and no dependency of its own, and its two native helpers are optional peers -
|
|
44
|
+
* which is what lets the AppStore install this plugin with --ignore-scripts and have it work.
|
|
45
|
+
* Anything with a node-gyp step in it breaks on the boat rather than here; check before adding.
|
|
46
|
+
*
|
|
47
|
+
* Everything else - @signalk/server-api, express - is types only, and stays a devDependency:
|
|
48
|
+
* keep every import from them `import type`, because a value import would crash on a boat the
|
|
49
|
+
* AppStore installed without devDependencies.
|
|
46
50
|
*/
|
|
47
51
|
const fs = __importStar(require("node:fs"));
|
|
48
52
|
const path = __importStar(require("node:path"));
|
|
@@ -56,6 +60,7 @@ const rest_1 = require("./rest");
|
|
|
56
60
|
const rollup_1 = require("./rollup");
|
|
57
61
|
const store_1 = require("./store");
|
|
58
62
|
const time_1 = require("./time");
|
|
63
|
+
const live_1 = require("./live");
|
|
59
64
|
const uplink_1 = require("./uplink");
|
|
60
65
|
const voyagelog_1 = require("./voyagelog");
|
|
61
66
|
const PLUGIN_ID = 'siparu';
|
|
@@ -76,6 +81,7 @@ module.exports = (app) => {
|
|
|
76
81
|
let query = null;
|
|
77
82
|
let voyages = null;
|
|
78
83
|
let uplink = null;
|
|
84
|
+
let liveUplink = null;
|
|
79
85
|
let timer = null;
|
|
80
86
|
let unsubscribes = [];
|
|
81
87
|
// Bumped on every start/stop; a stale async init aborts instead of leaking
|
|
@@ -102,8 +108,19 @@ module.exports = (app) => {
|
|
|
102
108
|
if (!state || !store)
|
|
103
109
|
return;
|
|
104
110
|
const now = Date.now();
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
// The horizon is what separates a record from a memory. Every field is gated on
|
|
112
|
+
// the age of its own source, so an instrument that went quiet leaves a gap here
|
|
113
|
+
// instead of a value wearing this row's timestamp: the live read below is free to
|
|
114
|
+
// keep showing the last one, but nothing measured it at `now`, and this row says
|
|
115
|
+
// it did. A voyage is built from these rows.
|
|
116
|
+
const snap = state.snapshot(now, true, config_1.INTERNAL.fabricationHorizonMs);
|
|
117
|
+
// Dynamic gauge history rides the same NDJSON row, but only on the store path:
|
|
118
|
+
// the voyage engine below reads the core snapshot and has no use for it, and the
|
|
119
|
+
// live frame builds its own `paths` separately. Added here, it reaches disk and
|
|
120
|
+
// the rollups without touching either.
|
|
121
|
+
const numeric = state.numericDynamicPaths(now, config_1.INTERNAL.fabricationHorizonMs);
|
|
122
|
+
const stored = Object.keys(numeric).length > 0 ? { ...snap, path_values: numeric } : snap;
|
|
123
|
+
await store.append(stored);
|
|
107
124
|
if (voyages)
|
|
108
125
|
await voyages.feed(snap);
|
|
109
126
|
lastSnapshotTs = now;
|
|
@@ -148,12 +165,37 @@ module.exports = (app) => {
|
|
|
148
165
|
if (!state)
|
|
149
166
|
throw new Error('not started');
|
|
150
167
|
const now = Date.now();
|
|
168
|
+
// No horizon here, deliberately: a screen showing the last known value is
|
|
169
|
+
// doing its job, and field_ages is how it says how old that value is. The
|
|
170
|
+
// recording path is the one that may not guess (writeSnapshot).
|
|
151
171
|
const snap = state.snapshot(now, false);
|
|
152
172
|
return {
|
|
153
173
|
...snap,
|
|
154
|
-
data_age_s: state.lastDeltaTs === null ? null : Math.round((now - state.lastDeltaTs) / 1000)
|
|
174
|
+
data_age_s: state.lastDeltaTs === null ? null : Math.round((now - state.lastDeltaTs) / 1000),
|
|
175
|
+
field_ages: state.coreFieldAges(now),
|
|
176
|
+
paths: state.dynamicPaths(now),
|
|
177
|
+
path_ages: state.dynamicPathAges(now)
|
|
155
178
|
};
|
|
156
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The dynamic paths this boat exposes right now, read straight from the
|
|
182
|
+
* server's model (not just what has arrived as a delta), narrowed to the
|
|
183
|
+
* families the dashboard understands. Reported live and retained nowhere:
|
|
184
|
+
* ashore it exists only while the boat is connected.
|
|
185
|
+
*/
|
|
186
|
+
function inventory() {
|
|
187
|
+
const available = app.streambundle.getAvailablePaths?.() ?? [];
|
|
188
|
+
const paths = [];
|
|
189
|
+
for (const p of available) {
|
|
190
|
+
if (!metrics_1.DYNAMIC_PREFIXES.some((pre) => p.startsWith(pre)))
|
|
191
|
+
continue;
|
|
192
|
+
const node = app.getSelfPath(p);
|
|
193
|
+
const rawUnits = node && typeof node === 'object' ? node.meta?.units : undefined;
|
|
194
|
+
paths.push({ path: p, units: typeof rawUnits === 'string' ? rawUnits : null });
|
|
195
|
+
}
|
|
196
|
+
paths.sort((a, b) => a.path.localeCompare(b.path));
|
|
197
|
+
return { paths };
|
|
198
|
+
}
|
|
157
199
|
const plugin = {
|
|
158
200
|
id: PLUGIN_ID,
|
|
159
201
|
name: 'Siparu',
|
|
@@ -201,7 +243,9 @@ module.exports = (app) => {
|
|
|
201
243
|
app.subscriptionmanager.subscribe({
|
|
202
244
|
// Cast: server-api brands Context/Path as nominal string types.
|
|
203
245
|
context: 'vessels.self',
|
|
204
|
-
|
|
246
|
+
// Core paths by name, plus one wildcard per dynamic family so
|
|
247
|
+
// engine/tank/generator paths flow in without naming each one.
|
|
248
|
+
subscribe: [...metrics_1.SUBSCRIBED_PATHS, ...metrics_1.DYNAMIC_PREFIXES.map((p) => `${p}*`)].map((p) => ({
|
|
205
249
|
path: p,
|
|
206
250
|
period: config_1.INTERNAL.samplePeriodMs,
|
|
207
251
|
policy: 'fixed'
|
|
@@ -220,6 +264,7 @@ module.exports = (app) => {
|
|
|
220
264
|
}, opts.snapshotSeconds * 1000);
|
|
221
265
|
(0, rest_1.setRestDeps)({
|
|
222
266
|
live,
|
|
267
|
+
inventory,
|
|
223
268
|
health,
|
|
224
269
|
snapshots: (q) => qs.snapshots(q, Date.now()),
|
|
225
270
|
voyages: async (limit) => vl.list(limit),
|
|
@@ -239,12 +284,29 @@ module.exports = (app) => {
|
|
|
239
284
|
//
|
|
240
285
|
// Started even when she is not paired - it sends nothing until she is, and it
|
|
241
286
|
// means pairing her mid-passage starts the feed without a restart.
|
|
242
|
-
|
|
287
|
+
// The socket, and the minute-by-minute POST behind it. Both are started, and only
|
|
288
|
+
// one of them speaks at a time: the POST path stands down while the socket is up
|
|
289
|
+
// and carries her the moment it is not. It is not a fallback that gets switched on
|
|
290
|
+
// when something notices a failure - it is already running when the failure happens.
|
|
291
|
+
const ws = new live_1.LiveUplink({
|
|
243
292
|
relayUrl: opts.relayUrl,
|
|
244
293
|
getRemote: () => opts.remote,
|
|
245
294
|
frame: () => live(),
|
|
295
|
+
// The one thing the shore may ask of her: her own recorded history for a gauge,
|
|
296
|
+
// read from the same store the local /snapshots serves. It reaches this query
|
|
297
|
+
// service and nowhere near Signal K - a read, never a command.
|
|
298
|
+
onHistoryQuery: (p, q) => qs.pathSeries(p, q, Date.now()),
|
|
246
299
|
debug: (msg) => app.debug(msg)
|
|
247
300
|
});
|
|
301
|
+
liveUplink = ws;
|
|
302
|
+
ws.start();
|
|
303
|
+
const up = new uplink_1.Uplink({
|
|
304
|
+
relayUrl: opts.relayUrl,
|
|
305
|
+
getRemote: () => opts.remote,
|
|
306
|
+
frame: () => live(),
|
|
307
|
+
debug: (msg) => app.debug(msg),
|
|
308
|
+
liveHealthy: () => ws.healthy()
|
|
309
|
+
});
|
|
248
310
|
uplink = up;
|
|
249
311
|
up.start();
|
|
250
312
|
app.setPluginStatus('Recording - waiting for first snapshot');
|
|
@@ -270,6 +332,7 @@ module.exports = (app) => {
|
|
|
270
332
|
unsubscribes = [];
|
|
271
333
|
(0, rest_1.setRestDeps)(null);
|
|
272
334
|
uplink?.stop();
|
|
335
|
+
liveUplink?.stop();
|
|
273
336
|
if (voyages)
|
|
274
337
|
await voyages.flush();
|
|
275
338
|
if (store)
|
|
@@ -280,6 +343,7 @@ module.exports = (app) => {
|
|
|
280
343
|
query = null;
|
|
281
344
|
voyages = null;
|
|
282
345
|
uplink = null;
|
|
346
|
+
liveUplink = null;
|
|
283
347
|
app.setPluginStatus('Stopped');
|
|
284
348
|
},
|
|
285
349
|
registerWithRouter(router) {
|
|
@@ -292,7 +356,7 @@ module.exports = (app) => {
|
|
|
292
356
|
app,
|
|
293
357
|
relayUrl: opts?.relayUrl ?? config_1.DEFAULTS.relayUrl,
|
|
294
358
|
boatName: () => opts?.boatName || String(app.getSelfPath('name') ?? ''),
|
|
295
|
-
uplinkStatus: () => uplink?.status() ?? null,
|
|
359
|
+
uplinkStatus: () => (0, uplink_1.reportedStatus)(liveUplink?.status(), uplink?.status() ?? null),
|
|
296
360
|
// The vessel's MMSI or UUID urn. Typed as a plain string, but the server only
|
|
297
361
|
// assigns it when the boat has one or the other, so at runtime it can be
|
|
298
362
|
// undefined - which is fine, because nothing is authorised by it: it is reported
|
|
@@ -312,6 +376,7 @@ module.exports = (app) => {
|
|
|
312
376
|
// by a fresh pairing must not leave "rejected" on the screen of a boat that is
|
|
313
377
|
// now streaming perfectly well.
|
|
314
378
|
uplink?.reset();
|
|
379
|
+
liveUplink?.reset();
|
|
315
380
|
}
|
|
316
381
|
});
|
|
317
382
|
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LiveUplink = exports.PING_EVERY_MS = exports.FRAME_EVERY_MS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* How often a frame goes up while the socket is open.
|
|
6
|
+
*
|
|
7
|
+
* The relay drops anything faster than one frame per 500ms, so this sits well clear of the
|
|
8
|
+
* floor. It is also what the shore's idea of "live" costs: at two seconds a boat at six knots
|
|
9
|
+
* has moved six metres between frames, which is under the width of the boat, and the Durable
|
|
10
|
+
* Object bills for the frames rather than for the season she is connected.
|
|
11
|
+
*/
|
|
12
|
+
exports.FRAME_EVERY_MS = 2_000;
|
|
13
|
+
/**
|
|
14
|
+
* Starlink sits behind CGNAT, which drops an idle flow in around a minute. The relay answers
|
|
15
|
+
* this without waking the Durable Object at all, so it costs nothing to send and everything
|
|
16
|
+
* to leave out.
|
|
17
|
+
*/
|
|
18
|
+
exports.PING_EVERY_MS = 25_000;
|
|
19
|
+
/** A boat with no uplink must not knock every five seconds for a fortnight. */
|
|
20
|
+
const RECONNECT_BASE_MS = 5_000;
|
|
21
|
+
const RECONNECT_CEILING_MS = 15 * 60_000;
|
|
22
|
+
/**
|
|
23
|
+
* What she waits after being told something that redialling cannot fix: an unknown token, or a
|
|
24
|
+
* newer socket of hers taking over. Long, deliberately - see the close handler.
|
|
25
|
+
*/
|
|
26
|
+
const STAND_OFF_MS = 15 * 60_000;
|
|
27
|
+
/** Not paired yet. She looks again, so that pairing her mid-passage needs no restart. */
|
|
28
|
+
const UNPAIRED_RECHECK_MS = 60_000;
|
|
29
|
+
/**
|
|
30
|
+
* The relay closes a socket with a reason, and the reason decides what she does next.
|
|
31
|
+
*
|
|
32
|
+
* 1012 is measured, not assumed: connect twice with one token against the live relay and the
|
|
33
|
+
* first socket is closed with exactly this. 1008 is the narrow case - the token was revoked
|
|
34
|
+
* while she was connected. A token that was already unknown when she dialled never reaches a
|
|
35
|
+
* close code at all: the relay refuses the handshake with a 401, which arrives as a refusal
|
|
36
|
+
* rather than a closure, and is handled there.
|
|
37
|
+
*/
|
|
38
|
+
const CLOSE_UNKNOWN_TOKEN = 1008;
|
|
39
|
+
const CLOSE_REPLACED = 1012;
|
|
40
|
+
/** HTTP, not WebSocket: this one comes back instead of an upgrade. */
|
|
41
|
+
const REFUSED_UNKNOWN_TOKEN = 401;
|
|
42
|
+
/**
|
|
43
|
+
* A handshake that never finishes. The TCP connection is up, so nothing errors and nothing
|
|
44
|
+
* closes - a captive portal or a CGNAT black hole simply never answers, and without this the
|
|
45
|
+
* dial hangs forever with no timer left running to rescue it.
|
|
46
|
+
*/
|
|
47
|
+
const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
48
|
+
class LiveUplink {
|
|
49
|
+
deps;
|
|
50
|
+
sock = null;
|
|
51
|
+
connected = false;
|
|
52
|
+
stopped = true;
|
|
53
|
+
/**
|
|
54
|
+
* Which socket is the current one. Every handler carries the generation it was born in and
|
|
55
|
+
* does nothing if it is not the one in hand: a socket that has already been replaced still
|
|
56
|
+
* delivers its close event, and acting on it would schedule a redial on top of the
|
|
57
|
+
* connection she already has - one drop becoming two sockets.
|
|
58
|
+
*/
|
|
59
|
+
gen = 0;
|
|
60
|
+
frameTimer = null;
|
|
61
|
+
pingTimer = null;
|
|
62
|
+
redialTimer = null;
|
|
63
|
+
/** A ping went out and nothing came back yet. Two of those in a row is a dead line. */
|
|
64
|
+
awaitingPong = false;
|
|
65
|
+
failures = 0;
|
|
66
|
+
lastFrameTs = null;
|
|
67
|
+
rejected = false;
|
|
68
|
+
lastError = null;
|
|
69
|
+
frameEveryMs;
|
|
70
|
+
pingEveryMs;
|
|
71
|
+
constructor(deps) {
|
|
72
|
+
this.deps = deps;
|
|
73
|
+
this.frameEveryMs = deps.frameEveryMs ?? exports.FRAME_EVERY_MS;
|
|
74
|
+
this.pingEveryMs = deps.pingEveryMs ?? exports.PING_EVERY_MS;
|
|
75
|
+
}
|
|
76
|
+
start() {
|
|
77
|
+
if (!this.stopped)
|
|
78
|
+
return;
|
|
79
|
+
this.stopped = false;
|
|
80
|
+
this.dial();
|
|
81
|
+
}
|
|
82
|
+
stop() {
|
|
83
|
+
this.stopped = true;
|
|
84
|
+
this.gen++; // whatever is in flight is no longer hers
|
|
85
|
+
this.clearTimers();
|
|
86
|
+
this.connected = false;
|
|
87
|
+
const sock = this.sock;
|
|
88
|
+
this.sock = null;
|
|
89
|
+
try {
|
|
90
|
+
sock?.close(1000, 'plugin stopping');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Already gone. Signal K restarts a plugin on every config save, so this is routine.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the shore is genuinely being fed from here. The HTTP uplink stands down while this
|
|
98
|
+
* is true and takes over the moment it is false, so it must never be optimistic: a live
|
|
99
|
+
* uplink that lies about its health takes the fallback down with it and the boat goes silent
|
|
100
|
+
* altogether.
|
|
101
|
+
*/
|
|
102
|
+
healthy() {
|
|
103
|
+
return !this.stopped && this.connected;
|
|
104
|
+
}
|
|
105
|
+
status() {
|
|
106
|
+
return {
|
|
107
|
+
connected: this.connected,
|
|
108
|
+
lastFrameTs: this.lastFrameTs,
|
|
109
|
+
failures: this.failures,
|
|
110
|
+
rejected: this.rejected,
|
|
111
|
+
lastError: this.lastError
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A new pairing starts a new life: the previous link's failures are not hers.
|
|
116
|
+
*
|
|
117
|
+
* And it dials again at once. Pairing her again is the cure the screen prescribes for a
|
|
118
|
+
* rejected token - so the cure must not sit out the punishment. The stand-off left by the
|
|
119
|
+
* old link is a quarter of an hour long, and without this the owner would fix the problem,
|
|
120
|
+
* watch the boat stay dark for fifteen minutes, and conclude that it did not work.
|
|
121
|
+
*/
|
|
122
|
+
reset() {
|
|
123
|
+
this.failures = 0;
|
|
124
|
+
this.rejected = false;
|
|
125
|
+
this.lastError = null;
|
|
126
|
+
this.lastFrameTs = null;
|
|
127
|
+
if (this.stopped)
|
|
128
|
+
return;
|
|
129
|
+
const old = this.sock;
|
|
130
|
+
this.gen++;
|
|
131
|
+
this.clearTimers();
|
|
132
|
+
this.connected = false;
|
|
133
|
+
this.sock = null;
|
|
134
|
+
if (old)
|
|
135
|
+
this.kill(old);
|
|
136
|
+
this.dial();
|
|
137
|
+
}
|
|
138
|
+
dial() {
|
|
139
|
+
if (this.stopped)
|
|
140
|
+
return;
|
|
141
|
+
const remote = this.deps.getRemote();
|
|
142
|
+
if (!remote) {
|
|
143
|
+
// Nothing to send and nobody to send it to. She looks again rather than giving up, so
|
|
144
|
+
// that pairing her at sea starts the feed without a restart.
|
|
145
|
+
this.redialTimer = setTimeout(() => this.dial(), UNPAIRED_RECHECK_MS);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const gen = ++this.gen;
|
|
149
|
+
const url = `${this.deps.relayUrl.replace(/^http/, 'ws')}/live/boat`;
|
|
150
|
+
const connect = this.deps.connect ?? wsConnect;
|
|
151
|
+
let sock;
|
|
152
|
+
try {
|
|
153
|
+
sock = connect(url, remote.boatToken);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
this.failed(gen, `Cannot reach Siparu. Is the boat online?`, e);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.sock = sock;
|
|
160
|
+
sock.onOpen(() => {
|
|
161
|
+
if (gen !== this.gen)
|
|
162
|
+
return;
|
|
163
|
+
this.connected = true;
|
|
164
|
+
this.failures = 0;
|
|
165
|
+
this.rejected = false;
|
|
166
|
+
this.lastError = null;
|
|
167
|
+
this.awaitingPong = false;
|
|
168
|
+
// At once, not in two seconds: the shore's whole reason for wanting this socket is to
|
|
169
|
+
// know she is there, and making it wait would be theatre.
|
|
170
|
+
this.sendFrame(gen);
|
|
171
|
+
this.frameTimer = setInterval(() => this.sendFrame(gen), this.frameEveryMs);
|
|
172
|
+
this.pingTimer = setInterval(() => this.keepalive(gen), this.pingEveryMs);
|
|
173
|
+
});
|
|
174
|
+
sock.onMessage((data) => {
|
|
175
|
+
if (gen !== this.gen)
|
|
176
|
+
return;
|
|
177
|
+
if (data === 'pong') {
|
|
178
|
+
this.awaitingPong = false;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// The one thing beyond a pong that the shore may say: asking the boat to send back her
|
|
182
|
+
// own recorded history. It is not a command - see handleHistory - and anything that is
|
|
183
|
+
// not one of these two is not acted on, because the shore may not steer a boat.
|
|
184
|
+
this.handleHistory(gen, data);
|
|
185
|
+
});
|
|
186
|
+
sock.onClose((code) => {
|
|
187
|
+
if (gen !== this.gen)
|
|
188
|
+
return;
|
|
189
|
+
this.closed(gen, code);
|
|
190
|
+
});
|
|
191
|
+
sock.onError((e) => {
|
|
192
|
+
if (gen !== this.gen)
|
|
193
|
+
return;
|
|
194
|
+
// An error is followed by a close on a real socket, but not always, and not on every
|
|
195
|
+
// implementation. Destroying it here makes the two paths one.
|
|
196
|
+
this.deps.debug(`live uplink error: ${String(e)}`);
|
|
197
|
+
this.kill(sock);
|
|
198
|
+
this.closed(gen, 1006);
|
|
199
|
+
});
|
|
200
|
+
// The relay looked at her token and would not even open the socket. This is the ordinary
|
|
201
|
+
// way an unknown token is refused - a boat whose owner unlinked her dials, and is turned
|
|
202
|
+
// away at the door rather than let in and shown out. Telling her "the boat is offline"
|
|
203
|
+
// here, which is what a bare connection error would have said, would send a skipper to
|
|
204
|
+
// check an aerial that is working perfectly.
|
|
205
|
+
sock.onRefused((status) => {
|
|
206
|
+
if (gen !== this.gen)
|
|
207
|
+
return;
|
|
208
|
+
this.kill(sock);
|
|
209
|
+
this.closed(gen, status === REFUSED_UNKNOWN_TOKEN ? CLOSE_UNKNOWN_TOKEN : 1006);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** The only thing that reliably ends a socket that may already be broken. */
|
|
213
|
+
kill(sock) {
|
|
214
|
+
try {
|
|
215
|
+
sock.terminate();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Already gone, which is the outcome we wanted.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
sendFrame(gen) {
|
|
222
|
+
if (gen !== this.gen || !this.sock)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
this.sock.send(JSON.stringify(this.deps.frame()));
|
|
226
|
+
this.lastFrameTs = Date.now();
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
// The socket died under her. Treat it as the drop it is, rather than throwing inside a
|
|
230
|
+
// timer where nobody is listening.
|
|
231
|
+
this.deps.debug(`live uplink send failed: ${String(e)}`);
|
|
232
|
+
this.kill(this.sock);
|
|
233
|
+
this.closed(gen, 1006);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A history request from the shore, answered from the boat's own store.
|
|
238
|
+
*
|
|
239
|
+
* This is the whole of the inbound surface, and it is deliberately narrow: parse the
|
|
240
|
+
* message, and act only if it is a history request. A command, a PUT, a malformed line -
|
|
241
|
+
* anything else - is dropped in silence, because there is no other thing the shore is
|
|
242
|
+
* allowed to ask and answering would be a reply to make of a surface. The query goes to the
|
|
243
|
+
* store, never to Signal K, and the store read is already clamped (today-only for raw, a
|
|
244
|
+
* hard row cap for rollups), so a request cannot ask the boat for more than she will give.
|
|
245
|
+
*/
|
|
246
|
+
handleHistory(gen, data) {
|
|
247
|
+
const handler = this.deps.onHistoryQuery;
|
|
248
|
+
if (!handler)
|
|
249
|
+
return;
|
|
250
|
+
let msg;
|
|
251
|
+
try {
|
|
252
|
+
msg = JSON.parse(data);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!isHistoryRequest(msg))
|
|
258
|
+
return;
|
|
259
|
+
const { id, path, query } = msg;
|
|
260
|
+
handler(path, query).then((result) => this.replyHistory(gen, { type: 'history', id, result }), (err) => {
|
|
261
|
+
// The caught text stays here, in the debug log - it can hold a data-dir path, and the
|
|
262
|
+
// reply crosses the wire to the shore. What the shore gets is that the query failed,
|
|
263
|
+
// which is all a screen waiting on a chart needs to stop waiting.
|
|
264
|
+
this.deps.debug(`history query failed: ${String(err)}`);
|
|
265
|
+
this.replyHistory(gen, {
|
|
266
|
+
type: 'history',
|
|
267
|
+
id,
|
|
268
|
+
error: { code: 'HISTORY_FAILED', message: 'history query failed' }
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Send a history answer, but only if it still belongs to the socket that asked. A query
|
|
274
|
+
* reads the disk while the line may drop and redial underneath it; the generation guard is
|
|
275
|
+
* what keeps a slow answer from landing on a fresh connection that never made the request.
|
|
276
|
+
*/
|
|
277
|
+
replyHistory(gen, msg) {
|
|
278
|
+
if (gen !== this.gen || !this.sock)
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
this.sock.send(JSON.stringify(msg));
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
// The socket died between the query starting and its answer arriving. The close handler
|
|
285
|
+
// has the redial; here there is nothing to do but not throw inside a promise callback.
|
|
286
|
+
this.deps.debug(`history reply send failed: ${String(e)}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
keepalive(gen) {
|
|
290
|
+
if (gen !== this.gen || !this.sock)
|
|
291
|
+
return;
|
|
292
|
+
// A half-open connection looks exactly like a healthy one from this side: the sends
|
|
293
|
+
// succeed and nothing ever comes back. The only way to tell is to ask, and to notice that
|
|
294
|
+
// the last question was never answered.
|
|
295
|
+
if (this.awaitingPong) {
|
|
296
|
+
this.deps.debug('live uplink: no answer to the last keepalive - the line is dead');
|
|
297
|
+
// Destroyed, not closed. There is nobody left to complete a closing handshake with, and
|
|
298
|
+
// asking for one on a dead line is how the socket ends up held open forever.
|
|
299
|
+
this.kill(this.sock);
|
|
300
|
+
this.closed(gen, 1006);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
this.awaitingPong = true;
|
|
305
|
+
this.sock.send('ping');
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
this.kill(this.sock);
|
|
309
|
+
this.closed(gen, 1006);
|
|
310
|
+
this.deps.debug(`live uplink keepalive failed: ${String(e)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
closed(gen, code) {
|
|
314
|
+
if (gen !== this.gen)
|
|
315
|
+
return;
|
|
316
|
+
this.gen++; // this socket is finished; nothing it does from here counts
|
|
317
|
+
this.clearTimers();
|
|
318
|
+
this.connected = false;
|
|
319
|
+
this.sock = null;
|
|
320
|
+
if (this.stopped)
|
|
321
|
+
return;
|
|
322
|
+
if (code === CLOSE_UNKNOWN_TOKEN) {
|
|
323
|
+
// She does not unpair herself. See the file header: a relay that answers "unknown" by
|
|
324
|
+
// mistake would otherwise take the whole fleet off the air, permanently, and the cure
|
|
325
|
+
// would run on every boat rather than on our own server.
|
|
326
|
+
this.rejected = true;
|
|
327
|
+
this.lastError = 'Siparu no longer recognises this boat. Pair her again.';
|
|
328
|
+
this.deps.debug(`live uplink: ${this.lastError}`);
|
|
329
|
+
this.redial(STAND_OFF_MS);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (code === CLOSE_REPLACED) {
|
|
333
|
+
// A newer socket carrying this same token took over. Redialling would displace THAT
|
|
334
|
+
// one, which would redial and displace this one, and two instances of the plugin would
|
|
335
|
+
// flap against each other forever - waking the Durable Object on every round and
|
|
336
|
+
// billing for it. She stands well back. If the other one is the real boat, she is being
|
|
337
|
+
// reported already; if it dies, this one comes back on its own.
|
|
338
|
+
this.lastError = 'Another copy of this boat is connected to Siparu.';
|
|
339
|
+
this.deps.debug(`live uplink: ${this.lastError}`);
|
|
340
|
+
this.redial(STAND_OFF_MS);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.failures++;
|
|
344
|
+
this.lastError = 'Cannot reach Siparu. Is the boat online?';
|
|
345
|
+
this.redial(this.backoffMs());
|
|
346
|
+
}
|
|
347
|
+
failed(gen, message, e) {
|
|
348
|
+
if (gen !== this.gen)
|
|
349
|
+
return;
|
|
350
|
+
this.connected = false;
|
|
351
|
+
this.sock = null;
|
|
352
|
+
this.failures++;
|
|
353
|
+
this.lastError = message;
|
|
354
|
+
// Offline is the normal state of a boat, not an incident. Debug, never error: a week in an
|
|
355
|
+
// anchorage must not fill the Signal K log with red.
|
|
356
|
+
this.deps.debug(`live uplink unreachable: ${String(e)}`);
|
|
357
|
+
this.redial(this.backoffMs());
|
|
358
|
+
}
|
|
359
|
+
backoffMs() {
|
|
360
|
+
return Math.min(RECONNECT_BASE_MS * 2 ** Math.max(0, this.failures - 1), RECONNECT_CEILING_MS);
|
|
361
|
+
}
|
|
362
|
+
redial(delayMs) {
|
|
363
|
+
if (this.stopped)
|
|
364
|
+
return;
|
|
365
|
+
if (this.redialTimer)
|
|
366
|
+
clearTimeout(this.redialTimer);
|
|
367
|
+
this.redialTimer = setTimeout(() => this.dial(), delayMs);
|
|
368
|
+
}
|
|
369
|
+
clearTimers() {
|
|
370
|
+
if (this.frameTimer)
|
|
371
|
+
clearInterval(this.frameTimer);
|
|
372
|
+
if (this.pingTimer)
|
|
373
|
+
clearInterval(this.pingTimer);
|
|
374
|
+
if (this.redialTimer)
|
|
375
|
+
clearTimeout(this.redialTimer);
|
|
376
|
+
this.frameTimer = null;
|
|
377
|
+
this.pingTimer = null;
|
|
378
|
+
this.redialTimer = null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
exports.LiveUplink = LiveUplink;
|
|
382
|
+
/**
|
|
383
|
+
* Whether a parsed shore message is a history request and nothing else. Strict on purpose:
|
|
384
|
+
* the type tag is the gate that keeps a command from being read as a request, so a message
|
|
385
|
+
* missing it, or carrying a non-string path, or no query object, is not one. The query's own
|
|
386
|
+
* contents (bucket, range) are the store's to validate - it rejects a bad bucket - so they
|
|
387
|
+
* are not re-checked here.
|
|
388
|
+
*/
|
|
389
|
+
function isHistoryRequest(m) {
|
|
390
|
+
if (typeof m !== 'object' || m === null)
|
|
391
|
+
return false;
|
|
392
|
+
const o = m;
|
|
393
|
+
return (o.type === 'history' &&
|
|
394
|
+
typeof o.id === 'string' &&
|
|
395
|
+
typeof o.path === 'string' &&
|
|
396
|
+
typeof o.query === 'object' &&
|
|
397
|
+
o.query !== null);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The real socket.
|
|
401
|
+
*
|
|
402
|
+
* `ws` is here rather than the platform's own WebSocket because the compatibility floor is Node
|
|
403
|
+
* 20, where the global one is behind a flag. It carries no install script and no runtime
|
|
404
|
+
* dependency of its own, and its two native helpers are optional peers - which is what lets the
|
|
405
|
+
* Signal K AppStore install this plugin with --ignore-scripts and have it work.
|
|
406
|
+
*/
|
|
407
|
+
function wsConnect(url, token) {
|
|
408
|
+
// Required lazily so that a test - and a boat that never pairs - never loads it at all.
|
|
409
|
+
const WS = require('ws');
|
|
410
|
+
const sock = new WS(url, {
|
|
411
|
+
headers: { authorization: `Bearer ${token}` },
|
|
412
|
+
// Opt-in, and without it a handshake that is never answered is never abandoned either.
|
|
413
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT_MS
|
|
414
|
+
});
|
|
415
|
+
return {
|
|
416
|
+
send: (data) => sock.send(data),
|
|
417
|
+
close: (code, reason) => sock.close(code, reason),
|
|
418
|
+
terminate: () => sock.terminate(),
|
|
419
|
+
onOpen: (cb) => sock.on('open', cb),
|
|
420
|
+
onMessage: (cb) => sock.on('message', (d) => cb(String(d))),
|
|
421
|
+
onClose: (cb) => sock.on('close', (code) => cb(code)),
|
|
422
|
+
onError: (cb) => sock.on('error', cb),
|
|
423
|
+
// ws reports a refused upgrade here first, and only then as an error. Taking it here is
|
|
424
|
+
// what lets a 401 be read as "she is not paired any more" rather than "the boat is offline".
|
|
425
|
+
onRefused: (cb) => sock.on('unexpected-response', (_req, res) => cb(res.statusCode ?? 0))
|
|
426
|
+
};
|
|
427
|
+
}
|