signalk-pypilot-newui 3.3.0 → 3.5.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,106 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.0 — 2026-10-19 — Fixes from two sea trials, logbook while sailing
4
+
5
+ Rolls up Rev509 → Rev516 on top of 3.4.0. Minor bump: live logbook,
6
+ heading in the compass tack summary, trace pruning. No breaking changes.
7
+
8
+ Rev515 comes from the sail of 2026-10-19: it reverts two changes that
9
+ made things worse (Rev510 compass-until-6°, Rev512 head-to-wind in
10
+ compass) and fixes what the sail showed. Audit:
11
+ `docs/_audit_sail_2026-10-19.md`.
12
+
13
+ ### Changed
14
+ - **Maneuver traces keep the newest 60 files** (pruned when a new trace
15
+ opens). 201 files / 888 MB had piled up on the Tunatunes.
16
+ - **Stuck pilot recovery only on a real false end stop**: it now needs
17
+ pypilot's directional overcurrent fault. 0 A is not evidence (the
18
+ TinyPilot reads 0 A while driving, `DRIVER_TIMEOUT`), it never acts
19
+ during pypilot's own tack or for 20 s after, and a target change of
20
+ more than 10° restarts its clock. On 2026-10-19 it cycled the AP three
21
+ times by mistake, twice in the middle of a tack from the remote.
22
+ - **Gybe confirmation only right after a cancel**: the long-way gybe
23
+ question (> 180°) now appears only within 20 s of cancelling a tack -
24
+ the 2026-10-18 accident - not on deliberate gybes. Yes / No buttons
25
+ are translated.
26
+
27
+ ### Added
28
+ - **Logbook while sailing**: the trip being recorded appears in the
29
+ logbook with an "IN PROGRESS" badge, live statistics (distance, time,
30
+ speeds, wind, tacks, energy...) and the track on the chart up to now.
31
+ - **Tack summary in compass mode** shows the mean heading before / after
32
+ instead of the apparent wind angle.
33
+ - **Confirmation before a gybe the long way** (see above).
34
+
35
+ ### Fixed
36
+ - **Cancelling a virtual tack left a "ghost" target**: pypilot processes
37
+ the mode change in its own loop and overwrote our "stay here" target
38
+ with one recomputed from the compass error (2026-10-19: 56° -> 3.9°,
39
+ bow into the wind). The cancel now waits for the mode change, writes
40
+ the target, checks it and rewrites it if pypilot moved it.
41
+ - **Remote tack HUD turning the wrong way**: pypilot overwrites
42
+ `ap.tack.direction` with the wind side ~0.5 s after the remote starts a
43
+ tack; the HUD now keeps the direction it had at the start. It also stays
44
+ open until the boat is within 10° of the new target (max 15 s), not only
45
+ until pypilot's 50 % threshold.
46
+ - **Tack countdown**: shows the distance from the wind arrow to the target
47
+ diamond along the turn (what the rose shows), no "ajustando" text.
48
+ - **Two screens showing different modes after a tack**: the mode now
49
+ comes from the backend (plain mode label, no "TACKING ->" prefix).
50
+ - **Previous manoeuvre's summary popping up during the next one**.
51
+ - **Settings tab**: dragging a slider no longer darkens the whole tab
52
+ (only the other rows), and the darkening clears if the touch is
53
+ interrupted. A value the sailor writes stays on screen until the
54
+ backend confirms it (or reports a different one).
55
+ - **`ap.tack.direction` noise in standby**: published as `none` once
56
+ while the AP is off (pypilot flips it ~40 times a minute there).
57
+ - **Gust ghost arrow almost never visible**: the fade started in the
58
+ same frame the ghost was created, so the browser skipped it.
59
+
60
+ ### Reverted
61
+ - Rev510 "virtual tack holds compass until 6° of the final heading": the
62
+ apparent wind at the final heading is not the mirror, so wind mode then
63
+ swung back 40-50° (S-turn); handover is at 20° again.
64
+ - Rev512 "head to wind holds in compass": head to wind is always in
65
+ apparent wind, as before.
66
+
67
+ ## 3.4.0 — 2026-10-18 — The sailor always wins, and the pilot gets unstuck
68
+
69
+ Rolls up Rev506 → Rev508 on top of 3.3.0, from the strong-wind sea
70
+ trial of 2026-10-18 (26 kn TWS, 48° heel). Minor bump: new opt-in
71
+ recovery and alarms. No breaking changes; everything new is off until
72
+ enabled.
73
+
74
+ ### Added
75
+ - **Stuck pilot recovery** (Smart Pilot card, opt-in). With no rudder
76
+ sensor, pypilot treats an overcurrent as the end of rudder travel and
77
+ stops driving that way. If the AP is engaged, the heading error is
78
+ over 25° and growing and the servo is not pushing for 4 s, the plugin
79
+ turns the AP off and on (0.7 s, same mode and target), which clears
80
+ that fault in pypilot. At most 3 tries per minute. An order from the
81
+ sailor during the cycle wins, and a running virtual tack is kept.
82
+ - **New alarms** (opt-in, Setup > Alarms): `steer-stuck`,
83
+ `steer-gave-up` ("steer by hand") and `steer-dropped` (AP disengaged
84
+ from the remote or the TinyPilot keys while underway).
85
+ - **+-1 / +-10 during a virtual tack** move the tack's final heading
86
+ instead of being rejected.
87
+ - `servo.flags` is watched and saved in every session sample.
88
+
89
+ ### Fixed
90
+ - **Taking the AP off now cancels a virtual tack**, whatever the source
91
+ (visor, KIP, remote, TinyPilot). Before, the tack kept running and
92
+ rejected the sailor's own headings until it finished. After a cancel
93
+ the boat holds its current wind angle.
94
+ - Repeated taps on the tack button within 1.5 s no longer chain
95
+ start / cancel / start (wind modes).
96
+ - `unable-to-steer` can fire again: it required servo duty, which the
97
+ TinyPilot never reports. It now also fires on a large error while the
98
+ servo is stopped by an overcurrent fault.
99
+ - Settings tab: unlocking a group no longer asks about the profile; the
100
+ question appears only for PROFILE settings. Open or unlocked groups
101
+ are no longer folded, relocked and reset to old values by the 10 s
102
+ background refresh.
103
+
3
104
  ## 3.3.0 — 2026-10-19 — Light on the Pi, AIS on the ring, settings shared by every device
