sailkick-boat 0.23.8 → 0.27.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
@@ -52,6 +52,13 @@ files to send later — nothing is lost in the gap.
52
52
 
53
53
  - Network errors, `429` and `5xx` are retried with backoff (1 s → 60 s); the data stays
54
54
  on disk.
55
+ - **Responses are decoded and checked for completeness.** Core `https` does neither, and
56
+ `fetch` did both silently: the upstream serves terrain tiles pre-compressed with
57
+ `Content-Encoding: gzip` whether asked to or not, so for one release the mirror cached
58
+ gzip bytes labelled as terrain and Cesium read the gzip header as a vertex count
59
+ ("Invalid typed array length: 11239580910"). A body shorter than its `Content-Length` is
60
+ now rejected too — tiles are pinned once written, so a truncated one would be served for
61
+ ever.
55
62
  - **All cloud traffic uses core `https`, not `fetch`** — telemetry sync, the offline
56
63
  mirror, the cache-manifest poller, the contract check, the backfill and the Sync page
57
64
  share one connection pool (`lib/net.js`), so one reset clears every subsystem. Twice in one afternoon this boat's
@@ -341,6 +348,106 @@ at all. The raw channels are always recorded regardless, so the cloud can recomp
341
348
  history if the maths ever changes: the recorded channel is a materialisation, not the only
342
349
  truth.
343
350
 
351
+ ## Alerts and alarms, evaluated on board
352
+
353
+ Rules — anchor drag, wind over or under a threshold, a big wind shift, boat speed below
354
+ polar — are evaluated **here**, in the SignalK process, against the boat's own bus. That
355
+ is the point: the case this exists for is the anchor dragging at 3am with the phone in
356
+ airplane mode and no uplink, which a cloud watcher cannot help with.
357
+
358
+ The rule **evaluator** is vendored verbatim from the app (`shared/engine/alerts.js`, with
359
+ its commit and sha256 in the header), exactly as the polar maths is. One definition of
360
+ "has this rule fired", or an alarm means one thing when the boat notices and another when
361
+ the cloud does. `test/alerts.test.js` replays the upstream suite against the vendored copy
362
+ — flapping, wrap-around, anchor swing, data gaps, the position-source conflict and the
363
+ two-clocks contract — and pins the boat-side host as well.
364
+
365
+ **Delivery is SignalK notifications**, which is what makes this worth more than another
366
+ screen: an alarm panel, a chart app or a Node-RED buzzer flow already listens to them.
367
+
368
+ ```
369
+ notifications.navigation.anchor anchor drag — state "alarm", method visual + sound
370
+ notifications.sailkick.<ruleId> everything else — state "alert", method visual
371
+ ```
372
+
373
+ Only anchor drag takes a conventional path: a boat has one anchor, so it cannot collide,
374
+ and it is the rule other software reacts to. Wind rules get a path of their own per rule,
375
+ because "over 30 kt" and "under 5 kt" are two rules a sailor plausibly sets at once, and
376
+ one shared path would make each transition overwrite the other's state. Clearing is
377
+ `state: "normal"`, per the schema — the path is never deleted. A rule may carry its own
378
+ `state`/`method`, validated against the SignalK enums (`nominal|normal|alert|warn|alarm|
379
+ emergency`, `visual|sound`).
380
+
381
+ **Alarms reach the cloud through the telemetry spool.** A transition is written as one
382
+ row of Influx line protocol and handed to the same store-and-forward buffer as every other
383
+ channel, so it inherits what that buffer was built for: ordered, gapless, nothing lost to
384
+ a failed POST or a restart, and an alarm raised mid-ocean arrives when the link does. It
385
+ also makes alarm history queryable afterwards — *when did we drag?* The schema:
386
+
387
+ ```
388
+ alerts,context=vessels.<urn>,self=true,rule=<id>,kind=<kind>
389
+ raised=1i,transition="raised",state="alarm",message="…",value=111,name="Anchor" <ns>
390
+ ```
391
+
392
+ `rule` and `kind` are the only tags — both bounded and stable, so cardinality stays flat.
393
+ `transition` and `state` are deliberately **fields**: keeping them out of the series key
394
+ means one series per rule, so "is this raised right now" is `last(raised)` on a single
395
+ series rather than a merge-and-compare across two — a query that is easy to get wrong, and
396
+ wrong in the direction of *no alarm*. Feed conditions ride the same measurement under
397
+ `rule=__feed__`. With no cloud account configured, alarms still ring on board and the
398
+ status line says `local only (no cloud sync)` rather than letting you assume otherwise.
399
+
400
+ Our own notifications are **not** also uploaded through the generic delta path — they
401
+ would arrive a second time as flattened `notifications.*` rows, describing the same event
402
+ in a worse shape in a namespace that means "a device's own condition". Every other
403
+ plugin's notifications (the Victron monitors, the server's own) are untouched.
404
+
405
+ **Drop anchor** — `POST /api/alerts/anchor {ruleId}` — writes the boat's current fix into
406
+ the rule and arms the watch in the same step. The datum goes into the *rule* because the
407
+ evaluator's in-memory one does not survive a restart or a rule edit, both of which happen
408
+ at anchor; and it is taken from the boat's own fix because that is the position the rule
409
+ will be evaluated against. A browser would send whatever its last telemetry frame said,
410
+ from a socket that may have dropped — which is exactly the moment this matters.
411
+
412
+ **Rules live on the boat**, in the profile beside routes and polars
413
+ (`/api/profile/alerts`), and that copy is deliberately **not** synced with the cloud. For
414
+ alarms that is the right way round: a rule edited from ashore must not silently change
415
+ what the boat alarms on mid-passage.
416
+
417
+ Three behaviours worth knowing, because each is a way an alarm system becomes useless:
418
+
419
+ - **Editing a rule re-arms its alarm.** A rebuilt evaluator has no memory of the raise, so
420
+ anything currently up is taken down explicitly and comes back after its hold time if the
421
+ condition still holds. The alternative is a notification latched at `alarm` that nothing
422
+ can ever clear — which is how an owner learns to mute the path.
423
+ - **Stopping the plugin clears what it raised**, loudly, for the same reason: nothing will
424
+ evaluate the rule while it is stopped.
425
+ - **A dead feed is not an alarm.** It goes to the status line and the log. It also never
426
+ clears a raised alarm: the input going away is not evidence the danger did.
427
+
428
+ **Two sources on `navigation.position` disable the anchor alarm — silently.** Not by
429
+ false-alarming: the far source reads as *outside* the circle and the good one as *inside*,
430
+ and since a raise needs the condition to hold continuously, the alternation resets the
431
+ hold time for ever and the alarm never fires at all. This boat had exactly that before its
432
+ position source was pinned — a second source a median 2.3 km away, jumping up to 22.7 km
433
+ between fixes, while the good receiver at rest scattered a median **1.88 m** from its
434
+ centroid (max 2.91 m over 90 minutes). The evaluator now reports it as a feed condition
435
+ (three implausible jumps in five minutes), and the plugin says so in the log and on the
436
+ status line, naming the fix: set a source priority for `navigation.position`. Metres of
437
+ ordinary GPS scatter never trip it.
438
+
439
+ **A malformed rule is dropped, not stored looking armed.** Rules are validated with the
440
+ app's own `validateRule` (vendored with the evaluator), because the evaluator treats an
441
+ unknown kind — or a deadband on the wrong side of the threshold, which could never clear —
442
+ as *inert*. Such a rule would otherwise sit in the list looking active and never fire. The
443
+ status line counts them and the log names each one and why.
444
+
445
+ **Feed staleness and clocks.** Rules are evaluated on the SignalK timestamp, so the timing
446
+ is the data's own, not the machine's — but staleness has to be measured on wall clock (a
447
+ dead feed is exactly a timestamp that stops moving). The two are reconciled by bounding
448
+ the skew: a SignalK clock more than a minute from system time falls back to system time
449
+ with one warning, rather than reporting a permanent phantom "feed stale".
450
+
344
451
  ## Two paths, one reading
345
452
 
346
453
  Source priorities solve *several devices on one path*. There is a second, separate case:
@@ -605,8 +712,16 @@ cloud, in-memory ring on a DB-less edge*. The boat is a third case — an edge t
605
712
  serves the app's history endpoints from its **own** live data:
606
713
  ```
607
714
  GET /api/history/series?window=3600s&every=30s -> { series: { sog|heading|tws|… : [[tMs,val],…] } }
608
- GET /api/history/track?window=3600s -> { track: [{ t, lat, lon }, …] }
715
+ GET /api/history/track?window=3600s&every=10s -> { track: [{ t, lat, lon }, …] }
716
+ GET /api/history/track?from=<epochMs>&to=<epochMs> (absolute range; ISO also accepted)
609
717
  ```
718
+ Both endpoints take **either** a trailing `window`, **or** an absolute `from`/`to` —
719
+ which is what the app sends whenever the view is scrolled back in time (the historic
720
+ trail, and a Trends flyout on a past period). The response echoes the `from`/`to` it
721
+ actually served. Before 0.24.0 the boat parsed only `window`, so a request for a past
722
+ 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.
724
+
610
725
  Same JSON the cloud returns, so the browser can't tell the difference — but it
611
726
  works **offline** with the boat's own data. Only when no telemetry source is
612
727
  available at all do these paths **fall through to the cloud mirror**, so an
@@ -665,6 +780,8 @@ in `index.js`.
665
780
  - **Sailkick account**: `slug` (boat name), `writeToken`
666
781
  - **Telemetry sync → cloud**: `enabled`
667
782
  - **Upload AIS targets**: `enabled` (default off), `source`
783
+ - **Alerts & alarms**: `enabled` (default on — inert until you add a rule),
784
+ `notifications` (whether to put alarms on the SignalK bus)
668
785
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
669
786
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
670
787
  `prefetchDetailZoom`
package/index.js CHANGED
@@ -11,6 +11,7 @@ const { createAis } = require('./lib/ais')
11
11
  const { createAisTargets } = require('./lib/ais/targets')
12
12
  const { createProfile } = require('./lib/profile')
13
13
  const { createPerf } = require('./lib/perf')
14
+ const { createAlerts } = require('./lib/alerts')
14
15
  const { createCloud } = require('./lib/cloud')
15
16
  const { resolveAccountConfig } = require('./lib/account')
16
17
 
@@ -107,6 +108,7 @@ module.exports = function (app) {
107
108
  let profile = null
108
109
  let cloud = null
109
110
  let perf = null
111
+ let alerts = null
110
112
  let proxyPort = null // what the launcher page needs to build its links
111
113
  let pairedSlug = null
112
114
  let statusTimer = null
@@ -165,6 +167,15 @@ module.exports = function (app) {
165
167
  sourceBucket: { type: 'string', title: '…bucket', default: 'signalk' }
166
168
  }
167
169
  },
170
+ alerts: {
171
+ type: 'object',
172
+ title: 'Alerts & alarms',
173
+ description: 'Watch this boat\'s own data and raise SignalK notifications — anchor drag, wind over or under a threshold, a big wind shift, speed below polar. Evaluated here on board, so an anchor alarm still works with the phone in airplane mode and no internet. The rules themselves are set in the app (they are stored on the boat, not in the cloud); nothing is raised until you add one.',
174
+ properties: {
175
+ enabled: { type: 'boolean', title: 'Evaluate alert rules', default: true },
176
+ notifications: { type: 'boolean', title: 'Raise SignalK notifications', description: 'Puts alarms on the SignalK bus, where chart apps, alarm panels and buzzers pick them up (anchor drag goes to the conventional notifications.navigation.anchor). Turn off to evaluate rules without touching the bus.', default: true }
177
+ }
178
+ },
168
179
  proxy: {
169
180
  type: 'object',
170
181
  title: 'Offline app & maps',
@@ -239,6 +250,9 @@ module.exports = function (app) {
239
250
  bucket: b.bucket || s.bucket,
240
251
  token: b.writeToken || s.token,
241
252
  spoolDir,
253
+ // So sync can tell OUR notifications from every other plugin's: ours are relayed
254
+ // as `alerts` rows and must not also go up flattened (lib/sync ownNotificationsStripped).
255
+ pluginId: plugin.id,
242
256
  batchSize: s.batchSize || SYNC_TUNING.batchSize,
243
257
  flushIntervalMs: s.flushIntervalMs || SYNC_TUNING.flushIntervalMs,
244
258
  maxBufferBytes: s.maxBufferBytes || SYNC_TUNING.maxBufferBytes,
@@ -383,6 +397,34 @@ module.exports = function (app) {
383
397
  }
384
398
  }
385
399
 
400
+ // Alert rules, evaluated on board. After perf, because perf-below reads the polar %
401
+ // it computes; needs telemetry for everything else. Inert until the owner adds a
402
+ // rule, so it is on by default.
403
+ if (telemetry && (opts.alerts || {}).enabled !== false) {
404
+ try {
405
+ alerts = createAlerts(app, {
406
+ source: telemetry,
407
+ perfSource: perf,
408
+ pluginId: plugin.id,
409
+ notifications: (opts.alerts || {}).notifications !== false,
410
+ // The cloud half: transitions ride the telemetry spool as `alerts` rows, so an
411
+ // alarm raised offshore arrives when the link does instead of being lost.
412
+ // Null when sync is off — alarms then ring on board only, and say so.
413
+ relay: sync ? (lines) => sync.writeLines(lines) : null,
414
+ context: app.selfContext || ('vessels.' + (app.selfId || 'self')),
415
+ // For "drop anchor": the datum is written into the rule through the profile's
416
+ // own serialized queue, so it survives a restart and cannot race an app save.
417
+ profile,
418
+ profileFile: path.join((app.getDataDirPath && app.getDataDirPath()) || '.', 'profile.json')
419
+ })
420
+ alerts.start()
421
+ pOpts.alerts = alerts // so /api/config can say the rules are evaluated here
422
+ } catch (e) {
423
+ (app.error || console.error)('[sailkick-boat] alerts start failed: ' + e.message)
424
+ alerts = null
425
+ }
426
+ }
427
+
386
428
  if (pOpts.history.enabled !== false) {
387
429
  try {
388
430
  // ringSource = the telemetry module: when no local InfluxDB token is set
@@ -487,6 +529,7 @@ module.exports = function (app) {
487
529
  if (aisTargets) parts.push(aisTargets.status())
488
530
  if (profile) parts.push(profile.status())
489
531
  if (perf) parts.push(perf.status())
532
+ if (alerts) parts.push(alerts.status())
490
533
  if (ais) parts.push(ais.status())
491
534
  if (backfill) parts.push(backfill.status())
492
535
  try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
@@ -502,6 +545,7 @@ module.exports = function (app) {
502
545
  try { if (profile) profile.stop() } catch {}
503
546
  try { if (cloud) cloud.stop() } catch {}
504
547
  try { if (perf) perf.stop() } catch {}
548
+ try { if (alerts) alerts.stop() } catch {}
505
549
  try { if (ais) ais.stop() } catch {}
506
550
  try { if (backfill) backfill.stop() } catch {}
507
551
  try { if (proxy) proxy.stop() } catch {}
@@ -514,6 +558,7 @@ module.exports = function (app) {
514
558
  profile = null
515
559
  cloud = null
516
560
  perf = null
561
+ alerts = null
517
562
  proxyPort = null
518
563
  pairedSlug = null
519
564
  proxy = null
@@ -0,0 +1,314 @@
1
+ // VENDORED from sailkick/shared/engine/alerts.js @ d54bfc1 sha256:aeac568257bb2acc
2
+ // Do not edit here — fix upstream and re-vendor. ONE definition of "has this rule fired",
3
+ // or an alarm means one thing on the boat and another in the cloud. test/alerts.test.js
4
+ // replays the upstream suite (tests/test-alerts.mjs) against this copy to prove it, and
5
+ // test/vendored.test.js fails if this header's hash stops matching the app source.
6
+ //
7
+ // Converted ESM -> CommonJS ONLY (imports rewritten to require, export keywords removed,
8
+ // module.exports appended). No logic changed.
9
+ //
10
+ // The two upstream imports:
11
+ // wrap180 <- shared/engine/perf-live.js, which this repo already vendors
12
+ // (lib/perf/perf-live.js @ sha256:7ce83a1df13926f0)
13
+ // greatCircleKm <- shared/engine/wind-field.js:256, copied alone into ./great-circle.js
14
+ // rather than vendoring a grid-generation module for one haversine.
15
+ // alerts.js itself now uses it, for the position-conflict detector.
16
+
17
+ // Alert engine — ONE definition of "has this rule fired", shared by every host that will
18
+ // evaluate it: the boat plugin (vendored, so anchor drag alarms with no internet) and the
19
+ // cloud watcher (so you hear about it from ashore). Same evaluator both sides, or a rule
20
+ // means two different things depending on who noticed — the drift shared/engine/perf-live.js
21
+ // was extracted to kill.
22
+ //
23
+ // Pure, and deliberately narrow: rules in, state TRANSITIONS out. No timers, no delivery,
24
+ // no notion of a buzzer or a push token, and no Date.now() of its own — the caller passes
25
+ // `now`, so a host can replay recorded history through it and get the same answers. That
26
+ // is also what makes the hard parts below testable.
27
+ //
28
+ // See docs/DESIGN-alerts.md.
29
+ //
30
+ // const engine = createAlertEngine(rules);
31
+ // const events = engine.update(boatState, sampleMs); // [] most ticks
32
+ // const feed = engine.tick(wallClockMs); // feed health
33
+ //
34
+ // TWO CLOCKS, AND THEY MUST DIFFER. `update` takes the SAMPLE's own timestamp
35
+ // (Date.parse(state.updatedAt)) so time-weighted state replays deterministically. `tick`
36
+ // takes the WALL CLOCK. Passing the sample stamp to both is the trap: a frozen feed keeps
37
+ // handing back the same state, so the sample stamp stops advancing, `now - lastSample`
38
+ // stays 0, and staleness NEVER fires — the feed dies exactly when the one condition that
39
+ // would tell you cannot. (The boat plugin hit precisely this; its stale test caught it.)
40
+ //
41
+ // `perfPct` is NOT a BoatState field — nothing in server/telemetry/interface.js defines
42
+ // it. A host that wants the `perf-below` rule must inject it (the boat plugin does, from
43
+ // its perf module); otherwise that rule reads undefined and, correctly but uselessly,
44
+ // never fires.
45
+ //
46
+ // Each event: { ruleId, kind, transition: 'raised'|'cleared', at, reason, context }
47
+
48
+ const { wrap180 } = require('../perf/perf-live')
49
+ const { greatCircleKm } = require('./great-circle')
50
+
51
+ // Absent telemetry is `undefined` on the boat and `null` in the cloud — neither is a
52
+ // number, and a rule that treats either as 0 fires the moment a sensor is missing.
53
+ const num = (v) => (Number.isFinite(v) ? v : null);
54
+
55
+ const DEFAULT_FOR_SEC = 30; // a condition must hold this long before raising
56
+ const DEFAULT_CLEAR_SEC = 30; // …and fail this long before clearing
57
+ // Default only — overridable per engine (`createAlertEngine(rules, { staleSec })`). A host
58
+ // watching a live SignalK feed wants seconds; the cloud heartbeat, which sees a boat only
59
+ // as often as its link allows, wants many minutes.
60
+ const STALE_SEC = 120;
61
+ const M_PER_KM = 1000;
62
+
63
+ // Two SignalK sources publishing navigation.position produce a position that teleports
64
+ // between them. Measured on a real boat: a median 2.3 km apart, jumps to 22.7 km. That
65
+ // does NOT false-alarm an anchor watch — it makes it NEVER FIRE, because the fixes take
66
+ // turns being outside the circle and reset the hold time for ever. Silent, and the worst
67
+ // possible outcome, so the feed reports it rather than the rule swallowing it.
68
+ const MAX_PLAUSIBLE_KT = 100; // no sailing vessel; this is two sources or a glitch
69
+ const JUMP_WINDOW_MS = 300_000; // …counted over 5 minutes
70
+ const JUMP_COUNT = 3; // one glitch is noise; three is a conflict
71
+
72
+ // ---- rule kinds -------------------------------------------------------------
73
+ // Each returns { active, value } — `active` is "the raw condition is true RIGHT NOW",
74
+ // before any hold/deadband smoothing. `null` means "cannot tell" (missing input), which
75
+ // is NOT the same as false and must never clear a raised alarm.
76
+
77
+ const KINDS = {
78
+ // { kind:'wind-above', twsKt:25, clearKt?:22 }
79
+ 'wind-above': (r, s) => {
80
+ const v = num(s.twsKt);
81
+ if (v == null) return null;
82
+ return { active: v >= r.twsKt, clearActive: v >= (r.clearKt ?? r.twsKt), value: v };
83
+ },
84
+ 'wind-below': (r, s) => {
85
+ const v = num(s.twsKt);
86
+ if (v == null) return null;
87
+ return { active: v <= r.twsKt, clearActive: v <= (r.clearKt ?? r.twsKt), value: v };
88
+ },
89
+ // { kind:'perf-below', pct:80 } — the channel the boat now computes.
90
+ 'perf-below': (r, s) => {
91
+ const v = num(s.perfPct);
92
+ if (v == null) return null;
93
+ return { active: v <= r.pct, clearActive: v <= (r.clearPct ?? r.pct), value: v };
94
+ },
95
+ };
96
+
97
+ // ---- validation -------------------------------------------------------------
98
+ // Lives here, not in the route, because every host needs the same answer: the server on
99
+ // write, the UI before submitting, the boat plugin once it vendors this. A malformed rule
100
+ // must be REJECTED rather than stored — the engine treats an unknown kind as inert, so a
101
+ // bad rule would otherwise sit in the list looking armed and never fire, which is the
102
+ // worst possible failure for an alarm.
103
+ const SPEC = {
104
+ 'wind-above': { req: [['twsKt', 0, 100]], dead: ['clearKt', 'below'] },
105
+ 'wind-below': { req: [['twsKt', 0, 100]], dead: ['clearKt', 'above'] },
106
+ 'perf-below': { req: [['pct', 0, 200]], dead: ['clearPct', 'above'] },
107
+ 'wind-shift': { req: [['deg', 1, 180]], dead: ['clearDeg', 'below'] },
108
+ 'anchor-drift': { req: [['radiusM', 5, 5000]], dead: ['clearRadiusM', 'below'] },
109
+ };
110
+
111
+ const RULE_KINDS = Object.keys(SPEC);
112
+
113
+ // → { ok: true, rule } | { ok: false, error }
114
+ function validateRule(r) {
115
+ if (!r || typeof r !== 'object') return { ok: false, error: 'rule must be an object' };
116
+ const spec = SPEC[r.kind];
117
+ if (!spec) return { ok: false, error: `unknown kind "${r.kind}" — expected one of ${RULE_KINDS.join(', ')}` };
118
+
119
+ for (const [field, lo, hi] of spec.req) {
120
+ const v = r[field];
121
+ if (!Number.isFinite(v)) return { ok: false, error: `${r.kind}.${field} is required and must be a number` };
122
+ if (v < lo || v > hi) return { ok: false, error: `${r.kind}.${field} must be between ${lo} and ${hi}` };
123
+ }
124
+ for (const [field] of [['forSec'], ['clearSec'], ['windowSec']]) {
125
+ if (r[field] !== undefined && (!Number.isFinite(r[field]) || r[field] < 0 || r[field] > 86400)) {
126
+ return { ok: false, error: `${field} must be a number of seconds between 0 and 86400` };
127
+ }
128
+ }
129
+ // A deadband on the WRONG SIDE of the threshold is the subtle one: clearing a
130
+ // "wind above 25" at 30 means the rule can never clear, so the alarm latches forever
131
+ // and the user has no way to see why.
132
+ const [deadField, side] = spec.dead;
133
+ const dead = r[deadField];
134
+ if (dead !== undefined) {
135
+ if (!Number.isFinite(dead)) return { ok: false, error: `${deadField} must be a number` };
136
+ const threshold = r[spec.req[0][0]];
137
+ const wrong = side === 'below' ? dead > threshold : dead < threshold;
138
+ if (wrong) {
139
+ return { ok: false, error: `${deadField} (${dead}) must be ${side === 'below' ? 'at or below' : 'at or above'} `
140
+ + `${spec.req[0][0]} (${threshold}), or the alarm can never clear` };
141
+ }
142
+ }
143
+ if (r.kind === 'anchor-drift' && r.anchor !== undefined) {
144
+ const a = r.anchor;
145
+ if (!a || !Number.isFinite(a.lat) || !Number.isFinite(a.lon) || Math.abs(a.lat) > 90 || Math.abs(a.lon) > 180) {
146
+ return { ok: false, error: 'anchor must be { lat, lon } in range' };
147
+ }
148
+ }
149
+ return { ok: true, rule: r };
150
+ }
151
+
152
+ // ---- the engine -------------------------------------------------------------
153
+
154
+ function createAlertEngine(rules = [], { staleSec = STALE_SEC } = {}) {
155
+ // Per-rule state. `raised` is the only thing a host ultimately cares about; the rest
156
+ // exists to stop it flapping.
157
+ const st = new Map();
158
+ const stateOf = (id) => {
159
+ if (!st.has(id)) st.set(id, { raised: false, since: null, failingSince: null, hist: [], anchor: null, lastSeen: null });
160
+ return st.get(id);
161
+ };
162
+ let staleRaised = false, lastState = null;
163
+ let jumpyRaised = false, lastFix = null, jumps = [];
164
+
165
+ // Wind shift is a CHANGE over a window, so it needs its own history rather than an
166
+ // instantaneous test. Compared shortest-path: a veer 350°→010° is 20°, not 340°.
167
+ function windShift(r, s, now, k) {
168
+ const d = num(s.twdDeg);
169
+ if (d == null) return null;
170
+ const win = (r.windowSec ?? 600) * 1000;
171
+ k.hist.push({ t: now, d });
172
+ while (k.hist.length && k.hist[0].t < now - win) k.hist.shift();
173
+ // Largest excursion from the OLDEST sample in the window — a slow steady veer and a
174
+ // sudden shift both matter, and comparing only first-vs-last would miss a shift that
175
+ // partly recovered within it.
176
+ let worst = 0;
177
+ for (const p of k.hist) {
178
+ const delta = Math.abs(wrap180(d - p.d));
179
+ if (delta > worst) worst = delta;
180
+ }
181
+ // Not enough history yet to make a claim — "cannot tell", not "false".
182
+ if (k.hist.length < 2 || now - k.hist[0].t < Math.min(win, (r.forSec ?? DEFAULT_FOR_SEC) * 1000)) return null;
183
+ return { active: worst >= r.deg, clearActive: worst >= (r.clearDeg ?? r.deg), value: Math.round(worst) };
184
+ }
185
+
186
+ // Anchor drift. The anchor is EXPLICIT (r.anchor = {lat, lon}) because inferring where
187
+ // the hook went down is the kind of cleverness that fails silently at 3am. A boat at
188
+ // anchor swings and a stationary GPS wanders tens of metres, so the raise still rides
189
+ // the hold time like every other rule.
190
+ function anchorDrift(r, s, k) {
191
+ const lat = num(s.lat), lon = num(s.lon);
192
+ const a = r.anchor || k.anchor;
193
+ if (lat == null || lon == null || !a || !Number.isFinite(a.lat) || !Number.isFinite(a.lon)) return null;
194
+ const m = greatCircleKm(a.lat, a.lon, lat, lon) * M_PER_KM;
195
+ return { active: m >= r.radiusM, clearActive: m >= (r.clearRadiusM ?? r.radiusM * 0.8), value: Math.round(m) };
196
+ }
197
+
198
+ function evaluate(r, s, now, k) {
199
+ if (r.kind === 'wind-shift') return windShift(r, s, now, k);
200
+ if (r.kind === 'anchor-drift') return anchorDrift(r, s, k);
201
+ const fn = KINDS[r.kind];
202
+ return fn ? fn(r, s) : null;
203
+ }
204
+
205
+ return {
206
+ // One tick. Returns the transitions that happened THIS tick (usually none).
207
+ update(s, now) {
208
+ const events = [];
209
+ if (!s || typeof s !== 'object') return events;
210
+ lastState = now;
211
+
212
+ // Position sanity, from the fixes themselves — the evaluator never sees SignalK
213
+ // sources, but an impossible speed between consecutive fixes is the same evidence.
214
+ const plat = num(s.lat), plon = num(s.lon);
215
+ if (plat != null && plon != null) {
216
+ if (lastFix && now > lastFix.t) {
217
+ const kt = (greatCircleKm(lastFix.lat, lastFix.lon, plat, plon) / 1.852) / ((now - lastFix.t) / 3600e3);
218
+ if (kt > MAX_PLAUSIBLE_KT) jumps.push(now);
219
+ }
220
+ lastFix = { t: now, lat: plat, lon: plon };
221
+ }
222
+
223
+ for (const r of rules) {
224
+ if (r.enabled === false) continue;
225
+ const k = stateOf(r.id);
226
+ const res = evaluate(r, s, now, k);
227
+
228
+ // "Cannot tell" — a missing sensor or too little history. Hold whatever state the
229
+ // rule is in. Critically this does NOT clear a raised alarm: the input going away
230
+ // is not evidence the danger did.
231
+ if (res == null) { k.since = null; k.failingSince = null; continue; }
232
+ k.lastSeen = now;
233
+
234
+ const forMs = (r.forSec ?? DEFAULT_FOR_SEC) * 1000;
235
+ const clearMs = (r.clearSec ?? DEFAULT_CLEAR_SEC) * 1000;
236
+
237
+ if (!k.raised) {
238
+ // Rising edge: the condition must hold for forSec. Wind hovering on a threshold
239
+ // otherwise fires every tick, and an alarm people learn to ignore has stopped
240
+ // being an alarm.
241
+ if (res.active) {
242
+ k.since ??= now;
243
+ if (now - k.since >= forMs) {
244
+ k.raised = true; k.since = null; k.failingSince = null;
245
+ events.push({ ruleId: r.id, kind: r.kind, transition: 'raised', at: now,
246
+ reason: r.name || r.kind, context: { value: res.value, rule: r } });
247
+ }
248
+ } else k.since = null;
249
+ } else {
250
+ // Falling edge rides a DEADBAND (clearKt / clearDeg / clearRadiusM), so the
251
+ // clear threshold is deliberately not the raise threshold.
252
+ if (!res.clearActive) {
253
+ k.failingSince ??= now;
254
+ if (now - k.failingSince >= clearMs) {
255
+ k.raised = false; k.failingSince = null; k.since = null;
256
+ events.push({ ruleId: r.id, kind: r.kind, transition: 'cleared', at: now,
257
+ reason: r.name || r.kind, context: { value: res.value, rule: r } });
258
+ }
259
+ } else k.failingSince = null;
260
+ }
261
+ }
262
+ return events;
263
+ },
264
+
265
+ // Staleness is its own condition, not a rule outcome: the feed dying is worth knowing
266
+ // in itself, and it must never be confused with a rule clearing. Hosts call this on
267
+ // their own tick so it fires even when no state is arriving at all.
268
+ tick(now) {
269
+ if (lastState == null) return [];
270
+ const out = [];
271
+ const stale = now - lastState >= staleSec * 1000;
272
+ if (stale !== staleRaised) {
273
+ staleRaised = stale;
274
+ out.push({ ruleId: '__feed__', kind: 'feed-stale', transition: stale ? 'raised' : 'cleared',
275
+ at: now, reason: 'telemetry feed', context: { lastStateAt: lastState } });
276
+ }
277
+ // Prune against the SAMPLE times already recorded, not this wall clock, so a quiet
278
+ // period ages the evidence out rather than a busy one hiding it.
279
+ const cut = (lastFix?.t ?? lastState) - JUMP_WINDOW_MS;
280
+ jumps = jumps.filter((t) => t >= cut);
281
+ const jumpy = jumps.length >= JUMP_COUNT;
282
+ if (jumpy !== jumpyRaised) {
283
+ jumpyRaised = jumpy;
284
+ out.push({ ruleId: '__feed__', kind: 'position-jumpy', transition: jumpy ? 'raised' : 'cleared',
285
+ at: now, reason: 'position source conflict',
286
+ context: { jumps: jumps.length, overKt: MAX_PLAUSIBLE_KT } });
287
+ }
288
+ return out;
289
+ },
290
+
291
+ // Current state, for a status line or an API — not the transition stream.
292
+ get active() {
293
+ const out = [];
294
+ for (const r of rules) if (st.get(r.id)?.raised) out.push(r.id);
295
+ if (staleRaised || jumpyRaised) out.push('__feed__');
296
+ return out;
297
+ },
298
+ isRaised: (id) => !!st.get(id)?.raised,
299
+ // Drop-anchor for a host that wants a runtime datum. IN-MEMORY ONLY — it does not
300
+ // survive a restart or a rule edit. The durable path is writing `anchor: {lat, lon}`
301
+ // into the rule itself (which is what the Config pane does).
302
+ setAnchor(ruleId, lat, lon) { stateOf(ruleId).anchor = { lat, lon }; },
303
+ };
304
+ }
305
+
306
+ module.exports = {
307
+ createAlertEngine,
308
+ validateRule,
309
+ RULE_KINDS,
310
+ DEFAULT_FOR_SEC,
311
+ DEFAULT_CLEAR_SEC,
312
+ STALE_SEC,
313
+ MAX_PLAUSIBLE_KT
314
+ }
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ // VENDORED from sailkick/shared/engine/wind-field.js:256 @ 8008b0e sha256:523a9e378fcab848
4
+ // Do not edit here — fix upstream and re-vendor. ONE function copied rather than the whole
5
+ // module: wind-field.js is grid generation, and hauling it in for eight lines of haversine
6
+ // would vendor a large surface we do not use and cannot keep honest.
7
+ //
8
+ // The hash above is wind-field.js's, not this file's — it is what to re-check against.
9
+ // Converted ESM -> CommonJS ONLY. No logic changed.
10
+
11
+ // Helper for tests + UI: distance from (lat0, lon0) to (lat1, lon1) in km.
12
+ function greatCircleKm (lat0, lon0, lat1, lon1) {
13
+ const R = 6371
14
+ const dLat = (lat1 - lat0) * Math.PI / 180
15
+ const dLon = (lon1 - lon0) * Math.PI / 180
16
+ const a = Math.sin(dLat / 2) ** 2 +
17
+ Math.cos(lat0 * Math.PI / 180) * Math.cos(lat1 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
18
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)))
19
+ }
20
+
21
+ module.exports = { greatCircleKm }