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 +101 -0
- package/README.md +12 -3
- package/dist/alarms.js +47 -5
- package/dist/autopilot-provider.js +220 -34
- package/dist/index.js +189 -18
- package/dist/maneuver-trace.js +32 -0
- package/dist/steer-watch.js +174 -0
- package/dist/trip-recorder.js +29 -12
- package/dist/virtual-tack.js +34 -1
- package/package.json +1 -1
- package/public/app.js +462 -59
- package/public/index.html +14 -3
- package/public/style.css +4 -2
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 —
|
|
160
|
-
unable-to-steer,
|
|
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
|
-
|
|
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",
|
|
@@ -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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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(
|
|
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 -
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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 =
|
|
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
|