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 +74 -38
- package/index.js +164 -115
- package/lib/account/index.js +75 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# sailkick-boat
|
|
2
2
|
|
|
3
|
-
> ## ⚠️ Project status: early alpha —
|
|
3
|
+
> ## ⚠️ Project status: early alpha — invite only
|
|
4
4
|
>
|
|
5
|
-
> This plugin is the **boat-side companion of the sailkick platform**
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
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
|
-
>
|
|
12
|
-
>
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
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 `<
|
|
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: `
|
|
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 `
|
|
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.
|
|
127
|
-
the
|
|
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
|
-
`<
|
|
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
|
-
##
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
its cloud
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
27
|
-
'caching mirror of the sailkick
|
|
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 (
|
|
36
|
-
description: '
|
|
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
|
-
|
|
39
|
-
slug: { type: 'string', title: 'Boat
|
|
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 →
|
|
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: '
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
103
|
-
type: '
|
|
104
|
-
title: '
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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 (
|
|
171
|
-
const
|
|
172
|
-
|
|
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,
|
|
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 (
|
|
227
|
+
if (pOpts.history.enabled !== false) {
|
|
183
228
|
try {
|
|
184
|
-
// ringSource = the telemetry module: when no local InfluxDB is
|
|
185
|
-
// (e.g. a Victron GX), history serves a DB-less ring from
|
|
186
|
-
// storeDir
|
|
187
|
-
history = createHistory(app, { ...
|
|
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
|
package/lib/account/index.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// Sailkick account
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
const
|
|
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
|
-
|
|
72
|
+
resp = await fetch(url, {
|
|
46
73
|
method: 'POST',
|
|
47
74
|
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
body: JSON.stringify({
|
|
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
|
|
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.
|
|
4
|
-
"description": "EARLY ALPHA —
|
|
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"
|