sailkick-boat 0.29.0 → 0.31.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 CHANGED
@@ -363,6 +363,46 @@ at all. The raw channels are always recorded regardless, so the cloud can recomp
363
363
  history if the maths ever changes: the recorded channel is a materialisation, not the only
364
364
  truth.
365
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
+
366
406
  ## Alerts and alarms, evaluated on board
367
407
 
368
408
  Rules — anchor drag, wind over or under a threshold, a big wind shift, boat speed below
@@ -745,10 +785,22 @@ A useful side-effect: the auto-coarsening that keeps the ring under `MAX_SAMPLES
745
785
  longer lossy. Only the *emit* rate coarsens, never the poll, so a 30-day passage emitting
746
786
  every ~52 s still carries the true min/max within each 52 s.
747
787
 
748
- **Compass channels never get a band** — `twd`, `twa`, `awa`, `cog`, `heading`, `wptBrg`.
749
- The mean of 359° and 1° is 180°, the exact opposite of the truth, so those carry a
750
- last-reading snapshot and no band, ever. It is the one error here that would look entirely
751
- plausible on screen, so the tests pin it.
788
+ **Compass channels are circular-meaned and never get a band** — `twd`, `twa`, `awa`,
789
+ `cog`, `heading`, `wptBrg`. The *arithmetic* mean of 359° and 1° is 180°, the exact
790
+ opposite of the truth, so these use the unit-vector mean `atan2(Σsin, Σcos)` instead: a
791
+ genuine average with no seam. A min/max stays meaningless on a circle, so they carry no
792
+ band, ever — the one error here that would look entirely plausible on screen.
793
+
794
+ Two things the tests pin hard. **`twa` and `awa` stay signed, −180..180**, port negative,
795
+ because that is what the number means; a circular mean returns 0..360, so they are folded
796
+ back — without it a port-side wind reads as 270°, which is not a rounding difference but a
797
+ different quantity. (Pinned as a fuzzed invariant, not two examples.) And **readings that
798
+ cancel report nothing rather than north**: two exactly opposite bearings have no average,
799
+ and `atan2(0, 0)` answers 0° with total confidence.
800
+
801
+ Before 0.31.0 these channels served the last reading in each bucket — correct, but one
802
+ sample per bucket, and the emit rate coarsens to ~52 s on a 30-day passage, so a direction
803
+ trace was visibly jumpier aboard than ashore for the same minute.
752
804
 
753
805
  `chans=sog,aws` narrows the answer to the channels actually plotted; `bands` appears only
754
806
  when `stats=1` was asked *and* the provider produced them, so every client degrades to the
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
@@ -0,0 +1,86 @@
1
+ // VENDORED from sailkick/shared/engine/angles.js @ a8c7153 sha256:6d001aa2e5a8f83b
2
+ // Do not edit here — fix upstream and re-vendor. ONE definition of compass maths, so the
3
+ // boat and the cloud answer "what was the average wind direction in this minute" the same
4
+ // way. test/history-ring.test.js exercises it through the ring.
5
+ //
6
+ // Converted ESM -> CommonJS ONLY (export keywords removed, module.exports appended).
7
+ // No logic changed.
8
+ //
9
+ // NB the upstream note on wrap180: it is deliberately duplicated here and in
10
+ // perf-live.js rather than imported, so that each stays a SINGLE-FILE vendor with its own
11
+ // pinned hash. The app fuzzes the two definitions against each other
12
+ // (tests/test-angles.mjs); the same discipline applies to this copy.
13
+
14
+ // Compass angle maths. Pure — no imports, no DOM — because shared/engine is the layer the
15
+ // boat plugin vendors (tests/test-layering.mjs enforces both).
16
+ //
17
+ // The one idea worth stating: a compass reading is a point on a CIRCLE, and almost every
18
+ // arithmetic operation you would reach for is wrong on a circle. The mean of 359° and 1° is
19
+ // 180°, the exact reciprocal. min/max across the seam are meaningless. A line drawn from
20
+ // 359° to 1° streaks the full height of a chart. Each of those has bitten this codebase.
21
+ //
22
+ // Two frames, and the whole file is about keeping them straight:
23
+ //
24
+ // WRAPPED — every value in [0,360). What instruments publish and what BoatState,
25
+ // /api/history/series and the dials all use. Correct for "where is it now",
26
+ // useless for "how did it change".
27
+ // UNWRAPPED — a continuous frame where 359 → 1 is recorded as 359 → 361. Differences and
28
+ // averages are ordinary arithmetic again. Correct for a series, meaningless
29
+ // as an absolute bearing until you wrap360 it at the very end.
30
+ //
31
+ // Same doctrine the routing field already uses for longitude — work in an unwrapped frame,
32
+ // wrap only at the boundary (shared/engine/route-field.js:48, docs/ROUTING-FIELD.md:85).
33
+
34
+ const wrap360 = (d) => ((d % 360) + 360) % 360;
35
+
36
+ // Signed shortest path, (-180, 180]. NOTE: deliberately duplicated from perf-live.js:16
37
+ // rather than imported. perf-live.js is vendored into sailkick-boat as a SINGLE FILE with a
38
+ // pinned hash (see server/history/influx-provider.js:22); giving it an import would turn a
39
+ // one-file copy into a two-file vendor contract. tests/test-angles.mjs fuzzes the two
40
+ // definitions against each other, which buys the anti-drift guarantee without the coupling.
41
+ const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x; };
42
+
43
+ // One step of the UNBOUNDED accumulate: the nearest equivalent of `deg` to `prev`.
44
+ // `prev` is the previous UNWRAPPED value, or null/undefined to seed the chain.
45
+ //
46
+ // Unbounded is the whole point, and is what separates this from the superficially identical
47
+ // accumulators in perf-live.js:43 and mobile/main.js:352 — both of those re-wrap on every
48
+ // step (bounded), because a dial must not spin away. A series must: after a full 360° veer
49
+ // the result is legitimately start+360, and clamping it would reintroduce the jump this
50
+ // exists to remove.
51
+ const unwrapStep = (prev, deg) => (prev == null ? wrap360(deg) : prev + wrap180(deg - prev));
52
+
53
+ // Lift a wrapped compass series into the unwrapped frame, in array order.
54
+ //
55
+ // Non-finite entries pass through untouched and DO NOT reset the chain. That is not
56
+ // politeness about bad data: re-seeding after a dropout picks a fresh branch of the circle,
57
+ // so every later point silently shifts by a multiple of 360 and the trace jumps for a reason
58
+ // no one will ever track down.
59
+ function unwrapDeg(degs) {
60
+ const out = new Array(degs.length);
61
+ let prev = null;
62
+ for (let i = 0; i < degs.length; i++) {
63
+ const d = degs[i];
64
+ if (!Number.isFinite(d)) { out[i] = d; continue; }
65
+ prev = unwrapStep(prev, d);
66
+ out[i] = prev;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // Circular mean of wrapped degrees — the honest average of a set of directions, via the
72
+ // unit-vector sum. Returns wrapped degrees, or null when there is nothing to average or the
73
+ // directions cancel exactly (opposite readings have no meaningful mean, and atan2(0,0) would
74
+ // answer 0° with false confidence).
75
+ function circularMeanDeg(degs) {
76
+ let sx = 0, sy = 0, n = 0;
77
+ for (const d of degs) {
78
+ if (!Number.isFinite(d)) continue;
79
+ const r = d * Math.PI / 180;
80
+ sx += Math.cos(r); sy += Math.sin(r); n++;
81
+ }
82
+ if (!n || Math.hypot(sx, sy) < 1e-9) return null;
83
+ return wrap360(Math.atan2(sy, sx) * 180 / Math.PI);
84
+ }
85
+
86
+ module.exports = { wrap360, wrap180, unwrapStep, unwrapDeg, circularMeanDeg }
@@ -63,7 +63,14 @@ const CHANNELS = [
63
63
  // mistake here would look entirely plausible on screen, which is why the test pins it.
64
64
  const WRAPPED = new Set(['twd', 'twa', 'awa', 'cog', 'heading', 'wptBrg'])
65
65
 
66
- const wrap360 = (d) => ((d % 360) + 360) % 360
66
+ // Of those, TWA and AWA are SIGNED: -180..+180 with port negative, because "40° off the
67
+ // bow to port" is what the number means. The other four are absolute bearings, 0..360.
68
+ // A circular mean always comes back 0..360, so these two MUST be folded back or a
69
+ // port-side wind reads as 270° — nonsense on every screen that shows it.
70
+ const SIGNED = new Set(['twa', 'awa'])
71
+ const rewrap = (c, d) => (SIGNED.has(c) ? wrap180(d) : wrap360(d))
72
+
73
+ const { wrap360, wrap180, circularMeanDeg } = require('./angles')
67
74
  // Stored values are rounded to 3 decimals. Rows now carry a mean plus two extremes per
68
75
  // channel, so full float noise ("6.430000000000001") would inflate every persisted line
69
76
  // for precision no instrument has and no chart can draw.
@@ -134,7 +141,7 @@ class RingHistoryProvider {
134
141
  // polls counts readings seen since the last emit. Zero means the telemetry source
135
142
  // gave us nothing at all, and we push no row — a gap is the honest record, where a
136
143
  // 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 }
144
+ this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, sinS: {}, cosS: {}, lat: null, lon: null }
138
145
  }
139
146
 
140
147
  // BoatState -> the flat channel map this ring records. Kept separate from the
@@ -169,7 +176,18 @@ class RingHistoryProvider {
169
176
  const v = raw[c]
170
177
  if (!Number.isFinite(v)) continue
171
178
  a.last[c] = v
172
- if (WRAPPED.has(c)) continue // a bearing has no meaningful mean or extreme
179
+ if (WRAPPED.has(c)) {
180
+ // A bearing still has no meaningful min/max — but it does have an average: the
181
+ // unit-vector (circular) mean, which is a genuine average with no seam. Serving
182
+ // one sample per bucket instead was correct but jumpy, and on a long passage the
183
+ // emit rate coarsens to ~52 s, so that would be one reading per bucket where the
184
+ // cloud averages every sample.
185
+ const r = v * DEG
186
+ a.sinS[c] = (a.sinS[c] || 0) + Math.sin(r)
187
+ a.cosS[c] = (a.cosS[c] || 0) + Math.cos(r)
188
+ a.cnt[c] = (a.cnt[c] || 0) + 1
189
+ continue
190
+ }
173
191
  a.sum[c] = (a.sum[c] || 0) + v
174
192
  a.cnt[c] = (a.cnt[c] || 0) + 1
175
193
  a.lo[c] = Math.min(a.lo[c] == null ? v : a.lo[c], v)
@@ -188,7 +206,15 @@ class RingHistoryProvider {
188
206
  if (!a.polls) return
189
207
  const row = { t: Date.now(), lo: {}, hi: {}, n: {} }
190
208
  for (const c of CHANNELS) {
191
- if (WRAPPED.has(c)) { row[c] = a.last[c] == null ? null : round3(a.last[c]); continue }
209
+ if (WRAPPED.has(c)) {
210
+ const n = a.cnt[c] || 0
211
+ // A near-zero resultant means the readings cancelled (exactly opposite bearings):
212
+ // there is no meaningful average, and atan2(0, 0) would answer 0° — due north,
213
+ // with total confidence. A gap is the honest record.
214
+ const mag = n ? Math.hypot(a.sinS[c], a.cosS[c]) : 0
215
+ row[c] = mag < 1e-9 ? null : round3(rewrap(c, wrap360(Math.atan2(a.sinS[c], a.cosS[c]) / DEG)))
216
+ continue
217
+ }
192
218
  const n = a.cnt[c] || 0
193
219
  if (!n) { row[c] = null; continue }
194
220
  row[c] = round3(a.sum[c] / n)
@@ -258,9 +284,9 @@ class RingHistoryProvider {
258
284
  if (v == null) continue
259
285
  const key = step ? Math.floor(r.t / step) * step : seq++
260
286
  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 }))
287
+ if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null, dirs: [] }))
262
288
  b.last = v
263
- if (wrapped) continue
289
+ if (wrapped) { b.dirs.push(v); continue }
264
290
  // Rows written before v0.29.0 carry no lo/hi/n. Reading them defensively means an
265
291
  // append-log from an older version still loads and simply yields a degenerate
266
292
  // band (the point value itself) rather than an empty chart or a crash.
@@ -273,8 +299,15 @@ class RingHistoryProvider {
273
299
  if (hi > b.hi) b.hi = hi
274
300
  }
275
301
  const out = [...buckets.values()].sort((a, b) => a.t - b.t)
302
+ // Wrapped channels re-bucket by circular mean too — unweighted, matching the
303
+ // cloud's provider: a row built from 15 polls counts the same as one built from 3.
304
+ // A small inaccuracy, kept deliberately, because the two providers answering the
305
+ // same question identically is worth more here than either being marginally righter
306
+ // alone. Buckets whose directions cancel drop out rather than reporting north.
276
307
  const pts = wrapped
277
- ? out.map((b) => [b.t, b.last])
308
+ ? out.map((b) => [b.t, circularMeanDeg(b.dirs)])
309
+ .filter(([, d]) => d != null)
310
+ .map(([t, d]) => [t, round3(rewrap(c, d))])
278
311
  : out.filter((b) => b.n > 0).map((b) => [b.t, round3(b.sum / b.n)])
279
312
  if (!pts.length) continue
280
313
  series[c] = pts
@@ -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.18.6
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 = '3e42369c002b'
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 @ dc57057 sha256:3e42369c002b7cb7
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) and 348c3d9 (heading true
9
- // authoritative) — both of which came from findings handed over from this repo.
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.29.0",
3
+ "version": "0.31.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": {