sailkick-boat 0.14.4 → 0.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,10 +63,19 @@ comes from the cloud **announcing bakes**, not from a clock:
63
63
  `X-Sailkick-Cache` reports `HIT` / `MISS` / `UPDATED` / `STALE` / `LIVE` per response.
64
64
 
65
65
  **Static vs dynamic — two strategies.** Tiles and app assets are **cache-first**
66
- (pinned, offline forever). Dynamic `/api/*` data (AIS, weather) is **network-first**:
67
- fetched live every time online (so AIS/weather never go stale), with the last
68
- response kept only as an **offline fallback** (`STALE`). Local `/api/history` and the
69
- patched `/api/config` are served specially (above).
66
+ (pinned, offline forever). Dynamic `/api/*` data (AIS, weather, lightning) is
67
+ **network-first**: fetched live every time online (so it never goes stale), with the
68
+ last response kept only as an **offline fallback** (`STALE`). Local `/api/history` and
69
+ the patched `/api/config` are served specially (above).
70
+
71
+ **Exception — velocity tiles are pinned.** The app serves its wind/current field from
72
+ `/api/velocity/tiles/<layer>/<runId>/<z>/<x>/<y>/<hour>.f32`. The forecast **run id is
73
+ in the path**, so a URL's bytes never change — a new run means new URLs. Those are
74
+ cache-first like map tiles: each tile downloads once instead of on every pan, and the
75
+ wind particles + storm field keep rendering **offline**. The velocity *manifest*
76
+ (`?run=latest`) stays network-first, or the boat would pin itself to a stale run
77
+ forever. Nothing prefetches velocity tiles yet, so offline wind covers only what you
78
+ have panned over, and old runs are not pruned.
70
79
 
71
80
  **Offline circuit breaker.** Some boat routers return errors (or hang) for outbound
72
81
  requests when the uplink is down, instead of failing cleanly. Once a fetch fails, the
@@ -137,6 +146,38 @@ Two ways, chosen automatically:
137
146
  same BoatState feeding `/ws/telemetry`) — for boats with no local InfluxDB, e.g.
138
147
  **SignalK on a Victron GX / Venus OS**. Same JSON contract, fully offline, no database.
139
148
  (`historyAvailable` reports true either way, so the app shows the Trends panel.)
149
+ Same eight channels as the InfluxDB path, `stw` included.
150
+
151
+ **Serving an archive.** Set `historyToken` and the three `history*` fields point at any
152
+ InfluxDB holding `signalk-to-influxdb-v2`-schema data — a still-running local database,
153
+ or a bucket you imported an old logbook into. That is the archive's real value: history
154
+ from before this boat ever synced to the cloud. The token is the switch; blank means the
155
+ built-in ring. The plugin logs which source it chose at startup:
156
+
157
+ ```
158
+ [sailkick-boat] history -> archive http://127.0.0.1:8086 org=addiction bucket=bandg
159
+ [sailkick-boat] history -> built-in ring (no archive token set)
160
+ ```
161
+
162
+ Upgrading from before 0.14.7, when these lived in a hidden `proxy.history` block: your
163
+ saved values still win over the newly pre-filled defaults, so an archive in a
164
+ non-default org/bucket is not silently lost when the config UI writes `signalk` over it.
165
+ The plugin says so in the log — copy the values into the visible fields when convenient.
166
+
167
+ **Do you actually need a local InfluxDB?** For *live* trends, usually not. The app never requests finer
168
+ than `every=5s` over a `window` of 24 h, and the ring's floor at that window is 2 s — so
169
+ set `ringSampleSec: 5` and it matches anything the UI can draw, from live state, with no
170
+ database to run. A local InfluxDB is worth it only for history predating cloud sync,
171
+ more than 30 days offline, or channels outside BoatState (engine, batteries) that the
172
+ Trends panel does not chart anyway.
173
+
174
+ **True wind comes from your instruments.** If the boat publishes
175
+ `environment.wind.speedTrue` / `directionTrue`, those are stored verbatim — a wind
176
+ system corrects for heel and leeway against water-referenced boat speed, and every other
177
+ display aboard shows its numbers. Only when the boat publishes none is true wind
178
+ derived, and then from **STW**, not SOG: true wind is relative to motion through the
179
+ water. (Before v0.14.6 the derivation used SOG, which in 3 kt of foul tide skewed TWD by
180
+ ~12° and TWS by ~1.8 kt.) SOG is the last resort for boats with no paddlewheel.
140
181
  - **Persistent (append-log):** the ring is saved as a JSONL append-log at
