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 +21 -0
- package/index.js +20 -2
- package/lib/profile/index.js +193 -0
- package/lib/proxy/index.js +16 -1
- package/lib/proxy/manifest.js +5 -0
- package/lib/telemetry/contract.js +67 -0
- package/lib/telemetry/signalk-map.js +39 -1
- package/package.json +2 -2
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
|
-
'
|
|
108
|
-
'
|
|
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 }
|
package/lib/proxy/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/lib/proxy/manifest.js
CHANGED
|
@@ -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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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"
|