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 +5 -1
- package/plugin/dist/index.js +65 -9
- package/plugin/dist/live.js +427 -0
- package/plugin/dist/metrics.js +81 -2
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-siparu",
|
|
3
|
-
"version": "0.1.
|
|
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
|
}
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/plugin/dist/metrics.js
CHANGED
|
@@ -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
|
-
|
|
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 = {};
|
package/plugin/dist/query.js
CHANGED
|
@@ -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));
|
package/plugin/dist/rest.js
CHANGED
|
@@ -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');
|
package/plugin/dist/rollup.js
CHANGED
|
@@ -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 {
|
package/plugin/dist/uplink.js
CHANGED
|
@@ -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
|