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 +70 -41
- package/index.js +104 -40
- package/lib/ais/index.js +262 -0
- package/lib/backfill/index.js +257 -0
- package/lib/backfill/lineproto.js +114 -0
- package/lib/history/index.js +21 -138
- package/lib/sync/index.js +8 -1
- package/lib/sync/lineprotocol.js +6 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
-
|
|
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
|
package/lib/ais/index.js
ADDED
|
@@ -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 }
|
package/lib/history/index.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// (
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
-
: 'serving /api/history from an in-memory
|
|
59
|
+
? `serving /api/history from a persistent ring (${persistFile})`
|
|
60
|
+
: 'serving /api/history from an in-memory ring')
|
|
109
61
|
} else {
|
|
110
|
-
provider = null
|
|
111
|
-
log('no
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 }
|
package/lib/sync/lineprotocol.js
CHANGED
|
@@ -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
|
|
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.
|
|
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": {
|