sailkick-boat 0.14.7 → 0.17.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
@@ -27,7 +27,7 @@ One Signal K plugin, independently-toggleable modules — so the boat stays
27
27
  same contracts as the cloud, offline-first):
28
28
  - **`/ws/telemetry`** — the app's live telemetry bus, fed from local SignalK.
29
29
  - **`/api/history/{series,track}`** — the app's Trends panel + track, served
30
- from the boat's local InfluxDB (or a DB-less telemetry ring) — full local history.
30
+ from a live ring sampled on the boat — full local history, no database.
31
31
 
32
32
  Kept as separate modules so a proxy fault can't wedge the data-critical sync path.
33
33
 
@@ -139,37 +139,71 @@ config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep th
139
139
  login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
140
140
 
141
141
  ## Local history (offline Trends + track)
142
- Two ways, chosen automatically:
143
- - **Read token set** full history queried from a **local InfluxDB** (e.g. a bucket
144
- written by `signalk-to-influxdb-v2`).
145
- - **No token, telemetry on** → a **DB-less ring** sampled from live telemetry (the
146
- same BoatState feeding `/ws/telemetry`) — for boats with no local InfluxDB, e.g.
147
- **SignalK on a Victron GX / Venus OS**. Same JSON contract, fully offline, no database.
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.
142
+ **One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
143
+ no database, works on a Victron GX with nothing else installed. `historyAvailable` is
144
+ forced on so the app shows the Trends panel, and the eight channels match what the cloud
145
+ serves, `stw` included.
146
+
147
+ There is deliberately **no way to point this at a local InfluxDB**. Until v0.15.0 a read
148
+ token did exactly that, and it was a trap: the app only ever asks for a *relative* window
149
+ clamped to 24 h, so aiming it at a bucket of older data matched nothing — Trends went
150
+ blank **and** the working live ring was switched off. If you still have a token in your
151
+ config it is now inert; the plugin logs `history -> live ring` regardless.
152
+
153
+ A local InfluxDB is not a competitor here anyway. The app never requests finer than
154
+ `every=5s` over a 24 h window, and the ring's floor at that window is 2 s set
155
+ `ringSampleSec: 5` and it matches anything the UI can draw, from live state. What an old
156
+ database *is* good for is its contents ending up **in the cloud** — see below.
157
+
158
+ ## Uploading AIS targets
159
+ The cloud app already draws other vessels, but its AIS source polls a SignalK server over
160
+ the LAN and keeps everything in memory — which cannot work once a boat is on a mobile
161
+ link. Enable **Upload AIS targets** and the boat pushes what its own receiver hears, so
162
+ the web app can show other boats, their heading and their trail from stored data.
163
+
164
+ Only **locally received** AIS is forwarded. A boat running an internet feed such as
165
+ `signalk-aisstream` would otherwise spend uplink bandwidth sending data the cloud can
166
+ fetch directly from the same API — known feeds are skipped automatically. The plugin logs
167
+ the AIS sources it sees, so you can name your own receiver in **Only this AIS source** if
168
+ you want to be explicit.
169
+
170
+ There is no radius or rate limit: a real AIS receiver is bounded by VHF line-of-sight,
171
+ which is the honest limiter, and offshore where this data is most valuable, because
172
+ commercial feeds are blind there — it tends to zero. Vessel identity (name, dimensions,
173
+ ship type) repeats every few minutes and never changes, so it is re-sent at most hourly;
174
+ positions are never throttled.
175
+
176
+ Telemetry always wins the link. AIS buffers in its **own** spool with its own cap and
177
+ stands down completely whenever the telemetry spool has a backlog, so a busy anchorage
178
+ can never delay or evict your own boat's data.
179
+
180
+ > ⚠️ **Requires a cloud that filters history on `self`.** Every AIS row is tagged
181
+ > `self=false`, and the cloud's Trends and track queries must filter `self == "true"`.
182
+ > Without that, other ships' speed and heading appear in *your* charts. Leave this off
183
+ > until the server side is in place.
184
+
185
+ ## Copying older history to the cloud (one-time)
186
+ If the boat recorded into its own InfluxDB before it started syncing — a
187
+ `signalk-to-influxdb-v2` bucket, or an imported logbook — the **backfill** copies it up
188
+ so the cloud holds your full history. Live sync can't do this: it only ever sees deltas
189
+ arriving now, and the spool only replays what it captured itself while offline.
190
+
191
+ Fill the **Copy older history to the cloud** section and save. It walks backwards in
192
+ one-hour windows (newest first, so recent history lands first), resumes after a restart
193
+ from a manifest, and stands aside whenever live telemetry has a backlog — the data-
194
+ critical path is never starved by a bulk upload. Progress shows in the status line.
195
+
196
+ It needs a **cloud read+write token**, not the write token from signup. Every hour it
197
+ uploads is verified by counting the destination, and a write-only token cannot read. A
198
+ `204` means InfluxDB accepted the bytes, not that every point landed — without the count
199
+ a partial write would be marked done and lost. The token is only needed while the
200
+ backfill runs: **revoke it afterwards**, live sync is unaffected.
201
+
202
+ Safe to re-run. Points are keyed by (measurement, tagset, nanosecond timestamp), so an
203
+ identical point overwrites rather than duplicating — an interrupted migration is simply
204
+ run again. Everything in the bucket is copied, including AIS contexts; hand-edit
205
+ `backfill.selfOnly: true` to restrict it to your own vessel if cloud series cardinality
206
+ becomes a problem.
173
207
 
174
208
  **True wind comes from your instruments.** If the boat publishes
175
209
  `environment.wind.speedTrue` / `directionTrue`, those are stored verbatim — a wind
@@ -190,14 +224,14 @@ water. (Before v0.14.6 the derivation used SOG, which in 3 kt of foul tide skewe
190
224
 
191
225
  The sailkick app is deployment-agnostic about history: *central Influx in the
192
226
  cloud, in-memory ring on a DB-less edge*. The boat is a third case — an edge that
193
- serves the app's history endpoints from its **own** data (local InfluxDB or the ring):
227
+ serves the app's history endpoints from its **own** live data:
194
228
  ```
195
229
  GET /api/history/series?window=3600s&every=30s -> { series: { sog|heading|tws|… : [[tMs,val],…] } }
196
230
  GET /api/history/track?window=3600s -> { track: [{ t, lat, lon }, …] }
197
231
  ```
198
232
  Same JSON the cloud returns, so the browser can't tell the difference — but it
199
- works **offline** with the boat's own data. Only when neither a local InfluxDB nor
200
- telemetry is available do these paths **fall through to the cloud mirror**, so an
233
+ works **offline** with the boat's own data. Only when no telemetry source is
234
+ available at all do these paths **fall through to the cloud mirror**, so an
201
235
  online boat is never worse off than before.
202
236
 
203
237
  ## Setup: register on the web, then paste the token
@@ -237,21 +271,16 @@ in `index.js`.
237
271
 
238
272
  - **Sailkick account**: `slug` (boat name), `writeToken`
239
273
  - **Telemetry sync → cloud**: `enabled`
274
+ - **Upload AIS targets**: `enabled` (default off), `source`
240
275
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
241
276
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
242
277
  `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`)
246
278
 
247
279
  `dataDir` is the one storage location — cached maps, the telemetry spool and the
248
280
  history ring log all live under it. **Put it on the SSD/USB disk, not the SD card.**
249
281
  Leave it blank and each part falls back to its historical spot under the plugin data
250
282
  dir.
251
283
 
252
- `historyToken` is optional and only matters if the boat already runs its own InfluxDB.
253
- Blank — the normal case — uses the built-in DB-less ring.
254
-
255
284
  Cache-manifest polling is always on (no toggle): tile freshness comes from the cloud
256
285
  announcing bakes, and without it a re-baked dataset would never refresh.
257
286
 
package/index.js CHANGED
@@ -6,6 +6,8 @@ const { createSync } = require('./lib/sync')
6
6
  const { createProxy } = require('./lib/proxy')
7
7
  const { createTelemetry } = require('./lib/telemetry')
8
8
  const { createHistory } = require('./lib/history')
9
+ const { createBackfill } = require('./lib/backfill')
10
+ const { createAis } = require('./lib/ais')
9
11
  const { resolveAccountConfig } = require('./lib/account')
