sailkick-boat 0.18.5 → 0.20.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
@@ -465,8 +482,21 @@ Point your chart app / browser at:
465
482
  ```bash
466
483
  cd ~/.signalk && npm install sailkick-boat # or a packed tarball
467
484
  ```
468
- Enable + configure under **Server → Plugin Config → "Sailkick boat companion"**.
469
- Set **Data directory** to a path on the SSD (or leave blank for the plugin data dir).
485
+ The plugin **enables itself on install** and appears in Signal K's **Webapps** menu as
486
+ **Sailkick** — a launcher with two entries, "Open Sailkick" (full app) and "Open on
487
+ phone" (mobile view). Both point at the mirror on this boat, so nothing has to be typed
488
+ by hand. One npm package can only produce one menu item — Signal K dedupes webapps by
489
+ package name — hence one launcher rather than two entries.
490
+
491
+ Nothing is uploaded by enabling it. Telemetry sync needs an account write token and
492
+ refuses to start without one; AIS upload and the backfill are off by default; and the
493
+ worldwide base-map seed is skipped entirely on an unpaired boat, so a fresh install
494
+ downloads only what you actually look at. **The whole app works with no account** —
495
+ charts, instruments, trends, AIS, routes and polars are all served from the boat. An
496
+ account adds cloud sync, off-boat access and long-term history.
497
+
498
+ Set **Data directory** to a path on the SSD (or leave blank for the plugin data dir) —
499
+ that is the one setting worth changing straight away.
470
500
 
471
501
  ### Victron GX / Venus OS (Cerbo, Ekrano)
472
502
  Works on Venus OS Large (Signal K enabled). Point **Data directory** at USB/SD storage
@@ -517,6 +547,10 @@ Things this deliberately does not do yet, so they don't come as a surprise:
517
547
  - **Backfilled history is not browsable in the app.** `/api/history/*` accepts only a
518
548
  relative window clamped to 24 h, so once 2024 is in the cloud there is still no way to
519
549
  display it. That needs `from`/`to` support server-side.
550
+ - **Routes saved on the boat don't reach the cloud, and vice versa.** `/api/profile/*` is
551
+ served from a file on board because the cloud's copy is session-gated and unreachable
552
+ from the mirror. The two copies never merge, so a route drawn at anchor won't show up
553
+ in the web app on shore.
520
554
  - **Per-path sync rate is approximate.** The subscription sets `period` without a
521
555
  `policy`, so Signal K's default governs and a few chatty paths exceed the configured