141
182
  `<dataDir>/history/history-ring.jsonl` (on the SSD/USB with the tiles; override with
142
183
  `proxy.history.ringDir`), so it **survives restarts**. Each sample appends one line; the file is
@@ -198,7 +239,10 @@ in `index.js`.
198
239
  - **Telemetry sync → cloud**: `enabled`
199
240
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
200
241
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
201
- `prefetchDetailZoom`, `historyToken`
242
+ `prefetchDetailZoom`
243
+ - **History archive** (optional): `historyToken`, `historyInfluxUrl`
244
+ (default `http://127.0.0.1:8086`), `historyOrg` + `historyBucket` (default `signalk`,
245
+ matching `signalk-to-influxdb-v2`)
202
246
 
203
247
  `dataDir` is the one storage location — cached maps, the telemetry spool and the
204
248
  history ring log all live under it. **Put it on the SSD/USB disk, not the SD card.**
package/index.js CHANGED
@@ -69,6 +69,20 @@ function isPrivateHostUrl (u) {
69
69
  } catch { return false }
70
70
  }
71
71
 
72
+ // A pre-filled schema default must not silently shadow a legacy value that differs. The
73
+ // Signal K config UI writes every default on save, so `historyOrg: "signalk"` appears on
74
+ // a boat whose archive has always lived in org "addiction" under the old nested
75
+ // proxy.history block — and the archive would go quiet with no explanation. Rule: an
76
+ // explicitly-changed field wins; otherwise a legacy value that differs from the default
77
+ // wins; otherwise the default.
78
+ function pickHistoryField (flat, legacy, dflt) {
79
+ const f = String(flat == null ? '' : flat).trim()
80
+ const l = String(legacy == null ? '' : legacy).trim()
81
+ if (f && f !== dflt) return { value: f, from: 'config' }
82
+ if (l && l !== dflt) return { value: l, from: 'legacy' }
83
+ return { value: f || l || dflt, from: 'default' }
84
+ }
85
+
72
86
  // Endpoint resolution with one rule: the constant wins unless self-hosting is declared.
73
87
  // Returns { url, ignored } — `ignored` is the stale value we refused, so the caller can
74
88
  // say so out loud instead of leaving the owner to guess why nothing arrives.
@@ -141,7 +155,10 @@ module.exports = function (app) {
141
155
  enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
142
156
  default: 13
143
157
  },
144
- historyToken: { type: 'string', title: 'Local InfluxDB read token (optional)', description: 'Only if this boat already runs its own InfluxDB (e.g. signalk-to-influxdb-v2) and you want full history from it. Leave blank — the plugin then keeps its own lightweight history, which is what most boats want.' }
158
+ historyToken: { type: 'string', title: 'History archive: read token', description: 'Leave blank for the built-in lightweight history, which is what most boats want and needs no database. Paste a read token to serve Trends and the track from a local InfluxDB instead worth it for history recorded before this boat synced to the cloud, for more than 30 days offline, or for an imported archive. Setting a token switches the source; the three fields below say where to find it.' },
159
+ historyInfluxUrl: { type: 'string', title: '…InfluxDB URL', description: 'Where the archive lives. The default is an InfluxDB on this machine.', default: 'http://127.0.0.1:8086' },
160
+ historyOrg: { type: 'string', title: '…organization', description: 'Default matches signalk-to-influxdb-v2.', default: 'signalk' },
161
+ historyBucket: { type: 'string', title: '…bucket', description: 'Default matches signalk-to-influxdb-v2. An imported archive often uses its own name.', default: 'signalk' }
145
162
  }
146
163
  }
147
164
  }
