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