4
105
 
5
106
  Rolls up Rev483 → Rev505 on top of 3.2.0. Minor bump: new shared
package/README.md CHANGED
@@ -147,7 +147,13 @@ up automatically.
147
147
  for 20 s, temporarily switch to the Heavy profile for 30 s, or
148
148
  just warn.
149
149
  17. **Auto-disengage on lost authority** — RMS heading error > 30°
150
- with servo duty > 90% sustained 10 s.
150
+ with servo duty > 90% sustained 10 s. **Stuck pilot recovery**
151
+ (opt-in): with no rudder sensor, pypilot treats an overcurrent as
152
+ the end of rudder travel and stops driving that way; when pypilot
153
+ reports that directional overcurrent fault and the error keeps
154
+ growing, the plugin turns the AP off and on (same mode and target)
155
+ to clear it, at most 3 times per minute. Never during pypilot's own
156
+ tack.
151
157
  18. **Pre-departure Autopilot Check** — a single READY / CAVEATS /
152
158
  DO-NOT-ENGAGE verdict aggregating every sensor, servo, voltage
153
159
  and link signal.
@@ -156,8 +162,9 @@ up automatically.
156
162
  20. **Servo Health** — learns a baseline for typical servo duty at
157
163
  sea and flags recent, peak, temperature or voltage deviations
158
164
  from that baseline.
159
- 21. **Alarm engine — 14 rules** — heading deviation, cruise drift,
160
- unable-to-steer, servo overcurrent, servo current near trip,
165
+ 21. **Alarm engine — 17 rules** — heading deviation, cruise drift,
166
+ unable-to-steer, pilot stuck, pilot cannot recover, AP disengaged
167
+ from the remote / TinyPilot, servo overcurrent, servo current near trip,
161
168
  rudder near its end stop, servo controller temperature, motor
162
169
  temperature, high AP load, low voltage, sensor lost, pypilot
163
170
  disconnected, extreme heel, extreme pitch — all published as
@@ -194,6 +201,8 @@ up automatically.
194
201
  split, min voltage, AP-engaged %), OpenSeaMap trace coloured by
195
202
  SOG with markers on peak gust, peak SOG, min voltage and every
196
203
  detected tack. Share as portrait PNG report card or as plain text.
