signalk-pypilot-newui 2.9.0 → 2.11.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/CHANGELOG.md +98 -0
- package/README.md +16 -16
- package/dist/alarms.js +112 -10
- package/dist/autopilot-provider.js +172 -29
- package/dist/failsafe-bsp.js +64 -0
- package/dist/index.js +769 -26
- package/dist/leeway.js +74 -0
- package/dist/maneuver-trace.js +197 -0
- package/dist/publisher.js +2 -2
- package/dist/tack-catchup.js +90 -0
- package/dist/test/alarms-attitude.test.js +110 -0
- package/dist/test/alarms-trim.test.js +1 -1
- package/dist/test/episodes.test.js +1 -0
- package/dist/test/failsafe-bsp.test.js +56 -0
- package/dist/test/leeway.test.js +91 -0
- package/dist/test/servo-error-log.test.js +1 -1
- package/dist/test/tack-catchup.test.js +90 -0
- package/dist/trip-recorder.js +68 -1
- package/package.json +26 -8
- package/public/app.js +3011 -224
- package/public/index.html +351 -185
- package/public/style.css +106 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.10.0 — 2026-09-27 — Sea-trial wind-mode fixes + maneuver forensics
|
|
4
|
+
|
|
5
|
+
Rolls up Rev309 through Rev323. Between 2.9.0 (Rev280) and 2.10.0 the
|
|
6
|
+
plugin absorbed a sea trial (2026-09-25) that surfaced several critical
|
|
7
|
+
gaps in the wind-mode workflow, plus a new forensics module so future
|
|
8
|
+
sea trials can be audited offline.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Maneuver Trace Log** (Rev322, opt-in). Every user-driven action from
|
|
13
|
+
the visor — aproado / empopado pick, tack tap, mode change, target PUT,
|
|
14
|
+
engage / disengage — is recorded together with pypilot state right
|
|
15
|
+
before the command AND 300 ms after. Pair pre/post makes it trivial
|
|
16
|
+
to answer "did the pilot actually obey?". Files land as JSONL under
|
|
17
|
+
`<plugin data dir>/maneuver-trace/`. New endpoints:
|
|
18
|
+
`GET|POST /maneuver-trace/{status,start,stop,event}` +
|
|
19
|
+
`GET /maneuver-trace/tail?n=N`. Setup card "Maneuver trace
|
|
20
|
+
(sea-trial forensics)" toggles it on the fly, off by default.
|
|
21
|
+
- **Empopado pseudo-mode** (Rev313 → Rev320) — mirror of Aproado for the
|
|
22
|
+
other extreme: bring the boat to TWA ≈ 180° so the mainsail flogs the
|
|
23
|
+
least while striking sail. Full HUD (choosing → transit → active),
|
|
24
|
+
hot gains at latch (P × 1.5, D × 1.7, servo slew × 1.3), audible
|
|
25
|
+
warning when `|TWA| < 160°` sustained 1 s ("empopado inestable"),
|
|
26
|
+
auto-cancel when `|TWA| < 140°` sustained 2.5 s. Restore in order
|
|
27
|
+
hot gains → profile → mode → target → engaged on SALIR / cancel.
|
|
28
|
+
- **Attitude safety alarms** (Rev299 / I1) — `attitude-heel-extreme` and
|
|
29
|
+
`attitude-pitch-extreme` rules with configurable thresholds
|
|
30
|
+
(`alarmAttitudeHeelDeg`, `alarmAttitudePitchDeg`), off by default so
|
|
31
|
+
a fresh install never disarms on the dock.
|
|
32
|
+
- **Roll feed-forward** (Rev282+) and **Post-tack catch-up** (Rev299)
|
|
33
|
+
publish diagnostic paths under
|
|
34
|
+
`steering.autopilot.pypilot.tuning.rollFf.*` and `.tackCatchup.*`.
|
|
35
|
+
Both are currently `appliedToAp: false` — computed and observable
|
|
36
|
+
but not yet fed to the pilot; a later release flips the switch after
|
|
37
|
+
sea trial validation.
|
|
38
|
+
- **Leeway estimator** (Rev298 / H4) publishing
|
|
39
|
+
`steering.autopilot.pypilot.derived.leewayRad` from the classical
|
|
40
|
+
`drift_deg = adj * heel_deg / bsp_kn²` formula. Opt-in via
|
|
41
|
+
`leewayAdjustment > 0`, kept off the canonical `performance.leeway`
|
|
42
|
+
path so it never overwrites `signalk-derived-data`.
|
|
43
|
+
- **Failsafe boat speed** (Rev299 / H2) — when both BSP and SOG go
|
|
44
|
+
missing, the plugin injects a configurable knots value so downstream
|
|
45
|
+
wind / leeway calculations keep producing plausible numbers.
|
|
46
|
+
- **Profile default persistence fix** (Rev310) — opening the visor from
|
|
47
|
+
a second device no longer silently overwrites the pypilot profile with
|
|
48
|
+
`default`; the select is now primed from the HTTP snapshot before the
|
|
49
|
+
WebSocket delta arrives.
|
|
50
|
+
|
|
51
|
+
### Fixed (critical — from the 2026-09-25 sea trial)
|
|
52
|
+
|
|
53
|
+
- **Tack in wind mode** (Rev309) — pressing VIRAR to a specific side now
|
|
54
|
+
reliably rotates the bow to that side, even when the "short arc" would
|
|
55
|
+
go the other way. Root cause: `planned = -refRad` in the countdown
|
|
56
|
+
path was ignoring the sign; now `_tackCountdownToExecuting` delegates
|
|
57
|
+
to `ap.tack.direction` + `ap.tack.state=begin` so pypilot honours the
|
|
58
|
+
band the sailor chose.
|
|
59
|
+
- **Aproado without countdown** (Rev311) — pressing POR PROA / POR POPA
|
|
60
|
+
starts the maneuver on the same tap (previously 5 s delay). Sustain
|
|
61
|
+
3 s for the "achieved" latch avoids false positives on brief AWA
|
|
62
|
+
oscillations.
|
|
63
|
+
- **Alarm silence during maneuvers** (Rev311 → Rev312 → Rev323) — the
|
|
64
|
+
`heading-deviation`, `cruise-drift` and `unable-to-steer` rules now
|
|
65
|
+
respect both pypilot's own `ap.tack.state != "none"` AND a visor-side
|
|
66
|
+
pseudo-mode flag posted to `POST /maneuver-state` when Aproado /
|
|
67
|
+
Empopado are running. Independent flag needed because pypilot does
|
|
68
|
+
not always update `ap.tack.state` when the sailor uses one of the
|
|
69
|
+
pseudo-modes.
|
|
70
|
+
- **Heading-deviation false positives in wind mode** (Rev323) —
|
|
71
|
+
threshold raised to 35° and sustain to 30 s when the pilot mode is
|
|
72
|
+
`wind` / `true wind`. In waves and gusts the AWA target legitimately
|
|
73
|
+
oscillates ±20-30° while the AP is still correcting; the 20°/15 s
|
|
74
|
+
default of compass mode was disarming the alarm several times per
|
|
75
|
+
hour on Tunatunes.
|
|
76
|
+
- **Tack countdown stability close-out** (Rev312) — if the tack overshoots
|
|
77
|
+
the plan but the boat is clearly settled (rate-of-turn < 3°/s for 2 s),
|
|
78
|
+
the overlay closes on its own instead of counting up to the 120 s
|
|
79
|
+
timeout.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
- **Alarms OFF by default** (Rev317) — a fresh install no longer arms
|
|
84
|
+
every rule; the sailor opts in via Setup → Alarms. Pre-Rev299 installs
|
|
85
|
+
keep legacy behaviour (undefined = all-on) so nothing changes silently
|
|
86
|
+
under an existing boat.
|
|
87
|
+
- **Terminology cleanup** (Rev321) — all references to third-party
|
|
88
|
+
autopilot brands / dashboards in code, comments, docs, UI hints and
|
|
89
|
+
package metadata have been rewritten as neutral descriptions of the
|
|
90
|
+
mechanism ("downstream Signal K clients", "wind-response damping
|
|
91
|
+
bands", "classical heel-over-speed² formula"). No behavioural change.
|
|
92
|
+
- **Tablet vertical crop hardening** (Rev314) — `100vh` → `100dvh` in
|
|
93
|
+
every fullscreen modal, `-webkit-overflow-scrolling: touch`, safe-area
|
|
94
|
+
padding. Fixes bottom-of-card clipping on Android tablets in portrait.
|
|
95
|
+
|
|
96
|
+
### Removed
|
|
97
|
+
|
|
98
|
+
- `docs/PROMPT_FOR_LLM_maneuver_catalog.md` — obsolete Rev209 audit
|
|
99
|
+
prompt, superseded by the current tack design.
|
|
100
|
+
|
|
3
101
|
## 2.9.0 — 2026-09-23 — Relicensed to AGPL-3.0-or-later
|
|
4
102
|
|
|
5
103
|
### License change (see NOTICE for details)
|
package/README.md
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
**This plugin enhances and extends the magic of [pypilot](https://github.com/pypilot/pypilot)**,
|
|
4
4
|
Sean D'Epagnier's open-source autopilot, with more functions, a modern
|
|
5
5
|
touch-first user experience on tablets and phones, first-class
|
|
6
|
-
**Signal K integration** so
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
**Signal K integration** so any downstream Signal K client can drive
|
|
7
|
+
and monitor the autopilot out of the box, and a strong focus on
|
|
8
|
+
**reliability, ease of use and ease of tuning**: the plugin itself
|
|
9
|
+
watches how your autopilot is behaving and quietly suggests how to
|
|
10
|
+
sharpen its settings, so you spend more time sailing and less time
|
|
11
|
+
guessing which slider to move.
|
|
12
12
|
|
|
13
13
|
Runs alongside [`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)
|
|
14
14
|
by Panaaj — see [Working alongside pypilot-autopilot-provider](#working-alongside-pypilot-autopilot-provider).
|
|
@@ -78,8 +78,8 @@ by Panaaj — see [Working alongside pypilot-autopilot-provider](#working-alongs
|
|
|
78
78
|
18. **Alarm engine — 9 rules** — heading deviation, cruise drift,
|
|
79
79
|
unable-to-steer, servo overcurrent, servo temp, motor temp, high
|
|
80
80
|
AP load, low voltage, sensor lost, pypilot disconnected — all
|
|
81
|
-
published as canonical `notifications.autopilot.*` for
|
|
82
|
-
|
|
81
|
+
published as canonical `notifications.autopilot.*` for any
|
|
82
|
+
downstream Signal K client.
|
|
83
83
|
19. **Head-to-wind pseudo-mode** — a "raise the sails" mode not
|
|
84
84
|
present in pypilot, with countdown and bow / stern swing
|
|
85
85
|
selection.
|
|
@@ -112,16 +112,16 @@ by Panaaj — see [Working alongside pypilot-autopilot-provider](#working-alongs
|
|
|
112
112
|
stored as JSONL with tags (wind, sea state, propulsion, point of
|
|
113
113
|
sail, crew), shareable for advice.
|
|
114
114
|
28. **Full Signal K path catalog viewer** in the visor with
|
|
115
|
-
copy-to-clipboard for
|
|
115
|
+
copy-to-clipboard for downstream widgets.
|
|
116
116
|
|
|
117
117
|
### Signal K integration — 8 new added functions
|
|
118
118
|
|
|
119
119
|
29. **~110 Signal K paths** under `steering.autopilot.pypilot.*`,
|
|
120
120
|
opt-in per path so you never flood the bus.
|
|
121
|
-
30. **Autopilot API v2 provider** (absorbable) —
|
|
122
|
-
|
|
121
|
+
30. **Autopilot API v2 provider** (absorbable) — any downstream
|
|
122
|
+
Signal K autopilot client talks to this plugin as the canonical
|
|
123
123
|
autopilot.
|
|
124
|
-
31. **
|
|
124
|
+
31. **Simple Switch action paths** — engage, nudge, tack,
|
|
125
125
|
mode selector, profile selector.
|
|
126
126
|
32. **9 momentary switches** — nudge ±10 / ±1, tack port / starboard
|
|
127
127
|
/ cancel — as KIP boolean paths.
|
|
@@ -152,8 +152,8 @@ history.
|
|
|
152
152
|
2.0.0 is a **breaking release** addressing feedback from Sean D'Epagnier
|
|
153
153
|
(pypilot author). Every pypilot key now maps 1:1 to
|
|
154
154
|
`steering.autopilot.pypilot.<key>` verbatim - no more hand-picked renames
|
|
155
|
-
or C -> K / deg -> rad conversions. If you have
|
|
156
|
-
|
|
155
|
+
or C -> K / deg -> rad conversions. If you have Signal K dashboards
|
|
156
|
+
wired to 1.0.0 paths, see the full migration table in
|
|
157
157
|
[`CHANGELOG.md`](./CHANGELOG.md) - most notable examples:
|
|
158
158
|
|
|
159
159
|
- `.servo.controllerTemperature` (K) -> `.servo.controller_temp` (C)
|
|
@@ -241,7 +241,7 @@ We build on top of the excellent
|
|
|
241
241
|
[`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)
|
|
242
242
|
by Panaaj — the plugin that gives Signal K the standard
|
|
243
243
|
**Autopilot API v2** surface (`/signalk/v2/api/vessels/self/autopilots/*`) so
|
|
244
|
-
|
|
244
|
+
any downstream Signal K autopilot client can drive pypilot through the canonical
|
|
245
245
|
interface. Panaaj focuses tightly on that API and opens its own socket.io
|
|
246
246
|
connection to `pypilot_web`.
|
|
247
247
|
|
|
@@ -260,7 +260,7 @@ register the same ID and behaviour is undefined). Valid setups:
|
|
|
260
260
|
| Enabled | Off (default) | Both run happily side by side. Panaaj owns the AP v2 API; this plugin adds the extra paths + UI. **2 sockets** to `pypilot_web`. |
|
|
261
261
|
| Disabled | **On** | Only this plugin. Same AP v2 API + all the extras. **1 socket**. Handy on Pi Zero W. |
|
|
262
262
|
| Enabled | On | Both try to register the same AP ID — leave one off. |
|
|
263
|
-
| Disabled | Off |
|
|
263
|
+
| Disabled | Off | No downstream Signal K client will see the AP. Only useful if you exclusively use this plugin's own webapp. |
|
|
264
264
|
|
|
265
265
|
Absorb mode lives in **Setup → Autopilot Provider (one-socket mode)**.
|
|
266
266
|
|
package/dist/alarms.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
// - Rules never share state - a rule that says "true" this tick can
|
|
13
13
|
// later say "false" without any cleanup dance.
|
|
14
14
|
// - Every fire increments a per-rule counter used to add jitter to
|
|
15
|
-
// the emitted notification timestamp, so
|
|
16
|
-
// collapse two consecutive alerts into one.
|
|
15
|
+
// the emitted notification timestamp, so downstream SK clients
|
|
16
|
+
// never collapse two consecutive alerts into one.
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.AlarmEngine = exports.DEFAULT_RULES = exports.RULE_PYPILOT_DISC_SEC = exports.RULE_RUDDER_TRIM_RAD = exports.RULE_SERVO_TRIM_FRAC = exports.RULE_LOW_VOLTAGE_V = exports.RULE_SERVO_TEMP_C = exports.RULE_SERVO_OVERCURR_A = exports.RULE_UNABLE_DUTY_MIN = exports.RULE_UNABLE_ERR_RAD = exports.RULE_HEADING_ERR_RAD = exports.DEFAULT_MOTOR_TEMP_C = void 0;
|
|
18
|
+
exports.AlarmEngine = exports.RULE_ATTITUDE_PITCH_DEG = exports.RULE_ATTITUDE_HEEL_DEG = exports.DEFAULT_RULES = exports.RULE_PYPILOT_DISC_SEC = exports.RULE_RUDDER_TRIM_RAD = exports.RULE_SERVO_TRIM_FRAC = exports.RULE_LOW_VOLTAGE_V = exports.RULE_SERVO_TEMP_C = exports.RULE_SERVO_OVERCURR_A = exports.RULE_UNABLE_DUTY_MIN = exports.RULE_UNABLE_ERR_RAD = exports.RULE_HEADING_ERR_RAD = exports.DEFAULT_MOTOR_TEMP_C = void 0;
|
|
19
19
|
const SEV_TO_SK = {
|
|
20
20
|
info: "nominal",
|
|
21
21
|
warn: "warn",
|
|
@@ -53,13 +53,35 @@ exports.DEFAULT_RULES = [
|
|
|
53
53
|
label: "Heading deviation",
|
|
54
54
|
severity: "warn",
|
|
55
55
|
defaultEnabled: true,
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// Rev323 (Carlos, 2026-09-27): sustain subido de 15 s a 30 s. Con
|
|
57
|
+
// 15 s cualquier ola grande / racha corta metía la RMS 20 s por
|
|
58
|
+
// encima del umbral y disparaba la alarma; 30 s filtra el ruido
|
|
59
|
+
// legítimo de un mar entrecortado sin perder la señal de un AP
|
|
60
|
+
// realmente descolgado.
|
|
61
|
+
sustainSec: 30,
|
|
62
|
+
description: `RMS heading error exceeds ${(exports.RULE_HEADING_ERR_RAD * 180 / Math.PI) | 0}° (compass) / 35° (wind) with the AP engaged.`,
|
|
58
63
|
evaluate: (c) => {
|
|
59
64
|
if (!c.kpis || !c.sample || !c.sample.engaged)
|
|
60
65
|
return false;
|
|
66
|
+
// Rev311: durante una maniobra nativa del pypilot silenciamos.
|
|
67
|
+
if (c.maneuverInProgress)
|
|
68
|
+
return false;
|
|
69
|
+
// Rev323 (Carlos, 2026-09-27): pseudo-modo visor-side (Aproado /
|
|
70
|
+
// Empopado) también silencia — el visor está pilotando la
|
|
71
|
+
// maniobra por su cuenta y el "error" que reporta pypilot es
|
|
72
|
+
// el gap intencionado durante el swing.
|
|
73
|
+
if (c.maneuverPseudoActive)
|
|
74
|
+
return false;
|
|
75
|
+
// Rev323 mode-aware threshold. En wind / true-wind el heading
|
|
76
|
+
// command es un AWA/TWA target, que oscila naturalmente ±20-30°
|
|
77
|
+
// en olas y ráfagas mientras el AP sí está corrigiendo. Subir
|
|
78
|
+
// el umbral en esos modos elimina el disparo por doquier que
|
|
79
|
+
// Carlos reportó 2026-09-27.
|
|
80
|
+
const mode = (c.sample.mode || "").toLowerCase();
|
|
81
|
+
const isWind = mode.includes("wind");
|
|
82
|
+
const threshold = isWind ? (35 * Math.PI / 180) : exports.RULE_HEADING_ERR_RAD;
|
|
61
83
|
const r = c.kpis.window1m.rmsErrorRad;
|
|
62
|
-
return typeof r === "number" && r >
|
|
84
|
+
return typeof r === "number" && r > threshold;
|
|
63
85
|
},
|
|
64
86
|
message: (c) => {
|
|
65
87
|
const r = c.kpis?.window1m.rmsErrorRad ?? 0;
|
|
@@ -81,6 +103,10 @@ exports.DEFAULT_RULES = [
|
|
|
81
103
|
evaluate: (c) => {
|
|
82
104
|
if (!c.kpis || !c.sample || !c.sample.engaged)
|
|
83
105
|
return false;
|
|
106
|
+
if (c.maneuverInProgress)
|
|
107
|
+
return false; // Rev311: silencio en maniobra
|
|
108
|
+
if (c.maneuverPseudoActive)
|
|
109
|
+
return false; // Rev323: idem pseudo-modo
|
|
84
110
|
const mean = c.kpis.window1m.meanErrorRad;
|
|
85
111
|
return typeof mean === "number" && Math.abs(mean) > (5 * Math.PI / 180);
|
|
86
112
|
},
|
|
@@ -100,6 +126,10 @@ exports.DEFAULT_RULES = [
|
|
|
100
126
|
evaluate: (c) => {
|
|
101
127
|
if (!c.kpis || !c.sample || !c.sample.engaged)
|
|
102
128
|
return false;
|
|
129
|
+
if (c.maneuverInProgress)
|
|
130
|
+
return false; // Rev311: silencio en maniobra
|
|
131
|
+
if (c.maneuverPseudoActive)
|
|
132
|
+
return false; // Rev323: idem pseudo-modo
|
|
103
133
|
const r = c.kpis.window1m.rmsErrorRad;
|
|
104
134
|
const d = c.kpis.window1m.servoDutyPct;
|
|
105
135
|
return typeof r === "number" && r > exports.RULE_UNABLE_ERR_RAD
|
|
@@ -213,9 +243,10 @@ exports.DEFAULT_RULES = [
|
|
|
213
243
|
{
|
|
214
244
|
// Rev156 (Carlos): ServoHealth already grades the drive as
|
|
215
245
|
// learning/good/elevated/high on the deviation ratio (recent avg
|
|
216
|
-
// vs learned baseline). Surface "high" as a notification so
|
|
217
|
-
//
|
|
218
|
-
// fires once the baseline is actually learned
|
|
246
|
+
// vs learned baseline). Surface "high" as a notification so
|
|
247
|
+
// downstream SK clients show the same red banner the visor card
|
|
248
|
+
// does. Only fires once the baseline is actually learned
|
|
249
|
+
// (deviation != null).
|
|
219
250
|
id: "servo-load-high",
|
|
220
251
|
label: "Autopilot load high",
|
|
221
252
|
severity: "warn",
|
|
@@ -318,7 +349,55 @@ exports.DEFAULT_RULES = [
|
|
|
318
349
|
return `Pypilot desconectado desde hace ${tail}`;
|
|
319
350
|
},
|
|
320
351
|
},
|
|
352
|
+
// Rev299 (I1): attitude safety envelope. These rules watch what the
|
|
353
|
+
// sailor cares about — is the boat pinned on its side, is the bow
|
|
354
|
+
// buried — not just what the servo is doing. Both are opt-in
|
|
355
|
+
// (defaultEnabled=false) because a heavy-weather passage will trip
|
|
356
|
+
// them and we do not want a fresh install screaming across a
|
|
357
|
+
// deliberate rail-in-the-water broach. Thresholds live in
|
|
358
|
+
// AlarmThresholds so the sailor can tune per-boat.
|
|
359
|
+
{
|
|
360
|
+
id: "attitude-heel-extreme",
|
|
361
|
+
label: "Escora extrema",
|
|
362
|
+
severity: "warn",
|
|
363
|
+
defaultEnabled: false,
|
|
364
|
+
sustainSec: 5,
|
|
365
|
+
description: "Heel angle sostenido por encima del umbral configurado (por defecto 45°). Sirve como envoltura de seguridad: no reemplaza el criterio del navegante ni desengancha el piloto.",
|
|
366
|
+
evaluate: (c) => {
|
|
367
|
+
if (!c.sample || typeof c.sample.heel !== "number")
|
|
368
|
+
return false;
|
|
369
|
+
const thrDeg = c.thresholds?.attitudeHeelDeg ?? exports.RULE_ATTITUDE_HEEL_DEG;
|
|
370
|
+
return Math.abs(c.sample.heel) * 180 / Math.PI > thrDeg;
|
|
371
|
+
},
|
|
372
|
+
message: (c) => {
|
|
373
|
+
const heelDeg = Math.abs((c.sample?.heel ?? 0) * 180 / Math.PI);
|
|
374
|
+
const thrDeg = c.thresholds?.attitudeHeelDeg ?? exports.RULE_ATTITUDE_HEEL_DEG;
|
|
375
|
+
return `Escora ${heelDeg.toFixed(0)}° (umbral ${thrDeg}°)`;
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: "attitude-pitch-extreme",
|
|
380
|
+
label: "Cabeceo extremo",
|
|
381
|
+
severity: "warn",
|
|
382
|
+
defaultEnabled: false,
|
|
383
|
+
sustainSec: 3,
|
|
384
|
+
description: "Pitch (cabeceo) sostenido por encima del umbral configurado. Suele indicar embarque de proa o clavado en ola.",
|
|
385
|
+
evaluate: (c) => {
|
|
386
|
+
if (!c.sample || typeof c.sample.pitch !== "number")
|
|
387
|
+
return false;
|
|
388
|
+
const thrDeg = c.thresholds?.attitudePitchDeg ?? exports.RULE_ATTITUDE_PITCH_DEG;
|
|
389
|
+
return Math.abs(c.sample.pitch) * 180 / Math.PI > thrDeg;
|
|
390
|
+
},
|
|
391
|
+
message: (c) => {
|
|
392
|
+
const pitchDeg = Math.abs((c.sample?.pitch ?? 0) * 180 / Math.PI);
|
|
393
|
+
const thrDeg = c.thresholds?.attitudePitchDeg ?? exports.RULE_ATTITUDE_PITCH_DEG;
|
|
394
|
+
return `Cabeceo ${pitchDeg.toFixed(0)}° (umbral ${thrDeg}°)`;
|
|
395
|
+
},
|
|
396
|
+
},
|
|
321
397
|
];
|
|
398
|
+
// Rev299 (I1): defaults for the attitude envelope.
|
|
399
|
+
exports.RULE_ATTITUDE_HEEL_DEG = 45;
|
|
400
|
+
exports.RULE_ATTITUDE_PITCH_DEG = 25;
|
|
322
401
|
class AlarmEngine {
|
|
323
402
|
constructor(rules = exports.DEFAULT_RULES, opts = {}) {
|
|
324
403
|
this.rules = new Map();
|
|
@@ -394,6 +473,20 @@ class AlarmEngine {
|
|
|
394
473
|
}
|
|
395
474
|
return changed;
|
|
396
475
|
}
|
|
476
|
+
/** Rev342 (Carlos, 2026-09-28): override the severity of one rule
|
|
477
|
+
* at runtime. The RuleDef default is preserved so subsequent boots
|
|
478
|
+
* without an override recover the shipped severity. Returns true
|
|
479
|
+
* on success, false if the rule id or severity is unknown. */
|
|
480
|
+
setRuleSeverity(ruleId, severity) {
|
|
481
|
+
const rule = this.rules.get(ruleId);
|
|
482
|
+
const s = this.state.get(ruleId);
|
|
483
|
+
if (!rule || !s)
|
|
484
|
+
return false;
|
|
485
|
+
if (severity !== "info" && severity !== "warn" && severity !== "alarm")
|
|
486
|
+
return false;
|
|
487
|
+
s.severity = severity;
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
397
490
|
/** Rev292: change the sustain window of one rule at runtime, e.g.
|
|
398
491
|
* when the sailor updates alarmPypilotDiscSec in /supervisor/config.
|
|
399
492
|
* Returns true on success, false if the rule id is unknown. Values
|
|
@@ -491,7 +584,16 @@ class AlarmEngine {
|
|
|
491
584
|
out.push({
|
|
492
585
|
id,
|
|
493
586
|
label: rule.label,
|
|
494
|
-
|
|
587
|
+
// Rev375 (Carlos, 2026-09-30): was `rule.severity` (default),
|
|
588
|
+
// which made the frontend severity dropdown revert to default
|
|
589
|
+
// on every _alFetch poll. `s.severity` reflects setRuleSeverity
|
|
590
|
+
// overrides applied at boot from props.alarmSeverityOverrides
|
|
591
|
+
// and by runtime POSTs to /alarms/severity/:id.
|
|
592
|
+
severity: s.severity,
|
|
593
|
+
// Rev376 (Carlos, 2026-09-30): expose the shipped default so
|
|
594
|
+
// the frontend dropdown can mark it (★) — sailor asked to see
|
|
595
|
+
// which option is "the default" before changing it.
|
|
596
|
+
defaultSeverity: rule.severity,
|
|
495
597
|
enabled: s.enabled,
|
|
496
598
|
description: rule.description,
|
|
497
599
|
sustainSec: rule.sustainSec,
|
|
@@ -101,6 +101,7 @@ class AutopilotProvider {
|
|
|
101
101
|
if (opts?.allowDodge)
|
|
102
102
|
this.allowDodge = true;
|
|
103
103
|
this.onDataChanged = opts?.onDataChanged;
|
|
104
|
+
this.onStageEvent = opts?.onStageEvent;
|
|
104
105
|
}
|
|
105
106
|
notifyChanged(fields) {
|
|
106
107
|
try {
|
|
@@ -108,10 +109,23 @@ class AutopilotProvider {
|
|
|
108
109
|
}
|
|
109
110
|
catch { /* silent */ }
|
|
110
111
|
}
|
|
111
|
-
// Called from PypilotClient 'value' event. Returns
|
|
112
|
-
//
|
|
112
|
+
// Called from PypilotClient 'value' event. Returns the field that
|
|
113
|
+
// changed (so the caller can push a field-scoped delta and avoid
|
|
114
|
+
// dragging siblings with stale values), or null if nothing changed.
|
|
115
|
+
//
|
|
116
|
+
// Rev352 (Carlos, 2026-09-30, trace analysis): previously returned
|
|
117
|
+
// boolean and the caller line 729 defaulted to fields="all" — that
|
|
118
|
+
// emitted state+engaged+target+mode+availableActions with whatever
|
|
119
|
+
// apProvider.data held at that instant. When pypilot took seconds
|
|
120
|
+
// to echo an engage (external tack from mando/nativo), any other
|
|
121
|
+
// pypilot value arriving in the meantime triggered fields="all"
|
|
122
|
+
// publishes with the stale engaged=false. The next echo produced
|
|
123
|
+
// fields="all" with engaged=true, but subscription manager's
|
|
124
|
+
// minPeriod:500 collapsed the pair and the visor saw only false.
|
|
125
|
+
// Field-scoping the push kills the collision at source.
|
|
113
126
|
receiveValue(name, value) {
|
|
114
127
|
let changed = false;
|
|
128
|
+
let changedField = null;
|
|
115
129
|
switch (name) {
|
|
116
130
|
case "ap.heading_command":
|
|
117
131
|
if (typeof value === "number") {
|
|
@@ -132,30 +146,105 @@ class AutopilotProvider {
|
|
|
132
146
|
// Real echo landed - clear pending, keep our value (may be
|
|
133
147
|
// slightly more precise than the echo). No change signal
|
|
134
148
|
// needed since data.target already equals the pending value.
|
|
149
|
+
this.onStageEvent?.("provider", {
|
|
150
|
+
path: "ap.heading_command",
|
|
151
|
+
decision: "echo_dropped_match",
|
|
152
|
+
reason: "diff_within_tol",
|
|
153
|
+
rawValue: value,
|
|
154
|
+
normalizedValue: rad,
|
|
155
|
+
pendingValue: this.pendingTarget.value,
|
|
156
|
+
diffRad: diff,
|
|
157
|
+
});
|
|
135
158
|
this.pendingTarget = null;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
// Rev350 (Carlos, 2026-09-29, cross-check LLM audit): diff
|
|
162
|
+
// GRANDE dentro de la ventana pending. Antes: descartábamos
|
|
163
|
+
// el valor y no limpiábamos pending → si pypilot no volvía
|
|
164
|
+
// a publicar tras el drop, data.target quedaba rancio hasta
|
|
165
|
+
// que expiraba pending Y pypilot re-emitía. Confirmado por
|
|
166
|
+
// GPT-Codex: "la ventana dura 2.5 s pero el valor descartado
|
|
167
|
+
// puede perderse indefinidamente". Ver memoria
|
|
168
|
+
// [[backend-echo-cancel-drops-external-override]].
|
|
169
|
+
//
|
|
170
|
+
// Ahora: interpretamos diff > TOL como OVERRIDE EXTERNO
|
|
171
|
+
// genuino (mando físico / UI nativo cambió target mientras
|
|
172
|
+
// nuestra escritura estaba pendiente). Aplicamos el valor,
|
|
173
|
+
// liberamos el gate, notificamos cambio. El caso Rev68
|
|
174
|
+
// original (echo con mismo valor) sigue funcionando: cae en
|
|
175
|
+
// la rama `<= TOL` y limpia pending sin cambiar target.
|
|
176
|
+
const pendingSnap = this.pendingTarget.value;
|
|
177
|
+
this.pendingTarget = null;
|
|
178
|
+
this.onStageEvent?.("provider", {
|
|
179
|
+
path: "ap.heading_command",
|
|
180
|
+
decision: "external_override_applied",
|
|
181
|
+
reason: "diff_over_tol_in_pending_window",
|
|
182
|
+
rawValue: value,
|
|
183
|
+
normalizedValue: rad,
|
|
184
|
+
pendingValue: pendingSnap,
|
|
185
|
+
diffRad: diff,
|
|
186
|
+
});
|
|
187
|
+
if (rad !== this.data.target) {
|
|
188
|
+
this.data.target = rad;
|
|
189
|
+
changed = true;
|
|
190
|
+
changedField = "target";
|
|
136
191
|
}
|
|
137
|
-
// Whether match or stale, do not overwrite data.target inside
|
|
138
|
-
// the pending window.
|
|
139
192
|
break;
|
|
140
193
|
}
|
|
141
|
-
|
|
142
|
-
this.data.target
|
|
143
|
-
|
|
194
|
+
{
|
|
195
|
+
const prev = this.data.target;
|
|
196
|
+
if (rad !== prev) {
|
|
197
|
+
this.data.target = rad;
|
|
198
|
+
changed = true;
|
|
199
|
+
changedField = "target";
|
|
200
|
+
}
|
|
201
|
+
this.onStageEvent?.("provider", {
|
|
202
|
+
path: "ap.heading_command",
|
|
203
|
+
decision: changed ? "accepted_change" : "accepted_noop",
|
|
204
|
+
reason: "no_pending_window",
|
|
205
|
+
rawValue: value,
|
|
206
|
+
normalizedValue: rad,
|
|
207
|
+
prevValue: prev,
|
|
208
|
+
});
|
|
144
209
|
}
|
|
145
210
|
}
|
|
146
211
|
else if (value === false && this.data.target !== null) {
|
|
212
|
+
const prev = this.data.target;
|
|
147
213
|
this.data.target = null;
|
|
148
214
|
this.pendingTarget = null;
|
|
149
215
|
changed = true;
|
|
216
|
+
changedField = "target";
|
|
217
|
+
this.onStageEvent?.("provider", {
|
|
218
|
+
path: "ap.heading_command",
|
|
219
|
+
decision: "accepted_change",
|
|
220
|
+
reason: "value_false_cleared_target",
|
|
221
|
+
rawValue: value,
|
|
222
|
+
prevValue: prev,
|
|
223
|
+
});
|
|
150
224
|
}
|
|
151
225
|
break;
|
|
152
226
|
case "ap.mode":
|
|
153
|
-
if (typeof value === "string"
|
|
154
|
-
this.data.mode
|
|
155
|
-
if (
|
|
156
|
-
this.data.
|
|
227
|
+
if (typeof value === "string") {
|
|
228
|
+
const prev = this.data.mode;
|
|
229
|
+
if (value !== prev) {
|
|
230
|
+
this.data.mode = value;
|
|
231
|
+
if (this.data.options.modes.length === 0) {
|
|
232
|
+
this.data.options.modes.push(value);
|
|
233
|
+
}
|
|
234
|
+
changed = true;
|
|
235
|
+
// Rev352: mode change also affects recomputeActions (some
|
|
236
|
+
// actions like courseCurrentPoint gate on mode). Push "all"
|
|
237
|
+
// so state+engaged+target+mode+availableActions stay in
|
|
238
|
+
// sync. This IS a genuine multi-field change, not a
|
|
239
|
+
// spurious drag.
|
|
240
|
+
changedField = "all";
|
|
157
241
|
}
|
|
158
|
-
|
|
242
|
+
this.onStageEvent?.("provider", {
|
|
243
|
+
path: "ap.mode",
|
|
244
|
+
decision: changed ? "accepted_change" : "accepted_noop",
|
|
245
|
+
rawValue: value,
|
|
246
|
+
prevValue: prev,
|
|
247
|
+
});
|
|
159
248
|
}
|
|
160
249
|
break;
|
|
161
250
|
case "ap.modes":
|
|
@@ -171,6 +260,7 @@ class AutopilotProvider {
|
|
|
171
260
|
// stayed unavailable until the next engage/disengage.
|
|
172
261
|
this.recomputeActions();
|
|
173
262
|
changed = true;
|
|
263
|
+
changedField = "all";
|
|
174
264
|
}
|
|
175
265
|
break;
|
|
176
266
|
case "ap.enabled": {
|
|
@@ -185,23 +275,51 @@ class AutopilotProvider {
|
|
|
185
275
|
// engage-disengage-engage. Ignore echoes that don't match the
|
|
186
276
|
// pending intent while the pending window is open.
|
|
187
277
|
if (this.pendingEngaged && Date.now() < this.pendingEngaged.until) {
|
|
188
|
-
|
|
278
|
+
const pendVal = this.pendingEngaged.value;
|
|
279
|
+
if (eng === pendVal) {
|
|
189
280
|
this.pendingEngaged = null;
|
|
281
|
+
this.onStageEvent?.("provider", {
|
|
282
|
+
path: "ap.enabled",
|
|
283
|
+
decision: "echo_dropped_match",
|
|
284
|
+
rawValue: value,
|
|
285
|
+
normalizedValue: eng,
|
|
286
|
+
pendingValue: pendVal,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
this.onStageEvent?.("provider", {
|
|
291
|
+
path: "ap.enabled",
|
|
292
|
+
decision: "echo_dropped_stale",
|
|
293
|
+
rawValue: value,
|
|
294
|
+
normalizedValue: eng,
|
|
295
|
+
pendingValue: pendVal,
|
|
296
|
+
});
|
|
190
297
|
}
|
|
191
298
|
break;
|
|
192
299
|
}
|
|
193
300
|
const st = eng ? "enabled" : "disabled";
|
|
301
|
+
const prevState = this.data.state;
|
|
302
|
+
const prevEngaged = this.data.engaged;
|
|
194
303
|
if (this.data.state !== st || this.data.engaged !== eng) {
|
|
195
304
|
this.data.state = st;
|
|
196
305
|
this.data.engaged = eng;
|
|
197
306
|
changed = true;
|
|
307
|
+
changedField = "engaged";
|
|
198
308
|
}
|
|
309
|
+
this.onStageEvent?.("provider", {
|
|
310
|
+
path: "ap.enabled",
|
|
311
|
+
decision: changed ? "accepted_change" : "accepted_noop",
|
|
312
|
+
rawValue: value,
|
|
313
|
+
normalizedValue: eng,
|
|
314
|
+
prevValue: prevEngaged,
|
|
315
|
+
prevState,
|
|
316
|
+
});
|
|
199
317
|
break;
|
|
200
318
|
}
|
|
201
319
|
}
|
|
202
320
|
if (changed)
|
|
203
321
|
this.recomputeActions();
|
|
204
|
-
return
|
|
322
|
+
return changedField;
|
|
205
323
|
}
|
|
206
324
|
markOffline() {
|
|
207
325
|
if (this.data.state !== "off-line" || this.data.engaged) {
|
|
@@ -266,33 +384,58 @@ class AutopilotProvider {
|
|
|
266
384
|
// the retry backoff, the stale isStale() check aborts the retry
|
|
267
385
|
// and we throw without mutating optimistic state.
|
|
268
386
|
const gen = this._bumpEngageGen();
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
387
|
+
// Rev351 (Carlos, 2026-09-29, sea-trial QA Rev350): optimist STATE
|
|
388
|
+
// + pending window BEFORE the await. Antes: pendingEngaged sólo se
|
|
389
|
+
// seteaba tras el ACK de pypilot. Durante ese await (~10 s en engage
|
|
390
|
+
// observados por Carlos en localhost), el watch 2 Hz de pypilot
|
|
391
|
+
// seguía emitiendo `ap.enabled=false` (stale) y el receiveValue lo
|
|
392
|
+
// aplicaba como estado real → push SK engaged=false → visor
|
|
393
|
+
// revertía la UI optimista. Disengage no lo sufría porque pypilot
|
|
394
|
+
// aplica disable en < 500 ms y el echo real llegaba inmediato.
|
|
395
|
+
//
|
|
396
|
+
// Fix: setear pendingEngaged + data.state/engaged + notifyChanged
|
|
397
|
+
// ANTES del _setWithRetry, con ventana extendida a 15 s para cubrir
|
|
398
|
+
// el peor caso de latencia pypilot. Si el set falla, revertimos.
|
|
399
|
+
const prevState = this.data.state;
|
|
400
|
+
const prevEngaged = this.data.engaged;
|
|
401
|
+
this.pendingEngaged = { value: eng, until: Date.now() + 15000 };
|
|
402
|
+
this.data.state = apSt;
|
|
403
|
+
this.data.engaged = eng;
|
|
404
|
+
this.recomputeActions();
|
|
405
|
+
// Rev84: publish ONLY the engaged/state/actions delta - do NOT
|
|
406
|
+
// include target. If setTarget was called in parallel, its own
|
|
407
|
+
// notifyChanged("target") will publish the new target value
|
|
408
|
+
// independently. Publishing target here would carry the STALE
|
|
409
|
+
// apProvider.data.target and clobber JS state.target if this
|
|
410
|
+
// delta happens to land second.
|
|
411
|
+
this.notifyChanged("engaged");
|
|
412
|
+
// Rev254 (Carlos audit): reject the write on offline socket.
|
|
272
413
|
// Rev255: retry with backoff to tolerate socket.io reconnects.
|
|
414
|
+
// Rev351: on failure, revert the optimist so the UI reflects
|
|
415
|
+
// reality (unless a newer bump has already overwritten us).
|
|
273
416
|
if (!(await this._setWithRetry("ap.enabled", eng, () => this.engageGen !== gen))) {
|
|
417
|
+
if (this.engageGen === gen) {
|
|
418
|
+
this.pendingEngaged = null;
|
|
419
|
+
this.data.state = prevState;
|
|
420
|
+
this.data.engaged = prevEngaged;
|
|
421
|
+
this.recomputeActions();
|
|
422
|
+
this.notifyChanged("engaged");
|
|
423
|
+
}
|
|
274
424
|
if (this.engageGen !== gen) {
|
|
275
425
|
throw new Error("engage/disengage superseded by newer order");
|
|
276
426
|
}
|
|
277
427
|
throw new Error("pypilot offline: engage/disengage not delivered");
|
|
278
428
|
}
|
|
279
429
|
// Rev271: last-check after the successful set - a newer bump could
|
|
280
|
-
// have won the race between the emit and this line.
|
|
281
|
-
//
|
|
430
|
+
// have won the race between the emit and this line. If superseded,
|
|
431
|
+
// the winning order has already published its own optimist; leave
|
|
432
|
+
// state alone and let the winner's echo reconcile.
|
|
282
433
|
if (this.engageGen !== gen) {
|
|
283
434
|
throw new Error("engage/disengage superseded after write");
|
|
284
435
|
}
|
|
285
|
-
|
|
286
|
-
|
|
436
|
+
// Rev351: pypilot ACK-eó el set. Reduce la ventana pending al
|
|
437
|
+
// valor normal (2.5 s) porque el eco real llegará en < 1 s típico.
|
|
287
438
|
this.pendingEngaged = { value: eng, until: Date.now() + TARGET_PENDING_MS };
|
|
288
|
-
this.recomputeActions();
|
|
289
|
-
// Rev84: publish ONLY the engaged/state/actions delta - do NOT
|
|
290
|
-
// include target. If setTarget was called in parallel, its own
|
|
291
|
-
// notifyChanged("target") will publish the new target value
|
|
292
|
-
// independently. Publishing target here would carry the STALE
|
|
293
|
-
// apProvider.data.target and clobber JS state.target if this
|
|
294
|
-
// delta happens to land second.
|
|
295
|
-
this.notifyChanged("engaged");
|
|
296
439
|
return eng;
|
|
297
440
|
}
|
|
298
441
|
async setMode(mode) {
|