signalk-pypilot-newui 2.0.3 → 2.0.4

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,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.4 — 2026-08-21 — Rev67
4
+
5
+ Reintroduces (with proper QA) the UI polish + reliability work that was
6
+ withdrawn in 2.0.3 when 2.0.2 hit regressions. Every item below was
7
+ tested on Carlos's Tunatunes Pi Zero W setup before publish.
8
+
9
+ ### Fixed
10
+
11
+ - **Restart engine now uses `sv` (runit), not `systemctl`.** piCore does
12
+ not ship systemd; the previous `sudo systemctl restart pypilot pypilot_web`
13
+ was silently falling through to `|| true` on every install. Only the
14
+ `pkill -9` had any effect, and runit's `runsv` was auto-relaunching
15
+ the killed processes into a slightly inconsistent state that
16
+ accumulated zombies + I2C contention until the Pi Zero locked up.
17
+ Fixed to `sudo sv restart /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat`
18
+ with a selective `pkill -9 -f 'python.*pypilot --version'` safety net.
19
+ - **`pypilot --version` preset in Debug console wrapped in `timeout 3`.**
20
+ On piCore that command starts the full pypilot process and never
21
+ exits cleanly; each click was leaving a zombie holding I2C and TCP
22
+ resources. Falls back to reading the packaged version file.
23
+ - **Target diamond drifted after tapping AP on laggy links.** The
24
+ optimistic-target assign now happens INSTANTLY on click (same tick),
25
+ not after the parallel promise resolves - so the diamond snaps to
26
+ the captured heading immediately, without a jump to the stale value
27
+ then back. Nudge also updates `state.target` optimistically so the
28
+ diamond moves in the same tick as the button press.
29
+ - **socket.io auto-reconnect no longer gives up.** `reconnectionAttempts`
30
+ changed from 30 (which could exhaust on a long outage) to Infinity;
31
+ backoff capped at 30 s. Restarts done via `/debug-cmd` (`restart.web`
32
+ / `reboot.pi`) now schedule an explicit `pause+resume` after the
33
+ operation.
34
+ - **Reconnection watchdog** (every 60 s, was 30 s): if the socket has
35
+ been disconnected > 90 s, force a pause+resume once. Max 3 forced
36
+ reconnects in a rolling 15-min window so a permanently-dead
37
+ pypilot_web is not turned into a local DDoS. Watchdog interval
38
+ loosened per Carlos: "con la Pi Zero debemos ser suuuuuper ratas".
39
+
40
+ ### Changed - Emergency section simplified
41
+
42
+ The 3-level escalated restart triad shipped in 2.0.2 has been trimmed
43
+ to 2 levels in Setup -> Emergency: **RESTART pypilot** (level 2, ~10-15 s
44
+ outage) + **reboot Pi** (level 3, ~35-60 s). The old level 1
45
+ (`restart pypilot_web`) rarely helps in practice - when the Pi Zero is
46
+ saturated a full reboot is what actually helps - so it moved to the
47
+ Debug console below as an advanced preset for the ~5 % edge cases.
48
+
49
+ ### Compass rose
50
+
51
+ - HDG in a small dark chip that straddles the top of the ring, narrowed
52
+ from 48 to 40 units wide so the enlarged target diamond can peek out
53
+ on all four sides even when target is dead ahead.
54
+ - Target diamond enlarged (28 wide, 30 tall) so its top point asoma
55
+ above the HDG chip and its bottom point below it.
56
+ - Wind arrows both solid (no dashed shaft on TWA), amber AWA (big head)
57
+ + sea-green (#5ce8c0) TWA (small head), each with its own rotating
58
+ text label placed at very different radii so they never overlap even
59
+ when both arrows point in the same direction.
60
+ - Wind speed at the bottom of the rose now inside a small dark chip
61
+ (same look as the HDG chip on top). Short-tap on the chip OR on a
62
+ corner mapped to "wind" flips AWS/TWS. Long-press opens the popup.
63
+ - Corner options `AWA` and `TWA` now show angle AND matching wind speed
64
+ (AWA/AWS and TWA/TWS) as a combined 2-line cell, consistent with the
65
+ `wind` combo.
66
+
67
+ ### Rudder angle indicator in the swipe-hint bar
68
+
69
+ - Default range **±70°** (was ±45°), matching typical TinyPilot configs.
70
+ - Explicit numeric labels at both ends showing the actual range from
71
+ the pypilot catalog.
72
+ - Intermediate tick marks every 10° plus numeric mid-labels at 20°, 40°
73
+ and 60° each side, so the operator reads the rudder angle at a glance.
74
+
75
+ ### Internal
76
+
77
+ - Debug console preset `pypilot.version` now `timeout 3 python3 -c
78
+ 'import pypilot; print(pypilot.__version__)'` with fallback.
79
+ - Merge-based `_pluginConfigMerge()` helper preserved.
80
+ - Per-cell state for the long-press / short-tap handler on the corner
81
+ dashboard (Rev65's shared timer flags were race-flipping and killing
82
+ the tap toggle intermittently).
83
+
3
84
  ## 2.0.3 — 2026-08-20 — Rev66 - rollback of 2.0.2
4
85
 
5
86
  Codebase is functionally identical to 2.0.1. Version 2.0.2 introduced
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ const scanner_1 = require("./scanner");
6
6
  const autopilot_provider_1 = require("./autopilot-provider");
7
7
  // Rev counter bumped on every build so the user can distinguish deploys
8
8
  // from the webapp header (feedback_revision_bump_each_build).
9
- const PLUGIN_REVISION = "Rev66";
9
+ const PLUGIN_REVISION = "Rev67";
10
10
  // Rev59: read package.json once at load time so /status can report the
11
11
  // npm package version alongside the internal Rev counter.
12
12
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -255,6 +255,17 @@ module.exports = function (app) {
255
255
  catch { /* defensive */ }
256
256
  app._pypilotNewuiKeepAlive = null;
257
257
  }
258
+ // Rev66 / 2.0.4: clear the reconnection watchdog interval, otherwise
259
+ // a Disable+Enable cycle in SK Admin would leave the previous
260
+ // watchdog running against a null `client` forever.
261
+ const wd = app._pypilotNewuiWatchdogTimer;
262
+ if (wd) {
263
+ try {
264
+ clearInterval(wd);
265
+ }
266
+ catch { /* defensive */ }
267
+ app._pypilotNewuiWatchdogTimer = null;
268
+ }
258
269
  // The SK autopilot API does not expose an unregister; on plugin stop
259
270
  // the server drops our provider when it garbage-collects the plugin.
260
271
  apProvider = null;
@@ -269,6 +280,16 @@ module.exports = function (app) {
269
280
  },
270
281
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
271
282
  registerWithRouter: (router) => {
283
+ // Rev66 / 2.0.4: shared "hard reconnect" helper - pauses the socket
284
+ // and re-opens it after a short beat. Used by /restart-pypilot,
285
+ // /debug-cmd (restart.web / reboot.pi presets) and the watchdog.
286
+ const doReconnect = () => {
287
+ try {
288
+ client?.pause();
289
+ setTimeout(() => client?.resume(), 800);
290
+ }
291
+ catch { }
292
+ };
272
293
  router.get("/status", (_req, res) => {
273
294
  res.json({
274
295
  revision: PLUGIN_REVISION,
@@ -552,13 +573,29 @@ module.exports = function (app) {
552
573
  const host = props.host;
553
574
  const sshUser = props.sshUser || "tc";
554
575
  const sshPassword = props.sshPassword || "";
555
- const restartCmd = "sudo systemctl restart pypilot pypilot_web 2>&1 || sudo /etc/init.d/pypilot restart 2>&1 || true";
556
- // Local reconnect helper used both on success and on error paths.
557
- const doReconnect = () => { try {
558
- client?.pause();
559
- setTimeout(() => client?.resume(), 800);
560
- }
561
- catch { } };
576
+ // Rev66 / 2.0.4: TinyPilot runs piCore Linux which uses runit,
577
+ // NOT systemd. Our previous `sudo systemctl restart ...` command
578
+ // was silently falling through to `|| true` on every install
579
+ // because systemctl does not exist. The only step that had any
580
+ // effect was the pkill; runsv then auto-relaunched the killed
581
+ // processes. That inconsistent "kill without a clean start"
582
+ // pattern was accumulating zombies + saturating the Pi Zero's
583
+ // RAM until it hung.
584
+ //
585
+ // The runit-native way: `sudo sv restart /etc/sv/<service>` sends
586
+ // SIGTERM to the child, waits for it to exit, and starts it back
587
+ // up under supervision. Clean by design. We keep pkill as a
588
+ // belt-and-braces safety net in case a python child ignores TERM
589
+ // (some pypilot subprocesses have historically done that).
590
+ const restartCmd = [
591
+ "sudo sv restart /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat 2>&1 || true",
592
+ "sleep 2",
593
+ "sudo pkill -9 -f 'python.*pypilot --version' 2>&1 || true",
594
+ "sleep 1",
595
+ "sudo sv up /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat 2>&1 || true",
596
+ ].join(" ; ");
597
+ // `doReconnect` is defined at the router scope (Rev66) and reused by
598
+ // /restart-pypilot, /debug-cmd and the watchdog.
562
599
  if (!sshPassword) {
563
600
  doReconnect();
564
601
  return res.status(202).json({
@@ -649,8 +686,22 @@ module.exports = function (app) {
649
686
  "top.snapshot": "top -bn1 2>&1 | head -20",
650
687
  "uptime": "uptime && cat /proc/loadavg",
651
688
  "df": "df -h 2>&1",
652
- "pypilot.version": "pypilot --version 2>&1 || (test -f /home/tc/pypilot/version && cat /home/tc/pypilot/version) || echo 'unknown'",
653
- "restart.web": "sudo systemctl restart pypilot_web 2>&1 && echo OK",
689
+ // Rev66 / 2.0.4: `pypilot --version` starts the full pypilot
690
+ // process and never exits cleanly on TinyCore + pypilot 2021 -
691
+ // every click of this preset left a python zombie holding I2C
692
+ // and TCP resources. Wrap with `timeout 3` so the exec is killed
693
+ // after 3 s, then fall back to reading the packaged version
694
+ // file. Also `python3 -c 'import pypilot; print(pypilot.__version__)'`
695
+ // as belt-and-braces.
696
+ "pypilot.version": "timeout 3 python3 -c 'import pypilot; print(pypilot.__version__)' 2>&1 || (test -f /home/tc/pypilot/version && cat /home/tc/pypilot/version) || (ls -1 /usr/local/lib/python3.8/site-packages/pypilot-*.dist-info 2>&1 | head -1) || echo 'unknown'",
697
+ // Rev66 / 2.0.4: piCore uses runit, not systemd. `sudo sv
698
+ // restart /etc/sv/pypilot_web` is the correct clean-restart
699
+ // primitive - it TERMs the child, waits, then supervise brings
700
+ // it back up. Scoped to the web unit so the AP core keeps
701
+ // steering. Kept as an advanced preset in the Debug console
702
+ // (the Emergency triad simplified to 2 levels: RESTART pypilot
703
+ // + reboot Pi).
704
+ "restart.web": "sudo sv restart /etc/sv/pypilot_web 2>&1 && echo OK",
654
705
  "reboot.pi": "sudo reboot",
655
706
  };
656
707
  router.post("/debug-cmd", async (req, res) => {
@@ -722,6 +773,15 @@ module.exports = function (app) {
722
773
  finish({ ok: false, stdout: e?.message || String(e) });
723
774
  }
724
775
  });
776
+ // Rev66 / 2.0.4: schedule a hard reconnect after presets that
777
+ // disrupt pypilot_web. Level 1 (restart.web) is quick - 4 s is
778
+ // enough for the socket to notice + wait for pypilot_web to be
779
+ // back up. Level 3 (reboot.pi) needs to survive a ~40 s cold
780
+ // reboot; the watchdog below picks up anything longer than that.
781
+ if (preset === "restart.web")
782
+ setTimeout(doReconnect, 4000);
783
+ if (preset === "reboot.pi")
784
+ setTimeout(doReconnect, 60000);
725
785
  // For reboot.pi the ssh session drops as the box goes down, so we
726
786
  // report success even on a non-zero exit if we at least connected.
727
787
  return res.json({
@@ -736,6 +796,49 @@ module.exports = function (app) {
736
796
  return res.status(500).json({ error: e?.message || String(e), preset });
737
797
  }
738
798
  });
799
+ // Rev66 / 2.0.4: reconnection watchdog. If the socket has been
800
+ // disconnected for > 90 s (much longer than any legitimate
801
+ // pypilot_web restart), force a pause+resume to kick socket.io out
802
+ // of whatever state it got stuck in. Anti-loop: at most 3 forced
803
+ // reconnects in a rolling 15-min window - after that we stop
804
+ // trying automatically so a permanently-dead pypilot_web does
805
+ // not turn into a local DDoS.
806
+ // Rev66 / 2.0.4: watchdog tick every 60 s (was 30 s). The Pi Zero
807
+ // running pypilot is fragile so we minimise every recurring
808
+ // operation - a check that took 30 s worth of wakeups now takes 60 s.
809
+ // Combined with the >90 s "down" threshold, the watchdog only ever
810
+ // acts when the socket has been dead for a while, not on transient
811
+ // Tailscale hiccups.
812
+ let _wdogDisconnectSince = null;
813
+ let _wdogForcedAttempts = []; // timestamps of forced reconnects
814
+ const _wdogTimer = setInterval(() => {
815
+ try {
816
+ if (client?.connected) {
817
+ _wdogDisconnectSince = null;
818
+ return;
819
+ }
820
+ if (_wdogDisconnectSince == null) {
821
+ _wdogDisconnectSince = Date.now();
822
+ return;
823
+ }
824
+ const downMs = Date.now() - _wdogDisconnectSince;
825
+ if (downMs < 90000)
826
+ return;
827
+ // Prune attempts older than 15 min.
828
+ const cutoff = Date.now() - 15 * 60000;
829
+ _wdogForcedAttempts = _wdogForcedAttempts.filter((t) => t > cutoff);
830
+ if (_wdogForcedAttempts.length >= 3)
831
+ return;
832
+ _wdogForcedAttempts.push(Date.now());
833
+ _wdogDisconnectSince = Date.now(); // reset the timer for the next check
834
+ app.debug(`[watchdog] socket down ${(downMs / 1000) | 0}s - forcing pause+resume (attempt ${_wdogForcedAttempts.length}/3 in 15 min)`);
835
+ doReconnect();
836
+ }
837
+ catch { /* silent */ }
838
+ }, 60000);
839
+ // Store on `app` so plugin.stop() can clear it (defensive - the
840
+ // registerWithRouter closure does not have a stop hook here).
841
+ app._pypilotNewuiWatchdogTimer = _wdogTimer;
739
842
  },
740
843
  };
741
844
  // ---- helpers ----
@@ -55,13 +55,18 @@ class PypilotClient extends events_1.EventEmitter {
55
55
  this.opts.log("info", `[pypilot] connecting socket.io to ${url}`);
56
56
  this.socket = (0, socket_io_client_1.io)(url, {
57
57
  transports: ["websocket", "polling"],
58
- // Reconnect but with a ceiling so a wedged pypilot_web (Pi Zero pinned
59
- // at 100% CPU) does not get hammered forever. After the ceiling we
60
- // give up until the user pushes /resume from the webapp.
58
+ // Rev66 / 2.0.4: infinite reconnect attempts. The old 30-attempt
59
+ // ceiling meant a long-ish outage (say a Pi reboot that takes 45 s +
60
+ // a couple of socket.io backoff cycles) could exhaust the counter
61
+ // and leave the socket "closed forever" - the only recovery was
62
+ // toggling the plugin in SK Admin. With infinite retries plus the
63
+ // 30 s cap on backoff delay, the client keeps trying at most every
64
+ // 30 s until pypilot_web comes back on its own or the watchdog in
65
+ // index.ts triggers a hard pause+resume.
61
66
  reconnection: true,
62
- reconnectionAttempts: 30,
67
+ reconnectionAttempts: Infinity,
63
68
  reconnectionDelay: this.opts.reconnectDelayMs,
64
- reconnectionDelayMax: 20000,
69
+ reconnectionDelayMax: 30000,
65
70
  timeout: 10000,
66
71
  autoConnect: true,
67
72
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with the capability to spread its functions and buttons to other programs like KIP, WilhelmSK and freeboard. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings) as first-class Signal K paths and Simple Switches. Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
package/public/app.js CHANGED
@@ -534,6 +534,20 @@
534
534
  windAngleTrue: null,
535
535
  windSpeedTrue: null,
536
536
  sog: null, // m/s
537
+ // Rev65 / 2.0.2 - wind-corner display preference. "awa" or "twa".
538
+ // Initial value derives from the browser-stored preference; if
539
+ // absent, defaults to "awa" and later auto-follows the AP mode
540
+ // (see _syncWindCornerFromMode).
541
+ windCornerShow: (() => {
542
+ try { return localStorage.getItem("pypilot-newui.windCornerShow") || "awa"; }
543
+ catch { return "awa"; }
544
+ })(),
545
+ windCornerUserPinned: (() => {
546
+ // User's manual tap "pins" the choice so mode changes stop
547
+ // overwriting it. Persistent per browser.
548
+ try { return localStorage.getItem("pypilot-newui.windCornerPinned") === "1"; }
549
+ catch { return false; }
550
+ })(),
537
551
  };
538
552
 
539
553
  const RAD2DEG = 180 / Math.PI;
@@ -672,7 +686,7 @@
672
686
  function applyValue(path, value) {
673
687
  switch (path) {
674
688
  case "steering.autopilot.state": state.apState = value; break;
675
- case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); break;
689
+ case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); _syncWindCornerFromMode(value); break;
676
690
  case "steering.autopilot.target": state.target = numericOrNull(value); renderTargetArrow(); break;
677
691
  case "steering.autopilot.engaged": state.engaged = !!value; renderEngage(); renderTargetArrow(); break;
678
692
  case "steering.autopilot.availableActions": state.availableActions = value || []; break;
@@ -780,9 +794,12 @@
780
794
  // getComputedTextLength() only works on rendered SVG text nodes.
781
795
  // Fall back to a heuristic width if the SVG has not laid out yet
782
796
  // (e.g. tab is not visible).
797
+ // Fallback width when SVG has not laid out yet (tab not visible).
798
+ // Rev65: HDG font shrunk from 18 to 14 for the new chip, so the
799
+ // per-char heuristic drops from 12 to 9 units.
783
800
  let w;
784
- try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 12; }
785
- if (!isFinite(w) || w <= 0) w = s.length * 12;
801
+ try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 9; }
802
+ if (!isFinite(w) || w <= 0) w = s.length * 9;
786
803
  hdgDeg.setAttribute("x", (w / 2 + 1).toFixed(1));
787
804
  }
788
805
  }
@@ -799,14 +816,30 @@
799
816
  const DASH_DATA = {
800
817
  none: { label: "-", read: () => "" },
801
818
  hdg: { label: "HDG", read: () => state.heading == null ? "---" : Math.round(state.heading * RAD2DEG) + "°" },
802
- awa: { label: "AWA", read: () => state.windAngle == null ? "---" : _sgn(state.windAngle * RAD2DEG) + "°" },
819
+ // Rev66 / 2.0.4 (Carlos): awa/twa muestran ANGULO + VELOCIDAD juntos
820
+ // como el `wind` combo. aws/tws solo velocidad para quien la quiera sola.
821
+ awa: { label: "AWA/AWS", read: () => {
822
+ const a = state.windAngle == null ? "---" : _sgn(state.windAngle * RAD2DEG) + "°";
823
+ const s = state.windSpeed == null ? "---" : (state.windSpeed * 1.94384).toFixed(1) + " kn";
824
+ return { value: a, sub: s };
825
+ } },
803
826
  aws: { label: "AWS", read: () => state.windSpeed == null ? "---" : (state.windSpeed * 1.94384).toFixed(1) + " kn" },
804
- twa: { label: "TWA", read: () => state.windAngleTrue == null ? "---" : _sgn(state.windAngleTrue * RAD2DEG) + "°" },
827
+ twa: { label: "TWA/TWS", read: () => {
828
+ const a = state.windAngleTrue == null ? "---" : _sgn(state.windAngleTrue * RAD2DEG) + "°";
829
+ const s = state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn";
830
+ return { value: a, sub: s };
831
+ } },
805
832
  tws: { label: "TWS", read: () => state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn" },
806
833
  twd: { label: "TWD", read: () => state.windDirTrue == null ? "---" : Math.round(state.windDirTrue * RAD2DEG) + "°" },
807
- wind: { label: () => (String(state.mode || "").includes("true") ? "TWA/TWS" : "AWA/AWS"),
834
+ // Rev65 / 2.0.2: the "wind" corner now honours a user-toggleable
835
+ // preference (state.windCornerShow: "awa" or "twa"), overridable by
836
+ // a short tap on the corner. When the AP mode changes TO true-wind,
837
+ // the preference auto-flips to TWA/TWS; when it changes AWAY from
838
+ // true-wind, it flips back to AWA/AWS. Manual taps after that stick
839
+ // until the mode flips again (see _syncWindCornerFromMode).
840
+ wind: { label: () => (state.windCornerShow === "twa" ? "TWA/TWS" : "AWA/AWS"),
808
841
  read: () => {
809
- const useTrue = String(state.mode || "").includes("true");
842
+ const useTrue = state.windCornerShow === "twa";
810
843
  const ang = useTrue ? state.windAngleTrue : state.windAngle;
811
844
  const spd = useTrue ? state.windSpeedTrue : state.windSpeed;
812
845
  const a = ang == null ? "---" : _sgn(ang * RAD2DEG) + "°";
@@ -885,6 +918,32 @@
885
918
  });
886
919
  }
887
920
  _refreshCustomDashEntries();
921
+ // Rev65 / 2.0.2: short tap on the "wind" corner flips the preference
922
+ // between AWA/AWS and TWA/TWS, and pins it so mode auto-changes stop
923
+ // overwriting it until the user opts back into auto-follow.
924
+ function _toggleWindCornerShow() {
925
+ state.windCornerShow = (state.windCornerShow === "twa") ? "awa" : "twa";
926
+ state.windCornerUserPinned = true;
927
+ try {
928
+ localStorage.setItem("pypilot-newui.windCornerShow", state.windCornerShow);
929
+ localStorage.setItem("pypilot-newui.windCornerPinned", "1");
930
+ } catch {}
931
+ _dashRenderCorners();
932
+ renderWindRose();
933
+ }
934
+ // Called from applyValue whenever the AP mode changes. If the user has
935
+ // not pinned the wind-corner preference, follow the mode: true-wind
936
+ // modes show TWA/TWS, everything else shows AWA/AWS.
937
+ function _syncWindCornerFromMode(mode) {
938
+ if (state.windCornerUserPinned) return;
939
+ const wantTrue = String(mode || "").toLowerCase().includes("true");
940
+ const next = wantTrue ? "twa" : "awa";
941
+ if (state.windCornerShow === next) return;
942
+ state.windCornerShow = next;
943
+ try { localStorage.setItem("pypilot-newui.windCornerShow", next); } catch {}
944
+ _dashRenderCorners();
945
+ renderWindRose();
946
+ }
888
947
  function _sgn(n) { return (n > 0 ? "+" : "") + Math.round(n); }
889
948
  function _dashLoadCfg() {
890
949
  try {
@@ -1062,9 +1121,10 @@
1062
1121
  }
1063
1122
 
1064
1123
  // SVG compass rose. The card (cardinals + ticks) rotates so N points to
1065
- // magnetic north regardless of boat heading; the boat is fixed pointing up;
1066
- // TWO independent wind arrows (AWA amber solid, TWA magenta dashed) rotate
1067
- // to their respective angles; wedges are boat-fixed helm guides.
1124
+ // magnetic north regardless of boat heading; the boat is fixed pointing
1125
+ // up; TWO independent wind arrows (AWA amber solid, TWA sea-green solid)
1126
+ // rotate to their respective angles; wedges are boat-fixed helm guides;
1127
+ // the bottom AWS/TWS readout follows the user's wind-corner preference.
1068
1128
  function renderWindRose() {
1069
1129
  const card = document.getElementById("rose-card");
1070
1130
  if (card && state.heading != null) {
@@ -1090,22 +1150,29 @@
1090
1150
  }
1091
1151
  }
1092
1152
 
1093
- // Speed readout at the bottom: prefer the source that is enabled; if
1094
- // both are enabled, show apparent (the one the crew feels).
1095
- const showAws = _windArrowShow.awa && state.windSpeed != null;
1096
- const showTws = _windArrowShow.twa && state.windSpeedTrue != null;
1097
- let windSpeed = null, tag = "";
1098
- if (showAws) { windSpeed = state.windSpeed; tag = "AWS"; }
1099
- else if (showTws) { windSpeed = state.windSpeedTrue; tag = "TWS"; }
1100
-
1101
- // Rev56: sailing zones (green stbd / red port) are DRAWN STATIC in
1102
- // the SVG at fixed boat-centerline angles +/-30..60, so nothing to
1103
- // update here.
1104
- const speedText = document.getElementById("rose-speed");
1153
+ // Rev65 / 2.0.2: bottom AWS/TWS readout now uses TWO separate text
1154
+ // nodes (small dim label on top, big white number below) and follows
1155
+ // `state.windCornerShow` so a tap on the wind corner also swaps the
1156
+ // readout at the bottom of the rose. Falls back gracefully if the
1157
+ // chosen source has no value yet.
1158
+ const useTrue = state.windCornerShow === "twa";
1159
+ const primarySpd = useTrue ? state.windSpeedTrue : state.windSpeed;
1160
+ const primaryTag = useTrue ? "TWS" : "AWS";
1161
+ let windSpeed = primarySpd, tag = primaryTag;
1162
+ if (windSpeed == null) {
1163
+ // Fall back to the other source so the number is not "---" when only
1164
+ // one wind flavour is available (typical on boats without SOG).
1165
+ const altSpd = useTrue ? state.windSpeed : state.windSpeedTrue;
1166
+ const altTag = useTrue ? "AWS" : "TWS";
1167
+ if (altSpd != null) { windSpeed = altSpd; tag = altTag; }
1168
+ }
1169
+ const speedLabel = document.getElementById("rose-speed-label");
1170
+ const speedText = document.getElementById("rose-speed");
1171
+ if (speedLabel) speedLabel.textContent = windSpeed == null ? "" : tag;
1105
1172
  if (speedText) {
1106
1173
  speedText.textContent = windSpeed == null
1107
1174
  ? "--- kn"
1108
- : (windSpeed * 1.94384).toFixed(1) + " kn" + (tag ? " " + tag : "");
1175
+ : (windSpeed * 1.94384).toFixed(1) + " kn";
1109
1176
  }
1110
1177
  }
1111
1178
 
@@ -1199,15 +1266,73 @@
1199
1266
  }
1200
1267
  }
1201
1268
 
1269
+ // Rev66 / 2.0.4 - cache to avoid repainting ticks every rudder delta.
1270
+ let _lastRudderRange = null;
1271
+ function _renderRudderScale(rangeDeg) {
1272
+ const ticksBox = $("#rudder-ticks");
1273
+ if (!ticksBox) return;
1274
+ ticksBox.innerHTML = "";
1275
+ // Ticks cada 10 deg, majors + labels cada 20 deg. Include 0 in the
1276
+ // middle. Sign of the number tells the side.
1277
+ const step = 10;
1278
+ for (let d = -Math.round(rangeDeg); d <= Math.round(rangeDeg); d += step) {
1279
+ const pct = 50 + (d / rangeDeg) * 50;
1280
+ const tick = document.createElement("div");
1281
+ tick.className = (d % 20 === 0) ? "rt major" : "rt";
1282
+ tick.style.left = pct.toFixed(2) + "%";
1283
+ ticksBox.appendChild(tick);
1284
+ // Numeric labels every 20 deg, but skip the extremes (already shown
1285
+ // by the left/right end labels) and skip 0 (would clash with the
1286
+ // yellow nav-toggle triangle in the middle).
1287
+ if (d % 20 === 0 && d !== 0 && Math.abs(d) < rangeDeg - 2) {
1288
+ const label = document.createElement("div");
1289
+ label.className = "rl";
1290
+ label.style.left = pct.toFixed(2) + "%";
1291
+ label.textContent = (d > 0 ? "+" : "") + d;
1292
+ ticksBox.appendChild(label);
1293
+ }
1294
+ }
1295
+ }
1202
1296
  function renderRudder(rad) {
1203
- if (rad == null) { $("#rudder-widget").setAttribute("hidden", ""); return; }
1204
- $("#rudder-widget").removeAttribute("hidden");
1297
+ // Rev66 / 2.0.4: update the rudder-range labels FIRST so they show
1298
+ // even before the first rudder delta arrives (no rudder yet ->
1299
+ // marker centred but labels visible). Range from pypilot catalog,
1300
+ // defaults to 70 deg.
1301
+ const rangeDeg = Number(state.values?.["steering.autopilot.pypilot.rudder.range"]) || 70;
1302
+ const lLeft = $("#rudder-label-left");
1303
+ const lRight = $("#rudder-label-right");
1304
+ if (lLeft) lLeft.textContent = "-" + Math.round(rangeDeg);
1305
+ if (lRight) lRight.textContent = "+" + Math.round(rangeDeg);
1306
+ if (rangeDeg !== _lastRudderRange) {
1307
+ _renderRudderScale(rangeDeg);
1308
+ _lastRudderRange = rangeDeg;
1309
+ }
1310
+ // Legacy widget (still hidden by default) - kept in case someone wires it.
1311
+ const w = $("#rudder-widget");
1312
+ if (rad == null) {
1313
+ if (w) w.setAttribute("hidden", "");
1314
+ // Centre the swipe-hint rudder marker so it looks amidships when
1315
+ // data is missing rather than sticking to some old value.
1316
+ const m = $("#rudder-marker");
1317
+ if (m) m.style.left = "50%";
1318
+ return;
1319
+ }
1320
+ if (w) w.removeAttribute("hidden");
1205
1321
  const deg = rad * RAD2DEG;
1206
- const pct = Math.max(-1, Math.min(1, deg / 45));
1322
+ const pct45 = Math.max(-1, Math.min(1, deg / 45));
1207
1323
  const fill = $("#rudder-fill");
1208
- fill.style.width = Math.abs(pct * 50) + "%";
1209
- fill.style.transform = pct < 0 ? "translateX(-100%)" : "translateX(0%)";
1210
- $("#rudder-value").textContent = deg.toFixed(1) + " deg";
1324
+ if (fill) {
1325
+ fill.style.width = Math.abs(pct45 * 50) + "%";
1326
+ fill.style.transform = pct45 < 0 ? "translateX(-100%)" : "translateX(0%)";
1327
+ }
1328
+ const val = $("#rudder-value");
1329
+ if (val) val.textContent = deg.toFixed(1) + " deg";
1330
+ const marker = $("#rudder-marker");
1331
+ if (marker) {
1332
+ const pct = Math.max(-1, Math.min(1, deg / rangeDeg));
1333
+ // pct = -1 -> left edge (0%), 0 -> center (50%), +1 -> right edge (100%).
1334
+ marker.style.left = (50 + pct * 50).toFixed(1) + "%";
1335
+ }
1211
1336
  }
1212
1337
 
1213
1338
  // Rev36 helper: build a slider row with nudge buttons -10/-1/+1/+10.
@@ -1639,6 +1764,13 @@
1639
1764
  const sign = String(state.mode || "").includes("wind") ? -1 : 1;
1640
1765
  const newTargetRad = (state.localTargetRad ?? 0) + sign * nudge * DEG2RAD;
1641
1766
  state.localTargetRad = newTargetRad;
1767
+ // Rev66 / 2.0.4: also update state.target optimistically so the
1768
+ // target diamond MOVES IN THE SAME TICK as the nudge, instead of
1769
+ // waiting for the server's delta reply (~200-800 ms on Tailscale).
1770
+ // If the server later rejects the new target the incoming delta
1771
+ // will overwrite this and the diamond re-syncs.
1772
+ state.target = newTargetRad;
1773
+ renderTargetArrow();
1642
1774
  apSetTargetRad(newTargetRad);
1643
1775
  });
1644
1776
  });
@@ -1667,9 +1799,34 @@
1667
1799
  _apPending = true;
1668
1800
  eng.classList.add("pending");
1669
1801
  const wasEngaged = state.engaged;
1670
- // Optimistic: flip the visual state before waiting on the network.
1802
+ // Rev65 / 2.0.2: snapshot the heading the USER IS SEEING at the exact
1803
+ // moment of the click, and use that same value for (a) the target we
1804
+ // send to the server, (b) the optimistic local assign, (c) any later
1805
+ // reference in this handler. Previously we re-read state.heading on
1806
+ // each await point - on a laggy Tailscale link (200-800 ms RTT) the
1807
+ // heading could have drifted a few degrees by the time the POST
1808
+ // executed, so we sent one value to the server, assigned another
1809
+ // locally, and the diamond jumped to a third when the real delta
1810
+ // arrived. Carlos's report: "el diamante no se fija bien".
1811
+ const capturedHeading = state.heading;
1812
+ // Optimistic: flip the visual state AND the target reference before
1813
+ // waiting on the network. Rev66 / 2.0.4: without this early assign
1814
+ // the target diamond was painted at the STALE state.target (last
1815
+ // engage's value) until the server's target-set reply arrived
1816
+ // hundreds of ms later, and then jumped to the fresh value. The
1817
+ // user saw the diamond "sale donde le sale de los huevos". Now
1818
+ // the diamond snaps to the captured heading in the same tick as
1819
+ // the click, and if the server rejects the operation we roll
1820
+ // both engaged and target back below.
1821
+ const prevTarget = state.target;
1822
+ const prevLocalTarget = state.localTargetRad;
1671
1823
  state.engaged = !wasEngaged;
1824
+ if (!wasEngaged && capturedHeading != null) {
1825
+ state.target = capturedHeading;
1826
+ state.localTargetRad = capturedHeading;
1827
+ }
1672
1828
  renderEngage();
1829
+ renderTargetArrow();
1673
1830
  let ok = false;
1674
1831
  try {
1675
1832
  if (wasEngaged) {
@@ -1680,20 +1837,24 @@
1680
1837
  // does not serialise 2 round-trips before the AP actually engages.
1681
1838
  const [rEng, rTgt] = await Promise.all([
1682
1839
  _apRetry(apEngage),
1683
- state.heading != null ? _apRetry(() => apSetTargetRad(state.heading)) : Promise.resolve({ ok: true }),
1840
+ capturedHeading != null ? _apRetry(() => apSetTargetRad(capturedHeading)) : Promise.resolve({ ok: true }),
1684
1841
  ]);
1685
1842
  ok = !!(rEng && rEng.ok);
1686
- if (rTgt && rTgt.ok && state.heading != null) {
1687
- state.target = state.heading;
1688
- state.localTargetRad = state.heading;
1689
- renderControl();
1690
- }
1843
+ // The optimistic assign above already painted the diamond in the
1844
+ // right spot; just re-render in case renderControl needs to
1845
+ // catch up other widgets that read state.target.
1846
+ if (rTgt && rTgt.ok && capturedHeading != null) renderControl();
1691
1847
  }
1692
1848
  } catch (_) { ok = false; }
1693
1849
  if (!ok) {
1694
- // Revert the optimistic flip so the UI reflects reality.
1850
+ // Revert the optimistic flip so the UI reflects reality. Rev66:
1851
+ // also revert the target snapshot we assigned above so the
1852
+ // diamond does not stay stuck on a target the server rejected.
1695
1853
  state.engaged = wasEngaged;
1854
+ state.target = prevTarget;
1855
+ state.localTargetRad = prevLocalTarget;
1696
1856
  renderEngage();
1857
+ renderTargetArrow();
1697
1858
  eng.classList.add("failed");
1698
1859
  setTimeout(() => eng.classList.remove("failed"), 1500);
1699
1860
  }
@@ -2487,36 +2648,74 @@
2487
2648
  _activateTab(ids[nextIdx], dir === 1 ? "next" : "prev");
2488
2649
  });
