sailkick-boat 0.30.0 → 0.32.0
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/README.md +64 -4
- package/index.js +6 -1
- package/lib/history/angles.js +86 -0
- package/lib/history/ring.js +40 -7
- package/lib/proxy/index.js +14 -1
- package/lib/telemetry/index.js +74 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -203,6 +203,16 @@ because a renderer handed a gzip header errors hard while a missing tile simply
|
|
|
203
203
|
to its parent. Content that is legitimately gzip — a `.gz` asset, a gzip content-type — is
|
|
204
204
|
left alone: that is a payload, not an encoding.
|
|
205
205
|
|
|
206
|
+
**`/sw.js` is network-first too**, and it is the sharpest case. The mobile app is a PWA
|
|
207
|
+
whose service worker answers `mobile.html` and its assets from its *own* cache — before
|
|
208
|
+
the request ever reaches this mirror — and the browser installs a new worker only when
|
|
209
|
+
`sw.js`'s bytes change. So `sw.js` is the one un-hashed file that *is* the update signal:
|
|
210
|
+
serve a stale one and the whole mobile shell freezes, however fresh the copy of
|
|
211
|
+
`mobile.html` in the store happens to be. (Strictly it was never pinned *forever* — the
|
|
212
|
+
manifest's `app` family invalidates it whenever the app's sha changes — but making the
|
|
213
|
+
shell's update depend on a best-effort 5-minute poller is exactly the dependency the
|
|
214
|
+
entry-document rule exists to remove.)
|
|
215
|
+
|
|
206
216
|
## Offline map coverage — global base seed + region prefetch
|
|
207
217
|
On-demand caching only holds what you browsed. To make a usable map exist offline
|
|
208
218
|
*everywhere*, the plugin seeds a worldwide low-zoom base on start and lets you warm a
|
|
@@ -503,6 +513,44 @@ dead feed is exactly a timestamp that stops moving). The two are reconciled by b
|
|
|
503
513
|
the skew: a SignalK clock more than a minute from system time falls back to system time
|
|
504
514
|
with one warning, rather than reporting a permanent phantom "feed stale".
|
|
505
515
|
|
|
516
|
+
## A dead instrument is a gap, not a frozen value
|
|
517
|
+
|
|
518
|
+
The telemetry state accumulates every SignalK delta into one object, so without care
|
|
519
|
+
nothing in it ever expires. When **one** instrument dies while the others keep publishing,
|
|
520
|
+
its fields freeze at their last value while `updatedAt` stays fresh — and every consumer
|
|
521
|
+
treats dead data as live.
|
|
522
|
+
|
|
523
|
+
This is not hypothetical. In deep water the sounder loses the bottom and simply stops
|
|
524
|
+
publishing. Measured on this boat: for the hour after it went quiet the cloud (which
|
|
525
|
+
stores only real updates) showed an honest **gap**, while the boat's ring recorded
|
|
526
|
+
`224.85 m` every 15 seconds — a dead-flat plateau, and with min/max bands a **zero-width
|
|
527
|
+
envelope**, which is the most confident possible rendering of an instrument that is not
|
|
528
|
+
there. Two stories about the same hour; the plateau is the lie.
|
|
529
|
+
|
|
530
|
+
So every field carries the time it was last patched, and the published view omits anything
|
|
531
|
+
older than `fieldTtlSec` (default 15 s). Downstream this needs no cooperation: the ring
|
|
532
|
+
records nothing so the series gaps, the app's `Number.isFinite` guards show `—`, and the
|
|
533
|
+
alert evaluator's own "cannot tell" semantics take over — which never clears a raised
|
|
534
|
+
alarm and never raises a new one on dead data. A returning instrument reappears on its
|
|
535
|
+
first fresh delta; the internal state is never mutated, only filtered on read.
|
|
536
|
+
|
|
537
|
+
Two cases needed more than a timestamp:
|
|
538
|
+
|
|
539
|
+
- **A position expires whole.** `lat`/`lon` arrive as one `navigation.position` value, half
|
|
540
|
+
a fix is not a fix, and an anchor watch must not watch a frozen one.
|
|
541
|
+
- **`headingDeg` is computed**, not patched — it never appears in a delta, so its freshness
|
|
542
|
+
is derived from its inputs (compass + variation, or a published true heading). Without
|
|
543
|
+
that rule it would never expire, which is the frozen-heading bug surviving the fix.
|
|
544
|
+
|
|
545
|
+
**The TTL is measured, not guessed.** Every path that feeds BoatState on this boat arrives
|
|
546
|
+
at ~1 Hz with a worst observed inter-sample gap of 2.8 s, so 15 s is about seven times the
|
|
547
|
+
worst case. Crucially the subscription is fixed-period (`{ path: '*', period: 1000 }`) and
|
|
548
|
+
the server republishes unchanged values — `navigation.magneticVariation`,
|
|
549
|
+
`navigation.gnss.satellites` and `propulsion.port.runTime` all arrive at 1 Hz with 100%
|
|
550
|
+
repeated values — so a healthy-but-constant instrument (an engine at rest publishing
|
|
551
|
+
`rpm 0`) keeps arriving and does not expire. A boat configured on-change could differ,
|
|
552
|
+
which is why `proxy.fieldTtlSec` can be raised by hand.
|
|
553
|
+
|
|
506
554
|
## Two paths, one reading
|
|
507
555
|
|
|
508
556
|
Source priorities solve *several devices on one path*. There is a second, separate case:
|
|
@@ -785,10 +833,22 @@ A useful side-effect: the auto-coarsening that keeps the ring under `MAX_SAMPLES
|
|
|
785
833
|
longer lossy. Only the *emit* rate coarsens, never the poll, so a 30-day passage emitting
|
|
786
834
|
every ~52 s still carries the true min/max within each 52 s.
|
|
787
835
|
|
|
788
|
-
**Compass channels never get a band** — `twd`, `twa`, `awa`,
|
|
789
|
-
The mean of 359° and 1° is 180°, the exact
|
|
790
|
-
|
|
791
|
-
|
|
836
|
+
**Compass channels are circular-meaned and never get a band** — `twd`, `twa`, `awa`,
|
|
837
|
+
`cog`, `heading`, `wptBrg`. The *arithmetic* mean of 359° and 1° is 180°, the exact
|
|
838
|
+
opposite of the truth, so these use the unit-vector mean `atan2(Σsin, Σcos)` instead: a
|
|
839
|
+
genuine average with no seam. A min/max stays meaningless on a circle, so they carry no
|
|
840
|
+
band, ever — the one error here that would look entirely plausible on screen.
|
|
841
|
+
|
|
842
|
+
Two things the tests pin hard. **`twa` and `awa` stay signed, −180..180**, port negative,
|
|
843
|
+
because that is what the number means; a circular mean returns 0..360, so they are folded
|
|
844
|
+
back — without it a port-side wind reads as 270°, which is not a rounding difference but a
|
|
845
|
+
different quantity. (Pinned as a fuzzed invariant, not two examples.) And **readings that
|
|
846
|
+
cancel report nothing rather than north**: two exactly opposite bearings have no average,
|
|
847
|
+
and `atan2(0, 0)` answers 0° with total confidence.
|
|
848
|
+
|
|
849
|
+
Before 0.31.0 these channels served the last reading in each bucket — correct, but one
|
|
850
|
+
sample per bucket, and the emit rate coarsens to ~52 s on a 30-day passage, so a direction
|
|
851
|
+
trace was visibly jumpier aboard than ashore for the same minute.
|
|
792
852
|
|
|
793
853
|
`chans=sog,aws` narrows the answer to the channels actually plotted; `bands` appears only
|
|
794
854
|
when `stats=1` was asked *and* the provider produced them, so every client degrades to the
|
package/index.js
CHANGED
|
@@ -372,7 +372,12 @@ module.exports = function (app) {
|
|
|
372
372
|
|
|
373
373
|
if (p.serveTelemetry !== false) {
|
|
374
374
|
try {
|
|
375
|
-
|
|
375
|
+
// fieldTtlSec is a hand-editable escape hatch, not a config field: the right
|
|
376
|
+
// value follows from the bus's publish cadence, which the owner has no way to
|
|
377
|
+
// judge. Measured on this boat, every path feeding BoatState arrives at ~1 Hz
|
|
378
|
+
// (worst gap 2.8 s), so the 15 s default is ~7x margin. A boat whose SignalK is
|
|
379
|
+
// configured on-change, or with a slow NMEA0183 source, can raise it here.
|
|
380
|
+
telemetry = createTelemetry(app, { fieldTtlSec: p.fieldTtlSec })
|
|
376
381
|
telemetry.start()
|
|
377
382
|
pOpts.telemetryUpgrade = (req, sock, head) => telemetry.handleUpgrade(req, sock, head)
|
|
378
383
|
} catch (e) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// VENDORED from sailkick/shared/engine/angles.js @ a8c7153 sha256:6d001aa2e5a8f83b
|
|
2
|
+
// Do not edit here — fix upstream and re-vendor. ONE definition of compass maths, so the
|
|
3
|
+
// boat and the cloud answer "what was the average wind direction in this minute" the same
|
|
4
|
+
// way. test/history-ring.test.js exercises it through the ring.
|
|
5
|
+
//
|
|
6
|
+
// Converted ESM -> CommonJS ONLY (export keywords removed, module.exports appended).
|
|
7
|
+
// No logic changed.
|
|
8
|
+
//
|
|
9
|
+
// NB the upstream note on wrap180: it is deliberately duplicated here and in
|
|
10
|
+
// perf-live.js rather than imported, so that each stays a SINGLE-FILE vendor with its own
|
|
11
|
+
// pinned hash. The app fuzzes the two definitions against each other
|
|
12
|
+
// (tests/test-angles.mjs); the same discipline applies to this copy.
|
|
13
|
+
|
|
14
|
+
// Compass angle maths. Pure — no imports, no DOM — because shared/engine is the layer the
|
|
15
|
+
// boat plugin vendors (tests/test-layering.mjs enforces both).
|
|
16
|
+
//
|
|
17
|
+
// The one idea worth stating: a compass reading is a point on a CIRCLE, and almost every
|
|
18
|
+
// arithmetic operation you would reach for is wrong on a circle. The mean of 359° and 1° is
|
|
19
|
+
// 180°, the exact reciprocal. min/max across the seam are meaningless. A line drawn from
|
|
20
|
+
// 359° to 1° streaks the full height of a chart. Each of those has bitten this codebase.
|
|
21
|
+
//
|
|
22
|
+
// Two frames, and the whole file is about keeping them straight:
|
|
23
|
+
//
|
|
24
|
+
// WRAPPED — every value in [0,360). What instruments publish and what BoatState,
|
|
25
|
+
// /api/history/series and the dials all use. Correct for "where is it now",
|
|
26
|
+
// useless for "how did it change".
|
|
27
|
+
// UNWRAPPED — a continuous frame where 359 → 1 is recorded as 359 → 361. Differences and
|
|
28
|
+
// averages are ordinary arithmetic again. Correct for a series, meaningless
|
|
29
|
+
// as an absolute bearing until you wrap360 it at the very end.
|
|
30
|
+
//
|
|
31
|
+
// Same doctrine the routing field already uses for longitude — work in an unwrapped frame,
|
|
32
|
+
// wrap only at the boundary (shared/engine/route-field.js:48, docs/ROUTING-FIELD.md:85).
|
|
33
|
+
|
|
34
|
+
const wrap360 = (d) => ((d % 360) + 360) % 360;
|
|
35
|
+
|
|
36
|
+
// Signed shortest path, (-180, 180]. NOTE: deliberately duplicated from perf-live.js:16
|
|
37
|
+
// rather than imported. perf-live.js is vendored into sailkick-boat as a SINGLE FILE with a
|
|
38
|
+
// pinned hash (see server/history/influx-provider.js:22); giving it an import would turn a
|
|
39
|
+
// one-file copy into a two-file vendor contract. tests/test-angles.mjs fuzzes the two
|
|
40
|
+
// definitions against each other, which buys the anti-drift guarantee without the coupling.
|
|
41
|
+
const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x; };
|
|
42
|
+
|
|
43
|
+
// One step of the UNBOUNDED accumulate: the nearest equivalent of `deg` to `prev`.
|
|
44
|
+
// `prev` is the previous UNWRAPPED value, or null/undefined to seed the chain.
|
|
45
|
+
//
|
|
46
|
+
// Unbounded is the whole point, and is what separates this from the superficially identical
|
|
47
|
+
// accumulators in perf-live.js:43 and mobile/main.js:352 — both of those re-wrap on every
|
|
48
|
+
// step (bounded), because a dial must not spin away. A series must: after a full 360° veer
|
|
49
|
+
// the result is legitimately start+360, and clamping it would reintroduce the jump this
|
|
50
|
+
// exists to remove.
|
|
51
|
+
const unwrapStep = (prev, deg) => (prev == null ? wrap360(deg) : prev + wrap180(deg - prev));
|
|
52
|
+
|
|
53
|
+
// Lift a wrapped compass series into the unwrapped frame, in array order.
|
|
54
|
+
//
|
|
55
|
+
// Non-finite entries pass through untouched and DO NOT reset the chain. That is not
|
|
56
|
+
// politeness about bad data: re-seeding after a dropout picks a fresh branch of the circle,
|
|
57
|
+
// so every later point silently shifts by a multiple of 360 and the trace jumps for a reason
|
|
58
|
+
// no one will ever track down.
|
|
59
|
+
function unwrapDeg(degs) {
|
|
60
|
+
const out = new Array(degs.length);
|
|
61
|
+
let prev = null;
|
|
62
|
+
for (let i = 0; i < degs.length; i++) {
|
|
63
|
+
const d = degs[i];
|
|
64
|
+
if (!Number.isFinite(d)) { out[i] = d; continue; }
|
|
65
|
+
prev = unwrapStep(prev, d);
|
|
66
|
+
out[i] = prev;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Circular mean of wrapped degrees — the honest average of a set of directions, via the
|
|
72
|
+
// unit-vector sum. Returns wrapped degrees, or null when there is nothing to average or the
|
|
73
|
+
// directions cancel exactly (opposite readings have no meaningful mean, and atan2(0,0) would
|
|
74
|
+
// answer 0° with false confidence).
|
|
75
|
+
function circularMeanDeg(degs) {
|
|
76
|
+
let sx = 0, sy = 0, n = 0;
|
|
77
|
+
for (const d of degs) {
|
|
78
|
+
if (!Number.isFinite(d)) continue;
|
|
79
|
+
const r = d * Math.PI / 180;
|
|
80
|
+
sx += Math.cos(r); sy += Math.sin(r); n++;
|
|
81
|
+
}
|
|
82
|
+
if (!n || Math.hypot(sx, sy) < 1e-9) return null;
|
|
83
|
+
return wrap360(Math.atan2(sy, sx) * 180 / Math.PI);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { wrap360, wrap180, unwrapStep, unwrapDeg, circularMeanDeg }
|
package/lib/history/ring.js
CHANGED
|
@@ -63,7 +63,14 @@ const CHANNELS = [
|
|
|
63
63
|
// mistake here would look entirely plausible on screen, which is why the test pins it.
|
|
64
64
|
const WRAPPED = new Set(['twd', 'twa', 'awa', 'cog', 'heading', 'wptBrg'])
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// Of those, TWA and AWA are SIGNED: -180..+180 with port negative, because "40° off the
|
|
67
|
+
// bow to port" is what the number means. The other four are absolute bearings, 0..360.
|
|
68
|
+
// A circular mean always comes back 0..360, so these two MUST be folded back or a
|
|
69
|
+
// port-side wind reads as 270° — nonsense on every screen that shows it.
|
|
70
|
+
const SIGNED = new Set(['twa', 'awa'])
|
|
71
|
+
const rewrap = (c, d) => (SIGNED.has(c) ? wrap180(d) : wrap360(d))
|
|
72
|
+
|
|
73
|
+
const { wrap360, wrap180, circularMeanDeg } = require('./angles')
|
|
67
74
|
// Stored values are rounded to 3 decimals. Rows now carry a mean plus two extremes per
|
|
68
75
|
// channel, so full float noise ("6.430000000000001") would inflate every persisted line
|
|
69
76
|
// for precision no instrument has and no chart can draw.
|
|
@@ -134,7 +141,7 @@ class RingHistoryProvider {
|
|
|
134
141
|
// polls counts readings seen since the last emit. Zero means the telemetry source
|
|
135
142
|
// gave us nothing at all, and we push no row — a gap is the honest record, where a
|
|
136
143
|
// row of nulls would be a claim that we looked and the boat had no data.
|
|
137
|
-
this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, lat: null, lon: null }
|
|
144
|
+
this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, sinS: {}, cosS: {}, lat: null, lon: null }
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
// BoatState -> the flat channel map this ring records. Kept separate from the
|
|
@@ -169,7 +176,18 @@ class RingHistoryProvider {
|
|
|
169
176
|
const v = raw[c]
|
|
170
177
|
if (!Number.isFinite(v)) continue
|
|
171
178
|
a.last[c] = v
|
|
172
|
-
if (WRAPPED.has(c))
|
|
179
|
+
if (WRAPPED.has(c)) {
|
|
180
|
+
// A bearing still has no meaningful min/max — but it does have an average: the
|
|
181
|
+
// unit-vector (circular) mean, which is a genuine average with no seam. Serving
|
|
182
|
+
// one sample per bucket instead was correct but jumpy, and on a long passage the
|
|
183
|
+
// emit rate coarsens to ~52 s, so that would be one reading per bucket where the
|
|
184
|
+
// cloud averages every sample.
|
|
185
|
+
const r = v * DEG
|
|
186
|
+
a.sinS[c] = (a.sinS[c] || 0) + Math.sin(r)
|
|
187
|
+
a.cosS[c] = (a.cosS[c] || 0) + Math.cos(r)
|
|
188
|
+
a.cnt[c] = (a.cnt[c] || 0) + 1
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
173
191
|
a.sum[c] = (a.sum[c] || 0) + v
|
|
174
192
|
a.cnt[c] = (a.cnt[c] || 0) + 1
|
|
175
193
|
a.lo[c] = Math.min(a.lo[c] == null ? v : a.lo[c], v)
|
|
@@ -188,7 +206,15 @@ class RingHistoryProvider {
|
|
|
188
206
|
if (!a.polls) return
|
|
189
207
|
const row = { t: Date.now(), lo: {}, hi: {}, n: {} }
|
|
190
208
|
for (const c of CHANNELS) {
|
|
191
|
-
if (WRAPPED.has(c)) {
|
|
209
|
+
if (WRAPPED.has(c)) {
|
|
210
|
+
const n = a.cnt[c] || 0
|
|
211
|
+
// A near-zero resultant means the readings cancelled (exactly opposite bearings):
|
|
212
|
+
// there is no meaningful average, and atan2(0, 0) would answer 0° — due north,
|
|
213
|
+
// with total confidence. A gap is the honest record.
|
|
214
|
+
const mag = n ? Math.hypot(a.sinS[c], a.cosS[c]) : 0
|
|
215
|
+
row[c] = mag < 1e-9 ? null : round3(rewrap(c, wrap360(Math.atan2(a.sinS[c], a.cosS[c]) / DEG)))
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
192
218
|
const n = a.cnt[c] || 0
|
|
193
219
|
if (!n) { row[c] = null; continue }
|
|
194
220
|
row[c] = round3(a.sum[c] / n)
|
|
@@ -258,9 +284,9 @@ class RingHistoryProvider {
|
|
|
258
284
|
if (v == null) continue
|
|
259
285
|
const key = step ? Math.floor(r.t / step) * step : seq++
|
|
260
286
|
let b = buckets.get(key)
|
|
261
|
-
if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null }))
|
|
287
|
+
if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null, dirs: [] }))
|
|
262
288
|
b.last = v
|
|
263
|
-
if (wrapped) continue
|
|
289
|
+
if (wrapped) { b.dirs.push(v); continue }
|
|
264
290
|
// Rows written before v0.29.0 carry no lo/hi/n. Reading them defensively means an
|
|
265
291
|
// append-log from an older version still loads and simply yields a degenerate
|
|
266
292
|
// band (the point value itself) rather than an empty chart or a crash.
|
|
@@ -273,8 +299,15 @@ class RingHistoryProvider {
|
|
|
273
299
|
if (hi > b.hi) b.hi = hi
|
|
274
300
|
}
|
|
275
301
|
const out = [...buckets.values()].sort((a, b) => a.t - b.t)
|
|
302
|
+
// Wrapped channels re-bucket by circular mean too — unweighted, matching the
|
|
303
|
+
// cloud's provider: a row built from 15 polls counts the same as one built from 3.
|
|
304
|
+
// A small inaccuracy, kept deliberately, because the two providers answering the
|
|
305
|
+
// same question identically is worth more here than either being marginally righter
|
|
306
|
+
// alone. Buckets whose directions cancel drop out rather than reporting north.
|
|
276
307
|
const pts = wrapped
|
|
277
|
-
? out.map((b) => [b.t, b.
|
|
308
|
+
? out.map((b) => [b.t, circularMeanDeg(b.dirs)])
|
|
309
|
+
.filter(([, d]) => d != null)
|
|
310
|
+
.map(([t, d]) => [t, round3(rewrap(c, d))])
|
|
278
311
|
: out.filter((b) => b.n > 0).map((b) => [b.t, round3(b.sum / b.n)])
|
|
279
312
|
if (!pts.length) continue
|
|
280
313
|
series[c] = pts
|
package/lib/proxy/index.js
CHANGED
|
@@ -48,7 +48,20 @@ const isImmutableApi = (p) => IMMUTABLE_API_PREFIXES.some((x) => p.startsWith(x)
|
|
|
48
48
|
// copy as STALE like any network-first path, so the app still opens with no uplink.
|
|
49
49
|
// /health reports the running build and uptime — pinning it freezes the version the UI
|
|
50
50
|
// displays, which is its own small version of this bug.
|
|
51
|
-
|
|
51
|
+
//
|
|
52
|
+
// /sw.js is here for the same reason as the entry documents, and it is the sharpest case:
|
|
53
|
+
// it is the mobile PWA's ONE un-hashed file, and its bytes ARE the update signal. The
|
|
54
|
+
// browser installs a new service worker only when sw.js differs from the installed one,
|
|
55
|
+
// and until it does, the worker keeps answering mobile.html and its assets from its own
|
|
56
|
+
// cache — before the request ever reaches this mirror. So a stale sw.js freezes the whole
|
|
57
|
+
// mobile shell, and a fresh mobile.html sitting in our store makes no difference at all.
|
|
58
|
+
//
|
|
59
|
+
// It was not, strictly, pinned FOREVER: the cache-manifest's `app` family invalidates it
|
|
60
|
+
// whenever the app's sha changes (measured on this boat — the family was invalidated at
|
|
61
|
+
// 03:21 on 2026-09-01). But making the shell's update depend on a best-effort 5-minute
|
|
62
|
+
// poller is the dependency the entry-document rule above exists to remove, and if the
|
|
63
|
+
// manifest is unreachable or its endpoint changes shape, "forever" becomes literally true.
|
|
64
|
+
const LIVE_PATHS = new Set(['/health', '/sw.js'])
|
|
52
65
|
const isEntryDocument = (p) => {
|
|
53
66
|
const path = p.split('?')[0]
|
|
54
67
|
return path === '/' || path.endsWith('/') || path.endsWith('.html') ||
|
package/lib/telemetry/index.js
CHANGED
|
@@ -32,6 +32,45 @@ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
|
32
32
|
const SUBPROTOCOL = 'sailkick.telemetry.v1'
|
|
33
33
|
const wrap360d = (d) => ((d % 360) + 360) % 360
|
|
34
34
|
const wrap180 = (d) => { const w = wrap360d(d); return w > 180 ? w - 360 : w }
|
|
35
|
+
// PER-FIELD FRESHNESS. The accumulated state merges every delta into one object, so
|
|
36
|
+
// nothing in it ever expires: when ONE instrument dies while the others keep publishing,
|
|
37
|
+
// its fields freeze at their last value while `updatedAt` stays fresh, and every consumer
|
|
38
|
+
// treats dead data as live. Measured on this boat: the sounder lost the bottom in deep
|
|
39
|
+
// water and stopped publishing at 05:20, and for the hour that followed the history ring
|
|
40
|
+
// recorded 224.85 m every 15 s — a dead-flat plateau with a zero-width min/max band,
|
|
41
|
+
// which reads as "rock steady" — while the cloud, which stores only real updates, showed
|
|
42
|
+
// an honest gap for the same hour. Two stories about the same hour; the plateau is the
|
|
43
|
+
// lie. The same freeze makes a wind-speed alarm unable to fire during a wind-instrument
|
|
44
|
+
// outage, because it keeps seeing the last live reading.
|
|
45
|
+
//
|
|
46
|
+
// So each field carries the time it was last PATCHED, and the published view omits any
|
|
47
|
+
// field older than the TTL. The internal state is never mutated — filtering happens on
|
|
48
|
+
// read and on emit, so a returning instrument reappears on its first fresh delta.
|
|
49
|
+
//
|
|
50
|
+
// TTL: measured on this boat's bus, every path that feeds BoatState arrives at ~1 Hz with
|
|
51
|
+
// a worst inter-sample gap of 1.1–2.1 s (magneticVariation reaches 2.8 s from its fast
|
|
52
|
+
// sources). 15 s is ~7x the worst case. Critically, this subscription is FIXED-PERIOD
|
|
53
|
+
// ({ path: '*', period: 1000 }), and the server republishes unchanged values — proven by
|
|
54
|
+
// magneticVariation, gnss.satellites and propulsion.port.runTime arriving at 1 Hz with
|
|
55
|
+
// 100% repeated values. So a healthy-but-constant instrument (an engine at rest
|
|
56
|
+
// publishing rpm 0) keeps arriving and does NOT expire. A boat whose SignalK is
|
|
57
|
+
// configured on-change, or with a slow NMEA0183 source, could differ — hence the knob.
|
|
58
|
+
const FIELD_TTL_SEC = 15
|
|
59
|
+
|
|
60
|
+
// Fields that are ONE physical reading and must expire together. A position is published
|
|
61
|
+
// as a single navigation.position value; half a fix is not a fix, and a stale one should
|
|
62
|
+
// disappear whole so the anchor watch stops watching a frozen position.
|
|
63
|
+
const FIELD_GROUPS = [['lat', 'lon']]
|
|
64
|
+
|
|
65
|
+
// headingDeg is COMPUTED at the merge site, so it never appears in a patch and has no
|
|
66
|
+
// freshness of its own. Its inputs do: the compass path plus variation, or a published
|
|
67
|
+
// true heading. Without this it would either never expire — the frozen-heading bug
|
|
68
|
+
// surviving the fix — or expire always. (Note the `|| 0` fallback below, which is why a
|
|
69
|
+
// boat with no heading source currently reads due north rather than nothing.)
|
|
70
|
+
const COMPUTED_FRESHNESS = {
|
|
71
|
+
headingDeg: (fresh) => (fresh('hdgMagDeg') && fresh('magVarDeg')) || fresh('hdgTrueDeg')
|
|
72
|
+
}
|
|
73
|
+
|
|
35
74
|
const SEED = { sogKt: 0, cogDeg: 0, headingDeg: 0, awsKt: null, awaDeg: null }
|
|
36
75
|
|
|
37
76
|
function encodeTextFrame (str) {
|
|
@@ -197,6 +236,30 @@ function createTelemetry (app, options = {}) {
|
|
|
197
236
|
return compass
|
|
198
237
|
}
|
|
199
238
|
|
|
239
|
+
// field -> ms when it was last patched. Never pruned: it is bounded by the number of
|
|
240
|
+
// BoatState fields, and an entry for a field that never returns is a few bytes.
|
|
241
|
+
const seenAt = {}
|
|
242
|
+
const ttlMs = Math.max(1, (options.fieldTtlSec || FIELD_TTL_SEC)) * 1000
|
|
243
|
+
|
|
244
|
+
// The ONE view every consumer sees — getState(), the update broadcast and the hello
|
|
245
|
+
// frame. They must not disagree: a field the ring records but the screen omits (or the
|
|
246
|
+
// reverse) is the same class of bug as the freeze itself.
|
|
247
|
+
function publicState (now = Date.now()) {
|
|
248
|
+
if (!state) return state
|
|
249
|
+
const fresh = (k) => seenAt[k] != null && (now - seenAt[k]) < ttlMs
|
|
250
|
+
const out = { updatedAt: state.updatedAt } // whole-feed staleness stays the app's job
|
|
251
|
+
for (const [k, v] of Object.entries(state)) {
|
|
252
|
+
if (k === 'updatedAt') continue
|
|
253
|
+
const rule = COMPUTED_FRESHNESS[k]
|
|
254
|
+
if (rule ? rule(fresh) : fresh(k)) out[k] = v
|
|
255
|
+
}
|
|
256
|
+
// Grouped fields go together or not at all.
|
|
257
|
+
for (const g of FIELD_GROUPS) {
|
|
258
|
+
if (g.some((k) => !(k in out))) for (const k of g) delete out[k]
|
|
259
|
+
}
|
|
260
|
+
return out
|
|
261
|
+
}
|
|
262
|
+
|
|
200
263
|
function onDelta (delta) {
|
|
201
264
|
if (!delta || !Array.isArray(delta.updates)) return
|
|
202
265
|
const patch = {}
|
|
@@ -212,9 +275,11 @@ function createTelemetry (app, options = {}) {
|
|
|
212
275
|
state = { ...SEED }
|
|
213
276
|
}
|
|
214
277
|
state = { ...state, ...patch, updatedAt: ts || new Date().toISOString() }
|
|
278
|
+
const now = Date.now()
|
|
279
|
+
for (const k of Object.keys(patch)) seenAt[k] = now
|
|
215
280
|
const hd = resolveHeading(state)
|
|
216
281
|
state.headingDeg = Number.isFinite(hd) ? hd : (state.headingDeg || state.cogDeg || 0)
|
|
217
|
-
broadcast({ type: 'telemetry/update', state })
|
|
282
|
+
broadcast({ type: 'telemetry/update', state: publicState(now) })
|
|
218
283
|
}
|
|
219
284
|
|
|
220
285
|
function start () {
|
|
@@ -260,16 +325,21 @@ function createTelemetry (app, options = {}) {
|
|
|
260
325
|
socket.on('close', drop)
|
|
261
326
|
socket.on('error', () => { drop(); try { socket.destroy() } catch {} })
|
|
262
327
|
socket.on('data', (buf) => { if (buf && buf.length && (buf[0] & 0x0f) === 0x8) { drop(); try { socket.destroy() } catch {} } }) // client close frame
|
|
263
|
-
send(socket, { type: 'hello', source: 'signalk-local', state })
|
|
328
|
+
send(socket, { type: 'hello', source: 'signalk-local', state: publicState() })
|
|
264
329
|
}
|
|
265
330
|
|
|
266
331
|
// current BoatState (or null before the first fix) — used as the DB-less ring
|
|
267
332
|
// history source, and by tests.
|
|
268
|
-
|
|
333
|
+
// Filtered, like everything else. Consumers (the history ring, the alert engine, the
|
|
334
|
+
// polar %) then see an absent field rather than a frozen one: the ring records a gap,
|
|
335
|
+
// and the shared alert evaluator's own "cannot tell" semantics take over — which never
|
|
336
|
+
// clears a raised alarm.
|
|
337
|
+
function getState () { return publicState() }
|
|
269
338
|
function _ingest (delta) { onDelta(delta) } // for tests
|
|
270
339
|
const _state = getState
|
|
340
|
+
const _rawState = () => state // tests + status: the unfiltered accumulator
|
|
271
341
|
|
|
272
|
-
return { start, stop, status, handleUpgrade, getState, _ingest, _state }
|
|
342
|
+
return { start, stop, status, handleUpgrade, getState, _ingest, _state, _rawState, _seenAt: () => seenAt, _publicState: publicState }
|
|
273
343
|
}
|
|
274
344
|
|
|
275
345
|
module.exports = { createTelemetry, encodeTextFrame, SUBPROTOCOL }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only — info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|