sailkick-boat 0.23.3 → 0.23.8

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,62 @@ 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
+ Both are TRUE headings and on this boat they agree to 0.12°, so correctness does not
373
+ separate them. **Resolution does:**
374
+
375
+ | | rate | resolution |
376
+ |---|---|---|
377
+ | `navigation.headingTrue` (AIS transponder) | 1 Hz | whole degrees |
378
+ | `navigation.headingMagnetic` (Precision-9) | 20 Hz | 0.006° |
379
+
380
+ AIS transmits heading as an integer, so the transponder rounds before publishing. The
381
+ compass is the same underlying sensor without that rounding, so using it costs nothing in
382
+ accuracy and gains 20× the rate — a display that flows instead of stepping once a second.
383
+
384
+ The published value is still used, as an **independent check**: if the two disagree by
385
+ more than 10° something is broken and the log says so, naming both numbers. The displayed
386
+ value does not change on the strength of that.
387
+
388
+ The comparison runs **only when variation is on the bus** — without it the compass yields
389
+ raw magnetic, which is wrong by the local declination (16° here), so there is no valid
390
+ compass option at all and the boat's own `headingTrue` is used instead.
391
+
392
+ > This is a deliberate divergence from the app, which makes `headingTrue` authoritative on
393
+ > correctness grounds without weighing transmission rounding. Handed back upstream; if it
394
+ > ever prefers the higher-resolution source when both are true, this can be dropped.
395
+
396
+ This also lines the boat up with the cloud's history provider, which takes `headingTrue`
397
+ first. (Its fallback converts `headingMagnetic` **without** adding variation, so a boat
398
+ publishing only magnetic gets raw magnetic in Trends — an upstream bug, flagged.)
399
+
344
400
  ## Several devices publishing the same value
345
401
 
346
402
  A real N2K network usually has more than one device announcing a given path, and they do
package/index.js CHANGED
@@ -45,7 +45,12 @@ const SYNC_TUNING = {
45
45
  retryMinMs: 1000,
46
46
  retryMaxMs: 60000
47
47
  }
48
- const PROXY_TUNING = { requestTimeoutMs: 20000, localPaths: ['/signalk'], telemetryPath: '/ws/telemetry' }
48
+ // requestTimeoutMs is for CACHED fetches (tiles, assets) where 20s is generous.
49
+ // relayTimeoutMs is for the transparent non-GET relay, which carries requests that are
50
+ // legitimately slow: /api/isochrone is weather routing and the app itself allows 120s for
51
+ // it (FETCH_TIMEOUT_MS in public/engine/wind-client.js). Applying the tile timeout to the
52
+ // relay killed every route that took longer and returned 502.
53
+ const PROXY_TUNING = { requestTimeoutMs: 20000, relayTimeoutMs: 180000, localPaths: ['/signalk'], telemetryPath: '/ws/telemetry' }
49
54
  const MANIFEST = { enabled: true, path: '/api/cache-manifest', pollIntervalSec: 300 }
50
55
  const SEED_TUNING = { coastlineMaxZoom: 8, seabedMaxZoom: 6, concurrency: 4 }
51
56
  const PREFETCH_TUNING = { concurrency: 4 }
