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 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 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.
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
- this._vtInternalWrite = true;
1306
- try {
1307
- await this.setTarget(awaRad);
1308
- }
1309
- catch { /* best-effort */ }
1310
- finally {
1311
- 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`);
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;
@@ -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;