sailkick-boat 0.13.4 → 0.14.3

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
@@ -1,18 +1,20 @@
1
1
  # sailkick-boat
2
2
 
3
- > ## ⚠️ Project status: early alpha — no public service yet
3
+ > ## ⚠️ Project status: early alpha — invite only
4
4
  >
5
- > This plugin is the **boat-side companion of the sailkick platform** (a self-hosted
6
- > 3D sailing app + a central telemetry sync server). **That platform is not publicly
7
- > available yet** there is no public cloud endpoint to point this plugin at, and it
8
- > is currently developed and tested only against sailkick's own development
9
- > environment (a private LAN).
5
+ > This plugin is the **boat-side companion of the sailkick platform**: **cloud
6
+ > telemetry** (gapless boat→shore sync of your vessel's data into a central InfluxDB)
7
+ > and a **local proxy that keeps the sailkick app and its charts/maps fully usable
8
+ > offline** on board. The service runs at **[www.sailkick.io](https://www.sailkick.io)**.
10
9
  >
11
- > It is published this early for development visibility and for adventurous
12
- > **self-hosters**: to use it today you must run your own sailkick app server and
13
- > central InfluxDB v2. Expect breaking changes while the version is 0.x. If you found
14
- > this in the Signal K Appstore and don't run a sailkick server, it will not do
15
- > anything useful yet watch the repo for the public launch.
10
+ > **Registration is invite-only** and happens on the website, not in the plugin — so
11
+ > installing this from the Signal K Appstore is not enough on its own. Expect breaking
12
+ > changes while the version is 0.x.
13
+ >
14
+ > **Want an invite, or more information?** Get in touch:
15
+ > **[info@sailkick.io](mailto:info@sailkick.io)**. Early testers are welcome.
16
+ > Self-hosters can point the plugin at their own sailkick server and InfluxDB v2
17
+ > instead — see [Config](#config).
16
18
 
17
19
  One Signal K plugin, independently-toggleable modules — so the boat stays
18
20
  "just SignalK + plugins":
@@ -79,7 +81,7 @@ POST /plugins/sailkick-boat/cache/clear # default kee
79
81
  POST /plugins/sailkick-boat/cache/clear?keep=tiles,terrain,history # refresh app shell; keep tiles + ring log
80
82
  POST /plugins/sailkick-boat/cache/clear?prefix=tiles/seamap # nuke one tileset
81
83
  ```
82
- (The persistent history ring log lives under `<storeDir>/history`, so keep `history`
84
+ (The persistent history ring log lives under `<dataDir>/history`, so keep `history`
83
85
  when clearing — the default keep already does. A hand-typed `find` clear should add
84
86
  `! -name history` alongside `! -name tiles ! -name terrain`.)
85
87
 
@@ -94,13 +96,14 @@ passage area on demand.
94
96
  `coastlineMaxZoom 8` (~12k tiles) + `seabedMaxZoom 6` (~5.5k). Idempotent (re-runs
95
97
  hit cache only) and self-throttling — it reuses the circuit breaker, so it goes quiet
96
98
  offline and resumes when back online. Progress shows in the plugin status line.
97
- Config: `proxy.seed.{enabled,coastlineMaxZoom,seabedMaxZoom,concurrency}`.
99
+ Config: **Download a worldwide base map on start** (`seedEnabled`); zoom levels and
100
+ concurrency are constants (hand-editable as `proxy.seed.*`).
98
101
  - **Download around the boat** (settings dropdowns, no token) — the easiest way to
99
102
  cache a passage area: in Plugin Config pick a **Radius around boat** (25/50/100/200 nm)
100
103
  and a **Detail level** (Overview z12 … Harbor z15), then save. The plugin reads the
101
104
  boat's current position from local SignalK, builds a box, and warms the chart layers
102
105
  in the background (progress in the status line). Idempotent; re-saving tops up. An
103
- oversized radius+detail is refused (reduce one). Config `proxy.prefetch.{radiusNm,detailZoom,concurrency}`.
106
+ oversized radius+detail is refused (reduce one). Config `prefetchRadiusNm` / `prefetchDetailZoom`.
104
107
  - **Region prefetch (API)** — for scripted/arbitrary boxes, warm the detailed chart layers for an area:
105
108
  ```
106
109
  POST /plugins/sailkick-boat/prefetch/region
@@ -123,8 +126,8 @@ cookie on HTTP, so login just loops. Since the boat is single-tenant and its dat
123
126
  endpoints aren't server-gated, the proxy serves `/api/config` with `auth.required`
124
127
  forced to `false` (and `historyAvailable` forced on when history is served locally),
125
128
  so the boat's own app opens with no password, fully offline. Everything else in the
126
- config passes through untouched. Toggle off with `proxy.openAccess: false` to keep
127
- the cloud login gate.
129
+ config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep the cloud
130
+ login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
128
131
 
129
132
  ## Local history (offline Trends + track)
130
133
  Two ways, chosen automatically:
@@ -135,7 +138,7 @@ Two ways, chosen automatically:
135
138
  **SignalK on a Victron GX / Venus OS**. Same JSON contract, fully offline, no database.
136
139
  (`historyAvailable` reports true either way, so the app shows the Trends panel.)
137
140
  - **Persistent (append-log):** the ring is saved as a JSONL append-log at
138
- `<storeDir>/history/history-ring.jsonl` (on the SSD/USB with the tiles; override with
141
+ `<dataDir>/history/history-ring.jsonl` (on the SSD/USB with the tiles; override with
139
142
  `proxy.history.ringDir`), so it **survives restarts**. Each sample appends one line; the file is
140
143
  compacted (atomic rewrite to the current window) only rarely, so a long passage
141
144
  writes < ~1 GB (vs the ~600 GB a full-rewrite snapshot would). Config
@@ -156,26 +159,57 @@ works **offline** with the boat's own data. Only when neither a local InfluxDB n
156
159
  telemetry is available do these paths **fall through to the cloud mirror**, so an
157
160
  online boat is never worse off than before.
158
161
 
159
- ## Easiest setup: log in with your sailkick account
160
- Instead of pasting InfluxDB URL / org / bucket / write-token, fill the **Sailkick
161
- account** section**host URL + slug + password** and the plugin fetches the rest of
162
- its cloud config on start (`POST <host>/api/boat/config`): the scoped write token,
163
- bucket, org, and InfluxDB URL for your boat. The bundle is cached (0600) in the plugin
164
- data dir, so sync keeps working **offline** after the first connect. Leave the account
165
- section blank to configure `sync`/`proxy` manually (advanced / self-hosted). Account
166
- values take precedence over the manual sync fields; the mirror upstream (`sailkickUrl`)
167
- is set from the account host.
168
-
169
- ## Config (each section toggleable)
170
- - **Telemetry sync → InfluxDB**: `enabled`, `influxUrl`, `org`, `bucket`, `token`, `spoolDir`, …
171
- - **Sailkick caching proxy**: `enabled`, `sailkickUrl` (the one upstream), `storeDir`,
172
- - `serveTelemetry` (default on) provide `/ws/telemetry` from local SignalK.
173
- - **Cache manifest** (default on): `path` (default `/api/cache-manifest`),
174
- `pollIntervalSec` (default 300) auto-refresh datasets when the cloud
175
- announces a new bake.
176
- - **Local history** (default on): `influxUrl` (default `http://127.0.0.1:8086`),
177
- `org`, `bucket`, `token` (a read token — set to serve full history from a local
178
- InfluxDB; blank = the DB-less telemetry ring), plus the ring settings above.
162
+ ## Setup: register on the web, then paste the token
163
+ 1. Register your boat at **[www.sailkick.io](https://www.sailkick.io)** (an invite code
164
+ is neededsee above). The signup screen shows your boat's ingest credentials.
165
+ 2. **Copy the "Write token"** it is shown **once** and cannot be recovered. If you
166
+ lose it you need a new one minted.
167
+ 3. In Plugin Config, fill the **Sailkick account** section **boat name + write
168
+ token** and save.
169
+
170
+ That is the whole handshake. The plugin resolves everything else locally (`bucket` =
171
+ `<slug>_raw`, `org` = `sailkick`) and never calls the app for configuration, so setup
172
+ works with no internet and there is nothing to re-fetch after a restart.
173
+
174
+ > **Ignore the "Influx URL", "Organization" and "Bucket" on that screen** — they are for
175
+ > the community `signalk-to-influxdb-v2` plugin. This plugin always writes to
176
+ > `https://sync.sailkick.io`; the endpoint is fleet-wide and cannot be set from the UI,
177
+ > so a wrong value can never redirect your telemetry. (Self-hosters: hand-edit
178
+ > `sync.influxUrl` in the plugin config JSON.) If the status line ever warns that sync is
179
+ > writing to a local address, telemetry is not leaving the boat.
180
+
181
+ Registration deliberately happens **only** in the web app. The plugin cannot sign up,
182
+ so there is no way to half-create an account or burn an invite from the boat.
183
+
184
+ > Changing the token later — after a rotation, say — is just editing the field and
185
+ > saving. Pasted values are trimmed, and the boat name is lower-cased for you.
186
+
187
+ ## Config
188
+ The page is deliberately small — everything else has a right answer and is a constant
189
+ in `index.js`.
190
+
191
+ - **Sailkick account**: `slug` (boat name), `writeToken`
192
+ - **Telemetry sync → cloud**: `enabled`
193
+ - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
194
+ (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
195
+ `prefetchDetailZoom`, `historyToken`
196
+
197
+ `dataDir` is the one storage location — cached maps, the telemetry spool and the
198
+ history ring log all live under it. **Put it on the SSD/USB disk, not the SD card.**
199
+ Leave it blank and each part falls back to its historical spot under the plugin data
200
+ dir.
201
+
202
+ `historyToken` is optional and only matters if the boat already runs its own InfluxDB.
203
+ Blank — the normal case — uses the built-in DB-less ring.
204
+
205
+ Cache-manifest polling is always on (no toggle): tile freshness comes from the cloud
206
+ announcing bakes, and without it a re-baked dataset would never refresh.
207
+
208
+ **Self-hosting / advanced.** Every module still reads its option before falling back to
209
+ the constant, so anything removed from the page can be set by hand in
210
+ `~/.signalk/plugin-config-data/sailkick-boat.json` — `sync.influxUrl`, `sync.token`,
211
+ `proxy.sailkickUrl`, `proxy.seed.coastlineMaxZoom`, the ring window, the retry backoff,
212
+ and so on. Values saved by earlier versions keep working after an upgrade.
179
213
 
180
214
  Point your chart app / browser at:
181
215
  `http://<boat>:3000/plugins/sailkick-boat/p/`
@@ -185,7 +219,17 @@ Point your chart app / browser at:
185
219
  cd ~/.signalk && npm install sailkick-boat # or a packed tarball
186
220
  ```
187
221
  Enable + configure under **Server → Plugin Config → "Sailkick boat companion"**.
188
- Put `spoolDir`/`storeDir` on the SSD (or leave blank for the plugin data dir).
222
+ Set **Data directory** to a path on the SSD (or leave blank for the plugin data dir).
223
+
224
+ ### Victron GX / Venus OS (Cerbo, Ekrano)
225
+ Works on Venus OS Large (Signal K enabled). Point **Data directory** at USB/SD storage
226
+ (internal flash is small). Leave the history token
227
+ blank — the DB-less telemetry ring is used automatically. **Note:** a GX only sees
228
+ Victron data (batteries, solar, tanks) by default; **position/wind/speed/depth
229
+ require the boat's NMEA2000 backbone on the VE.Can port** (250 kbit/s N2K profile +
230
+ a VE.Can↔Micro-C drop cable; leave the spare RJ45 unterminated) or a USB GPS/N2K
231
+ gateway. Without a position source, the live boat / trends / area-download stay
232
+ idle — energy telemetry still syncs to the cloud.
189
233
 
190
234
  ## Dev / tests
191
235
  ```bash
@@ -193,5 +237,11 @@ npm install && npm test # proxy: mirror/cache/offline + Express route; sync: s
193
237
  ```
194
238
  Supersedes the separate `signalk-to-influxdb-gapless` + `signalk-tile-cache` plugins.
195
239
 
240
+ ## Contact
241
+ Questions, feedback, or want to test the app on your own boat?
242
+ Email **[info@sailkick.io](mailto:info@sailkick.io)** — happy to help with setup, and
243
+ early testers are very welcome. Bug reports and feature requests are also fine as
244
+ [GitHub issues](https://github.com/lauchat/sailkick-boat/issues).
245
+
196
246
  ## License
197
247
  MIT
package/index.js CHANGED
@@ -1,16 +1,61 @@
1
1
  'use strict'
2
2
 
3
+ const path = require('path')
4
+
3
5
  const { createSync } = require('./lib/sync')
4
6
  const { createProxy } = require('./lib/proxy')
5
7
  const { createTelemetry } = require('./lib/telemetry')
6
8
  const { createHistory } = require('./lib/history')
7
- const { resolveAccountConfig, accountConfigured } = require('./lib/account')
9
+ const { resolveAccountConfig } = require('./lib/account')
8
10
 
9
11
  // sailkick-boat: one Signal K plugin, two independently-toggleable modules —
10
12
  // sync : gapless store-and-forward of self telemetry to InfluxDB (data OUT)
11
13
  // proxy: offline-first caching mirror of the sailkick host (data IN)
12
14
  // Kept as separate modules so a proxy fault can't wedge the data-critical sync.
13
15
 
16
+ // Fixed cloud endpoints. Boats never choose these, so they are constants rather than
17
+ // config fields — but every module still reads its option first (see startModules), so
18
+ // a hand-edited plugin-config JSON can override any of them for self-hosting.
19
+ const SAILKICK_APP_URL = 'https://www.sailkick.io' // signup + mirror upstream
20
+ const SAILKICK_INFLUX_URL = 'https://sync.sailkick.io' // data egress; pairing overrides this
21
+
22
+ // Tuning that has a right answer. Deliberately NOT on the config page: a boat owner has
23
+ // no basis to choose these, and a wrong value silently degrades sync or the cache.
24
+ const SYNC_TUNING = {
25
+ org: 'sailkick',
26
+ batchSize: 1000,
27
+ flushIntervalMs: 1000,
28
+ maxBufferBytes: 500 * 1024 * 1024,
29
+ subscribePeriodMs: 1000,
30
+ requestTimeoutMs: 30000,
31
+ retryMinMs: 1000,
32
+ retryMaxMs: 60000
33
+ }
34
+ const PROXY_TUNING = { requestTimeoutMs: 20000, localPaths: ['/signalk'], telemetryPath: '/ws/telemetry' }
35
+ const MANIFEST = { enabled: true, path: '/api/cache-manifest', pollIntervalSec: 300 }
36
+ const SEED_TUNING = { coastlineMaxZoom: 8, seabedMaxZoom: 6, concurrency: 4 }
37
+ const PREFETCH_TUNING = { concurrency: 4 }
38
+ const HISTORY_TUNING = {
39
+ influxUrl: 'http://127.0.0.1:8086',
40
+ org: 'signalk',
41
+ bucket: 'signalk',
42
+ requestTimeoutMs: 15000,
43
+ ringPersist: true,
44
+ ringWindowSec: 86400,
45
+ ringSampleSec: 15
46
+ }
47
+
48
+ // A *cloud* endpoint on loopback means telemetry never leaves the boat. On the wire it
49
+ // is indistinguishable from a normal offline backlog — the spool just grows — so it has
50
+ // to be called out explicitly. (The local history DB is legitimately on 127.0.0.1; this
51
+ // check is only ever applied to the sync path.)
52
+ function isLoopbackUrl (u) {
53
+ try {
54
+ const h = new URL(u).hostname.replace(/^\[|\]$/g, '')
55
+ return h === 'localhost' || h === '::1' || /^127\./.test(h)
56
+ } catch { return false }
57
+ }
58
+
14
59
  module.exports = function (app) {
15
60
  let sync = null
16
61
  let proxy = null
@@ -18,13 +63,14 @@ module.exports = function (app) {
18
63
  let history = null
19
64
  let statusTimer = null
20
65
  let accountStatus = null
66
+ let syncWarning = null
21
67
 
22
68
  const plugin = {
23
69
  id: 'sailkick-boat',
24
70
  name: 'Sailkick boat companion',
25
71
  description:
26
- 'Boat-side sailkick: gapless telemetry sync to InfluxDB + an offline-first ' +
27
- 'caching mirror of the sailkick host. Each feature can be toggled on/off.'
72
+ 'Boat-side sailkick: gapless telemetry sync to the cloud + an offline-first ' +
73
+ 'caching mirror of the sailkick app. Each feature can be toggled on/off.'
28
74
  }
29
75
 
30
76
  plugin.schema = {
@@ -32,106 +78,47 @@ module.exports = function (app) {
32
78
  properties: {
33
79
  account: {
34
80
  type: 'object',
35
- title: 'Sailkick account (auto-configure)',
36
- description: 'Log in with your sailkick account and the plugin fetches its cloud config (write token, bucket, org, InfluxDB URL) automatically no manual token entry. Leave blank to configure sync/mirror manually below. The last-good config is cached, so sync keeps working offline.',
81
+ title: 'Sailkick account',
82
+ description: 'Register your boat at www.sailkick.io first the signup screen shows a write token. Paste it here together with your boat name. Keep that token safe: it is shown only once and cannot be recovered.',
37
83
  properties: {
38
- sailkickUrl: { type: 'string', title: 'Sailkick host URL', description: 'e.g. https://app.sailkick.com used for login and as the mirror upstream.' },
39
- slug: { type: 'string', title: 'Boat slug (username)' },
40
- password: { type: 'string', title: 'Password' }
84
+ slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
85
+ writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). Everything else — Influx URL, organization, bucket — is derived from it and your boat name.' }
41
86
  }
42
87
  },
43
88
  sync: {
44
89
  type: 'object',
45
- title: 'Telemetry sync → InfluxDB',
90
+ title: 'Telemetry sync → cloud',
91
+ description: 'Send this vessel\'s data to your sailkick account. Buffered on disk, so an offline passage or a restart loses nothing. Requires pairing above.',
46
92
  properties: {
47
- enabled: { type: 'boolean', title: 'Enable telemetry sync', default: true },
48
- influxUrl: { type: 'string', title: 'InfluxDB URL' },
49
- org: { type: 'string', title: 'Organization', default: 'sailkick' },
50
- bucket: { type: 'string', title: 'Bucket (your <slug>_raw)' },
51
- token: { type: 'string', title: 'Write token (scoped to your bucket)' },
52
- spoolDir: { type: 'string', title: 'Buffer directory', description: 'Default: plugin data dir. Put on the SSD.' },
53
- batchSize: { type: 'number', title: 'Batch size (points/file)', default: 1000 },
54
- flushIntervalMs: { type: 'number', title: 'Flush interval (ms)', default: 1000 },
55
- maxBufferBytes: { type: 'number', title: 'Max on-disk buffer (bytes)', default: 524288000 },
56
- subscribePeriodMs: { type: 'number', title: 'Min interval per path (ms)', default: 1000 },
57
- requestTimeoutMs: { type: 'number', title: 'Upload timeout (ms)', default: 30000 },
58
- retryMinMs: { type: 'number', title: 'Retry backoff min (ms)', default: 1000 },
59
- retryMaxMs: { type: 'number', title: 'Retry backoff max (ms)', default: 60000 }
93
+ enabled: { type: 'boolean', title: 'Enable telemetry sync', default: true }
60
94
  }
61
95
  },
62
96
  proxy: {
63
97
  type: 'object',
64
- title: 'Sailkick caching proxy',
98
+ title: 'Offline app & maps',
99
+ description: 'Serve the sailkick app on board — charts, trends and live data keep working with no internet.',
65
100
  properties: {
66
- enabled: { type: 'boolean', title: 'Enable caching proxy', default: true },
67
- serveTelemetry: { type: 'boolean', title: 'Serve /ws/telemetry from local SignalK', description: 'Provide the app\'s telemetry bus from the boat\'s SignalK, so the app uses the same contract as the cloud server.', default: true },
68
- openAccess: { type: 'boolean', title: 'No login on the boat', description: 'Serve /api/config with the cloud login disabled, so the boat\'s own app opens without a password (single-tenant, offline-first). Turn off to keep the cloud login gate.', default: true },
69
- sailkickUrl: { type: 'string', title: 'Sailkick host URL', description: 'The one upstream this boat mirrors (usually set automatically from the account section). e.g. https://app.sailkick.com' },
70
- proxyPort: { type: 'number', title: 'Mirror server port', description: 'Standalone HTTP server serving the mirror at origin root (no SignalK auth). With host networking it is directly on the Pi. 0 = disable.', default: 8080 },
71
- localSignalkUrl: { type: 'string', title: 'Local SignalK URL (live telemetry)', description: 'Live data + WebSocket stream are proxied here (not cached, not mirrored).', default: 'http://127.0.0.1:3000' },
72
- localPaths: { type: 'array', title: 'Paths served by LOCAL SignalK', description: 'Prefixes routed to local SignalK instead of the mirror.', items: { type: 'string' }, default: ['/signalk'] },
73
- storeDir: { type: 'string', title: 'Cache directory', description: 'Default: plugin data dir. Put on the SSD.' },
74
- requestTimeoutMs: { type: 'number', title: 'Fetch timeout (ms)', default: 20000 },
75
- manifest: {
76
- type: 'object',
77
- title: 'Auto-refresh on new bakes (cache manifest)',
78
- description: 'Poll the cloud\'s bake manifest so tiles/app refresh lazily when a dataset is re-baked. Tiles are otherwise pinned (never expire by time).',
79
- properties: {
80
- enabled: { type: 'boolean', title: 'Poll the cache manifest', default: true },
81
- path: { type: 'string', title: 'Manifest path on the sailkick host', default: '/api/cache-manifest' },
82
- pollIntervalSec: { type: 'number', title: 'Poll interval (s)', default: 300 }
83
- }
101
+ enabled: { type: 'boolean', title: 'Enable the offline mirror', default: true },
102
+ proxyPort: { type: 'number', title: 'Mirror port', description: 'Open the app at http://<boat>:<port>/. 0 = disable the standalone server.', default: 8080 },
103
+ localSignalkUrl: { type: 'string', title: 'Local SignalK URL', description: 'Where this plugin reaches SignalK for live data. Change only if SignalK is not on the default port.', default: 'http://127.0.0.1:3000' },
104
+ dataDir: { type: 'string', title: 'Data directory', description: 'Where cached maps, the telemetry buffer and local history live. Put this on the SSD/USB disk not the SD card. Blank = the plugin data dir.' },
105
+ seedEnabled: { type: 'boolean', title: 'Download a worldwide base map on start', description: 'Caches a low-zoom coastline + seabed base (~17k tiles) so a usable map exists offline everywhere. Runs once, resumes if interrupted.', default: true },
106
+ prefetchRadiusNm: {
107
+ type: 'number',
108
+ title: 'Download detailed charts around the boat',
109
+ description: 'On save, caches the full chart detail for this radius around the boat\'s current position. Progress shows in the status line.',
110
+ enum: [0, 25, 50, 100, 200],
111
+ enumNames: ['Off', '25 nm', '50 nm', '100 nm', '200 nm'],
112
+ default: 0
84
113
  },
85
- history: {
86
- type: 'object',
87
- title: 'Local history (app Trends panel + track)',
88
- description: 'Serve the app\'s /api/history endpoints locally so trends + track work offline. With a read token → full history from a local InfluxDB. Without one → a DB-less ~1h ring sampled from live telemetry (e.g. SignalK on a Victron GX with no InfluxDB).',
89
- properties: {
90
- enabled: { type: 'boolean', title: 'Serve /api/history locally', default: true },
91
- influxUrl: { type: 'string', title: 'Local InfluxDB URL', default: 'http://127.0.0.1:8086' },
92
- org: { type: 'string', title: 'Organization', default: 'signalk' },
93
- bucket: { type: 'string', title: 'Bucket', default: 'signalk' },
94
- token: { type: 'string', title: 'Read token (scoped to the bucket)', description: 'Set to serve full history from a local InfluxDB. Leave blank to use the DB-less live-telemetry ring (Victron GX / no InfluxDB).' },
95
- requestTimeoutMs: { type: 'number', title: 'Query timeout (ms)', default: 15000 },
96
- ringPersist: { type: 'boolean', title: 'Persist the DB-less ring across restarts', description: 'Append-log in the plugin data dir. Off = in-memory only (lost on restart). Ignored when a token is set (InfluxDB is used).', default: true },
97
- ringWindowSec: { type: 'number', title: 'DB-less ring window (s)', description: 'How much history the ring keeps: 86400 = 24h (default), up to 2592000 = 30d for long passages. Resolution auto-coarsens for large windows. NB: the app currently caps history requests at 24h.', default: 86400 },
98
- ringSampleSec: { type: 'number', title: 'DB-less ring sample interval (s)', description: 'Auto-raised for large windows so the ring stays bounded (~50k samples).', default: 15 },
99
- ringDir: { type: 'string', title: 'Ring log directory (override)', description: 'Where the persistent ring log lives. Default: a "history" folder under the cache directory (storeDir), so it sits on the SSD/USB with the tiles. Set to override.' }
100
- }
114
+ prefetchDetailZoom: {
115
+ type: 'number',
116
+ title: '…at this detail level',
117
+ enum: [12, 13, 14, 15],
118
+ enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
119
+ default: 13
101
120
  },
102
- seed: {
103
- type: 'object',
104
- title: 'Offline base seed (global coastline + seabed)',
105
- description: 'On start, cache a worldwide low-zoom base so a usable map exists offline everywhere. Idempotent; self-throttles when offline. Enable the "Coastline"/depth layers in the app to see it.',
106
- properties: {
107
- enabled: { type: 'boolean', title: 'Seed the global base on start', default: true },
108
- coastlineMaxZoom: { type: 'number', title: 'Coastline max zoom', description: 'Sparse vector coastline z0..N (z8 ≈ 12k tiles).', default: 8 },
109
- seabedMaxZoom: { type: 'number', title: 'Seabed (bathy) max zoom', description: 'Dense depth raster z0..N (z6 ≈ 5.5k tiles).', default: 6 },
110
- concurrency: { type: 'number', title: 'Parallel fetches', default: 4 }
111
- }
112
- },
113
- prefetch: {
114
- type: 'object',
115
- title: 'Offline area download (around the boat)',
116
- description: 'On save, cache the detailed chart layers for a radius around the boat\'s current position (from local SignalK), so a passage area is fully offline. Progress shows in the status line above.',
117
- properties: {
118
- radiusNm: {
119
- type: 'number',
120
- title: 'Radius around boat',
121
- enum: [0, 25, 50, 100, 200],
122
- enumNames: ['Off', '25 nm', '50 nm', '100 nm', '200 nm'],
123
- default: 0
124
- },
125
- detailZoom: {
126
- type: 'number',
127
- title: 'Detail level',
128
- enum: [12, 13, 14, 15],
129
- enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
130
- default: 13
131
- },
132
- concurrency: { type: 'number', title: 'Parallel fetches', default: 4 }
133
- }
134
- }
121
+ historyToken: { type: 'string', title: 'Local InfluxDB read token (optional)', description: 'Only if this boat already runs its own InfluxDB (e.g. signalk-to-influxdb-v2) and you want full history from it. Leave blank — the plugin then keeps its own lightweight history, which is what most boats want.' }
135
122
  }
136
123
  }
137
124
  }
@@ -139,59 +126,135 @@ module.exports = function (app) {
139
126
 
140
127
  plugin.start = function (options) {
141
128
  const opts = options || {}
142
- // If a sailkick account is configured, fetch the cloud config first (write token,
143
- // bucket, org, InfluxDB URL) and merge it in then start the modules. Otherwise
144
- // start immediately with the manually-entered config (unchanged behaviour).
145
- ;(async () => {
146
- if (accountConfigured(opts.account)) {
147
- const r = await resolveAccountConfig(app, opts.account)
148
- if (r.bundle) {
149
- opts.sync = { ...(opts.sync || {}), influxUrl: r.bundle.influxUrl, org: r.bundle.org, bucket: r.bundle.bucket, token: r.bundle.writeToken }
150
- opts.proxy = { ...(opts.proxy || {}), sailkickUrl: opts.account.sailkickUrl }
151
- accountStatus = `account: ${opts.account.slug} (${r.source})`
152
- } else {
153
- accountStatus = `account: ${opts.account.slug} — no config (${r.error || 'not reachable, no cache'})`
154
- }
155
- }
156
- startModules(opts)
157
- })().catch((e) => (app.error || console.error)('[sailkick-boat] start failed: ' + e.message))
129
+ try {
130
+ // Purely local: the boat registers on the website, the owner pastes the write
131
+ // token here. Nothing to fetch, so nothing that can fail while offline.
132
+ const r = resolveAccountConfig(app, opts.account)
133
+ if (r.bundle) accountStatus = `account: ${r.bundle.slug}`
134
+ else if (r.error) accountStatus = `account: ${r.error}`
135
+ else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
136
+ startModules(opts, r.bundle)
137
+ } catch (e) {
138
+ (app.error || console.error)('[sailkick-boat] start failed: ' + e.message)
139
+ }
158
140
  }
159
141
 
160
- function startModules (opts) {
142
+ function startModules (opts, bundle) {
143
+ const s = opts.sync || {}
144
+ const p = opts.proxy || {}
145
+ const b = bundle || {}
146
+ // One "Data directory" now drives cache + spool + ring log. `storeDir` is its
147
+ // pre-0.14 name — keep honouring it so an upgrade doesn't strand an existing tile
148
+ // cache. When nothing is set we pass undefined and let each module apply its own
149
+ // historical default (<pluginData>/store, <pluginData>/spool), so an upgrade never
150
+ // orphans a populated cache or a spool full of unsent telemetry.
151
+ const store = p.dataDir || p.storeDir || undefined
152
+ const spoolDir = s.spoolDir || (p.dataDir ? path.join(p.dataDir, 'spool') : undefined)
153
+
161
154
  // --- sync module (isolated: its failure must not affect the proxy) ---
162
- if (opts.sync && opts.sync.enabled !== false && opts.sync.influxUrl) {
163
- try { sync = createSync(app, opts.sync); sync.start() } catch (e) {
155
+ // Pairing wins over hand-written fields; hand-written fields win over the constants.
156
+ if (s.enabled !== false) {
157
+ const syncOpts = {
158
+ ...SYNC_TUNING,
159
+ // The cloud endpoint is fleet-wide and fixed. A pasted or cached bundle can
160
+ // never set it — the app's signup screen hands out its own http://localhost:8086,
161
+ // and a boat that believed it would spool to loopback forever, looking healthy.
162
+ // `sync.influxUrl` survives only as a self-hosting escape hatch: it is not in
163
+ // the schema, so it cannot arrive by pasting, only by editing the config JSON.
164
+ influxUrl: s.influxUrl || SAILKICK_INFLUX_URL,
165
+ org: b.org || s.org || SYNC_TUNING.org,
166
+ bucket: b.bucket || s.bucket,
167
+ token: b.writeToken || s.token,
168
+ spoolDir,
169
+ batchSize: s.batchSize || SYNC_TUNING.batchSize,
170
+ flushIntervalMs: s.flushIntervalMs || SYNC_TUNING.flushIntervalMs,
171
+ maxBufferBytes: s.maxBufferBytes || SYNC_TUNING.maxBufferBytes,
172
+ subscribePeriodMs: s.subscribePeriodMs || SYNC_TUNING.subscribePeriodMs,
173
+ requestTimeoutMs: s.requestTimeoutMs || SYNC_TUNING.requestTimeoutMs,
174
+ retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
175
+ retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
176
+ }
177
+ if (isLoopbackUrl(syncOpts.influxUrl)) {
178
+ syncWarning = `sync: ⚠ writing to ${syncOpts.influxUrl} — telemetry is NOT reaching the cloud`
179
+ ;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a local address — telemetry will not reach the cloud`)
180
+ }
181
+ try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
164
182
  (app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
165
183
  sync = null
166
184
  }
167
185
  }
168
186
 
169
187
  // --- proxy module (+ /ws/telemetry provider and /api/history from local sources) ---
170
- if (!opts.proxy || opts.proxy.enabled !== false) {
171
- const pOpts = { ...(opts.proxy || {}) }
172
- if (pOpts.serveTelemetry !== false) {
188
+ if (p.enabled !== false) {
189
+ const oldSeed = p.seed || {}
190
+ const oldPrefetch = p.prefetch || {}
191
+ const oldHistory = p.history || {}
192
+ const pOpts = {
193
+ sailkickUrl: p.sailkickUrl || SAILKICK_APP_URL,
194
+ storeDir: store,
195
+ proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
196
+ localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
197
+ localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
198
+ telemetryPath: p.telemetryPath || PROXY_TUNING.telemetryPath,
199
+ requestTimeoutMs: p.requestTimeoutMs || PROXY_TUNING.requestTimeoutMs,
200
+ openAccess: p.openAccess !== false, // single-tenant boat: the cloud login gate can't work over plain HTTP
201
+ manifest: MANIFEST, // always on — freshness comes from the cloud announcing bakes
202
+ seed: {
203
+ ...SEED_TUNING,
204
+ // Never seed an unpaired boat: it would pull ~17k tiles from a host this
205
+ // install has no account on, and retry forever behind the circuit breaker.
206
+ // Pair first, then the worldwide base downloads on the next start.
207
+ enabled: !!b.writeToken && (p.seedEnabled != null ? p.seedEnabled !== false : oldSeed.enabled !== false),
208
+ coastlineMaxZoom: oldSeed.coastlineMaxZoom || SEED_TUNING.coastlineMaxZoom,
209
+ seabedMaxZoom: oldSeed.seabedMaxZoom || SEED_TUNING.seabedMaxZoom,
210
+ concurrency: oldSeed.concurrency || SEED_TUNING.concurrency
211
+ },
212
+ prefetch: {
213
+ radiusNm: p.prefetchRadiusNm != null ? p.prefetchRadiusNm : oldPrefetch.radiusNm,
214
+ detailZoom: p.prefetchDetailZoom != null ? p.prefetchDetailZoom : oldPrefetch.detailZoom,
215
+ concurrency: oldPrefetch.concurrency || PREFETCH_TUNING.concurrency
216
+ },
217
+ history: {
218
+ ...HISTORY_TUNING,
219
+ enabled: oldHistory.enabled !== false,
220
+ influxUrl: oldHistory.influxUrl || HISTORY_TUNING.influxUrl,
221
+ org: oldHistory.org || HISTORY_TUNING.org,
222
+ bucket: oldHistory.bucket || HISTORY_TUNING.bucket,
223
+ token: p.historyToken || oldHistory.token || '',
224
+ ringPersist: oldHistory.ringPersist !== false,
225
+ ringWindowSec: oldHistory.ringWindowSec || HISTORY_TUNING.ringWindowSec,
226
+ ringSampleSec: oldHistory.ringSampleSec || HISTORY_TUNING.ringSampleSec,
227
+ ringDir: oldHistory.ringDir
228
+ }
229
+ }
230
+
231
+ if (p.serveTelemetry !== false) {
173
232
  try {
174
233
  telemetry = createTelemetry(app, {})
175
234
  telemetry.start()
176
- pOpts.telemetryUpgrade = (req, s, h) => telemetry.handleUpgrade(req, s, h)
235
+ pOpts.telemetryUpgrade = (req, sock, head) => telemetry.handleUpgrade(req, sock, head)
177
236
  } catch (e) {
178
237
  (app.error || console.error)('[sailkick-boat] telemetry start failed: ' + e.message)
179
238
  telemetry = null
180
239
  }
181
240
  }
182
- if (!pOpts.history || pOpts.history.enabled !== false) {
241
+ if (pOpts.history.enabled !== false) {
183
242
  try {
184
- // ringSource = the telemetry module: when no local InfluxDB is configured
185
- // (e.g. a Victron GX), history serves a DB-less ring from live telemetry.
186
- // storeDir lets the persistent ring log default onto the SSD with the tiles.
187
- history = createHistory(app, { ...(pOpts.history || {}), ringSource: telemetry, storeDir: pOpts.storeDir })
243
+ // ringSource = the telemetry module: when no local InfluxDB token is set
244
+ // (the common case, e.g. a Victron GX), history serves a DB-less ring from
245
+ // live telemetry. storeDir puts the persistent ring log on the SSD.
246
+ history = createHistory(app, { ...pOpts.history, ringSource: telemetry, storeDir: store })
188
247
  history.start()
189
248
  pOpts.history = history // proxy dispatches /api/history to it when available()
190
249
  } catch (e) {
191
250
  (app.error || console.error)('[sailkick-boat] history start failed: ' + e.message)
192
251
  history = null
252
+ pOpts.history = null
193
253
  }
254
+ } else {
255
+ pOpts.history = null
194
256
  }
257
+
195
258
  try { proxy = createProxy(app, pOpts); proxy.start() } catch (e) {
196
259
  (app.error || console.error)('[sailkick-boat] proxy start failed: ' + e.message)
197
260
  proxy = null
@@ -206,6 +269,7 @@ module.exports = function (app) {
206
269
  if (!app.setPluginStatus) return
207
270
  const parts = []
208
271
  if (accountStatus) parts.push(accountStatus)
272
+ if (syncWarning) parts.push(syncWarning)
209
273
  if (sync) parts.push(sync.status())
210
274
  if (proxy) parts.push(proxy.status())
211
275
  if (telemetry) parts.push(telemetry.status())
@@ -225,6 +289,7 @@ module.exports = function (app) {
225
289
  history = null
226
290
  proxy = null
227
291
  accountStatus = null
292
+ syncWarning = null
228
293
  }
229
294
 
230
295
  // Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
@@ -1,65 +1,87 @@
1
1
  'use strict'
2
2
 
3
- // Sailkick account auto-config. Instead of pasting InfluxDB URL/org/bucket/write-token
4
- // into the plugin, the owner enters host + slug + password; on start the plugin POSTs
5
- // them to the sailkick app's /api/boat/config and gets its cloud config back. The
6
- // last-good bundle is cached (0600) so sync keeps working offline after the first
7
- // connect. Dependency-free; never throws.
3
+ // Sailkick account config a pure local resolver, no network.
4
+ //
5
+ // Registration happens in the web app: the user redeems an invite there, and the
6
+ // signup screen shows the boat's ingest credentials once (see the app's
7
+ // public/ui/login-overlay.js). They paste the write token into this plugin, and that
8
+ // is the whole handshake. The plugin deliberately does NOT sign up on its own —
9
+ // one registration path means no half-created accounts and no spent-invite dead ends.
10
+ //
11
+ // Everything else about the bundle is derivable, so only two fields are asked for:
12
+ // bucket = "<slug>_raw" (the app's convention — server/auth/registry.js bucketFor)
13
+ // org = "sailkick" (single org, fleet-wide)
14
+ //
15
+ // This bundle deliberately carries NO Influx URL. "Which boat am I" and "where is the
16
+ // cloud" are separate questions, and the second one has a single fleet-wide answer that
17
+ // index.js owns. Letting a pasted or cached value set the host is how telemetry ends up
18
+ // spooling to a loopback address forever while the status line looks healthy — the app's
19
+ // signup screen has been handing out its own internal http://localhost:8086.
20
+ //
21
+ // NB the write token cannot be re-fetched: the app shows it once at signup and never
22
+ // stores it (registry.js keeps only passwordHash + influxReadToken). Losing it means
23
+ // minting a new one, so the pasted value is the source of truth here.
8
24
 
9
25
  const fs = require('fs')
10
26
  const path = require('path')
11
27
 
12
- function accountConfigured (account) {
13
- return !!(account && account.sailkickUrl && account.slug && account.password)
14
- }
28
+ const DEFAULT_ORG = 'sailkick'
15
29
 
16
30
  function cacheFileFor (app) {
17
31
  const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
18
32
  return path.join(dataDir, 'account.json')
19
33
  }
20
34
 
35
+ // Legacy/manual escape hatch: a hand-written (or pre-0.14.2) account.json still works,
36
+ // so an install that was set up before the token field existed keeps syncing. Any
37
+ // influxUrl in such a file is dropped — those were copied off the signup screen, which
38
+ // was showing the server's own loopback address.
21
39
  function readCache (file) {
22
40
  try {
23
41
  const j = JSON.parse(fs.readFileSync(file, 'utf8'))
24
- return (j && j.writeToken) ? j : null
42
+ if (!j || !j.writeToken) return null
43
+ const { influxUrl, ...rest } = j
44
+ return rest
25
45
  } catch { return null }
26
46
  }
27
47
 
28
- function writeCache (file, bundle) {
29
- try {
30
- fs.mkdirSync(path.dirname(file), { recursive: true })
31
- const tmp = `${file}.tmp-${process.pid}`
32
- fs.writeFileSync(tmp, JSON.stringify(bundle), { mode: 0o600 })
33
- fs.renameSync(tmp, file)
34
- } catch {}
35
- }
48
+ const clean = (v) => String(v == null ? '' : v).trim()
36
49
 
37
- // Returns { bundle, source: 'live'|'cache'|null, error? }.
38
- // bundle = { influxUrl, org, bucket, writeToken, readToken }.
39
- async function resolveAccountConfig (app, account, opts = {}) {
40
- const timeoutMs = opts.timeoutMs || 15000
41
- const file = cacheFileFor(app)
42
- const url = String(account.sailkickUrl).replace(/\/+$/, '') + '/api/boat/config'
43
- const fallback = (error) => { const c = readCache(file); return { bundle: c, source: c ? 'cache' : null, error } }
44
- try {
45
- const resp = await fetch(url, {
46
- method: 'POST',
47
- headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify({ slug: account.slug, password: account.password }),
49
- signal: AbortSignal.timeout(timeoutMs)
50
- })
51
- if (!resp.ok) {
52
- const body = await resp.text().catch(() => '')
53
- return fallback(`HTTP ${resp.status}: ${body.slice(0, 120)}`) // offline-safe: keep the cached bundle
50
+ // Returns one of:
51
+ // { bundle, source: 'config' } token pasted into the plugin config
52
+ // { bundle, source: 'cache' } from a hand-written/legacy account.json
53
+ // { bundle: null, source: null, error? }
54
+ //
55
+ // bundle = { slug, org, bucket, writeToken } — no influxUrl by design, see above.
56
+ function resolveAccountConfig (app, account) {
57
+ const acct = account || {}
58
+ const slug = clean(acct.slug).toLowerCase()
59
+ const writeToken = clean(acct.writeToken)
60
+
61
+ if (slug && writeToken) {
62
+ return {
63
+ source: 'config',
64
+ bundle: {
65
+ slug,
66
+ org: clean(acct.org) || DEFAULT_ORG,
67
+ bucket: clean(acct.bucket) || `${slug}_raw`,
68
+ writeToken
69
+ }
54
70
  }
55
- const j = await resp.json().catch(() => null)
56
- if (!j || j.ok === false || !j.writeToken) return fallback('unexpected /api/boat/config response')
57
- const bundle = { influxUrl: j.influxUrl, org: j.org, bucket: j.bucket, writeToken: j.writeToken, readToken: j.readToken }
58
- writeCache(file, bundle)
59
- return { bundle, source: 'live' }
60
- } catch (e) {
61
- return fallback(e.message) // offline / unreachable → use the cached bundle
62
71
  }
72
+
73
+ const cached = readCache(cacheFileFor(app))
74
+ if (cached) return { bundle: cached, source: 'cache' }
75
+
76
+ // Half-filled: say which half, so the status line is actionable.
77
+ if (writeToken) return { bundle: null, source: null, error: 'boat name is missing' }
78
+ if (slug) return { bundle: null, source: null, error: 'write token is missing' }
79
+ return { bundle: null, source: null }
80
+ }
81
+
82
+ // True when sync has credentials to work with.
83
+ function accountConfigured (app, account) {
84
+ return !!resolveAccountConfig(app, account).bundle
63
85
  }
64
86
 
65
- module.exports = { resolveAccountConfig, accountConfigured }
87
+ module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor, DEFAULT_ORG }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.13.4",
4
- "description": "EARLY ALPHA — boat-side companion for the sailkick platform (not yet publicly available; self-hosters only). Gapless telemetry sync to InfluxDB, offline-first caching mirror of the sailkick host, and local telemetry + history endpoints from the boat's own data.",
3
+ "version": "0.14.3",
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": {
7
7
  "test": "node --test"