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 +56 -0
- package/index.js +7 -1
- package/lib/proxy/index.js +19 -2
- package/lib/telemetry/contract.js +1 -1
- package/lib/telemetry/index.js +144 -2
- package/lib/telemetry/signalk-map.js +151 -92
- package/package.json +1 -1
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
|
-
|
|
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
|
@@ -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 =
|
|
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
|
-
|
|
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": {
|