signalk-pypilot-newui 3.4.0 → 3.6.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 +87 -0
- package/README.md +13 -6
- package/dist/autopilot-provider.js +60 -7
- package/dist/autowind.js +71 -0
- package/dist/index.js +234 -3
- package/dist/maneuver-trace.js +32 -0
- package/dist/steer-watch.js +31 -4
- package/dist/trip-recorder.js +29 -12
- package/dist/virtual-tack.js +10 -0
- package/package.json +1 -1
- package/public/app.js +403 -32
- package/public/index.html +14 -3
- package/public/style.css +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.6.0 — 2026-10-19 — AUTO WIND and a logbook button on the rose
|
|
4
|
+
|
|
5
|
+
Rolls up Rev517 → Rev520 on top of 3.5.0. Minor bump: new AUTO WIND
|
|
6
|
+
pseudo-mode. No breaking changes; AUTO WIND is off until chosen.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **AUTO WIND pseudo-mode**: apparent wind below 120° TWA, true wind
|
|
10
|
+
above (hysteresis ±5°, 10 s sustained, at most one switch per 30 s;
|
|
11
|
+
never during a tack, head-to-wind, head-downwind or with the AP off).
|
|
12
|
+
Each side has its own pypilot profile, chosen in the mode window, so
|
|
13
|
+
the true-wind side can be the more reactive one. Runs in the backend
|
|
14
|
+
(works with the visor closed); picking any other mode, or compass from
|
|
15
|
+
the remote, turns it off. Mode button shows "AUTO · REAL / APPARENT".
|
|
16
|
+
Without GPS pypilot offers no true wind: the entry is shown greyed out
|
|
17
|
+
("needs GPS for true wind"), and pypilot's own drop to compass on a
|
|
18
|
+
GPS loss does not turn AUTO WIND off.
|
|
19
|
+
Listed right below compass / GPS in the mode window.
|
|
20
|
+
- **Logbook button**: while a trip is being recorded it opens that trip;
|
|
21
|
+
in port it opens the logbook list.
|
|
22
|
+
- **Logbook button on the rose**: a helm icon on the left, mirroring the
|
|
23
|
+
tide button on the right, opens the Logbook full screen; its x goes
|
|
24
|
+
back to the rose.
|
|
25
|
+
|
|
26
|
+
## 3.5.0 — 2026-10-19 — Fixes from two sea trials, logbook while sailing
|
|
27
|
+
|
|
28
|
+
Rolls up Rev509 → Rev516 on top of 3.4.0. Minor bump: live logbook,
|
|
29
|
+
heading in the compass tack summary, trace pruning. No breaking changes.
|
|
30
|
+
|
|
31
|
+
Rev515 comes from the sail of 2026-10-19: it reverts two changes that
|
|
32
|
+
made things worse (Rev510 compass-until-6°, Rev512 head-to-wind in
|
|
33
|
+
compass) and fixes what the sail showed. Audit:
|
|
34
|
+
`docs/_audit_sail_2026-10-19.md`.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **Maneuver traces keep the newest 60 files** (pruned when a new trace
|
|
38
|
+
opens). 201 files / 888 MB had piled up on the Tunatunes.
|
|
39
|
+
- **Stuck pilot recovery only on a real false end stop**: it now needs
|
|
40
|
+
pypilot's directional overcurrent fault. 0 A is not evidence (the
|
|
41
|
+
TinyPilot reads 0 A while driving, `DRIVER_TIMEOUT`), it never acts
|
|
42
|
+
during pypilot's own tack or for 20 s after, and a target change of
|
|
43
|
+
more than 10° restarts its clock. On 2026-10-19 it cycled the AP three
|
|
44
|
+
times by mistake, twice in the middle of a tack from the remote.
|
|
45
|
+
- **Gybe confirmation only right after a cancel**: the long-way gybe
|
|
46
|
+
question (> 180°) now appears only within 20 s of cancelling a tack -
|
|
47
|
+
the 2026-10-18 accident - not on deliberate gybes. Yes / No buttons
|
|
48
|
+
are translated.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
- **Logbook while sailing**: the trip being recorded appears in the
|
|
52
|
+
logbook with an "IN PROGRESS" badge, live statistics (distance, time,
|
|
53
|
+
speeds, wind, tacks, energy...) and the track on the chart up to now.
|
|
54
|
+
- **Tack summary in compass mode** shows the mean heading before / after
|
|
55
|
+
instead of the apparent wind angle.
|
|
56
|
+
- **Confirmation before a gybe the long way** (see above).
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
- **Cancelling a virtual tack left a "ghost" target**: pypilot processes
|
|
60
|
+
the mode change in its own loop and overwrote our "stay here" target
|
|
61
|
+
with one recomputed from the compass error (2026-10-19: 56° -> 3.9°,
|
|
62
|
+
bow into the wind). The cancel now waits for the mode change, writes
|
|
63
|
+
the target, checks it and rewrites it if pypilot moved it.
|
|
64
|
+
- **Remote tack HUD turning the wrong way**: pypilot overwrites
|
|
65
|
+
`ap.tack.direction` with the wind side ~0.5 s after the remote starts a
|
|
66
|
+
tack; the HUD now keeps the direction it had at the start. It also stays
|
|
67
|
+
open until the boat is within 10° of the new target (max 15 s), not only
|
|
68
|
+
until pypilot's 50 % threshold.
|
|
69
|
+
- **Tack countdown**: shows the distance from the wind arrow to the target
|
|
70
|
+
diamond along the turn (what the rose shows), no "ajustando" text.
|
|
71
|
+
- **Two screens showing different modes after a tack**: the mode now
|
|
72
|
+
comes from the backend (plain mode label, no "TACKING ->" prefix).
|
|
73
|
+
- **Previous manoeuvre's summary popping up during the next one**.
|
|
74
|
+
- **Settings tab**: dragging a slider no longer darkens the whole tab
|
|
75
|
+
(only the other rows), and the darkening clears if the touch is
|
|
76
|
+
interrupted. A value the sailor writes stays on screen until the
|
|
77
|
+
backend confirms it (or reports a different one).
|
|
78
|
+
- **`ap.tack.direction` noise in standby**: published as `none` once
|
|
79
|
+
while the AP is off (pypilot flips it ~40 times a minute there).
|
|
80
|
+
- **Gust ghost arrow almost never visible**: the fade started in the
|
|
81
|
+
same frame the ghost was created, so the browser skipped it.
|
|
82
|
+
|
|
83
|
+
### Reverted
|
|
84
|
+
- Rev510 "virtual tack holds compass until 6° of the final heading": the
|
|
85
|
+
apparent wind at the final heading is not the mirror, so wind mode then
|
|
86
|
+
swung back 40-50° (S-turn); handover is at 20° again.
|
|
87
|
+
- Rev512 "head to wind holds in compass": head to wind is always in
|
|
88
|
+
apparent wind, as before.
|
|
89
|
+
|
|
3
90
|
## 3.4.0 — 2026-10-18 — The sailor always wins, and the pilot gets unstuck
|
|
4
91
|
|
|
5
92
|
Rolls up Rev506 → Rev508 on top of 3.3.0, from the strong-wind sea
|
package/README.md
CHANGED
|
@@ -131,7 +131,8 @@ up automatically.
|
|
|
131
131
|
13. **Tide at a glance** (with `signalk-mareas-ihm`) — button on the
|
|
132
132
|
rose opens a popup with an interactive tide curve, current height,
|
|
133
133
|
trend, next HW/LW and coefficient, and can open the anchor-watch
|
|
134
|
-
visor inside the rose.
|
|
134
|
+
visor inside the rose. A helm button on the other side opens the
|
|
135
|
+
logbook (the trip in progress while sailing).
|
|
135
136
|
|
|
136
137
|
### Autopilot enhancements — 12 new added functions
|
|
137
138
|
|
|
@@ -142,17 +143,21 @@ up automatically.
|
|
|
142
143
|
original stays one tap away.
|
|
143
144
|
15. **Smart Pilot — auto profile by wind** — switches the pypilot
|
|
144
145
|
profile between Light / Medium / Heavy according to the averaged
|
|
145
|
-
TWS.
|
|
146
|
+
TWS. **AUTO WIND pseudo-mode**: apparent wind below 120° TWA, true
|
|
147
|
+
wind above (with hysteresis), one pypilot profile for each side so
|
|
148
|
+
the true-wind side can be the more reactive one. Needs GPS (pypilot
|
|
149
|
+
only offers true wind with it).
|
|
146
150
|
16. **Smart Pilot — gust strategy** — freeze target, boost D +20%
|
|
147
151
|
for 20 s, temporarily switch to the Heavy profile for 30 s, or
|
|
148
152
|
just warn.
|
|
149
153
|
17. **Auto-disengage on lost authority** — RMS heading error > 30°
|
|
150
154
|
with servo duty > 90% sustained 10 s. **Stuck pilot recovery**
|
|
151
155
|
(opt-in): with no rudder sensor, pypilot treats an overcurrent as
|
|
152
|
-
the end of rudder travel and stops driving that way; when
|
|
153
|
-
|
|
154
|
-
off and on (same mode and target)
|
|
155
|
-
minute.
|
|
156
|
+
the end of rudder travel and stops driving that way; when pypilot
|
|
157
|
+
reports that directional overcurrent fault and the error keeps
|
|
158
|
+
growing, the plugin turns the AP off and on (same mode and target)
|
|
159
|
+
to clear it, at most 3 times per minute. Never during pypilot's own
|
|
160
|
+
tack.
|
|
156
161
|
18. **Pre-departure Autopilot Check** — a single READY / CAVEATS /
|
|
157
162
|
DO-NOT-ENGAGE verdict aggregating every sensor, servo, voltage
|
|
158
163
|
and link signal.
|
|
@@ -200,6 +205,8 @@ up automatically.
|
|
|
200
205
|
split, min voltage, AP-engaged %), OpenSeaMap trace coloured by
|
|
201
206
|
SOG with markers on peak gust, peak SOG, min voltage and every
|
|
202
207
|
detected tack. Share as portrait PNG report card or as plain text.
|
|
208
|
+
The trip in progress can be opened while sailing (live statistics
|
|
209
|
+
and the track so far).
|
|
203
210
|
27. **Chart tab** — 9 live Trip Stats cards, history with
|
|
204
211
|
30 s / 2 min / 10 min windows and hover-freeze, plus a Servo
|
|
205
212
|
Health card.
|
|
@@ -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
|
|
@@ -113,6 +114,8 @@ class AutopilotProvider {
|
|
|
113
114
|
/** Rev506: recovery cycles (steer-recovery) toggle the AP off/on on
|
|
114
115
|
* purpose; that must NOT annul the tack the cycle is trying to save. */
|
|
115
116
|
this._recoveryCycleUntil = 0;
|
|
117
|
+
/** Rev515: last cancel of a running virtual tack (any source). */
|
|
118
|
+
this._lastVtCancelTs = 0;
|
|
116
119
|
if (opts?.allowDodge)
|
|
117
120
|
this.allowDodge = true;
|
|
118
121
|
this.onDataChanged = opts?.onDataChanged;
|
|
@@ -766,6 +769,24 @@ class AutopilotProvider {
|
|
|
766
769
|
&& Number.isFinite(opts.finalAngleRadOverride)
|
|
767
770
|
&& Math.abs(opts.finalAngleRadOverride) <= Math.PI
|
|
768
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
|
+
}
|
|
769
790
|
this._runVirtualTack(opts.direction, windMode, headingRad, awaRad, finalAngleOverride).catch((e) => {
|
|
770
791
|
// eslint-disable-next-line no-console
|
|
771
792
|
console.log(`[startVirtualTack] driver threw: ${e?.message || e}`);
|
|
@@ -1302,13 +1323,32 @@ class AutopilotProvider {
|
|
|
1302
1323
|
}
|
|
1303
1324
|
catch { /* noop */ }
|
|
1304
1325
|
if (awaRad !== null) {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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`);
|
|
1312
1352
|
}
|
|
1313
1353
|
}
|
|
1314
1354
|
}
|
|
@@ -1361,6 +1401,7 @@ class AutopilotProvider {
|
|
|
1361
1401
|
return;
|
|
1362
1402
|
// eslint-disable-next-line no-console
|
|
1363
1403
|
console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase} (${reason})`);
|
|
1404
|
+
this._lastVtCancelTs = Date.now();
|
|
1364
1405
|
this._virtualTack.phase = "cancelling";
|
|
1365
1406
|
this._virtualTack.outcomeReason = reason;
|
|
1366
1407
|
this.notifyChanged("all");
|
|
@@ -1382,6 +1423,18 @@ class AutopilotProvider {
|
|
|
1382
1423
|
this.notifyChanged("virtualTack");
|
|
1383
1424
|
return true;
|
|
1384
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
|
+
}
|
|
1385
1438
|
/** Rev506: true while a virtual tack is running (not terminal). */
|
|
1386
1439
|
isVirtualTackActive() {
|
|
1387
1440
|
const vt = this._virtualTack;
|
package/dist/autowind.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Rev518 (Carlos 2026-10-19): pseudo-mode AUTOWIND. Apparent wind
|
|
3
|
+
// (pypilot "wind") when |TWA| < threshold, true wind ("true wind") when
|
|
4
|
+
// above: downwind the apparent angle swings with boat speed and the
|
|
5
|
+
// pilot chases it (2026-10-19 18:48: +-40 deg in apparent at TWA ~140,
|
|
6
|
+
// steady in true wind). Each side has its own pypilot profile, so the
|
|
7
|
+
// true-wind side can be the more reactive one.
|
|
8
|
+
//
|
|
9
|
+
// Pure decision logic: fed once per second, no I/O.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.AutowindDecider = exports.AUTOWIND_MIN_GAP_MS = exports.AUTOWIND_SUSTAIN_MS = exports.AUTOWIND_HYSTERESIS_DEG = exports.AUTOWIND_DEFAULT_THRESHOLD_DEG = void 0;
|
|
12
|
+
exports.desiredSubMode = desiredSubMode;
|
|
13
|
+
exports.AUTOWIND_DEFAULT_THRESHOLD_DEG = 120;
|
|
14
|
+
exports.AUTOWIND_HYSTERESIS_DEG = 5; // +-5 around the threshold
|
|
15
|
+
exports.AUTOWIND_SUSTAIN_MS = 10000; // condition held this long
|
|
16
|
+
exports.AUTOWIND_MIN_GAP_MS = 30000; // between two switches
|
|
17
|
+
/** Side of the threshold the wind is on, with hysteresis; null in the band. */
|
|
18
|
+
function desiredSubMode(twaDeg, thresholdDeg = exports.AUTOWIND_DEFAULT_THRESHOLD_DEG) {
|
|
19
|
+
const a = Math.abs(twaDeg);
|
|
20
|
+
if (a >= thresholdDeg + exports.AUTOWIND_HYSTERESIS_DEG)
|
|
21
|
+
return "true wind";
|
|
22
|
+
if (a <= thresholdDeg - exports.AUTOWIND_HYSTERESIS_DEG)
|
|
23
|
+
return "wind";
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
class AutowindDecider {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.pending = null;
|
|
29
|
+
this.pendingSince = null;
|
|
30
|
+
this.lastSwitchMs = null;
|
|
31
|
+
}
|
|
32
|
+
/** Returns the mode to switch to now, or null. */
|
|
33
|
+
tick(i) {
|
|
34
|
+
const cur = String(i.mode || "").toLowerCase();
|
|
35
|
+
const curSub = cur === "true wind" ? "true wind" : cur === "wind" ? "wind" : null;
|
|
36
|
+
if (!i.engaged || i.blocked || typeof i.twaDeg !== "number" || !isFinite(i.twaDeg)) {
|
|
37
|
+
this.pending = null;
|
|
38
|
+
this.pendingSince = null;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const want = desiredSubMode(i.twaDeg, i.thresholdDeg);
|
|
42
|
+
if (want == null || want === curSub) {
|
|
43
|
+
this.pending = null;
|
|
44
|
+
this.pendingSince = null;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (this.pending !== want) {
|
|
48
|
+
this.pending = want;
|
|
49
|
+
this.pendingSince = i.nowMs;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (this.pendingSince != null && i.nowMs - this.pendingSince < exports.AUTOWIND_SUSTAIN_MS)
|
|
53
|
+
return null;
|
|
54
|
+
if (this.lastSwitchMs != null && i.nowMs - this.lastSwitchMs < exports.AUTOWIND_MIN_GAP_MS)
|
|
55
|
+
return null;
|
|
56
|
+
this.pending = null;
|
|
57
|
+
this.pendingSince = null;
|
|
58
|
+
this.lastSwitchMs = i.nowMs;
|
|
59
|
+
return want;
|
|
60
|
+
}
|
|
61
|
+
/** Called when Autowind is switched on: no sustain, no gap. */
|
|
62
|
+
initial(twaDeg, thresholdDeg) {
|
|
63
|
+
this.pending = null;
|
|
64
|
+
this.pendingSince = null;
|
|
65
|
+
if (typeof twaDeg !== "number" || !isFinite(twaDeg))
|
|
66
|
+
return "wind";
|
|
67
|
+
return Math.abs(twaDeg) >= (thresholdDeg ?? exports.AUTOWIND_DEFAULT_THRESHOLD_DEG) ? "true wind" : "wind";
|
|
68
|
+
}
|
|
69
|
+
noteSwitch(nowMs) { this.lastSwitchMs = nowMs; }
|
|
70
|
+
}
|
|
71
|
+
exports.AutowindDecider = AutowindDecider;
|