sailkick-boat 0.23.3 → 0.23.7

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
@@ -341,6 +341,52 @@ at all. The raw channels are always recorded regardless, so the cloud can recomp
341
341
  history if the maths ever changes: the recorded channel is a materialisation, not the only
342
342
  truth.
343
343
 
344
+ ## Two paths, one reading
345
+
346
+ Source priorities solve *several devices on one path*. There is a second, separate case:
347
+ **several paths that mean the same thing**. Signal K publishes active-waypoint course data
348
+ under three prefixes — `navigation.courseGreatCircle.nextPoint.*`,
349
+ `navigation.courseRhumbline.nextPoint.*` and `navigation.course.calcValues.*` — and the
350
+ app maps all three onto the same readouts. A boat publishing more than one gets whichever
351
+ delta arrived last, so the waypoint distance alternates: measured on this boat, 2049.48 nm
352
+ from great circle against 2050.86 nm from the course provider, several times a second.
353
+
354
+ `sourcePriorities` cannot fix that — it arbitrates sources on ONE path, and these are
355
+ different paths, each legitimately sourced. The plugin therefore applies the precedence
356
+ the app already documents on its history side (great circle primary, the other two
357
+ `fallback: true`): a lower-priority prefix is ignored while a better one is publishing,
358
+ and takes over if that one goes quiet for 10 s.
359
+
360
+ An audit of the mapper found exactly one other case: **depth**, fed by both
361
+ `environment.depth.belowSurface` and `belowTransducer`. On a boat publishing both they
362
+ differ by the transducer offset (0.3 m here), so the reading would oscillate in shallow
363
+ water where the sounder streams. Same rule, with `belowSurface` preferred — the honest
364
+ "how much water is under me" figure, and what the mapper itself calls preferred.
365
+
366
+ ## Heading: the boat's own true heading
367
+
368
+ Two ways to know true heading — the boat publishes `navigation.headingTrue`, or it is
369
+ derived from `headingMagnetic + magneticVariation`. The plugin uses the **published**
370
+ value.
371
+
372
+ The vendored mapper prefers the derivation, citing a heading frozen at 151° while the
373
+ compass read true ~293° — but that came from another vessel's AIS data, not from a boat's
374
+ own instruments, so it is weak grounds for distrusting your own.
375
+
376
+ A cross-check is still worth having against a genuinely stuck publisher, and it runs
377
+ **only when variation is on the bus**, comparing two *true* headings. That condition
378
+ matters: without variation the derivation is raw magnetic, so comparing against it would
379
+ just measure the variation — 16° on this boat, over 20° in places — and reject a perfectly
380
+ good `headingTrue`, reporting magnetic as true. The check would have caused the very error
381
+ it exists to prevent. When variation is absent the boat is simply taken at its word.
382
+
383
+ With both available a healthy boat sits near zero (0.5° here); a gap over 10° falls back
384
+ to the compass and says so once, and recovers automatically.
385
+
386
+ This also lines the boat up with the cloud's history provider, which takes `headingTrue`
387
+ first. (Its fallback converts `headingMagnetic` **without** adding variation, so a boat
388
+ publishing only magnetic gets raw magnetic in Trends — an upstream bug, flagged.)
389
+
344
390
  ## Several devices publishing the same value
345
391
 
346
392
  A real N2K network usually has more than one device announcing a given path, and they do
@@ -30,6 +30,8 @@ const { signalkValuesToPatch, resolveHeadingDeg } = require('./signalk-map')
30
30
 
31
31
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
32
32
  const SUBPROTOCOL = 'sailkick.telemetry.v1'
33
+ const wrap360d = (d) => ((d % 360) + 360) % 360
34
+ const wrap180 = (d) => { const w = wrap360d(d); return w > 180 ? w - 360 : w }
33
35
  const SEED = { sogKt: 0, cogDeg: 0, headingDeg: 0, awsKt: null, awaDeg: null }
34
36
 