10
12
 
11
13
  // sailkick-boat: one Signal K plugin, two independently-toggleable modules —
@@ -43,15 +45,8 @@ const PROXY_TUNING = { requestTimeoutMs: 20000, localPaths: ['/signalk'], teleme
43
45
  const MANIFEST = { enabled: true, path: '/api/cache-manifest', pollIntervalSec: 300 }
44
46
  const SEED_TUNING = { coastlineMaxZoom: 8, seabedMaxZoom: 6, concurrency: 4 }
45
47
  const PREFETCH_TUNING = { concurrency: 4 }
46
- const HISTORY_TUNING = {
47
- influxUrl: 'http://127.0.0.1:8086',
48
- org: 'signalk',
49
- bucket: 'signalk',
50
- requestTimeoutMs: 15000,
51
- ringPersist: true,
52
- ringWindowSec: 86400,
53
- ringSampleSec: 15
54
- }
48
+ const HISTORY_TUNING = { ringPersist: true, ringWindowSec: 86400, ringSampleSec: 15 }
49
+ const BACKFILL_SRC_DEFAULTS = { influxUrl: 'http://127.0.0.1:8086', org: 'signalk', bucket: 'signalk' }
55
50
 
56
51
  // A *cloud* endpoint on a loopback or private address means telemetry never leaves the
57
52
  // LAN. On the wire that is indistinguishable from a normal offline backlog — the spool
@@ -69,13 +64,12 @@ function isPrivateHostUrl (u) {
69
64
  } catch { return false }
70
65
  }
71
66
 
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) {
67
+ // The Signal K config UI writes every schema default on save, so a freshly pre-filled
68
+ // `backfillOrg: "signalk"` can appear on a boat whose archive has always lived in org
69
+ // "addiction" under the pre-0.15 `proxy.history` block and the backfill would read an
70
+ // empty bucket. Rule: an explicitly-changed field wins; else a legacy value that differs
71
+ // from the default wins; else the default.
72
+ function pickField (flat, legacy, dflt) {
79
73
  const f = String(flat == null ? '' : flat).trim()
80
74
  const l = String(legacy == null ? '' : legacy).trim()
81
75
  if (f && f !== dflt) return { value: f, from: 'config' }
@@ -98,6 +92,8 @@ module.exports = function (app) {
98
92
  let proxy = null
99
93
  let telemetry = null
100
94
  let history = null
95
+ let backfill = null
96
+ let ais = null
101
97
  let statusTimer = null
102
98
  let accountStatus = null
103
99
  let syncWarning = null
@@ -130,6 +126,28 @@ module.exports = function (app) {
130
126
  enabled: { type: 'boolean', title: 'Enable telemetry sync', default: true }
131
127
  }
132
128
  },
129
+ ais: {
130
+ type: 'object',
131
+ title: 'Upload AIS targets',
132
+ description: 'Send the AIS this boat\'s own receiver hears to the cloud, so the web app can show other vessels, their heading and their trail. Only locally received AIS is forwarded — an internet feed such as signalk-aisstream is skipped, since the cloud can fetch that itself without spending your uplink. REQUIRES a cloud that filters history on self; until then leave this off or the owner\'s SOG and heading charts will pick up other ships.',
133
+ properties: {
134
+ enabled: { type: 'boolean', title: 'Upload AIS targets', default: false },
135
+ source: { type: 'string', title: 'Only this AIS source', description: 'Blank forwards every source except known internet feeds. The plugin logs the AIS sources it sees — copy the one for your own receiver here if you want to be explicit.' }
136
+ }
137
+ },
138
+ backfill: {
139
+ type: 'object',
140
+ title: 'Copy older history to the cloud (one-time)',
141
+ description: 'If this boat recorded data into its own InfluxDB before it started syncing — a signalk-to-influxdb-v2 bucket, or an imported logbook — this copies it up so the app can chart it. It runs in the background, resumes after a restart, verifies every hour it uploads, and stands aside whenever live telemetry is behind. Safe to re-run: identical points overwrite rather than duplicate.',
142
+ properties: {
143
+ enabled: { type: 'boolean', title: 'Run the backfill', default: false },
144
+ cloudToken: { type: 'string', title: 'Cloud read+write token', description: 'A token with READ and WRITE on your cloud bucket. Your normal write token cannot read, and reading is how each uploaded hour is verified. Needed only while the backfill runs — revoke it afterwards; live sync is unaffected.' },
145
+ sourceToken: { type: 'string', title: 'Local InfluxDB read token' },
146
+ sourceUrl: { type: 'string', title: '…local InfluxDB URL', default: 'http://127.0.0.1:8086' },
147
+ sourceOrg: { type: 'string', title: '…organization', default: 'signalk' },
148
+ sourceBucket: { type: 'string', title: '…bucket', default: 'signalk' }
149
+ }
150
+ },
133
151
  proxy: {
134
152
  type: 'object',
135
153
  title: 'Offline app & maps',
@@ -154,11 +172,7 @@ module.exports = function (app) {
154
172
  enum: [12, 13, 14, 15],
155
173
  enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
156
174
  default: 13
157
- },
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' }
175
+ }
162
176
  }
163
177
  }
164
178
  }
@@ -237,21 +251,7 @@ module.exports = function (app) {
237
251
  const oldSeed = p.seed || {}
238
252
  const oldPrefetch = p.prefetch || {}
239
253
  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
- }
254
+ console.log('[sailkick-boat] history -> live ring')
255
255
 
