signalk-pypilot-newui 3.3.0 → 3.4.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 +37 -0
- package/README.md +9 -3
- package/dist/alarms.js +47 -5
- package/dist/autopilot-provider.js +160 -27
- package/dist/index.js +139 -17
- package/dist/steer-watch.js +147 -0
- package/dist/virtual-tack.js +24 -1
- package/package.json +1 -1
- package/public/app.js +241 -41
- package/public/index.html +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.0 — 2026-10-18 — The sailor always wins, and the pilot gets unstuck
|
|
4
|
+
|
|
5
|
+
Rolls up Rev506 → Rev508 on top of 3.3.0, from the strong-wind sea
|
|
6
|
+
trial of 2026-10-18 (26 kn TWS, 48° heel). Minor bump: new opt-in
|
|
7
|
+
recovery and alarms. No breaking changes; everything new is off until
|
|
8
|
+
enabled.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Stuck pilot recovery** (Smart Pilot card, opt-in). With no rudder
|
|
12
|
+
sensor, pypilot treats an overcurrent as the end of rudder travel and
|
|
13
|
+
stops driving that way. If the AP is engaged, the heading error is
|
|
14
|
+
over 25° and growing and the servo is not pushing for 4 s, the plugin
|
|
15
|
+
turns the AP off and on (0.7 s, same mode and target), which clears
|
|
16
|
+
that fault in pypilot. At most 3 tries per minute. An order from the
|
|
17
|
+
sailor during the cycle wins, and a running virtual tack is kept.
|
|
18
|
+
- **New alarms** (opt-in, Setup > Alarms): `steer-stuck`,
|
|
19
|
+
`steer-gave-up` ("steer by hand") and `steer-dropped` (AP disengaged
|
|
20
|
+
from the remote or the TinyPilot keys while underway).
|
|
21
|
+
- **+-1 / +-10 during a virtual tack** move the tack's final heading
|
|
22
|
+
instead of being rejected.
|
|
23
|
+
- `servo.flags` is watched and saved in every session sample.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Taking the AP off now cancels a virtual tack**, whatever the source
|
|
27
|
+
(visor, KIP, remote, TinyPilot). Before, the tack kept running and
|
|
28
|
+
rejected the sailor's own headings until it finished. After a cancel
|
|
29
|
+
the boat holds its current wind angle.
|
|
30
|
+
- Repeated taps on the tack button within 1.5 s no longer chain
|
|
31
|
+
start / cancel / start (wind modes).
|
|
32
|
+
- `unable-to-steer` can fire again: it required servo duty, which the
|
|
33
|
+
TinyPilot never reports. It now also fires on a large error while the
|
|
34
|
+
servo is stopped by an overcurrent fault.
|
|
35
|
+
- Settings tab: unlocking a group no longer asks about the profile; the
|
|
36
|
+
question appears only for PROFILE settings. Open or unlocked groups
|
|
37
|
+
are no longer folded, relocked and reset to old values by the 10 s
|
|
38
|
+
background refresh.
|
|
39
|
+
|
|
3
40
|
## 3.3.0 — 2026-10-19 — Light on the Pi, AIS on the ring, settings shared by every device
|
|
4
41
|
|
|
5
42
|
Rolls up Rev483 → Rev505 on top of 3.2.0. Minor bump: new shared
|
package/README.md
CHANGED
|
@@ -147,7 +147,12 @@ 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 the error
|
|
153
|
+
keeps growing and the servo is not pushing, the plugin turns the AP
|
|
154
|
+
off and on (same mode and target) to clear it, at most 3 times per
|
|
155
|
+
minute.
|
|
151
156
|
18. **Pre-departure Autopilot Check** — a single READY / CAVEATS /
|
|
152
157
|
DO-NOT-ENGAGE verdict aggregating every sensor, servo, voltage
|
|
153
158
|
and link signal.
|
|
@@ -156,8 +161,9 @@ up automatically.
|
|
|
156
161
|
20. **Servo Health** — learns a baseline for typical servo duty at
|
|
157
162
|
sea and flags recent, peak, temperature or voltage deviations
|
|
158
163
|
from that baseline.
|
|
159
|
-
21. **Alarm engine —
|
|
160
|
-
unable-to-steer,
|
|
164
|
+
21. **Alarm engine — 17 rules** — heading deviation, cruise drift,
|
|
165
|
+
unable-to-steer, pilot stuck, pilot cannot recover, AP disengaged
|
|
166
|
+
from the remote / TinyPilot, servo overcurrent, servo current near trip,
|
|
161
167
|
rudder near its end stop, servo controller temperature, motor
|
|
162
168
|
temperature, high AP load, low voltage, sensor lost, pypilot
|
|
163
169
|
disconnected, extreme heel, extreme pitch — all published as
|
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
|
-
|
|
136
|
-
|
|
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
|
|
141
|
-
|
|
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",
|
|
@@ -110,6 +110,9 @@ class AutopilotProvider {
|
|
|
110
110
|
* adjust target, legacy countdown) that would compete with the
|
|
111
111
|
* maneuver. */
|
|
112
112
|
this._vtInternalWrite = false;
|
|
113
|
+
/** Rev506: recovery cycles (steer-recovery) toggle the AP off/on on
|
|
114
|
+
* purpose; that must NOT annul the tack the cycle is trying to save. */
|
|
115
|
+
this._recoveryCycleUntil = 0;
|
|
113
116
|
if (opts?.allowDodge)
|
|
114
117
|
this.allowDodge = true;
|
|
115
118
|
this.onDataChanged = opts?.onDataChanged;
|
|
@@ -290,6 +293,8 @@ class AutopilotProvider {
|
|
|
290
293
|
const pendVal = this.pendingEngaged.value;
|
|
291
294
|
if (eng === pendVal) {
|
|
292
295
|
this.pendingEngaged = null;
|
|
296
|
+
if (!eng)
|
|
297
|
+
this._cancelVirtualTackOnDisengage("disengage echo");
|
|
293
298
|
this.onStageEvent?.("provider", {
|
|
294
299
|
path: "ap.enabled",
|
|
295
300
|
decision: "echo_dropped_match",
|
|
@@ -317,6 +322,17 @@ class AutopilotProvider {
|
|
|
317
322
|
this.data.engaged = eng;
|
|
318
323
|
changed = true;
|
|
319
324
|
changedField = "engaged";
|
|
325
|
+
// Rev506: AP off from ANY source (pypilot itself, physical
|
|
326
|
+
// remote, native UI, KIP switch, /raw) annuls the virtual tack.
|
|
327
|
+
if (!eng)
|
|
328
|
+
this._cancelVirtualTackOnDisengage("disengaged externally");
|
|
329
|
+
// Rev508: steer-dropped alarm (skip our own recovery cycle).
|
|
330
|
+
if (!eng && Date.now() >= this._recoveryCycleUntil) {
|
|
331
|
+
try {
|
|
332
|
+
this.onExternalDisengage?.();
|
|
333
|
+
}
|
|
334
|
+
catch { /* observer only */ }
|
|
335
|
+
}
|
|
320
336
|
}
|
|
321
337
|
this.onStageEvent?.("provider", {
|
|
322
338
|
path: "ap.enabled",
|
|
@@ -391,6 +407,16 @@ class AutopilotProvider {
|
|
|
391
407
|
// echoes while pending.
|
|
392
408
|
const eng = st.engaged;
|
|
393
409
|
const apSt = eng ? "enabled" : "disabled";
|
|
410
|
+
// Rev506 (Carlos sea trial 2026-10-18): taking the AP off annuls a
|
|
411
|
+
// running virtual tack ("si quito AP la virada queda anulada"). The
|
|
412
|
+
// FSM used to keep driving after a disengage and then rejected the
|
|
413
|
+
// sailor's own targets ("FSM is driving") until it finished.
|
|
414
|
+
if (!eng)
|
|
415
|
+
this._cancelVirtualTackOnDisengage("disengage order");
|
|
416
|
+
try {
|
|
417
|
+
this.onAppEngageOrder?.();
|
|
418
|
+
}
|
|
419
|
+
catch { /* observer only */ }
|
|
394
420
|
// Rev271 (audit R01+R04): bump generation and cancel any NAV
|
|
395
421
|
// pending timer. If this call is superseded before we make it past
|
|
396
422
|
// the retry backoff, the stale isStale() check aborts the retry
|
|
@@ -983,22 +1009,33 @@ class AutopilotProvider {
|
|
|
983
1009
|
// pypilot pick the short arc in the WRONG direction).
|
|
984
1010
|
let remainingIntermediates = geometry.intermediatesRad.slice();
|
|
985
1011
|
let stepCounter = 0;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1012
|
+
// Rev506: final targets follow the sailor's nudges (adjustRad).
|
|
1013
|
+
const curAdjust = () => (this._virtualTack && this._virtualTack.id === vtId) ? this._virtualTack.adjustRad : 0;
|
|
1014
|
+
const curAdjustVersion = () => (this._virtualTack && this._virtualTack.id === vtId) ? this._virtualTack.adjustVersion : 0;
|
|
1015
|
+
const finalCompass = () => (0, virtual_tack_1.adjustedFinalCompassRad)(geometry, curAdjust());
|
|
1016
|
+
const finalWind = () => (0, virtual_tack_1.adjustedFinalWindRad)(geometry, curAdjust());
|
|
1017
|
+
let seenAdjustVersion = curAdjustVersion();
|
|
1018
|
+
// Rev506: publish remaining degrees on every tick (not only per
|
|
1019
|
+
// step) so the countdown shows what is LEFT to turn.
|
|
1020
|
+
const publishRemaining = (hRad) => {
|
|
1021
|
+
if (hRad === null || !this._virtualTack || this._virtualTack.id !== vtId)
|
|
1022
|
+
return;
|
|
1023
|
+
const rem = Math.abs(shortestArcRad(finalCompass(), hRad) * RAD_TO_DEG);
|
|
1024
|
+
const prev = this._virtualTack.remainingDeg;
|
|
1025
|
+
if (prev === null || Math.abs(prev - rem) >= 1) {
|
|
1026
|
+
this._virtualTack.remainingDeg = rem;
|
|
996
1027
|
try {
|
|
997
1028
|
this.notifyChanged("virtualTack");
|
|
998
1029
|
}
|
|
999
1030
|
catch { /* noop */ }
|
|
1000
1031
|
}
|
|
1001
|
-
}
|
|
1032
|
+
};
|
|
1033
|
+
// Rev410 (fix T.1-A, Carlos 2026-10-04): publish an initial
|
|
1034
|
+
// `remainingDeg` so the visor's mirror HUD shows a meaningful
|
|
1035
|
+
// number from the first snapshot, not just elapsed seconds.
|
|
1036
|
+
// Granularity thereafter is "once per intermediate step reached"
|
|
1037
|
+
// (option A, confirmed by Carlos — no 500 ms tick).
|
|
1038
|
+
publishRemaining(this._readHeadingRad());
|
|
1002
1039
|
while (remainingIntermediates.length > 0) {
|
|
1003
1040
|
checkCancelled();
|
|
1004
1041
|
this._virtualTack.stepIndex = stepCounter;
|
|
@@ -1018,8 +1055,16 @@ class AutopilotProvider {
|
|
|
1018
1055
|
// if they want to abort for any other reason.
|
|
1019
1056
|
let lastHeadingRad = null;
|
|
1020
1057
|
let lastHeadingChangeTs = Date.now();
|
|
1058
|
+
let retarget = false;
|
|
1021
1059
|
while (true) {
|
|
1022
1060
|
const hNow = this._readHeadingRad();
|
|
1061
|
+
publishRemaining(hNow);
|
|
1062
|
+
// Rev506: sailor nudged the destination -> re-aim from here.
|
|
1063
|
+
if (curAdjustVersion() !== seenAdjustVersion) {
|
|
1064
|
+
seenAdjustVersion = curAdjustVersion();
|
|
1065
|
+
retarget = true;
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1023
1068
|
if (hNow !== null) {
|
|
1024
1069
|
if ((0, virtual_tack_1.isAtTarget)({
|
|
1025
1070
|
hNowRad: hNow,
|
|
@@ -1046,27 +1091,35 @@ class AutopilotProvider {
|
|
|
1046
1091
|
await sleep(500);
|
|
1047
1092
|
checkCancelled();
|
|
1048
1093
|
}
|
|
1094
|
+
if (retarget) {
|
|
1095
|
+
const hNow = this._readHeadingRad();
|
|
1096
|
+
if (hNow !== null) {
|
|
1097
|
+
remainingIntermediates = (0, virtual_tack_1.recomputeRemainingIntermediates)({
|
|
1098
|
+
hNowRad: hNow,
|
|
1099
|
+
finalCompassTargetRad: finalCompass(),
|
|
1100
|
+
originalDeltaSign: Math.sign(geometry.deltaHRad),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
// No heading: keep the plan but aim the last step at the new final.
|
|
1105
|
+
remainingIntermediates[remainingIntermediates.length - 1] = finalCompass();
|
|
1106
|
+
}
|
|
1107
|
+
// eslint-disable-next-line no-console
|
|
1108
|
+
console.log(`[virtual-tack ${vtId}] re-aimed by sailor: adjust=${(curAdjust() * RAD_TO_DEG).toFixed(0)}deg, steps=${remainingIntermediates.length}`);
|
|
1109
|
+
continue; // write the new first step; nothing was "reached"
|
|
1110
|
+
}
|
|
1049
1111
|
// Step reached. Pop it and recompute the rest from REAL heading.
|
|
1050
1112
|
remainingIntermediates.shift();
|
|
1051
1113
|
stepCounter++;
|
|
1052
1114
|
// Rev410 (fix T.1-A): refresh `remainingDeg` from the current
|
|
1053
1115
|
// 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
|
-
}
|
|
1116
|
+
publishRemaining(this._readHeadingRad());
|
|
1064
1117
|
if (remainingIntermediates.length > 0) {
|
|
1065
1118
|
const hNow = this._readHeadingRad();
|
|
1066
1119
|
if (hNow !== null) {
|
|
1067
1120
|
remainingIntermediates = (0, virtual_tack_1.recomputeRemainingIntermediates)({
|
|
1068
1121
|
hNowRad: hNow,
|
|
1069
|
-
finalCompassTargetRad,
|
|
1122
|
+
finalCompassTargetRad: finalCompass(),
|
|
1070
1123
|
originalDeltaSign: Math.sign(geometry.deltaHRad),
|
|
1071
1124
|
});
|
|
1072
1125
|
}
|
|
@@ -1091,12 +1144,13 @@ class AutopilotProvider {
|
|
|
1091
1144
|
checkCancelled();
|
|
1092
1145
|
this._vtInternalWrite = true;
|
|
1093
1146
|
try {
|
|
1094
|
-
await this.setTarget(
|
|
1147
|
+
await this.setTarget(finalWind());
|
|
1095
1148
|
}
|
|
1096
1149
|
finally {
|
|
1097
1150
|
this._vtInternalWrite = false;
|
|
1098
1151
|
}
|
|
1099
1152
|
checkCancelled();
|
|
1153
|
+
seenAdjustVersion = curAdjustVersion();
|
|
1100
1154
|
// Rev387: settling phase. Watches the wind angle error until
|
|
1101
1155
|
// it stays under tolerance for a short dwell.
|
|
1102
1156
|
// Rev407: no artificial "declaring completed anyway" fallback.
|
|
@@ -1139,10 +1193,23 @@ class AutopilotProvider {
|
|
|
1139
1193
|
}
|
|
1140
1194
|
}
|
|
1141
1195
|
catch { /* noop */ }
|
|
1196
|
+
// Rev506: a nudge during settling re-aims the wind target.
|
|
1197
|
+
if (curAdjustVersion() !== seenAdjustVersion) {
|
|
1198
|
+
seenAdjustVersion = curAdjustVersion();
|
|
1199
|
+
this._vtInternalWrite = true;
|
|
1200
|
+
try {
|
|
1201
|
+
await this.setTarget(finalWind());
|
|
1202
|
+
}
|
|
1203
|
+
finally {
|
|
1204
|
+
this._vtInternalWrite = false;
|
|
1205
|
+
}
|
|
1206
|
+
checkCancelled();
|
|
1207
|
+
dwellStart = null;
|
|
1208
|
+
}
|
|
1142
1209
|
const sampleFresh = windNowRad !== null && (sampleAgeMs === null || sampleAgeMs < STALE_SAMPLE_MS);
|
|
1143
1210
|
if (sampleFresh) {
|
|
1144
1211
|
awaEmaRad = (0, virtual_tack_1.awaEmaStep)(awaEmaRad, windNowRad, EMA_ALPHA);
|
|
1145
|
-
const errDeg = Math.abs(((((awaEmaRad -
|
|
1212
|
+
const errDeg = Math.abs(((((awaEmaRad - finalWind()) * RAD_TO_DEG) + 540) % 360) - 180);
|
|
1146
1213
|
if (errDeg < SETTLE_TOLERANCE_DEG) {
|
|
1147
1214
|
if (dwellStart === null)
|
|
1148
1215
|
dwellStart = Date.now();
|
|
@@ -1286,18 +1353,84 @@ class AutopilotProvider {
|
|
|
1286
1353
|
* when the sailor taps the tack button a second time (frontend
|
|
1287
1354
|
* Rev379). Sets phase to "abort" so the running driver bails and
|
|
1288
1355
|
* rolls back on its next await. */
|
|
1289
|
-
cancelVirtualTack() {
|
|
1356
|
+
cancelVirtualTack(reason = "user cancelled") {
|
|
1290
1357
|
if (!this._virtualTack)
|
|
1291
1358
|
return;
|
|
1292
1359
|
const terminal = ["completed", "cancelled", "failed"];
|
|
1293
1360
|
if (terminal.includes(this._virtualTack.phase))
|
|
1294
1361
|
return;
|
|
1295
1362
|
// eslint-disable-next-line no-console
|
|
1296
|
-
console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase}`);
|
|
1363
|
+
console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase} (${reason})`);
|
|
1297
1364
|
this._virtualTack.phase = "cancelling";
|
|
1298
|
-
this._virtualTack.outcomeReason =
|
|
1365
|
+
this._virtualTack.outcomeReason = reason;
|
|
1299
1366
|
this.notifyChanged("all");
|
|
1300
1367
|
}
|
|
1368
|
+
/** Rev506 (Carlos sea trial 2026-10-18): +-1/+-10 during a virtual tack
|
|
1369
|
+
* move its final destination. deltaRad is bow-relative, positive = bow
|
|
1370
|
+
* to starboard (same sign as the visor's nudge buttons). Returns false
|
|
1371
|
+
* when no tack is running so the caller can fall back to a normal nudge. */
|
|
1372
|
+
adjustVirtualTack(deltaRad) {
|
|
1373
|
+
if (!Number.isFinite(deltaRad) || !this.isVirtualTackActive())
|
|
1374
|
+
return false;
|
|
1375
|
+
const vt = this._virtualTack;
|
|
1376
|
+
if (vt.phase === "cancelling")
|
|
1377
|
+
return false;
|
|
1378
|
+
vt.adjustRad = (0, virtual_tack_1.accumulateTackAdjust)(vt.adjustRad, deltaRad);
|
|
1379
|
+
vt.adjustVersion += 1;
|
|
1380
|
+
// eslint-disable-next-line no-console
|
|
1381
|
+
console.log(`[virtual-tack ${vt.id}] sailor adjust ${(deltaRad * RAD_TO_DEG).toFixed(0)}deg -> total ${(vt.adjustRad * RAD_TO_DEG).toFixed(0)}deg`);
|
|
1382
|
+
this.notifyChanged("virtualTack");
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
/** Rev506: true while a virtual tack is running (not terminal). */
|
|
1386
|
+
isVirtualTackActive() {
|
|
1387
|
+
const vt = this._virtualTack;
|
|
1388
|
+
return !!vt && !["idle", "completed", "cancelled", "failed"].includes(vt.phase);
|
|
1389
|
+
}
|
|
1390
|
+
/** Rev508 (steer recovery): AP off -> gapMs -> AP on, same mode and
|
|
1391
|
+
* target (pypilot keeps both). Changing ap.enabled clears pypilot's
|
|
1392
|
+
* directional overcurrent fault (servo.py), the false end stop that
|
|
1393
|
+
* left the servo at 0 A on 2026-10-18. A running virtual tack is NOT
|
|
1394
|
+
* annulled by the cycle. If any other engage/disengage order arrives
|
|
1395
|
+
* meanwhile, the cycle aborts and does not re-engage. */
|
|
1396
|
+
async recoveryCycle(gapMs = 700) {
|
|
1397
|
+
if (!this.data.engaged || !this.client.connected)
|
|
1398
|
+
return "skipped";
|
|
1399
|
+
this._recoveryCycleUntil = Date.now() + gapMs + 3000;
|
|
1400
|
+
try {
|
|
1401
|
+
await this.setState("disabled");
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
this._recoveryCycleUntil = 0;
|
|
1405
|
+
return "aborted";
|
|
1406
|
+
}
|
|
1407
|
+
const gen = this.engageGen;
|
|
1408
|
+
await new Promise((r) => setTimeout(r, gapMs));
|
|
1409
|
+
if (this.engageGen !== gen) {
|
|
1410
|
+
// The sailor gave an order during the gap: theirs wins. If it was a
|
|
1411
|
+
// disengage, the tack must be annulled as usual.
|
|
1412
|
+
this._recoveryCycleUntil = 0;
|
|
1413
|
+
if (!this.data.engaged)
|
|
1414
|
+
this._cancelVirtualTackOnDisengage("disengage during recovery");
|
|
1415
|
+
return "aborted";
|
|
1416
|
+
}
|
|
1417
|
+
try {
|
|
1418
|
+
await this.setState("enabled");
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
this._recoveryCycleUntil = 0;
|
|
1422
|
+
this._cancelVirtualTackOnDisengage("recovery re-engage failed");
|
|
1423
|
+
return "aborted";
|
|
1424
|
+
}
|
|
1425
|
+
return "done";
|
|
1426
|
+
}
|
|
1427
|
+
_cancelVirtualTackOnDisengage(reason) {
|
|
1428
|
+
if (Date.now() < this._recoveryCycleUntil)
|
|
1429
|
+
return;
|
|
1430
|
+
if (!this.isVirtualTackActive())
|
|
1431
|
+
return;
|
|
1432
|
+
this.cancelVirtualTack(reason);
|
|
1433
|
+
}
|
|
1301
1434
|
async engage() {
|
|
1302
1435
|
// Rev279 (audit follow-up A): the catch used to fall through to
|
|
1303
1436
|
// setState("enabled") on ANY setNavMode failure, including a
|