signalk-pypilot-newui 3.4.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,69 @@
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
+
3
67
  ## 3.4.0 — 2026-10-18 — The sailor always wins, and the pilot gets unstuck
4
68
 
5
69
  Rolls up Rev506 → Rev508 on top of 3.3.0, from the strong-wind sea
package/README.md CHANGED
@@ -149,10 +149,11 @@ up automatically.
149
149
  17. **Auto-disengage on lost authority** — RMS heading error > 30°
150
150
  with servo duty > 90% sustained 10 s. **Stuck pilot recovery**
151
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.
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.
156
157
  18. **Pre-departure Autopilot Check** — a single READY / CAVEATS /
157
158
  DO-NOT-ENGAGE verdict aggregating every sensor, servo, voltage
158
159
  and link signal.
@@ -200,6 +201,8 @@ up automatically.
200
201
  split, min voltage, AP-engaged %), OpenSeaMap trace coloured by
201
202
  SOG with markers on peak gust, peak SOG, min voltage and every
202
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).
203
206
  27. **Chart tab** — 9 live Trip Stats cards, history with
204
207
  30 s / 2 min / 10 min windows and hover-freeze, plus a Servo
205
208
  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;
package/dist/index.js CHANGED
@@ -32,7 +32,7 @@ const trip_recorder_1 = require("./trip-recorder");
32
32
  const maneuver_trace_1 = require("./maneuver-trace");
33
33
  // Rev counter bumped on every build so the user can distinguish deploys
34
34
  // from the webapp header (feedback_revision_bump_each_build).
35
- const PLUGIN_REVISION = "Rev508";
35
+ const PLUGIN_REVISION = "Rev516";
36
36
  // Rev59: read package.json once at load time so /status can report the
37
37
  // npm package version alongside the internal Rev counter.
38
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -523,7 +523,12 @@ module.exports = function (app) {
523
523
  publishCatalogDerived(catalog);
524
524
  });
525
525
  client.on("value", (name, value) => {
526
- publishValue(name, value);
526
+ // Rev513: ap.tack.direction is meaningless in standby and pypilot
527
+ // flips it none <-> port/starboard ~40 times a minute there.
528
+ if (name === "ap.tack.direction")
529
+ _publishTackDirection(value);
530
+ else
531
+ publishValue(name, value);
527
532
  if (apProvider) {
528
533
  // Rev352: receiveValue now returns the field that changed
529
534
  // (engaged | target | all | null) so we push field-scoped
@@ -535,6 +540,14 @@ module.exports = function (app) {
535
540
  if (changedField)
536
541
  pushAutopilotUpdate(changedField);
537
542
  }
543
+ if (name === "ap.enabled") {
544
+ // Rev513: re-sync the direction on engage / disengage (after the
545
+ // provider has taken the new engaged state).
546
+ try {
547
+ _publishTackDirection(client?.getValues?.()["ap.tack.direction"]);
548
+ }
549
+ catch { /* silent */ }
550
+ }
538
551
  // Rev299 (I2): watch ap.tack.state for the tacking → none
539
552
  // transition, which is the moment the sailor needs the
540
553
  // catch-up nudge. `ap.tack.direction` is watched separately
@@ -2044,10 +2057,16 @@ module.exports = function (app) {
2044
2057
  direction: dir,
2045
2058
  requestId,
2046
2059
  finalAngleRadOverride,
2060
+ confirmLongJibe: req.body?.confirmLongJibe === true,
2047
2061
  });
2048
2062
  res.json(r);
2049
2063
  }
2050
2064
  catch (e) {
2065
+ // Rev511: 428 (not 409, which the visor retries) so the visor
2066
+ // asks the sailor before a long-way gybe.
2067
+ if (e?.code === "confirm_long_jibe") {
2068
+ return res.status(428).json({ error: e.message, code: e.code, deltaDeg: e.deltaDeg });
2069
+ }
2051
2070
  res.status(409).json({ error: e?.message || String(e) });
2052
2071
  }
2053
2072
  });
@@ -3270,9 +3289,12 @@ module.exports = function (app) {
3270
3289
  return res.status(503).json({ error: "recorder not initialised" });
3271
3290
  // Return list + attach summary for each so the visor can render
3272
3291
  // the card grid with one call.
3292
+ const liveId = tripRecorder.currentTripId();
3273
3293
  const rows = tripRecorder.list().map((r) => ({
3274
3294
  ...r,
3275
3295
  summary: tripRecorder.summary(r.id),
3296
+ // Rev516: the trip being recorded right now.
3297
+ live: r.id === liveId,
3276
3298
  }));
3277
3299
  res.json({ trips: rows });
3278
3300
  });
@@ -3309,6 +3331,9 @@ module.exports = function (app) {
3309
3331
  const id = String(req.params.id || "").replace(/[^A-Za-z0-9\-]/g, "");
3310
3332
  // eslint-disable-next-line @typescript-eslint/no-require-imports
3311
3333
  const fs = require("fs");
3334
+ // Rev516: trip in progress -> write the pending samples first.
3335
+ if (id === tripRecorder.currentTripId())
3336
+ tripRecorder.flushNow();
3312
3337
  const file = tripRecorder.jsonlPath(id);
3313
3338
  if (!fs.existsSync(file))
3314
3339
  return res.status(404).json({ error: "not found" });
@@ -3981,6 +4006,11 @@ module.exports = function (app) {
3981
4006
  stepIndex: vt.stepIndex,
3982
4007
  totalSteps: g?.intermediatesRad?.length ?? 0,
3983
4008
  remainingDeg,
4009
+ // Rev510: whole compass rotation of this tack, sailor's +-1/+-10
4010
+ // included, so every visor can show "X deg of Y".
4011
+ totalDeg: (g && typeof g.deltaHRad === "number")
4012
+ ? Math.abs(g.deltaHRad * RAD2DEG + (typeof vt.adjustRad === "number" ? vt.adjustRad * RAD2DEG : 0))
4013
+ : null,
3984
4014
  outcomeReason: vt.outcomeReason ?? null,
3985
4015
  },
3986
4016
  });
@@ -4269,6 +4299,7 @@ module.exports = function (app) {
4269
4299
  servoCurrentA: s.servoCurrent,
4270
4300
  servoFlags: typeof pv["servo.flags"] === "string" ? pv["servo.flags"] : null,
4271
4301
  nativeTack: !!(_tackStatePrev && _tackStatePrev !== "none"),
4302
+ cmdDeg: typeof pv["ap.heading_command"] === "number" ? pv["ap.heading_command"] : null,
4272
4303
  recoveryEnabled: !!props.steerRecoveryEnabled && !!props.allowWrites && !!apProvider,
4273
4304
  });
4274
4305
  if (action !== "cycle" || !apProvider || _steerRecoveryInFlight)
@@ -5422,6 +5453,24 @@ module.exports = function (app) {
5422
5453
  _applyWatches(c, catalog);
5423
5454
  _startWatchSweep(c);
5424
5455
  }
