sailkick-boat 0.14.7 → 0.17.2

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,92 @@ 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.
205
+
206
+ **Only this boat's data is copied**, and that is not configurable. The cloud's history
207
+ queries assume your bucket holds one vessel, so uploading an archive's AIS would put
208
+ other ships into your own SOG and heading charts. If the source holds several contexts
209
+ the plugin copies yours and logs which it skipped. If it holds exactly **one** context it
210
+ is copied whatever identity string it uses — a bucket with one vessel cannot be an AIS
211
+ collection, and this is what lets an archive recorded under an older Signal K UUID (or an
212
+ MMSI URN) still migrate. Hand-edit `backfill.context` to force a specific one.
213
+
214
+ A run that copies **zero** points is reported as a problem, not as success: that almost
215
+ always means the org or bucket is wrong rather than that the archive is empty.
216
+
217
+ **It starts below what live sync already covers.** The destination's own oldest point is
218
+ the moment cloud sync began, so the walk begins there rather than at *now*. Without that,
219
+ a source archive that is still being written — a `signalk-to-influxdb-v2` bucket still
220
+ recording — makes the first windows re-upload today's data. The timestamps are correct,
221
+ but it is data the cloud already has, and a lot of wasted uplink.
222
+
223
+ **Dense archives are subdivided.** A window is read whole and converted in memory, and a
224
+ busy boat can produce millions of points an hour (54M/day was measured on a real boat —
225
+ about 400 MB of CSV per hour). When a window holds more than `maxRowsPerChunk` points it
226
+ is halved until it fits, down to a one-minute floor. The count is already known before
227
+ the read, so this costs nothing extra.
173
228
 
174
229
  **True wind comes from your instruments.** If the boat publishes
175
230
  `environment.wind.speedTrue` / `directionTrue`, those are stored verbatim — a wind
@@ -190,14 +245,14 @@ water. (Before v0.14.6 the derivation used SOG, which in 3 kt of foul tide skewe
190
245
 
191
246
  The sailkick app is deployment-agnostic about history: *central Influx in the
192
247
  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):
248
+ serves the app's history endpoints from its **own** live data:
194
249
  ```
195
250
  GET /api/history/series?window=3600s&every=30s -> { series: { sog|heading|tws|… : [[tMs,val],…] } }
196
251
  GET /api/history/track?window=3600s -> { track: [{ t, lat, lon }, …] }
197
252
  ```
198
253
  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
254
+ works **offline** with the boat's own data. Only when no telemetry source is
255
+ available at all do these paths **fall through to the cloud mirror**, so an
201
256
  online boat is never worse off than before.
202
257
 
203
258
  ## Setup: register on the web, then paste the token
@@ -237,21 +292,16 @@ in `index.js`.
237
292
 
238
293
  - **Sailkick account**: `slug` (boat name), `writeToken`
239
294
  - **Telemetry sync → cloud**: `enabled`
295
+ - **Upload AIS targets**: `enabled` (default off), `source`
240
296
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
241
297
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
242
298
  `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
299
 
247
300
  `dataDir` is the one storage location — cached maps, the telemetry spool and the
248
301
  history ring log all live under it. **Put it on the SSD/USB disk, not the SD card.**
249
302
  Leave it blank and each part falls back to its historical spot under the plugin data
250
303
  dir.
251
304
 
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
305
  Cache-manifest polling is always on (no toggle): tile freshness comes from the cloud
256
306
  announcing bakes, and without it a re-baked dataset would never refresh.
257
307
 
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,71 @@ 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
+ // Never a config option: the plugin must not upload data that is not this
377
+ // boat's, and the cloud's history queries depend on that holding.
378
+ selfContext: app.selfContext || ('vessels.' + (app.selfId || 'self')),
379
+ context: String(bf.context || '').trim() || null, // hand-edit escape hatch
380
+ startBound: bf.startBound,
381
+ stateFile: path.join((app.getDataDirPath && app.getDataDirPath()) || '.', 'backfill.json'),
382
+ pending: sync ? sync.pending : null
383
+ })
384
+ backfill.start()
385
+ } catch (e) {
386
+ (app.error || console.error)('[sailkick-boat] backfill start failed: ' + e.message)
387
+ backfill = null
388
+ }
389
+ }
390
+ }
391
+
331
392
  statusTimer = setInterval(updateStatus, 5000)
332
393
  updateStatus()
333
394
  }
@@ -341,6 +402,8 @@ module.exports = function (app) {
341
402
  if (proxy) parts.push(proxy.status())
342
403
  if (telemetry) parts.push(telemetry.status())
343
404
  if (history) parts.push(history.status())
405
+ if (ais) parts.push(ais.status())
406
+ if (backfill) parts.push(backfill.status())
344
407
  try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
345
408
  }
346
409
 
@@ -350,10 +413,14 @@ module.exports = function (app) {
350
413
  try { if (sync) sync.stop() } catch {}
351
414
  try { if (telemetry) telemetry.stop() } catch {}
352
415
  try { if (history) history.stop() } catch {}
416
+ try { if (ais) ais.stop() } catch {}
417
+ try { if (backfill) backfill.stop() } catch {}
353
418
  try { if (proxy) proxy.stop() } catch {}
354
419
  sync = null
355
420
  telemetry = null
356
421
  history = null
422
+ backfill = null
423
+ ais = null
357
424
  proxy = null
358
425
  accountStatus = null
359
426
  syncWarning = null