@@ -220,6 +237,22 @@ module.exports = function (app) {
220
237
  const oldSeed = p.seed || {}
221
238
  const oldPrefetch = p.prefetch || {}
222
239
  const oldHistory = p.history || {}
240
+ // History source, resolved before the modules start so it can be reported. A token
241
+ // is the switch: present = query the archive, absent = the built-in ring.
242
+ const hUrl = pickHistoryField(p.historyInfluxUrl, oldHistory.influxUrl, HISTORY_TUNING.influxUrl)
243
+ const hOrg = pickHistoryField(p.historyOrg, oldHistory.org, HISTORY_TUNING.org)
244
+ const hBucket = pickHistoryField(p.historyBucket, oldHistory.bucket, HISTORY_TUNING.bucket)
245
+ const hToken = String(p.historyToken || oldHistory.token || '').trim()
246
+ const fromLegacy = [['URL', hUrl], ['org', hOrg], ['bucket', hBucket]].filter(([, v]) => v.from === 'legacy')
247
+ if (hToken) {
248
+ console.log(`[sailkick-boat] history -> archive ${hUrl.value} org=${hOrg.value} bucket=${hBucket.value}`)
249
+ if (fromLegacy.length) {
250
+ ;(app.error || console.error)(`[sailkick-boat] history archive ${fromLegacy.map(([n]) => n).join(' + ')} taken from the old proxy.history config, not the new field(s) — copy the value(s) up to keep them after the next save`)
251
+ }
252
+ } else {
253
+ console.log('[sailkick-boat] history -> built-in ring (no archive token set)')
254
+ }
255
+
223
256
  if (upstream.ignored) {
224
257
  ;(app.error || console.error)(`[sailkick-boat] ignoring proxy.sailkickUrl "${upstream.ignored}" left over from an older config — mirroring ${upstream.url}. Set proxy.selfHosted:true to keep your own server.`)
225
258
  }
@@ -251,10 +284,10 @@ module.exports = function (app) {
251
284
  history: {
252
285
  ...HISTORY_TUNING,
253
286
  enabled: oldHistory.enabled !== false,
254
- influxUrl: oldHistory.influxUrl || HISTORY_TUNING.influxUrl,
255
- org: oldHistory.org || HISTORY_TUNING.org,
256
- bucket: oldHistory.bucket || HISTORY_TUNING.bucket,
257
- token: p.historyToken || oldHistory.token || '',
287
+ influxUrl: hUrl.value,
288
+ org: hOrg.value,
289
+ bucket: hBucket.value,
290
+ token: hToken,
258
291
  ringPersist: oldHistory.ringPersist !== false,
259
292
  ringWindowSec: oldHistory.ringWindowSec || HISTORY_TUNING.ringWindowSec,
260
293
  ringSampleSec: oldHistory.ringSampleSec || HISTORY_TUNING.ringSampleSec,
@@ -24,12 +24,28 @@ const DEG = Math.PI / 180
24
24
  const wrap360 = (d) => ((d % 360) + 360) % 360
25
25
  const MAX_SAMPLES = 50000
26
26
 
27
- // True wind (TWS kt, TWD ° the wind blows FROM) from apparent + motion over ground.
27
+ // True wind (TWS kt, TWD ° the wind blows FROM).
28
+ //
29
+ // Prefer what the instruments publish. A wind system computes true wind from its own
30
+ // calibrated apparent wind with heel and leeway correction and a water-referenced boat
31
+ // speed — strictly better than anything derivable here, and it is what every other
32
+ // display on board shows, so deriving a second answer would just disagree with them.
33
+ //
34
+ // Only when the boat publishes no true wind do we derive it, and then from speed
35
+ // THROUGH WATER: true wind is defined relative to the boat's motion through the water,
36
+ // not over the ground. Using SOG (as this did before v0.14.6) skews both TWS and TWD in
37
+ // any tidal stream — several degrees and about a knot in 3 kt of tide, which is exactly
38
+ // the error you would be trimming against. SOG remains the last resort for boats with
39
+ // no paddlewheel, where it is at least right in slack water.
28
40
  function trueWind (s) {
41
+ if (Number.isFinite(s.twsKt) && Number.isFinite(s.twdDeg)) {
42
+ return { tws: s.twsKt, twd: wrap360(s.twdDeg), measured: true }
43
+ }
29
44
  if (!Number.isFinite(s.awsKt) || !Number.isFinite(s.awaDeg) || !Number.isFinite(s.headingDeg)) return null
45
+ const boatSpeed = Number.isFinite(s.stwKt) ? s.stwKt : (Number.isFinite(s.sogKt) ? s.sogKt : 0)
30
46
  const a = s.awaDeg * DEG
31
- const x = s.awsKt * Math.cos(a) - (s.sogKt || 0); const y = s.awsKt * Math.sin(a)
32
- return { tws: Math.hypot(x, y), twd: wrap360(s.headingDeg + Math.atan2(y, x) / DEG) }
47
+ const x = s.awsKt * Math.cos(a) - boatSpeed; const y = s.awsKt * Math.sin(a)
48
+ return { tws: Math.hypot(x, y), twd: wrap360(s.headingDeg + Math.atan2(y, x) / DEG), measured: false }
33
49
  }
34
50
 
35
51
  class RingHistoryProvider {
@@ -61,6 +77,7 @@ class RingHistoryProvider {
61
77
  const row = {
62
78
  t: Date.now(),
63
79
  sog: num(s.sogKt), cog: num(s.cogDeg), heading: num(s.headingDeg),
80
+ stw: num(s.stwKt),
64
81
  aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
65
82
  tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
66
83
  lat: num(s.lat), lon: num(s.lon)
@@ -80,7 +97,10 @@ class RingHistoryProvider {
80
97
 
81
98
  getSeries ({ windowSec } = {}) {
82
99
  const rows = this._window(windowSec)
83
- const chans = ['tws', 'twd', 'aws', 'awa', 'sog', 'heading', 'depth'] // no STW in BoatState
100
+ // Matches the InfluxDB provider's channel set exactly (lib/history/index.js MAP), so
101
+ // the Trends panel looks the same whichever provider is serving. Empty channels are
102
+ // omitted, so a boat with no paddlewheel simply has no `stw` key.
103
+ const chans = ['tws', 'twd', 'aws', 'awa', 'sog', 'stw', 'heading', 'depth']
84
104
  const series = {}
85
105
  for (const c of chans) {
86
106
  const pts = rows.filter((r) => r[c] != null).map((r) => [r.t, r[c]])
@@ -18,6 +18,20 @@ const { countBboxTiles, bboxTiles, boxAround } = require('./tiles')
18
18
  // get its live data from the boat's local SignalK and its charts/weather cached.
19
19
  // Also exposes the auth'd /plugins/sailkick-boat/p/* router handlers.
20
20
 
21
+ // Everything under /api/ is dynamic and network-first — EXCEPT paths whose URL is
22
+ // immutable by construction. Velocity tiles embed the forecast run id
23
+ // (/api/velocity/tiles/<layer>/<runId>/<z>/<x>/<y>/<hour>.f32, see the app's
24
+ // public/engine/velocity-client.js), so a given URL's bytes never change: a new
25
+ // forecast run produces new URLs. Treating them as network-first re-downloaded the
26
+ // whole wind field on every pan while online, and left nothing usable offline —
27
+ // pinning them is what makes wind particles and the storm field work with no uplink.
28
+ //
29
+ // The velocity *manifest* is deliberately NOT in here: it resolves `run=latest` and
30
+ // must stay live, otherwise the boat pins itself to a stale forecast run forever.
31
+ const IMMUTABLE_API_PREFIXES = ['/api/velocity/tiles/']
32
+ const isImmutableApi = (p) => IMMUTABLE_API_PREFIXES.some((x) => p.startsWith(x))
33
+ const isNetworkFirst = (p) => p.startsWith('/api/') && !isImmutableApi(p)
34
+
21
35
  function createProxy (app, options) {
22
36
  const log = (m) => (app.debug ? app.debug('[proxy] ' + m) : console.log('[sailkick-boat:proxy]', m))
23
37
  let cfg = null
@@ -138,8 +152,10 @@ function createProxy (app, options) {
138
152
  if (req.method === 'GET' || req.method === 'HEAD') {
139
153
  const { buffer, contentType, cacheState } = await getResource({
140
154
  storeDir: cfg.storeDir, upstream: cfg.upstream, reqPath: req.url, timeoutMs: cfg.timeoutMs,
141
- invalidatedAt: manifest ? manifest.invalidatedAtFor(req.url) : 0,
142
- networkFirst: req.url.startsWith('/api/'), // live data (AIS, weather): fresh online, cache-fallback offline
155
+ // An immutable URL needs no invalidation: a bake announcement can't change
156
+ // bytes that are keyed by run id, so don't let an app rebuild churn them.
157
+ invalidatedAt: isImmutableApi(req.url) ? 0 : (manifest ? manifest.invalidatedAtFor(req.url) : 0),
158
+ networkFirst: isNetworkFirst(req.url), // live data (AIS, weather, lightning): fresh online, cache-fallback offline
143
159
  health
144
160
  })
145
161
  res.setHeader('Content-Type', contentType)
@@ -242,8 +258,8 @@ function createProxy (app, options) {
242
258
  try {
243
259
  const { buffer, contentType, cacheState } = await getResource({
244
260
  storeDir: cfg.storeDir, upstream: cfg.upstream, reqPath, timeoutMs: cfg.timeoutMs,
245
- invalidatedAt: manifest ? manifest.invalidatedAtFor(reqPath) : 0,
246
- networkFirst: reqPath.startsWith('/api/'), health
261
+ invalidatedAt: isImmutableApi(reqPath) ? 0 : (manifest ? manifest.invalidatedAtFor(reqPath) : 0),
262
+ networkFirst: isNetworkFirst(reqPath), health
247
263
  })
248
264
  res.set('Content-Type', contentType)
249
265
  res.set('X-Sailkick-Cache', cacheState)
@@ -390,4 +406,4 @@ function createProxy (app, options) {
390
406
  return { start, stop, status, handleGet, handlePrefetch, handlePrefetchRegion, handleClear, _serveMirror: serveMirror, _serveConfig: serveConfig, _manifest: () => manifest, _seeder: () => seeder, _startAreaPrefetch: startAreaPrefetch, _area: () => area }
391
407
  }
392
408
 
393
- module.exports = { createProxy }
409
+ module.exports = { createProxy, isNetworkFirst, isImmutableApi }
@@ -27,6 +27,9 @@ function signalkValuesToPatch (values) {
27
27
  case 'navigation.speedOverGround':
28
28
  if (Number.isFinite(v.value)) patch.sogKt = Math.max(0, v.value * MS_TO_KT)
29
29
  break
30
+ case 'navigation.speedThroughWater':
31
+ if (Number.isFinite(v.value)) patch.stwKt = Math.max(0, v.value * MS_TO_KT)
32
+ break
30
33
  case 'navigation.courseOverGroundTrue':
31
34
  if (Number.isFinite(v.value)) patch.cogDeg = wrap360(v.value * RAD2DEG)
32
35
  break
@@ -45,10 +48,34 @@ function signalkValuesToPatch (values) {
45
48
  case 'environment.wind.angleApparent':
46
49
  if (Number.isFinite(v.value)) patch.awaDeg = wrap180(v.value * RAD2DEG)
47
50
  break
51
+ case 'environment.wind.speedTrue':
52
+ if (Number.isFinite(v.value)) patch.twsKt = Math.max(0, v.value * MS_TO_KT)
53
+ break
54
+ case 'environment.wind.directionTrue': // absolute compass direction (not off-bow)
55
+ if (Number.isFinite(v.value)) patch.twdDeg = wrap360(v.value * RAD2DEG)
56
+ break
48
57
  case 'environment.depth.belowSurface':
49
58
  case 'environment.depth.belowTransducer':
50
59
  if (Number.isFinite(v.value)) patch.depthM = v.value
51
60
  break
61
+ case 'steering.rudderAngle':
62
+ if (Number.isFinite(v.value)) patch.rudderDeg = v.value * RAD2DEG
63
+ break
64
+ case 'navigation.rateOfTurn':
65
+ if (Number.isFinite(v.value)) patch.rotDegMin = v.value * RAD2DEG * 60
66
+ break
67
+ case 'navigation.attitude': // object { roll, pitch, yaw } (rad)
68
+ if (v.value && Number.isFinite(v.value.roll)) patch.heelDeg = v.value.roll * RAD2DEG
69
+ break
70
+ case 'steering.autopilot.state':
71
+ if (typeof v.value === 'string') patch.apState = v.value
72
+ break
73
+ case 'steering.autopilot.target.headingTrue':
74
+ if (Number.isFinite(v.value)) patch.apTargetDeg = wrap360(v.value * RAD2DEG)
75
+ break
76
+ case 'steering.autopilot.target.windAngleApparent':
77
+ if (Number.isFinite(v.value)) patch.apTargetAwa = wrap180(v.value * RAD2DEG)
78
+ break
52
79
  case 'navigation.datetime':
53
80
  if (typeof v.value === 'string') patch.gpsTime = v.value
54
81
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.14.4",
3
+ "version": "0.14.7",
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.",
5
5
  "main": "index.js",
6
6
  "scripts": {