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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.1.2",
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
  }
@@ -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. */
@@ -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
- * Runtime dependencies: none. Types come from @signalk/server-api and
43
- * express as devDependencies - keep every import from them `import type`,
44
- * a value import would crash on the boat (AppStore installs with
45
- * --ignore-scripts and without devDependencies).
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
- const snap = state.snapshot(now, true);
106
- await store.append(snap);
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
- subscribe: metrics_1.SUBSCRIBED_PATHS.map((p) => ({
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
- const up = new uplink_1.Uplink({
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
+ }