5456
+ // Rev513 (audit 2026-10-19): pypilot tacking.py sets ap.tack.direction
5457
+ // to 'none' on every loop while the AP is off, and its wind log sets
5458
+ // it back to port/starboard every 0.25 s when the wind holds on one
5459
+ // side. 2026-10-18: 7551 of 7587 changes were in standby (~40/min);
5460
+ // overnight at the berth 99.9 % of the maneuver-trace lines. In standby
5461
+ // we publish 'none' once; engaged, the real value as before.
5462
+ let _tackDirPublished = undefined;
5463
+ function _publishTackDirection(raw) {
5464
+ const pv = (client && client.connected) ? client.getValues() : {};
5465
+ const engaged = apProvider
5466
+ ? !!apProvider.data.engaged
5467
+ : (pv["ap.enabled"] === true || pv["ap.enabled"] === 1);
5468
+ const v = engaged ? raw : "none";
5469
+ if (v === undefined || v === _tackDirPublished)
5470
+ return;
5471
+ _tackDirPublished = v;
5472
+ publishValue("ap.tack.direction", v);
5473
+ }
5425
5474
  function publishValue(name, value) {
5426
5475
  if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
5427
5476
  return;
@@ -101,6 +101,38 @@ class ManeuverTraceLog {
101
101
  const pad = (n) => String(n).padStart(2, "0");
102
102
  const name = `trace-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.jsonl`;
103
103
  this.currentFile = path.join(this.dir, name);
104
+ try {
105
+ this.pruneToCount();
106
+ }
107
+ catch { /* silent */ }
108
+ }
109
+ /** Rev514 (Carlos 2026-10-19, "limita a 60"): keep only the newest
110
+ * `maxCount` trace files (201 files / 888 MB had piled up since
111
+ * 2026-09-28). Oldest first by name (trace-YYYYMMDD-HHMMSS), never the
112
+ * file in use. Same rule as SessionRecorder.pruneToCount (Rev505). */
113
+ pruneToCount(maxCount = 60) {
114
+ let deleted = 0;
115
+ let names = [];
116
+ try {
117
+ names = fs.readdirSync(this.dir).filter((f) => f.startsWith("trace-") && f.endsWith(".jsonl"));
118
+ }
119
+ catch {
120
+ return { deleted };
121
+ }
122
+ names.sort();
123
+ const current = this.currentFile ? path.basename(this.currentFile) : null;
124
+ const closed = names.filter((n) => n !== current);
125
+ const keepClosed = Math.max(0, maxCount - (current ? 1 : 0));
126
+ for (const name of closed.slice(0, Math.max(0, closed.length - keepClosed))) {
127
+ try {
128
+ fs.unlinkSync(path.join(this.dir, name));
129
+ deleted += 1;
130
+ }
131
+ catch { /* skip */ }
132
+ }
133
+ if (deleted > 0)
134
+ this.log("info", `[maneuver-trace] pruned ${deleted} old traces (keeping the newest ${maxCount})`);
135
+ return { deleted };
104
136
  }
105
137
  record(input, pre, getPost) {
106
138
  const entry = {
@@ -11,14 +11,20 @@
11
11
  //
12
12
  // Pure state machine: fed once per second, no I/O, no timers.
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.SteerWatch = exports.DROPPED_MIN_SOG_MS = exports.DROPPED_ALARM_MS = exports.CYCLE_MIN_GAP_MS = exports.CYCLE_WINDOW_MS = exports.MAX_CYCLES = exports.IDLE_CURRENT_A = exports.ENGAGE_GRACE_MS = exports.CONVERGE_MIN_DROP_DEG = exports.CONVERGE_WINDOW_MS = exports.STUCK_SUSTAIN_MS = exports.STUCK_ERR_DEG = void 0;
14
+ exports.SteerWatch = exports.DROPPED_MIN_SOG_MS = exports.DROPPED_ALARM_MS = exports.CYCLE_MIN_GAP_MS = exports.CYCLE_WINDOW_MS = exports.MAX_CYCLES = exports.TARGET_CHANGE_RESET_DEG = exports.NATIVE_TACK_HOLDOFF_MS = exports.ENGAGE_GRACE_MS = exports.CONVERGE_MIN_DROP_DEG = exports.CONVERGE_WINDOW_MS = exports.STUCK_SUSTAIN_MS = exports.STUCK_ERR_DEG = void 0;
15
15
  exports.hasDirectionalFault = hasDirectionalFault;
16
16
  exports.STUCK_ERR_DEG = 25;
17
17
  exports.STUCK_SUSTAIN_MS = 4000;
18
18
  exports.CONVERGE_WINDOW_MS = 4000;
19
19
  exports.CONVERGE_MIN_DROP_DEG = 3;
20
20
  exports.ENGAGE_GRACE_MS = 5000;
21
- exports.IDLE_CURRENT_A = 0.3;
21
+ // Rev515 (sail 2026-10-19): no current-based fallback. With the
22
+ // TinyPilot the current reads 0 A while the motor IS driving (servo.flags
23
+ // DRIVER_TIMEOUT), which fired three false cycles (18:53:32 and 18:54:14
24
+ // mid remote tack, 19:23:45 after a big course change). Only pypilot's
25
+ // directional overcurrent fault - the thing a cycle actually clears - counts.
26
+ exports.NATIVE_TACK_HOLDOFF_MS = 20000;
27
+ exports.TARGET_CHANGE_RESET_DEG = 10;
22
28
  exports.MAX_CYCLES = 3;
23
29
  exports.CYCLE_WINDOW_MS = 60000;
24
30
  exports.CYCLE_MIN_GAP_MS = 10000;
@@ -39,6 +45,8 @@ class SteerWatch {
39
45
  this.gaveUp = false;
40
46
  this.droppedAtMs = null;
41
47
  this.dirFault = false;
48
+ this.lastNativeTackMs = null;
49
+ this.lastCmdDeg = null;
42
50
  }
43
51
  tick(i) {
44
52
  const now = i.nowMs;
@@ -55,6 +63,26 @@ class SteerWatch {
55
63
  if (this.engagedSinceMs == null)
56
64
  this.engagedSinceMs = now;
57
65
  this.droppedAtMs = null;
66
+ if (i.nativeTack)
67
+ this.lastNativeTackMs = now;
68
+ const inTackHoldoff = this.lastNativeTackMs != null && now - this.lastNativeTackMs < exports.NATIVE_TACK_HOLDOFF_MS;
69
+ if (typeof i.cmdDeg === "number" && isFinite(i.cmdDeg)) {
70
+ if (this.lastCmdDeg != null) {
71
+ const jump = Math.abs(((i.cmdDeg - this.lastCmdDeg) % 360 + 540) % 360 - 180);
72
+ if (jump > exports.TARGET_CHANGE_RESET_DEG) {
73
+ // A new target is a new error baseline (sailor's course change).
74
+ this.errHist = [];
75
+ this.condSinceMs = null;
76
+ }
77
+ }
78
+ this.lastCmdDeg = i.cmdDeg;
79
+ }
80
+ if (inTackHoldoff) {
81
+ this.errHist = [];
82
+ this.condSinceMs = null;
83
+ this.stuck = false;
84
+ return null;
85
+ }
58
86
  if (typeof i.errDeg !== "number" || !isFinite(i.errDeg)) {
59
87
  this.errHist = [];
60
88
  this.condSinceMs = null;
@@ -68,8 +96,7 @@ class SteerWatch {
68
96
  }
69
97
  const graceOk = now - this.engagedSinceMs >= exports.ENGAGE_GRACE_MS;
70
98
  const bigErr = absErr > exports.STUCK_ERR_DEG;
71
- const notPushing = this.dirFault
72
- || (typeof i.servoCurrentA === "number" && i.servoCurrentA < exports.IDLE_CURRENT_A);
99
+ const notPushing = this.dirFault;
73
100
  const notConverging = this.notConverging(now, absErr);
74
101
  const cond = graceOk && bigErr && notPushing && notConverging;
75
102
  if (!cond) {
@@ -323,8 +323,22 @@ class TripRecorder {
323
323
  clearInterval(this.flushTimer);
324
324
  this.flushTimer = null;
325
325
  }
326
- // Compute summary.
327
- const summary = {
326
+ const summary = this._buildSummary(endTs);
327
+ try {
328
+ if (this.currentSummaryFile)
329
+ fs.writeFileSync(this.currentSummaryFile, JSON.stringify(summary, null, 2));
330
+ }
331
+ catch (e) {
332
+ this.log("error", `trip summary write failed: ${e?.message || e}`);
333
+ }
334
+ this.log("info", `[trip] closed ${this.currentId} (${this.sampleCount} samples, ${summary.distanceNm.toFixed(1)} nm, ${this.tackCount} tacks)`);
335
+ this._resetActive();
336
+ return summary;
337
+ }
338
+ // Rev516: shared by stop() and liveSummary().
339
+ _buildSummary(endTs) {
340
+ const durMs = endTs - this.startTs;
341
+ return {
328
342
  id: this.currentId,
329
343
  startTs: this.startTs,
330
344
  endTs,
@@ -360,17 +374,18 @@ class TripRecorder {
360
374
  revision: this.header?.revision ?? "?",
361
375
  sampleCount: this.sampleCount,
362
376
  };
363
- try {
364
- if (this.currentSummaryFile)
365
- fs.writeFileSync(this.currentSummaryFile, JSON.stringify(summary, null, 2));
366
- }
367
- catch (e) {
368
- this.log("error", `trip summary write failed: ${e?.message || e}`);
369
- }
370
- this.log("info", `[trip] closed ${this.currentId} (${this.sampleCount} samples, ${summary.distanceNm.toFixed(1)} nm, ${this.tackCount} tacks)`);
371
- this._resetActive();
372
- return summary;
373
377
  }
378
+ /** Rev516 (Carlos 2026-10-19: "que lo que se está grabando de bitácora
379
+ * se pueda consultar mientras está navegando"): summary of the trip in
380
+ * progress from the same running stats stop() uses. */
381
+ liveSummary() {
382
+ if (!this.currentFile || this.startTs == null || !this.currentId)
383
+ return null;
384
+ return { ...this._buildSummary(Date.now()), live: true };
385
+ }
386
+ /** Rev516: write the pending samples so a download of the trip in
387
+ * progress carries the track up to now (buffer flushes every 30 s). */
388
+ flushNow() { this.flush(); }
374
389
  flush() {
375
390
  if (!this.currentFile || this.buffer.length === 0)
376
391
  return;
@@ -504,6 +519,8 @@ class TripRecorder {
504
519
  }
505
520
  }
506
521
  summary(id) {
522
+ if (this.currentId && id === this.currentId)
523
+ return this.liveSummary();
507
524
  const sumPath = path.join(this.dir, `trip-${id}.summary.json`);
508
525
  try {
509
526
  return JSON.parse(fs.readFileSync(sumPath, "utf8"));
@@ -30,6 +30,7 @@ exports.signedAngleDelta = signedAngleDelta;
30
30
  exports.computeTackGeometry = computeTackGeometry;
31
31
  exports.fractionateRotation = fractionateRotation;
32
32
  exports.makeInitialState = makeInitialState;
33
+ exports.needsLongJibeConfirm = needsLongJibeConfirm;
33
34
  exports.adjustedFinalCompassRad = adjustedFinalCompassRad;
34
35
  exports.adjustedFinalWindRad = adjustedFinalWindRad;
35
36
  exports.accumulateTackAdjust = accumulateTackAdjust;
@@ -252,6 +253,15 @@ function makeInitialState() {
252
253
  }
253
254
  /** Rev506: largest total nudge accepted during one manoeuvre. */
254
255
  exports.MAX_TACK_ADJUST_RAD = 60 * exports.DEG;
256
+ /** Rev511 (Carlos sea trial 2026-10-18 18:11:44): a second tap on the
257
+ * same button after a cancelled tack, with the wind now on the other
258
+ * bow, asked for a 278 deg gybe from close-hauled in 25 kn. A gybe the
259
+ * long way (rotation > 180 deg, only possible from |AWA| < 90) needs an
260
+ * explicit confirmation. Normal gybes from a run/broad reach and long
261
+ * tacks from downwind are unaffected. */
262
+ function needsLongJibeConfirm(g) {
263
+ return g.maneuverKind === "jibe" && Math.abs(g.deltaHRad) > Math.PI;
264
+ }
255
265
  /** Rev506: final compass heading of the rotation phase after the
256
266
  * sailor's nudges. Bow to starboard = heading increases. */
257
267
  function adjustedFinalCompassRad(g, adjustRad) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with first-class Signal K paths and Simple Switches so any downstream Signal K client can drive or monitor the pilot. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings). Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "files": [
package/public/app.js CHANGED
@@ -462,6 +462,10 @@
462
462
  "mode.label.compass": "COMPASS",
463
463
  "mode.label.gps": "GPS",
464
464
  "mode.label.wind": "APPARENT WIND",
465
+ "dlg.yes": "Yes",
466
+ "dlg.no": "No",
467
+ "vt.longJibe.title": "Gybe the long way?",
468
+ "vt.longJibe.body": "The wind is on the other bow: this button turns through the stern ({deg}°). To tack, use the other button.",
465
469
  "mode.label.true-wind": "TRUE WIND",
466
470
  "mode.label.nav": "WAYPOINT",
467
471
  "mode.label.aproado": "HEAD TO WIND",
@@ -739,6 +743,8 @@
739
743
  "trips.caption.voltMin": "Min volt",
740
744
  "trips.caption.sags": "sags",
741
745
  "trips.status.recording": "LOGBOOK ENTRY IN PROGRESS for",
746
+ "trips.live": "IN PROGRESS",
747
+ "tackStats.hdg": "HDG",
742
748
  "trips.status.navState": "Nav state",
743
749
  "trips.status.noneOpen": "no logbook entry in progress.",
744
750
  "trips.status.disabled": "Logbook recording is OFF.",
@@ -1395,6 +1401,10 @@
1395
1401
  "mode.label.compass": "COMPÁS",
1396
1402
  "mode.label.gps": "GPS",
1397
1403
  "mode.label.wind": "APARENTE",
1404
+ "dlg.yes": "Sí",
1405
+ "dlg.no": "No",
1406
+ "vt.longJibe.title": "¿Trasluchar por popa?",
1407
+ "vt.longJibe.body": "El viento está en la otra amura: este botón da la vuelta por popa ({deg}°). Para virar, usa el otro botón.",
1398
1408
  "mode.label.true-wind": "REAL",
1399
1409
  "mode.label.nav": "RUMBO A WAYPOINT",
1400
1410
  "mode.label.aproado": "APROADO",
@@ -1678,6 +1688,8 @@
1678
1688
  "trips.caption.voltMin": "Volt min",
1679
1689
  "trips.caption.sags": "caidas",
1680
1690
  "trips.status.recording": "BITÁCORA EN CURSO desde hace",
1691
+ "trips.live": "EN CURSO",
1692
+ "tackStats.hdg": "RUMBO",
1681
1693
  "trips.status.navState": "Estado nav",
1682
1694
  "trips.status.noneOpen": "sin bitácora en curso.",
1683
1695
  "trips.status.disabled": "Grabación de bitácora OFF.",
@@ -1998,6 +2010,8 @@
1998
2010
  "mode.label.compass": "KOMPASS",
1999
2011
  "mode.label.gps": "GPS",
2000
2012
  "mode.label.wind": "SCHEINBARER WIND",
2013
+ "dlg.yes": "Ja",
2014
+ "dlg.no": "Nein",
2001
2015
  "mode.label.true-wind": "WAHRER WIND",
2002
2016
  "mode.label.nav": "WEGPUNKT",
2003
2017
  "mode.label.aproado": "AUFSCHIESSEN",
@@ -2197,6 +2211,8 @@
2197
2211
  "mode.label.compass": "COMPAS",
2198
2212
  "mode.label.gps": "GPS",
2199
2213
  "mode.label.wind": "VENT APPARENT",
2214
+ "dlg.yes": "Oui",
2215
+ "dlg.no": "Non",
2200
2216
  "mode.label.true-wind": "VENT REEL",
2201
2217
  "mode.label.nav": "WAYPOINT",
2202
2218
  "mode.label.aproado": "FACE AU VENT",
@@ -2956,6 +2972,7 @@
2956
2972
  try { renderTargetArrow(); } catch {}
2957
2973
  }
2958
2974
  try { setSelect("#mode-select", state.virtualTack.windMode || state.mode); } catch {}
2975
+ try { _renderModeSelectBtn(); } catch {}
2959
2976
  try { _lastTackDirSeen = state.virtualTack.direction; } catch {}
2960
2977
  try { if (typeof _openExternalTackOverlay === "function") _openExternalTackOverlay(state.virtualTack); } catch (e) { console.warn("[vt] open overlay", e); }
2961
2978
  }
@@ -2984,10 +3001,18 @@
2984
3001
  } else if (typeof state._targetBeforeVt === "number") {
2985
3002
  state.target = state._targetBeforeVt;
2986
3003
  }
2987
- if (typeof state._modeBeforeVt === "string") {
2988
- state.mode = state._modeBeforeVt;
2989
- try { setSelect("#mode-select", state._modeBeforeVt); } catch {}
3004
+ // Rev510: the FSM restores its windMode on completed, cancelled
3005
+ // and failed. _modeBeforeVt could hold "compass" when the
3006
+ // FSM's own compass delta beat the first VT snapshot to this
3007
+ // device - that left one screen on COMPASS and another on WIND.
3008
+ const modeAfter = (typeof state.virtualTack?.windMode === "string" && state.virtualTack.windMode)
3009
+ ? state.virtualTack.windMode
3010
+ : state._modeBeforeVt;
3011
+ if (typeof modeAfter === "string") {
3012
+ state.mode = modeAfter;
3013
+ try { setSelect("#mode-select", modeAfter); } catch {}
2990
3014
  }
3015
+ try { _renderModeSelectBtn(); } catch {}
2991
3016
  // Rev410 (fix U.3, Carlos 2026-10-04): explicit failure toast
2992
3017
  // fed by the backend's `outcomeReason`, replacing the fragile
2993
3018
  // time-since-delta heuristic. Fires on `failed` only — a
@@ -5611,20 +5636,56 @@
5611
5636
  // hudOwner semantics: overlay open + _externalManeuver=false
5612
5637
  // means the local path owns it; we must not intrude.
5613
5638
  if (overlayOpen && !_externalManeuver) return;
5639
+ // Rev515: a new tack while the previous one is still finishing.
5640
+ if (isActive && !wasActive && _externalArrivalTimer) {
5641
+ _closeExternalTackOverlay();
5642
+ }
5614
5643
  if (isActive && !wasActive && !_externalManeuver) {
5615
5644
  _openExternalTackOverlay();
5616
5645
  return;
5617
5646
  }
5618
5647
  if (!isActive && _externalManeuver) {
5648
+ // Rev515 (Carlos 2026-10-19 18:55: "se desactiva el HUD antes de
5649
+ // terminar la virada"): pypilot ends its tack at ap.tack.threshold
5650
+ // (50 %) with ~40 deg still to turn. For pypilot's own tacks keep
5651
+ // the HUD until the boat is within 10 deg of the new target, max
5652
+ // 15 s. Virtual tacks close from their own snapshot as before.
5653
+ if (!state.virtualTack) { _closeExternalWhenArrived(); return; }
5619
5654
  _closeExternalTackOverlay();
5620
5655
  }
5621
5656
  }
5657
+ let _externalArrivalTimer = null;
5658
+ function _closeExternalWhenArrived() {
5659
+ if (_externalArrivalTimer) return;
5660
+ const t0 = Date.now();
5661
+ _externalArrivalTimer = setInterval(() => {
5662
+ const modeStr = String(state.mode || "").toLowerCase();
5663
+ const cur = modeStr.includes("wind")
5664
+ ? (modeStr.includes("true") ? state.windAngleTrue : state.windAngle)
5665
+ : state.heading;
5666
+ let arrived = false;
5667
+ if (typeof cur === "number" && typeof state.target === "number") {
5668
+ let e = (cur - state.target) * RAD2DEG;
5669
+ while (e > 180) e -= 360;
5670
+ while (e < -180) e += 360;
5671
+ arrived = Math.abs(e) < 10;
5672
+ }
5673
+ if (arrived || Date.now() - t0 > 15000 || !_externalManeuver) {
5674
+ clearInterval(_externalArrivalTimer);
5675
+ _externalArrivalTimer = null;
5676
+ if (_externalManeuver) _closeExternalTackOverlay();
5677
+ }
5678
+ }, 500);
5679
+ }
5622
5680
  function _openExternalTackOverlay(vt = null) {
5623
5681
  // Rev358 (Carlos, 2026-09-30, QA Rev355): a new external maneuver
5624
5682
  // arrived — close any prior stats HUD so it does not stay behind
5625
5683
  // the mirror overlay and get overwritten later when this new tack
5626
5684
  // completes.
5627
5685
  try { if (_tackStats.hideAt) _tackStatsHide(); } catch { /* silent */ }
5686
+ // Rev515 (Carlos 2026-10-19 19:19): the previous manoeuvre's post
5687
+ // window (5 s + 10 s) re-showed its summary in the middle of this one.
5688
+ _tackStats.postScheduledFor = 0;
5628
5689
  // Rev395 (Carlos, 2026-10-02, Round-3): prefer the backend snapshot
5629
5690
  // over local heuristics for direction, mode and maneuverKind. The
5630
5691
  // backend knows WHICH side it is driving and WHICH classification
@@ -5733,19 +5794,34 @@
5733
5794
  // `remainingDeg` is no longer the useful metric. Show a short
5734
5795
  // "settling" label so Carlos reads the correct phase.
5735
5796
  const vt = state.virtualTack;
5736
- const inSettling = vt && (vt.phase === "handover" || vt.phase === "settling");
5737
- const remDeg = (vt && vt.active && typeof vt.remainingDeg === "number")
5738
- ? vt.remainingDeg : null;
5739
- if (inSettling) {
5740
- el.textContent = t("tack.settling") || "settling";
5741
- } else if (remDeg !== null) {
5797
+ // Rev515: wind arrow -> diamond (what the rose shows), also while
5798
+ // settling; no "ajustando" text (Carlos: read as a new mode).
5799
+ const inSettling = false;
5800
+ const remDeg = (vt && vt.active)
5801
+ ? (_vtWindRemainingDeg(vt) ?? (typeof vt.remainingDeg === "number" ? vt.remainingDeg : null))
5802
+ : null;
5803
+ if (remDeg !== null) {
5742
5804
  el.textContent = `${Math.round(remDeg)}°`;
5743
5805
  } else {
5744
5806
  el.textContent = "— —";
5745
5807
  }
5808
+ // Rev510: "de Y°" under the number, like the device that started it.
5809
+ const degLine = document.getElementById("tack-countdown-deg");
5810
+ if (degLine) {
5811
+ if (vt && vt.active && typeof vt.totalDeg === "number" && vt.totalDeg > 0 && !inSettling) {
5812
+ degLine.textContent = `de ${Math.round(vt.totalDeg)}°`;
5813
+ degLine.style.display = "";
5814
+ } else {
5815
+ degLine.style.display = "none";
5816
+ }
5817
+ }
5746
5818
  }
5747
5819
  // Re-detect kind if direction/AWA showed up after open.
5748
- if (_lastTackDirSeen && _tackCountdownDir !== _lastTackDirSeen) {
5820
+ // Rev515 (Carlos 2026-10-19 18:53-18:55, HUD turning the wrong way):
5821
+ // only when the direction was unknown at open. pypilot's wind log
5822
+ // (tacking.py poll) overwrites ap.tack.direction with the wind side
5823
+ // ~0.5 s after the remote's begin; the value at begin is the turn.
5824
+ if (!_tackCountdownDir && _lastTackDirSeen && _lastTackDirSeen !== "none") {
5749
5825
  _tackCountdownDir = _lastTackDirSeen === "port" ? "port" : "starboard";
5750
5826
  try { _refreshOverlayKind(); } catch { /* silent */ }
5751
5827
  }
@@ -5772,6 +5848,7 @@
5772
5848
  }
5773
5849
  }
5774
5850
  function _closeExternalTackOverlay() {
5851
+ if (_externalArrivalTimer) { clearInterval(_externalArrivalTimer); _externalArrivalTimer = null; }
5775
5852
  if (_externalManeuverTimer) { clearInterval(_externalManeuverTimer); _externalManeuverTimer = null; }
5776
5853
  if (_externalManeuverWatchdog) { clearTimeout(_externalManeuverWatchdog); _externalManeuverWatchdog = null; }
5777
5854
  _externalManeuver = false;
@@ -5802,6 +5879,9 @@
5802
5879
  // does not stay behind the new countdown and confuse the sailor
5803
5880
  // when the new maneuver completes and overwrites its content.
5804
5881
  try { if (_tackStats.hideAt) _tackStatsHide(); } catch { /* silent */ }
5882
+ // Rev515 (Carlos 2026-10-19 19:19): the previous manoeuvre's post
5883
+ // window (5 s + 10 s) re-showed its summary in the middle of this one.
5884
+ _tackStats.postScheduledFor = 0;
5805
5885
  _tackCountdownKind = MANEUVER_STYLES[kind] ? kind : "tack";
5806
5886
  _tackDiamondBlink = true;
5807
5887
  renderTargetArrow();
@@ -6114,6 +6194,8 @@
6114
6194
  let _externalManeuverTimer = null; // pure UI refresh, no logic
6115
6195
  let _externalManeuverWatchdog = null; // 60 s safety close
6116
6196
  function _tackCountdownDegTotal() {
6197
+ const vt = _vtActiveSnapshot();
6198
+ if (vt && typeof vt.totalDeg === "number" && vt.totalDeg > 0) return Math.round(vt.totalDeg);
6117
6199
  if (_tackAccumulatedRotationDeg > 0) return Math.round(_tackAccumulatedRotationDeg);
6118
6200
  const raw = state.pypilotValues?.["ap.tack.angle"];
6119
6201
  if (typeof raw === "number" && raw > 10 && raw < 180) return Math.round(raw);
@@ -6169,7 +6251,41 @@
6169
6251
  const expectSign = isWind ? -hullSign : hullSign;
6170
6252
  _tackRotationDoneDeg = Math.max(0, _tackRotationDoneDeg + deltaDeg * expectSign);
6171
6253
  }
6254
+ // Rev510 (Carlos sea trial 2026-10-18: "te pone los grados, pero no los
6255
+ // que le quedan por llegar"): while a virtual tack runs, the backend's
6256
+ // remainingDeg (real heading vs the FINAL compass target, +-10 included)
6257
+ // is the truth. The local AWA integration drifted with gusts, the boat
6258
+ // slowing down and the sailor's adjustments.
6259
+ function _vtActiveSnapshot() {
6260
+ const vt = state.virtualTack;
6261
+ return (vt && vt.active) ? vt : null;
6262
+ }
6263
+ // Rev515 (Carlos 2026-10-19: "el contador miente … no se corresponde
6264
+ // flecha viento con target y dato"): count what the rose shows - the
6265
+ // wind arrow's distance to the target diamond, along the turn. Near the
6266
+ // target (settling, overshoot) the plain error; no "ajustando" text.
6267
+ function _vtWindRemainingDeg(vt) {
6268
+ if (!vt || typeof vt.finalWindTargetRad !== "number") return null;
6269
+ const useTrue = String(vt.windMode || "").toLowerCase().includes("true");
6270
+ const cur = useTrue ? state.windAngleTrue : state.windAngle;
6271
+ if (typeof cur !== "number") return null;
6272
+ let e = (cur - vt.finalWindTargetRad) * RAD2DEG;
6273
+ while (e > 180) e -= 360;
6274
+ while (e < -180) e += 360;
6275
+ if (Math.abs(e) < 30) return Math.round(Math.abs(e));
6276
+ // Hull to starboard -> wind angle decreases (and vice versa).
6277
+ const s = vt.direction === "starboard" ? 1 : -1;
6278
+ let d = ((cur - vt.finalWindTargetRad) * RAD2DEG * s) % 360;
6279
+ if (d < 0) d += 360;
6280
+ return Math.round(d);
6281
+ }
6172
6282
  function _tackCountdownDegRemaining() {
6283
+ const vt = _vtActiveSnapshot();
6284
+ if (vt) {
6285
+ const w = _vtWindRemainingDeg(vt);
6286
+ if (w != null) return w;
6287
+ if (typeof vt.remainingDeg === "number") return Math.max(0, Math.round(vt.remainingDeg));
6288
+ }
6173
6289
  const total = _tackAccumulatedRotationDeg > 0 ? _tackAccumulatedRotationDeg : null;
6174
6290
  if (total == null) return null;
6175
6291
  return Math.max(0, Math.round(total - _tackRotationDoneDeg));
@@ -6500,6 +6616,8 @@
6500
6616
  awaSigned: typeof state.windAngle === "number" ? state.windAngle : null,
6501
6617
  twaSigned: typeof state.windAngleTrue === "number" ? state.windAngleTrue : null,
6502
6618
  sog: typeof state.sog === "number" ? state.sog * 1.94384 : null,
6619
+ // Rev516: heading for the compass-mode summary.
6620
+ hdg: typeof state.heading === "number" ? state.heading : null,
6503
6621
  });
6504
6622
  // Rev172 (Carlos): the gust-ghost detector piggybacks on this 1 Hz
6505
6623
  // ring buffer - baseline AWS and rolling AWA both come from the
@@ -6531,8 +6649,10 @@
6531
6649
  // Rev225 (Carlos): also keep the SIGN of AWA so the HUD can show
6532
6650
  // "45deg B" or "45deg E" instead of the bare absolute value.
6533
6651
  let awaSignedSum = 0, awaSignedN = 0;
6652
+ let hx = 0, hy = 0, hN = 0;
6534
6653
  for (const s of _tackStats.samples) {
6535
6654
  if (s.ts < fromTs || s.ts > toTs) continue;
6655
+ if (typeof s.hdg === "number") { hx += Math.cos(s.hdg); hy += Math.sin(s.hdg); hN += 1; }
6536
6656
  if (typeof s.tws === "number") { tws += s.tws; twsN += 1; }
6537
6657
  if (typeof s.awaAbs === "number") { awa += s.awaAbs; awaN += 1; }
6538
6658
  if (typeof s.awaSigned === "number") {
@@ -6546,6 +6666,7 @@
6546
6666
  awa: awaN > 0 ? awa / awaN : null,
6547
6667
  awaSide: awaSignedN > 0 ? (awaSignedSum >= 0 ? "E" : "B") : null,
6548
6668
  sog: sogN > 0 ? sog / sogN : null,
6669
+ hdg: hN > 0 ? ((Math.atan2(hy, hx) * RAD2DEG) + 360) % 360 : null,
6549
6670
  };
6550
6671
  }
6551
6672
  function _tackStatsOnTackCompleted() {
@@ -6877,7 +6998,13 @@
6877
6998
  // transform we could unknowingly inherit later.
6878
6999
  svg.appendChild(ghost);
6879
7000
  _gustGhostActive = { g: ghost, expireAt: Date.now() + GUST_GHOST_FADE_MS + 500 };
6880
- requestAnimationFrame(() => { ghost.style.opacity = "0"; });
7001
+ // Rev509 (Carlos 2026-10-18, "las rachas no salen casi nunca"): one
7002
+ // rAF is not enough - its callback runs before the browser has ever
7003
+ // styled the new node, so there is no 0.5 starting point and the
7004
+ // transition is skipped: the ghost went invisible at once (headless
7005
+ // Chrome: opacity 0 after 1 s with one rAF, 0.47 with two). The second
7006
+ // rAF starts the fade after the first frame painted it.
7007
+ requestAnimationFrame(() => requestAnimationFrame(() => { ghost.style.opacity = "0"; }));
6881
7008
  setTimeout(() => {
6882
7009
  try { ghost.remove(); } catch { /* silent */ }
6883
7010
  _gustGhostActive = null;
@@ -6996,8 +7123,23 @@
6996
7123
  if (o == null || o.awa == null) return (isPost && pending) ? "…" : "--";
6997
7124
  return `${Math.round(o.awa)}° ${o.awaSide || ""}`.trim();
6998
7125
  };
6999
- set("tack-stats-awa-before", awaFmt(pre, false));
7000
- set("tack-stats-awa-after", awaFmt(post, true));
7126
+ // Rev516 (Carlos 2026-10-19: "cuando estamos en modo compás no debe
7127
+ // hablar del viento aparente sino del rumbo"): compass manoeuvre ->
7128
+ // mean heading before / after.
7129
+ // Frozen when the summary first shows, so a mode change before the
7130
+ // post window lands does not flip the row.
7131
+ if (post == null || _tackStats.compassStats == null) {
7132
+ const modeNow = String(_maneuverModeAtStart || state.mode || "").toLowerCase();
7133
+ _tackStats.compassStats = !modeNow.includes("wind");
7134
+ }
7135
+ const compassStats = _tackStats.compassStats;
7136
+ set("tack-stats-awa-lbl", compassStats ? (t("tackStats.hdg") || "HDG") : "AWA");
7137
+ const hdgFmt = (o, isPost) => {
7138
+ if (o == null || o.hdg == null) return (isPost && pending) ? "…" : "--";
7139
+ return `${String(Math.round(o.hdg) % 360).padStart(3, "0")}°`;
7140
+ };
7141
+ set("tack-stats-awa-before", compassStats ? hdgFmt(pre, false) : awaFmt(pre, false));
7142
+ set("tack-stats-awa-after", compassStats ? hdgFmt(post, true) : awaFmt(post, true));
7001
7143
  set("tack-stats-sog-before", (pre?.sog == null ? "--" : pre.sog.toFixed(1) + " kn"));
7002
7144
  set("tack-stats-sog-after", fmt(post?.sog, " kn", 1));
7003
7145
  // Rev224: execution time (from arming-to-executing switch to
@@ -7319,10 +7461,16 @@
7319
7461
  document.body.classList.add("adjusting");
7320
7462
  row.classList.add("active");
7321
7463
  });
7322
- rng.addEventListener("pointerup", () => {
7464
+ // Rev515: Android turns a drag into a scroll (pointercancel, no
7465
+ // pointerup) and the tab stayed dimmed; clear on every end event.
7466
+ const endAdjust = () => {
7323
7467
  document.body.classList.remove("adjusting");
7324
7468
  row.classList.remove("active");
7325
- });
7469
+ };
7470
+ rng.addEventListener("pointerup", endAdjust);
7471
+ rng.addEventListener("pointercancel", endAdjust);
7472
+ rng.addEventListener("lostpointercapture", endAdjust);
7473
+ rng.addEventListener("blur", endAdjust);
7326
7474
  rng.addEventListener("input", () => { val.innerHTML = `${Number(rng.value).toFixed(decimals)}${unitHtml}`; });
7327
7475
  rng.addEventListener("change", () => { try { opts.onChange(Number(rng.value)); } catch { /* silent */ } });
7328
7476
  row.querySelectorAll(".nudge-btn").forEach((btn) => {
@@ -7527,7 +7675,13 @@
7527
7675
  if (row.classList.contains("active")) return;
7528
7676
  const rng = row.querySelector("input[type=range]");
7529
7677
  const val = row.querySelector(".value");
7530
- if (rng && Number(rng.value) !== v) rng.value = String(v);
7678
+ // Half a slider step: the range input snaps 0.7 to 0.6945.
7679
+ const halfStep = rng ? (Number(rng.step) || 0) / 2 : 0;
7680
+ if (rng && Math.abs(Number(rng.value) - v) > Math.max(halfStep, 1e-6 * Math.max(1, Math.abs(v)))) {
7681
+ // Rev515: log every value the refresh changes on screen.
7682
+ try { _mtLog("other", { stage: "visor", via: "tune-refresh-change", name, from: Number(rng.value), to: v }); } catch { /* silent */ }
7683
+ rng.value = String(v);
7684
+ }
7531
7685
  if (val) {
7532
7686
  const unitStr = _gainUnits(name);
7533
7687
  const unitHtml = unitStr
@@ -7777,10 +7931,24 @@
7777
7931
  // Now uses skFetch (adds JWT if we have one), retries with backoff, and
7778
7932
  // opens the login modal after 3 consecutive 401s.
7779
7933
  let _refreshValues401Streak = 0;
7780
- // Rev507: name -> ts of the sailor's last successful RangeSetting
7781
- // write (pluginRaw). See refreshPypilotValues.
7782
- const _pvLocalWriteTs = new Map();
7783
- const PV_LOCAL_WRITE_HOLD_MS = 5000;
7934
+ // Rev507: name -> the sailor's last successful RangeSetting write
7935
+ // (pluginRaw). Rev515 (Carlos 2026-10-19: "se siguen borrando los
7936
+ // cambios"): a fixed 5 s hold painted the old value back whenever the
7937
+ // backend had not received pypilot's echo yet. Now the written value
7938
+ // stays until the backend reports it (confirmed) or a THIRD value
7939
+ // (someone else changed it), at most PV_LOCAL_WRITE_MAX_MS.
7940
+ const _pvLocalWriteTs = new Map(); // name -> { value, prev, ts }
7941
+ const PV_LOCAL_WRITE_MAX_MS = 5 * 60_000;
7942
+ const _pvSame = (a, b) => typeof a === "number" && typeof b === "number" && Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a));
7943
+ // true = keep the sailor's value, false = take the fetched one.
7944
+ function _pvKeepLocal(k, fetchedV, nowTs) {
7945
+ const w = _pvLocalWriteTs.get(k);
7946
+ if (!w) return false;
7947
+ if (nowTs - w.ts > PV_LOCAL_WRITE_MAX_MS || _pvSame(fetchedV, w.value)) { _pvLocalWriteTs.delete(k); return false; }
7948
+ if (w.prev === undefined || _pvSame(fetchedV, w.prev)) return true; // stale echo
7949
+ _pvLocalWriteTs.delete(k); // changed elsewhere
7950
+ return false;
7951
+ }
7784
7952
  async function refreshPypilotValues(opts) {
7785
7953
  const replace = !!(opts && opts.replace);
7786
7954
  try {
@@ -7803,17 +7971,13 @@
7803
7971
  // {replace:true} for that case so the sliders reflect the new
7804
7972
  // profile instead of the previous one.
7805
7973
  const fetched = await r.json();
7806
- // Rev507: a value the sailor wrote < PV_LOCAL_WRITE_HOLD_MS ago
7807
- // wins over the snapshot (pypilot may not have echoed it yet).
7974
+ // Rev507/515: the sailor's own write wins until confirmed.
7808
7975
  const nowTs = Date.now();
7809
- const freshLocal = (k) => {
7810
- const ts = _pvLocalWriteTs.get(k);
7811
- return ts != null && (nowTs - ts) < PV_LOCAL_WRITE_HOLD_MS;
7812
- };
7976
+ const freshLocal = (k) => _pvKeepLocal(k, (fetched || {})[k], nowTs);
7813
7977
  if (replace) {
7814
7978
  const prev = state.pypilotValues || {};
7815
7979
  state.pypilotValues = fetched || {};
7816
- for (const k of _pvLocalWriteTs.keys()) {
7980
+ for (const k of Array.from(_pvLocalWriteTs.keys())) {
7817
7981
  if (freshLocal(k) && prev[k] != null) state.pypilotValues[k] = prev[k];
7818
7982
  }
7819
7983
  } else {
@@ -8091,6 +8255,17 @@
8091
8255
  function _renderModeSelectBtn() {
8092
8256
  const btn = document.getElementById("mode-select-btn");
8093
8257
  if (!btn) return;
8258
+ // Rev510 (Carlos sea trial 2026-10-18, 18:07-18:12: "la tablet decia
8259
+ // un modo y el telefono decia otro"): during a virtual tack pypilot is
8260
+ // in compass on purpose. Every visor shows the wind mode of the tack,
8261
+ // from the backend snapshot (same on every screen). Rev515 (Carlos
8262
+ // 2026-10-19): no "TACKING ->" prefix, it read as a new mode.
8263
+ const vt = state.virtualTack;
8264
+ if (vt && vt.active) {
8265
+ const txt = _modeLabel(vt.windMode || state.mode || "wind");
8266
+ if (btn.textContent !== txt) btn.textContent = txt;
8267
+ return;
8268
+ }
8094
8269
  const m = state.mode || "";
8095
8270
  btn.textContent = m ? _modeLabel(m) : "—";
8096
8271
  }
@@ -9892,8 +10067,11 @@
9892
10067
  if (res.ok && state.catalog?.[name]?.type === "RangeSetting"
9893
10068
  && typeof value === "number" && isFinite(value)) {
9894
10069
  if (!state.pypilotValues) state.pypilotValues = {};
10070
+ const prevV = state.pypilotValues[name];
9895
10071
  state.pypilotValues[name] = value;
9896
- _pvLocalWriteTs.set(name, Date.now());
10072
+ _pvLocalWriteTs.set(name, { value, prev: prevV, ts: Date.now() });
10073
+ // Rev515: evidence for "se borran los cambios" next time.
10074
+ try { _mtLog("other", { stage: "visor", via: "tune-write", name, value, prev: prevV }); } catch { /* silent */ }
9897
10075
  }
9898
10076
  if (!res.ok) {
9899
10077
  const bodyTxt = await res.text().catch(() => "");
@@ -10630,6 +10808,7 @@
10630
10808
  // (500ms each → ~2s total) swallowing 409s silently, then
10631
10809
  // only flash fail on persistent failure. Other HTTP errors
10632
10810
  // (400/500/network) still fail immediately.
10811
+ let confirmLongJibe = false;
10633
10812
  const vtStart = async () => {
10634
10813
  // Rev400 (Carlos QA Rev399): subo retries 4→10 (~5s total).
10635
10814
  // Rev399 QA mostró casos donde el VT previo tardaba >2s en
@@ -10644,6 +10823,7 @@
10644
10823
  // Clamped to ±170° (deg) to prevent abuse; backend
10645
10824
  // additionally rejects anything outside (-π, π].
10646
10825
  const body = { direction: dir, requestId };
10826
+ if (confirmLongJibe) body.confirmLongJibe = true;
10647
10827
  try {
10648
10828
  const rawOv = localStorage.getItem("pypilotnewui.tackFinalAngleDeg");
10649
10829
  const deg = rawOv == null ? NaN : Number(rawOv);
@@ -10661,7 +10841,20 @@
10661
10841
  await new Promise((res) => setTimeout(res, RETRY_DELAY_MS));
10662
10842
  }
10663
10843
  };
10664
- const r = await vtStart();
10844
+ let r = await vtStart();
10845
+ // Rev511 (Carlos sea trial 2026-10-18 18:11:44): the backend
10846
+ // asks before a gybe the long way (> 180 deg from close-hauled).
10847
+ if (r.status === 428) {
10848
+ const j = await r.json().catch(() => ({}));
10849
+ const deg = Math.round(Math.abs(Number(j.deltaDeg) || 0));
10850
+ const ok = await _pmConfirm(
10851
+ t("vt.longJibe.title") || "¿Trasluchar por popa?",
10852
+ (t("vt.longJibe.body") || "El viento está en la otra amura: este botón da la vuelta por popa ({deg}°). Para virar, usa el otro botón.").replace("{deg}", String(deg)),
10853
+ );
10854
+ if (!ok) return;
10855
+ confirmLongJibe = true;
10856
+ r = await vtStart();
10857
+ }
10665
10858
  if (!r.ok) {
10666
10859
  const err = await r.text().catch(() => String(r.status));
10667
10860
  console.warn(`[vt] start failed: ${err}`);
@@ -12470,10 +12663,15 @@
12470
12663
  // Rev330 (Carlos, 2026-09-28): tipografía más grande + colores del
12471
12664
  // sistema (--fg, --fg-dim, --accent). Nombre en accent cuando
12472
12665
  // existe, fecha siempre en fg-dim.
12666
+ // Rev516 (Carlos 2026-10-19: consult the logbook while sailing): the
12667
+ // trip being recorded shows live stats and this badge.
12668
+ const liveBadge = trip.live
12669
+ ? `<span style="display:inline-block;margin-left:8px;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:0.5px;color:#ff9500;background:rgba(255,149,0,0.15);border:1px solid #ff9500;vertical-align:middle">● ${t("trips.live")}</span>`
12670
+ : "";
12473
12671
  const titleHtml = nameEsc
12474
- ? `<div style="font-size:16px;font-weight:600;color:var(--accent,#4dd4ff);line-height:1.2">${nameEsc}</div>
12672
+ ? `<div style="font-size:16px;font-weight:600;color:var(--accent,#4dd4ff);line-height:1.2">${nameEsc}${liveBadge}</div>
12475
12673
  <div style="font-size:13px;color:var(--fg-dim);margin-top:2px">${dateStr}</div>`
12476
- : `<div style="font-size:15px;font-weight:600;color:var(--fg);line-height:1.2">${dateStr}</div>`;
12674
+ : `<div style="font-size:15px;font-weight:600;color:var(--fg);line-height:1.2">${dateStr}${liveBadge}</div>`;
12477
12675
  const kpiHtml = s
12478
12676
  ? `<div style="font-size:13px;color:var(--fg);margin-top:6px"><b>${distNm} nm</b> · ${durMin} min · SOG ${avg} ${sog} kn</div>
12479
12677
  <div style="font-size:12px;color:var(--fg-dim);margin-top:2px">TWS ${avg} ${tws} kn · ${tacks} ${bordos} · peak SOG ${s.sogMaxKn?.toFixed(1) ?? "?"} kn · peak TWS ${s.twsMaxKn?.toFixed(1) ?? "?"} kn</div>`
@@ -13473,6 +13671,11 @@
13473
13671
  // set, falling back to the timestamp. Inline pencil button reopens
13474
13672
  // the rename prompt.
13475
13673
  _tripsRenderTitle(id, s.name || null, dateStr);
13674
+ if (s.live) {
13675
+ // Rev516: trip still being recorded - stats and track up to now.
13676
+ const titleEl = document.getElementById("trip-detail-title");
13677
+ if (titleEl) titleEl.insertAdjacentHTML("beforeend", `<span style="margin-left:8px;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;color:#ff9500;background:rgba(255,149,0,0.15);border:1px solid #ff9500">● ${t("trips.live")}</span>`);
13678
+ }
13476
13679
  // Rev332 (Carlos, 2026-09-28): "quita los colorines" — todas las
13477
13680
  // KPI cards con el mismo color neutro var(--fg). El tint arg se
13478
13681
  // conserva por si un día queremos volver a resaltar algo, pero por
package/public/index.html CHANGED
@@ -27,7 +27,7 @@
27
27
  style.css never shows the rose unstyled on a white page. The rose
28
28
  is hidden until style.css (loaded below) makes it visible again. -->
29
29
  <style>html,body{background:#0a1220;color:#e6edf3}#wind-rose{visibility:hidden}</style>
30
- <link rel="stylesheet" href="style.css?v=Rev508" />
30
+ <link rel="stylesheet" href="style.css?v=Rev516" />
31
31
  </head>
32
32
  <body class="theme-dark">
33
33
  <!-- Rev490 (GPU audit 2026-10-15): flag the Raspberry Pi's own screen
@@ -286,7 +286,7 @@
286
286
  fill="#ffffff" font-size="12" font-weight="800"
287
287
  font-family="ui-monospace, monospace">--</text>
288
288
  <line x1="-72" y1="-14" x2="72" y2="-14" stroke="#4a5867" stroke-width="0.6"/>
289
- <text x="-72" y="0" fill="#8b98a5" font-size="10" font-weight="700"
289
+ <text x="-72" y="0" id="tack-stats-awa-lbl" fill="#8b98a5" font-size="10" font-weight="700"
290
290
  font-family="ui-monospace, monospace">AWA</text>
291
291
  <text x="-28" y="0" id="tack-stats-awa-before" text-anchor="middle"
292
292
  fill="#ffffff" font-size="12" font-weight="800"
@@ -2651,6 +2651,6 @@
2651
2651
  </div>
2652
2652
  </div>
2653
2653
 
2654
- <script src="app.js?v=Rev508"></script>
2654
+ <script src="app.js?v=Rev516"></script>
2655
2655
  </body>
2656
2656
  </html>
package/public/style.css CHANGED
@@ -1773,8 +1773,10 @@ hr.tune-separator {
1773
1773
 
1774
1774
  /* Translucent effect while adjusting - see-through so user can watch heading
1775
1775
  converge to target in the Control tab underneath. */
1776
- body.adjusting main #tab-tune { opacity: 0.32; transition: opacity 0.1s ease; }
1777
- body.adjusting main #tab-tune .gain-row.active { opacity: 1; }
1776
+ /* Rev515 (Carlos 2026-10-19: "se pone negro, no puedo ver lo que hago"):
1777
+ dim the OTHER rows only. Dimming the whole tab capped the active row
1778
+ at 32 % too (a child cannot be more opaque than its parent). */
1779
+ body.adjusting main #tab-tune .gain-row:not(.active) { opacity: 0.32; transition: opacity 0.1s ease; }
1778
1780
 
1779
1781
  .section-title {
1780
1782
  color: var(--fg-dim);