sailkick-boat 0.18.4 → 0.19.0

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
@@ -237,6 +237,23 @@ so the boat's own app opens with no password, fully offline. Everything else in
237
237
  config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep the cloud
238
238
  login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
239
239
 
240
+ ## Routes, polars and settings — stored on the boat
241
+ The app reads and writes these through `/api/profile/*`. On the cloud that router is
242
+ session-gated, and the mirror can never satisfy it: the caching GET path forwards no
243
+ headers at all, and the browser is on the boat's LAN origin so it holds no cloud cookie
244
+ to forward either. Every call returned **401** — the route panel showed nothing, saving a
245
+ route failed, and the mobile route-weather deck silently fell back to "Dead reckoning".
246
+ Offline it was a 504.
247
+
248
+ So the plugin serves `/api/profile/*` itself, from `profile.json` in the plugin's data
249
+ directory (atomic writes, saves serialized so a burst from the route panel can't clobber
250
+ itself). Same envelopes as the cloud, so the app can't tell the difference — and route
251
+ planning now works with no uplink at all, which is when you actually want it.
252
+
253
+ **This copy is boat-local and does not sync.** A route saved on board stays on board; a
254
+ route saved in the web app stays in the cloud. Merging the two needs conflict resolution
255
+ worth designing properly rather than guessing at, so for now they are simply separate.
256
+
240
257
  ## Local history (offline Trends + track)
241
258
  **One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
242
259
  — no database, works on a Victron GX with nothing else installed. `historyAvailable` is
@@ -517,6 +534,10 @@ Things this deliberately does not do yet, so they don't come as a surprise:
517
534
  - **Backfilled history is not browsable in the app.** `/api/history/*` accepts only a
518
535
  relative window clamped to 24 h, so once 2024 is in the cloud there is still no way to
519
536
  display it. That needs `from`/`to` support server-side.
537
+ - **Routes saved on the boat don't reach the cloud, and vice versa.** `/api/profile/*` is
538
+ served from a file on board because the cloud's copy is session-gated and unreachable
539
+ from the mirror. The two copies never merge, so a route drawn at anchor won't show up
540
+ in the web app on shore.
520
541
  - **Per-path sync rate is approximate.** The subscription sets `period` without a
521
542
  `policy`, so Signal K's default governs and a few chatty paths exceed the configured
522
543
  interval.
package/index.js CHANGED
@@ -9,6 +9,7 @@ const { createHistory } = require('./lib/history')
9
9
  const { createBackfill } = require('./lib/backfill')
10
10
  const { createAis } = require('./lib/ais')
11
11
  const { createAisTargets } = require('./lib/ais/targets')
12
+ const { createProfile } = require('./lib/profile')
12
13
  const { resolveAccountConfig } = require('./lib/account')
13
14
 
14
15
  // sailkick-boat: one Signal K plugin, two independently-toggleable modules —