256
256
  if (upstream.ignored) {
257
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.`)
@@ -284,10 +284,6 @@ module.exports = function (app) {
284
284
  history: {
285
285
  ...HISTORY_TUNING,
286
286
  enabled: oldHistory.enabled !== false,
287
- influxUrl: hUrl.value,
288
- org: hOrg.value,
289
- bucket: hBucket.value,
290
- token: hToken,
291
287
  ringPersist: oldHistory.ringPersist !== false,
292
288
  ringWindowSec: oldHistory.ringWindowSec || HISTORY_TUNING.ringWindowSec,
293
289
  ringSampleSec: oldHistory.ringSampleSec || HISTORY_TUNING.ringSampleSec,
@@ -328,6 +324,68 @@ module.exports = function (app) {
328
324
  }
329
325
  }
330
326
 
327
+ // --- AIS upload (isolated; yields to telemetry, its own spool) ---
328
+ const aisOpts = opts.ais || {}
329
+ if (aisOpts.enabled === true) {
330
+ if (!b.writeToken) {
331
+ (app.error || console.error)('[sailkick-boat] AIS upload needs a paired account for its destination bucket — skipped')
332
+ } else {
333
+ try {
334
+ ais = createAis(app, {
335
+ influxUrl: influx.url,
336
+ org: b.org || SYNC_TUNING.org,
337
+ bucket: b.bucket,
338
+ token: b.writeToken,
339
+ source: String(aisOpts.source || '').trim() || null,
340
+ spoolDir: store ? path.join(store, 'ais-spool') : undefined,
341
+ pending: sync ? sync.pending : null
342
+ })
343
+ ais.start()
344
+ console.log(`[sailkick-boat] ais -> ${influx.url} bucket=${b.bucket}${aisOpts.source ? ' source=' + aisOpts.source : ''}`)
345
+ } catch (e) {
346
+ (app.error || console.error)('[sailkick-boat] AIS start failed: ' + e.message)
347
+ ais = null
348
+ }
349
+ }
350
+ }
351
+
352
+ // --- backfill (best-effort, isolated: it must never disturb sync or the proxy) ---
353
+ const bf = opts.backfill || {}
354
+ const oldHist = (opts.proxy || {}).history || {}
355
+ if (bf.enabled === true) {
356
+ // Pre-0.15 installs kept the archive's coordinates in proxy.history.* — reuse them
357
+ // so nobody retypes an org/bucket the plugin already knows.
358
+ const src = {
359
+ url: pickField(bf.sourceUrl, oldHist.influxUrl, BACKFILL_SRC_DEFAULTS.influxUrl).value,
360
+ org: pickField(bf.sourceOrg, oldHist.org, BACKFILL_SRC_DEFAULTS.org).value,
361
+ bucket: pickField(bf.sourceBucket, oldHist.bucket, BACKFILL_SRC_DEFAULTS.bucket).value,
362
+ token: String(bf.sourceToken || oldHist.token || '').trim()
363
+ }
364
+ const dst = { url: influx.url, org: b.org || SYNC_TUNING.org, bucket: b.bucket, token: String(bf.cloudToken || '').trim() }
365
+ if (!b.writeToken) {
366
+ (app.error || console.error)('[sailkick-boat] backfill needs a paired account for its destination bucket — skipped')
367
+ } else if (!dst.token) {
368
+ (app.error || console.error)('[sailkick-boat] backfill needs a cloud READ+WRITE token: every uploaded hour is verified by counting the destination, which the write-only sync token cannot do — skipped')
369
+ } else if (!src.token) {
370
+ (app.error || console.error)('[sailkick-boat] backfill needs a read token for the local InfluxDB — skipped')
371
+ } else {
372
+ try {
373
+ backfill = createBackfill(app, {
374
+ src,
375
+ dst,
376
+ selfOnly: bf.selfOnly === true,
377
+ startBound: bf.startBound,
378
+ stateFile: path.join((app.getDataDirPath && app.getDataDirPath()) || '.', 'backfill.json'),
379
+ pending: sync ? sync.pending : null
380
+ })
381
+ backfill.start()
382
+ } catch (e) {
383
+ (app.error || console.error)('[sailkick-boat] backfill start failed: ' + e.message)
384
+ backfill = null
385
+ }
386
+ }
387
+ }
388
+
331
389
  statusTimer = setInterval(updateStatus, 5000)
332
390
  updateStatus()
333
391
  }
@@ -341,6 +399,8 @@ module.exports = function (app) {
341
399
  if (proxy) parts.push(proxy.status())
342
400
  if (telemetry) parts.push(telemetry.status())
343
401
  if (history) parts.push(history.status())
402
+ if (ais) parts.push(ais.status())
403
+ if (backfill) parts.push(backfill.status())
344
404
  try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
345
405
  }
346
406
 
@@ -350,10 +410,14 @@ module.exports = function (app) {
350
410
  try { if (sync) sync.stop() } catch {}
351
411
  try { if (telemetry) telemetry.stop() } catch {}
352
412
  try { if (history) history.stop() } catch {}
413
+ try { if (ais) ais.stop() } catch {}
414
+ try { if (backfill) backfill.stop() } catch {}
353
415
  try { if (proxy) proxy.stop() } catch {}
354
416
  sync = null
355
417
  telemetry = null
356
418
  history = null
419
+ backfill = null
420
+ ais = null
357
421
  proxy = null
358
422
  accountStatus = null
359
423
  syncWarning = null
@@ -0,0 +1,262 @@
1
+ 'use strict'
2
+
3
+ // AIS upload — forward the targets this boat's own receiver hears, so the cloud app can
4
+ // draw other vessels, their heading and their trail.
5
+ //
6
+ // The cloud already renders AIS (public/viewer/ais.js → /api/ais), but its source polls a
7
+ // SignalK server over the LAN and keeps everything in memory. That only works while the
8
+ // cloud can reach the boat inbound, which stops being true on a mobile link. So the boat
9
+ // pushes instead.
10
+ //
11
+ // Only LOCALLY RECEIVED AIS is worth uploading. A boat running an internet feed like
12
+ // signalk-aisstream would otherwise spend uplink bandwidth sending data the cloud could
13
+ // fetch directly from the same API over a fat pipe. The value is what your own VHF hears
14
+ // offshore, where commercial feeds are blind — which is also why there is no radius or
15
+ // rate limit here: VHF line-of-sight is the honest limiter, and offshore it tends to zero.
16
+ //
17
+ // Two invariants this module must not break:
18
+ // - Telemetry always wins the link. Separate spool, separate uploader, and it stands
19
+ // down whenever the telemetry spool has a backlog.
20
+ // - Every row is tagged self=false. The cloud's history queries filter on self=="true"
21
+ // to keep other vessels out of the boat's own Trends and track; if that tag were
22
+ // wrong, AIS would land in the owner's SOG and heading charts.
23
+
24
+ const fs = require('fs')
25
+ const path = require('path')
26
+ const { Spool } = require('../sync/spool')
27
+ const { writeLines } = require('../sync/influxWrite')
28
+ const { deltaToLines } = require('../sync/lineprotocol')
29
+
30
+ // What the cloud's /api/ais envelope needs. Subscribing to these rather than '*' keeps
31
+ // delta volume down without imposing a rate limit.
32
+ const POSITION_PATHS = [
33
+ 'navigation.position',
34
+ 'navigation.speedOverGround',
35
+ 'navigation.courseOverGroundTrue',
36
+ 'navigation.headingTrue',
37
+ 'navigation.headingMagnetic',
38
+ 'navigation.magneticVariation',
39
+ 'navigation.rateOfTurn'
40
+ ]
41
+ // Static/identity data: repeats every ~6 min per vessel and essentially never changes.
42
+ const STATIC_PATHS = ['design.length', 'design.beam', 'design.aisShipType']
43
+ const ALL_PATHS = [...POSITION_PATHS, ...STATIC_PATHS]
44
+
45
+ // Sources that are internet feeds rather than a receiver on this boat. Uploading these
46
+ // is pure round-tripping. Matched case-insensitively as a substring of $source.
47
+ const INTERNET_FEEDS = ['aisstream', 'aishub', 'marinetraffic', 'vesselfinder']
48
+
49
+ const DEFAULTS = {
50
+ staticIntervalMs: 3600000, // re-send a vessel's identity at most hourly
51
+ flushIntervalMs: 5000,
52
+ batchSize: 2000,
53
+ maxBufferBytes: 50 * 1024 * 1024, // its own, smaller cap — see below
54
+ idlePollMs: 5000,
55
+ retryMinMs: 2000,
56
+ retryMaxMs: 120000,
57
+ requestTimeoutMs: 30000
58
+ }
59
+
60
+ const isInternetFeed = (src) => {
61
+ const s = String(src || '').toLowerCase()
62
+ return INTERNET_FEEDS.some((f) => s.includes(f))
63
+ }
64
+
65
+ function createAis (app, options) {
66
+ const log = (m) => (app.debug ? app.debug('[ais] ' + m) : console.log('[sailkick-boat:ais]', m))
67
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:ais] ' + m) : console.error('[sailkick-boat:ais]', m))
68
+
69
+ const cfg = { ...DEFAULTS, ...options }
70
+ let state = null
71
+
72
+ function start () {
73
+ if (!cfg.influxUrl || !cfg.bucket || !cfg.token) {
74
+ warn('not started — the boat is not paired, so there is no destination bucket')
75
+ state = { statusLine: 'ais: not configured', stopped: true }
76
+ return
77
+ }
78
+ const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
79
+ // A SEPARATE spool. Sharing the telemetry one would be dangerous: it drops the
80
+ // OLDEST files on overflow, so an AIS flood in a busy anchorage could evict
81
+ // telemetry that had not been sent yet.
82
+ const spoolDir = cfg.spoolDir || path.join(dataDir, 'ais-spool')
83
+ const spool = new Spool({ dir: spoolDir, maxBytes: cfg.maxBufferBytes, logger: log })
84
+ const selfContext = app.selfContext || ('vessels.' + (app.selfId || 'self'))
85
+
86
+ state = {
87
+ spool,
88
+ selfContext,
89
+ batch: [],
90
+ lastStatic: new Map(), // context -> ms, so identity is re-sent at most hourly
91
+ sourcesSeen: new Map(), // $source -> count, for the discovery log
92
+ targets: new Set(),
93
+ forwarded: 0,
94
+ dropped: 0,
95
+ lastOkAt: null,
96
+ backoff: cfg.retryMinMs,
97
+ pumping: false,
98
+ stopped: false,
99
+ unsubscribes: [],
100
+ statusLine: 'ais: starting'
101
+ }
102
+
103
+ spool.init().then(() => {
104
+ // stop() can land before init resolves (disable during startup, or a fast
105
+ // restart). Without this the timers are installed on an already-stopped module
106
+ // and never cleared — a leaked interval that also keeps the host process alive.
107
+ if (!state || state.stopped) return
108
+ state.flushTimer = setInterval(flush, cfg.flushIntervalMs)
109
+ state.reportTimer = setInterval(reportSources, 300000)
110
+ // Unref every timer: this module must never be the reason the Signal K server
111
+ // cannot exit.
112
+ if (state.flushTimer.unref) state.flushTimer.unref()
113
+ if (state.reportTimer.unref) state.reportTimer.unref()
114
+ subscribe()
115
+ pump()
116
+ log(`started; ${cfg.source ? 'source "' + cfg.source + '" only' : 'all sources except known internet feeds'}; buffer ${spoolDir}`)
117
+ }).catch((e) => warn('init failed: ' + e.message))
118
+ }
119
+
120
+ // Which $source values are actually producing AIS, so the config field can be filled
121
+ // in from the log instead of guessed.
122
+ function reportSources () {
123
+ if (!state || !state.sourcesSeen.size) return
124
+ const list = [...state.sourcesSeen.entries()].sort((a, b) => b[1] - a[1])
125
+ .map(([s, n]) => `${s} (${n}${isInternetFeed(s) ? ', internet feed — not forwarded' : ''})`)
126
+ log('AIS sources seen: ' + list.join('; '))
127
+ }
128
+
129
+ function accept (context, source) {
130
+ if (!context || context === state.selfContext) return false // never our own vessel
131
+ if (cfg.source) return String(source) === String(cfg.source)
132
+ return !isInternetFeed(source)
133
+ }
134
+
135
+ function handleDelta (delta) {
136
+ if (!state || state.stopped || !delta || !Array.isArray(delta.updates)) return
137
+ const context = delta.context
138
+ if (!context || context === state.selfContext) return
139
+
140
+ for (const update of delta.updates) {
141
+ if (!update || !Array.isArray(update.values)) continue
142
+ const source = update.$source || (update.source && (update.source.label || update.source.type)) || 'unknown'
143
+ state.sourcesSeen.set(source, (state.sourcesSeen.get(source) || 0) + 1)
144
+ if (!accept(context, source)) { state.dropped++; continue }
145
+
146
+ // Split identity from position: identity repeats constantly and never changes.
147
+ const now = Date.now()
148
+ const staticDue = (now - (state.lastStatic.get(context) || 0)) >= cfg.staticIntervalMs
149
+ const values = update.values.filter((pv) => {
150
+ if (!pv || !pv.path) return false
151
+ if (STATIC_PATHS.includes(pv.path)) return staticDue
152
+ return POSITION_PATHS.includes(pv.path)
153
+ })
154
+ if (!values.length) continue
155
+ if (staticDue && values.some((pv) => STATIC_PATHS.includes(pv.path))) state.lastStatic.set(context, now)
156
+
157
+ // self:false is the tag the cloud filters on to keep AIS out of the owner's charts.
158
+ const lines = deltaToLines({ context, updates: [{ ...update, values }] }, { self: false })
159
+ if (lines.length) {
160
+ state.batch.push(...lines)
161
+ state.targets.add(context)
162
+ state.forwarded += lines.length
163
+ }
164
+ if (state.batch.length >= cfg.batchSize) flush()
165
+ }
166
+ }
167
+
168
+ function subscribe () {
169
+ const sub = { context: '*', subscribe: ALL_PATHS.map((p) => ({ path: p, period: cfg.periodMs || 10000 })) }
170
+ if (app.subscriptionmanager && app.subscriptionmanager.subscribe) {
171
+ app.subscriptionmanager.subscribe(sub, state.unsubscribes, (err) => warn('subscription error: ' + err), handleDelta)
172
+ } else if (app.signalk && app.signalk.on) {
173
+ const h = (d) => handleDelta(d)
174
+ app.signalk.on('delta', h)
175
+ state.unsubscribes.push(() => app.signalk.removeListener('delta', h))
176
+ } else {
177
+ warn('no subscription mechanism available — nothing will be forwarded')
178
+ }
179
+ }
180
+
181
+ function flush () {
182
+ if (!state || state.stopped || !state.batch.length) return
183
+ const lines = state.batch
184
+ state.batch = []
185
+ state.spool.append(lines).then(() => pump()).catch((e) => {
186
+ warn('spool append failed: ' + e.message)
187
+ state.batch.unshift(...lines)
188
+ })
189
+ }
190
+
191
+ async function pump () {
192
+ if (!state || state.stopped || state.pumping) return
193
+ // Telemetry always wins the link: stand down entirely while its spool is behind.
194
+ if (cfg.pending) {
195
+ let depth = 0
196
+ try { depth = (await cfg.pending()).count || 0 } catch {}
197
+ if (depth) { refreshStatus(`waiting — telemetry backlog (${depth})`); scheduleIdle(); return }
198
+ }
199
+ state.pumping = true
200
+ try {
201
+ for (const file of await state.spool.pending()) {
202
+ if (state.stopped) break
203
+ let body
204
+ try { body = await fs.promises.readFile(file, 'utf8') } catch { continue }
205
+ if (!body.trim()) { await state.spool.remove(file); continue }
206
+ const res = await writeLines({ influxUrl: cfg.influxUrl, org: cfg.org, bucket: cfg.bucket, token: cfg.token, timeoutMs: cfg.requestTimeoutMs }, body)
207
+ if (res.ok) {
208
+ await state.spool.remove(file); state.lastOkAt = new Date(); state.backoff = cfg.retryMinMs
209
+ } else if (res.retryable) {
210
+ state.pumping = false; scheduleRetry(res); return
211
+ } else {
212
+ warn(`batch rejected (HTTP ${res.status}) — quarantined`)
213
+ await state.spool.quarantine(file)
214
+ }
215
+ }
216
+ } catch (e) {
217
+ warn('pump error: ' + e.message); state.pumping = false; scheduleRetry(); return
218
+ }
219
+ state.pumping = false
220
+ scheduleIdle()
221
+ refreshStatus()
222
+ }
223
+
224
+ function scheduleRetry (res) {
225
+ if (!state || state.stopped) return
226
+ clearTimeout(state.pumpTimer)
227
+ const delay = state.backoff
228
+ state.backoff = Math.min(state.backoff * 2, cfg.retryMaxMs)
229
+ state.pumpTimer = setTimeout(pump, delay)
230
+ if (state.pumpTimer.unref) state.pumpTimer.unref()
231
+ refreshStatus(`${res && res.status ? 'HTTP ' + res.status : 'offline'} — retry ${Math.round(delay / 1000)}s`)
232
+ }
233
+ function scheduleIdle () {
234
+ if (!state || state.stopped) return
235
+ clearTimeout(state.pumpTimer)
236
+ state.pumpTimer = setTimeout(pump, cfg.idlePollMs)
237
+ if (state.pumpTimer.unref) state.pumpTimer.unref()
238
+ }
239
+ function refreshStatus (suffix) {
240
+ if (!state) return
241
+ state.statusLine = `ais: ${state.targets.size} target(s), ${state.forwarded} point(s)${state.dropped ? `, ${state.dropped} skipped` : ''}${suffix ? '; ' + suffix : ''}`
242
+ }
243
+
244
+ function stop () {
245
+ if (!state) return
246
+ state.stopped = true
247
+ clearInterval(state.flushTimer)
248
+ clearInterval(state.reportTimer)
249
+ clearTimeout(state.pumpTimer)
250
+ for (const u of (state.unsubscribes || [])) { try { u() } catch {} }
251
+ if (state.batch && state.batch.length && state.spool) {
252
+ try { fs.writeFileSync(path.join(state.spool.dir, `${Date.now()}-final.lp`), state.batch.join('\n') + '\n') } catch {}
253
+ state.batch = []
254
+ }
255
+ }
256
+
257
+ function status () { return state ? state.statusLine : 'ais: off' }
258
+
259
+ return { start, stop, status, _handleDelta: handleDelta, _state: () => state, _flush: flush }
260
+ }
261
+
262
+ module.exports = { createAis, isInternetFeed, POSITION_PATHS, STATIC_PATHS }
@@ -0,0 +1,257 @@
1
+ 'use strict'
2
+
3
+ // Backfill: copy an older local InfluxDB into the boat's cloud bucket.
4
+ //
5
+ // The point is history from BEFORE this boat ever synced. Live sync only ever sees
6
+ // deltas arriving now, and the spool only replays what it captured itself while offline,
7
+ // so nothing else in the plugin can reach back.
8
+ //
9
+ // The control flow is lifted from sailkick-sync/tools/import-bandg.sh, which has already
10
+ // done this job for real: newest-first hour windows, count-before-fetch, a manifest of
11
+ // completed windows, verify each window against the destination, and abort a run after
12
+ // consecutive errors so a boat that has gone offline never marks a window falsely done.
13
+ // The difference is direction — that script PULLS from the boat and must reach its
14
+ // InfluxDB inbound, which a boat on a mobile link cannot offer, so this PUSHES.
15
+ //
16
+ // Safety rests on two things. Writes are idempotent on (measurement, tagset, ns
17
+ // timestamp), so re-running is always harmless. And every window is verified by counting
18
+ // the destination, which is why this needs a read+write cloud token rather than the
19
+ // write-only one live sync uses: a 204 means InfluxDB accepted the bytes, not that every
20
+ // point landed. That token is only needed during the migration and can be revoked after,
21
+ // leaving live sync on its least-privilege token.
22
+
23
+ const fs = require('fs')
24
+ const path = require('path')
25
+ const { writeLines } = require('../sync/influxWrite')
26
+ const { csvToLineProtocol } = require('./lineproto')
27
+
28
+ const HOUR_MS = 3600000
29
+ const DEFAULTS = {
30
+ windowMs: HOUR_MS,
31
+ batchSize: 10000,
32
+ idleMs: 2000, // breathing room between windows so a slow link isn't monopolised
33
+ backlogWaitMs: 15000, // how long to stand down when live sync has a backlog
34
+ maxErrorStreak: 5,
35
+ queryTimeoutMs: 120000
36
+ }
37
+
38
+ const iso = (ms) => new Date(ms).toISOString()
39
+ const hourFloor = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS
40
+ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref() })
41
+
42
+ function createBackfill (app, options) {
43
+ const log = (m) => (app.debug ? app.debug('[backfill] ' + m) : console.log('[sailkick-boat:backfill]', m))
44
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:backfill] ' + m) : console.error('[sailkick-boat:backfill]', m))
45
+
46
+ const cfg = {
47
+ ...DEFAULTS,
48
+ ...options,
49
+ src: { ...(options.src || {}) },
50
+ dst: { ...(options.dst || {}) }
51
+ }
52
+ let state = null
53
+ let running = false
54
+ let stopped = false
55
+ let statusLine = 'backfill: off'
56
+ let runPromise = null
57
+
58
+ // --- persisted manifest -------------------------------------------------------
59
+ function load () {
60
+ try {
61
+ const j = JSON.parse(fs.readFileSync(cfg.stateFile, 'utf8'))
62
+ if (j && typeof j === 'object') return { done: j.done || {}, earliest: j.earliest || null, points: j.points || 0, complete: !!j.complete }
63
+ } catch {}
64
+ return { done: {}, earliest: null, points: 0, complete: false }
65
+ }
66
+ function save () {
67
+ try {
68
+ fs.mkdirSync(path.dirname(cfg.stateFile), { recursive: true })
69
+ const tmp = `${cfg.stateFile}.tmp-${process.pid}`
70
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 })
71
+ fs.renameSync(tmp, cfg.stateFile)
72
+ } catch (e) { warn('could not persist progress: ' + e.message) }
73
+ }
74
+
75
+ // --- InfluxDB reads -----------------------------------------------------------
76
+ async function flux (conn, body) {
77
+ const url = `${conn.url.replace(/\/+$/, '')}/api/v2/query?org=${encodeURIComponent(conn.org)}`
78
+ let resp
79
+ try {
80
+ resp = await fetch(url, {
81
+ method: 'POST',
82
+ headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/vnd.flux', Accept: 'application/csv' },
83
+ body,
84
+ signal: AbortSignal.timeout(cfg.queryTimeoutMs)
85
+ })
86
+ } catch (e) { return { ok: false, message: e.message } }
87
+ if (!resp.ok) {
88
+ const t = await resp.text().catch(() => '')
89
+ return { ok: false, status: resp.status, message: `HTTP ${resp.status}: ${t.slice(0, 160)}` }
90
+ }
91
+ return { ok: true, text: await resp.text() }
92
+ }
93
+
94
+ // Total points in a window. Returns a number, or null on ANY transport/HTTP failure —
95
+ // never 0, so an unreachable database can't be mistaken for "no data here" and mark a
96
+ // window falsely done. (The source script is emphatic about this distinction.)
97
+ async function count (conn, bucket, startMs, stopMs) {
98
+ const r = await flux(conn, `from(bucket:"${bucket}")|>range(start:${iso(startMs)},stop:${iso(stopMs)})|>count()|>group()|>sum()|>keep(columns:["_value"])`)
99
+ if (!r.ok) return null
100
+ for (const line of r.text.split('\n')) {
101
+ if (!line || line.startsWith('#')) continue
102
+ const cells = line.trim().split(',')
103
+ const last = cells[cells.length - 1]
104
+ if (/^\d+$/.test(last)) return Number(last)
105
+ }
106
+ return 0
107
+ }
108
+
109
+ async function earliestPoint () {
110
+ // `first()` reduces each series before anything is merged, then _time is isolated
111
+ // BEFORE group(). Both matter on a real bucket: grouping the raw stream fails with
112
+ // "schema collision: cannot group boolean and integer types together" the moment the
113
+ // database holds more than one field type, which any real boat's does.
114
+ const r = await flux(cfg.src, `from(bucket:"${cfg.src.bucket}")|>range(start:0)|>first()|>keep(columns:["_time"])|>group()|>min(column:"_time")`)
115
+ if (!r.ok) return null
116
+ for (const line of r.text.split('\n')) {
117
+ if (!line || line.startsWith('#') || line.includes('_time')) continue
118
+ for (const cell of line.split(',')) {
119
+ // Must look like RFC3339. Date.parse is far too permissive for scanning cells:
120
+ // the CSV's `table` column is "0", and Date.parse('0') happily returns the year
121
+ // 2000 — which would set the floor 26 years too early and walk a quarter of a
122
+ // million empty windows.
123
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(cell.trim())) continue
124
+ const ms = Date.parse(cell.trim())
125
+ if (!Number.isNaN(ms)) return ms
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ // --- one window ---------------------------------------------------------------
132
+ // Returns 'done' | 'empty' | 'retry' | 'stopped'.
133
+ async function doWindow (startMs, stopMs) {
134
+ const srcCount = await count(cfg.src, cfg.src.bucket, startMs, stopMs)
135
+ if (srcCount == null) return 'retry'
136
+ if (srcCount === 0) return 'empty'
137
+
138
+ const filter = cfg.selfOnly ? '|>filter(fn:(r)=>r.self=="true")' : ''
139
+ const r = await flux(cfg.src, `from(bucket:"${cfg.src.bucket}")|>range(start:${iso(startMs)},stop:${iso(stopMs)})${filter}|>drop(columns:["_start","_stop"])`)
140
+ if (!r.ok) { warn(`read ${iso(startMs)} failed — ${r.message}`); return 'retry' }
141
+
142
+ const { lines, skipped } = csvToLineProtocol(r.text, { selfOnly: cfg.selfOnly })
143
+ if (skipped) log(`${iso(startMs)}: skipped ${skipped} unconvertible row(s)`)
144
+ if (!lines.length) return 'empty'
145
+
146
+ for (let i = 0; i < lines.length; i += cfg.batchSize) {
147
+ if (stopped) return 'stopped'
148
+ const body = lines.slice(i, i + cfg.batchSize).join('\n') + '\n'
149
+ const res = await writeLines({ influxUrl: cfg.dst.url, org: cfg.dst.org, bucket: cfg.dst.bucket, token: cfg.dst.token, timeoutMs: cfg.queryTimeoutMs }, body)
150
+ if (!res.ok) {
151
+ // 4xx is fatal: bad credentials or malformed data, and retrying cannot fix it.
152
+ if (!res.retryable) { warn(`write REJECTED (HTTP ${res.status}) — ${res.body ? String(res.body).slice(0, 160) : 'no detail'}`); return 'fatal' }
153
+ warn(`write ${iso(startMs)} failed — ${res.status ? 'HTTP ' + res.status : 'unreachable'}; will retry`)
154
+ return 'retry'
155
+ }
156
+ }
157
+
158
+ // Verify: a 204 says the bytes were accepted, not that every point landed. This is
159
+ // the check the write-only sync token could never perform.
160
+ const dstCount = await count(cfg.dst, cfg.dst.bucket, startMs, stopMs)
161
+ if (dstCount == null) { warn(`could not verify ${iso(startMs)} — leaving it for the next run`); return 'retry' }
162
+ if (dstCount < lines.length) {
163
+ warn(`${iso(startMs)} MISMATCH: wrote ${lines.length}, destination has ${dstCount} — not marking done`)
164
+ return 'retry'
165
+ }
166
+ if (state) state.points += lines.length
167
+ return 'done'
168
+ }
169
+
170
+ // --- the walk -----------------------------------------------------------------
171
+ async function run () {
172
+ running = true
173
+ try {
174
+ if (state.earliest == null) {
175
+ const e = await earliestPoint()
176
+ if (e == null) { warn('could not read the oldest point from the source — is it reachable?'); statusLine = 'backfill: source unreachable'; return }
177
+ state.earliest = e
178
+ log(`oldest point in ${cfg.src.bucket}: ${iso(e)}`)
179
+ save()
180
+ }
181
+ // Floor to the START of the hour that CONTAINS the oldest point. Comparing the
182
+ // cursor against the raw timestamp drops that window entirely — the loop stops as
183
+ // soon as the next cursor falls below it, so an archive whose first point is at
184
+ // 21:20 never gets its 21:00 window copied. That is silent data loss at the very
185
+ // edge the backfill exists to reach.
186
+ const rawFloor = cfg.startBound ? Math.max(state.earliest, Date.parse(cfg.startBound)) : state.earliest
187
+ const floor = hourFloor(rawFloor)
188
+
189
+ let cursor = hourFloor(Date.now()) // newest-first: recent history lands first
190
+ let errStreak = 0
191
+ let didWork = 0
192
+
193
+ while (!stopped && cursor >= floor) {
194
+ const startMs = cursor
195
+ const stopMs = cursor + cfg.windowMs
196
+ cursor -= cfg.windowMs
197
+ const key = iso(startMs)
198
+ if (state.done[key]) continue
199
+
200
+ // Live telemetry is data-critical; this is not. Stand down while it is behind.
201
+ while (!stopped && cfg.pending) {
202
+ let depth = 0
203
+ try { depth = (await cfg.pending()).count || 0 } catch {}
204
+ if (!depth) break
205
+ statusLine = `backfill: paused — live sync backlog (${depth} file(s))`
206
+ await sleep(cfg.backlogWaitMs)
207
+ }
208
+ if (stopped) break
209
+
210
+ const r = await doWindow(startMs, stopMs)
211
+ if (r === 'stopped') break
212
+ if (r === 'fatal') { statusLine = 'backfill: stopped — write rejected, see the log'; return }
213
+ if (r === 'retry') {
214
+ if (++errStreak >= cfg.maxErrorStreak) { warn(`${errStreak} consecutive failures — stopping this run, it resumes on restart`); statusLine = 'backfill: paused after repeated errors'; return }
215
+ continue
216
+ }
217
+ errStreak = 0
218
+ state.done[key] = r === 'empty' ? 'empty' : 'ok'
219
+ didWork++
220
+ save()
221
+ statusLine = `backfill: ${iso(startMs).slice(0, 10)} → now, ${Object.keys(state.done).length} window(s), ${state.points} point(s)`
222
+ await sleep(cfg.idleMs)
223
+ }
224
+
225
+ if (!stopped && cursor < floor) {
226
+ state.complete = true
227
+ save()
228
+ statusLine = `backfill: complete — ${state.points} point(s) from ${iso(floor).slice(0, 10)}`
229
+ log(`complete: ${Object.keys(state.done).length} windows, ${state.points} points`)
230
+ } else if (didWork === 0 && !stopped) {
231
+ statusLine = 'backfill: nothing to do'
232
+ }
233
+ } finally {
234
+ running = false
235
+ }
236
+ }
237
+
238
+ function start () {
239
+ if (running) return runPromise
240
+ stopped = false
241
+ state = load()
242
+ if (state.complete) { statusLine = `backfill: complete — ${state.points} point(s)`; return null }
243
+ if (!cfg.src.token || !cfg.src.bucket || !cfg.src.url) { statusLine = 'backfill: not configured (source)'; return null }
244
+ if (!cfg.dst.token || !cfg.dst.bucket) { statusLine = 'backfill: not configured (cloud token)'; return null }
245
+ log(`${cfg.src.url} ${cfg.src.org}/${cfg.src.bucket} -> ${cfg.dst.url} ${cfg.dst.org}/${cfg.dst.bucket}${cfg.selfOnly ? ' [self only]' : ''}`)
246
+ statusLine = 'backfill: starting'
247
+ runPromise = run().catch((e) => { warn('run failed: ' + e.message); statusLine = 'backfill: error — ' + e.message })
248
+ return runPromise
249
+ }
250
+
251
+ function stop () { stopped = true }
252
+ function status () { return statusLine }
253
+
254
+ return { start, stop, status, _state: () => state, _doWindow: doWindow, _wait: () => runPromise }
255
+ }
256
+
257
+ module.exports = { createBackfill }
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ // InfluxDB annotated-CSV → line protocol, with types preserved.
4
+ //
5
+ // Ported from sailkick-sync/tools/parse-bandg.js, which has already moved this boat's
6
+ // `bandg` bucket into the dev InfluxDB — so the shape is proven on exactly this data
7
+ // rather than invented here.
8
+ //
9
+ // The whole point is the `#datatype` header. lib/history's old parser skipped every `#`
10
+ // line, which is fine for reading a couple of float columns to draw a chart and wrong
11
+ // for copying a database: without it every integer, boolean and string is rewritten as a
12
+ // float, and the copy silently differs from the original. Types come from the annotation
13
+ // when present, and are inferred only as a fallback.
14
+ //
15
+ // Tags are carried through verbatim. Timestamps are converted to nanoseconds, which is
16
+ // what makes a re-run idempotent: same (measurement, tagset, ns) overwrites rather than
17
+ // duplicating, so an interrupted backfill can always simply be run again.
18
+
19
+ // Split one CSV line, honouring quoted fields and "" escapes.
20
+ function parseCsvLine (line) {
21
+ const out = []
22
+ let cur = ''
23
+ let q = false
24
+ for (let i = 0; i < line.length; i++) {
25
+ const c = line[i]
26
+ if (q) {
27
+ if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++ } else q = false } else cur += c
28
+ } else {
29
+ if (c === '"') q = true
30
+ else if (c === ',') { out.push(cur); cur = '' } else cur += c
31
+ }
32
+ }
33
+ out.push(cur)
34
+ return out
35
+ }
36
+
37
+ const escM = (s) => String(s).replace(/([,\s])/g, '\\$1')
38
+ const escT = (s) => String(s).replace(/([,=\s])/g, '\\$1')
39
+ const escS = (s) => '"' + String(s).replace(/(["\\])/g, '\\$1') + '"'
40
+
41
+ function toNs (t) {
42
+ const ms = Date.parse(t)
43
+ return Number.isNaN(ms) ? null : (BigInt(ms) * 1000000n).toString()
44
+ }
45
+
46
+ const NUM = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
47
+
48
+ // Columns that are structural, not tags.
49
+ const NOT_A_TAG = new Set(['', 'result', 'table', '_start', '_stop', '_time', '_value', '_field', '_measurement'])
50
+
51
+ // Render one record. `valueType` is the #datatype entry for the _value column, e.g.
52
+ // 'double' | 'long' | 'unsignedLong' | 'boolean' | 'string'.
53
+ function recordToLine (rec, valueType, { selfOnly = false } = {}) {
54
+ const m = rec._measurement
55
+ if (!m) return null
56
+ const v = rec._value
57
+ if (v === '' || v == null) return null
58
+ if (selfOnly && rec.self !== 'true') return null
59
+
60
+ let fv
61
+ if (valueType === 'double') fv = v
62
+ else if (valueType === 'long') fv = v + 'i'
63
+ else if (valueType === 'unsignedLong') fv = v + 'u'
64
+ else if (valueType === 'boolean') fv = v
65
+ else if (valueType === 'string') fv = escS(v)
66
+ else if (v === 'true' || v === 'false') fv = v // no annotation → infer
67
+ else if (NUM.test(v)) fv = v
68
+ else fv = escS(v)
69
+
70
+ const tags = []
71
+ for (const k of Object.keys(rec)) {
72
+ if (NOT_A_TAG.has(k)) continue
73
+ const val = rec[k]
74
+ if (val !== '' && val != null) tags.push(`${escT(k)}=${escT(val)}`)
75
+ }
76
+ tags.sort() // stable series key regardless of column order
77
+
78
+ const ns = toNs(rec._time)
79
+ if (ns == null) return null
80
+ const field = rec._field || 'value'
81
+ return `${escM(m)}${tags.length ? ',' + tags.join(',') : ''} ${escT(field)}=${fv} ${ns}`
82
+ }
83
+
84
+ // Convert a whole annotated-CSV document. Returns { lines, skipped }.
85
+ // A CSV response can contain several tables, each re-declaring #datatype and a header —
86
+ // a blank line resets both, exactly as the source tool does.
87
+ function csvToLineProtocol (text, opts = {}) {
88
+ const lines = []
89
+ let skipped = 0
90
+ let cols = null
91
+ let dt = null
92
+ let valueIdx = -1
93
+
94
+ for (const raw of String(text).split('\n')) {
95
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
96
+ if (line === '') { cols = null; dt = null; valueIdx = -1; continue }
97
+ if (line.startsWith('#datatype')) { dt = parseCsvLine(line); continue }
98
+ if (line.startsWith('#')) continue // #group / #default carry nothing we need
99
+ const f = parseCsvLine(line)
100
+ if (cols === null) {
101
+ cols = f
102
+ valueIdx = cols.indexOf('_value')
103
+ continue
104
+ }
105
+ const rec = {}
106
+ cols.forEach((c, i) => { rec[c] = f[i] })
107
+ const valueType = (dt && valueIdx >= 0) ? dt[valueIdx] : undefined
108
+ const out = recordToLine(rec, valueType, opts)
109
+ if (out) lines.push(out); else skipped++
110
+ }
111
+ return { lines, skipped }
112
+ }
113
+
114
+ module.exports = { csvToLineProtocol, recordToLine, parseCsvLine }
@@ -1,20 +1,19 @@
1
1
  'use strict'
2
2
 
3
3
  // History module — serves the sailkick app's GET /api/history/series and
4
- // /api/history/track from the boat's LOCAL InfluxDB (a signalk-to-influxdb-v2
5
- // style bucket), so the app's Trends panel + track work offline with the full
6
- // local dataset.
4
+ // /api/history/track from the boat's own LIVE data, so the Trends panel and the track
5
+ // work offline. The JSON envelope is byte-for-byte what the app's
6
+ // server/routes/history.js returns, so the browser can't tell the difference.
7
7
  //
8
- // This is the boat-edge counterpart of the app's server/history/influx-provider.js:
9
- // the Flux queries and channel map are ported VERBATIM (the signalk-to-influxdb-v2 schema —
10
- // measurement=SignalK path, field="value", position→lat/lon — is exactly what that
11
- // provider assumes), and the JSON envelope is byte-for-byte the same the app's
12
- // server/routes/history.js returns, so the browser client can't tell the difference.
13
- // Keep MAP / the queries in sync with the app copy.
8
+ // There is exactly ONE source: the DB-less ring (lib/history/ring.js), sampled from the
9
+ // same BoatState that feeds /ws/telemetry.
14
10
  //
15
- // When no local InfluxDB is configured (e.g. SignalK on a Victron GX / Venus OS)
16
- // but a telemetry source is available, it instead serves a DB-less in-memory ring
17
- // (lib/history/ring.js) same JSON contract, ~1h offline window, no database.
11
+ // Until v0.15.0 a local InfluxDB could be configured here instead, and it won because a
12
+ // read token was present. That was a trap: the app only ever asks for a RELATIVE window
13
+ // clamped to 24 h (sailkick/server/routes/history.js), so pointing this at a bucket of
14
+ // older data returned nothing at all — Trends went blank AND the working live ring was
15
+ // switched off. Local history is now always live; an old InfluxDB's value is getting its
16
+ // contents INTO the cloud, which is what lib/backfill is for.
18
17
 
19
18
  const path = require('path')
20
19
  const { RingHistoryProvider } = require('./ring')
@@ -24,21 +23,6 @@ const RAD2DEG = 180 / Math.PI
24
23
  const wrap360 = (d) => ((d % 360) + 360) % 360
25
24
  const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x }
26
25
 
27
- // SignalK path (Influx measurement) → { chan, conv }. Field key is "value".
28
- // Some channels have a fallback measurement (primary wins when both present).
29
- const MAP = {
30
- 'environment.wind.speedTrue': { chan: 'tws', conv: (v) => v * MS_TO_KT },
31
- 'environment.wind.directionTrue': { chan: 'twd', conv: (v) => wrap360(v * RAD2DEG) },
32
- 'environment.wind.speedApparent': { chan: 'aws', conv: (v) => v * MS_TO_KT },
33
- 'environment.wind.angleApparent': { chan: 'awa', conv: (v) => wrap180(v * RAD2DEG) },
34
- 'navigation.speedOverGround': { chan: 'sog', conv: (v) => v * MS_TO_KT },
35
- 'navigation.speedThroughWater': { chan: 'stw', conv: (v) => v * MS_TO_KT },
36
- 'navigation.headingTrue': { chan: 'heading', conv: (v) => wrap360(v * RAD2DEG) },
37
- 'navigation.headingMagnetic': { chan: 'heading', conv: (v) => wrap360(v * RAD2DEG), fallback: true },
38
- 'environment.depth.belowTransducer': { chan: 'depth', conv: (v) => v },
39
- 'environment.depth.belowSurface': { chan: 'depth', conv: (v) => v, fallback: true }
40
- }
41
- const MEASUREMENTS = Object.keys(MAP)
42
26
 
43
27
  // Parse a duration like "1h" / "30m" / "600s" / "3600" → seconds, clamped.
44
28
  // Ported from the app's server/routes/history.js so limits match exactly.
@@ -50,44 +34,13 @@ function dur (s, def, min, max) {
50
34
  return Math.max(min, Math.min(max, n))
51
35
  }
52
36
 
53
- // Minimal InfluxDB v2 annotated-CSV parser (same approach as the app's
54
- // server/influx/client.js). Values in our long-format queries are simple.
55
- function parseAnnotatedCsv (text) {
56
- const rows = []
57
- let header = null
58
- for (const raw of text.split('\n')) {
59
- const line = raw.replace(/\r$/, '')
60
- if (!line || line.startsWith('#')) { header = null; continue }
61
- const cols = line.split(',')
62
- if (!header) { header = cols; continue }
63
- const o = {}
64
- for (let i = 0; i < header.length; i++) o[header[i]] = cols[i]
65
- rows.push(o)
66
- }
67
- return rows
68
- }
69
37
 
70
38
  function createHistory (app, options) {
71
39
  const log = (m) => (app.debug ? app.debug('[history] ' + m) : console.log('[sailkick-boat:history]', m))
72
- let cfg = null
73
- let provider = null // { getSeries, getTrack, destroy? } — InfluxDB queries or the ring
74
- let mode = null // 'influx' | 'ring' | null
40
+ let provider = null // the ring, or null when there is no telemetry to sample
75
41
 
76
42
  function start () {
77
- cfg = {
78
- url: (options.influxUrl || 'http://127.0.0.1:8086').replace(/\/+$/, ''),
79
- org: options.org || 'signalk',
80
- bucket: options.bucket || 'signalk',
81
- token: options.token || '',
82
- timeoutMs: options.requestTimeoutMs || 15000
83
- }
84
- if (cfg.url && cfg.token && cfg.bucket) {
85
- // Full history from a local InfluxDB.
86
- provider = { getSeries: influxSeries, getTrack: influxTrack }
87
- mode = 'influx'
88
- log(`serving /api/history from ${cfg.url} bucket "${cfg.bucket}"`)
89
- } else if (options.ringSource && options.ringSource.getState) {
90
- // No local InfluxDB → DB-less rolling ring from live telemetry (GX/Venus OS).
43
+ if (options.ringSource && options.ringSource.getState) {
91
44
  // Persist the append-log so it survives restarts (unless ringPersist is off).
92
45
  // Default location: a "history" folder under the tile cache dir (storeDir), so it
93
46
  // sits on the SSD/USB with the tiles; `ringDir` overrides. Resolve storeDir the
@@ -102,19 +55,18 @@ function createHistory (app, options) {
102
55
  sampleSec: options.ringSampleSec,
103
56
  persistFile
104
57
  })
105
- mode = 'ring'
106
58
  log(persistFile
107
- ? `serving /api/history from a persistent DB-less ring (${persistFile})`
108
- : 'serving /api/history from an in-memory DB-less ring')
59
+ ? `serving /api/history from a persistent ring (${persistFile})`
60
+ : 'serving /api/history from an in-memory ring')
109
61
  } else {
110
- provider = null; mode = null
111
- log('no local InfluxDB and no telemetry — /api/history falls through to the mirror')
62
+ provider = null
63
+ log('no telemetry source — /api/history falls through to the cloud mirror')
112
64
  }
113
65
  }
114
66
 
115
67
  function stop () {
116
68
  try { if (provider && provider.destroy) provider.destroy() } catch {}
117
- provider = null; mode = null; cfg = null
69
+ provider = null
118
70
  }
119
71
 
120
72
  // available() gates local serving: when false the proxy lets /api/history fall
@@ -123,79 +75,10 @@ function createHistory (app, options) {
123
75
 
124
76
  function status () {
125
77
  if (!provider) return 'history: off'
126
- return mode === 'ring' ? `history: ring${provider._persistFile ? ' (persistent)' : ''}` : `history: ${cfg.bucket}@local`
127
- }
128
-
129
- async function queryFlux (flux, signal) {
130
- const url = `${cfg.url}/api/v2/query?org=${encodeURIComponent(cfg.org)}`
131
- let resp
132
- try {
133
- resp = await fetch(url, {
134
- method: 'POST',
135
- headers: { Authorization: `Token ${cfg.token}`, 'Content-Type': 'application/vnd.flux', Accept: 'application/csv' },
136
- body: flux,
137
- signal: signal || AbortSignal.timeout(cfg.timeoutMs)
138
- })
139
- } catch (e) {
140
- if (e && e.name === 'AbortError') return { ok: false, status: 0, message: 'InfluxDB query aborted/timed out' }
141
- return { ok: false, status: 502, message: `Network error talking to InfluxDB: ${e.message}` }
142
- }
143
- if (!resp.ok) {
144
- const body = await resp.text().catch(() => '')
145
- return { ok: false, status: resp.status, message: `InfluxDB ${resp.status}: ${body.slice(0, 200)}` }
146
- }
147
- return { ok: true, rows: parseAnnotatedCsv(await resp.text()) }
148
- }
149
-
150
- async function influxSeries ({ windowSec, everySec, signal }) {
151
- const filt = MEASUREMENTS.map((m) => `r._measurement == "${m}"`).join(' or ')
152
- const flux = `from(bucket: "${cfg.bucket}")
153
- |> range(start: -${windowSec}s)
154
- |> filter(fn: (r) => r._field == "value" and (${filt}))
155
- |> aggregateWindow(every: ${everySec}s, fn: mean, createEmpty: false)
156
- |> keep(columns: ["_time", "_value", "_measurement"])`
157
- const r = await queryFlux(flux, signal)
158
- if (!r.ok) return r
159
-
160
- const byChan = {} // chan -> { primary:[[t,v]], fallback:[[t,v]] }
161
- for (const row of r.rows) {
162
- const spec = MAP[row._measurement]
163
- if (!spec) continue
164
- const t = Date.parse(row._time)
165
- const v = Number(row._value)
166
- if (!Number.isFinite(t) || !Number.isFinite(v)) continue
167
- const slot = (byChan[spec.chan] || (byChan[spec.chan] = { primary: [], fallback: [] }))
168
- ;(spec.fallback ? slot.fallback : slot.primary).push([t, spec.conv(v)])
169
- }
170
- const series = {}
171
- for (const [chan, { primary, fallback }] of Object.entries(byChan)) {
172
- const pts = (primary.length ? primary : fallback).sort((a, b) => a[0] - b[0])
173
- if (pts.length) series[chan] = pts
174
- }
175
- return { ok: true, series }
176
- }
177
-
178
- async function influxTrack ({ windowSec, everySec = 30, signal }) {
179
- const flux = `from(bucket: "${cfg.bucket}")
180
- |> range(start: -${windowSec}s)
181
- |> filter(fn: (r) => r._measurement == "navigation.position" and (r._field == "lat" or r._field == "lon"))
182
- |> aggregateWindow(every: ${everySec}s, fn: last, createEmpty: false)
183
- |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
184
- |> keep(columns: ["_time", "lat", "lon"])`
185
- const r = await queryFlux(flux, signal)
186
- if (!r.ok) return r
187
- const track = []
188
- for (const row of r.rows) {
189
- const t = Date.parse(row._time)
190
- const lat = Number(row.lat)
191
- const lon = Number(row.lon)
192
- if (Number.isFinite(t) && Number.isFinite(lat) && Number.isFinite(lon)) track.push({ t, lat, lon })
193
- }
194
- track.sort((a, b) => a.t - b.t)
195
- return { ok: true, track }
78
+ return `history: ring${provider._persistFile ? ' (persistent)' : ''}`
196
79
  }
197
80
 
198
- // Abort the Influx query if the client disconnects mid-request.
81
+ // Abort the query if the client disconnects mid-request.
199
82
  function abortOnClose (res) {
200
83
  const ac = new AbortController()
201
84
  res.on('close', () => { if (!res.writableFinished) ac.abort() })
@@ -241,7 +124,7 @@ function createHistory (app, options) {
241
124
  }
242
125
  }
243
126
 
244
- return { start, stop, status, available, handleSeries, handleTrack, _mode: () => mode, _getSeries: influxSeries, _getTrack: influxTrack }
127
+ return { start, stop, status, available, handleSeries, handleTrack, _provider: () => provider }
245
128
  }
246
129
 
247
130
  module.exports = { createHistory }
package/lib/sync/index.js CHANGED
@@ -175,7 +175,14 @@ function createSync (app, options) {
175
175
 
176
176
  function status () { return state ? state.statusLine : 'sync: off' }
177
177
 
178
- return { start, stop, status }
178
+ // Spool depth, so best-effort work (the backfill) can stand down while live telemetry
179
+ // is behind. Live data is the one thing that must not be starved by a bulk upload.
180
+ async function pending () {
181
+ if (!state || !state.spool) return { count: 0, bytes: 0 }
182
+ try { return await state.spool.stats() } catch { return { count: 0, bytes: 0 } }
183
+ }
184
+
185
+ return { start, stop, status, pending }
179
186
  }
180
187
 
181
188
  module.exports = { createSync }
@@ -85,16 +85,21 @@ function emit (path, value, tags, ns, lines) {
85
85
 
86
86
  // delta -> array of line-protocol strings.
87
87
  // opts.context = fallback self context ("vessels.<urn>") when delta.context absent.
88
+ // opts.self = value of the `self` tag; defaults to true because live telemetry is
89
+ // self-only. AIS targets pass false, and that tag is what the cloud's
90
+ // history queries filter on to keep other vessels out of the boat's own
91
+ // Trends and track — the two halves of that contract must agree.
88
92
  function deltaToLines (delta, opts) {
89
93
  const lines = []
90
94
  if (!delta || !Array.isArray(delta.updates)) return lines
91
95
  const ctx = escapeTag(delta.context || (opts && opts.context) || 'vessels.self')
96
+ const selfTag = (opts && opts.self === false) ? 'false' : 'true'
92
97
 
93
98
  for (const update of delta.updates) {
94
99
  if (!update || !Array.isArray(update.values)) continue
95
100
  const source = escapeTag(update.$source || sourceLabel(update.source) || 'unknown')
96
101
  const ns = toNs(update.timestamp != null ? update.timestamp : Date.now())
97
- const tags = `context=${ctx},self=true,source=${source}`
102
+ const tags = `context=${ctx},self=${selfTag},source=${source}`
98
103
  for (const pv of update.values) {
99
104
  if (!pv || !pv.path) continue // skip vessel-level '' path objects
100
105
  emit(pv.path, pv.value, tags, ns, lines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.14.7",
3
+ "version": "0.17.0",
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": {