sailkick-boat 0.23.7 → 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
@@ -369,19 +369,29 @@ Two ways to know true heading — the boat publishes `navigation.headingTrue`, o
369
369
  derived from `headingMagnetic + magneticVariation`. The plugin uses the **published**
370
370
  value.
371
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.
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.
385
395
 
386
396
  This also lines the boat up with the cloud's history provider, which takes `headingTrue`
387
397
  first. (Its fallback converts `headingMagnetic` **without** adding variation, so a boat
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))
@@ -61,26 +61,44 @@ function encodeTextFrame (str) {
61
61
  // Deliberately NOT patched into lib/telemetry/signalk-map.js: that file is vendored
62
62
  // verbatim from the app and must stay byte-comparable. Handed upstream so the rule can
63
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.
64
+ // DELIBERATE DIVERGENCE FROM UPSTREAM, at the owner's request.
66
65
  //
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°.
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
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.
72
+ // navigation.headingTrue NMEA.31 NAIS 500 1 Hz, quantised to
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.
81
88
  const HEADING_DISAGREE_DEG = 10
82
89
 
83
- // Each group is one BoatState field fed by several paths, most-preferred first.
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.
84
102
  const PRECEDENCE_GROUPS = [
85
103
  // Active waypoint: wptBrgDeg / wptDistNm / wptVmgKt / wptTtgSec (COURSE_RE).
86
104
  ['navigation.courseGreatCircle.nextPoint.',
@@ -146,29 +164,37 @@ function createTelemetry (app, options = {}) {
146
164
  })
147
165
  }
148
166
 
149
- // See HEADING_DISAGREE_DEG. Returns true heading in degrees, or undefined.
167
+ // See HEADING_DISAGREE_DEG. Returns TRUE heading in degrees, or undefined.
150
168
  let headingWarned = false
151
169
  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.')
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')
167
195
  }
168
- return derived
169
196
  }
170
- if (headingWarned) { headingWarned = false; log('navigation.headingTrue agrees with the compass again — using it') }
171
- return published
197
+ return compass
172
198
  }
173
199
 
174
200
  function onDelta (delta) {
@@ -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.7",
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": {