@@ -96,6 +97,7 @@ module.exports = function (app) {
96
97
  let backfill = null
97
98
  let ais = null
98
99
  let aisTargets = null
100
+ let profile = null
99
101
  let statusTimer = null
100
102
  let accountStatus = null
101
103
  let syncWarning = null
@@ -104,8 +106,9 @@ module.exports = function (app) {
104
106
  id: 'sailkick-boat',
105
107
  name: 'Sailkick boat companion',
106
108
  description:
107
- 'Boat-side sailkick: gapless telemetry sync to the cloud + an offline-first ' +
108
- 'caching mirror of the sailkick app. Each feature can be toggled on/off.'
109
+ 'Runs the sailkick app on board: charts and maps cached for offline, live data ' +
110
+ '(position, trends, AIS) served from this boat\'s own SignalK, and telemetry ' +
111
+ 'synced to your sailkick account. Each part can be turned off below.'
109
112
  }
110
113
 
111
114
  plugin.schema = {
@@ -306,6 +309,18 @@ module.exports = function (app) {
306
309
  }
307
310
  }
308
311
 
312
+ // Routes / polars / settings, from a file on the boat. The cloud's /api/profile is
313
+ // session-gated and the mirror can never hold that session, so proxying it always
314
+ // returned 401 — no saved routes, and route-weather fell back to dead reckoning.
315
+ try {
316
+ profile = createProfile(app, {})
317
+ profile.start()
318
+ pOpts.profile = profile
319
+ } catch (e) {
320
+ (app.error || console.error)('[sailkick-boat] profile start failed: ' + e.message)
321
+ profile = null
322
+ }
323
+
309
324
  if (p.serveTelemetry !== false) {
310
325
  try {
311
326
  telemetry = createTelemetry(app, {})
@@ -418,6 +433,7 @@ module.exports = function (app) {
418
433
  if (telemetry) parts.push(telemetry.status())
419
434
  if (history) parts.push(history.status())
420
435
  if (aisTargets) parts.push(aisTargets.status())
436
+ if (profile) parts.push(profile.status())
421
437
  if (ais) parts.push(ais.status())
422
438
  if (backfill) parts.push(backfill.status())
423
439
  try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
@@ -430,6 +446,7 @@ module.exports = function (app) {
430
446
  try { if (telemetry) telemetry.stop() } catch {}
431
447
  try { if (history) history.stop() } catch {}
432
448
  try { if (aisTargets) aisTargets.stop() } catch {}
449
+ try { if (profile) profile.stop() } catch {}
433
450
  try { if (ais) ais.stop() } catch {}
434
451
  try { if (backfill) backfill.stop() } catch {}
435
452
  try { if (proxy) proxy.stop() } catch {}
@@ -439,6 +456,7 @@ module.exports = function (app) {
439
456
  backfill = null
440
457
  ais = null
441
458
  aisTargets = null
459
+ profile = null
442
460
  proxy = null
443
461
  accountStatus = null
444
462
  syncWarning = null
@@ -0,0 +1,193 @@
1
+ 'use strict'
2
+
3
+ // Per-boat profile (routes, polars, settings), served from the boat itself.
4
+ //
5
+ // The app reads and writes routes through /api/profile/routes. On the cloud that
6
+ // router is requireBoat-gated, and the mirror can never satisfy it: the GET cache path
7
+ // forwards no headers at all, and even if it did, the browser is on the boat's LAN
8
+ // origin and holds no cloud cookie to forward. So every /api/profile call through the
9
+ // mirror returned 401 — the route panel showed nothing, mobile route-weather silently
10
+ // fell back to dead reckoning, and saving a route failed. Offline it was a 504.
11
+ //
12
+ // Same answer as history and AIS: serve it locally. The boat is single-tenant, so
13
+ // there is nothing to authenticate; and route planning offshore is exactly when the
14
+ // cloud is unreachable, which makes local the more useful copy rather than a
15
+ // compromise.
16
+ //
17
+ // Contract: byte-for-byte the envelopes of the cloud's server/routes/profile.js, over
18
+ // the same storage shape as server/profile/store.js (one JSON file, atomic writes,
19
+ // serialized so concurrent saves can't clobber). public/ui/route-panel.js and
20
+ // public/mobile/route-weather.js cannot tell the difference.
21
+ //
22
+ // Boat-local is AUTHORITATIVE here and does not sync: a route saved on board stays on
23
+ // board, a route saved in the webapp stays in the cloud. That divergence is deliberate
24
+ // for now — merging the two needs conflict resolution that is worth designing on its
25
+ // own rather than guessing at.
26
+
27
+ const fs = require('fs')
28
+ const fsp = fs.promises
29
+ const path = require('path')
30
+ const { randomBytes } = require('crypto')
31
+
32
+ const SECTIONS = ['polars', 'routes'] // item sections, as the cloud router defines them
33
+ const MAX_BODY_BYTES = 4 * 1024 * 1024 // a polar table is the big one; well under this
34
+ const emptyProfile = () => ({ polars: [], activePolar: null, routes: [], settings: {} })
35
+ const newId = () => randomBytes(8).toString('hex')
36
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v)
37
+
38
+ function createProfile (app, options = {}) {
39
+ const log = (m) => (app.debug ? app.debug('[profile] ' + m) : console.log('[sailkick-boat:profile]', m))
40
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:profile] ' + m) : console.error('[sailkick-boat:profile]', m))
41
+
42
+ const dataDir = options.dataDir || (app.getDataDirPath && app.getDataDirPath()) || '.'
43
+ const file = options.file || path.join(dataDir, 'profile.json')
44
+ let queue = Promise.resolve() // serializes read-modify-write, as the cloud store does
45
+ let lastError = null
46
+ let writes = 0
47
+
48
+ async function load () {
49
+ try {
50
+ return { ...emptyProfile(), ...JSON.parse(await fsp.readFile(file, 'utf8')) }
51
+ } catch (e) {
52
+ if (e.code === 'ENOENT') return emptyProfile() // first use
53
+ throw e // corrupt or unreadable — surface it rather than silently resetting
54
+ }
55
+ }
56
+
57
+ async function save (profile) {
58
+ await fsp.mkdir(path.dirname(file), { recursive: true })
59
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
60
+ await fsp.writeFile(tmp, JSON.stringify(profile, null, 2), { mode: 0o600 })
61
+ await fsp.rename(tmp, file) // atomic on POSIX — a yanked power cable can't truncate it
62
+ writes++
63
+ return profile
64
+ }
65
+
66
+ // mutator(profile) may mutate in place and/or return the next profile.
67
+ function update (mutator) {
68
+ const run = async () => {
69
+ const p = await load()
70
+ return save((await mutator(p)) || p)
71
+ }
72
+ const next = queue.then(run, run) // run regardless of how the previous one ended
73
+ queue = next.then(() => {}, () => {}) // the tail must never reject
74
+ return next
75
+ }
76
+
77
+ // ---- HTTP ------------------------------------------------------------------
78
+ const send = (res, status, body) => {
79
+ res.statusCode = status
80
+ res.setHeader('Content-Type', 'application/json')
81
+ res.end(JSON.stringify(body))
82
+ }
83
+ const notFound = (res, what) => send(res, 404, { ok: false, code: 'not-found', message: `${what} not found` })
84
+ const badBody = (res, what) => send(res, 400, { ok: false, code: 'bad-body', message: `${what} must be a JSON object` })
85
+ const failed = (res, e) => { lastError = e.message; warn(e.message); send(res, 500, { ok: false, code: 'profile-error', message: e.message }) }
86
+
87
+ function readJson (req) {
88
+ return new Promise((resolve, reject) => {
89
+ let size = 0
90
+ const chunks = []
91
+ req.on('data', (c) => {
92
+ size += c.length
93
+ if (size > MAX_BODY_BYTES) { reject(new Error('body too large')); req.destroy(); return }
94
+ chunks.push(c)
95
+ })
96
+ req.on('error', reject)
97
+ req.on('end', () => {
98
+ const raw = Buffer.concat(chunks).toString('utf8')
99
+ if (!raw.trim()) return resolve(null)
100
+ try { resolve(JSON.parse(raw)) } catch { resolve(undefined) } // undefined = malformed
101
+ })
102
+ })
103
+ }
104
+
105
+ // True for anything this module owns, so the mirror can hand it over before the
106
+ // cache/proxy path sees it.
107
+ function handles (reqPath) {
108
+ const p = String(reqPath).split('?')[0]
109
+ return p === '/api/profile' || p.startsWith('/api/profile/')
110
+ }
111
+
112
+ async function handle (req, res) {
113
+ try {
114
+ const [rawPath, query] = String(req.url).split('?')
115
+ const rest = rawPath.slice('/api/profile'.length).replace(/^\//, '') // '' | 'settings' | 'routes' | 'routes/<id>'
116
+ const [head, rawId] = rest.split('/')
117
+ const id = rawId ? decodeURIComponent(rawId) : null
118
+ const method = req.method === 'HEAD' ? 'GET' : req.method
119
+
120
+ // GET /api/profile[?section=…] — whole profile or one section
121
+ if (head === '' && method === 'GET') {
122
+ const section = new URLSearchParams(query || '').get('section')
123
+ const p = await load()
124
+ return send(res, 200, { ok: true, profile: section ? p[section] : p })
125
+ }
126
+
127
+ if (head === 'settings') {
128
+ if (method === 'GET') return send(res, 200, { ok: true, settings: (await load()).settings || {} })
129
+ if (method === 'PUT') {
130
+ const body = await readJson(req)
131
+ if (!isObj(body)) return badBody(res, 'settings')
132
+ await update((p) => { p.settings = body; return p })
133
+ return send(res, 200, { ok: true })
134
+ }
135
+ }
136
+
137
+ if (head === 'active-polar' && method === 'PUT') {
138
+ const body = await readJson(req)
139
+ await update((p) => { p.activePolar = (body && body.id != null) ? body.id : null; return p })
140
+ return send(res, 200, { ok: true })
141
+ }
142
+
143
+ if (SECTIONS.includes(head)) {
144
+ if (method === 'GET' && !id) {
145
+ const arr = (await load())[head]
146
+ return send(res, 200, { ok: true, [head]: Array.isArray(arr) ? arr : [] })
147
+ }
148
+ if (method === 'GET') {
149
+ const item = ((await load())[head] || []).find((x) => x && x.id === id)
150
+ return item ? send(res, 200, { ok: true, item }) : notFound(res, `${head}/${id}`)
151
+ }
152
+ if (method === 'POST' && !id) {
153
+ const body = await readJson(req)
154
+ if (!isObj(body)) return badBody(res, `${head} item`)
155
+ const item = { ...body, id: newId(), updatedAt: Date.now() }
156
+ await update((p) => { (Array.isArray(p[head]) ? p[head] : (p[head] = [])).push(item); return p })
157
+ return send(res, 201, { ok: true, item })
158
+ }
159
+ if (method === 'PUT' && id) {
160
+ const body = await readJson(req)
161
+ if (!isObj(body)) return badBody(res, `${head} item`)
162
+ let missing = false
163
+ const item = { ...body, id, updatedAt: Date.now() }
164
+ await update((p) => {
165
+ const arr = Array.isArray(p[head]) ? p[head] : (p[head] = [])
166
+ const i = arr.findIndex((x) => x && x.id === id)
167
+ if (i < 0) { missing = true; return p } // 404, and nothing is written
168
+ arr[i] = { ...arr[i], ...item }
169
+ return p
170
+ })
171
+ return missing ? notFound(res, `${head}/${id}`) : send(res, 200, { ok: true, item })
172
+ }
173
+ if (method === 'DELETE' && id) {
174
+ await update((p) => { p[head] = (p[head] || []).filter((x) => x && x.id !== id); return p })
175
+ return send(res, 200, { ok: true })
176
+ }
177
+ }
178
+
179
+ send(res, 404, { ok: false, code: 'not-found', message: `${req.method} ${rawPath} not found` })
180
+ } catch (e) { failed(res, e) }
181
+ }
182
+
183
+ function start () { log(`serving /api/profile from ${file} (boat-local; not synced with the cloud copy)`) }
184
+ function stop () {}
185
+ function available () { return true } // a local file — no reason it would not be
186
+ function status () {
187
+ return `profile: local${writes ? `, ${writes} save(s)` : ''}${lastError ? ` (last error: ${lastError})` : ''}`
188
+ }
189
+
190
+ return { start, stop, status, available, handles, handle, _load: load, _file: () => file }
191
+ }
192
+
193
+ module.exports = { createProfile, SECTIONS }
@@ -5,6 +5,7 @@ const http = require('http')
5
5
  const net = require('net')
6
6
  const { getResource, clearStore } = require('./cache')
7
7
  const { createManifest } = require('./manifest')
8
+ const { createContractCheck } = require('../telemetry/contract')
8
9
  const { createSeeder } = require('./seed')
9
10
  const { countBboxTiles, bboxTiles, boxAround } = require('./tiles')
10
11
 
@@ -70,6 +71,7 @@ function createProxy (app, options) {
70
71
  let server = null
71
72
  let manifest = null
72
73
  let seeder = null
74
+ let contract = null
73
75
  let area = null // "download around the boat" progress
74
76
  let stopping = false
75
77
  // Shared upstream circuit breaker: once a fetch fails offline, uncached requests
@@ -90,6 +92,7 @@ function createProxy (app, options) {
90
92
  telemetryPath: options.telemetryPath || '/ws/telemetry',
91
93
  history: options.history || null,
92
94
  aisTargets: options.aisTargets || null,
95
+ profile: options.profile || null,
93
96
  openAccess: options.openAccess !== false
94
97
  }
95
98
  log(`mirroring ${cfg.upstream}; local SignalK ${cfg.localSignalk}; store ${cfg.storeDir}`)
@@ -98,9 +101,11 @@ function createProxy (app, options) {
98
101
  // announces a new bake. Tiles are otherwise pinned (no time-based expiry).
99
102
  if (!options.manifest || options.manifest.enabled !== false) {
100
103
  try {
104
+ contract = createContractCheck(app, { timeoutMs: cfg.timeoutMs })
101
105
  manifest = createManifest(app, {
102
106
  upstream: cfg.upstream,
103
107
  storeDir: cfg.storeDir,
108
+ contract,
104
109
  manifestPath: options.manifest && options.manifest.path,
105
110
  pollIntervalSec: options.manifest && options.manifest.pollIntervalSec,
106
111
  timeoutMs: cfg.timeoutMs
@@ -147,6 +152,7 @@ function createProxy (app, options) {
147
152
  seeder = null
148
153
  try { if (manifest) manifest.stop() } catch {}
149
154
  manifest = null
155
+ contract = null
150
156
  if (server) {
151
157
  try { if (server.closeAllConnections) server.closeAllConnections() } catch {}
152
158
  try { server.close() } catch {}
@@ -158,7 +164,8 @@ function createProxy (app, options) {
158
164
  if (!cfg) return 'proxy: off'
159
165
  const m = manifest ? '; ' + manifest.status() : ''
160
166
  const s = seeder ? '; ' + seeder.status() : ''
161
- return `proxy: mirror ${cfg.upstream}${cfg.port ? ' :' + cfg.port : ''}; live -> ${cfg.localSignalk}${m}${s}${areaStatus()}`
167
+ const c = (contract && contract.status()) ? '; ' + contract.status() : ''
168
+ return `proxy: mirror ${cfg.upstream}${cfg.port ? ' :' + cfg.port : ''}; live -> ${cfg.localSignalk}${m}${s}${areaStatus()}${c}`
162
169
  }
163
170
 
164
171
  // Per-layer max zoom from the upstream asset manifest, cached. Never throws: on any
@@ -232,6 +239,14 @@ function createProxy (app, options) {
232
239
  req.url.split('?')[0] === '/api/ais') {
233
240
  return cfg.aisTargets.handleAis(req, res)
234
241
  }
242
+ // Routes / polars / settings, from a file on the boat. The cloud's /api/profile is
243
+ // requireBoat-gated and the mirror can hold no such session — the GET cache path
244
+ // forwards no headers, and the browser has no cloud cookie for this LAN origin
245
+ // anyway — so proxying it returned 401 on every call and 504 offline. All methods,
246
+ // since the app saves and deletes routes here too.
247
+ if (cfg.profile && cfg.profile.available() && cfg.profile.handles(req.url)) {
248
+ return cfg.profile.handle(req, res)
249
+ }
235
250
  // /api/config drives the app's login gate + Trends toggle. On the single-tenant
236
251
  // boat we serve it with auth turned off (no cloud login over the offline HTTP
237
252
  // mirror) and history forced on when we serve it locally. All other config passes
@@ -67,6 +67,11 @@ function createManifest (app, options) {
67
67
  } catch { return } // offline / bad JSON → no-op, nothing invalidated
68
68
  lastOk = Date.now()
69
69
 
70
+ // We are online and talking to the app server — the one moment worth re-checking
71
+ // the signalk-map contract seam. Piggybacking here keeps it to one timer, and it
72
+ // must never affect cache invalidation, so it is fire-and-forget.
73
+ if (options.contract) { try { options.contract.check(cfg.upstream) } catch {} }
74
+
70
75
  const families = { ...((m && m.bakes) || {}) }
71
76
  if (m && m.app != null) families.app = m.app
72
77
 
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ // Drift detector for the highest-risk contract seam in the system.
4
+ //
5
+ // lib/telemetry/signalk-map.js is a hand-ported copy of the app's
6
+ // public/engine/signalk-map.js. It has now drifted TWICE without anyone noticing:
7
+ // - v0.14.6: nine cases (STW, measured true wind, attitude, rudder, autopilot)
8
+ // - v0.18.6: the active-waypoint course block + TWA, VMG, sea/air temp, engine rpm
9
+ // Both times the data was sitting on the boat's SignalK bus and the plugin threw it
10
+ // away, so the app looked broken only when connected to the boat — the hardest place
11
+ // to debug and the only place that matters offshore.
12
+ //
13
+ // The server now publishes the content hash of its copy at /health:
14
+ // { contracts: { signalkMap: "2015ae986cd8" } }
15
+ // We can't compare byte-for-byte — our copy is CommonJS and theirs is an ES module —
16
+ // so we pin the hash of the app file we last ported FROM. If the app changes that
17
+ // file, the published hash stops matching the pin and we say so, loudly, in the
18
+ // Signal K log. It cannot tell us WHAT changed; it tells us to go and look, which is
19
+ // exactly what was missing both times.
20
+ //
21
+ // Updating this pin is part of re-porting signalk-map.js — never on its own:
22
+ // sha256sum <sailkick>/public/engine/signalk-map.js | cut -c1-12
23
+
24
+ // sha256(app public/engine/signalk-map.js)[0..12] as ported in v0.18.6
25
+ const PINNED_APP_HASH = '2015ae986cd8'
26
+
27
+ function createContractCheck (app, options = {}) {
28
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:contract] ' + m) : console.error('[sailkick-boat:contract]', m))
29
+ const log = (m) => (app.debug ? app.debug('[contract] ' + m) : console.log('[sailkick-boat:contract]', m))
30
+ const timeoutMs = options.timeoutMs || 15000
31
+ let drifted = null // null = unknown (never reached the server, or it predates /health.contracts)
32
+ let announced = null // last state we logged, so a 5-minute poll doesn't spam the log
33
+
34
+ // Best-effort: a boat is offline most of the time, and older servers don't publish
35
+ // `contracts` at all. Neither is an error — both just leave the state unknown.
36
+ async function check (upstream) {
37
+ if (!upstream) return drifted
38
+ let remote
39
+ try {
40
+ const r = await fetch(String(upstream).replace(/\/+$/, '') + '/health', { signal: AbortSignal.timeout(timeoutMs) })
41
+ if (!r.ok) return drifted
42
+ const body = await r.json()
43
+ remote = body && body.contracts && body.contracts.signalkMap
44
+ } catch { return drifted } // offline → keep whatever we last knew
45
+ if (!remote) return drifted // server predates the contract hash
46
+
47
+ drifted = remote !== PINNED_APP_HASH
48
+ if (drifted !== announced) {
49
+ announced = drifted
50
+ if (drifted) {
51
+ warn(`signalk-map contract DRIFT: the server runs ${remote}, this plugin was ported from ${PINNED_APP_HASH}. ` +
52
+ 'The app may show blank instrument or waypoint values when connected to this boat. ' +
53
+ 're-port lib/telemetry/signalk-map.js from the app and update the pin.')
54
+ } else {
55
+ log(`signalk-map contract in sync (${remote})`)
56
+ }
57
+ }
58
+ return drifted
59
+ }
60
+
61
+ // Only worth a status line when something is wrong; "in sync" is the boring case.
62
+ function status () { return drifted ? 'contract: signalk-map DRIFTED from the server — see log' : null }
63
+
64
+ return { check, status, _state: () => drifted, PINNED_APP_HASH }
65
+ }
66
+
67
+ module.exports = { createContractCheck, PINNED_APP_HASH }
@@ -10,11 +10,29 @@ const RAD2DEG = 180 / Math.PI
10
10
  const wrap360 = (d) => ((d % 360) + 360) % 360
11
11
  const wrap180 = (d) => { const w = wrap360(d); return w > 180 ? w - 360 : w }
12
12
 
13
+ // Active-waypoint course data. SignalK publishes it under three prefixes
14
+ // 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)$/
17
+
13
18
  function signalkValuesToPatch (values) {
14
19
  const patch = {}
15
20
  if (!Array.isArray(values)) return patch
16
21
  for (const v of values) {
17
- if (!v || v.value == null) continue
22
+ if (!v || !v.path) continue
23
+ // Course paths first: unlike sensors, a null here MEANS something — the
24
+ // destination was cleared — so propagate it instead of skipping, or the
25
+ // ribbon would show stale waypoint numbers forever.
26
+ const course = v.path.match(COURSE_RE)
27
+ 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
34
+ }
35
+ if (v.value == null) continue
18
36
  switch (v.path) {
19
37
  case 'navigation.position': {
20
38
  const { latitude, longitude } = v.value || {}
@@ -54,10 +72,30 @@ function signalkValuesToPatch (values) {
54
72
  case 'environment.wind.directionTrue': // absolute compass direction (not off-bow)
55
73
  if (Number.isFinite(v.value)) patch.twdDeg = wrap360(v.value * RAD2DEG)
56
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
57
81
  case 'environment.depth.belowSurface':
58
82
  case 'environment.depth.belowTransducer':
59
83
  if (Number.isFinite(v.value)) patch.depthM = v.value
60
84
  break
85
+ // (active-waypoint course paths are handled above via COURSE_RE — all three
86
+ // 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
96
+ case 'propulsion.starboard.revolutions':
97
+ if (Number.isFinite(v.value)) patch.rpmStbd = Math.max(0, v.value * 60)
98
+ break
61
99
  case 'steering.rudderAngle':
62
100
  if (Number.isFinite(v.value)) patch.rudderDeg = v.value * RAD2DEG
63
101
  break
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.18.4",
4
- "description": "EARLY ALPHA cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
3
+ "version": "0.19.0",
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 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test"