sailkick-boat 0.31.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 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:
package/index.js CHANGED
@@ -372,7 +372,12 @@ module.exports = function (app) {
372
372
 
373
373
  if (p.serveTelemetry !== false) {
374
374
  try {
375
- telemetry = createTelemetry(app, {})
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) {
@@ -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
- const LIVE_PATHS = new Set(['/health'])
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') ||
@@ -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
- function getState () { return state }
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.31.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": {