35
37
  function encodeTextFrame (str) {
@@ -41,9 +43,68 @@ function encodeTextFrame (str) {
41
43
  return Buffer.concat([header, payload])
42
44
  }
43
45
 
46
+ // Signal K publishes active-waypoint course data under THREE prefixes, and
47
+ // signalk-map.js maps all of them onto the same BoatState fields (wptDistNm and friends)
48
+ // — see COURSE_RE there. On a boat that publishes more than one, whichever delta arrives
49
+ // last wins and the readout flip-flops: measured here, courseGreatCircle said 2049.48 nm
50
+ // while course.calcValues said 2050.86 nm, alternating several times a second.
51
+ //
52
+ // Signal K's own sourcePriorities cannot fix this. It arbitrates between SOURCES on ONE
53
+ // path; here the competing values are on DIFFERENT paths, each legitimately sourced.
54
+ //
55
+ // The app already states the intended order on its history side: great circle is primary,
56
+ // the other two are `fallback: true` (server/history/influx-provider.js). Its LIVE mapper
57
+ // simply never implemented that, which does not show up in the cloud because that
58
+ // deployment reads history rather than live Signal K. So this applies the app's own
59
+ // documented precedence to the live stream.
60
+ //
61
+ // Deliberately NOT patched into lib/telemetry/signalk-map.js: that file is vendored
62
+ // verbatim from the app and must stay byte-comparable. Handed upstream so the rule can
63
+ // move into COURSE_RE and this can be deleted.
64
+ // The boat's PUBLISHED navigation.headingTrue is preferred over deriving true heading
65
+ // from magnetic + variation.
66
+ //
67
+ // The vendored mapper's resolveHeadingDeg() does the opposite. The case it cites — a
68
+ // heading frozen at 151° while the compass read true ~293° — came from ANOTHER vessel's
69
+ // AIS data, not from self telemetry, so it is weak evidence for distrusting a boat's own
70
+ // instruments. This boat's headingTrue is healthy and agrees to 0.52°.
71
+ //
72
+ // A cross-check is still worth having against a genuinely broken publisher, but ONLY when
73
+ // there is something valid to check against. resolveHeadingDeg() returns RAW MAGNETIC
74
+ // when no variation is published, and comparing a true heading against raw magnetic just
75
+ // measures the variation — 16° here, more elsewhere. A guard built on that would reject a
76
+ // perfectly good headingTrue on every boat that does not publish variation, and report
77
+ // magnetic as if it were true: a 16° error introduced by the safety check itself. So the
78
+ // comparison runs only when variation is on the bus, and both sides are true headings.
79
+ // Both sides are TRUE headings, so a healthy boat sits near zero (0.52° here). This is
80
+ // sized to catch a stuck publisher, not to police variation error.
81
+ const HEADING_DISAGREE_DEG = 10
82
+
83
+ // Each group is one BoatState field fed by several paths, most-preferred first.
84
+ const PRECEDENCE_GROUPS = [
85
+ // Active waypoint: wptBrgDeg / wptDistNm / wptVmgKt / wptTtgSec (COURSE_RE).
86
+ ['navigation.courseGreatCircle.nextPoint.',
87
+ 'navigation.courseRhumbline.nextPoint.',
88
+ 'navigation.course.calcValues.'],
89
+ // depthM. Both are published by the same transducer here, 0.3 m apart — that gap IS
90
+ // environment.depth.surfaceToTransducer. belowSurface is the honest "how much water is
91
+ // under me" number and is what signalk-map.js calls preferred, so it wins.
92
+ //
93
+ // NOTE for the re-vendor: the app's two implementations disagree here. Its live mapper
94
+ // comments belowSurface "preferred" and belowTransducer "fallback", while its history
95
+ // provider (influx-provider.js MAP) has belowTransducer primary and belowSurface
96
+ // `fallback: true` — the opposite. So live and Trends can differ by the transducer
97
+ // offset for the same instant. Flagged upstream; this follows the live mapper.
98
+ ['environment.depth.belowSurface', 'environment.depth.belowTransducer']
99
+ ]
100
+ // How long a higher-priority path stays "live" after its last value. Long enough to
101
+ // cover a slow publisher, short enough that a genuinely stopped source hands over.
102
+ const PRECEDENCE_STALE_MS = 10000
103
+
44
104
  function createTelemetry (app, options = {}) {
45
105
  const log = (m) => (app.debug ? app.debug('[telemetry] ' + m) : console.log('[sailkick-boat:telemetry]', m))
46
106
  let state = null
107
+ const pathSeen = new Map() // 'group:index' -> last ms, for the precedence above
47
108
  const clients = new Set()
48
109
  const unsubscribes = []
49
110
 
@@ -55,13 +116,68 @@ function createTelemetry (app, options = {}) {
55
116
  for (const s of clients) { try { s.write(frame) } catch { clients.delete(s) } }
56
117
  }
57
118
 
119
+ // Drop a value whose path is covered by a higher-priority sibling that is currently
120
+ // publishing. Anything outside PRECEDENCE_GROUPS passes through untouched.
121
+ function applyPrecedence (values) {
122
+ const hit = (path) => {
123
+ for (let g = 0; g < PRECEDENCE_GROUPS.length; g++) {
124
+ const i = PRECEDENCE_GROUPS[g].findIndex((p) => path === p || path.startsWith(p))
125
+ if (i >= 0) return { g, i }
126
+ }
127
+ return null
128
+ }
129
+ let touched = false
130
+ for (const v of values) {
131
+ if (!v || !v.path) continue
132
+ const h = hit(v.path)
133
+ if (h) { pathSeen.set(`${h.g}:${h.i}`, Date.now()); touched = true }
134
+ }
135
+ if (!touched) return values
136
+ const now = Date.now()
137
+ return values.filter((v) => {
138
+ if (!v || !v.path) return true
139
+ const h = hit(v.path)
140
+ if (!h || h.i === 0) return true // not grouped, or already the top choice
141
+ for (let j = 0; j < h.i; j++) {
142
+ const seen = pathSeen.get(`${h.g}:${j}`)
143
+ if (seen && now - seen < PRECEDENCE_STALE_MS) return false
144
+ }
145
+ return true
146
+ })
147
+ }
148
+
149
+ // See HEADING_DISAGREE_DEG. Returns true heading in degrees, or undefined.
150
+ let headingWarned = false
151
+ function resolveHeading (st) {
152
+ const derived = resolveHeadingDeg(st) // magnetic + variation, per the vendored mapper
153
+ const published = st.hdgTrueDeg
154
+ if (!Number.isFinite(published)) return derived
155
+ if (!Number.isFinite(derived)) return published
156
+ // Without variation, `derived` is raw MAGNETIC and the comparison would just measure
157
+ // the variation. Nothing to corroborate against — take the boat at its word.
158
+ if (!Number.isFinite(st.magVarDeg)) return published
159
+ const gap = Math.abs(wrap180(published - derived))
160
+ if (gap > HEADING_DISAGREE_DEG) {
161
+ if (!headingWarned) {
162
+ headingWarned = true
163
+ const warn = app.error ? (m) => app.error('[sailkick-boat:telemetry] ' + m) : log
164
+ warn(`navigation.headingTrue (${published.toFixed(1)}°) disagrees with the compass + variation ` +
165
+ `(${derived.toFixed(1)}°) by ${gap.toFixed(1)}° — using the compass. A headingTrue that is ` +
166
+ 'stale or static is a known failure mode; check which device publishes it.')
167
+ }
168
+ return derived
169
+ }
170
+ if (headingWarned) { headingWarned = false; log('navigation.headingTrue agrees with the compass again — using it') }
171
+ return published
172
+ }
173
+
58
174
  function onDelta (delta) {
59
175
  if (!delta || !Array.isArray(delta.updates)) return
60
176
  const patch = {}
61
177
  let ts = null
62
178
  for (const u of delta.updates) {
63
179
  if (!u || !Array.isArray(u.values)) continue
64
- Object.assign(patch, signalkValuesToPatch(u.values))
180
+ Object.assign(patch, signalkValuesToPatch(applyPrecedence(u.values)))
65
181
  if (u.timestamp) ts = u.timestamp
66
182
  }
67
183
  if (Object.keys(patch).length === 0) return
@@ -70,7 +186,7 @@ function createTelemetry (app, options = {}) {
70
186
  state = { ...SEED }
71
187
  }
72
188
  state = { ...state, ...patch, updatedAt: ts || new Date().toISOString() }
73
- const hd = resolveHeadingDeg(state)
189
+ const hd = resolveHeading(state)
74
190
  state.headingDeg = Number.isFinite(hd) ? hd : (state.headingDeg || state.cogDeg || 0)
75
191
  broadcast({ type: 'telemetry/update', state })
76
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.23.3",
3
+ "version": "0.23.7",
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 \u2014 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {