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 +118 -1
- package/index.js +45 -0
- package/lib/alerts/alerts.js +314 -0
- package/lib/alerts/great-circle.js +21 -0
- package/lib/alerts/index.js +431 -0
- package/lib/alerts/relay.js +94 -0
- package/lib/history/index.js +48 -4
- package/lib/history/ring.js +24 -5
- package/lib/net.js +50 -10
- package/lib/perf/polar.js +7 -1
- package/lib/profile/index.js +25 -3
- package/lib/proxy/index.js +34 -0
- package/lib/sync/index.js +48 -2
- package/lib/telemetry/contract.js +8 -2
- package/lib/telemetry/signalk-map.js +2 -1
- package/package.json +2 -2
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
|
|
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 }
|