2489
2650
  });
2490
- // Rev48: long-press on any of the 4 corner cells opens the selector
2491
- // popup so the user assigns whatever data they want per corner.
2651
+ // Rev48/65: long-press opens the corner-selector popup. Short tap on
2652
+ // a corner whose current mapping is "wind" flips the label between
2653
+ // AWA/AWS and TWA/TWS, pinning the user's choice so subsequent AP
2654
+ // mode changes stop auto-flipping it (see _syncWindCornerFromMode).
2492
2655
  _dashLoadCfg();
2493
2656
  _dashPopulateSelectors();
2494
2657
  _dashRenderCorners();
2495
- let _dashLongPressTimer = null;
2496
- $$(".dash-rose .dash-cell[data-corner]").forEach((cell) => {
2658
+ // Rev66 / 2.0.4 - long-press / short-tap handler with PER-CELL state.
2659
+ // The previous Rev65 version leaked shared timer / timestamp / wasLong
2660
+ // flags across all 4 cells, so a touchstart on one cell overwrote the
2661
+ // in-flight state of another. Short-tap detection race-flipped and the
2662
+ // AWS/TWS toggle stopped working reliably. Wrapping the state inside
2663
+ // each cell's forEach closure gives us clean isolation - each cell
2664
+ // owns its own timer and its own last-press timestamp.
2665
+ const attachLongTap = (el, onShortTap) => {
2666
+ let timer = null;
2667
+ let pressStartedAt = 0;
2668
+ let wasLong = false;
2497
2669
  const openSelector = (e) => {
2498
2670
  e && e.preventDefault();
2671
+ wasLong = true;
2499
2672
  _dashPopulateSelectors();
2500
2673
  const pop = document.getElementById("dash-help-pop");
2501
2674
  if (pop) pop.classList.add("open");
2502
2675
  };
2503
- cell.addEventListener("touchstart", (e) => {
2504
- if (_dashLongPressTimer) clearTimeout(_dashLongPressTimer);
2505
- _dashLongPressTimer = setTimeout(() => openSelector(e), 500);
2506
- }, { passive: true });
2507
- const cancel = () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } };
2508
- cell.addEventListener("touchend", cancel, { passive: true });
2509
- cell.addEventListener("touchmove", cancel, { passive: true });
2510
- cell.addEventListener("touchcancel", cancel, { passive: true });
2511
- cell.addEventListener("contextmenu", openSelector);
2512
- let _mouseTimer = null;
2513
- cell.addEventListener("mousedown", (e) => {
2676
+ const startPress = (e) => {
2677
+ pressStartedAt = Date.now();
2678
+ wasLong = false;
2679
+ if (timer) clearTimeout(timer);
2680
+ timer = setTimeout(() => openSelector(e), 500);
2681
+ };
2682
+ const endPress = () => {
2683
+ if (timer) { clearTimeout(timer); timer = null; }
2684
+ if (wasLong) return;
2685
+ const elapsed = Date.now() - pressStartedAt;
2686
+ if (elapsed >= 500) return;
2687
+ onShortTap();
2688
+ };
2689
+ const cancelPress = () => {
2690
+ if (timer) { clearTimeout(timer); timer = null; }
2691
+ };
2692
+ el.addEventListener("touchstart", startPress, { passive: true });
2693
+ el.addEventListener("touchend", endPress, { passive: true });
2694
+ el.addEventListener("touchmove", () => { cancelPress(); wasLong = true; }, { passive: true });
2695
+ el.addEventListener("touchcancel", cancelPress, { passive: true });
2696
+ el.addEventListener("contextmenu", openSelector);
2697
+ el.addEventListener("mousedown", (e) => {
2514
2698
  if (e.button !== 0) return;
2515
- _mouseTimer = setTimeout(() => openSelector(e), 600);
2699
+ startPress(e);
2700
+ });
2701
+ el.addEventListener("mouseup", endPress);
2702
+ el.addEventListener("mouseleave", cancelPress);
2703
+ };
2704
+ // 4 corner cells: short-tap on a cell mapped to "wind" toggles AWS/TWS.
2705
+ $$(".dash-rose .dash-cell[data-corner]").forEach((cell) => {
2706
+ attachLongTap(cell, () => {
2707
+ if (_dashCornerCfg[cell.dataset.corner] === "wind") _toggleWindCornerShow();
2516
2708
  });
2517
- cell.addEventListener("mouseup", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
2518
- cell.addEventListener("mouseleave", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
2519
2709
  });
2710
+ // Rev66 / 2.0.4 - Carlos yes, the wind-speed chip below the rose
2711
+ // also acts as a tap target: short-tap on it toggles AWS/TWS.
2712
+ // Long-press behaves like long-press on any corner cell -> opens
2713
+ // the popup.
2714
+ const speedChip = document.getElementById("rose-speed-chip");
2715
+ if (speedChip) {
2716
+ speedChip.style.cursor = "pointer";
2717
+ attachLongTap(speedChip, () => _toggleWindCornerShow());
2718
+ }
2520
2719
  // Rev43: horizontal swipe between tabs on mobile/tablet.
2521
2720
  // Rules:
2522
2721
  // - Swipe is DISABLED when the Ajustes sliders are unlocked. The
@@ -3009,6 +3208,7 @@
3009
3208
  applyI18n();
3010
3209
  renderEngage();
3011
3210
  renderNudgeLabels();
3211
+ renderRudder(null); // Rev66: pinta los labels ±70 aunque no haya rudder aun
3012
3212
 
3013
3213
  // Login modal wiring (Rev17)
3014
3214
  const loginSubmit = $("#login-submit");
package/public/index.html CHANGED
@@ -71,7 +71,7 @@
71
71
  <div class="dash-cell corner-tr" data-corner="tr"></div>
72
72
  <div class="dash-cell corner-bl" data-corner="bl"></div>
73
73
  <div class="dash-cell corner-br" data-corner="br"></div>
74
- <svg id="wind-rose" class="rose-svg" viewBox="-100 -100 200 200" preserveAspectRatio="xMidYMid meet">
74
+ <svg id="wind-rose" class="rose-svg" viewBox="-100 -110 200 220" preserveAspectRatio="xMidYMid meet">
75
75
  <!-- Outer compass ring -->
76
76
  <circle cx="0" cy="0" r="94" fill="none" stroke="#2b3542" stroke-width="2"/>
77
77
  <!-- Rev58: sailing zones as PIE SLICES from origin (r=0) out
@@ -106,10 +106,13 @@
106
106
  <line x1="0" y1="-94" x2="0" y2="-84" transform="rotate(330)"/>
107
107
  </g>
108
108
  <!-- 4 cardinals: N accent yellow, ESW dim white. -->
109
- <text x="0" y="-70" text-anchor="middle" dominant-baseline="middle" fill="#ffd54a" font-size="14" font-weight="900">N</text>
110
- <text x="70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">E</text>
111
- <text x="0" y="70" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">S</text>
112
- <text x="-70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">W</text>
109
+ <text x="0" y="-70" text-anchor="middle" dominant-baseline="middle" fill="#ffd54a" font-size="14" font-weight="900">N</text>
110
+ <text x="70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">E</text>
111
+ <!-- Rev65: S moved inward from y=70 to y=58 so the AWS/TWS
112
+ bottom-of-rose label at y=70 does not overlap it when the
113
+ card sits at heading 0. -->
114
+ <text x="0" y="58" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">S</text>
115
+ <text x="-70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">W</text>
113
116
  <!-- 8 numeric labels at 30 deg increments (skip cardinals). -->
114
117
  <g fill="#8b98a5" font-size="7" font-weight="700" font-family="ui-monospace, monospace" text-anchor="middle" dominant-baseline="middle">
115
118
  <text transform="rotate(30) translate(0,-72)">030</text>
@@ -122,42 +125,79 @@
122
125
  <text transform="rotate(330) translate(0,-72)">330</text>
123
126
  </g>
124
127
  </g>
125
- <!-- Rev58: HDG pushed to y=-82 (right against the top rim);
126
- wind speed dropped to y=+82 (bottom rim) to keep the
127
- whole center free for the boat + arrows. Number CENTERED
128
- on the digits only; the degree symbol is positioned by
129
- JS just after them so "9" and "298" both anchor at x=0. -->
130
- <text id="rose-hdg-value" x="0" y="-82" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="18" font-weight="900" font-family="ui-monospace, monospace">---</text>
131
- <text id="rose-hdg-deg" x="0" y="-82" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="13" font-weight="700">°</text>
128
+ <!-- Rev65 / 2.0.2: HDG in a small chip that straddles the top
129
+ rim. The chip's top edge sits above the ring by ~4 units
130
+ (visually "asomando" per Carlos), the bottom edge sits
131
+ just below the ring so the chip clearly reads as an
132
+ overlay on top of the compass, not as a floating badge.
133
+ The chip does NOT push the SVG viewBox up - only the
134
+ painted rect crosses the ring line. Number CENTERED on
135
+ the digits only; degree glyph placed by JS after the
136
+ digits so "9" and "298" both anchor at x=0. -->
137
+ <!-- Rev66 / 2.0.4: chip narrowed from 48 to 40 wide + font
138
+ shrunk from 14 to 12, so the enlarged target diamond
139
+ below can peek out on the LEFT and RIGHT of the chip
140
+ even when the target is dead ahead (rotation 0). -->
141
+ <g id="rose-hdg-chip">
142
+ <rect x="-20" y="-100" width="40" height="16" rx="4" ry="4"
143
+ fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
144
+ <text id="rose-hdg-value" x="0" y="-91" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">---</text>
145
+ <text id="rose-hdg-deg" x="0" y="-91" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="9" font-weight="700">°</text>
146
+ </g>
132
147
  <!-- Rev50: boat overhead SVG (mareas-ihm), scaled 0.22 so
133
148
  the hull spans ~100 units = 50 % of the 200-unit rose
134
149
  diameter. Rotated -90 so bow points up. -->
135
150
  <g id="rose-boat" transform="rotate(-90) scale(0.22)">
136
151
  <image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
137
152
  </g>
138
- <!-- Rev58: two independent wind arrows so the crew can watch
139
- apparent AND true at the same time. AWA in amber (crew
140
- default), TWA in magenta with a dashed shaft. Each is
141
- toggled independently from the corner popup (localStorage). -->
153
+ <!-- Rev65/66 / 2.0.4: two independent wind arrows, both solid,
154
+ each labelled AWA / TWA in text that rotates with the arrow
155
+ so it always reads from the head. AWA = amber (big head,
156
+ the wind the crew feels). TWA = sea-green (small head).
157
+ Rev66: labels are placed at very different radii along
158
+ the shaft (AWA near the ring at y=-72, TWA near the boat
159
+ at y=-52) so they never overlap even when both arrows
160
+ point in exactly the same direction. -->
142
161
  <g id="rose-wind-arrow" style="display:none">
143
- <line x1="0" y1="-86" x2="0" y2="-36" stroke="#ffd54a" stroke-width="3" stroke-linecap="round"/>
144
- <polygon points="0,-30 -7,-42 7,-42" fill="#ffd54a" stroke="#8a6a00" stroke-width="0.6"/>
162
+ <line x1="0" y1="-86" x2="0" y2="-32" stroke="#ffd54a" stroke-width="3" stroke-linecap="round"/>
163
+ <polygon points="0,-24 -9,-40 9,-40" fill="#ffd54a" stroke="#8a6a00" stroke-width="0.6"/>
164
+ <text x="0" y="-72" text-anchor="middle" dominant-baseline="middle"
165
+ fill="#ffd54a" stroke="#3a2600" stroke-width="0.4" paint-order="stroke fill"
166
+ font-size="8" font-weight="900" letter-spacing="1">AWA</text>
145
167
  </g>
146
168
  <g id="rose-wind-arrow-twa" style="display:none">
147
- <line x1="0" y1="-86" x2="0" y2="-36" stroke="#c780f5" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 3"/>
148
- <polygon points="0,-30 -6,-40 6,-40" fill="#c780f5" stroke="#3f1d5c" stroke-width="0.6"/>
169
+ <line x1="0" y1="-86" x2="0" y2="-38" stroke="#5ce8c0" stroke-width="2.5" stroke-linecap="round"/>
170
+ <polygon points="0,-32 -6,-42 6,-42" fill="#5ce8c0" stroke="#1a4438" stroke-width="0.6"/>
171
+ <text x="0" y="-52" text-anchor="middle" dominant-baseline="middle"
172
+ fill="#5ce8c0" stroke="#0f2620" stroke-width="0.4" paint-order="stroke fill"
173
+ font-size="7" font-weight="900" letter-spacing="1">TWA</text>
149
174
  </g>
150
- <!-- Rev57: target indicator - CYAN diamond just outside the
151
- compass ring, only shown when AP is engaged. Rotated to
175
+ <!-- Rev66 / 2.0.4: cyan diamond enlarged (28 wide, 30 tall).
176
+ With the narrowed HDG chip above (40 wide) the diamond
177
+ asoma por los lados AND por arriba AND por debajo del
178
+ chip even when target is dead ahead. Rotated to
152
179
  (target - heading) so it always points where pypilot is
153
- steering. -->
180
+ steering (compass/gps/nav modes) or to target directly
181
+ (wind modes, target already in boat frame). -->
154
182
  <g id="rose-target-arrow" style="display:none">
155
- <polygon points="0,-104 -7,-96 0,-88 7,-96" fill="#4dd4ff" stroke="#003b52" stroke-width="1.2"/>
183
+ <polygon points="0,-108 -14,-93 0,-78 14,-93" fill="#4dd4ff" stroke="#003b52" stroke-width="1.5"/>
184
+ </g>
185
+ <!-- Rev66 / 2.0.4: wind speed now inside a small dark chip
186
+ that straddles the bottom of the compass ring, same
187
+ look as the HDG chip on top. Top of the chip sits a
188
+ couple of units INSIDE the ring, bottom asoma below.
189
+ Contains a small dim AWS/TWS label above the number.
190
+ The whole group carries `id=rose-speed-chip` so the
191
+ short-tap toggle (fix #5) can attach a listener to
192
+ the chip itself, not just the corner cells. -->
193
+ <g id="rose-speed-chip">
194
+ <rect x="-32" y="90" width="64" height="22" rx="4" ry="4"
195
+ fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
196
+ <text id="rose-speed-label" x="0" y="96" text-anchor="middle" dominant-baseline="middle"
197
+ fill="#8b98a5" font-size="7" font-weight="700" letter-spacing="1.2"></text>
198
+ <text id="rose-speed" x="0" y="106" text-anchor="middle" dominant-baseline="middle"
199
+ fill="#ffffff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">--- kn</text>
156
200
  </g>
157
- <!-- Rev58: wind speed dropped to y=+82 (bottom rim) to keep
158
- the boat area clean. Sub-label picks the source with a
159
- hint (AWS / TWS / both). -->
160
- <text id="rose-speed" x="0" y="82" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="800">--- kn</text>
161
201
  </svg>
162
202
  </div>
163
203
  </div>
@@ -251,12 +291,25 @@
251
291
  <div id="rudder-value" class="value">--</div>
252
292
  </div>
253
293
 
254
- <!-- Rev53: swipe affordance strip with central triangle toggle.
255
- Left/right arrows navigate to prev/next tab (like a mini
256
- breadcrumb + swipe hint). Central yellow glow triangle
257
- hides/shows the top nav bar. This lets the user reclaim
258
- the full viewport for the rose. Only visible inside Control. -->
294
+ <!-- Rev53/65: swipe affordance strip with central triangle toggle
295
+ AND (Rev65 / 2.0.2) a rudder-angle indicator painted on the
296
+ same strip. Track: horizontal gradient from red (port) at the
297
+ left to green (stbd) at the right, on a black band. Marker:
298
+ a thin vertical bar that sits at the exact centre when the
299
+ rudder is amidships and slides left/right proportional to
300
+ `state.rudderAngle / state.rudderRange`. The central yellow
301
+ triangle keeps its nav-toggle role AND visually reads as the
302
+ "zero" reference of the rudder bar. -->
259
303
  <div class="swipe-hint" id="swipe-hint" aria-hidden="true">
304
+ <div class="rudder-track"></div>
305
+ <!-- Rev66 / 2.0.4: rudder scale ticks + labels populated by
306
+ renderRudder() based on the actual pypilot rudder.range
307
+ (default 70 deg). Ticks every 10 deg, numeric labels
308
+ every 20 deg (0, 20, 40, 60) plus explicit ends. -->
309
+ <div class="rudder-ticks" id="rudder-ticks"></div>
310
+ <span class="rudder-label rudder-label-left" id="rudder-label-left">-70</span>
311
+ <span class="rudder-label rudder-label-right" id="rudder-label-right">+70</span>
312
+ <div class="rudder-marker" id="rudder-marker" style="left:50%"></div>
260
313
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
261
314
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
262
315
  <button class="nav-toggle" id="nav-toggle" aria-label="Mostrar / ocultar barra de pestañas" title="Mostrar / ocultar barra de pestañas">
@@ -476,23 +529,20 @@
476
529
  </div>
477
530
  <div id="cfg-ssh-status" class="hint" style="margin-top:6px"></div>
478
531
 
479
- <!-- Rev63 / 2.0.0: three escalated restart levels. Each fires a
480
- helm-manned confirmation modal before executing. -->
532
+ <!-- Rev66 / 2.0.4: simplified to 2 levels. `restart pypilot_web`
533
+ (level 1) rarely helps in practice - when the Pi Zero is
534
+ saturated the whole box needs a full reboot. Moved to the
535
+ Debug console below as an advanced preset for the 5 % of
536
+ cases where only the web layer is stuck but the AP core
537
+ keeps steering (rare). -->
481
538
  <div class="restart-block" style="margin-top:14px">
482
- <div class="restart-title" data-i18n="setup.restart.title">Restart / reboot (escalated)</div>
483
- <div class="restart-row">
484
- <button id="btn-restart-web" class="restart-btn level-1" data-preset="restart.web" data-outage-i18n="setup.restart.outage.web">
485
- <span class="rb-num">1</span>
486
- <span class="rb-title" data-i18n="setup.restart.web.title">restart pypilot_web</span>
487
- <span class="rb-sub" data-i18n="setup.restart.web.sub">~3-5 s &middot; AP keeps steering</span>
488
- </button>
539
+ <div class="restart-title" data-i18n="setup.restart.title">Restart / reboot</div>
540
+ <div class="restart-row two-cols">
489
541
  <button id="cli-restart-pypilot" class="restart-btn level-2" data-outage-i18n="setup.restart.outage.pypilot">
490
- <span class="rb-num">2</span>
491
542
  <span class="rb-title" data-i18n="setup.restart.pypilot.title">RESTART pypilot</span>
492
543
  <span class="rb-sub" data-i18n="setup.restart.pypilot.sub">~10-15 s &middot; AP drops the heading</span>
493
544
  </button>
494
545
  <button id="btn-reboot-pi" class="restart-btn level-3" data-preset="reboot.pi" data-outage-i18n="setup.restart.outage.reboot">
495
- <span class="rb-num">3</span>
496
546
  <span class="rb-title" data-i18n="setup.restart.reboot.title">reboot Pi</span>
497
547
  <span class="rb-sub" data-i18n="setup.restart.reboot.sub">~35-60 s &middot; full outage</span>
498
548
  </button>
@@ -500,7 +550,7 @@
500
550
  </div>
501
551
 
502
552
  <div id="cli-restart-status" class="hint" style="margin-top:8px"></div>
503
- <div class="hint" data-i18n="setup.restart.hint">All three actions use the SSH user+password above. Level 1 does not interrupt steering; levels 2 and 3 do. A confirmation asking whether someone is at the helm is shown before executing.</div>
553
+ <div class="hint" data-i18n="setup.restart.hint">Both actions use the SSH user + password above and show a helm-manned confirmation before executing. Use RESTART pypilot for the common case; reboot Pi when the TinyPilot has degraded and needs a cold reset. For a lighter web-only restart see the Debug console below.</div>
504
554
  <div class="hint" data-i18n="setup.security.hint" style="margin-top:6px">Signal K stores plugin config in plain text at ~/.signalk/plugin-config-data/.</div>
505
555
 
506
556
  <!-- Rev63 / 2.0.0: Debug console. Closed whitelist of preset
@@ -516,6 +566,10 @@
516
566
  <button class="ghost debug-preset" data-preset="uptime">uptime</button>
517
567
  <button class="ghost debug-preset" data-preset="df">df</button>
518
568
  <button class="ghost debug-preset" data-preset="pypilot.version">pypilot --version</button>
569
+ <!-- Rev66 / 2.0.4: moved from the Emergency triad. Rarely
570
+ needed on a healthy Pi; when the box is saturated a full
571
+ reboot is what actually helps. Kept here for edge cases. -->
572
+ <button class="ghost debug-preset" data-preset="restart.web">restart pypilot_web</button>
519
573
  </div>
520
574
  <textarea id="debug-output" readonly rows="12"
521
575
  style="width:100%;background:var(--bg-3);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px;font-family:ui-monospace,monospace;font-size:12px;resize:vertical;"
@@ -886,6 +940,6 @@
886
940
  </div>
887
941
 
888
942
  <!-- Cache-busting: bump the ?v= on every Rev to force Firefox to fetch fresh app.js -->
889
- <script src="app.js?v=Rev66"></script>
943
+ <script src="app.js?v=Rev67"></script>
890
944
  </body>
891
945
  </html>
package/public/style.css CHANGED
@@ -904,48 +904,71 @@ body.nav-hidden .nav-toggle .tri { transform: rotate(180deg); }
904
904
  text-transform: uppercase;
905
905
  margin: 0 0 8px;
906
906
  }
907
+ /* Rev66 / 2.0.4: default 3 cols for legacy 3-level layouts; the
908
+ Emergency simplified layout in Rev66 uses .two-cols to switch to
909
+ 2 cols on desktop. Both collapse to 1 col on narrow phones. */
907
910
  .restart-row {
908
911
  display: grid;
909
912
  grid-template-columns: repeat(3, minmax(0, 1fr));
910
913
  gap: 8px;
911
914
  }
912
- @media (max-width: 640px) {
913
- .restart-row { grid-template-columns: 1fr; }
915
+ .restart-row.two-cols {
916
+ grid-template-columns: repeat(2, minmax(0, 1fr));
914
917
  }
918
+ @media (max-width: 640px) {
919
+ .restart-row,
920
+ .restart-row.two-cols { grid-template-columns: 1fr; }
921
+ }
922
+ /* Rev66 / 2.0.4: simple flex column layout - the numbered badge is
923
+ gone in the 2-level Emergency, and the previous grid template still
924
+ assumed a 28 px badge column, which stretched the title into two
925
+ awkward lines with the sub label overlapping. Flex column stacks
926
+ title on top of sub cleanly regardless of button width. */
915
927
  .restart-btn {
916
- display: grid;
917
- grid-template-columns: 28px 1fr;
918
- grid-template-rows: auto auto;
919
- align-items: center;
920
- gap: 2px 10px;
921
- padding: 10px 12px;
928
+ display: flex;
929
+ flex-direction: column;
930
+ align-items: center; /* Rev66 fix: text centered per Carlos */
931
+ justify-content: center;
932
+ gap: 4px;
933
+ padding: 14px 16px;
934
+ min-height: 68px;
922
935
  border-radius: 10px;
923
936
  border: 2px solid var(--border);
924
937
  background: var(--bg-3);
925
938
  color: var(--fg);
926
939
  cursor: pointer;
927
- text-align: left;
940
+ text-align: center; /* Rev66 fix: text centered */
928
941
  font-family: inherit;
929
942
  transition: transform 80ms ease, background 100ms ease, border-color 100ms ease;
930
943
  }
931
944
  .restart-btn:hover { border-color: var(--fg-dim); }
932
945
  .restart-btn:active { transform: scale(0.97); }
946
+ .restart-btn .rb-title {
947
+ font-weight: 800;
948
+ font-size: 15px;
949
+ letter-spacing: 0.5px;
950
+ line-height: 1.2;
951
+ }
952
+ .restart-btn .rb-sub {
953
+ font-size: 12px;
954
+ color: var(--fg-dim);
955
+ line-height: 1.2;
956
+ }
957
+ /* Optional numbered badge - only rendered by markup that still uses
958
+ the old 3-level layout. When present it sits inline before the
959
+ title on the first row via nested flex, without breaking the plain
960
+ layout when absent. */
933
961
  .restart-btn .rb-num {
934
- grid-row: 1 / span 2;
935
- width: 28px; height: 28px;
962
+ width: 24px; height: 24px;
936
963
  border-radius: 50%;
937
- display: flex; align-items: center; justify-content: center;
938
- font-weight: 900; font-size: 15px;
964
+ display: inline-flex; align-items: center; justify-content: center;
965
+ font-weight: 900; font-size: 13px;
939
966
  background: rgba(255, 255, 255, 0.08);
940
967
  color: var(--fg);
968
+ margin-right: 6px;
969
+ vertical-align: middle;
941
970
  }
942
- .restart-btn .rb-title { font-weight: 800; font-size: 14px; letter-spacing: 0.5px; }
943
- .restart-btn .rb-sub { font-size: 12px; color: var(--fg-dim); }
944
- /* Level 1: green tint, safest - AP keeps steering. */
945
- .restart-btn.level-1 { border-color: rgba(46, 204, 113, 0.55); }
946
- .restart-btn.level-1 .rb-num { background: rgba(46, 204, 113, 0.22); color: #2ecc71; }
947
- .restart-btn.level-1:hover { background: rgba(46, 204, 113, 0.10); }
948
- /* Level 2: amber, brief AP outage. Matches the old RESTART pypilot look. */
971
+ /* Level 2: amber, brief AP outage. */
949
972
  .restart-btn.level-2 { border-color: var(--warn, #f37812); }
950
973
  .restart-btn.level-2 .rb-num { background: rgba(243, 120, 18, 0.25); color: var(--warn, #f37812); }
951
974
  .restart-btn.level-2:hover { background: rgba(243, 120, 18, 0.10); }
@@ -953,6 +976,10 @@ body.nav-hidden .nav-toggle .tri { transform: rotate(180deg); }
953
976
  .restart-btn.level-3 { border-color: var(--err, #e74c3c); }
954
977
  .restart-btn.level-3 .rb-num { background: rgba(231, 76, 60, 0.30); color: var(--err, #e74c3c); }
955
978
  .restart-btn.level-3:hover { background: rgba(231, 76, 60, 0.10); }
979
+ /* Level 1: kept for backwards-compat if some markup uses it. */
980
+ .restart-btn.level-1 { border-color: rgba(46, 204, 113, 0.55); }
981
+ .restart-btn.level-1 .rb-num { background: rgba(46, 204, 113, 0.22); color: #2ecc71; }
982
+ .restart-btn.level-1:hover { background: rgba(46, 204, 113, 0.10); }
956
983
 
957
984
  /* Rev63 - Helm-manned confirmation modal. Same overlay pattern as
958
985
  #dash-help-pop but with a danger accent on the confirm button and a
@@ -1543,13 +1570,15 @@ main { touch-action: pan-y; overflow-x: hidden; }
1543
1570
  strip with subtle motion cueing that swipe is available. Also acts
1544
1571
  as tappable arrows for tab navigation. */
1545
1572
  .swipe-hint {
1573
+ position: relative; /* Rev65: anchor for the rudder track + marker */
1546
1574
  display: flex;
1547
1575
  align-items: center;
1548
1576
  justify-content: space-between;
1549
1577
  gap: 10px;
1550
1578
  margin-top: 10px;
1551
1579
  padding: 8px 12px;
1552
- background: linear-gradient(90deg, rgba(55,182,255,0.14) 0%, rgba(55,182,255,0.05) 50%, rgba(55,182,255,0.14) 100%);
1580
+ background: #08111c; /* Rev65: solid dark base; the rudder track
1581
+ below paints its own gradient on top. */
1553
1582
  border: 1px dashed rgba(55,182,255,0.35);
1554
1583
  border-radius: 10px;
1555
1584
  color: rgba(255, 255, 255, 0.65);
@@ -1559,6 +1588,95 @@ main { touch-action: pan-y; overflow-x: hidden; }
1559
1588
  user-select: none;
1560
1589
  overflow: hidden;
1561
1590
  }
1591
+
1592
+ /* Rev65 / 2.0.2 - Rudder angle bar painted inside the swipe-hint strip.
1593
+ .rudder-track is a full-width horizontal gradient (red = port left,
1594
+ green = stbd right) with a dim mid-tone so amidships reads as
1595
+ "neutral". The three foreground layers (arrows, nav-toggle triangle,
1596
+ marker) sit above it via z-index. The nav-toggle's own yellow
1597
+ triangle visually stands in for the 0 deg reference. */
1598
+ .swipe-hint .rudder-track {
1599
+ position: absolute;
1600
+ inset: 4px 8px; /* small inset from the strip edge */
1601
+ border-radius: 8px;
1602
+ background: linear-gradient(
1603
+ 90deg,
1604
+ rgba(231, 76, 60, 0.65) 0%, /* PORT: red */
1605
+ rgba(231, 76, 60, 0.20) 40%,
1606
+ rgba(46, 46, 46, 0.30) 50%, /* dim mid = amidships */
1607
+ rgba(46, 204, 113, 0.20) 60%,
1608
+ rgba(46, 204, 113, 0.65) 100% /* STBD: green */
1609
+ );
1610
+ z-index: 0;
1611
+ pointer-events: none;
1612
+ }
1613
+ .swipe-hint .rudder-marker {
1614
+ position: absolute;
1615
+ top: 2px;
1616
+ bottom: 2px;
1617
+ width: 4px;
1618
+ margin-left: -2px; /* center on `left` */
1619
+ background: #ffffff;
1620
+ border-radius: 2px;
1621
+ box-shadow: 0 0 6px rgba(255, 255, 255, 0.55);
1622
+ z-index: 1;
1623
+ transition: left 180ms ease-out;
1624
+ pointer-events: none;
1625
+ }
1626
+ /* Rev66 / 2.0.4 - rudder scale labels at both ends of the swipe-hint
1627
+ bar. Small dim numbers indicating the rudder range in degrees. Text
1628
+ populated by renderRudder() with the current pypilot rudder.range
1629
+ (default 70 deg). Pointer-events off so they don't block taps. */
1630
+ .swipe-hint .rudder-label {
1631
+ position: absolute;
1632
+ top: 50%;
1633
+ transform: translateY(-50%);
1634
+ font-size: 10px;
1635
+ font-weight: 700;
1636
+ color: rgba(255, 255, 255, 0.55);
1637
+ z-index: 1;
1638
+ pointer-events: none;
1639
+ padding: 0 6px;
1640
+ font-family: ui-monospace, monospace;
1641
+ letter-spacing: 0.5px;
1642
+ }
1643
+ .swipe-hint .rudder-label-left { left: 6px; }
1644
+ .swipe-hint .rudder-label-right { right: 6px; }
1645
+ /* Rev66 / 2.0.4 - intermediate tick container. renderRudder() fills
1646
+ this with small marks every 10 deg + numeric labels every 20 deg. */
1647
+ .swipe-hint .rudder-ticks {
1648
+ position: absolute;
1649
+ inset: 4px 8px;
1650
+ z-index: 0;
1651
+ pointer-events: none;
1652
+ }
1653
+ .swipe-hint .rudder-ticks .rt {
1654
+ position: absolute;
1655
+ top: 0;
1656
+ bottom: 0;
1657
+ width: 1px;
1658
+ margin-left: -0.5px;
1659
+ background: rgba(255, 255, 255, 0.25);
1660
+ }
1661
+ .swipe-hint .rudder-ticks .rt.major {
1662
+ width: 1px;
1663
+ background: rgba(255, 255, 255, 0.45);
1664
+ }
1665
+ .swipe-hint .rudder-ticks .rl {
1666
+ position: absolute;
1667
+ top: 50%;
1668
+ transform: translate(-50%, -50%);
1669
+ font-size: 9px;
1670
+ font-weight: 700;
1671
+ color: rgba(255, 255, 255, 0.55);
1672
+ font-family: ui-monospace, monospace;
1673
+ pointer-events: none;
1674
+ }
1675
+ .swipe-hint-arrow,
1676
+ .swipe-hint .nav-toggle {
1677
+ position: relative; /* stack above the rudder track */
1678
+ z-index: 2;
1679
+ }
1562
1680
  .swipe-hint-arrow {
1563
1681
  font-size: 28px;
1564
1682
  line-height: 1;