@@ -286,6 +291,7 @@ module.exports = function (app) {
286
291
  localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
287
292
  telemetryPath: p.telemetryPath || PROXY_TUNING.telemetryPath,
288
293
  requestTimeoutMs: p.requestTimeoutMs || PROXY_TUNING.requestTimeoutMs,
294
+ relayTimeoutMs: p.relayTimeoutMs || PROXY_TUNING.relayTimeoutMs,
289
295
  openAccess: p.openAccess !== false, // single-tenant boat: the cloud login gate can't work over plain HTTP
290
296
  manifest: MANIFEST, // always on — freshness comes from the cloud announcing bakes
291
297
  seed: {
@@ -88,6 +88,8 @@ function createProxy (app, options) {
88
88
  upstream: options.sailkickUrl.replace(/\/+$/, ''),
89
89
  storeDir: options.storeDir || path.join(dataDir, 'store'),
90
90
  timeoutMs: options.requestTimeoutMs || 20000,
91
+ // The relay carries slow work: /api/isochrone routing budgets 120s app-side.
92
+ relayTimeoutMs: options.relayTimeoutMs || 180000,
91
93
  localSignalk: (options.localSignalkUrl || 'http://127.0.0.1:3000').replace(/\/+$/, ''),
92
94
  localPaths: (options.localPaths && options.localPaths.length) ? options.localPaths : ['/signalk'],
93
95
  telemetryPath: options.telemetryPath || '/ws/telemetry',
@@ -339,8 +341,23 @@ function createProxy (app, options) {
339
341
  }
340
342
  let r
341
343
  try {
342
- r = await request(target, { method: req.method, headers: fwd, body: chunks.length ? Buffer.concat(chunks) : undefined, timeoutMs: cfg.timeoutMs })
343
- } catch (e) { res.statusCode = 502; res.end('upstream unreachable'); return }
344
+ r = await request(target, {
345
+ method: req.method,
346
+ headers: fwd,
347
+ body: chunks.length ? Buffer.concat(chunks) : undefined,
348
+ timeoutMs: cfg.relayTimeoutMs
349
+ })
350
+ } catch (e) {
351
+ // Say WHICH failure. A timeout on a long computation and an unreachable host need
352
+ // different fixes, and a bare 'upstream unreachable' claimed to know which it was.
353
+ const timedOut = e.code === 'ETIMEDOUT'
354
+ log(`relay ${req.method} ${req.url.split('?')[0]} failed — ${e.code || e.message}`)
355
+ res.statusCode = timedOut ? 504 : 502
356
+ res.end(timedOut
357
+ ? `upstream timed out after ${Math.round(cfg.relayTimeoutMs / 1000)}s`
358
+ : `upstream unreachable (${e.code || 'error'})`)
359
+ return
360
+ }
344
361
  res.statusCode = r.status
345
362
  r.headers.forEach((v, k) => {
346
363
  const kl = k.toLowerCase()
@@ -24,7 +24,7 @@
24
24
  // sha256(app public/engine/signalk-map.js)[0..12] as ported in v0.18.6
25
25
  const { request } = require('../net') // owned connection pool + real error codes
26
26
 
27
- const PINNED_APP_HASH = '2015ae986cd8'
27
+ const PINNED_APP_HASH = '3e42369c002b'
28
28
 
29
29
  function createContractCheck (app, options = {}) {
30
30
  const warn = (m) => (app.error ? app.error('[sailkick-boat:contract] ' + m) : console.error('[sailkick-boat:contract]', m))
@@ -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,86 @@ 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
+ // DELIBERATE DIVERGENCE FROM UPSTREAM, at the owner's request.
65
+ //
66
+ // resolveHeadingDeg() in the vendored mapper now returns navigation.headingTrue when it is
67
+ // present (348c3d9, "true heading is authoritative"). That is right on CORRECTNESS — raw
68
+ // magnetic would be wrong by the local variation — but on this boat both candidates are
69
+ // true headings that agree to 0.12°, so correctness is not what separates them. Resolution
70
+ // is, and by a wide margin:
71
+ //
72
+ // navigation.headingTrue NMEA.31 NAIS 500 1 Hz, quantised to 1°
73
+ // navigation.headingMagnetic NMEA.23 Precision-9 20 Hz, 0.006° steps
74
+ //
75
+ // The AIS transponder rounds heading to a whole degree before transmitting — normal for
76
+ // AIS, but it makes the display step once a second. The Precision-9 is the same underlying
77
+ // compass the transponder derives from, so taking it directly costs nothing in accuracy
78
+ // and gains 20x the rate.
79
+ //
80
+ // The published value is still used, as an INDEPENDENT check: if the two disagree
81
+ // materially something is broken, and the log says so. The displayed value never changes
82
+ // on the strength of that, because falling back to a 1 Hz 1° source would trade a
83
+ // suspected problem for a certainly worse reading.
84
+ //
85
+ // Handed upstream: the app's choice is about correctness and does not weigh transmission
86
+ // rounding. If it ever prefers the higher-resolution source when both are true, this whole
87
+ // block can go.
88
+ const HEADING_DISAGREE_DEG = 10
89
+
90
+ // Cross-delta precedence for fields fed by several paths.
91
+ //
92
+ // Upstream now ranks these inside signalkValuesToPatch (b519a9f) — but `courseRank` and
93
+ // `depthRank` are declared per CALL, so that ranking only orders values within ONE delta.
94
+ // On this boat the competing publishers are different sources sending SEPARATE deltas
95
+ // (courseGreatCircle from NMEA.24, course.calcValues from course-provider), so last one
96
+ // in still wins and the readout flip-flops between the two solves. Measured: 2049.48 nm
97
+ // against 2050.86 nm, several times a second.
98
+ //
99
+ // This layer remembers which path last spoke and for how long, so the precedence survives
100
+ // across deltas. It is complementary to the upstream fix, not a duplicate — delete it only
101
+ // if the ranking upstream ever becomes stateful.
102
+ const PRECEDENCE_GROUPS = [
103
+ // Active waypoint: wptBrgDeg / wptDistNm / wptVmgKt / wptTtgSec (COURSE_RE).
104
+ ['navigation.courseGreatCircle.nextPoint.',
105
+ 'navigation.courseRhumbline.nextPoint.',
106
+ 'navigation.course.calcValues.'],
107
+ // depthM. Both are published by the same transducer here, 0.3 m apart — that gap IS
108
+ // environment.depth.surfaceToTransducer. belowSurface is the honest "how much water is
109
+ // under me" number and is what signalk-map.js calls preferred, so it wins.
110
+ //
111
+ // NOTE for the re-vendor: the app's two implementations disagree here. Its live mapper
112
+ // comments belowSurface "preferred" and belowTransducer "fallback", while its history
113
+ // provider (influx-provider.js MAP) has belowTransducer primary and belowSurface
114
+ // `fallback: true` — the opposite. So live and Trends can differ by the transducer
115
+ // offset for the same instant. Flagged upstream; this follows the live mapper.
116
+ ['environment.depth.belowSurface', 'environment.depth.belowTransducer']
117
+ ]
118
+ // How long a higher-priority path stays "live" after its last value. Long enough to
119
+ // cover a slow publisher, short enough that a genuinely stopped source hands over.
120
+ const PRECEDENCE_STALE_MS = 10000
121
+
44
122
  function createTelemetry (app, options = {}) {
45
123
  const log = (m) => (app.debug ? app.debug('[telemetry] ' + m) : console.log('[sailkick-boat:telemetry]', m))
46
124
  let state = null
125
+ const pathSeen = new Map() // 'group:index' -> last ms, for the precedence above
47
126
  const clients = new Set()
48
127
  const unsubscribes = []
49
128
 
@@ -55,13 +134,76 @@ function createTelemetry (app, options = {}) {
55
134
  for (const s of clients) { try { s.write(frame) } catch { clients.delete(s) } }
56
135
  }
57
136
 
137
+ // Drop a value whose path is covered by a higher-priority sibling that is currently
138
+ // publishing. Anything outside PRECEDENCE_GROUPS passes through untouched.
139
+ function applyPrecedence (values) {
140
+ const hit = (path) => {
141
+ for (let g = 0; g < PRECEDENCE_GROUPS.length; g++) {
142
+ const i = PRECEDENCE_GROUPS[g].findIndex((p) => path === p || path.startsWith(p))
143
+ if (i >= 0) return { g, i }
144
+ }
145
+ return null
146
+ }
147
+ let touched = false
148
+ for (const v of values) {
149
+ if (!v || !v.path) continue
150
+ const h = hit(v.path)
151
+ if (h) { pathSeen.set(`${h.g}:${h.i}`, Date.now()); touched = true }
152
+ }
153
+ if (!touched) return values
154
+ const now = Date.now()
155
+ return values.filter((v) => {
156
+ if (!v || !v.path) return true
157
+ const h = hit(v.path)
158
+ if (!h || h.i === 0) return true // not grouped, or already the top choice
159
+ for (let j = 0; j < h.i; j++) {
160
+ const seen = pathSeen.get(`${h.g}:${j}`)
161
+ if (seen && now - seen < PRECEDENCE_STALE_MS) return false
162
+ }
163
+ return true
164
+ })
165
+ }
166
+
167
+ // See HEADING_DISAGREE_DEG. Returns TRUE heading in degrees, or undefined.
168
+ let headingWarned = false
169
+ function resolveHeading (st) {
170
+ const published = Number.isFinite(st.hdgTrueDeg) ? st.hdgTrueDeg : undefined
171
+ // Compass-derived TRUE heading. Only valid when variation is known — without it this
172
+ // would be RAW MAGNETIC, wrong by the local declination (16° here), which is exactly
173
+ // what upstream's 348c3d9 set out to eliminate. So no variation, no compass option.
174
+ const compass = (Number.isFinite(st.hdgMagDeg) && Number.isFinite(st.magVarDeg))
175
+ ? wrap360d(st.hdgMagDeg + st.magVarDeg)
176
+ : undefined
177
+
178
+ // Neither of ours applies — let the vendored mapper decide (it handles the remaining
179
+ // cases, including magnetic-without-variation, however upstream judges best).
180
+ if (compass === undefined) return published !== undefined ? published : resolveHeadingDeg(st)
181
+
182
+ if (published !== undefined) {
183
+ const gap = Math.abs(wrap180(published - compass))
184
+ if (gap > HEADING_DISAGREE_DEG) {
185
+ if (!headingWarned) {
186
+ headingWarned = true
187
+ const warn = app.error ? (m) => app.error('[sailkick-boat:telemetry] ' + m) : log
188
+ warn(`navigation.headingTrue (${published.toFixed(1)}°) disagrees with the compass + variation ` +
189
+ `(${compass.toFixed(1)}°) by ${gap.toFixed(1)}° — displaying the compass. Check which device ` +
190
+ 'publishes headingTrue, and the variation in use.')
191
+ }
192
+ } else if (headingWarned) {
193
+ headingWarned = false
194
+ log('navigation.headingTrue agrees with the compass again')
195
+ }
196
+ }
197
+ return compass
198
+ }
199
+
58
200
  function onDelta (delta) {
59
201
  if (!delta || !Array.isArray(delta.updates)) return
60
202
  const patch = {}
61
203
  let ts = null
62
204
  for (const u of delta.updates) {
63
205
  if (!u || !Array.isArray(u.values)) continue
64
- Object.assign(patch, signalkValuesToPatch(u.values))
206
+ Object.assign(patch, signalkValuesToPatch(applyPrecedence(u.values)))
65
207
  if (u.timestamp) ts = u.timestamp
66
208
  }
67
209
  if (Object.keys(patch).length === 0) return
@@ -70,7 +212,7 @@ function createTelemetry (app, options = {}) {
70
212
  state = { ...SEED }
71
213
  }
72
214
  state = { ...state, ...patch, updatedAt: ts || new Date().toISOString() }
73
- const hd = resolveHeadingDeg(state)
215
+ const hd = resolveHeading(state)
74
216
  state.headingDeg = Number.isFinite(hd) ? hd : (state.headingDeg || state.cogDeg || 0)
75
217
  broadcast({ type: 'telemetry/update', state })
76
218
  }
@@ -1,136 +1,195 @@
1
- 'use strict'
1
+ // VENDORED from sailkick/public/engine/signalk-map.js @ dc57057 sha256:3e42369c002b7cb7
2
+ // Do not edit here — fix upstream and re-vendor. One SignalK -> BoatState mapping, or
3
+ // the boat and the app quietly disagree (it has drifted twice; both times silently).
4
+ //
5
+ // Converted ESM -> CommonJS ONLY. No logic changed.
6
+ //
7
+ // Re-vendored after b519a9f (course + depth precedence) and 348c3d9 (heading true
8
+ // authoritative) — both of which came from findings handed over from this repo.
2
9
 
3
- // SignalK -> BoatState mapping. Ported VERBATIM from the sailkick app
4
- // (public/engine/signalk-map.js) so the plugin's /ws/telemetry is identical to
5
- // the server's SignalKSource. SignalK emits SI units (m/s, radians); BoatState
6
- // uses knots + degrees. Keep in sync with the app copy.
10
+ // SignalK BoatState mapping pure, dependency-free, so it's shared by both
11
+ // the client provider (public/ui/boat-panel.js, browser WebSocket) and the
12
+ // server-side SignalKSource (server/telemetry/signalk.js, ws). No imports.
13
+ // SignalK emits SI units (m/s, radians); BoatState uses knots + degrees.
7
14
 
8
- const MS_TO_KT = 1.94384
9
- const RAD2DEG = 180 / Math.PI
10
- const wrap360 = (d) => ((d % 360) + 360) % 360
11
- const wrap180 = (d) => { const w = wrap360(d); return w > 180 ? w - 360 : w }
15
+ const MS_TO_KT = 1.94384;
16
+ const RAD2DEG = 180 / Math.PI;
17
+ const wrap360 = (d) => ((d % 360) + 360) % 360;
18
+ const wrap180 = (d) => { const w = wrap360(d); return w > 180 ? w - 360 : w; };
12
19
 
13
20
  // Active-waypoint course data. SignalK publishes it under three prefixes
14
21
  // depending on server version/config (v1 courseGreatCircle/courseRhumbline,
15
- // v2 course.calcValues) — all carry the same SI values (rad, m, m/s, s).
16
- const COURSE_RE = /^navigation\.(?:course(?:GreatCircle|Rhumbline)\.nextPoint|course\.calcValues)\.(bearingTrue|distance|velocityMadeGood|timeToGo)$/
22
+ // v2 course.calcValues) — all carry the same SI values (rad, m, m/s, s). A boat
23
+ // commonly publishes SEVERAL of them in the same delta; great-circle is PRIMARY
24
+ // and the others only fill a field it left empty (mirrors the primary/fallback
25
+ // ordering in server/history/influx-provider.js). Group 1 = source prefix,
26
+ // group 2 = value suffix.
27
+ const COURSE_RE = /^navigation\.(courseGreatCircle\.nextPoint|courseRhumbline\.nextPoint|course\.calcValues)\.(bearingTrue|distance|velocityMadeGood|timeToGo)$/;
28
+ const COURSE_FIELD = { bearingTrue: 'wptBrgDeg', distance: 'wptDistNm', velocityMadeGood: 'wptVmgKt', timeToGo: 'wptTtgSec' };
17
29
 
18
- function signalkValuesToPatch (values) {
19
- const patch = {}
20
- if (!Array.isArray(values)) return patch
30
+ // A SignalK delta `values` array → a partial BoatState patch. Skips
31
+ // null / missing / non-finite values (SignalK sends null when a sensor is absent).
32
+ function signalkValuesToPatch(values) {
33
+ const patch = {};
34
+ if (!Array.isArray(values)) return patch;
35
+ const courseRank = {}; // wpt field → rank of the source that set it (great-circle 2, fallback 1)
36
+ let depthRank = 0; // rank of the depth source that set patch.depthM (belowSurface 2, transducer 1)
21
37
  for (const v of values) {
22
- if (!v || !v.path) continue
38
+ if (!v || !v.path) continue;
23
39
  // Course paths first: unlike sensors, a null here MEANS something — the
24
40
  // destination was cleared — so propagate it instead of skipping, or the
25
41
  // ribbon would show stale waypoint numbers forever.
26
- const course = v.path.match(COURSE_RE)
42
+ const course = v.path.match(COURSE_RE);
27
43
  if (course) {
28
- const suffix = course[1]
29
- if (suffix === 'bearingTrue') patch.wptBrgDeg = Number.isFinite(v.value) ? wrap360(v.value * RAD2DEG) : null
30
- else if (suffix === 'distance') patch.wptDistNm = Number.isFinite(v.value) ? Math.max(0, v.value / 1852) : null
31
- else if (suffix === 'velocityMadeGood') patch.wptVmgKt = Number.isFinite(v.value) ? v.value * MS_TO_KT : null
32
- else patch.wptTtgSec = Number.isFinite(v.value) && v.value >= 0 ? v.value : null
33
- continue
44
+ const [, src, suffix] = course;
45
+ // Great-circle wins; rhumbline / calcValues only fill a field it left empty
46
+ // this delta. Without this, a boat publishing several course prefixes would
47
+ // last-writer-win and flip-flop the waypoint readouts between the two solves.
48
+ const rank = src === 'courseGreatCircle.nextPoint' ? 2 : 1;
49
+ if ((courseRank[COURSE_FIELD[suffix]] || 0) > rank) continue; // a stronger source already set it
50
+ courseRank[COURSE_FIELD[suffix]] = rank;
51
+ if (suffix === 'bearingTrue') patch.wptBrgDeg = Number.isFinite(v.value) ? wrap360(v.value * RAD2DEG) : null;
52
+ else if (suffix === 'distance') patch.wptDistNm = Number.isFinite(v.value) ? Math.max(0, v.value / 1852) : null;
53
+ else if (suffix === 'velocityMadeGood') patch.wptVmgKt = Number.isFinite(v.value) ? v.value * MS_TO_KT : null;
54
+ else patch.wptTtgSec = Number.isFinite(v.value) && v.value >= 0 ? v.value : null;
55
+ continue;
34
56
  }
35
- if (v.value == null) continue
57
+ if (v.value == null) continue;
36
58
  switch (v.path) {
37
59
  case 'navigation.position': {
38
- const { latitude, longitude } = v.value || {}
60
+ const { latitude, longitude } = v.value || {};
39
61
  if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
40
- patch.lat = latitude
41
- patch.lon = longitude
62
+ patch.lat = latitude;
63
+ patch.lon = longitude;
42
64
  }
43
- break
65
+ break;
44
66
  }
45
67
  case 'navigation.speedOverGround':
46
- if (Number.isFinite(v.value)) patch.sogKt = Math.max(0, v.value * MS_TO_KT)
47
- break
68
+ if (Number.isFinite(v.value)) patch.sogKt = Math.max(0, v.value * MS_TO_KT);
69
+ break;
48
70
  case 'navigation.speedThroughWater':
49
- if (Number.isFinite(v.value)) patch.stwKt = Math.max(0, v.value * MS_TO_KT)
50
- break
71
+ if (Number.isFinite(v.value)) patch.stwKt = Math.max(0, v.value * MS_TO_KT);
72
+ break;
51
73
  case 'navigation.courseOverGroundTrue':
52
- if (Number.isFinite(v.value)) patch.cogDeg = wrap360(v.value * RAD2DEG)
53
- break
74
+ if (Number.isFinite(v.value)) patch.cogDeg = wrap360(v.value * RAD2DEG);
75
+ break;
76
+ // Heading: emit the RAW components (deg). Resolution happens in
77
+ // resolveHeadingDeg() against the ACCUMULATED state, so a heading-only
78
+ // message still uses the last-known variation instead of flip-flopping
79
+ // between true-heading and raw-magnetic.
54
80
  case 'navigation.headingTrue':
55
- if (Number.isFinite(v.value)) patch.hdgTrueDeg = wrap360(v.value * RAD2DEG)
56
- break
81
+ if (Number.isFinite(v.value)) patch.hdgTrueDeg = wrap360(v.value * RAD2DEG);
82
+ break;
57
83
  case 'navigation.headingMagnetic':
58
- if (Number.isFinite(v.value)) patch.hdgMagDeg = v.value * RAD2DEG
59
- break
84
+ if (Number.isFinite(v.value)) patch.hdgMagDeg = v.value * RAD2DEG; // wrapped after variation
85
+ break;
60
86
  case 'navigation.magneticVariation':
61
- if (Number.isFinite(v.value) && v.value !== 0) patch.magVarDeg = v.value * RAD2DEG
62
- break
87
+ // Skip a 0 sentinel (a common "no-data" value): applying 0 is a no-op
88
+ // anyway, and retaining the last real variation avoids a heading
89
+ // flip-flop when the source intermittently reports 0.
90
+ if (Number.isFinite(v.value) && v.value !== 0) patch.magVarDeg = v.value * RAD2DEG; // east positive
91
+ break;
63
92
  case 'environment.wind.speedApparent':
64
- if (Number.isFinite(v.value)) patch.awsKt = Math.max(0, v.value * MS_TO_KT)
65
- break
93
+ if (Number.isFinite(v.value)) patch.awsKt = Math.max(0, v.value * MS_TO_KT);
94
+ break;
66
95
  case 'environment.wind.angleApparent':
67
- if (Number.isFinite(v.value)) patch.awaDeg = wrap180(v.value * RAD2DEG)
68
- break
96
+ if (Number.isFinite(v.value)) patch.awaDeg = wrap180(v.value * RAD2DEG);
97
+ break;
69
98
  case 'environment.wind.speedTrue':
70
- if (Number.isFinite(v.value)) patch.twsKt = Math.max(0, v.value * MS_TO_KT)
71
- break
72
- case 'environment.wind.directionTrue': // absolute compass direction (not off-bow)
73
- if (Number.isFinite(v.value)) patch.twdDeg = wrap360(v.value * RAD2DEG)
74
- break
75
- case 'environment.wind.angleTrueWater': // true wind ANGLE off the bow, port negative
76
- if (Number.isFinite(v.value)) patch.twaDeg = wrap180(v.value * RAD2DEG)
77
- break
78
- case 'performance.velocityMadeGood': // signed: negative = losing ground to windward
79
- if (Number.isFinite(v.value)) patch.vmgKt = v.value * MS_TO_KT
80
- break
81
- case 'environment.depth.belowSurface':
82
- case 'environment.depth.belowTransducer':
83
- if (Number.isFinite(v.value)) patch.depthM = v.value
84
- break
99
+ if (Number.isFinite(v.value)) patch.twsKt = Math.max(0, v.value * MS_TO_KT);
100
+ break;
101
+ case 'environment.wind.directionTrue': // absolute compass direction (not off-bow)
102
+ if (Number.isFinite(v.value)) patch.twdDeg = wrap360(v.value * RAD2DEG);
103
+ break;
104
+ case 'environment.wind.angleTrueWater': // true wind ANGLE off the bow, port negative
105
+ if (Number.isFinite(v.value)) patch.twaDeg = wrap180(v.value * RAD2DEG);
106
+ break;
107
+ case 'performance.velocityMadeGood': // signed: negative = losing ground to windward
108
+ if (Number.isFinite(v.value)) patch.vmgKt = v.value * MS_TO_KT;
109
+ break;
110
+ case 'environment.depth.belowSurface': // primary: actual water depth (transducer offset applied)
111
+ case 'environment.depth.belowTransducer': { // fallback: raw sounder reading (offset not applied)
112
+ // belowSurface is authoritative; the transducer reading only fills in when
113
+ // belowSurface is absent. Without the rank guard, a boat publishing both
114
+ // would last-writer-win and jump by the transducer offset.
115
+ const rank = v.path === 'environment.depth.belowSurface' ? 2 : 1;
116
+ if (rank >= depthRank && Number.isFinite(v.value)) { patch.depthM = v.value; depthRank = rank; }
117
+ break;
118
+ }
85
119
  // (active-waypoint course paths are handled above via COURSE_RE — all three
86
120
  // publish prefixes, with nulls propagated so a cleared goto clears the values)
87
- case 'environment.water.temperature': // sea temperature, K → °C
88
- if (Number.isFinite(v.value)) patch.seaTempC = v.value - 273.15
89
- break
90
- case 'environment.outside.temperature': // air temperature, K → °C
91
- if (Number.isFinite(v.value)) patch.airTempC = v.value - 273.15
92
- break
93
- case 'propulsion.port.revolutions': // engine speed, Hz → rpm
94
- if (Number.isFinite(v.value)) patch.rpmPort = Math.max(0, v.value * 60)
95
- break
121
+ case 'environment.water.temperature': // sea temperature, K → °C
122
+ if (Number.isFinite(v.value)) patch.seaTempC = v.value - 273.15;
123
+ break;
124
+ case 'environment.outside.temperature': // air temperature, K → °C
125
+ if (Number.isFinite(v.value)) patch.airTempC = v.value - 273.15;
126
+ break;
127
+ case 'propulsion.port.revolutions': // engine speed, Hz → rpm
128
+ if (Number.isFinite(v.value)) patch.rpmPort = Math.max(0, v.value * 60);
129
+ break;
96
130
  case 'propulsion.starboard.revolutions':
97
- if (Number.isFinite(v.value)) patch.rpmStbd = Math.max(0, v.value * 60)
98
- break
131
+ if (Number.isFinite(v.value)) patch.rpmStbd = Math.max(0, v.value * 60);
132
+ break;
99
133
  case 'steering.rudderAngle':
100
- if (Number.isFinite(v.value)) patch.rudderDeg = v.value * RAD2DEG
101
- break
134
+ if (Number.isFinite(v.value)) patch.rudderDeg = v.value * RAD2DEG;
135
+ break;
102
136
  case 'navigation.rateOfTurn':
103
- if (Number.isFinite(v.value)) patch.rotDegMin = v.value * RAD2DEG * 60
104
- break
105
- case 'navigation.attitude': // object { roll, pitch, yaw } (rad)
106
- if (v.value && Number.isFinite(v.value.roll)) patch.heelDeg = v.value.roll * RAD2DEG
107
- break
137
+ if (Number.isFinite(v.value)) patch.rotDegMin = v.value * RAD2DEG * 60;
138
+ break;
139
+ case 'navigation.attitude': // object { roll, pitch, yaw } (rad)
140
+ if (Number.isFinite(v.value?.roll)) patch.heelDeg = v.value.roll * RAD2DEG;
141
+ break;
108
142
  case 'steering.autopilot.state':
109
- if (typeof v.value === 'string') patch.apState = v.value
110
- break
143
+ if (typeof v.value === 'string') patch.apState = v.value;
144
+ break;
111
145
  case 'steering.autopilot.target.headingTrue':
112
- if (Number.isFinite(v.value)) patch.apTargetDeg = wrap360(v.value * RAD2DEG)
113
- break
146
+ if (Number.isFinite(v.value)) patch.apTargetDeg = wrap360(v.value * RAD2DEG);
147
+ break;
114
148
  case 'steering.autopilot.target.windAngleApparent':
115
- if (Number.isFinite(v.value)) patch.apTargetAwa = wrap180(v.value * RAD2DEG)
116
- break
117
- case 'navigation.datetime':
118
- if (typeof v.value === 'string') patch.gpsTime = v.value
119
- break
149
+ if (Number.isFinite(v.value)) patch.apTargetAwa = wrap180(v.value * RAD2DEG);
150
+ break;
151
+ case 'navigation.datetime': // GNSS UTC time (from the satellites)
152
+ if (typeof v.value === 'string') patch.gpsTime = v.value;
153
+ break;
120
154
  default:
121
- break
155
+ break; // ignore everything else
122
156
  }
123
157
  }
124
- return patch
158
+ return patch;
125
159
  }
126
160
 
127
- function resolveHeadingDeg (state) {
128
- if (!state) return undefined
161
+ // Resolve the boat's true heading (deg) from accumulated components: prefer
162
+ // navigation.headingTrue; else navigation.headingMagnetic + the last-known
163
+ // magneticVariation. Because the raw components live in the accumulated state
164
+ // (not resolved per-message), a message carrying only headingMagnetic still
165
+ // gets the retained variation applied — no jumping. Returns undefined if no
166
+ // heading is known yet.
167
+ function resolveHeadingDeg(state) {
168
+ if (!state) return undefined;
169
+ // TRUE heading is authoritative. Raw magnetic heading (no variation applied) is
170
+ // dangerously wrong for sailing — off by the local declination, tens of degrees
171
+ // in places — so navigation.headingTrue wins. Only when it is absent do we DERIVE
172
+ // true from the magnetic compass + last-known variation; that still yields TRUE
173
+ // (never raw magnetic), so the fallback is safe, not a magnetic readout.
174
+ // (This boat once published a frozen headingTrue stuck at 151° while truly ~293°,
175
+ // which is why magnetic was temporarily preferred; verified 2026-08-20 that its
176
+ // NMEA.31 headingTrue is live at ~1 Hz, tracks the full 0–359° span, and agrees
177
+ // with magnetic+variation to ~1° — so true is primary again.)
178
+ if (Number.isFinite(state.hdgTrueDeg)) return state.hdgTrueDeg;
129
179
  if (Number.isFinite(state.hdgMagDeg)) {
130
- return wrap360(state.hdgMagDeg + (Number.isFinite(state.magVarDeg) ? state.magVarDeg : 0))
180
+ return wrap360(state.hdgMagDeg + (Number.isFinite(state.magVarDeg) ? state.magVarDeg : 0));
131
181
  }
132
- if (Number.isFinite(state.hdgTrueDeg)) return state.hdgTrueDeg
133
- return undefined
182
+ return undefined;
183
+ }
184
+
185
+ // Build the delta-stream URL from a base host/URL. Accepts a bare base
186
+ // (`ws://host:3000`) and appends the self stream path; passes a full
187
+ // `…/signalk/…` URL through untouched. Adds `&token=` only if a token is set.
188
+ function streamUrl(base, token) {
189
+ let url = String(base || '').trim().replace(/\/+$/, '');
190
+ if (!/\/signalk\//.test(url)) url += '/signalk/v1/stream?subscribe=self';
191
+ if (token) url += (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
192
+ return url;
134
193
  }
135
194
 
136
195
  module.exports = { signalkValuesToPatch, resolveHeadingDeg, MS_TO_KT }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.23.3",
3
+ "version": "0.23.8",
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": {