signalk-siparu 0.1.2 → 0.1.3

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.3",
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
  }
@@ -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
@@ -103,7 +109,13 @@ module.exports = (app) => {
103
109
  return;
104
110
  const now = Date.now();
105
111
  const snap = state.snapshot(now, true);
106
- await store.append(snap);
112
+ // Dynamic gauge history rides the same NDJSON row, but only on the store path:
113
+ // the voyage engine below reads the core snapshot and has no use for it, and the
114
+ // live frame builds its own `paths` separately. Added here, it reaches disk and
115
+ // the rollups without touching either.
116
+ const numeric = state.numericDynamicPaths(now);
117
+ const stored = Object.keys(numeric).length > 0 ? { ...snap, path_values: numeric } : snap;
118
+ await store.append(stored);
107
119
  if (voyages)
108
120
  await voyages.feed(snap);
109
121
  lastSnapshotTs = now;
@@ -151,9 +163,30 @@ module.exports = (app) => {
151
163
  const snap = state.snapshot(now, false);
152
164
  return {
153
165
  ...snap,
154
- data_age_s: state.lastDeltaTs === null ? null : Math.round((now - state.lastDeltaTs) / 1000)
166
+ data_age_s: state.lastDeltaTs === null ? null : Math.round((now - state.lastDeltaTs) / 1000),
167
+ paths: state.dynamicPaths(now),
168
+ path_ages: state.dynamicPathAges(now)
155
169
  };
156
170
  }
171
+ /**
172
+ * The dynamic paths this boat exposes right now, read straight from the
173
+ * server's model (not just what has arrived as a delta), narrowed to the
174
+ * families the dashboard understands. Reported live and retained nowhere:
175
+ * ashore it exists only while the boat is connected.
176
+ */
177
+ function inventory() {
178
+ const available = app.streambundle.getAvailablePaths?.() ?? [];
179
+ const paths = [];
180
+ for (const p of available) {
181
+ if (!metrics_1.DYNAMIC_PREFIXES.some((pre) => p.startsWith(pre)))
182
+ continue;
183
+ const node = app.getSelfPath(p);
184
+ const rawUnits = node && typeof node === 'object' ? node.meta?.units : undefined;
185
+ paths.push({ path: p, units: typeof rawUnits === 'string' ? rawUnits : null });
186
+ }
187
+ paths.sort((a, b) => a.path.localeCompare(b.path));
188
+ return { paths };
189
+ }
157
190
  const plugin = {
158
191
  id: PLUGIN_ID,
159
192
  name: 'Siparu',
@@ -201,7 +234,9 @@ module.exports = (app) => {
201
234
  app.subscriptionmanager.subscribe({
202
235
  // Cast: server-api brands Context/Path as nominal string types.
203
236
  context: 'vessels.self',
204
- subscribe: metrics_1.SUBSCRIBED_PATHS.map((p) => ({
237
+ // Core paths by name, plus one wildcard per dynamic family so
238
+ // engine/tank/generator paths flow in without naming each one.
239
+ subscribe: [...metrics_1.SUBSCRIBED_PATHS, ...metrics_1.DYNAMIC_PREFIXES.map((p) => `${p}*`)].map((p) => ({
205
240
  path: p,
206
241
  period: config_1.INTERNAL.samplePeriodMs,
207
242
  policy: 'fixed'
@@ -220,6 +255,7 @@ module.exports = (app) => {
220
255
  }, opts.snapshotSeconds * 1000);
221
256
  (0, rest_1.setRestDeps)({
222
257
  live,
258
+ inventory,
223
259
  health,
224
260
  snapshots: (q) => qs.snapshots(q, Date.now()),
225
261
  voyages: async (limit) => vl.list(limit),
@@ -239,12 +275,29 @@ module.exports = (app) => {
239
275
  //
240
276
  // Started even when she is not paired - it sends nothing until she is, and it
241
277
  // means pairing her mid-passage starts the feed without a restart.
242
- const up = new uplink_1.Uplink({
278
+ // The socket, and the minute-by-minute POST behind it. Both are started, and only
279
+ // one of them speaks at a time: the POST path stands down while the socket is up
280
+ // and carries her the moment it is not. It is not a fallback that gets switched on
281
+ // when something notices a failure - it is already running when the failure happens.
282
+ const ws = new live_1.LiveUplink({
243
283
  relayUrl: opts.relayUrl,
244
284
  getRemote: () => opts.remote,
245
285
  frame: () => live(),
286
+ // The one thing the shore may ask of her: her own recorded history for a gauge,
287
+ // read from the same store the local /snapshots serves. It reaches this query
288
+ // service and nowhere near Signal K - a read, never a command.
289
+ onHistoryQuery: (p, q) => qs.pathSeries(p, q, Date.now()),
246
290
  debug: (msg) => app.debug(msg)
247
291
  });
292
+ liveUplink = ws;
293
+ ws.start();
294
+ const up = new uplink_1.Uplink({
295
+ relayUrl: opts.relayUrl,
296
+ getRemote: () => opts.remote,
297
+ frame: () => live(),
298
+ debug: (msg) => app.debug(msg),
299
+ liveHealthy: () => ws.healthy()
300
+ });
248
301
  uplink = up;
249
302
  up.start();
250
303
  app.setPluginStatus('Recording - waiting for first snapshot');
@@ -270,6 +323,7 @@ module.exports = (app) => {
270
323
  unsubscribes = [];
271
324
  (0, rest_1.setRestDeps)(null);
272
325
  uplink?.stop();
326
+ liveUplink?.stop();
273
327
  if (voyages)
274
328
  await voyages.flush();
275
329
  if (store)
@@ -280,6 +334,7 @@ module.exports = (app) => {
280
334
  query = null;
281
335
  voyages = null;
282
336
  uplink = null;
337
+ liveUplink = null;
283
338
  app.setPluginStatus('Stopped');
284
339
  },
285
340
  registerWithRouter(router) {
@@ -292,7 +347,7 @@ module.exports = (app) => {
292
347
  app,
293
348
  relayUrl: opts?.relayUrl ?? config_1.DEFAULTS.relayUrl,
294
349
  boatName: () => opts?.boatName || String(app.getSelfPath('name') ?? ''),
295
- uplinkStatus: () => uplink?.status() ?? null,
350
+ uplinkStatus: () => (0, uplink_1.reportedStatus)(liveUplink?.status(), uplink?.status() ?? null),
296
351
  // The vessel's MMSI or UUID urn. Typed as a plain string, but the server only
297
352
  // assigns it when the boat has one or the other, so at runtime it can be
298
353
  // undefined - which is fine, because nothing is authorised by it: it is reported
@@ -312,6 +367,7 @@ module.exports = (app) => {
312
367
  // by a fresh pairing must not leave "rejected" on the screen of a boat that is
313
368
  // now streaming perfectly well.
314
369
  uplink?.reset();
370
+ liveUplink?.reset();
315
371
  }
316
372
  });
317
373
  }
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MetricsState = exports.SUBSCRIBED_PATHS = void 0;
3
+ exports.MetricsState = exports.DYNAMIC_PREFIXES = exports.SUBSCRIBED_PATHS = void 0;
4
4
  const config_1 = require("./config");
5
5
  /** Paths mapped 1:1 onto a snapshot field. */
6
6
  const DIRECT_PATHS = {
@@ -35,6 +35,19 @@ exports.SUBSCRIBED_PATHS = [
35
35
  ...DEPTH_PATHS,
36
36
  ...Object.keys(DIRECT_PATHS)
37
37
  ];
38
+ /**
39
+ * Dynamic path families beyond the fixed core: engine, tank and generator
40
+ * data a boat may expose. Subscribed by wildcard and carried on the live
41
+ * frame under their plain SK path name (contract LiveResult.paths). The core
42
+ * navigation/wind/depth paths keep their own hand-tuned handling; these ride
43
+ * the same source-resolution machinery but are never mixed into a Snapshot.
44
+ */
45
+ exports.DYNAMIC_PREFIXES = ['propulsion.', 'tanks.', 'electrical.generators.'];
46
+ /** Core paths get bespoke handling; anything else in state is a dynamic path. */
47
+ const CORE_PATH_SET = new Set(exports.SUBSCRIBED_PATHS);
48
+ function isDynamicPath(path) {
49
+ return !CORE_PATH_SET.has(path) && exports.DYNAMIC_PREFIXES.some((p) => path.startsWith(p));
50
+ }
38
51
  const STRING_FIELDS = new Set(['nav_state', 'ais_class']);
39
52
  const TWS_SET = new Set(TWS_PATHS);
40
53
  class MetricsState {
@@ -61,13 +74,25 @@ class MetricsState {
61
74
  else {
62
75
  const field = DIRECT_PATHS[path];
63
76
  const isConceptPath = TWS_SET.has(path) || DEPTH_PATHS.includes(path);
64
- if (!field && !isConceptPath)
77
+ const dynamic = !field && !isConceptPath && isDynamicPath(path);
78
+ if (!field && !isConceptPath && !dynamic)
65
79
  return false;
66
80
  if (field && STRING_FIELDS.has(field)) {
67
81
  if (typeof value !== 'string')
68
82
  return false;
69
83
  stored = value;
70
84
  }
85
+ else if (dynamic) {
86
+ // A dynamic gauge value is a number (rpm, temperature, level) or a
87
+ // short string (engine/generator state). Objects, booleans and the
88
+ // like are not gauge readings and are dropped.
89
+ if (typeof value === 'number' && Number.isFinite(value))
90
+ stored = value;
91
+ else if (typeof value === 'string')
92
+ stored = value;
93
+ else
94
+ return false;
95
+ }
71
96
  else {
72
97
  if (typeof value !== 'number' || !Number.isFinite(value))
73
98
  return false;
@@ -204,6 +229,60 @@ class MetricsState {
204
229
  ais_class: this.str('sensors.ais.class', now)
205
230
  };
206
231
  }
232
+ /**
233
+ * Live values of the dynamic (non-core) paths, keyed by SK path name, each
234
+ * resolved through the same winning-source logic as the core fields. Only
235
+ * number/string values surface; a path with no fresh usable value is absent.
236
+ */
237
+ dynamicPaths(now) {
238
+ const out = {};
239
+ for (const path of this.paths.keys()) {
240
+ if (!isDynamicPath(path))
241
+ continue;
242
+ const r = this.resolveSource(path, now);
243
+ if (r && (typeof r.entry.value === 'number' || typeof r.entry.value === 'string')) {
244
+ out[path] = r.entry.value;
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ /**
250
+ * Age in seconds of each dynamic path's on-screen value: how long ago the
251
+ * winning source last spoke. The key set matches dynamicPaths exactly - a
252
+ * path with no usable value has no age. This is what lets the shore fade a
253
+ * single frozen gauge on its own: the boat-wide data_age_s stays near zero
254
+ * as long as any subscribed path (a live GPS) is still moving, so it cannot
255
+ * see one instrument going quiet while the boat sails on.
256
+ */
257
+ dynamicPathAges(now) {
258
+ const out = {};
259
+ for (const path of this.paths.keys()) {
260
+ if (!isDynamicPath(path))
261
+ continue;
262
+ const r = this.resolveSource(path, now);
263
+ if (r && (typeof r.entry.value === 'number' || typeof r.entry.value === 'string')) {
264
+ out[path] = Math.round((now - r.entry.ts) / 1000);
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+ /**
270
+ * Numeric-only dynamic path values, for the history snapshot. String gauges
271
+ * (engine state) are dropped: history exists to be graphed and rolled up, and
272
+ * min/max/avg have no meaning for "started". Live display keeps strings
273
+ * (dynamicPaths); history does not.
274
+ */
275
+ numericDynamicPaths(now) {
276
+ const out = {};
277
+ for (const path of this.paths.keys()) {
278
+ if (!isDynamicPath(path))
279
+ continue;
280
+ const r = this.resolveSource(path, now);
281
+ if (r && typeof r.entry.value === 'number')
282
+ out[path] = r.entry.value;
283
+ }
284
+ return out;
285
+ }
207
286
  /** Per-path freshness + winning source, for /health micro-diagnosis. */
208
287
  pathAges(now) {
209
288
  const out = {};
@@ -27,6 +27,41 @@ const ALL_FIELDS = [
27
27
  ];
28
28
  exports.LIMIT_DEFAULT = 200;
29
29
  exports.LIMIT_MAX = 5000;
30
+ /**
31
+ * Core Snapshot fields a Bridge gauge can graph, keyed by the plain SK path the shore asks
32
+ * with. Wind and barometer live in the fixed Snapshot fields (and each rollup's `metrics`),
33
+ * not in the dynamic `path_values`/`path_metrics` maps - so pathSeries reads them from there
34
+ * instead. True wind is a concept (speedTrue with speedOverGround as fallback) already folded
35
+ * into `wind_speed_true`; speedTrue is its canonical name. Still read-only: this reads history
36
+ * the boat already recorded, exactly as the dynamic gauges do.
37
+ *
38
+ * These SK paths must stay in step with the portal's CORE_NUMERIC (format.ts), which scales
39
+ * exactly these paths for the axis. Add a third core gauge in both places or the shore asks for
40
+ * a series this never serves.
41
+ */
42
+ const CORE_SERIES_FIELDS = {
43
+ 'environment.wind.speedTrue': 'wind_speed_true',
44
+ 'environment.outside.pressure': 'air_pressure_pa'
45
+ };
46
+ /** The aggregate for a series in one rollup: a core metric is in `metrics`, a dynamic gauge in `path_metrics`. */
47
+ function aggFor(r, coreField, pathName) {
48
+ return coreField ? r.metrics[coreField] : r.path_metrics?.[pathName];
49
+ }
50
+ /**
51
+ * A rollup aggregate as a graph point, or nothing if this bucket did not carry the
52
+ * path (so flatMap drops it). A gauge added mid-history is simply absent from earlier
53
+ * buckets rather than a fabricated zero.
54
+ */
55
+ function aggToPoint(ts, agg) {
56
+ if (!agg ||
57
+ typeof agg.min !== 'number' ||
58
+ typeof agg.max !== 'number' ||
59
+ typeof agg.avg !== 'number' ||
60
+ typeof agg.last !== 'number') {
61
+ return [];
62
+ }
63
+ return [{ ts, min: agg.min, max: agg.max, avg: agg.avg, last: agg.last }];
64
+ }
30
65
  function rollupToRow(r) {
31
66
  const row = { ts: r.last_ts };
32
67
  for (const field of ALL_FIELDS) {
@@ -88,6 +123,59 @@ class QueryService {
88
123
  clamped = true;
89
124
  return { rows, clamped };
90
125
  }
126
+ /**
127
+ * One dynamic gauge's history, shaped for a graph. Same bucketing as snapshots():
128
+ * bucket=1 reads raw (today only, clamped), the rest read rollups. A raw sample
129
+ * becomes a point with min = max = avg = last; a rollup carries its real aggregate,
130
+ * so a chart can draw a band. A path a rollup does not carry simply yields no point.
131
+ */
132
+ async pathSeries(pathName, q, now) {
133
+ const limit = Math.min(Math.max(1, q.limit ?? exports.LIMIT_DEFAULT), exports.LIMIT_MAX);
134
+ const offset = Math.max(0, q.offset ?? 0);
135
+ const order = q.order ?? 'desc';
136
+ const to = q.to ?? now;
137
+ const coreField = CORE_SERIES_FIELDS[pathName];
138
+ let clamped = false;
139
+ let points;
140
+ if (q.bucket === 1) {
141
+ const todayStart = (0, time_1.startOfUtcDay)(now);
142
+ const from = Math.max(q.from ?? todayStart, todayStart);
143
+ if ((q.from ?? todayStart) < todayStart || to < todayStart)
144
+ clamped = true;
145
+ points = [];
146
+ for (const r of await this.readRawToday(now, from, to)) {
147
+ const v = coreField ? r[coreField] : r.path_values?.[pathName];
148
+ if (typeof v === 'number')
149
+ points.push({ ts: r.ts, min: v, max: v, avg: v, last: v });
150
+ }
151
+ }
152
+ else if (q.bucket === 60) {
153
+ const from = q.from ?? 0;
154
+ points = (await this.rollups.readHourly(from, to)).flatMap((h) => aggToPoint(h.last_ts, aggFor(h, coreField, pathName)));
155
+ }
156
+ else if (q.bucket === 360) {
157
+ const from = q.from ?? 0;
158
+ const byWindow = new Map();
159
+ for (const h of await this.rollups.readHourly(from, to)) {
160
+ byWindow.set(Math.floor(h.last_ts / (6 * 3_600_000)), h); // hours ascending; latest wins
161
+ }
162
+ points = [...byWindow.values()].flatMap((h) => aggToPoint(h.last_ts, aggFor(h, coreField, pathName)));
163
+ }
164
+ else if (q.bucket === 1440) {
165
+ const from = q.from ?? 0;
166
+ points = (await this.rollups.readDaily(from, to)).flatMap((d) => aggToPoint(d.last_ts, aggFor(d, coreField, pathName)));
167
+ }
168
+ else {
169
+ throw new QueryError('BAD_BUCKET', 'bucket must be one of 1, 60, 360, 1440');
170
+ }
171
+ points = points.filter((p) => p.ts >= (q.bucket === 1 ? 0 : q.from ?? 0) && p.ts <= to);
172
+ points.sort((a, b) => (order === 'asc' ? a.ts - b.ts : b.ts - a.ts));
173
+ const total = points.length;
174
+ points = points.slice(offset, offset + limit);
175
+ if (offset + points.length < total)
176
+ clamped = true;
177
+ return { path: pathName, points, clamped };
178
+ }
91
179
  async readRawToday(now, fromTs, toTs) {
92
180
  const today = (0, time_1.dayKey)(now);
93
181
  const keys = (await this.store.listRawKeys()).filter((k) => k.startsWith(today));
@@ -76,6 +76,11 @@ function registerRoutes(router) {
76
76
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
77
77
  res.json(deps.live());
78
78
  });
79
+ router.get('/inventory', (_req, res) => {
80
+ if (!deps)
81
+ return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
82
+ res.json(deps.inventory());
83
+ });
79
84
  router.get('/snapshots', (req, res) => {
80
85
  if (!deps)
81
86
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
@@ -134,6 +134,37 @@ function aggregateLinear(values) {
134
134
  }
135
135
  return { min, max, avg: sum / values.length, n: values.length, last: values[values.length - 1] ?? null };
136
136
  }
137
+ /** Merge already-aggregated linear buckets (hour rollups) into one. */
138
+ function mergeLinearAggs(aggs) {
139
+ let min = Infinity;
140
+ let max = -Infinity;
141
+ let sum = 0;
142
+ let n = 0;
143
+ let last = null;
144
+ for (const m of aggs) {
145
+ if (typeof m.min !== 'number' || typeof m.max !== 'number' || typeof m.avg !== 'number')
146
+ continue;
147
+ const mn = m.n ?? 1;
148
+ if (m.min < min)
149
+ min = m.min;
150
+ if (m.max > max)
151
+ max = m.max;
152
+ sum += m.avg * mn;
153
+ n += mn;
154
+ if (m.last !== null && m.last !== undefined)
155
+ last = m.last;
156
+ }
157
+ return n > 0 ? { min, max, avg: sum / n, n, last } : null;
158
+ }
159
+ /** Union of dynamic path names across a set of snapshots, in first-seen order. */
160
+ function pathKeysOf(rows) {
161
+ const keys = new Set();
162
+ for (const r of rows)
163
+ if (r.path_values)
164
+ for (const k of Object.keys(r.path_values))
165
+ keys.add(k);
166
+ return [...keys];
167
+ }
137
168
  function buildHourRollup(hour, rows) {
138
169
  const sorted = [...rows].sort((a, b) => a.ts - b.ts);
139
170
  const metrics = {};
@@ -143,6 +174,18 @@ function buildHourRollup(hour, rows) {
143
174
  if (agg)
144
175
  metrics[field] = agg;
145
176
  }
177
+ // Dynamic gauges: each SK path a boat exposed this hour, aggregated like a linear
178
+ // metric. Absent (not empty) when the boat carries none, so a nav-only boat's rollup
179
+ // line is unchanged.
180
+ const path_metrics = {};
181
+ for (const key of pathKeysOf(sorted)) {
182
+ const values = sorted
183
+ .map((r) => r.path_values?.[key])
184
+ .filter((v) => typeof v === 'number');
185
+ const agg = aggregateLinear(values);
186
+ if (agg)
187
+ path_metrics[key] = agg;
188
+ }
146
189
  for (const field of LAST_ONLY_FIELDS) {
147
190
  for (let i = sorted.length - 1; i >= 0; i--) {
148
191
  const v = sorted[i]?.[field];
@@ -163,7 +206,8 @@ function buildHourRollup(hour, rows) {
163
206
  distance_nm: trackDistanceNm(sorted),
164
207
  pos_first: first ? { lat: first.lat, lon: first.lon } : null,
165
208
  pos_last: last ? { lat: last.lat, lon: last.lon } : null,
166
- metrics
209
+ metrics,
210
+ ...(Object.keys(path_metrics).length > 0 ? { path_metrics } : {})
167
211
  };
168
212
  }
169
213
  function buildDayRollup(date, hours) {
@@ -201,6 +245,19 @@ function buildDayRollup(date, hours) {
201
245
  }
202
246
  }
203
247
  }
248
+ // Dynamic gauges: merge each path's hourly aggregates into the day, weighting the
249
+ // average by sample count, same as the linear fields above.
250
+ const path_metrics = {};
251
+ const dayPathKeys = new Set();
252
+ for (const h of sorted)
253
+ if (h.path_metrics)
254
+ for (const k of Object.keys(h.path_metrics))
255
+ dayPathKeys.add(k);
256
+ for (const key of dayPathKeys) {
257
+ const agg = mergeLinearAggs(sorted.map((h) => h.path_metrics?.[key]).filter((m) => m !== undefined));
258
+ if (agg)
259
+ path_metrics[key] = agg;
260
+ }
204
261
  const firstPos = sorted.map((h) => h.pos_first).find((p) => p !== null) ?? null;
205
262
  const lastPos = [...sorted]
206
263
  .reverse()
@@ -230,7 +287,8 @@ function buildDayRollup(date, hours) {
230
287
  distance_nm: distance,
231
288
  pos_first: firstPos,
232
289
  pos_last: lastPos,
233
- metrics
290
+ metrics,
291
+ ...(Object.keys(path_metrics).length > 0 ? { path_metrics } : {})
234
292
  };
235
293
  }
236
294
  class RollupEngine {
@@ -1,6 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Uplink = void 0;
4
+ exports.reportedStatus = reportedStatus;
5
+ /**
6
+ * What the boat's own screen is told, out of the two uplinks she has.
7
+ *
8
+ * Whichever one is carrying her. While the socket is up the POST path never sends, so its
9
+ * "last sent" stays null and its failure count stays frozen at whatever it last was - and
10
+ * reporting that would tell an owner her boat has never sent a frame, or cannot reach the
11
+ * relay at all, while it is in fact streaming to her perfectly. The panel is there to answer
12
+ * "is she getting through", and there are two ways for her to be getting through.
13
+ *
14
+ * The socket has to be CONNECTED to speak for her, not merely present: everything else - a
15
+ * dial in progress, a stand-off, a dead line - means the POST path is the one being relied on,
16
+ * and so it is the one whose troubles are worth showing.
17
+ */
18
+ function reportedStatus(socket, post) {
19
+ if (socket?.connected) {
20
+ return { lastSentTs: socket.lastFrameTs, failures: 0, rejected: false, lastError: null };
21
+ }
22
+ return post;
23
+ }
4
24
  const DEFAULT_INTERVAL_MS = 60_000;
5
25
  /**
6
26
  * A boat on a marginal uplink (Starlink through a squall, a phone hotspot at the
@@ -77,6 +97,14 @@ class Uplink {
77
97
  this.schedule(this.intervalMs);
78
98
  return;
79
99
  }
100
+ // The socket has it covered. Keep the timer running rather than stopping this uplink: the
101
+ // socket drops without warning and often, and the fallback has to be already ticking when
102
+ // it does - not started by whoever notices.
103
+ if (this.deps.liveHealthy?.()) {
104
+ if (!this.stopped)
105
+ this.schedule(this.intervalMs);
106
+ return;
107
+ }
80
108
  await this.send(remote);
81
109
  // stop() may have landed while the request was in the air. Rescheduling now would
82
110
  // resurrect a stopped uplink, so check before doing it - this is the guard the