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 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 }