204
+ The trip in progress can be opened while sailing (live statistics
205
+ and the track so far).
197
206
  27. **Chart tab** — 9 live Trip Stats cards, history with
198
207
  30 s / 2 min / 10 min windows and hover-freeze, plus a Servo
199
208
  Health card.
package/dist/alarms.js CHANGED
@@ -122,7 +122,7 @@ exports.DEFAULT_RULES = [
122
122
  severity: "alarm",
123
123
  defaultEnabled: true,
124
124
  sustainSec: 20,
125
- description: `Sustained high error AND servo running at ${(exports.RULE_UNABLE_DUTY_MIN * 100) | 0}%+ duty - the AP is losing authority.`,
125
+ description: `Sustained high error AND servo running at ${(exports.RULE_UNABLE_DUTY_MIN * 100) | 0}%+ duty (or stopped by an overcurrent fault) - the AP is losing authority.`,
126
126
  evaluate: (c) => {
127
127
  if (!c.kpis || !c.sample || !c.sample.engaged)
128
128
  return false;
@@ -132,15 +132,57 @@ exports.DEFAULT_RULES = [
132
132
  return false; // Rev323: idem pseudo-modo
133
133
  const r = c.kpis.window1m.rmsErrorRad;
134
134
  const d = c.kpis.window1m.servoDutyPct;
135
- return typeof r === "number" && r > exports.RULE_UNABLE_ERR_RAD
136
- && typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN;
135
+ if (typeof r !== "number" || r <= exports.RULE_UNABLE_ERR_RAD)
136
+ return false;
137
+ // Rev508: servoDuty never arrives from Carlos's TinyPilot, so the
138
+ // rule could not fire on 2026-10-18. A directional overcurrent
139
+ // fault (false end stop) with the same error is the same verdict.
140
+ if (c.steer?.dirFault)
141
+ return true;
142
+ return typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN;
137
143
  },
138
144
  message: (c) => {
139
145
  const r = c.kpis?.window1m.rmsErrorRad ?? 0;
140
- const d = c.kpis?.window1m.servoDutyPct ?? 0;
141
- return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo ${(d * 100).toFixed(0)}% duty`;
146
+ const d = c.kpis?.window1m.servoDutyPct;
147
+ if (c.steer?.dirFault && !(typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN)) {
148
+ return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo stopped by overcurrent`;
149
+ }
150
+ return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo ${((d ?? 0) * 100).toFixed(0)}% duty`;
142
151
  },
143
152
  },
153
+ // Rev508 (Carlos sea trial 2026-10-18): the pilot stopped steering.
154
+ // Opt-in like every alarm (BASIC vs Performance rule); enabled on the
155
+ // Tunatunes from Setup > Alarms. Sustain lives in steer-watch.
156
+ {
157
+ id: "steer-stuck",
158
+ label: "Pilot stuck",
159
+ severity: "alarm",
160
+ defaultEnabled: false,
161
+ sustainSec: 0,
162
+ description: "AP engaged, heading error > 25° and growing, servo not pushing (overcurrent false end stop or 0 A) for 4 s. With recovery enabled the plugin cycles AP off/on.",
163
+ evaluate: (c) => !!c.steer?.stuck && !c.steer.gaveUp,
164
+ message: () => "Pilot stuck: servo not pushing, error growing",
165
+ },
166
+ {
167
+ id: "steer-gave-up",
168
+ label: "Pilot cannot recover",
169
+ severity: "alarm",
170
+ defaultEnabled: false,
171
+ sustainSec: 0,
172
+ description: "Three recovery cycles in one minute did not get the servo pushing again. Steer by hand.",
173
+ evaluate: (c) => !!c.steer?.gaveUp,
174
+ message: () => "Pilot cannot recover: steer by hand",
175
+ },
176
+ {
177
+ id: "steer-dropped",
178
+ label: "AP disengaged from outside the app",
179
+ severity: "alarm",
180
+ defaultEnabled: false,
181
+ sustainSec: 0,
182
+ description: "ap.enabled went off with no order from this app (TinyPilot keys, remote, native UI) while underway. Clears on re-engage or after 60 s.",
183
+ evaluate: (c) => !!c.steer?.droppedActive,
184
+ message: () => "AP disengaged from outside the app (remote / TinyPilot)",
185
+ },
144
186
  {
145
187
  id: "servo-overcurrent",
146
188
  label: "Servo overcurrent",
@@ -26,6 +26,7 @@ const TARGET_PENDING_MS = 2500;
26
26
  // value the user had just replaced. 0.1° covers float32 → double
27
27
  // rounding on the wire without accidentally consuming a stale echo.
28
28
  const TARGET_ECHO_TOL_RAD = 0.1 * DEG_TO_RAD;
29
+ const LONG_JIBE_CONFIRM_AFTER_CANCEL_MS = 20000;
29
30
  // Rev272 (audit R07): bounded-cost normalisation. The previous while
30
31
  // loop could spin forever on pathological inputs (e.g. |a-b| >= 1e15
31
32
  // where 2π is below the mantissa's precision, so subtracting a full
@@ -110,6 +111,11 @@ class AutopilotProvider {
110
111
  * adjust target, legacy countdown) that would compete with the
111
112
  * maneuver. */
112
113
  this._vtInternalWrite = false;
114
+ /** Rev506: recovery cycles (steer-recovery) toggle the AP off/on on
115
+ * purpose; that must NOT annul the tack the cycle is trying to save. */
116
+ this._recoveryCycleUntil = 0;
117
+ /** Rev515: last cancel of a running virtual tack (any source). */
118
+ this._lastVtCancelTs = 0;
113
119
  if (opts?.allowDodge)
114
120
  this.allowDodge = true;
115
121
  this.onDataChanged = opts?.onDataChanged;
@@ -290,6 +296,8 @@ class AutopilotProvider {
290
296
  const pendVal = this.pendingEngaged.value;
291
297
  if (eng === pendVal) {
292
298
  this.pendingEngaged = null;
299
+ if (!eng)
300
+ this._cancelVirtualTackOnDisengage("disengage echo");
293
301
  this.onStageEvent?.("provider", {
294
302
  path: "ap.enabled",
295
303
  decision: "echo_dropped_match",
@@ -317,6 +325,17 @@ class AutopilotProvider {
317
325
  this.data.engaged = eng;
318
326
  changed = true;
319
327
  changedField = "engaged";
328
+ // Rev506: AP off from ANY source (pypilot itself, physical
329
+ // remote, native UI, KIP switch, /raw) annuls the virtual tack.
330
+ if (!eng)
331
+ this._cancelVirtualTackOnDisengage("disengaged externally");
332
+ // Rev508: steer-dropped alarm (skip our own recovery cycle).
333
+ if (!eng && Date.now() >= this._recoveryCycleUntil) {
334
+ try {
335
+ this.onExternalDisengage?.();
336
+ }
337
+ catch { /* observer only */ }
338
+ }
320
339
  }
321
340
  this.onStageEvent?.("provider", {
322
341
  path: "ap.enabled",
@@ -391,6 +410,16 @@ class AutopilotProvider {
391
410
  // echoes while pending.
392
411
  const eng = st.engaged;
393
412
  const apSt = eng ? "enabled" : "disabled";
413
+ // Rev506 (Carlos sea trial 2026-10-18): taking the AP off annuls a
414
+ // running virtual tack ("si quito AP la virada queda anulada"). The
415
+ // FSM used to keep driving after a disengage and then rejected the
416
+ // sailor's own targets ("FSM is driving") until it finished.
417
+ if (!eng)
418
+ this._cancelVirtualTackOnDisengage("disengage order");
419
+ try {
420
+ this.onAppEngageOrder?.();
421
+ }
422
+ catch { /* observer only */ }
394
423
  // Rev271 (audit R01+R04): bump generation and cancel any NAV
395
424
  // pending timer. If this call is superseded before we make it past
396
425
  // the retry backoff, the stale isStale() check aborts the retry
@@ -740,6 +769,24 @@ class AutopilotProvider {
740
769
  && Number.isFinite(opts.finalAngleRadOverride)
741
770
  && Math.abs(opts.finalAngleRadOverride) <= Math.PI
742
771
  ? opts.finalAngleRadOverride : null;
772
+ // Rev511: same geometry the FSM will compute; refuse a long-way gybe
773
+ // unless the sailor confirmed it. Rev515 (Carlos 2026-10-19: 7 asks
774
+ // in a sail of deliberate gybes): only right after a cancel, which is
775
+ // the 2026-10-18 18:11:44 accident (second tap 3 s after cancelling).
776
+ if (!opts.confirmLongJibe && Date.now() - this._lastVtCancelTs < LONG_JIBE_CONFIRM_AFTER_CANCEL_MS) {
777
+ const g = (0, virtual_tack_1.computeTackGeometry)({
778
+ angleStartRad: awaRad,
779
+ hStartRad: headingRad,
780
+ direction: opts.direction,
781
+ angleNewRadOverride: finalAngleOverride ?? undefined,
782
+ });
783
+ if ((0, virtual_tack_1.needsLongJibeConfirm)(g)) {
784
+ const err = new Error(`virtual-tack: gybe the long way (${(g.deltaHRad * RAD_TO_DEG).toFixed(0)} deg) needs confirmation`);
785
+ err.code = "confirm_long_jibe";
786
+ err.deltaDeg = g.deltaHRad * RAD_TO_DEG;
787
+ throw err;
788
+ }
789
+ }
743
790
  this._runVirtualTack(opts.direction, windMode, headingRad, awaRad, finalAngleOverride).catch((e) => {
744
791
  // eslint-disable-next-line no-console
745
792
  console.log(`[startVirtualTack] driver threw: ${e?.message || e}`);
@@ -983,22 +1030,33 @@ class AutopilotProvider {
983
1030
  // pypilot pick the short arc in the WRONG direction).
984
1031
  let remainingIntermediates = geometry.intermediatesRad.slice();
985
1032
  let stepCounter = 0;
986
- let finalCompassTargetRad = geometry.hTargetRad;
987
- // Rev410 (fix T.1-A, Carlos 2026-10-04): publish an initial
988
- // `remainingDeg` so the visor's mirror HUD shows a meaningful
989
- // number from the first snapshot, not just elapsed seconds.
990
- // Granularity thereafter is "once per intermediate step reached"
991
- // (option A, confirmed by Carlos — no 500 ms tick).
992
- {
993
- const h0 = this._readHeadingRad();
994
- if (h0 !== null && this._virtualTack && this._virtualTack.id === vtId) {
995
- this._virtualTack.remainingDeg = Math.abs(shortestArcRad(finalCompassTargetRad, h0) * RAD_TO_DEG);
1033
+ // Rev506: final targets follow the sailor's nudges (adjustRad).
1034
+ const curAdjust = () => (this._virtualTack && this._virtualTack.id === vtId) ? this._virtualTack.adjustRad : 0;
1035
+ const curAdjustVersion = () => (this._virtualTack && this._virtualTack.id === vtId) ? this._virtualTack.adjustVersion : 0;
1036
+ const finalCompass = () => (0, virtual_tack_1.adjustedFinalCompassRad)(geometry, curAdjust());
1037
+ const finalWind = () => (0, virtual_tack_1.adjustedFinalWindRad)(geometry, curAdjust());
1038
+ let seenAdjustVersion = curAdjustVersion();
1039
+ // Rev506: publish remaining degrees on every tick (not only per
1040
+ // step) so the countdown shows what is LEFT to turn.
1041
+ const publishRemaining = (hRad) => {
1042
+ if (hRad === null || !this._virtualTack || this._virtualTack.id !== vtId)
1043
+ return;
1044
+ const rem = Math.abs(shortestArcRad(finalCompass(), hRad) * RAD_TO_DEG);
1045
+ const prev = this._virtualTack.remainingDeg;
1046
+ if (prev === null || Math.abs(prev - rem) >= 1) {
1047
+ this._virtualTack.remainingDeg = rem;
996
1048
  try {
997
1049
  this.notifyChanged("virtualTack");
998
1050
  }
999
1051
  catch { /* noop */ }
1000
1052
  }
1001
- }
1053
+ };
1054
+ // Rev410 (fix T.1-A, Carlos 2026-10-04): publish an initial
1055
+ // `remainingDeg` so the visor's mirror HUD shows a meaningful
1056
+ // number from the first snapshot, not just elapsed seconds.
1057
+ // Granularity thereafter is "once per intermediate step reached"
1058
+ // (option A, confirmed by Carlos — no 500 ms tick).
1059
+ publishRemaining(this._readHeadingRad());
1002
1060
  while (remainingIntermediates.length > 0) {
1003
1061
  checkCancelled();
1004
1062
  this._virtualTack.stepIndex = stepCounter;
@@ -1018,8 +1076,16 @@ class AutopilotProvider {
1018
1076
  // if they want to abort for any other reason.
1019
1077
  let lastHeadingRad = null;
1020
1078
  let lastHeadingChangeTs = Date.now();
1079
+ let retarget = false;
1021
1080
  while (true) {
1022
1081
  const hNow = this._readHeadingRad();
1082
+ publishRemaining(hNow);
1083
+ // Rev506: sailor nudged the destination -> re-aim from here.
1084
+ if (curAdjustVersion() !== seenAdjustVersion) {
1085
+ seenAdjustVersion = curAdjustVersion();
1086
+ retarget = true;
1087
+ break;
1088
+ }
1023
1089
  if (hNow !== null) {
1024
1090
  if ((0, virtual_tack_1.isAtTarget)({
1025
1091
  hNowRad: hNow,
@@ -1046,27 +1112,35 @@ class AutopilotProvider {
1046
1112
  await sleep(500);
1047
1113
  checkCancelled();
1048
1114
  }
1115
+ if (retarget) {
1116
+ const hNow = this._readHeadingRad();
1117
+ if (hNow !== null) {
1118
+ remainingIntermediates = (0, virtual_tack_1.recomputeRemainingIntermediates)({
1119
+ hNowRad: hNow,
1120
+ finalCompassTargetRad: finalCompass(),
1121
+ originalDeltaSign: Math.sign(geometry.deltaHRad),
1122
+ });
1123
+ }
1124
+ else {
1125
+ // No heading: keep the plan but aim the last step at the new final.
1126
+ remainingIntermediates[remainingIntermediates.length - 1] = finalCompass();
1127
+ }
1128
+ // eslint-disable-next-line no-console
1129
+ console.log(`[virtual-tack ${vtId}] re-aimed by sailor: adjust=${(curAdjust() * RAD_TO_DEG).toFixed(0)}deg, steps=${remainingIntermediates.length}`);
1130
+ continue; // write the new first step; nothing was "reached"
1131
+ }
1049
1132
  // Step reached. Pop it and recompute the rest from REAL heading.
1050
1133
  remainingIntermediates.shift();
1051
1134
  stepCounter++;
1052
1135
  // Rev410 (fix T.1-A): refresh `remainingDeg` from the current
1053
1136
  // heading. One update per step reached is the agreed granularity.
1054
- {
1055
- const hAfter = this._readHeadingRad();
1056
- if (hAfter !== null && this._virtualTack && this._virtualTack.id === vtId) {
1057
- this._virtualTack.remainingDeg = Math.abs(shortestArcRad(finalCompassTargetRad, hAfter) * RAD_TO_DEG);
1058
- try {
1059
- this.notifyChanged("virtualTack");
1060
- }
1061
- catch { /* noop */ }
1062
- }
1063
- }
1137
+ publishRemaining(this._readHeadingRad());
1064
1138
  if (remainingIntermediates.length > 0) {
1065
1139
  const hNow = this._readHeadingRad();
1066
1140
  if (hNow !== null) {
1067
1141
  remainingIntermediates = (0, virtual_tack_1.recomputeRemainingIntermediates)({
1068
1142
  hNowRad: hNow,
1069
- finalCompassTargetRad,
1143
+ finalCompassTargetRad: finalCompass(),
1070
1144
  originalDeltaSign: Math.sign(geometry.deltaHRad),
1071
1145
  });
1072
1146
  }
@@ -1091,12 +1165,13 @@ class AutopilotProvider {
1091
1165
  checkCancelled();
1092
1166
  this._vtInternalWrite = true;
1093
1167
  try {
1094
- await this.setTarget(geometry.angleNewRad);
1168
+ await this.setTarget(finalWind());
1095
1169
  }
1096
1170
  finally {
1097
1171
  this._vtInternalWrite = false;
1098
1172
  }
1099
1173
  checkCancelled();
1174
+ seenAdjustVersion = curAdjustVersion();
1100
1175
  // Rev387: settling phase. Watches the wind angle error until
1101
1176
  // it stays under tolerance for a short dwell.
1102
1177
  // Rev407: no artificial "declaring completed anyway" fallback.
@@ -1139,10 +1214,23 @@ class AutopilotProvider {
1139
1214
  }
1140
1215
  }
1141
1216
  catch { /* noop */ }
1217
+ // Rev506: a nudge during settling re-aims the wind target.
1218
+ if (curAdjustVersion() !== seenAdjustVersion) {
1219
+ seenAdjustVersion = curAdjustVersion();
1220
+ this._vtInternalWrite = true;
1221
+ try {
1222
+ await this.setTarget(finalWind());
1223
+ }
1224
+ finally {
1225
+ this._vtInternalWrite = false;
1226
+ }
1227
+ checkCancelled();
1228
+ dwellStart = null;
1229
+ }
1142
1230
  const sampleFresh = windNowRad !== null && (sampleAgeMs === null || sampleAgeMs < STALE_SAMPLE_MS);
1143
1231
  if (sampleFresh) {
1144
1232
  awaEmaRad = (0, virtual_tack_1.awaEmaStep)(awaEmaRad, windNowRad, EMA_ALPHA);
1145
- const errDeg = Math.abs(((((awaEmaRad - geometry.angleNewRad) * RAD_TO_DEG) + 540) % 360) - 180);
1233
+ const errDeg = Math.abs(((((awaEmaRad - finalWind()) * RAD_TO_DEG) + 540) % 360) - 180);
1146
1234
  if (errDeg < SETTLE_TOLERANCE_DEG) {
1147
1235
  if (dwellStart === null)
1148
1236
  dwellStart = Date.now();
@@ -1235,13 +1323,32 @@ class AutopilotProvider {
1235
1323
  }
1236
1324
  catch { /* noop */ }
1237
1325
  if (awaRad !== null) {
1238
- this._vtInternalWrite = true;
1239
- try {
1240
- await this.setTarget(awaRad);
1241
- }
1242
- catch { /* best-effort */ }
1243
- finally {
1244
- this._vtInternalWrite = false;
1326
+ // Rev515 (sail 2026-10-19 19:08:24, 19:15:44, 19:17:10): pypilot
1327
+ // processes the mode change in its own loop and "keeps the
1328
+ // heading" by recomputing heading_command from the compass error
1329
+ // - AFTER our target had landed, so it overwrote it (56 -> 3.9,
1330
+ // -57 -> 4.8: bow into the wind, "target fantasma"). Wait for
1331
+ // the mode echo plus a few pypilot loops, write, check, rewrite.
1332
+ await this._waitValue("ap.mode", windMode, 1500);
1333
+ await sleep(400);
1334
+ for (let attempt = 0; attempt < 2; attempt++) {
1335
+ this._vtInternalWrite = true;
1336
+ try {
1337
+ await this.setTarget(awaRad);
1338
+ }
1339
+ catch { /* best-effort */ }
1340
+ finally {
1341
+ this._vtInternalWrite = false;
1342
+ }
1343
+ await sleep(1200);
1344
+ const cmdDeg = Number((this.client.getValues?.() || {})["ap.heading_command"]);
1345
+ if (!Number.isFinite(cmdDeg))
1346
+ break;
1347
+ const diff = Math.abs(((cmdDeg - awaRad * RAD_TO_DEG) % 360 + 540) % 360 - 180);
1348
+ if (diff <= 2)
1349
+ break;
1350
+ // eslint-disable-next-line no-console
1351
+ console.log(`[virtual-tack ${vtId}] cancel: pypilot moved the target to ${cmdDeg.toFixed(1)}deg, rewriting ${(awaRad * RAD_TO_DEG).toFixed(1)}deg`);
1245
1352
  }
1246
1353
  }
1247
1354
  }
@@ -1286,18 +1393,97 @@ class AutopilotProvider {
1286
1393
  * when the sailor taps the tack button a second time (frontend
1287
1394
  * Rev379). Sets phase to "abort" so the running driver bails and
1288
1395
  * rolls back on its next await. */
1289
- cancelVirtualTack() {
1396
+ cancelVirtualTack(reason = "user cancelled") {
1290
1397
  if (!this._virtualTack)
1291
1398
  return;
1292
1399
  const terminal = ["completed", "cancelled", "failed"];
1293
1400
  if (terminal.includes(this._virtualTack.phase))
1294
1401
  return;
1295
1402
  // eslint-disable-next-line no-console
1296
- console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase}`);
1403
+ console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase} (${reason})`);
1404
+ this._lastVtCancelTs = Date.now();
1297
1405
  this._virtualTack.phase = "cancelling";
1298
- this._virtualTack.outcomeReason = "user cancelled";
1406
+ this._virtualTack.outcomeReason = reason;
1299
1407
  this.notifyChanged("all");
1300
1408
  }
1409
+ /** Rev506 (Carlos sea trial 2026-10-18): +-1/+-10 during a virtual tack
1410
+ * move its final destination. deltaRad is bow-relative, positive = bow
1411
+ * to starboard (same sign as the visor's nudge buttons). Returns false
1412
+ * when no tack is running so the caller can fall back to a normal nudge. */
1413
+ adjustVirtualTack(deltaRad) {
1414
+ if (!Number.isFinite(deltaRad) || !this.isVirtualTackActive())
1415
+ return false;
1416
+ const vt = this._virtualTack;
1417
+ if (vt.phase === "cancelling")
1418
+ return false;
1419
+ vt.adjustRad = (0, virtual_tack_1.accumulateTackAdjust)(vt.adjustRad, deltaRad);
1420
+ vt.adjustVersion += 1;
1421
+ // eslint-disable-next-line no-console
1422
+ console.log(`[virtual-tack ${vt.id}] sailor adjust ${(deltaRad * RAD_TO_DEG).toFixed(0)}deg -> total ${(vt.adjustRad * RAD_TO_DEG).toFixed(0)}deg`);
1423
+ this.notifyChanged("virtualTack");
1424
+ return true;
1425
+ }
1426
+ /** Rev515: wait until pypilot reports key === expected (no cancel check;
1427
+ * used by the cancel path itself). */
1428
+ async _waitValue(key, expected, timeoutMs) {
1429
+ const t0 = Date.now();
1430
+ while (Date.now() - t0 < timeoutMs) {
1431
+ const v = this.client.getValues?.() || {};
1432
+ if (v[key] === expected)
1433
+ return true;
1434
+ await new Promise((r) => setTimeout(r, 50));
1435
+ }
1436
+ return false;
1437
+ }
1438
+ /** Rev506: true while a virtual tack is running (not terminal). */
1439
+ isVirtualTackActive() {
1440
+ const vt = this._virtualTack;
1441
+ return !!vt && !["idle", "completed", "cancelled", "failed"].includes(vt.phase);
1442
+ }
1443
+ /** Rev508 (steer recovery): AP off -> gapMs -> AP on, same mode and
1444
+ * target (pypilot keeps both). Changing ap.enabled clears pypilot's
1445
+ * directional overcurrent fault (servo.py), the false end stop that
1446
+ * left the servo at 0 A on 2026-10-18. A running virtual tack is NOT
1447
+ * annulled by the cycle. If any other engage/disengage order arrives
1448
+ * meanwhile, the cycle aborts and does not re-engage. */
1449
+ async recoveryCycle(gapMs = 700) {
1450
+ if (!this.data.engaged || !this.client.connected)
1451
+ return "skipped";
1452
+ this._recoveryCycleUntil = Date.now() + gapMs + 3000;
1453
+ try {
1454
+ await this.setState("disabled");
1455
+ }
1456
+ catch {
1457
+ this._recoveryCycleUntil = 0;
1458
+ return "aborted";
1459
+ }
1460
+ const gen = this.engageGen;
1461
+ await new Promise((r) => setTimeout(r, gapMs));
1462
+ if (this.engageGen !== gen) {
1463
+ // The sailor gave an order during the gap: theirs wins. If it was a
1464
+ // disengage, the tack must be annulled as usual.
1465
+ this._recoveryCycleUntil = 0;
1466
+ if (!this.data.engaged)
1467
+ this._cancelVirtualTackOnDisengage("disengage during recovery");
1468
+ return "aborted";
1469
+ }
1470
+ try {
1471
+ await this.setState("enabled");
1472
+ }
1473
+ catch {
1474
+ this._recoveryCycleUntil = 0;
1475
+ this._cancelVirtualTackOnDisengage("recovery re-engage failed");
1476
+ return "aborted";
1477
+ }
1478
+ return "done";
1479
+ }
1480
+ _cancelVirtualTackOnDisengage(reason) {
1481
+ if (Date.now() < this._recoveryCycleUntil)
1482
+ return;
1483
+ if (!this.isVirtualTackActive())
1484
+ return;
1485
+ this.cancelVirtualTack(reason);
1486
+ }
1301
1487
  async engage() {
1302
1488
  // Rev279 (audit follow-up A): the catch used to fall through to
1303
1489
  // setState("enabled") on ANY setNavMode failure, including a