sailkick-boat 0.27.0 → 0.30.0
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 +85 -1
- package/index.js +14 -0
- package/lib/history/index.js +24 -4
- package/lib/history/ring.js +181 -36
- package/lib/proxy/cache.js +36 -2
- package/lib/proxy/index.js +27 -0
- package/lib/sails/index.js +86 -0
- package/lib/sails/sails.js +180 -0
- package/lib/telemetry/contract.js +2 -2
- package/lib/telemetry/signalk-map.js +16 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -188,6 +188,21 @@ POST /plugins/sailkick-boat/cache/clear?prefix=tiles/seamap # nuke one ti
|
|
|
188
188
|
when clearing — the default keep already does. A hand-typed `find` clear should add
|
|
189
189
|
`! -name history` alongside `! -name tiles ! -name terrain`.)
|
|
190
190
|
|
|
191
|
+
**A pinned file that was cached wrong stays wrong.** Tiles have no expiry, which is the
|
|
192
|
+
whole point — but it means a bug in the *transport* outlives its fix. The pre-0.23.9
|
|
193
|
+
client stored still-gzipped bytes as if they were the payload (core `http` hands back the
|
|
194
|
+
compressed stream where `fetch` decoded it), and Cesium read the gzip header as a vertex
|
|
195
|
+
count: `Invalid typed array length: 11239580910`. Fixing the transport fixed new fetches
|
|
196
|
+
and nothing else: 2,391 terrain tiles and 188 vector tiles kept throwing for weeks after,
|
|
197
|
+
because nothing ever re-examined a stored file.
|
|
198
|
+
|
|
199
|
+
So a cache HIT is now checked — two bytes of a buffer already read. A file that claims to
|
|
200
|
+
be quantized-mesh or protobuf and begins `1f 8b` is dropped and re-fetched (`REPAIRED`);
|
|
201
|
+
if the upstream is unreachable the request FAILS rather than serving the poison again,
|
|
202
|
+
because a renderer handed a gzip header errors hard while a missing tile simply falls back
|
|
203
|
+
to its parent. Content that is legitimately gzip — a `.gz` asset, a gzip content-type — is
|
|
204
|
+
left alone: that is a payload, not an encoding.
|
|
205
|
+
|
|
191
206
|
## Offline map coverage — global base seed + region prefetch
|
|
192
207
|
On-demand caching only holds what you browsed. To make a usable map exist offline
|
|
193
208
|
*everywhere*, the plugin seeds a worldwide low-zoom base on start and lets you warm a
|
|
@@ -348,6 +363,46 @@ at all. The raw channels are always recorded regardless, so the cloud can recomp
|
|
|
348
363
|
history if the maths ever changes: the recorded channel is a materialisation, not the only
|
|
349
364
|
truth.
|
|
350
365
|
|
|
366
|
+
## Sails — the boat is the only writer
|
|
367
|
+
|
|
368
|
+
Sailkick records every instrument value but has no idea which sails are set, so the polar
|
|
369
|
+
estimator averages a full-main-and-genoa curve together with a three-reefs-and-staysail
|
|
370
|
+
one. Recording the plan as a time series is what will let it tell them apart.
|
|
371
|
+
|
|
372
|
+
`POST /api/sails {"plan":"genoa:0+main:2"}` publishes an ordinary SignalK delta on
|
|
373
|
+
`sails.plan`, and the gapless spool carries it to the cloud like any other value. The
|
|
374
|
+
**cloud refuses the same request** (`501 sail-write-not-here`), deliberately: one writer
|
|
375
|
+
keeps `<id>_raw` a faithful mirror of the boat's own SignalK, the write is offline-correct
|
|
376
|
+
for free — sail changes happen at sea, which is exactly when the cloud is unreachable —
|
|
377
|
+
and no Influx *write* token has to live in the cloud beside the password hashes.
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
genoa:0+main:0 full main and full genoa
|
|
381
|
+
main:2+staysail:0 two reefs, staysail, no headsail
|
|
382
|
+
genoa:2+stormjib:0 genoa furled two steps AND the storm jib set
|
|
383
|
+
bare nothing up
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`<id>:<reefs>` per **set** sail, joined by `+`, **sorted by id**. Any combination is
|
|
387
|
+
expressible, which is the point — a cutter flies genoa and staysail together, heavy
|
|
388
|
+
weather means a partly-furled genoa *and* the storm jib, downwind means twin headsails. A
|
|
389
|
+
fixed slot per station cannot say any of that.
|
|
390
|
+
|
|
391
|
+
**Sorting is the contract**, so the write door validates by round-tripping through the
|
|
392
|
+
vendored encoder (`shared/engine/sails.js`, pinned by hash) and **refuses** anything
|
|
393
|
+
non-canonical rather than fixing it up. `main:2+genoa:0` names the right sails and hashes
|
|
394
|
+
differently from `genoa:0+main:2`; accepting it would split the polar cloud this feature
|
|
395
|
+
exists to unify — silently, and visible only much later as a mysteriously noisy polar. A
|
|
396
|
+
client sending it is using its own encoder, which is the actual bug.
|
|
397
|
+
|
|
398
|
+
`bare` rather than an empty string distinguishes "the crew says nothing is up" from "we
|
|
399
|
+
have no sail data", which is null. Under bare poles in a survival storm that difference is
|
|
400
|
+
real.
|
|
401
|
+
|
|
402
|
+
`/api/config` gains `sailPlanWritable: true` — a **capability**, not a deployment test:
|
|
403
|
+
the dev box runs the cloud server on a LAN and self-hosters run it as their edge, so "am I
|
|
404
|
+
the cloud?" is the wrong question. The screen reads only the capability.
|
|
405
|
+
|
|
351
406
|
## Alerts and alarms, evaluated on board
|
|
352
407
|
|
|
353
408
|
Rules — anchor drag, wind over or under a threshold, a big wind shift, boat speed below
|
|
@@ -712,15 +767,44 @@ cloud, in-memory ring on a DB-less edge*. The boat is a third case — an edge t
|
|
|
712
767
|
serves the app's history endpoints from its **own** live data:
|
|
713
768
|
```
|
|
714
769
|
GET /api/history/series?window=3600s&every=30s -> { series: { sog|heading|tws|… : [[tMs,val],…] } }
|
|
770
|
+
GET /api/history/series?…&stats=1&chans=sog,aws -> { series: {…}, bands: { sog: [[t,min,max],…] } }
|
|
715
771
|
GET /api/history/track?window=3600s&every=10s -> { track: [{ t, lat, lon }, …] }
|
|
716
772
|
GET /api/history/track?from=<epochMs>&to=<epochMs> (absolute range; ISO also accepted)
|
|
717
773
|
```
|
|
774
|
+
**Gusts: `stats=1` adds true min/max bands under the mean.** A mean line hides the thing
|
|
775
|
+
you actually want to see — the app measured an hour of real sailing at 20 s buckets where
|
|
776
|
+
the mean spanned 4.8 kt and the true envelope spanned 8.3 kt, with 1.87 kt of spread
|
|
777
|
+
hidden inside an average bucket. That spread only exists if it is *recorded*: the ring
|
|
778
|
+
polls BoatState every second into a per-channel `{sum, cnt, lo, hi}` accumulator and emits
|
|
779
|
+
one row per sample interval carrying the mean plus the true extremes seen inside it. It
|
|
780
|
+
used to snapshot instead, throwing away 14 of every 15 readings before anything could ask
|
|
781
|
+
a question about them — no later bucketing, on the boat or in the browser, can bring those
|
|
782
|
+
back.
|
|
783
|
+
|
|
784
|
+
A useful side-effect: the auto-coarsening that keeps the ring under `MAX_SAMPLES` is no
|
|
785
|
+
longer lossy. Only the *emit* rate coarsens, never the poll, so a 30-day passage emitting
|
|
786
|
+
every ~52 s still carries the true min/max within each 52 s.
|
|
787
|
+
|
|
788
|
+
**Compass channels never get a band** — `twd`, `twa`, `awa`, `cog`, `heading`, `wptBrg`.
|
|
789
|
+
The mean of 359° and 1° is 180°, the exact opposite of the truth, so those carry a
|
|
790
|
+
last-reading snapshot and no band, ever. It is the one error here that would look entirely
|
|
791
|
+
plausible on screen, so the tests pin it.
|
|
792
|
+
|
|
793
|
+
`chans=sog,aws` narrows the answer to the channels actually plotted; `bands` appears only
|
|
794
|
+
when `stats=1` was asked *and* the provider produced them, so every client degrades to the
|
|
795
|
+
plain line. `series` is unchanged with or without either param.
|
|
796
|
+
|
|
718
797
|
Both endpoints take **either** a trailing `window`, **or** an absolute `from`/`to` —
|
|
719
798
|
which is what the app sends whenever the view is scrolled back in time (the historic
|
|
720
799
|
trail, and a Trends flyout on a past period). The response echoes the `from`/`to` it
|
|
721
800
|
actually served. Before 0.24.0 the boat parsed only `window`, so a request for a past
|
|
722
801
|
hour came back `200` with the **most recent** hour: the historic trail silently showed
|
|
723
|
-
live data. `every` thins a long track and always keeps the first and newest fix
|
|
802
|
+
live data. `every` thins a long track and always keeps the first and newest fix; on
|
|
803
|
+
`series` it re-buckets (means weighted by the sample count behind each row, extremes as
|
|
804
|
+
min-of-mins), labelling each bucket at its **end** to match the cloud's
|
|
805
|
+
`aggregateWindow(timeSrc: "_stop")` — label them at the start and the two providers plot
|
|
806
|
+
half a bucket apart on the same screen. It is floored so one answer stays under ~3k
|
|
807
|
+
points, as the cloud route does.
|
|
724
808
|
|
|
725
809
|
Same JSON the cloud returns, so the browser can't tell the difference — but it
|
|
726
810
|
works **offline** with the boat's own data. Only when no telemetry source is
|
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const { createAisTargets } = require('./lib/ais/targets')
|
|
|
12
12
|
const { createProfile } = require('./lib/profile')
|
|
13
13
|
const { createPerf } = require('./lib/perf')
|
|
14
14
|
const { createAlerts } = require('./lib/alerts')
|
|
15
|
+
const { createSails } = require('./lib/sails')
|
|
15
16
|
const { createCloud } = require('./lib/cloud')
|
|
16
17
|
const { resolveAccountConfig } = require('./lib/account')
|
|
17
18
|
|
|
@@ -109,6 +110,7 @@ module.exports = function (app) {
|
|
|
109
110
|
let cloud = null
|
|
110
111
|
let perf = null
|
|
111
112
|
let alerts = null
|
|
113
|
+
let sails = null
|
|
112
114
|
let proxyPort = null // what the launcher page needs to build its links
|
|
113
115
|
let pairedSlug = null
|
|
114
116
|
let statusTimer = null
|
|
@@ -425,6 +427,16 @@ module.exports = function (app) {
|
|
|
425
427
|
}
|
|
426
428
|
}
|
|
427
429
|
|
|
430
|
+
// The sail-plan write door. No config toggle: it is inert until the crew posts a
|
|
431
|
+
// plan, and a boat that cannot record its sails is the status quo this fixes.
|
|
432
|
+
try {
|
|
433
|
+
sails = createSails(app, { pluginId: plugin.id })
|
|
434
|
+
pOpts.sails = sails // proxy dispatches POST /api/sails and claims sailPlanWritable
|
|
435
|
+
} catch (e) {
|
|
436
|
+
(app.error || console.error)('[sailkick-boat] sails start failed: ' + e.message)
|
|
437
|
+
sails = null
|
|
438
|
+
}
|
|
439
|
+
|
|
428
440
|
if (pOpts.history.enabled !== false) {
|
|
429
441
|
try {
|
|
430
442
|
// ringSource = the telemetry module: when no local InfluxDB token is set
|
|
@@ -530,6 +542,7 @@ module.exports = function (app) {
|
|
|
530
542
|
if (profile) parts.push(profile.status())
|
|
531
543
|
if (perf) parts.push(perf.status())
|
|
532
544
|
if (alerts) parts.push(alerts.status())
|
|
545
|
+
if (sails) parts.push(sails.status())
|
|
533
546
|
if (ais) parts.push(ais.status())
|
|
534
547
|
if (backfill) parts.push(backfill.status())
|
|
535
548
|
try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
|
|
@@ -559,6 +572,7 @@ module.exports = function (app) {
|
|
|
559
572
|
cloud = null
|
|
560
573
|
perf = null
|
|
561
574
|
alerts = null
|
|
575
|
+
sails = null
|
|
562
576
|
proxyPort = null
|
|
563
577
|
pairedSlug = null
|
|
564
578
|
proxy = null
|
package/lib/history/index.js
CHANGED
|
@@ -26,6 +26,10 @@ const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x }
|
|
|
26
26
|
|
|
27
27
|
// Parse a duration like "1h" / "30m" / "600s" / "3600" → seconds, clamped.
|
|
28
28
|
// Ported from the app's server/routes/history.js so limits match exactly.
|
|
29
|
+
// One response has to stay drawable and sendable over a satellite link; the cloud route
|
|
30
|
+
// uses the same ceiling.
|
|
31
|
+
const MAX_POINTS = 3000
|
|
32
|
+
|
|
29
33
|
function dur (s, def, min, max) {
|
|
30
34
|
if (s == null) return def
|
|
31
35
|
const m = String(s).trim().match(/^(\d+)\s*([smh]?)$/)
|
|
@@ -127,12 +131,24 @@ function createHistory (app, options) {
|
|
|
127
131
|
if (!available()) return sendJson(res, 503, { ok: false, code: 'history-unavailable', message: 'history not available' })
|
|
128
132
|
const q = query(req.url)
|
|
129
133
|
const windowSec = dur(q.get('window'), 3600, 60, 86400)
|
|
130
|
-
const everySec = dur(q.get('every'), 30, 5, 600)
|
|
131
134
|
const abs = absRange(q)
|
|
135
|
+
// Floor the bucket so one answer stays drawable. An absolute range can be a month
|
|
136
|
+
// wide, and at the default 30 s that is 86,400 points down a link that may be
|
|
137
|
+
// Starlink on a bad night — the cloud route floors it the same way, so both providers
|
|
138
|
+
// return the same shape for the same request.
|
|
139
|
+
const spanSec = abs ? Math.max(1, Math.round((abs.toMs - abs.fromMs) / 1000)) : windowSec
|
|
140
|
+
const everySec = Math.max(dur(q.get('every'), 30, 5, 600), Math.ceil(spanSec / MAX_POINTS))
|
|
141
|
+
// Both additive and both ignorable, so a client that knows nothing of them is served
|
|
142
|
+
// exactly what it was served before. `stats=1` asks for the true per-bucket min/max
|
|
143
|
+
// under the mean — the gusts, which a mean line hides: on an hour of real sailing the
|
|
144
|
+
// mean spanned 4.8 kt where the true envelope spanned 8.3 kt. `chans=sog,aws` narrows
|
|
145
|
+
// the answer to what is actually plotted.
|
|
146
|
+
const stats = /^(1|true|yes)$/i.test(String(q.get('stats') || ''))
|
|
147
|
+
const chans = String(q.get('chans') || '').split(',').map((c) => c.trim()).filter(Boolean)
|
|
132
148
|
try {
|
|
133
|
-
const r = await provider.getSeries({ windowSec, everySec, ...(abs || {}), signal: abortOnClose(res) })
|
|
149
|
+
const r = await provider.getSeries({ windowSec, everySec, stats, chans, ...(abs || {}), signal: abortOnClose(res) })
|
|
134
150
|
if (!r.ok) return sendJson(res, r.status || 502, { ok: false, code: 'history-error', message: r.message })
|
|
135
|
-
|
|
151
|
+
const body = {
|
|
136
152
|
ok: true,
|
|
137
153
|
windowSec,
|
|
138
154
|
everySec,
|
|
@@ -140,7 +156,11 @@ function createHistory (app, options) {
|
|
|
140
156
|
from: abs ? abs.fromMs : Date.now() - windowSec * 1000,
|
|
141
157
|
to: abs ? abs.toMs : Date.now(),
|
|
142
158
|
series: r.series
|
|
143
|
-
}
|
|
159
|
+
}
|
|
160
|
+
// Only when asked AND produced: a client that sent stats=1 must still degrade to
|
|
161
|
+
// the plain line rather than wait for a key that never comes.
|
|
162
|
+
if (stats && r.bands) body.bands = r.bands
|
|
163
|
+
sendJson(res, 200, body)
|
|
144
164
|
} catch (e) {
|
|
145
165
|
sendJson(res, 502, { ok: false, code: 'history-error', message: e.message })
|
|
146
166
|
}
|
package/lib/history/ring.js
CHANGED
|
@@ -6,15 +6,28 @@
|
|
|
6
6
|
// series/track contract as the InfluxDB provider; TWS/TWD are derived from apparent
|
|
7
7
|
// wind + boat motion (as the ribbon does), STW is simply absent.
|
|
8
8
|
//
|
|
9
|
+
// Each emitted row carries, per linear channel, the MEAN over the interval plus the true
|
|
10
|
+
// min/max seen inside it (`lo`/`hi`) and the sample count behind the mean (`n`) — see the
|
|
11
|
+
// two-clocks note in the constructor. Compass channels carry a last-reading snapshot and
|
|
12
|
+
// none of the three, because a bearing has no arithmetic mean.
|
|
13
|
+
//
|
|
9
14
|
// Persistence (optional, `persistFile`): a JSONL APPEND-LOG so the ring survives
|
|
10
|
-
// restarts and can cover a long passage cheaply. Each
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// restarts and can cover a long passage cheaply. Each row is appended as one line; the
|
|
16
|
+
// file is compacted (atomic rewrite to the current window) only rarely — on start, once
|
|
17
|
+
// appends exceed the ring length, and on destroy — so write load is per-sample,
|
|
18
|
+
// decoupled from window size. Measured on a fully-populated row (every channel present):
|
|
19
|
+
// 307 B before the bands, 844 B with them, i.e. 2.7x. A 30-day passage is ~49.8k rows
|
|
20
|
+
// (the emit rate auto-coarsens to ~52 s, see below), so ~40 MB of appends plus ~2x that
|
|
21
|
+
// again in compaction rewrites — against ~600 GB for a full-rewrite-every-2min snapshot,
|
|
22
|
+
// and still nothing on a boat SSD.
|
|
23
|
+
//
|
|
24
|
+
// To keep RAM/disk bounded at any window, the EMIT rate auto-coarsens so the ring never
|
|
25
|
+
// exceeds ~MAX_SAMPLES rows (24 h → 15 s, 30 d → ~52 s). That coarsening used to throw
|
|
26
|
+
// readings away; it no longer does. Only the emit rate coarsens, never the poll, so a
|
|
27
|
+
// 30-day passage still carries the true min/max within each ~52 s bucket.
|
|
28
|
+
//
|
|
29
|
+
// All fs is guarded — a missing/corrupt file just means an empty start; appends +
|
|
30
|
+
// compactions are serialized so they can't interleave.
|
|
18
31
|
|
|
19
32
|
const fs = require('fs')
|
|
20
33
|
const fsp = fs.promises
|
|
@@ -43,7 +56,18 @@ const CHANNELS = [
|
|
|
43
56
|
// drawn over it.
|
|
44
57
|
'perf'
|
|
45
58
|
]
|
|
59
|
+
// The channels whose values WRAP. A compass bearing cannot be averaged or min/max'd
|
|
60
|
+
// arithmetically — the mean of 359° and 1° is 180°, the exact opposite of the truth — so
|
|
61
|
+
// these keep a plain snapshot of the LAST reading in the interval and never carry a band.
|
|
62
|
+
// Everything else is linear and gets both. Same split as the app's ring provider; a
|
|
63
|
+
// mistake here would look entirely plausible on screen, which is why the test pins it.
|
|
64
|
+
const WRAPPED = new Set(['twd', 'twa', 'awa', 'cog', 'heading', 'wptBrg'])
|
|
65
|
+
|
|
46
66
|
const wrap360 = (d) => ((d % 360) + 360) % 360
|
|
67
|
+
// Stored values are rounded to 3 decimals. Rows now carry a mean plus two extremes per
|
|
68
|
+
// channel, so full float noise ("6.430000000000001") would inflate every persisted line
|
|
69
|
+
// for precision no instrument has and no chart can draw.
|
|
70
|
+
const round3 = (v) => (Number.isFinite(v) ? Math.round(v * 1000) / 1000 : v)
|
|
47
71
|
const MAX_SAMPLES = 50000
|
|
48
72
|
|
|
49
73
|
// True wind (TWS kt, TWD ° the wind blows FROM).
|
|
@@ -71,10 +95,10 @@ function trueWind (s) {
|
|
|
71
95
|
}
|
|
72
96
|
|
|
73
97
|
class RingHistoryProvider {
|
|
74
|
-
constructor ({ source, perfSource, sampleSec, windowSec, persistFile } = {}) {
|
|
98
|
+
constructor ({ source, perfSource, sampleSec, pollSec, windowSec, persistFile } = {}) {
|
|
75
99
|
this._source = source
|
|
76
100
|
this._perfSource = perfSource || null
|
|
77
|
-
this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
|
|
101
|
+
this._ring = [] // [{ t, ...CHANNELS, lo:{}, hi:{}, n:{}, lat, lon }]
|
|
78
102
|
const win = windowSec || 3600
|
|
79
103
|
this._windowMs = win * 1000
|
|
80
104
|
this._persistFile = persistFile || null
|
|
@@ -83,45 +107,112 @@ class RingHistoryProvider {
|
|
|
83
107
|
// auto-coarsen so the ring is bounded (~MAX_SAMPLES rows) regardless of window
|
|
84
108
|
const stepSec = Math.max(sampleSec || 15, Math.ceil(win / MAX_SAMPLES))
|
|
85
109
|
this._stepSec = stepSec
|
|
110
|
+
// TWO CLOCKS. This used to SNAPSHOT BoatState once per stepSec and store point values,
|
|
111
|
+
// which threw away 14 of every 15 readings BEFORE anything could ask a question about
|
|
112
|
+
// them: the gusts were gone before a chart ever saw the data, and no amount of later
|
|
113
|
+
// bucketing could bring them back. Now the state is POLLED at pollSec (~1 s, the rate
|
|
114
|
+
// the boat publishes) into a per-channel sum/count/min/max accumulator, and one row is
|
|
115
|
+
// EMITTED every stepSec carrying the mean plus the true extremes seen inside it.
|
|
116
|
+
//
|
|
117
|
+
// Ring length and emit rate are unchanged, so every existing budget still holds — and
|
|
118
|
+
// MAX_SAMPLES coarsening stops being lossy: a 30-day passage emitting every ~52 s
|
|
119
|
+
// still carries the true min/max within each 52 s, because the poll never coarsened.
|
|
120
|
+
this._pollSec = Math.min(stepSec, Math.max(0.2, pollSec || 1))
|
|
121
|
+
this._resetAcc()
|
|
86
122
|
|
|
87
123
|
this._load() // seed from disk (survives restart)
|
|
88
124
|
this._compactSync() // rewrite clean + bounded, synchronously (file exists right at start)
|
|
89
|
-
this._sample() // immediate first
|
|
90
|
-
this.
|
|
125
|
+
this._sample() // immediate first row, so a fresh process is not blank for stepSec
|
|
126
|
+
this._pollTimer = setInterval(() => this._poll(), this._pollSec * 1000)
|
|
127
|
+
this._timer = setInterval(() => this._emit(), stepSec * 1000)
|
|
91
128
|
if (this._timer.unref) this._timer.unref()
|
|
129
|
+
if (this._pollTimer.unref) this._pollTimer.unref()
|
|
92
130
|
}
|
|
93
131
|
|
|
94
132
|
// --- sampling ---
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
133
|
+
_resetAcc () {
|
|
134
|
+
// polls counts readings seen since the last emit. Zero means the telemetry source
|
|
135
|
+
// gave us nothing at all, and we push no row — a gap is the honest record, where a
|
|
136
|
+
// row of nulls would be a claim that we looked and the boat had no data.
|
|
137
|
+
this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, lat: null, lon: null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// BoatState -> the flat channel map this ring records. Kept separate from the
|
|
141
|
+
// accumulator so poll and emit share one definition of where each channel comes from.
|
|
142
|
+
_raw (s) {
|
|
98
143
|
const tw = trueWind(s)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
sog: num(s.sogKt), cog: num(s.cogDeg), heading: num(s.headingDeg),
|
|
103
|
-
stw: num(s.stwKt),
|
|
104
|
-
aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
|
|
144
|
+
return {
|
|
145
|
+
sog: s.sogKt, cog: s.cogDeg, heading: s.headingDeg, stw: s.stwKt,
|
|
146
|
+
aws: s.awsKt, awa: s.awaDeg, depth: s.depthM,
|
|
105
147
|
tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
|
|
106
148
|
// Everything below arrived in BoatState with v0.18.6 but was never sampled here,
|
|
107
149
|
// so these instrument cells had a live value and an empty history flyout.
|
|
108
|
-
twa:
|
|
109
|
-
seaTemp:
|
|
110
|
-
rpmPort:
|
|
111
|
-
// Waypoint channels are legitimately null when no destination is active —
|
|
112
|
-
//
|
|
113
|
-
// rather than showing a flat line at zero.
|
|
114
|
-
wptBrg:
|
|
115
|
-
wptVmg:
|
|
116
|
-
perf: this._perfSource ?
|
|
117
|
-
lat: num(s.lat), lon: num(s.lon)
|
|
150
|
+
twa: s.twaDeg, vmg: s.vmgKt,
|
|
151
|
+
seaTemp: s.seaTempC, airTemp: s.airTempC,
|
|
152
|
+
rpmPort: s.rpmPort, rpmStbd: s.rpmStbd,
|
|
153
|
+
// Waypoint channels are legitimately null when no destination is active — the
|
|
154
|
+
// accumulator skips non-finite values and CHANNELS omits empty ones, so the flyout
|
|
155
|
+
// stays blank rather than showing a flat line at zero.
|
|
156
|
+
wptBrg: s.wptBrgDeg, wptDist: s.wptDistNm,
|
|
157
|
+
wptVmg: s.wptVmgKt, wptTtg: s.wptTtgSec,
|
|
158
|
+
perf: this._perfSource ? this._perfSource.getPerf() : null
|
|
118
159
|
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_poll () {
|
|
163
|
+
const s = this._source && this._source.getState && this._source.getState()
|
|
164
|
+
if (!s) return
|
|
165
|
+
const raw = this._raw(s)
|
|
166
|
+
const a = this._acc
|
|
167
|
+
a.polls++
|
|
168
|
+
for (const c of CHANNELS) {
|
|
169
|
+
const v = raw[c]
|
|
170
|
+
if (!Number.isFinite(v)) continue
|
|
171
|
+
a.last[c] = v
|
|
172
|
+
if (WRAPPED.has(c)) continue // a bearing has no meaningful mean or extreme
|
|
173
|
+
a.sum[c] = (a.sum[c] || 0) + v
|
|
174
|
+
a.cnt[c] = (a.cnt[c] || 0) + 1
|
|
175
|
+
a.lo[c] = Math.min(a.lo[c] == null ? v : a.lo[c], v)
|
|
176
|
+
a.hi[c] = Math.max(a.hi[c] == null ? v : a.hi[c], v)
|
|
177
|
+
}
|
|
178
|
+
if (Number.isFinite(s.lat)) a.lat = s.lat
|
|
179
|
+
if (Number.isFinite(s.lon)) a.lon = s.lon
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// One row per stepSec: the mean of everything seen since the last emit, plus the true
|
|
183
|
+
// extremes (`lo`/`hi`) and the sample count behind each mean (`n`, needed to weight a
|
|
184
|
+
// later re-bucket). Wrapped channels carry the last reading and appear in none of the
|
|
185
|
+
// three.
|
|
186
|
+
_emit () {
|
|
187
|
+
const a = this._acc
|
|
188
|
+
if (!a.polls) return
|
|
189
|
+
const row = { t: Date.now(), lo: {}, hi: {}, n: {} }
|
|
190
|
+
for (const c of CHANNELS) {
|
|
191
|
+
if (WRAPPED.has(c)) { row[c] = a.last[c] == null ? null : round3(a.last[c]); continue }
|
|
192
|
+
const n = a.cnt[c] || 0
|
|
193
|
+
if (!n) { row[c] = null; continue }
|
|
194
|
+
row[c] = round3(a.sum[c] / n)
|
|
195
|
+
row.lo[c] = round3(a.lo[c])
|
|
196
|
+
row.hi[c] = round3(a.hi[c])
|
|
197
|
+
row.n[c] = n
|
|
198
|
+
}
|
|
199
|
+
row.lat = a.lat == null ? null : a.lat
|
|
200
|
+
row.lon = a.lon == null ? null : a.lon
|
|
201
|
+
this._resetAcc()
|
|
202
|
+
|
|
119
203
|
this._ring.push(row)
|
|
120
204
|
const cutoff = Date.now() - this._windowMs
|
|
121
205
|
while (this._ring.length && this._ring[0].t < cutoff) this._ring.shift()
|
|
122
206
|
this._append(row)
|
|
123
207
|
}
|
|
124
208
|
|
|
209
|
+
// One poll + one emit, i.e. the old single-shot behaviour. Used for the first row at
|
|
210
|
+
// construction and by the tests, which drive the clocks by hand.
|
|
211
|
+
_sample () {
|
|
212
|
+
this._poll()
|
|
213
|
+
this._emit()
|
|
214
|
+
}
|
|
215
|
+
|
|
125
216
|
available () { return true }
|
|
126
217
|
|
|
127
218
|
// A trailing window, or an ABSOLUTE range when the caller passes fromMs/toMs — which
|
|
@@ -137,14 +228,67 @@ class RingHistoryProvider {
|
|
|
137
228
|
return this._ring.filter((r) => r.t >= cutoff)
|
|
138
229
|
}
|
|
139
230
|
|
|
140
|
-
|
|
231
|
+
// `series` is the mean line, exactly as before. Two additive extras, both ignorable:
|
|
232
|
+
// stats -> `bands`, the true per-bucket [t, min, max] under the mean;
|
|
233
|
+
// chans -> narrow the answer to the channels actually plotted.
|
|
234
|
+
//
|
|
235
|
+
// `everySec` is now HONOURED. It used to be ignored outright, so the history sheet's
|
|
236
|
+
// 24 h pill got raw sample-rate points whatever the pill said. Rows are re-bucketed
|
|
237
|
+
// into everySec windows: means weighted by the sample count behind each row, extremes
|
|
238
|
+
// as min-of-mins / max-of-maxes, wrapped channels taking the last reading. Buckets are
|
|
239
|
+
// labelled at their END, matching the cloud's aggregateWindow(timeSrc: "_stop") — label
|
|
240
|
+
// them at the start and the two providers plot half a bucket apart on the same screen.
|
|
241
|
+
getSeries ({ windowSec, everySec, fromMs, toMs, stats, chans } = {}) {
|
|
141
242
|
const rows = this._window(windowSec, fromMs, toMs)
|
|
243
|
+
const want = chans && chans.length ? new Set(chans) : null
|
|
244
|
+
const step = Math.max(0, Math.round(everySec || 0)) * 1000
|
|
142
245
|
const series = {}
|
|
246
|
+
const bands = {}
|
|
143
247
|
for (const c of CHANNELS) {
|
|
144
|
-
|
|
145
|
-
|
|
248
|
+
if (want && !want.has(c)) continue
|
|
249
|
+
const wrapped = WRAPPED.has(c)
|
|
250
|
+
// With no `every`, every row is its own point — which is what this endpoint has
|
|
251
|
+
// always returned, and the contract says `series` is unchanged with or without the
|
|
252
|
+
// new params. (Bucketing by r.t instead would merge two rows sharing a millisecond,
|
|
253
|
+
// which never happens in flight but does when a caller drives the clock by hand.)
|
|
254
|
+
const buckets = new Map()
|
|
255
|
+
let seq = 0
|
|
256
|
+
for (const r of rows) {
|
|
257
|
+
const v = r[c]
|
|
258
|
+
if (v == null) continue
|
|
259
|
+
const key = step ? Math.floor(r.t / step) * step : seq++
|
|
260
|
+
let b = buckets.get(key)
|
|
261
|
+
if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null }))
|
|
262
|
+
b.last = v
|
|
263
|
+
if (wrapped) continue
|
|
264
|
+
// Rows written before v0.29.0 carry no lo/hi/n. Reading them defensively means an
|
|
265
|
+
// append-log from an older version still loads and simply yields a degenerate
|
|
266
|
+
// band (the point value itself) rather than an empty chart or a crash.
|
|
267
|
+
const n = (r.n && r.n[c]) || 1
|
|
268
|
+
b.sum += v * n
|
|
269
|
+
b.n += n
|
|
270
|
+
const lo = (r.lo && r.lo[c] != null) ? r.lo[c] : v
|
|
271
|
+
const hi = (r.hi && r.hi[c] != null) ? r.hi[c] : v
|
|
272
|
+
if (lo < b.lo) b.lo = lo
|
|
273
|
+
if (hi > b.hi) b.hi = hi
|
|
274
|
+
}
|
|
275
|
+
const out = [...buckets.values()].sort((a, b) => a.t - b.t)
|
|
276
|
+
const pts = wrapped
|
|
277
|
+
? out.map((b) => [b.t, b.last])
|
|
278
|
+
: out.filter((b) => b.n > 0).map((b) => [b.t, round3(b.sum / b.n)])
|
|
279
|
+
if (!pts.length) continue
|
|
280
|
+
series[c] = pts
|
|
281
|
+
// No `|| wrapped` here: the bucket loop above never accumulates a wrapped channel,
|
|
282
|
+
// so `n` stays 0 and the filter below drops it anyway. Keeping the extra condition
|
|
283
|
+
// would be unreachable code that no test can pin — and an unpinnable guard is the
|
|
284
|
+
// kind that quietly stops matching the guard it duplicates.
|
|
285
|
+
if (!stats) continue
|
|
286
|
+
const band = out
|
|
287
|
+
.filter((b) => b.n > 0 && Number.isFinite(b.lo) && Number.isFinite(b.hi))
|
|
288
|
+
.map((b) => [b.t, b.lo, b.hi])
|
|
289
|
+
if (band.length) bands[c] = band
|
|
146
290
|
}
|
|
147
|
-
return { ok: true, series }
|
|
291
|
+
return stats ? { ok: true, series, bands } : { ok: true, series }
|
|
148
292
|
}
|
|
149
293
|
|
|
150
294
|
getTrack ({ windowSec, fromMs, toMs, everySec } = {}) {
|
|
@@ -224,8 +368,9 @@ class RingHistoryProvider {
|
|
|
224
368
|
|
|
225
369
|
destroy () {
|
|
226
370
|
clearInterval(this._timer)
|
|
371
|
+
clearInterval(this._pollTimer)
|
|
227
372
|
this._compact() // final flush of the current ring
|
|
228
373
|
}
|
|
229
374
|
}
|
|
230
375
|
|
|
231
|
-
module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS }
|
|
376
|
+
module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS, WRAPPED }
|
package/lib/proxy/cache.js
CHANGED
|
@@ -43,6 +43,29 @@ async function readFromDisk (file, meta) {
|
|
|
43
43
|
return { buffer, contentType }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Gzip bytes stored as if they were the payload. This is what the pre-0.23.9 transport
|
|
47
|
+
// did: core http hands back the still-compressed stream where fetch() decoded it, and the
|
|
48
|
+
// bytes were cached — and tiles are PINNED, so the corruption outlived the fix by weeks.
|
|
49
|
+
// Cesium read the gzip header as a vertex count: "Invalid typed array length:
|
|
50
|
+
// 11239580910". 2,391 terrain tiles and 188 vector tiles on this boat were still poisoned
|
|
51
|
+
// three days after the transport was fixed, because nothing ever re-examines a pinned
|
|
52
|
+
// file.
|
|
53
|
+
//
|
|
54
|
+
// So a HIT is checked, which costs two bytes of a buffer already in hand. A file that
|
|
55
|
+
// claims to be quantized-mesh or protobuf and begins 1f 8b is not that file; it is
|
|
56
|
+
// dropped and re-fetched. Content that is LEGITIMATELY gzip (a .gz asset, or a
|
|
57
|
+
// gzip content-type) is left alone — that is a payload, not an encoding.
|
|
58
|
+
function looksGzipped (buffer) {
|
|
59
|
+
return buffer && buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b
|
|
60
|
+
}
|
|
61
|
+
function gzipIsThePayload (contentType, reqPath) {
|
|
62
|
+
const ct = String(contentType || '').toLowerCase()
|
|
63
|
+
return ct.includes('gzip') || ct.includes('x-gzip') || /\.gz(\?|$)/i.test(String(reqPath || ''))
|
|
64
|
+
}
|
|
65
|
+
function isCorruptOnDisk (r, reqPath) {
|
|
66
|
+
return looksGzipped(r.buffer) && !gzipIsThePayload(r.contentType, reqPath)
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
// Fetch from upstream and persist atomically (bytes + content-type sidecar). The
|
|
47
70
|
// rename stamps the file mtime ~now, which is what freshness compares against.
|
|
48
71
|
async function fetchAndStore (url, file, meta, timeoutMs) {
|
|
@@ -112,7 +135,18 @@ async function getResource (opts) {
|
|
|
112
135
|
const stat = await fsp.stat(file).catch(() => null)
|
|
113
136
|
if (stat) {
|
|
114
137
|
const stale = invalidatedAt && stat.mtimeMs < invalidatedAt
|
|
115
|
-
if (!stale)
|
|
138
|
+
if (!stale) {
|
|
139
|
+
const hit = await serveDisk('HIT')
|
|
140
|
+
if (!isCorruptOnDisk(hit, reqPath)) return hit
|
|
141
|
+
// Left over from the transport that cached compressed bytes. Serving it again would
|
|
142
|
+
// reproduce the original error for ever, so drop it and fetch a clean copy. If that
|
|
143
|
+
// fails, FAIL: a renderer given a gzip header errors hard, where a missing tile
|
|
144
|
+
// simply falls back to its parent.
|
|
145
|
+
await fsp.unlink(file).catch(() => {})
|
|
146
|
+
await fsp.unlink(meta).catch(() => {})
|
|
147
|
+
if (downNow) throw offline('cached copy was corrupt (gzip) and the upstream is down')
|
|
148
|
+
try { return await tryFetch('REPAIRED') } catch (e) { noteFail(e); throw e }
|
|
149
|
+
}
|
|
116
150
|
// A newer bake was announced; the on-disk copy predates it.
|
|
117
151
|
if (downNow) return serveDisk('STALE')
|
|
118
152
|
try { return await tryFetch('UPDATED') } catch (e) { noteFail(e); return serveDisk('STALE') }
|
|
@@ -149,4 +183,4 @@ async function clearStore ({ storeDir, keep = [], prefix = '' } = {}) {
|
|
|
149
183
|
return { removed, mode: 'keep', kept: [...keepSet] }
|
|
150
184
|
}
|
|
151
185
|
|
|
152
|
-
module.exports = { getResource, storePaths, clearStore }
|
|
186
|
+
module.exports = { getResource, storePaths, clearStore, isCorruptOnDisk }
|
package/lib/proxy/index.js
CHANGED
|
@@ -97,6 +97,7 @@ function createProxy (app, options) {
|
|
|
97
97
|
aisTargets: options.aisTargets || null,
|
|
98
98
|
profile: options.profile || null,
|
|
99
99
|
alerts: options.alerts || null, // claims alertsEvaluatedHere in /api/config, see serveConfig
|
|
100
|
+
sails: options.sails || null, // the sail-plan write door; claims sailPlanWritable
|
|
100
101
|
boat: options.boat || null, // { perfKey, slug } — patched into /api/config, see serveConfig
|
|
101
102
|
openAccess: options.openAccess !== false
|
|
102
103
|
}
|
|
@@ -266,6 +267,24 @@ function createProxy (app, options) {
|
|
|
266
267
|
if (cfg.profile && cfg.profile.available() && cfg.profile.handles(req.url)) {
|
|
267
268
|
return cfg.profile.handle(req, res)
|
|
268
269
|
}
|
|
270
|
+
// Set which sails are up. The boat is the ONLY host that accepts this — the cloud
|
|
271
|
+
// answers 501 sail-write-not-here — because the plan reaches InfluxDB by being
|
|
272
|
+
// published as a SignalK delta here and spooled like every other value: one writer,
|
|
273
|
+
// offline-correct by construction, and no Influx write token in the cloud. Never
|
|
274
|
+
// proxied upstream, whatever the outcome.
|
|
275
|
+
if (req.method === 'POST' && cfg.sails && cfg.sails.available() &&
|
|
276
|
+
req.url.split('?')[0] === '/api/sails') {
|
|
277
|
+
return readJsonBody(req).then((body) => {
|
|
278
|
+
const r = cfg.sails.setPlan(body || {})
|
|
279
|
+
res.statusCode = r.ok ? 200 : (r.status || 400)
|
|
280
|
+
res.setHeader('Content-Type', 'application/json')
|
|
281
|
+
res.end(JSON.stringify(r.ok ? { ok: true, plan: r.plan, at: r.at } : { ok: false, code: r.code, message: r.message }))
|
|
282
|
+
}).catch((e) => {
|
|
283
|
+
res.statusCode = 500
|
|
284
|
+
res.setHeader('Content-Type', 'application/json')
|
|
285
|
+
res.end(JSON.stringify({ ok: false, code: 'sails-error', message: e.message }))
|
|
286
|
+
})
|
|
287
|
+
}
|
|
269
288
|
// /api/config drives the app's login gate + Trends toggle. On the single-tenant
|
|
270
289
|
// boat we serve it with auth turned off (no cloud login over the offline HTTP
|
|
271
290
|
// mirror) and history forced on when we serve it locally. All other config passes
|
|
@@ -332,6 +351,14 @@ function createProxy (app, options) {
|
|
|
332
351
|
// banner unless a host claims them — rules stored with nothing watching them is
|
|
333
352
|
// the failure it is warning about. This plugin IS that host, on board, so say so.
|
|
334
353
|
if (cfg.alerts && cfg.alerts.available()) j.alertsEvaluatedHere = true
|
|
354
|
+
// A CAPABILITY, not a deployment test: the sails screen is read-only unless a
|
|
355
|
+
// host says it can publish the delta. "Am I the cloud?" is the wrong question —
|
|
356
|
+
// the dev box runs the cloud server on a LAN and self-hosters run it as their
|
|
357
|
+
// edge, so a self-hosted single-boat cloud looks exactly like a boat otherwise.
|
|
358
|
+
if (cfg.sails && cfg.sails.available()) j.sailPlanWritable = true
|
|
359
|
+
// Display only. Nothing may branch on this — that is what the capability flags
|
|
360
|
+
// above are for; this exists so the UI can tell a human where data comes from.
|
|
361
|
+
j.deployment = 'boat'
|
|
335
362
|
// The cloud fills `boat` only for a logged-in session, and the mirror forwards no
|
|
336
363
|
// cookie — so it always arrived as null and the app had no identity. Harmless for
|
|
337
364
|
// most of the UI, but public/engine/polar-cloud.js keys the performance data cloud
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The sail plan write door — the one place a sail plan enters the system.
|
|
4
|
+
//
|
|
5
|
+
// Sailkick records every instrument value but has no idea which sails are set, so the
|
|
6
|
+
// polar estimator averages a full-main-and-genoa curve together with a
|
|
7
|
+
// three-reefs-and-staysail one. Recording the plan as a time series is what will let it
|
|
8
|
+
// tell them apart later.
|
|
9
|
+
//
|
|
10
|
+
// THE WRITE HAPPENS HERE AND NOWHERE ELSE. The app posts a plan to whichever server
|
|
11
|
+
// served it, and only the boat's mirror accepts one; the cloud answers 501
|
|
12
|
+
// `sail-write-not-here` so a stale client gets JSON it can show a human. The reasons are
|
|
13
|
+
// worth restating because they are the whole design:
|
|
14
|
+
//
|
|
15
|
+
// - <id>_raw stays a faithful mirror of the boat's own SignalK: one writer.
|
|
16
|
+
// - It is offline-correct for free. Sail changes happen at sea, which is exactly when
|
|
17
|
+
// the cloud is unreachable — the gapless spool already solves that, so the app needs
|
|
18
|
+
// no outbox of its own.
|
|
19
|
+
// - No Influx WRITE token has to live in the cloud next to the password hashes.
|
|
20
|
+
//
|
|
21
|
+
// Publishing is an ordinary SignalK delta on `sails.plan`, so everything downstream gets
|
|
22
|
+
// it for nothing: lib/sync spools it to the cloud bucket like any other value (string
|
|
23
|
+
// fields already work end to end — steering.autopilot.state proves it), the vendored
|
|
24
|
+
// mapper turns it into BoatState.sailPlan, and the app's sails screen renders identically
|
|
25
|
+
// on the boat and in the cloud.
|
|
26
|
+
|
|
27
|
+
const { isCanonicalPlan, decodePlan, describePlan, BARE } = require('./sails')
|
|
28
|
+
|
|
29
|
+
function createSails (app, options = {}) {
|
|
30
|
+
const log = (m) => (app.debug ? app.debug('[sails] ' + m) : console.log('[sailkick-boat:sails]', m))
|
|
31
|
+
const warn = (m) => (app.error ? app.error('[sailkick-boat:sails] ' + m) : console.error('[sailkick-boat:sails]', m))
|
|
32
|
+
|
|
33
|
+
let last = null // { plan, at } — what we published, for the status line
|
|
34
|
+
let writes = 0
|
|
35
|
+
|
|
36
|
+
// POST /api/sails { plan: "<canonical string>" } -> { ok, plan } | { ok:false, ... }
|
|
37
|
+
function setPlan (body) {
|
|
38
|
+
const plan = body && typeof body.plan === 'string' ? body.plan.trim() : null
|
|
39
|
+
if (!plan) {
|
|
40
|
+
return { ok: false, status: 400, code: 'bad-plan', message: 'body must be { plan: "<sail plan>" }' }
|
|
41
|
+
}
|
|
42
|
+
// Round-trip validation with the VENDORED encoder, so nothing that would decode
|
|
43
|
+
// differently than it was written ever reaches SignalK. The sort order is the whole
|
|
44
|
+
// contract — it is the key the polar work groups by — and "main:0+genoa:0" is
|
|
45
|
+
// exactly the kind of string that looks right and hashes wrong.
|
|
46
|
+
if (!isCanonicalPlan(plan)) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
status: 400,
|
|
50
|
+
code: 'bad-plan',
|
|
51
|
+
message: `"${plan}" is not a canonical sail plan. Expected <id>:<reefs> per set sail, joined by "+", sorted by id (e.g. "genoa:0+main:2"), or "${BARE}".`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!app.handleMessage) {
|
|
55
|
+
return { ok: false, status: 503, code: 'no-signalk', message: 'this plugin cannot publish to SignalK' }
|
|
56
|
+
}
|
|
57
|
+
const at = new Date().toISOString()
|
|
58
|
+
try {
|
|
59
|
+
app.handleMessage(options.pluginId || 'sailkick-boat', {
|
|
60
|
+
updates: [{ timestamp: at, values: [{ path: 'sails.plan', value: plan }] }]
|
|
61
|
+
})
|
|
62
|
+
} catch (e) {
|
|
63
|
+
warn('could not publish the sail plan: ' + e.message)
|
|
64
|
+
return { ok: false, status: 500, code: 'publish-failed', message: e.message }
|
|
65
|
+
}
|
|
66
|
+
last = { plan, at }
|
|
67
|
+
writes++
|
|
68
|
+
// Worth a normal log line, not a debug one: this is crew input, and "when did we put
|
|
69
|
+
// the second reef in" is a question people ask afterwards.
|
|
70
|
+
log(`sails: ${describePlan(plan)} (${plan})`)
|
|
71
|
+
return { ok: true, plan, at }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function status () {
|
|
75
|
+
if (!last) return 'sails: none set yet'
|
|
76
|
+
return `sails: ${describePlan(last.plan)}${writes > 1 ? ` (${writes} changes)` : ''}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Mirrors lib/history / lib/alerts: the proxy asks before claiming, in /api/config,
|
|
80
|
+
// that this host can accept a sail plan.
|
|
81
|
+
function available () { return !!app.handleMessage }
|
|
82
|
+
|
|
83
|
+
return { setPlan, status, available, _last: () => last, _decode: decodePlan }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { createSails }
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// VENDORED from sailkick/shared/engine/sails.js @ 1571308 sha256:7965d2e3e610e9fd
|
|
2
|
+
// Do not edit here — fix upstream and re-vendor. ONE definition of how a sail plan is
|
|
3
|
+
// written down, or the same plan hashes two ways and silently splits the polar cloud
|
|
4
|
+
// this feature exists to unify — visible only much later, as a mysteriously noisy polar.
|
|
5
|
+
// test/sails.test.js replays the upstream suite against this copy to prove it matches.
|
|
6
|
+
//
|
|
7
|
+
// Converted ESM -> CommonJS ONLY (export keywords removed, module.exports appended).
|
|
8
|
+
// No logic changed.
|
|
9
|
+
//
|
|
10
|
+
// The boat is the ONLY writer of a sail plan: the app posts one to whichever server
|
|
11
|
+
// served it, and only this mirror accepts it (the cloud answers 501). That keeps
|
|
12
|
+
// <id>_raw a faithful mirror of the boat's own SignalK with one writer, makes the write
|
|
13
|
+
// offline-correct for free — sail changes happen at sea, which is exactly when the cloud
|
|
14
|
+
// is unreachable, and the gapless spool already solves that — and keeps an Influx write
|
|
15
|
+
// token out of the cloud next to the password hashes.
|
|
16
|
+
|
|
17
|
+
// Sail plan — ONE definition of how a sail plan is written down, shared by every host
|
|
18
|
+
// that records or reads one: the boat plugin (which publishes it onto SignalK) and the
|
|
19
|
+
// cloud (which displays it and will later group polar samples by it). Pure and
|
|
20
|
+
// dependency-free, like shared/engine/alerts.js, for the same reason: a second
|
|
21
|
+
// implementation that disagrees is the failure mode this file exists to prevent.
|
|
22
|
+
//
|
|
23
|
+
// THE ENCODING
|
|
24
|
+
//
|
|
25
|
+
// "genoa:0+main:0" full main and full genoa
|
|
26
|
+
// "main:2+staysail:0" two reefs, staysail, no headsail
|
|
27
|
+
// "genoa:2+stormjib:0" genoa furled two steps AND the storm jib set
|
|
28
|
+
// "bare" nothing up
|
|
29
|
+
//
|
|
30
|
+
// `<id>:<reefs>` per ACTIVE sail, joined by "+", SORTED BY ID. Only sails that are
|
|
31
|
+
// actually set appear — "main down" is simply the main's absence, not a `down` state.
|
|
32
|
+
// That is what lets any combination be expressed: a cutter's genoa + staysail, a
|
|
33
|
+
// heavy-weather partly-furled genoa + storm jib, twin headsails poled out downwind.
|
|
34
|
+
// A fixed slot per station cannot say any of those, which is why there isn't one.
|
|
35
|
+
//
|
|
36
|
+
// SORTING IS THE WHOLE CONTRACT. The string is the join key the polar work will group
|
|
37
|
+
// by, so the same sail plan MUST produce the same bytes every time. An encoder that
|
|
38
|
+
// emitted insertion order would split one polar cloud into several that look unrelated
|
|
39
|
+
// — silently, and only visible much later as a mysteriously noisy polar. Hence
|
|
40
|
+
// encodePlan sorts, and the round-trip tests assert it.
|
|
41
|
+
//
|
|
42
|
+
// `bare` (rather than "") distinguishes "the crew says nothing is up" from "we have no
|
|
43
|
+
// sail data at all", which is null/absent. Under bare poles in a survival storm that
|
|
44
|
+
// distinction is real information.
|
|
45
|
+
|
|
46
|
+
// A sail id: lowercase, url-safe, and free of the encoding's own delimiters.
|
|
47
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
48
|
+
const STATIONS = ['main', 'head', 'flying'];
|
|
49
|
+
const SAIL_STATIONS = STATIONS;
|
|
50
|
+
const BARE = 'bare';
|
|
51
|
+
const MAX_REEFS = 5;
|
|
52
|
+
const MAX_SAILS = 12; // a plan longer than this is a bug or an attack, not a rig
|
|
53
|
+
|
|
54
|
+
// The inventory a boat gets before anyone edits it. Deliberately over-complete: it is
|
|
55
|
+
// far easier to delete the sails you don't carry than to remember the ones you do.
|
|
56
|
+
// `reefs` is how many REDUCED steps the sail has below full — 0 means it is all-or-
|
|
57
|
+
// nothing (a spinnaker is up or it isn't). For a furling headsail these are furl steps.
|
|
58
|
+
const DEFAULT_INVENTORY = [
|
|
59
|
+
{ id: 'main', name: 'Mainsail', station: 'main', reefs: 3 },
|
|
60
|
+
{ id: 'genoa', name: 'Genoa', station: 'head', reefs: 2 },
|
|
61
|
+
{ id: 'jib', name: 'Jib', station: 'head', reefs: 1 },
|
|
62
|
+
{ id: 'staysail', name: 'Staysail', station: 'head', reefs: 0 },
|
|
63
|
+
{ id: 'stormjib', name: 'Storm jib', station: 'head', reefs: 0 },
|
|
64
|
+
{ id: 'trysail', name: 'Trysail', station: 'main', reefs: 0 },
|
|
65
|
+
{ id: 'code0', name: 'Code 0', station: 'flying', reefs: 0 },
|
|
66
|
+
{ id: 'spinnaker', name: 'Spinnaker', station: 'flying', reefs: 0 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// [{ id, reefs }] → the canonical string. Ignores entries with an unusable id, and
|
|
70
|
+
// clamps a negative/NaN reef count to 0 rather than emitting a string that won't parse:
|
|
71
|
+
// a UI bug must not be able to write an unreadable record into the boat's history.
|
|
72
|
+
function encodePlan(sails) {
|
|
73
|
+
if (!Array.isArray(sails)) return BARE;
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
for (const s of sails) {
|
|
76
|
+
const id = typeof s?.id === 'string' ? s.id.trim().toLowerCase() : '';
|
|
77
|
+
if (!ID_RE.test(id)) continue;
|
|
78
|
+
const n = Number(s.reefs);
|
|
79
|
+
seen.set(id, Number.isFinite(n) ? Math.max(0, Math.min(MAX_REEFS, Math.round(n))) : 0);
|
|
80
|
+
}
|
|
81
|
+
if (!seen.size) return BARE;
|
|
82
|
+
return [...seen.entries()]
|
|
83
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) // byte order, not locale
|
|
84
|
+
.map(([id, reefs]) => `${id}:${reefs}`)
|
|
85
|
+
.join('+');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The canonical string → [{ id, reefs }], in the string's own (sorted) order.
|
|
89
|
+
//
|
|
90
|
+
// Deliberately TOLERANT: history outlives the inventory. A sail deleted from the boat's
|
|
91
|
+
// inventory today still appears in every plan recorded before it went, and a decoder
|
|
92
|
+
// that threw would take the whole screen — and later the whole polar split — down with
|
|
93
|
+
// it. Unparseable segments are dropped; `null`/absent/unknown input decodes to [].
|
|
94
|
+
function decodePlan(str) {
|
|
95
|
+
if (typeof str !== 'string') return [];
|
|
96
|
+
const s = str.trim();
|
|
97
|
+
if (!s || s === BARE) return [];
|
|
98
|
+
const out = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const seg of s.split('+').slice(0, MAX_SAILS)) {
|
|
101
|
+
const i = seg.indexOf(':');
|
|
102
|
+
if (i < 0) continue;
|
|
103
|
+
const id = seg.slice(0, i).trim().toLowerCase();
|
|
104
|
+
if (!ID_RE.test(id) || seen.has(id)) continue;
|
|
105
|
+
const reefs = Number(seg.slice(i + 1).trim());
|
|
106
|
+
if (!Number.isInteger(reefs) || reefs < 0 || reefs > MAX_REEFS) continue;
|
|
107
|
+
seen.add(id);
|
|
108
|
+
out.push({ id, reefs });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// True when the string is one this encoder could have produced. Used at the write door:
|
|
114
|
+
// the boat validates an incoming plan by round-tripping it, so nothing that would decode
|
|
115
|
+
// differently than it was written ever reaches SignalK.
|
|
116
|
+
function isCanonicalPlan(str) {
|
|
117
|
+
return typeof str === 'string' && encodePlan(decodePlan(str)) === str.trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The key polar samples will be GROUPED BY. Identity today — the plan string already is
|
|
121
|
+
// the key. It exists as a named seam because the polar work will want to coarsen plans
|
|
122
|
+
// into equivalence classes (a code 0 barely changes the upwind curve; a reef does), and
|
|
123
|
+
// when it does, that decision belongs here next to the encoding rather than scattered
|
|
124
|
+
// through the estimator.
|
|
125
|
+
function sailPlanKey(str) {
|
|
126
|
+
return typeof str === 'string' && str.trim() ? str.trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Render a plan for a human: "2 reefs · Staysail". `inventory` supplies display names;
|
|
130
|
+
// a sail missing from it falls back to its id, so historical plans stay readable after
|
|
131
|
+
// the sail is deleted. Returns '—' for no data, 'Bare poles' for an explicit bare.
|
|
132
|
+
function describePlan(str, inventory = DEFAULT_INVENTORY) {
|
|
133
|
+
if (typeof str !== 'string' || !str.trim()) return '—';
|
|
134
|
+
if (str.trim() === BARE) return 'Bare poles';
|
|
135
|
+
const byId = new Map((inventory || []).map((s) => [s.id, s]));
|
|
136
|
+
const parts = decodePlan(str).map(({ id, reefs }) => {
|
|
137
|
+
const name = byId.get(id)?.name || id;
|
|
138
|
+
if (!reefs) return name;
|
|
139
|
+
// The main is reefed; a furling headsail is furled. Same integer, different word —
|
|
140
|
+
// saying "1 reef" about a genoa reads wrong to anyone who has actually sailed.
|
|
141
|
+
return byId.get(id)?.station === 'main'
|
|
142
|
+
? `${reefs} reef${reefs > 1 ? 's' : ''}`
|
|
143
|
+
: `${name} −${reefs}`;
|
|
144
|
+
});
|
|
145
|
+
return parts.length ? parts.join(' · ') : 'Bare poles';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Validate one INVENTORY item (not a plan) — the profile section's write guard, in the
|
|
149
|
+
// shape of validateRule() in shared/engine/alerts.js. A malformed sail stores looking
|
|
150
|
+
// fine and then silently mislabels every polar sample recorded against it, so it is
|
|
151
|
+
// rejected at the door rather than tolerated.
|
|
152
|
+
function validateSail(s) {
|
|
153
|
+
if (!s || typeof s !== 'object') return { ok: false, error: 'sail must be an object' };
|
|
154
|
+
const id = typeof s.id === 'string' ? s.id.trim().toLowerCase() : '';
|
|
155
|
+
if (!ID_RE.test(id)) {
|
|
156
|
+
return { ok: false, error: `sail id "${s.id}" must be lowercase letters, digits or dashes (max 32) — it is written into every recorded plan` };
|
|
157
|
+
}
|
|
158
|
+
if (typeof s.name !== 'string' || !s.name.trim() || s.name.length > 40) {
|
|
159
|
+
return { ok: false, error: 'sail name is required (max 40 characters)' };
|
|
160
|
+
}
|
|
161
|
+
if (!STATIONS.includes(s.station)) {
|
|
162
|
+
return { ok: false, error: `sail station "${s.station}" must be one of ${STATIONS.join(', ')}` };
|
|
163
|
+
}
|
|
164
|
+
if (!Number.isInteger(s.reefs) || s.reefs < 0 || s.reefs > MAX_REEFS) {
|
|
165
|
+
return { ok: false, error: `sail reefs must be a whole number between 0 and ${MAX_REEFS}` };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
encodePlan,
|
|
172
|
+
decodePlan,
|
|
173
|
+
isCanonicalPlan,
|
|
174
|
+
sailPlanKey,
|
|
175
|
+
describePlan,
|
|
176
|
+
validateSail,
|
|
177
|
+
DEFAULT_INVENTORY,
|
|
178
|
+
SAIL_STATIONS,
|
|
179
|
+
BARE
|
|
180
|
+
}
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
// old path no longer existed. They now fail rather than skip when the checkout is present
|
|
28
28
|
// and the file is not; a guard that disappears when its subject moves is not a guard.
|
|
29
29
|
|
|
30
|
-
// sha256(app shared/engine/signalk-map.js)[0..12] as ported in v0.
|
|
30
|
+
// sha256(app shared/engine/signalk-map.js)[0..12] as ported in v0.30.0 (the sails merge)
|
|
31
31
|
const { request } = require('../net') // owned connection pool + real error codes
|
|
32
32
|
|
|
33
|
-
const PINNED_APP_HASH = '
|
|
33
|
+
const PINNED_APP_HASH = 'fd79e3688591'
|
|
34
34
|
|
|
35
35
|
function createContractCheck (app, options = {}) {
|
|
36
36
|
const warn = (m) => (app.error ? app.error('[sailkick-boat:contract] ' + m) : console.error('[sailkick-boat:contract]', m))
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// VENDORED from sailkick/shared/engine/signalk-map.js @
|
|
2
|
-
// (the app moved it public/engine -> shared/engine in 8fd58cf; content and hash unchanged)
|
|
1
|
+
// VENDORED from sailkick/shared/engine/signalk-map.js @ 1571308 sha256:fd79e368859133d8
|
|
3
2
|
// Do not edit here — fix upstream and re-vendor. One SignalK -> BoatState mapping, or
|
|
4
3
|
// the boat and the app quietly disagree (it has drifted twice; both times silently).
|
|
5
4
|
//
|
|
6
5
|
// Converted ESM -> CommonJS ONLY. No logic changed.
|
|
7
6
|
//
|
|
8
|
-
// Re-vendored after b519a9f (course + depth precedence)
|
|
9
|
-
// authoritative) — both
|
|
7
|
+
// Re-vendored after b519a9f (course + depth precedence), 348c3d9 (heading true
|
|
8
|
+
// authoritative) — both from findings handed over from this repo — and the sails merge
|
|
9
|
+
// (1571308), which adds the `sails.plan` case: the boat's mirror is the only writer of
|
|
10
|
+
// that path, so this copy has to understand what it publishes.
|
|
10
11
|
|
|
11
12
|
// SignalK → BoatState mapping — pure, dependency-free, so it's shared by both
|
|
12
13
|
// the client provider (public/ui/boat-panel.js, browser WebSocket) and the
|
|
@@ -152,6 +153,17 @@ function signalkValuesToPatch(values) {
|
|
|
152
153
|
case 'navigation.datetime': // GNSS UTC time (from the satellites)
|
|
153
154
|
if (typeof v.value === 'string') patch.gpsTime = v.value;
|
|
154
155
|
break;
|
|
156
|
+
// Which sails are set — a canonical plan string (shared/engine/sails.js), not a
|
|
157
|
+
// sensor reading: the crew publishes it from the app. Kept VERBATIM here; this
|
|
158
|
+
// mapper stays pure string-passing and the encoding lives in one place.
|
|
159
|
+
//
|
|
160
|
+
// A null is SKIPPED, like every sensor path and unlike the course paths above:
|
|
161
|
+
// the plan is a step function, so "the source went quiet" must leave the last
|
|
162
|
+
// known plan standing, not blank it. "Nothing is up" is the explicit string
|
|
163
|
+
// `bare`, which is exactly why that sentinel exists.
|
|
164
|
+
case 'sails.plan':
|
|
165
|
+
if (typeof v.value === 'string' && v.value.trim()) patch.sailPlan = v.value.trim();
|
|
166
|
+
break;
|
|
155
167
|
default:
|
|
156
168
|
break; // ignore everything else
|
|
157
169
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only — info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|