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 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 tools like [KIP](https://kip.signalk.org),
7
- WilhelmSK and freeboard can drive and monitor the autopilot out of the
8
- box, and a strong focus on **reliability, ease of use and ease of
9
- tuning**: the plugin itself watches how your autopilot is behaving and
10
- quietly suggests how to sharpen its settings, so you spend more time
11
- sailing and less time guessing which slider to move.
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 KIP /
82
- WilhelmSK.
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 KIP.
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) — WilhelmSK,
122
- freeboard and KIP talk to this plugin as the canonical
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. **KIP action paths** — Simple Switch for engage, nudge, tack,
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 KIP widgets or WilhelmSK
156
- dashboards wired to 1.0.0 paths, see the full migration table in
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
- WilhelmSK, freeboard and other clients can drive pypilot through the canonical
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 | WilhelmSK / freeboard / KIP won't see the AP. Only useful if you exclusively use this plugin's own webapp. |
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 KIP/WilhelmSK never
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
- sustainSec: 15,
57
- description: `RMS heading error exceeds ${(exports.RULE_HEADING_ERR_RAD * 180 / Math.PI) | 0}° with the AP engaged.`,
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 > exports.RULE_HEADING_ERR_RAD;
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 KIP /
217
- // WilhelmSK show the same red banner the visor card does. Only
218
- // fires once the baseline is actually learned (deviation != null).
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
- severity: rule.severity,
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 true if apData changed
112
- // and an autopilotUpdate should be pushed.
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
- if (rad !== this.data.target) {
142
- this.data.target = rad;
143
- changed = true;
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" && value !== this.data.mode) {
154
- this.data.mode = value;
155
- if (this.data.options.modes.length === 0) {
156
- this.data.options.modes.push(value);
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
- changed = true;
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
- if (eng === this.pendingEngaged.value) {
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 changed;
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
- // Rev254 (Carlos audit): reject the write BEFORE mutating optimistic
270
- // state if the pypilot socket is offline. Previously the visor was
271
- // told the order succeeded even though nothing left the plugin.
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. Do not mutate
281
- // optimistic state in that case; the winner's own setState will.
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
- this.data.state = apSt;
286
- this.data.engaged = eng;
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) {