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 +23 -13
- package/index.js +7 -1
- package/lib/proxy/index.js +19 -2
- package/lib/telemetry/contract.js +1 -1
- package/lib/telemetry/index.js +61 -35
- package/lib/telemetry/signalk-map.js +151 -92
- package/package.json +1 -1
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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: {
|
package/lib/proxy/index.js
CHANGED
|
@@ -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, {
|
|
343
|
-
|
|
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 = '
|
|
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))
|
package/lib/telemetry/index.js
CHANGED
|
@@ -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
|
-
//
|
|
65
|
-
// from magnetic + variation.
|
|
64
|
+
// DELIBERATE DIVERGENCE FROM UPSTREAM, at the owner's request.
|
|
66
65
|
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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.
|
|
81
88
|
const HEADING_DISAGREE_DEG = 10
|
|
82
89
|
|
|
83
|
-
//
|
|
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
|
|
167
|
+
// See HEADING_DISAGREE_DEG. Returns TRUE heading in degrees, or undefined.
|
|
150
168
|
let headingWarned = false
|
|
151
169
|
function resolveHeading (st) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
return published
|
|
197
|
+
return compass
|
|
172
198
|
}
|
|
173
199
|
|
|
174
200
|
function onDelta (delta) {
|
|
@@ -1,136 +1,195 @@
|
|
|
1
|
-
|
|
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
|
|
4
|
-
// (public/
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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':
|
|
73
|
-
if (Number.isFinite(v.value)) patch.twdDeg = wrap360(v.value * RAD2DEG)
|
|
74
|
-
break
|
|
75
|
-
case 'environment.wind.angleTrueWater':
|
|
76
|
-
if (Number.isFinite(v.value)) patch.twaDeg = wrap180(v.value * RAD2DEG)
|
|
77
|
-
break
|
|
78
|
-
case 'performance.velocityMadeGood':
|
|
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
|
-
|
|
84
|
-
|
|
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':
|
|
88
|
-
if (Number.isFinite(v.value)) patch.seaTempC = v.value - 273.15
|
|
89
|
-
break
|
|
90
|
-
case 'environment.outside.temperature':
|
|
91
|
-
if (Number.isFinite(v.value)) patch.airTempC = v.value - 273.15
|
|
92
|
-
break
|
|
93
|
-
case 'propulsion.port.revolutions':
|
|
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':
|
|
106
|
-
if (
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
+
"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": {
|