sailkick-boat 0.14.5 → 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
@@ -146,6 +146,38 @@ Two ways, chosen automatically:
146
146
  same BoatState feeding `/ws/telemetry`) — for boats with no local InfluxDB, e.g.
147
147
  **SignalK on a Victron GX / Venus OS**. Same JSON contract, fully offline, no database.
148
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.
149
181
  - **Persistent (append-log):** the ring is saved as a JSONL append-log at
150
182
  `<dataDir>/history/history-ring.jsonl` (on the SSD/USB with the tiles; override with
151
183
  `proxy.history.ringDir`), so it **survives restarts**. Each sample appends one line; the file is
@@ -207,7 +239,10 @@ in `index.js`.
207
239
  - **Telemetry sync → cloud**: `enabled`
208
240
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
209
241
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
210
- `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`)
211
246
 
212
247
  `dataDir` is the one storage location — cached maps, the telemetry spool and the
213
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]])
@@ -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.5",
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": {