sailkick-boat 0.13.4
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/LICENSE +11 -0
- package/README.md +197 -0
- package/index.js +252 -0
- package/lib/account/index.js +65 -0
- package/lib/history/index.js +247 -0
- package/lib/history/ring.js +162 -0
- package/lib/proxy/cache.js +151 -0
- package/lib/proxy/index.js +393 -0
- package/lib/proxy/manifest.js +121 -0
- package/lib/proxy/seed.js +139 -0
- package/lib/proxy/tiles.js +62 -0
- package/lib/sync/index.js +150 -0
- package/lib/sync/influxWrite.js +49 -0
- package/lib/sync/lineprotocol.js +121 -0
- package/lib/sync/spool.js +101 -0
- package/lib/telemetry/index.js +123 -0
- package/lib/telemetry/signalk-map.js +71 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sailkick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including the rights to use, copy, modify,
|
|
8
|
+
merge, publish, distribute, sublicense, and/or sell copies, subject to the
|
|
9
|
+
above copyright notice and this permission notice being included in all copies.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# sailkick-boat
|
|
2
|
+
|
|
3
|
+
> ## ⚠️ Project status: early alpha — no public service yet
|
|
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).
|
|
10
|
+
>
|
|
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.
|
|
16
|
+
|
|
17
|
+
One Signal K plugin, independently-toggleable modules — so the boat stays
|
|
18
|
+
"just SignalK + plugins":
|
|
19
|
+
|
|
20
|
+
- **`sync`** — gapless store-and-forward of self-vessel telemetry to InfluxDB v2
|
|
21
|
+
(durable spool; survives offline + restarts). Data **out**.
|
|
22
|
+
- **`proxy`** — offline-first caching **mirror of the sailkick host**: fetch once
|
|
23
|
+
online, serve from disk forever (incl. offline). Data **in**.
|
|
24
|
+
- served alongside the mirror, from the boat's **own** data (so the app uses the
|
|
25
|
+
same contracts as the cloud, offline-first):
|
|
26
|
+
- **`/ws/telemetry`** — the app's live telemetry bus, fed from local SignalK.
|
|
27
|
+
- **`/api/history/{series,track}`** — the app's Trends panel + track, served
|
|
28
|
+
from the boat's local InfluxDB (or a DB-less telemetry ring) — full local history.
|
|
29
|
+
|
|
30
|
+
Kept as separate modules so a proxy fault can't wedge the data-critical sync path.
|
|
31
|
+
|
|
32
|
+
## Proxy: how it works
|
|
33
|
+
```
|
|
34
|
+
laptop -> http://<boat>:3000/plugins/sailkick-boat/p/<anything>
|
|
35
|
+
-> on disk? yes -> serve [X-Sailkick-Cache: HIT]
|
|
36
|
+
no -> fetch <sailkickUrl>/<anything> -> store -> serve [MISS]
|
|
37
|
+
no + offline -> 504
|
|
38
|
+
```
|
|
39
|
+
Any content-type (tiles, JSON, app assets). Query strings cache separately.
|
|
40
|
+
Open the sailkick app *through* the proxy and every relative URL it loads is
|
|
41
|
+
cached automatically. `POST /plugins/sailkick-boat/prefetch` with
|
|
42
|
+
`{"paths":[...]}` warms a region/app-shell ahead of a passage.
|
|
43
|
+
|
|
44
|
+
## Cache freshness — pinned tiles, auto-refresh on new bakes
|
|
45
|
+
Tiles are **pinned**: once cached they're served from disk forever (online or
|
|
46
|
+
offline), never time-expired — so a big tile store never "goes slow." Freshness
|
|
47
|
+
comes from the cloud **announcing bakes**, not from a clock:
|
|
48
|
+
|
|
49
|
+
- The cloud publishes a small manifest (default `GET /api/cache-manifest`):
|
|
50
|
+
```json
|
|
51
|
+
{ "app": "2026-07-19a",
|
|
52
|
+
"bakes": { "tiles/osm-standard": "v3", "tiles/seamap": "2026-06", "terrain": "2026-01" } }
|
|
53
|
+
```
|
|
54
|
+
- The plugin polls it (only when online; a failed poll is a no-op). When a
|
|
55
|
+
dataset's id **changes**, files in that family older than the announcement are
|
|
56
|
+
refreshed **lazily** — refetched on next view when online (`X-Sailkick-Cache:
|
|
57
|
+
UPDATED`), served stale when offline (`STALE`). Untouched tiles never re-download.
|
|
58
|
+
- First sight of a family does **not** invalidate — your pre-populated store is
|
|
59
|
+
trusted. Only genuine bake changes refresh anything.
|
|
60
|
+
|
|
61
|
+
`X-Sailkick-Cache` reports `HIT` / `MISS` / `UPDATED` / `STALE` / `LIVE` per response.
|
|
62
|
+
|
|
63
|
+
**Static vs dynamic — two strategies.** Tiles and app assets are **cache-first**
|
|
64
|
+
(pinned, offline forever). Dynamic `/api/*` data (AIS, weather) is **network-first**:
|
|
65
|
+
fetched live every time online (so AIS/weather never go stale), with the last
|
|
66
|
+
response kept only as an **offline fallback** (`STALE`). Local `/api/history` and the
|
|
67
|
+
patched `/api/config` are served specially (above).
|
|
68
|
+
|
|
69
|
+
**Offline circuit breaker.** Some boat routers return errors (or hang) for outbound
|
|
70
|
+
requests when the uplink is down, instead of failing cleanly. Once a fetch fails, the
|
|
71
|
+
mirror marks the upstream down for a short cooldown and **fast-fails uncached
|
|
72
|
+
requests** (no per-request timeout hang) — so a handful of uncached tiles can't
|
|
73
|
+
starve the browser's ~6-connection pool and block the cached tiles. Cached `HIT`s are
|
|
74
|
+
never affected; a single success clears the breaker.
|
|
75
|
+
|
|
76
|
+
Manual force-refresh (no SSH):
|
|
77
|
+
```
|
|
78
|
+
POST /plugins/sailkick-boat/cache/clear # default keep: tiles,terrain,history
|
|
79
|
+
POST /plugins/sailkick-boat/cache/clear?keep=tiles,terrain,history # refresh app shell; keep tiles + ring log
|
|
80
|
+
POST /plugins/sailkick-boat/cache/clear?prefix=tiles/seamap # nuke one tileset
|
|
81
|
+
```
|
|
82
|
+
(The persistent history ring log lives under `<storeDir>/history`, so keep `history`
|
|
83
|
+
when clearing — the default keep already does. A hand-typed `find` clear should add
|
|
84
|
+
`! -name history` alongside `! -name tiles ! -name terrain`.)
|
|
85
|
+
|
|
86
|
+
## Offline map coverage — global base seed + region prefetch
|
|
87
|
+
On-demand caching only holds what you browsed. To make a usable map exist offline
|
|
88
|
+
*everywhere*, the plugin seeds a worldwide low-zoom base on start and lets you warm a
|
|
89
|
+
passage area on demand.
|
|
90
|
+
|
|
91
|
+
- **Global base seed** (default on): caches two worldwide layers, pinned forever —
|
|
92
|
+
**coastline** (sparse vector `.pbf`, parent-guided descent so it probes ~4× the real
|
|
93
|
+
tiles, not a full pyramid) and **seabed/bathy** (dense depth raster). Defaults
|
|
94
|
+
`coastlineMaxZoom 8` (~12k tiles) + `seabedMaxZoom 6` (~5.5k). Idempotent (re-runs
|
|
95
|
+
hit cache only) and self-throttling — it reuses the circuit breaker, so it goes quiet
|
|
96
|
+
offline and resumes when back online. Progress shows in the plugin status line.
|
|
97
|
+
Config: `proxy.seed.{enabled,coastlineMaxZoom,seabedMaxZoom,concurrency}`.
|
|
98
|
+
- **Download around the boat** (settings dropdowns, no token) — the easiest way to
|
|
99
|
+
cache a passage area: in Plugin Config pick a **Radius around boat** (25/50/100/200 nm)
|
|
100
|
+
and a **Detail level** (Overview z12 … Harbor z15), then save. The plugin reads the
|
|
101
|
+
boat's current position from local SignalK, builds a box, and warms the chart layers
|
|
102
|
+
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}`.
|
|
104
|
+
- **Region prefetch (API)** — for scripted/arbitrary boxes, warm the detailed chart layers for an area:
|
|
105
|
+
```
|
|
106
|
+
POST /plugins/sailkick-boat/prefetch/region
|
|
107
|
+
{ "bbox":[w,s,e,n], "minZoom":8, "maxZoom":15,
|
|
108
|
+
"layers":["osm-standard","bathy","seamap","coastline"] }
|
|
109
|
+
```
|
|
110
|
+
Enumerates the bbox×zoom×layers rectangle and warms it. It **estimates first** and
|
|
111
|
+
refuses > 50k tiles unless you pass `"force":true` (so a huge box can't run away over
|
|
112
|
+
the link). Returns `{requested,cached,empty,failed}`.
|
|
113
|
+
- **Empty tiles** (sparse coastline/seamap) are **negative-cached** (`.404` sentinel), so
|
|
114
|
+
offline they read as "empty" (404) exactly like online instead of stalling.
|
|
115
|
+
|
|
116
|
+
Note: the app's **Coastline** and depth layers are default-off toggles — enable them in
|
|
117
|
+
the app to see the seeded base.
|
|
118
|
+
|
|
119
|
+
## No login on the boat (single-tenant)
|
|
120
|
+
The cloud app gates behind a boat-account login (a `Secure` session cookie), which
|
|
121
|
+
can't work over the boat's plain-HTTP offline mirror — the browser drops a `Secure`
|
|
122
|
+
cookie on HTTP, so login just loops. Since the boat is single-tenant and its data
|
|
123
|
+
endpoints aren't server-gated, the proxy serves `/api/config` with `auth.required`
|
|
124
|
+
forced to `false` (and `historyAvailable` forced on when history is served locally),
|
|
125
|
+
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.
|
|
128
|
+
|
|
129
|
+
## Local history (offline Trends + track)
|
|
130
|
+
Two ways, chosen automatically:
|
|
131
|
+
- **Read token set** → full history queried from a **local InfluxDB** (e.g. a bucket
|
|
132
|
+
written by `signalk-to-influxdb-v2`).
|
|
133
|
+
- **No token, telemetry on** → a **DB-less ring** sampled from live telemetry (the
|
|
134
|
+
same BoatState feeding `/ws/telemetry`) — for boats with no local InfluxDB, e.g.
|
|
135
|
+
**SignalK on a Victron GX / Venus OS**. Same JSON contract, fully offline, no database.
|
|
136
|
+
(`historyAvailable` reports true either way, so the app shows the Trends panel.)
|
|
137
|
+
- **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
|
|
139
|
+
`proxy.history.ringDir`), so it **survives restarts**. Each sample appends one line; the file is
|
|
140
|
+
compacted (atomic rewrite to the current window) only rarely, so a long passage
|
|
141
|
+
writes < ~1 GB (vs the ~600 GB a full-rewrite snapshot would). Config
|
|
142
|
+
`proxy.history.ringWindowSec` (default 24 h, up to 2 592 000 = 30 d),
|
|
143
|
+
`ringSampleSec` (auto-coarsened so the ring stays ≤ ~50 k samples at any window),
|
|
144
|
+
`ringPersist` (default on; off = in-memory only). NB: the app currently caps
|
|
145
|
+
history requests at 24 h — a >24 h window needs the app-side clamp raised too.
|
|
146
|
+
|
|
147
|
+
The sailkick app is deployment-agnostic about history: *central Influx in the
|
|
148
|
+
cloud, in-memory ring on a DB-less edge*. The boat is a third case — an edge that
|
|
149
|
+
serves the app's history endpoints from its **own** data (local InfluxDB or the ring):
|
|
150
|
+
```
|
|
151
|
+
GET /api/history/series?window=3600s&every=30s -> { series: { sog|heading|tws|… : [[tMs,val],…] } }
|
|
152
|
+
GET /api/history/track?window=3600s -> { track: [{ t, lat, lon }, …] }
|
|
153
|
+
```
|
|
154
|
+
Same JSON the cloud returns, so the browser can't tell the difference — but it
|
|
155
|
+
works **offline** with the boat's own data. Only when neither a local InfluxDB nor
|
|
156
|
+
telemetry is available do these paths **fall through to the cloud mirror**, so an
|
|
157
|
+
online boat is never worse off than before.
|
|
158
|
+
|
|
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.
|
|
179
|
+
|
|
180
|
+
Point your chart app / browser at:
|
|
181
|
+
`http://<boat>:3000/plugins/sailkick-boat/p/`
|
|
182
|
+
|
|
183
|
+
## Install (like any Signal K plugin)
|
|
184
|
+
```bash
|
|
185
|
+
cd ~/.signalk && npm install sailkick-boat # or a packed tarball
|
|
186
|
+
```
|
|
187
|
+
Enable + configure under **Server → Plugin Config → "Sailkick boat companion"**.
|
|
188
|
+
Put `spoolDir`/`storeDir` on the SSD (or leave blank for the plugin data dir).
|
|
189
|
+
|
|
190
|
+
## Dev / tests
|
|
191
|
+
```bash
|
|
192
|
+
npm install && npm test # proxy: mirror/cache/offline + Express route; sync: subscribe+buffer
|
|
193
|
+
```
|
|
194
|
+
Supersedes the separate `signalk-to-influxdb-gapless` + `signalk-tile-cache` plugins.
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createSync } = require('./lib/sync')
|
|
4
|
+
const { createProxy } = require('./lib/proxy')
|
|
5
|
+
const { createTelemetry } = require('./lib/telemetry')
|
|
6
|
+
const { createHistory } = require('./lib/history')
|
|
7
|
+
const { resolveAccountConfig, accountConfigured } = require('./lib/account')
|
|
8
|
+
|
|
9
|
+
// sailkick-boat: one Signal K plugin, two independently-toggleable modules —
|
|
10
|
+
// sync : gapless store-and-forward of self telemetry to InfluxDB (data OUT)
|
|
11
|
+
// proxy: offline-first caching mirror of the sailkick host (data IN)
|
|
12
|
+
// Kept as separate modules so a proxy fault can't wedge the data-critical sync.
|
|
13
|
+
|
|
14
|
+
module.exports = function (app) {
|
|
15
|
+
let sync = null
|
|
16
|
+
let proxy = null
|
|
17
|
+
let telemetry = null
|
|
18
|
+
let history = null
|
|
19
|
+
let statusTimer = null
|
|
20
|
+
let accountStatus = null
|
|
21
|
+
|
|
22
|
+
const plugin = {
|
|
23
|
+
id: 'sailkick-boat',
|
|
24
|
+
name: 'Sailkick boat companion',
|
|
25
|
+
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.'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
plugin.schema = {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
account: {
|
|
34
|
+
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.',
|
|
37
|
+
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' }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
sync: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
title: 'Telemetry sync → InfluxDB',
|
|
46
|
+
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 }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
proxy: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
title: 'Sailkick caching proxy',
|
|
65
|
+
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
|
+
}
|
|
101
|
+
},
|
|
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
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
plugin.start = function (options) {
|
|
141
|
+
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))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function startModules (opts) {
|
|
161
|
+
// --- 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) {
|
|
164
|
+
(app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
|
|
165
|
+
sync = null
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- 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) {
|
|
173
|
+
try {
|
|
174
|
+
telemetry = createTelemetry(app, {})
|
|
175
|
+
telemetry.start()
|
|
176
|
+
pOpts.telemetryUpgrade = (req, s, h) => telemetry.handleUpgrade(req, s, h)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
(app.error || console.error)('[sailkick-boat] telemetry start failed: ' + e.message)
|
|
179
|
+
telemetry = null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!pOpts.history || pOpts.history.enabled !== false) {
|
|
183
|
+
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 })
|
|
188
|
+
history.start()
|
|
189
|
+
pOpts.history = history // proxy dispatches /api/history to it when available()
|
|
190
|
+
} catch (e) {
|
|
191
|
+
(app.error || console.error)('[sailkick-boat] history start failed: ' + e.message)
|
|
192
|
+
history = null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try { proxy = createProxy(app, pOpts); proxy.start() } catch (e) {
|
|
196
|
+
(app.error || console.error)('[sailkick-boat] proxy start failed: ' + e.message)
|
|
197
|
+
proxy = null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
statusTimer = setInterval(updateStatus, 5000)
|
|
202
|
+
updateStatus()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function updateStatus () {
|
|
206
|
+
if (!app.setPluginStatus) return
|
|
207
|
+
const parts = []
|
|
208
|
+
if (accountStatus) parts.push(accountStatus)
|
|
209
|
+
if (sync) parts.push(sync.status())
|
|
210
|
+
if (proxy) parts.push(proxy.status())
|
|
211
|
+
if (telemetry) parts.push(telemetry.status())
|
|
212
|
+
if (history) parts.push(history.status())
|
|
213
|
+
try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
plugin.stop = function () {
|
|
217
|
+
if (statusTimer) clearInterval(statusTimer)
|
|
218
|
+
statusTimer = null
|
|
219
|
+
try { if (sync) sync.stop() } catch {}
|
|
220
|
+
try { if (telemetry) telemetry.stop() } catch {}
|
|
221
|
+
try { if (history) history.stop() } catch {}
|
|
222
|
+
try { if (proxy) proxy.stop() } catch {}
|
|
223
|
+
sync = null
|
|
224
|
+
telemetry = null
|
|
225
|
+
history = null
|
|
226
|
+
proxy = null
|
|
227
|
+
accountStatus = null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
|
|
231
|
+
// proxy module (created in start), so enable/disable works at request time.
|
|
232
|
+
plugin.registerWithRouter = function (router) {
|
|
233
|
+
router.get('/p/*', (req, res) => {
|
|
234
|
+
if (proxy) proxy.handleGet(req, res)
|
|
235
|
+
else res.status(503).send('proxy not enabled')
|
|
236
|
+
})
|
|
237
|
+
router.post('/prefetch', (req, res) => {
|
|
238
|
+
if (proxy) proxy.handlePrefetch(req, res)
|
|
239
|
+
else res.status(503).send('proxy not enabled')
|
|
240
|
+
})
|
|
241
|
+
router.post('/prefetch/region', (req, res) => {
|
|
242
|
+
if (proxy) proxy.handlePrefetchRegion(req, res)
|
|
243
|
+
else res.status(503).send('proxy not enabled')
|
|
244
|
+
})
|
|
245
|
+
router.post('/cache/clear', (req, res) => {
|
|
246
|
+
if (proxy) proxy.handleClear(req, res)
|
|
247
|
+
else res.status(503).send('proxy not enabled')
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return plugin
|
|
252
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict'
|
|
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.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs')
|
|
10
|
+
const path = require('path')
|
|
11
|
+
|
|
12
|
+
function accountConfigured (account) {
|
|
13
|
+
return !!(account && account.sailkickUrl && account.slug && account.password)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cacheFileFor (app) {
|
|
17
|
+
const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
|
|
18
|
+
return path.join(dataDir, 'account.json')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readCache (file) {
|
|
22
|
+
try {
|
|
23
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
24
|
+
return (j && j.writeToken) ? j : null
|
|
25
|
+
} catch { return null }
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
}
|
|
36
|
+
|
|
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
|
|
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
|
+
} catch (e) {
|
|
61
|
+
return fallback(e.message) // offline / unreachable → use the cached bundle
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { resolveAccountConfig, accountConfigured }
|