522
556
  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,9 @@ module.exports = function (app) {
96
97
  let backfill = null
97
98
  let ais = null
98
99
  let aisTargets = null
100
+ let profile = null
101
+ let proxyPort = null // what the launcher page needs to build its links
102
+ let pairedSlug = null
99
103
  let statusTimer = null
100
104
  let accountStatus = null
101
105
  let syncWarning = null
@@ -262,7 +266,7 @@ module.exports = function (app) {
262
266
  const pOpts = {
263
267
  sailkickUrl: upstream.url,
264
268
  storeDir: store,
265
- proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
269
+ proxyPort: (proxyPort = p.proxyPort == null ? 8080 : p.proxyPort),
266
270
  localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
267
271
  localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
268
272
  telemetryPath: p.telemetryPath || PROXY_TUNING.telemetryPath,
@@ -307,6 +311,18 @@ module.exports = function (app) {
307
311
  }
308
312
  }
309
313
 
314
+ // Routes / polars / settings, from a file on the boat. The cloud's /api/profile is
315
+ // session-gated and the mirror can never hold that session, so proxying it always
316
+ // returned 401 — no saved routes, and route-weather fell back to dead reckoning.
317
+ try {
318
+ profile = createProfile(app, {})
319
+ profile.start()
320
+ pOpts.profile = profile
321
+ } catch (e) {
322
+ (app.error || console.error)('[sailkick-boat] profile start failed: ' + e.message)
323
+ profile = null
324
+ }
325
+
310
326
  if (p.serveTelemetry !== false) {
311
327
  try {
312
328
  telemetry = createTelemetry(app, {})
@@ -419,6 +435,7 @@ module.exports = function (app) {
419
435
  if (telemetry) parts.push(telemetry.status())
420
436
  if (history) parts.push(history.status())
421
437
  if (aisTargets) parts.push(aisTargets.status())
438
+ if (profile) parts.push(profile.status())
422
439
  if (ais) parts.push(ais.status())
423
440
  if (backfill) parts.push(backfill.status())
424
441
  try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
@@ -431,6 +448,7 @@ module.exports = function (app) {
431
448
  try { if (telemetry) telemetry.stop() } catch {}
432
449
  try { if (history) history.stop() } catch {}
433
450
  try { if (aisTargets) aisTargets.stop() } catch {}
451
+ try { if (profile) profile.stop() } catch {}
434
452
  try { if (ais) ais.stop() } catch {}
435
453
  try { if (backfill) backfill.stop() } catch {}
436
454
  try { if (proxy) proxy.stop() } catch {}
@@ -440,6 +458,9 @@ module.exports = function (app) {
440
458
  backfill = null
441
459
  ais = null
442
460
  aisTargets = null
461
+ profile = null
462
+ proxyPort = null
463
+ pairedSlug = null
443
464
  proxy = null
444
465
  accountStatus = null
445
466
  syncWarning = null
@@ -448,6 +469,19 @@ module.exports = function (app) {
448
469
  // Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
449
470
  // proxy module (created in start), so enable/disable works at request time.
450
471
  plugin.registerWithRouter = function (router) {
472
+ // Read by the launcher page (public/index.html), which Signal K serves at
473
+ // /sailkick-boat/. That page lives outside this process and cannot know which port
474
+ // the mirror was configured on, so it asks. `paired` drives the hint about cloud
475
+ // sync; `running` distinguishes "plugin off" from "mirror port disabled".
476
+ router.get('/info', (req, res) => {
477
+ res.json({
478
+ ok: true,
479
+ running: !!proxy,
480
+ port: proxyPort,
481
+ paired: !!pairedSlug,
482
+ version: (() => { try { return require('./package.json').version } catch { return null } })()
483
+ })
484
+ })
451
485
  router.get('/p/*', (req, res) => {
452
486
  if (proxy) proxy.handleGet(req, res)
453
487
  else res.status(503).send('proxy not enabled')
@@ -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
@@ -483,12 +498,19 @@ function createProxy (app, options) {
483
498
  if (clamped.length) log(`area prefetch: ${clamped.map(([l, v]) => `${l} capped at z${v.top}`).join(', ')} (upstream limit — those tiles do not exist)`)
484
499
  if (total > CAP) { area = { capped: true, total, radius, maxZoom }; log(`area prefetch: ~${total} tiles too large — reduce radius/detail`); return null }
485
500
  const paths = [...enumerateTiles(bbox, minZoom, perLayer)]
486
- area = { done: 0, total: paths.length, running: true, radius, maxZoom, perLayer }
501
+ // Hold our OWN reference and mutate that, never the `area` slot. A prefetch of tens
502
+ // of thousands of tiles outlives a plugin disable by minutes, and stop() sets
503
+ // area = null — so settling handlers that touched `area` threw
504
+ // "Cannot set properties of null (setting 'running')" on every disable/enable. The
505
+ // same reference also protects against a restart having installed a NEWER prefetch:
506
+ // the old run must not report its progress into the new one's counters.
507
+ const run = { done: 0, total: paths.length, running: true, radius, maxZoom, perLayer }
508
+ area = run
487
509
  log(`area prefetch: ${radius}nm around ${pos.latitude.toFixed(2)},${pos.longitude.toFixed(2)} to z${maxZoom} — ${paths.length} tiles`)
488
- area.promise = warmMany(paths, { concurrency: pf.concurrency || 4, onProgress: () => { area.done++ } })
489
- .then((r) => { area.running = false; area.result = r; log(`area prefetch done: ${r.cached} cached, ${r.empty} empty, ${r.failed} failed`); return r })
490
- .catch((e) => { area.running = false; log('area prefetch error: ' + e.message) })
491
- return area.promise
510
+ run.promise = warmMany(paths, { concurrency: pf.concurrency || 4, onProgress: () => { run.done++ } })
511
+ .then((r) => { run.running = false; run.result = r; log(`area prefetch done: ${r.cached} cached, ${r.empty} empty, ${r.failed} failed`); return r })
512
+ .catch((e) => { run.running = false; log('area prefetch error: ' + e.message) })
513
+ return run.promise
492
514
  }
493
515
 
494
516
  function areaStatus () {
@@ -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,13 +1,14 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.18.5",
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",
3
+ "version": "0.20.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 \u2014 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test"
8
8
  },
9
9
  "keywords": [
10
10
  "signalk-node-server-plugin",
11
+ "signalk-webapp",
11
12
  "signalk",
12
13
  "sailkick",
13
14
  "influxdb",
@@ -15,6 +16,7 @@
15
16
  "cache",
16
17
  "proxy"
17
18
  ],
19
+ "signalk-plugin-enabled-by-default": true,
18
20
  "author": "lauchat",
19
21
  "license": "MIT",
20
22
  "repository": {
@@ -35,6 +37,7 @@
35
37
  "files": [
36
38
  "index.js",
37
39
  "lib/",
40
+ "public/",
38
41
  "README.md",
39
42
  "LICENSE"
40
43
  ]
@@ -0,0 +1,97 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sailkick</title>
7
+ <!--
8
+ Launcher for the Signal K admin menu.
9
+
10
+ Signal K mounts <package>/public/ at /<package-name> for any package carrying the
11
+ `signalk-webapp` keyword, and dedupes the menu by PACKAGE name (interfaces/webapps.js,
12
+ uniqBy(webapps,'name')) — so one package can only ever produce one entry. Hence one
13
+ launcher offering both surfaces rather than two menu items.
14
+
15
+ The app itself is not served from here: it lives on the plugin's mirror, on a port the
16
+ owner can change. This page therefore asks the plugin where it is, via the plugin
17
+ router at /plugins/sailkick-boat/info, and falls back to the default port if that
18
+ cannot be reached. The host is always taken from the current URL, so this works over
19
+ whatever address the browser reached Signal K on — LAN IP, .local, or tailnet.
20
+ -->
21
+ <style>
22
+ :root { color-scheme: light dark; --bg:#f6f7f9; --fg:#12161c; --mut:#5c6672; --card:#fff; --line:#e2e6eb; --accent:#0b6ea9 }
23
+ @media (prefers-color-scheme: dark) {
24
+ :root { --bg:#11151a; --fg:#e8edf2; --mut:#96a1ad; --card:#171c23; --line:#252c35; --accent:#4aa8e0 }
25
+ }
26
+ * { box-sizing: border-box }
27
+ body { margin:0; padding:2rem 1rem; background:var(--bg); color:var(--fg);
28
+ font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
29
+ main { max-width:44rem; margin:0 auto }
30
+ h1 { margin:0 0 .25rem; font-size:1.5rem; letter-spacing:-.01em }
31
+ .sub { color:var(--mut); margin:0 0 1.5rem }
32
+ .grid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)) }
33
+ a.card { display:block; padding:1.1rem 1.2rem; background:var(--card); border:1px solid var(--line);
34
+ border-radius:.7rem; text-decoration:none; color:inherit; transition:border-color .15s, transform .15s }
35
+ a.card:hover { border-color:var(--accent); transform:translateY(-1px) }
36
+ a.card strong { display:block; font-size:1.05rem; margin-bottom:.2rem }
37
+ a.card span { color:var(--mut); font-size:.9rem }
38
+ .note { margin-top:1.5rem; padding:.85rem 1rem; background:var(--card); border:1px solid var(--line);
39
+ border-radius:.6rem; color:var(--mut); font-size:.9rem }
40
+ .note b { color:var(--fg); font-weight:600 }
41
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.87em }
42
+ .warn { border-color:#b4791f }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <h1>Sailkick</h1>
48
+ <p class="sub">Charts, instruments and AIS, served from this boat.</p>
49
+
50
+ <div class="grid">
51
+ <a class="card" id="desktop" href="#"><strong>Open Sailkick</strong><span>Full app — chart, trends, weather</span></a>
52
+ <a class="card" id="mobile" href="#"><strong>Open on phone</strong><span>Mobile view — swipeable instrument decks</span></a>
53
+ </div>
54
+
55
+ <div class="note" id="note">Locating the mirror…</div>
56
+ </main>
57
+
58
+ <script>
59
+ (function () {
60
+ var DEFAULT_PORT = 8080;
61
+ var note = document.getElementById('note');
62
+
63
+ function apply (port, info) {
64
+ var base = location.protocol + '//' + location.hostname + (port ? ':' + port : '');
65
+ document.getElementById('desktop').href = base + '/';
66
+ document.getElementById('mobile').href = base + '/mobile.html';
67
+
68
+ if (info && info.running === false) {
69
+ note.className = 'note warn';
70
+ note.innerHTML = '<b>The mirror is not running.</b> Enable the plugin in ' +
71
+ 'Server → Plugin Config → Sailkick boat companion, then reload this page.';
72
+ return;
73
+ }
74
+ if (port === 0) {
75
+ note.className = 'note warn';
76
+ note.innerHTML = '<b>The mirror port is disabled</b> (set to 0 in the plugin config), ' +
77
+ 'so the app cannot be opened from here. Set a port — 8080 is the default.';
78
+ return;
79
+ }
80
+ var paired = info && info.paired;
81
+ note.innerHTML = 'Serving on <code>' + base + '</code>. Works with no account and no internet — ' +
82
+ 'live data comes from this boat’s own Signal K, and maps are cached locally.' +
83
+ (paired ? '' : ' <b>Cloud sync is off</b> until you add a sailkick account in the plugin config; ' +
84
+ 'nothing leaves the boat before then.');
85
+ }
86
+
87
+ fetch('/plugins/sailkick-boat/info', { credentials: 'same-origin' })
88
+ .then(function (r) { return r.ok ? r.json() : null })
89
+ .then(function (j) {
90
+ if (j && typeof j.port === 'number') apply(j.port, j);
91
+ else apply(DEFAULT_PORT, null);
92
+ })
93
+ .catch(function () { apply(DEFAULT_PORT, null); });
94
+ })();
95
+ </script>
96
+ </body>
97
+ </html>