sailkick-boat 0.13.4 → 0.14.1

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
+ > **Pairing needs an invite code** there is no open registration yet, so installing
11
+ > this plugin 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,43 @@ 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: pair the boat once
163
+ Fill the **Sailkick account** section **invite code + boat name + password** — and
164
+ save. The plugin calls the app's `POST /api/auth/signup` once: that creates your
165
+ account, provisions its cloud storage, and returns the write token, bucket, org and
166
+ InfluxDB URL for your boat. The bundle is cached (0600) in the plugin data dir and
167
+ reused on every later start, so sync keeps working **offline** and across restarts.
168
+
169
+ Pairing is **one-time by design** — the invite code is single-use, so the plugin never
170
+ calls signup twice. Once `account.json` exists the account fields are no longer read;
171
+ delete that file to re-pair (which needs a fresh invite).
172
+
173
+ ## Config
174
+ The page is deliberately small everything else has a right answer and is a constant
175
+ in `index.js`.
176
+
177
+ - **Sailkick account**: `invite`, `slug` (boat name), `password`
178
+ - **Telemetry sync → cloud**: `enabled`
179
+ - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
180
+ (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
181
+ `prefetchDetailZoom`, `historyToken`
182
+
183
+ `dataDir` is the one storage location — cached maps, the telemetry spool and the
184
+ history ring log all live under it. **Put it on the SSD/USB disk, not the SD card.**
185
+ Leave it blank and each part falls back to its historical spot under the plugin data
186
+ dir.
187
+
188
+ `historyToken` is optional and only matters if the boat already runs its own InfluxDB.
189
+ Blank — the normal case — uses the built-in DB-less ring.
190
+
191
+ Cache-manifest polling is always on (no toggle): tile freshness comes from the cloud
192
+ announcing bakes, and without it a re-baked dataset would never refresh.
193
+
194
+ **Self-hosting / advanced.** Every module still reads its option before falling back to
195
+ the constant, so anything removed from the page can be set by hand in
196
+ `~/.signalk/plugin-config-data/sailkick-boat.json` — `sync.influxUrl`, `sync.token`,
197
+ `proxy.sailkickUrl`, `proxy.seed.coastlineMaxZoom`, the ring window, the retry backoff,
198
+ and so on. Values saved by earlier versions keep working after an upgrade.
179
199
 
180
200
  Point your chart app / browser at:
181
201
  `http://<boat>:3000/plugins/sailkick-boat/p/`
@@ -185,7 +205,17 @@ Point your chart app / browser at:
185
205
  cd ~/.signalk && npm install sailkick-boat # or a packed tarball
186
206
  ```
187
207
  Enable + configure under **Server → Plugin Config → "Sailkick boat companion"**.
188
- Put `spoolDir`/`storeDir` on the SSD (or leave blank for the plugin data dir).
208
+ Set **Data directory** to a path on the SSD (or leave blank for the plugin data dir).
209
+
210
+ ### Victron GX / Venus OS (Cerbo, Ekrano)
211
+ Works on Venus OS Large (Signal K enabled). Point **Data directory** at USB/SD storage
212
+ (internal flash is small). Leave the history token
213
+ blank — the DB-less telemetry ring is used automatically. **Note:** a GX only sees
214
+ Victron data (batteries, solar, tanks) by default; **position/wind/speed/depth
215
+ require the boat's NMEA2000 backbone on the VE.Can port** (250 kbit/s N2K profile +
216
+ a VE.Can↔Micro-C drop cable; leave the spare RJ45 unterminated) or a USB GPS/N2K
217
+ gateway. Without a position source, the live boat / trends / area-download stay
218
+ idle — energy telemetry still syncs to the cloud.
189
219
 
190
220
  ## Dev / tests
191
221
  ```bash
@@ -193,5 +223,11 @@ npm install && npm test # proxy: mirror/cache/offline + Express route; sync: s
193
223
  ```
194
224
  Supersedes the separate `signalk-to-influxdb-gapless` + `signalk-tile-cache` plugins.
195
225
 
226
+ ## Contact
227
+ Questions, feedback, or want to test the app on your own boat?
228
+ Email **[info@sailkick.io](mailto:info@sailkick.io)** — happy to help with setup, and
229
+ early testers are very welcome. Bug reports and feature requests are also fine as
230
+ [GitHub issues](https://github.com/lauchat/sailkick-boat/issues).
231
+
196
232
  ## License
197
233
  MIT
package/index.js CHANGED
@@ -1,16 +1,50 @@
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
+
14
48
  module.exports = function (app) {
15
49
  let sync = null
16
50
  let proxy = null
@@ -23,8 +57,8 @@ module.exports = function (app) {
23
57
  id: 'sailkick-boat',
24
58
  name: 'Sailkick boat companion',
25
59
  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.'
60
+ 'Boat-side sailkick: gapless telemetry sync to the cloud + an offline-first ' +
61
+ 'caching mirror of the sailkick app. Each feature can be toggled on/off.'
28
62
  }
29
63
 
30
64
  plugin.schema = {
@@ -32,106 +66,48 @@ module.exports = function (app) {
32
66
  properties: {
33
67
  account: {
34
68
  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) automaticallyno manual token entry. Leave blank to configure sync/mirror manually below. The last-good config is cached, so sync keeps working offline.',
69
+ title: 'Sailkick account (one-time pairing)',
70
+ description: 'Pair this boat with your sailkick account once. The plugin creates the account, provisions its cloud storage, and stores the result locally so it keeps syncing offline and after restarts. Your invite code is single-use; once paired these fields are no longer read.',
37
71
  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' }
72
+ invite: { type: 'string', title: 'Invite code' },
73
+ slug: { type: 'string', title: 'Boat name', description: '2–40 characters: lowercase letters, numbers, dashes. This is your permanent boat id.' },
74
+ password: { type: 'string', title: 'Password', description: 'At least 8 characters.' }
41
75
  }
42
76
  },
43
77
  sync: {
44
78
  type: 'object',
45
- title: 'Telemetry sync → InfluxDB',
79
+ title: 'Telemetry sync → cloud',
80
+ 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
81
  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 }
82
+ enabled: { type: 'boolean', title: 'Enable telemetry sync', default: true }
60
83
  }
61
84
  },
62
85
  proxy: {
63
86
  type: 'object',
64
- title: 'Sailkick caching proxy',
87
+ title: 'Offline app & maps',
88
+ description: 'Serve the sailkick app on board — charts, trends and live data keep working with no internet.',
65
89
  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
- }
84
- },
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
- }
90
+ enabled: { type: 'boolean', title: 'Enable the offline mirror', default: true },
91
+ proxyPort: { type: 'number', title: 'Mirror port', description: 'Open the app at http://<boat>:<port>/. 0 = disable the standalone server.', default: 8080 },
92
+ 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' },
93
+ 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.' },
94
+ 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 },
95
+ prefetchRadiusNm: {
96
+ type: 'number',
97
+ title: 'Download detailed charts around the boat',
98
+ description: 'On save, caches the full chart detail for this radius around the boat\'s current position. Progress shows in the status line.',
99
+ enum: [0, 25, 50, 100, 200],
100
+ enumNames: ['Off', '25 nm', '50 nm', '100 nm', '200 nm'],
101
+ default: 0
101
102
  },
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
- }
103
+ prefetchDetailZoom: {
104
+ type: 'number',
105
+ title: '…at this detail level',
106
+ enum: [12, 13, 14, 15],
107
+ enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
108
+ default: 13
112
109
  },
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
- }
110
+ 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
111
  }
136
112
  }
137
113
  }
@@ -139,59 +115,132 @@ module.exports = function (app) {
139
115
 
140
116
  plugin.start = function (options) {
141
117
  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).
118
+ // Pairing first: on the very first start this provisions the account and caches the
119
+ // bundle; on every later start it is a disk read (see lib/account — signup is
120
+ // single-use and must never run twice).
145
121
  ;(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
- }
122
+ const r = await resolveAccountConfig(app, opts.account, { appUrl: SAILKICK_APP_URL })
123
+ if (r.bundle) {
124
+ accountStatus = r.source === 'paired'
125
+ ? `account: paired as ${r.bundle.slug}`
126
+ : `account: ${r.bundle.slug}`
127
+ } else if (r.error) {
128
+ accountStatus = `account: pairing failed — ${r.error}${r.terminal ? '' : ' (will retry on restart)'}`
129
+ ;(app.error || console.error)('[sailkick-boat] pairing failed: ' + r.error)
130
+ } else {
131
+ accountStatus = 'account: not paired — enter your invite code'
155
132
  }
156
- startModules(opts)
133
+ startModules(opts, r.bundle)
157
134
  })().catch((e) => (app.error || console.error)('[sailkick-boat] start failed: ' + e.message))
158
135
  }
159
136
 
160
- function startModules (opts) {
137
+ function startModules (opts, bundle) {
138
+ const s = opts.sync || {}
139
+ const p = opts.proxy || {}
140
+ const b = bundle || {}
141
+ // One "Data directory" now drives cache + spool + ring log. `storeDir` is its
142
+ // pre-0.14 name — keep honouring it so an upgrade doesn't strand an existing tile
143
+ // cache. When nothing is set we pass undefined and let each module apply its own
144
+ // historical default (<pluginData>/store, <pluginData>/spool), so an upgrade never
145
+ // orphans a populated cache or a spool full of unsent telemetry.
146
+ const store = p.dataDir || p.storeDir || undefined
147
+ const spoolDir = s.spoolDir || (p.dataDir ? path.join(p.dataDir, 'spool') : undefined)
148
+
161
149
  // --- 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) {
150
+ // Pairing wins over hand-written fields; hand-written fields win over the constants.
151
+ if (s.enabled !== false) {
152
+ const syncOpts = {
153
+ ...SYNC_TUNING,
154
+ influxUrl: b.influxUrl || s.influxUrl || SAILKICK_INFLUX_URL,
155
+ org: b.org || s.org || SYNC_TUNING.org,
156
+ bucket: b.bucket || s.bucket,
157
+ token: b.writeToken || s.token,
158
+ spoolDir,
159
+ batchSize: s.batchSize || SYNC_TUNING.batchSize,
160
+ flushIntervalMs: s.flushIntervalMs || SYNC_TUNING.flushIntervalMs,
161
+ maxBufferBytes: s.maxBufferBytes || SYNC_TUNING.maxBufferBytes,
162
+ subscribePeriodMs: s.subscribePeriodMs || SYNC_TUNING.subscribePeriodMs,
163
+ requestTimeoutMs: s.requestTimeoutMs || SYNC_TUNING.requestTimeoutMs,
164
+ retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
165
+ retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
166
+ }
167
+ try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
164
168
  (app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
165
169
  sync = null
166
170
  }
167
171
  }
168
172
 
169
173
  // --- 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) {
174
+ if (p.enabled !== false) {
175
+ const oldSeed = p.seed || {}
176
+ const oldPrefetch = p.prefetch || {}
177
+ const oldHistory = p.history || {}
178
+ const pOpts = {
179
+ sailkickUrl: p.sailkickUrl || SAILKICK_APP_URL,
180
+ storeDir: store,
181
+ proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
182
+ localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
183
+ localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
184
+ telemetryPath: p.telemetryPath || PROXY_TUNING.telemetryPath,
185
+ requestTimeoutMs: p.requestTimeoutMs || PROXY_TUNING.requestTimeoutMs,
186
+ openAccess: p.openAccess !== false, // single-tenant boat: the cloud login gate can't work over plain HTTP
187
+ manifest: MANIFEST, // always on — freshness comes from the cloud announcing bakes
188
+ seed: {
189
+ ...SEED_TUNING,
190
+ // Never seed an unpaired boat: it would pull ~17k tiles from a host this
191
+ // install has no account on, and retry forever behind the circuit breaker.
192
+ // Pair first, then the worldwide base downloads on the next start.
193
+ enabled: !!b.writeToken && (p.seedEnabled != null ? p.seedEnabled !== false : oldSeed.enabled !== false),
194
+ coastlineMaxZoom: oldSeed.coastlineMaxZoom || SEED_TUNING.coastlineMaxZoom,
195
+ seabedMaxZoom: oldSeed.seabedMaxZoom || SEED_TUNING.seabedMaxZoom,
196
+ concurrency: oldSeed.concurrency || SEED_TUNING.concurrency
197
+ },
198
+ prefetch: {
199
+ radiusNm: p.prefetchRadiusNm != null ? p.prefetchRadiusNm : oldPrefetch.radiusNm,
200
+ detailZoom: p.prefetchDetailZoom != null ? p.prefetchDetailZoom : oldPrefetch.detailZoom,
201
+ concurrency: oldPrefetch.concurrency || PREFETCH_TUNING.concurrency
202
+ },
203
+ history: {
204
+ ...HISTORY_TUNING,
205
+ enabled: oldHistory.enabled !== false,
206
+ influxUrl: oldHistory.influxUrl || HISTORY_TUNING.influxUrl,
207
+ org: oldHistory.org || HISTORY_TUNING.org,
208
+ bucket: oldHistory.bucket || HISTORY_TUNING.bucket,
209
+ token: p.historyToken || oldHistory.token || '',
210
+ ringPersist: oldHistory.ringPersist !== false,
211
+ ringWindowSec: oldHistory.ringWindowSec || HISTORY_TUNING.ringWindowSec,
212
+ ringSampleSec: oldHistory.ringSampleSec || HISTORY_TUNING.ringSampleSec,
213
+ ringDir: oldHistory.ringDir
214
+ }
215
+ }
216
+
217
+ if (p.serveTelemetry !== false) {
173
218
  try {
174
219
  telemetry = createTelemetry(app, {})
175
220
  telemetry.start()
176
- pOpts.telemetryUpgrade = (req, s, h) => telemetry.handleUpgrade(req, s, h)
221
+ pOpts.telemetryUpgrade = (req, sock, head) => telemetry.handleUpgrade(req, sock, head)
177
222
  } catch (e) {
178
223
  (app.error || console.error)('[sailkick-boat] telemetry start failed: ' + e.message)
179
224
  telemetry = null
180
225
  }
181
226
  }
182
- if (!pOpts.history || pOpts.history.enabled !== false) {
227
+ if (pOpts.history.enabled !== false) {
183
228
  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 })
229
+ // ringSource = the telemetry module: when no local InfluxDB token is set
230
+ // (the common case, e.g. a Victron GX), history serves a DB-less ring from
231
+ // live telemetry. storeDir puts the persistent ring log on the SSD.
232
+ history = createHistory(app, { ...pOpts.history, ringSource: telemetry, storeDir: store })
188
233
  history.start()
189
234
  pOpts.history = history // proxy dispatches /api/history to it when available()
190
235
  } catch (e) {
191
236
  (app.error || console.error)('[sailkick-boat] history start failed: ' + e.message)
192
237
  history = null
238
+ pOpts.history = null
193
239
  }
240
+ } else {
241
+ pOpts.history = null
194
242
  }
243
+
195
244
  try { proxy = createProxy(app, pOpts); proxy.start() } catch (e) {
196
245
  (app.error || console.error)('[sailkick-boat] proxy start failed: ' + e.message)
197
246
  proxy = null
@@ -1,17 +1,26 @@
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 pairing ONE TIME, then cached forever.
4
+ //
5
+ // The owner enters an invite code + boat name + password once. The plugin POSTs them to
6
+ // the app's /api/auth/signup, which provisions the boat's InfluxDB bucket + write token
7
+ // and returns an `ingest` bundle. That bundle is cached (0600) in the plugin data dir
8
+ // and reused on every subsequent start.
9
+ //
10
+ // Cache-first is NOT an optimisation here, it is correctness: signup consumes a
11
+ // single-use invite and creates the account, so calling it twice returns 403
12
+ // bad-invite / 409 slug-taken. The cached bundle is the only repeatable path —
13
+ // /auth/login issues a session cookie but no token, and the app never persists the
14
+ // write token, so there is nothing to re-fetch.
15
+ //
16
+ // Dependency-free; never throws.
8
17
 
9
18
  const fs = require('fs')
10
19
  const path = require('path')
11
20
 
12
- function accountConfigured (account) {
13
- return !!(account && account.sailkickUrl && account.slug && account.password)
14
- }
21
+ // Terminal = re-running with the same input can only fail the same way; the owner has
22
+ // to change something (new invite, different boat name, longer password).
23
+ const TERMINAL_CODES = new Set(['bad-invite', 'slug-taken', 'bad-slug', 'weak-password'])
15
24
 
16
25
  function cacheFileFor (app) {
17
26
  const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
@@ -34,32 +43,71 @@ function writeCache (file, bundle) {
34
43
  } catch {}
35
44
  }
36
45
 
37
- // Returns { bundle, source: 'live'|'cache'|null, error? }.
38
- // bundle = { influxUrl, org, bucket, writeToken, readToken }.
46
+ // True when we could pair right now (nothing cached yet, but all three fields present).
47
+ function pairable (account) {
48
+ return !!(account && account.invite && account.slug && account.password)
49
+ }
50
+
51
+ // Returns one of:
52
+ // { bundle, source: 'cache' } already paired — no network call was made
53
+ // { bundle, source: 'paired' } just provisioned; bundle now cached
54
+ // { bundle: null, source: null } not configured (nothing to do)
55
+ // { bundle: null, source: null, error, terminal } pairing attempted and failed
56
+ //
57
+ // bundle = { slug, influxUrl, org, bucket, writeToken }
39
58
  async function resolveAccountConfig (app, account, opts = {}) {
40
- const timeoutMs = opts.timeoutMs || 15000
41
59
  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 } }
60
+
61
+ const cached = readCache(file)
62
+ if (cached) return { bundle: cached, source: 'cache' } // paired already — never call signup again
63
+
64
+ if (!pairable(account)) return { bundle: null, source: null }
65
+
66
+ const appUrl = String(opts.appUrl || '').replace(/\/+$/, '')
67
+ const url = appUrl + '/api/auth/signup'
68
+ const slug = String(account.slug).trim().toLowerCase()
69
+
70
+ let resp
44
71
  try {
45
- const resp = await fetch(url, {
72
+ resp = await fetch(url, {
46
73
  method: 'POST',
47
74
  headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify({ slug: account.slug, password: account.password }),
49
- signal: AbortSignal.timeout(timeoutMs)
75
+ body: JSON.stringify({ invite: account.invite, slug, password: account.password, name: slug }),
76
+ signal: AbortSignal.timeout(opts.timeoutMs || 20000)
50
77
  })
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
54
- }
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
78
  } catch (e) {
61
- return fallback(e.message) // offline / unreachable use the cached bundle
79
+ return { bundle: null, source: null, error: `cannot reach ${appUrl} (${e.message})`, terminal: false }
80
+ }
81
+
82
+ const j = await resp.json().catch(() => null)
83
+
84
+ if (!resp.ok || !j || j.ok === false) {
85
+ const code = (j && j.code) || `http-${resp.status}`
86
+ const message = (j && j.message) || `HTTP ${resp.status}`
87
+ return { bundle: null, source: null, error: `${message} (${code})`, terminal: TERMINAL_CODES.has(code) }
88
+ }
89
+
90
+ const ingest = j.ingest || {}
91
+ if (!ingest.writeToken) {
92
+ return { bundle: null, source: null, error: 'signup returned no write token', terminal: false }
62
93
  }
94
+
95
+ // NB: signup returns no readToken (the app keeps it in its own registry), so local
96
+ // history falls back to the DB-less ring unless a read token is set by hand.
97
+ const bundle = {
98
+ slug: (j.boat && j.boat.slug) || slug,
99
+ influxUrl: ingest.url,
100
+ org: ingest.org,
101
+ bucket: ingest.bucket,
102
+ writeToken: ingest.writeToken
103
+ }
104
+ writeCache(file, bundle)
105
+ return { bundle, source: 'paired' }
106
+ }
107
+
108
+ // True when the plugin either holds a bundle or has enough input to obtain one.
109
+ function accountConfigured (app, account) {
110
+ return !!readCache(cacheFileFor(app)) || pairable(account)
63
111
  }
64
112
 
65
- module.exports = { resolveAccountConfig, accountConfigured }
113
+ module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor }
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.1",
4
+ "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; pairing is invite-only). 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"