signalk-pypilot-newui 2.7.4 → 2.8.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,160 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0 — 2026-09-23 — Sea-trial critical fixes + intelligence layer
4
+
5
+ ### Fixed (critical, from real sea trial)
6
+
7
+ - **pypilot-disconnected alarm no longer panics on a 1 s hiccup.**
8
+ The banner used to fire after just 1 second of socket silence at
9
+ "alarm" severity (audio channel forced). In a marine environment
10
+ where a 4G router routinely blips for a few seconds on the SIM,
11
+ this generated constant false positives with unavoidable sound.
12
+ New default: **15 s sustain, "warn" severity** (silent banner). The
13
+ window is now runtime-configurable via
14
+ `POST /supervisor/config` field `alarmPypilotDiscSec` (range
15
+ 3..300 s). Applies to `notifications.autopilot.pypilot-disconnected`.
16
+
17
+ - **Wind-mode nudge buttons now steer the bow to the labelled side,
18
+ independent of AWA sign.** Before, `modeSign = -Math.sign(AWA)`
19
+ meant a `+10` on port-side ceñida (AWA=-45°) inverted the sign and
20
+ moved the target the wrong way, so the bow swung to the opposite
21
+ side of the button label. Fixed to `modeSign = -1` always in wind
22
+ mode; the four cases (AWA±45 × button±10) now all match the
23
+ physical expectation of "swing the bow to the labelled side".
24
+
25
+ - **Mode change compass↔wind no longer strands `heading_command` in
26
+ the previous space.** pypilot's `ap.heading_command` lives in
27
+ different value spaces per mode (compass=heading, wind=AWA,
28
+ true wind=TWA). Before, changing modes left the target number
29
+ unchanged, so a `heading_command=90°` in compass became a 90° AWA
30
+ target in wind mode and the pilot swung hard to reach it. The
31
+ provider now **re-anchors** `heading_command` to the current
32
+ measurement in the destination space (`wind.direction` /
33
+ `wind.true_direction` / `ap.heading`) right after the mode delta
34
+ is confirmed. Same-space transitions (compass↔gps↔nav,
35
+ wind↔true wind) skip the re-anchor. Decision extracted to
36
+ `src/mode-reanchor.ts` with 24 unit tests.
37
+
38
+ - **Profile selection cannot be overwritten to "default" when opening
39
+ the visor on a fresh device.** The `<select id="profile-select">`
40
+ starts with the DOM-default first option ("default") until the
41
+ authoritative delta lands. A stray change event fired during that
42
+ gap could write "default" to pypilot, overriding the sailor's
43
+ active profile. The visor now refuses to send profile changes
44
+ until it has received at least one authoritative `profile` delta
45
+ from the backend, and skips identity writes.
46
+
47
+ - **Gust temp-heavy strategy respects manual profile changes.** If
48
+ the sailor picks a different profile during the 30 s temp-heavy
49
+ window, the automatic restore no longer overwrites that choice.
50
+ It fires an advisory notification instead.
51
+
52
+ - **Runaway pilot defence.** `adjustTarget` clamp tightened from
53
+ 573° to 90°. A single adjustTarget above 90° is either a caller
54
+ bug or a corrupt payload; refused at the boundary before it
55
+ becomes an untraceable pypilot sweep. Nudges (max 100° per press)
56
+ and tacks (go through `setTarget` absolute) are unaffected.
57
+
58
+ ### New — Doctor rule set
59
+
60
+ - **Rule 5: step-response episode analysis.** When ≥3 correction
61
+ episodes closed during the Doctor session's window, high average
62
+ overshoot suggests raising D (+15% or +25% if overshoot > 35%),
63
+ and repeated timeouts flag "AP cannot reach the target — hardware
64
+ authority".
65
+ - **Rule 6: downwind roll advisory.** With Roll FF disabled and a
66
+ heel RMS > 5° on downwind engaged samples, Doctor suggests
67
+ activating the Roll feed-forward slider.
68
+
69
+ ### New — Tuning & profiles
70
+
71
+ - **Three-slider tuning panel (backend).** New endpoints
72
+ `GET/POST /tuning/knobs` map Aggressivity + Understeer/Oversteer +
73
+ Balance-Heading/Rate onto raw P/I/D/DD via `src/tuning-knobs.ts`.
74
+ Identity at (50, 0, 0); all factors clamped to [0.3×, 3.0×]
75
+ baseline. 26 unit tests.
76
+ - **Profile metadata.** `GET /profiles/metadata` + `PUT/DELETE
77
+ /profiles/metadata/:name` for tagging each pypilot profile with a
78
+ condition (light/medium/heavy/motor/custom) and free-text notes.
79
+ Persisted via `savePluginOptions`.
80
+ - **Profile advisor.** Watches the 1 min KPI window and notifies
81
+ when the boat has been tracking sustainedly badly ("consider a
82
+ more aggressive profile") or over-tightly with high servo duty
83
+ ("consider a less aggressive profile"). Never applies anything;
84
+ emits a `notifications.autopilot.pypilot.profileAdvisor` visual
85
+ banner with 5 min cooldown.
86
+ - **Profile change audit log.** New endpoint
87
+ `GET /profile-change-log` shows the last 50 profile transitions
88
+ with attributed source (user / auto-profile / gust-heavy /
89
+ gust-heavy-restore / doctor / external). Answers "who changed my
90
+ profile?" for surprise switches.
91
+
92
+ ### New — Correction episodes + config backup
93
+
94
+ - **Correction episode metrics.** `src/episodes.ts` detects each
95
+ target-step correction and records step-response metrics
96
+ (rise time, overshoot %, settling time, steady-state error).
97
+ Exposed at `GET /episodes` with a good/acceptable/poor quality
98
+ band. Feeds Rule 5 of the Doctor.
99
+ - **Config backup / restore.** `GET /config/export` downloads a
100
+ JSON bundle of plugin options + persistent pypilot settings
101
+ (P/I/D/DD, tack params, servo max_current, etc.). `POST
102
+ /config/import` accepts a bundle and applies it, filtering runtime
103
+ telemetry keys defensively.
104
+
105
+ ### New — Physical control terms
106
+
107
+ - **Roll feed-forward (off by default).** `src/roll-ff.ts` filters
108
+ the dynamic component of `navigation.attitude.roll` and computes a
109
+ small pre-emptive shift of the commanded heading, gated to
110
+ |TWA|>90°. Currently PUBLISHED at
111
+ `steering.autopilot.pypilot.tuning.rollFf.*` for observation; not
112
+ applied to the pilot yet — a later Rev flips that switch after sea
113
+ trial validates the term. Slider lives at `POST
114
+ /supervisor/config` field `rollFfGain` (0..2).
115
+ - **Gust heel-confirmation gate.** The Rev167 gust supervisor now
116
+ cross-checks the AWS jump against a >2° heel change during the
117
+ same 5 s window. A sensor spike that did not physically load the
118
+ boat is suppressed. Falls open when heel data is absent.
119
+
120
+ ### New — Alarms & telemetry
121
+
122
+ - **Persistent servo error log.** JSONL under the plugin dataDir
123
+ records every servo-* alarm transition with a 30 s peak summary
124
+ of pre-fault telemetry (max A, max °C, min V, max heading error).
125
+ Exposed at `GET /servo-error-log`.
126
+ - **Pre-fault trim warnings.** New "info"-severity rules
127
+ `servo-current-trim` (>75% of overcurrent limit) and
128
+ `rudder-range-trim` (>40° absolute) fire *before* the fault-severity
129
+ rules to give the sailor time to trim.
130
+ - **Configurable alarm thresholds.** `alarmLowVoltageV` (8..14 V),
131
+ `alarmServoTempC` (40..85 °C), `alarmServoMotorTempC` (40..90 °C)
132
+ now exposed via `/supervisor/config` and threaded through the
133
+ alarm rules per install.
134
+
135
+ ### New — NAV mode observability
136
+
137
+ - **APB source preview.** `GET /nav/apb-preview` shows the current
138
+ active-waypoint bearing computed both ways — direct `bearingTrue`
139
+ and plotter-provided `steerTo` — plus their divergence. Read-only
140
+ for now; a later Rev may switch to plugin-side NAV mode after sea
141
+ trial validates the preference.
142
+
143
+ ### Under the hood
144
+
145
+ - 8 new source modules with 177 unit tests (up from 5). All
146
+ additive — no existing endpoint or SK path changed contract.
147
+ - Rev sequence 281..297 on branch `feat/blind-batch-sep16`.
148
+
149
+ ## 2.7.5 — 2026-09-15 — Hotfix on 2.7.4 (visor did not load)
150
+
151
+ **Deprecates 2.7.4.** The restore verdict fix that landed in 2.7.4
152
+ declared `const wantMode` twice in the same block scope
153
+ (`SyntaxError: redeclaration of const wantMode`) which prevented
154
+ `app.js` from loading. This release removes the duplicate and keeps
155
+ everything else in 2.7.4 unchanged. If you installed 2.7.4, please
156
+ update to 2.7.5.
157
+
3
158
  ## 2.7.4 — 2026-09-15 — Async control tightening + uppercase accents
4
159
 
5
160
  Closes three findings from the external Rev277 review and finishes
package/dist/alarms.js CHANGED
@@ -15,12 +15,15 @@
15
15
  // the emitted notification timestamp, so KIP/WilhelmSK never
16
16
  // 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_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 = void 0;
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;
19
19
  const SEV_TO_SK = {
20
20
  info: "nominal",
21
21
  warn: "warn",
22
22
  alarm: "alarm",
23
23
  };
24
+ /** Canonical defaults, exposed for the /alarms/thresholds GET and for
25
+ * the visor to know the safe range. */
26
+ exports.DEFAULT_MOTOR_TEMP_C = 70;
24
27
  // -------- Built-in rules --------
25
28
  // Thresholds picked to be sensible defaults for a cruising sailboat.
26
29
  // A future Rev exposes them via plugin schema.
@@ -30,12 +33,20 @@ exports.RULE_UNABLE_DUTY_MIN = 0.7; // 70% duty
30
33
  exports.RULE_SERVO_OVERCURR_A = 5.0;
31
34
  exports.RULE_SERVO_TEMP_C = 60;
32
35
  exports.RULE_LOW_VOLTAGE_V = 11.0;
33
- // Rev256 lowered this from 10 s to 3 s. Rev258 (Carlos): with the new
34
- // pong-aware `healthy` check the socket is flagged offline after ~8 s
35
- // of missed pings (much earlier than TCP heartbeat would), so we can
36
- // afford a very short sustain here. Total from real pypilot power-off
37
- // to alarm firing: ~8 s + 1 s = 9 s.
38
- exports.RULE_PYPILOT_DISC_SEC = 1;
36
+ // Rev287 (E1): trim warnings fire BEFORE the corresponding fault
37
+ // so the sailor sees the drift toward the limit and can trim sails
38
+ // or ease pressure before the fault-severity rule kicks in.
39
+ exports.RULE_SERVO_TRIM_FRAC = 0.75; // 75% of overcurrent
40
+ exports.RULE_RUDDER_TRIM_RAD = 40 * Math.PI / 180; // 40 deg absolute
41
+ // Rev292 (Carlos, navigating): the previous 1 s default was too eager
42
+ // in a marine environment where a 4G router routinely blips for a few
43
+ // seconds on the SIM. The severity was also cranked to "alarm" which
44
+ // forced the audio channel and made the pop-up unavoidable. New
45
+ // default: 15 s sustain (comfortable with the 8 s pong-aware healthy
46
+ // check → total ~23 s to fire) and "warn" severity so the visor banner
47
+ // shows but does not force sound. Sailors who want stricter behaviour
48
+ // can drop sustainSec via /supervisor/config.
49
+ exports.RULE_PYPILOT_DISC_SEC = 15;
39
50
  exports.DEFAULT_RULES = [
40
51
  {
41
52
  id: "heading-deviation",
@@ -114,19 +125,68 @@ exports.DEFAULT_RULES = [
114
125
  },
115
126
  message: (c) => `Servo overcurrent ${(c.sample?.servoCurrent ?? 0).toFixed(1)} A`,
116
127
  },
128
+ {
129
+ // Rev287 (E1): pre-fault trim warning. Fires when the servo has
130
+ // been drawing >75% of the overcurrent limit sustained a few
131
+ // seconds. Info severity - it precedes the fault-severity rule
132
+ // above by design so the sailor has time to react.
133
+ id: "servo-current-trim",
134
+ label: "Servo current approaching limit",
135
+ severity: "info",
136
+ defaultEnabled: true,
137
+ sustainSec: 5,
138
+ description: `Servo drawing over ${(exports.RULE_SERVO_TRIM_FRAC * 100).toFixed(0)}% of the overcurrent limit — ease pressure or trim sails.`,
139
+ evaluate: (c) => {
140
+ if (!c.sample || typeof c.sample.servoCurrent !== "number")
141
+ return false;
142
+ const thr = exports.RULE_SERVO_OVERCURR_A * exports.RULE_SERVO_TRIM_FRAC;
143
+ const a = c.sample.servoCurrent;
144
+ // Don't double-fire with the fault-severity rule; back off when
145
+ // already in the overcurrent band.
146
+ if (a > exports.RULE_SERVO_OVERCURR_A)
147
+ return false;
148
+ return a > thr;
149
+ },
150
+ message: (c) => `Servo current ${(c.sample?.servoCurrent ?? 0).toFixed(1)} A (limit ${exports.RULE_SERVO_OVERCURR_A.toFixed(1)} A)`,
151
+ },
152
+ {
153
+ // Rev287 (E1): rudder pre-fault trim warning. Fires when the rudder
154
+ // has been sustainedly close to its stop, which usually precedes a
155
+ // servo out-of-range fault. Info severity.
156
+ id: "rudder-range-trim",
157
+ label: "Rudder near limit",
158
+ severity: "info",
159
+ defaultEnabled: true,
160
+ sustainSec: 3,
161
+ description: `Rudder deflection above ${(exports.RULE_RUDDER_TRIM_RAD * 180 / Math.PI).toFixed(0)}° — servo is near its end of travel.`,
162
+ evaluate: (c) => {
163
+ if (!c.sample || typeof c.sample.rudder !== "number")
164
+ return false;
165
+ return Math.abs(c.sample.rudder) > exports.RULE_RUDDER_TRIM_RAD;
166
+ },
167
+ message: (c) => {
168
+ const r = (c.sample?.rudder ?? 0) * 180 / Math.PI;
169
+ const side = r >= 0 ? "stbd" : "port";
170
+ return `Rudder ${Math.abs(r).toFixed(0)}° ${side} (limit ${(exports.RULE_RUDDER_TRIM_RAD * 180 / Math.PI).toFixed(0)}°)`;
171
+ },
172
+ },
117
173
  {
118
174
  id: "servo-temp-high",
119
175
  label: "Servo temperature high",
120
176
  severity: "warn",
121
177
  defaultEnabled: true,
122
178
  sustainSec: 10,
123
- description: `Servo controller temperature above ${exports.RULE_SERVO_TEMP_C} °C.`,
179
+ description: `Servo controller temperature above the configured threshold (default ${exports.RULE_SERVO_TEMP_C} °C).`,
124
180
  evaluate: (c) => {
125
181
  if (!c.sample || typeof c.sample.servoTemp !== "number")
126
182
  return false;
127
- return c.sample.servoTemp > exports.RULE_SERVO_TEMP_C;
183
+ const thr = c.thresholds?.servoTempC ?? exports.RULE_SERVO_TEMP_C;
184
+ return c.sample.servoTemp > thr;
185
+ },
186
+ message: (c) => {
187
+ const thr = c.thresholds?.servoTempC ?? exports.RULE_SERVO_TEMP_C;
188
+ return `Servo temp ${(c.sample?.servoTemp ?? 0).toFixed(0)} °C (limit ${thr} °C)`;
128
189
  },
129
- message: (c) => `Servo temp ${(c.sample?.servoTemp ?? 0).toFixed(0)} °C`,
130
190
  },
131
191
  {
132
192
  // Rev156 (Carlos): coil temperature complements servo-temp-high,
@@ -138,13 +198,17 @@ exports.DEFAULT_RULES = [
138
198
  severity: "warn",
139
199
  defaultEnabled: true,
140
200
  sustainSec: 15,
141
- description: "Motor coil temperature above 70 C.",
201
+ description: `Motor coil temperature above the configured threshold (default ${exports.DEFAULT_MOTOR_TEMP_C} °C).`,
142
202
  evaluate: (c) => {
143
203
  if (!c.sample || typeof c.sample.servoMotorTemp !== "number")
144
204
  return false;
145
- return c.sample.servoMotorTemp > 70;
205
+ const thr = c.thresholds?.servoMotorTempC ?? exports.DEFAULT_MOTOR_TEMP_C;
206
+ return c.sample.servoMotorTemp > thr;
207
+ },
208
+ message: (c) => {
209
+ const thr = c.thresholds?.servoMotorTempC ?? exports.DEFAULT_MOTOR_TEMP_C;
210
+ return `Servo motor temp ${(c.sample?.servoMotorTemp ?? 0).toFixed(0)} °C (limit ${thr} °C)`;
146
211
  },
147
- message: (c) => `Servo motor temp ${(c.sample?.servoMotorTemp ?? 0).toFixed(0)} °C`,
148
212
  },
149
213
  {
150
214
  // Rev156 (Carlos): ServoHealth already grades the drive as
@@ -175,13 +239,17 @@ exports.DEFAULT_RULES = [
175
239
  severity: "warn",
176
240
  defaultEnabled: true,
177
241
  sustainSec: 10,
178
- description: `Servo battery voltage below ${exports.RULE_LOW_VOLTAGE_V} V.`,
242
+ description: `Servo battery voltage below the configured threshold (default ${exports.RULE_LOW_VOLTAGE_V} V).`,
179
243
  evaluate: (c) => {
180
244
  if (!c.sample || typeof c.sample.servoVoltage !== "number")
181
245
  return false;
182
- return c.sample.servoVoltage < exports.RULE_LOW_VOLTAGE_V;
246
+ const thr = c.thresholds?.lowVoltageV ?? exports.RULE_LOW_VOLTAGE_V;
247
+ return c.sample.servoVoltage < thr;
248
+ },
249
+ message: (c) => {
250
+ const thr = c.thresholds?.lowVoltageV ?? exports.RULE_LOW_VOLTAGE_V;
251
+ return `Low voltage ${(c.sample?.servoVoltage ?? 0).toFixed(1)} V (limit ${thr} V)`;
183
252
  },
184
- message: (c) => `Low voltage ${(c.sample?.servoVoltage ?? 0).toFixed(1)} V`,
185
253
  },
186
254
  {
187
255
  id: "sensor-lost",
@@ -210,10 +278,21 @@ exports.DEFAULT_RULES = [
210
278
  {
211
279
  id: "pypilot-disconnected",
212
280
  label: "Pypilot desconectado",
213
- severity: "alarm",
281
+ // Rev292 (Carlos, navigating): dropped from "alarm" to "warn". A
282
+ // pypilot socket that stops sending pings does NOT put the boat in
283
+ // immediate danger — the sailor can still steer manually while the
284
+ // sailors's phone / router / SIM recovers. Reserve "alarm" (audio
285
+ // channel) for rules that actually mean "act now" (unable-to-steer,
286
+ // servo-overcurrent).
287
+ severity: "warn",
214
288
  defaultEnabled: true,
289
+ // sustainSec is now dynamic: threshold override wins; falls back
290
+ // to the module default (15 s in Rev292+). We keep the field here
291
+ // for the description; the actual sustain used is stateful because
292
+ // AlarmEngine reads it once at register time — see the sustain
293
+ // override handled by evalWithSustainOverride below.
215
294
  sustainSec: exports.RULE_PYPILOT_DISC_SEC,
216
- description: `pypilot_web socket has been down for ${exports.RULE_PYPILOT_DISC_SEC} seconds.`,
295
+ description: `pypilot_web socket has been down for the configured window (default ${exports.RULE_PYPILOT_DISC_SEC} s).`,
217
296
  evaluate: (c) => !c.connected,
218
297
  message: (c) => {
219
298
  if (c.disconnectedSinceMs == null)
@@ -315,6 +394,21 @@ class AlarmEngine {
315
394
  }
316
395
  return changed;
317
396
  }
397
+ /** Rev292: change the sustain window of one rule at runtime, e.g.
398
+ * when the sailor updates alarmPypilotDiscSec in /supervisor/config.
399
+ * Returns true on success, false if the rule id is unknown. Values
400
+ * outside a sane band are clamped, never rejected. */
401
+ setRuleSustain(ruleId, sec) {
402
+ const rule = this.rules.get(ruleId);
403
+ if (!rule)
404
+ return false;
405
+ const clamped = Math.max(1, Math.min(600, Math.floor(sec)));
406
+ // Mutate the RuleDef in place. The RuleDef object was captured by
407
+ // reference in this.rules.set(), so subsequent ticks pick up the
408
+ // new value on their next evaluate.
409
+ rule.sustainSec = clamped;
410
+ return true;
411
+ }
318
412
  fire(s, rule, ctx) {
319
413
  s.active = true;
320
414
  s.activeSinceMs = ctx.nowMs;
@@ -11,17 +11,20 @@
11
11
  // single socket to pypilot_web. See NOTICE + CHANGELOG for attribution.
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.AutopilotProvider = void 0;
14
+ const mode_reanchor_1 = require("./mode-reanchor");
14
15
  const DEG_TO_RAD = Math.PI / 180;
15
16
  const RAD_TO_DEG = 180 / Math.PI;
16
17
  // Rev68: after a local target write we ignore pypilot echoes that do not
17
18
  // match, for up to this many ms. 2 s covers the worst RTT + pypilot apply
18
19
  // latency observed on Tunatunes (~500-800 ms). See receiveValue for why.
19
20
  const TARGET_PENDING_MS = 2500;
20
- // Tolerance for treating an echoed heading_command as "the value we asked
21
- // for" vs "stale echo of the previous value". Pypilot stores heading_command
22
- // as float and echoes verbatim in practice, but allow a small margin for
23
- // rounding on the wire.
24
- const TARGET_ECHO_TOL_RAD = 2 * DEG_TO_RAD;
21
+ // Rev280 (audit T02): tightened from 2° to 0.1°. Pypilot echoes
22
+ // `heading_command` verbatim; the previous 2° window was wide enough
23
+ // that a stale echo of the previous value (e.g. 100°) matched a fresh
24
+ // request of 101°, so the visor kept snapping the target back to the
25
+ // value the user had just replaced. 0.1° covers float32 → double
26
+ // rounding on the wire without accidentally consuming a stale echo.
27
+ const TARGET_ECHO_TOL_RAD = 0.1 * DEG_TO_RAD;
25
28
  // Rev272 (audit R07): bounded-cost normalisation. The previous while
26
29
  // loop could spin forever on pathological inputs (e.g. |a-b| >= 1e15
27
30
  // where 2π is below the mantissa's precision, so subtracting a full
@@ -159,6 +162,15 @@ class AutopilotProvider {
159
162
  if (Array.isArray(value)) {
160
163
  this.pypilotModes = value.map(String);
161
164
  this.data.options.modes = [...this.pypilotModes];
165
+ // Rev280 (audit T16): the "courseCurrentPoint" action's
166
+ // availability depends on pypilotModes.includes("nav"), so
167
+ // a late-arriving ap.modes payload (typical: pypilot
168
+ // publishes it once its config loader has finished, a few
169
+ // seconds after the initial catalog) must trigger a fresh
170
+ // recomputeActions and a delta push. Previously the action
171
+ // stayed unavailable until the next engage/disengage.
172
+ this.recomputeActions();
173
+ changed = true;
162
174
  }
163
175
  break;
164
176
  case "ap.enabled": {
@@ -288,6 +300,22 @@ class AutopilotProvider {
288
300
  !this.data.options.modes.includes(mode)) {
289
301
  throw new Error(`Invalid mode: ${mode}`);
290
302
  }
303
+ // Rev292 (Carlos, navigating - bug C "piloto girando loco"):
304
+ // snapshot the OLD mode so we can decide whether the transition
305
+ // needs a heading_command re-anchor. Pypilot's ap.heading_command
306
+ // lives in different spaces depending on the mode:
307
+ // compass/gps/nav -> compass heading in degrees 0..360
308
+ // wind -> AWA in degrees -180..+180
309
+ // true wind -> TWA in degrees -180..+180
310
+ // A stale value from the previous mode (say a 90° heading) becomes
311
+ // a 90° AWA when we cross into wind mode, and pypilot promptly
312
+ // tries to swing the boat toward it — the "piloto se vuelve loco"
313
+ // symptom Carlos reported on Rev280 sea trial. The fix: right
314
+ // after ap.mode changes, force heading_command to the CURRENT
315
+ // measurement in the destination space, so the pilot has a
316
+ // sane target and never chases a value that meant something else.
317
+ const oldMode = String(this.data.mode || "").toLowerCase();
318
+ const newMode = String(mode || "").toLowerCase();
291
319
  // Rev279 (audit follow-up B): bump modeGen so a stale mode retry
292
320
  // aborts before overwriting a newer setMode / setState intent.
293
321
  const gen = ++this.modeGen;
@@ -302,6 +330,47 @@ class AutopilotProvider {
302
330
  if (this.modeGen !== gen) {
303
331
  throw new Error("mode change superseded after write");
304
332
  }
333
+ // Rev292: re-anchor. Only when the mode's target-space actually
334
+ // changed (compass↔gps↔nav share the compass space and don't need
335
+ // it; wind↔true wind share the wind space; only compass-family ↔
336
+ // wind-family crossings trigger the re-anchor).
337
+ if (this.data.engaged) {
338
+ try {
339
+ this._reAnchorTargetAfterModeChange(oldMode, newMode);
340
+ }
341
+ catch (e) {
342
+ // Never fail the mode change on the re-anchor path — worst
343
+ // case we leave the stale target and the sailor corrects with
344
+ // a nudge. Log so the diagnostic captures it.
345
+ // eslint-disable-next-line no-console
346
+ console.log(`[apProvider.setMode] re-anchor failed: ${e?.message || e}`);
347
+ }
348
+ }
349
+ }
350
+ /** Rev292: after a compass↔wind (or vice-versa) mode change, force
351
+ * heading_command to the current measurement in the destination
352
+ * space so pypilot never starts a mode with a target that meant
353
+ * something else in the previous mode. Rev295: decision extracted
354
+ * to src/mode-reanchor.ts (pure, unit-tested). This method only
355
+ * glues that decision to the pypilot socket. */
356
+ _reAnchorTargetAfterModeChange(oldMode, newMode) {
357
+ const values = this.client.getValues?.() || {};
358
+ const d = (0, mode_reanchor_1.decideReAnchor)(oldMode, newMode, values);
359
+ // eslint-disable-next-line no-console
360
+ console.log(`[apProvider.setMode] re-anchor: ${d.reason}`);
361
+ if (!d.shouldReAnchor || d.valueDeg == null)
362
+ return;
363
+ // Push directly to pypilot without going through setTarget() —
364
+ // setTarget bumps targetGen and could race with the modeGen we
365
+ // just claimed. Also bypass echo cancellation: the anchor value is
366
+ // what we WANT to see, so the echo does match.
367
+ try {
368
+ this.client.set("ap.heading_command", d.valueDeg);
369
+ }
370
+ catch (e) {
371
+ // eslint-disable-next-line no-console
372
+ console.log(`[apProvider.setMode] re-anchor write failed: ${e?.message || e}`);
373
+ }
305
374
  }
306
375
  async setTarget(rad) {
307
376
  // Rev272 (audit R07): reject at the boundary. Downstream callers
@@ -341,8 +410,15 @@ class AutopilotProvider {
341
410
  }
342
411
  async adjustTarget(rad) {
343
412
  // Rev272 (audit R07): reject non-finite deltas at the boundary.
344
- if (!Number.isFinite(rad) || Math.abs(rad) > 10) {
345
- throw new Error(`Invalid adjust rad: ${rad}`);
413
+ // Rev292 (Carlos, navigating - bug C "piloto girando loco"):
414
+ // tighten the ceiling. `adjustTarget` is called from user nudges
415
+ // (max nudgeBig = 100°, so ~1.75 rad in extreme configurations)
416
+ // and never for tacks (those go through this.tack() → setTarget
417
+ // absolute). A single adjustTarget above 90° is either a bug in
418
+ // the caller or a corrupt payload — refuse it before it reaches
419
+ // pypilot, where it becomes an untraceable heading sweep.
420
+ if (!Number.isFinite(rad) || Math.abs(rad) > Math.PI / 2) {
421
+ throw new Error(`Invalid adjust rad: ${rad} (max ±π/2 = ±90°)`);
346
422
  }
347
423
  // Rev272 (audit R06): serialise on the shared chain. base must be
348
424
  // read AFTER any pending adjust has committed data.target,
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ // Config backup + restore helpers.
3
+ //
4
+ // Two independent things travel in one bundle:
5
+ // 1. This plugin's own SK options (host, port, propose*, thresholds).
6
+ // 2. The pypilot core settings that the boat spent effort tuning
7
+ // (P/I/D/DD/FF per pilot, tack angle/rate/delay/threshold, servo
8
+ // max_current, wind offset, compass alignment, profile names).
9
+ //
10
+ // Runtime values (headings, currents, temps) are NOT included. The
11
+ // filter distinguishes them by name: any key whose "info.persistent"
12
+ // flag is true in the pypilot catalog, or that matches an explicit
13
+ // whitelist, is treated as a persistent setting.
14
+ //
15
+ // Restore applies the pypilot settings back to the core one at a time
16
+ // via client.set(). The plugin's own props are handed back to the
17
+ // caller so it can savePluginOptions() them.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.isRuntimeKey = isRuntimeKey;
20
+ exports.captureBundle = captureBundle;
21
+ exports.validateBundle = validateBundle;
22
+ exports.applyBundleToPypilot = applyBundleToPypilot;
23
+ /** Extra pypilot keys we always include even if their catalog entry
24
+ * does not flag them as persistent. Empirically these have proved
25
+ * worth carrying across a restore. */
26
+ const EXTRA_PERSISTENT_KEYS = [
27
+ "ap.mode",
28
+ "profile",
29
+ "profiles",
30
+ "servo.max_current",
31
+ "servo.min_speed",
32
+ "servo.max_speed",
33
+ ];
34
+ /** Runtime keys we NEVER include even if the catalog claims persistent. */
35
+ const RUNTIME_BLOCKLIST_SUFFIXES = [
36
+ ".command",
37
+ ".position",
38
+ ".voltage",
39
+ ".current",
40
+ ".temperature",
41
+ ".rate",
42
+ ".heading",
43
+ ".heel",
44
+ ".pitch",
45
+ ".roll",
46
+ ".fault",
47
+ ".flags",
48
+ ];
49
+ /** Returns true if the value of a pypilot key looks like a runtime
50
+ * telemetry number (which we should never include in a config backup)
51
+ * rather than a persistent setting. */
52
+ function isRuntimeKey(key) {
53
+ for (const suf of RUNTIME_BLOCKLIST_SUFFIXES) {
54
+ if (key.endsWith(suf))
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ /** Build a bundle from the current in-memory state. `catalog` comes
60
+ * from client.getCatalog(); `values` from client.getValues(); `props`
61
+ * from the plugin's own options. */
62
+ function captureBundle(args) {
63
+ const pypilotSettings = {};
64
+ for (const [key, entry] of Object.entries(args.catalog)) {
65
+ const persistent = entry?.info?.persistent === true;
66
+ const extra = EXTRA_PERSISTENT_KEYS.includes(key);
67
+ if (!persistent && !extra)
68
+ continue;
69
+ if (isRuntimeKey(key))
70
+ continue;
71
+ const v = args.values[key];
72
+ if (v === undefined)
73
+ continue;
74
+ // Skip nested objects and arrays — pypilot settings are scalars.
75
+ if (typeof v === "object" && v !== null)
76
+ continue;
77
+ pypilotSettings[key] = v;
78
+ }
79
+ return {
80
+ version: 1,
81
+ capturedTs: Date.now(),
82
+ revision: args.revision,
83
+ note: args.note,
84
+ pluginOptions: JSON.parse(JSON.stringify(args.props)),
85
+ pypilotSettings,
86
+ };
87
+ }
88
+ /** Validate that a bundle looks well-formed. Returns null on success,
89
+ * an error message otherwise. Kept liberal — we accept older
90
+ * bundles even if some fields are missing, as long as the essentials
91
+ * parse. */
92
+ function validateBundle(b) {
93
+ if (!b || typeof b !== "object")
94
+ return "not an object";
95
+ const bb = b;
96
+ if (bb.version !== 1)
97
+ return `unsupported version: ${bb.version}`;
98
+ if (typeof bb.capturedTs !== "number")
99
+ return "capturedTs missing";
100
+ if (!bb.pluginOptions || typeof bb.pluginOptions !== "object")
101
+ return "pluginOptions missing";
102
+ if (!bb.pypilotSettings || typeof bb.pypilotSettings !== "object")
103
+ return "pypilotSettings missing";
104
+ for (const [k, v] of Object.entries(bb.pypilotSettings)) {
105
+ if (v && typeof v === "object")
106
+ return `pypilotSettings.${k} is not a scalar`;
107
+ }
108
+ return null;
109
+ }
110
+ /** Apply the pypilot settings from a bundle to the running core via
111
+ * the given setter. Skips keys that no longer exist in the catalog
112
+ * (a firmware downgrade would remove them). Returns an audit list. */
113
+ function applyBundleToPypilot(bundle, catalog, set) {
114
+ const out = [];
115
+ for (const [key, value] of Object.entries(bundle.pypilotSettings)) {
116
+ if (isRuntimeKey(key)) {
117
+ out.push({ key, status: "skipped-runtime" });
118
+ continue;
119
+ }
120
+ if (!(key in catalog)) {
121
+ out.push({ key, status: "skipped-unknown" });
122
+ continue;
123
+ }
124
+ try {
125
+ set(key, value);
126
+ out.push({ key, status: "applied" });
127
+ }
128
+ catch (e) {
129
+ out.push({ key, status: "error", error: String(e?.message || e) });
130
+ }
131
+ }
132
+ return out;
133
+ }