signalk-pypilot-newui 2.0.2 → 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,44 +1,97 @@
1
1
  # Changelog
2
2
 
3
- ## 2.0.2 — 2026-08-20 — Rev65
3
+ ## 2.0.4 — 2026-08-21 — Rev67
4
4
 
5
- Bug fix + UI polish batch.
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.
6
8
 
7
9
  ### Fixed
8
10
 
9
- - **Target diamond drifted after tapping AP on laggy links** (Tailscale,
10
- 4G). We were re-reading `state.heading` inside the parallel promise
11
- chain, so between the click and the actual POST the heading could have
12
- moved a few degrees. Consequence: we sent one target to the server,
13
- asigned another locally, and the diamond jumped to a third when the
14
- real delta arrived. Now the heading is captured ONCE at the click and
15
- reused for the POST, the optimistic assign and any later reference in
16
- the handler.
17
-
18
- ### Changed - compass rose
19
-
20
- - HDG now shown in a small dark chip that straddles the top of the ring
21
- (asoma un poco por encima) instead of floating text. Font shrunk from
22
- 18 to 14 to fit inside the chip.
23
- - Wind arrows redrawn: both solid (no more dashed shaft on TWA), amber
24
- AWA keeps a big head, sea-green (#5ce8c0) TWA gets a smaller head, and
25
- each carries its own `AWA` / `TWA` text label rotating with the arrow.
26
- The magenta is gone.
27
- - Bottom of rose: `AWS` / `TWS` label small on top and the number large
28
- below, split into two lines with the S cardinal reubicated so the
29
- gap between them stays visible for the rose itself.
30
- - Short tap on a corner mapped to "wind" now flips the label between
31
- AWA/AWS and TWA/TWS and pins that choice. When the AP mode changes to
32
- a true-wind mode the label auto-follows to TWS (unless the user has
33
- already pinned it manually).
34
-
35
- ### Added - rudder angle indicator in the swipe-hint bar
36
-
37
- The bar under the Control tab now carries a horizontal red-to-green
38
- gradient (port → stbd) with a moving white marker showing the current
39
- rudder angle. The central yellow nav-toggle triangle sits at 0 deg,
40
- doubling as the amidships reference. Scale comes from the pypilot
41
- catalog's `rudder.range` (default 45 deg when unavailable).
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
+
84
+ ## 2.0.3 — 2026-08-20 — Rev66 - rollback of 2.0.2
85
+
86
+ Codebase is functionally identical to 2.0.1. Version 2.0.2 introduced
87
+ regressions in the socket auto-reconnect and the new "escalated restart"
88
+ triad that could leave the plugin showing `connected: false` against a
89
+ pypilot_web that is in fact alive and serving other consumers. Rolling
90
+ back to the 2.0.1 behaviour and reintroducing the 2.0.2 UI polish
91
+ (target diamond snapshot, redesigned AWA/TWA arrows, HDG chip, wind
92
+ speed chip, tap wind toggle, rudder bar, debug console, escalated
93
+ restarts) in a later 2.0.4+ release, one item at a time with proper QA
94
+ before publish. 2.0.2 has been deprecated on npm.
42
95
 
43
96
  ## 2.0.1 — 2026-08-20 — Rev64
44
97
 
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 = "Rev65";
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.2",
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
@@ -816,9 +816,19 @@
816
816
  const DASH_DATA = {
817
817
  none: { label: "-", read: () => "" },
818
818
  hdg: { label: "HDG", read: () => state.heading == null ? "---" : Math.round(state.heading * RAD2DEG) + "°" },
819
- 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
+ } },
820
826
  aws: { label: "AWS", read: () => state.windSpeed == null ? "---" : (state.windSpeed * 1.94384).toFixed(1) + " kn" },
821
- 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
+ } },
822
832
  tws: { label: "TWS", read: () => state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn" },
823
833
  twd: { label: "TWD", read: () => state.windDirTrue == null ? "---" : Math.round(state.windDirTrue * RAD2DEG) + "°" },
824
834
  // Rev65 / 2.0.2: the "wind" corner now honours a user-toggleable
@@ -1256,13 +1266,53 @@
1256
1266
  }
1257
1267
  }
1258
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
+ }
1259
1296
  function renderRudder(rad) {
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
+ }
1260
1310
  // Legacy widget (still hidden by default) - kept in case someone wires it.
1261
1311
  const w = $("#rudder-widget");
1262
1312
  if (rad == null) {
1263
1313
  if (w) w.setAttribute("hidden", "");
1264
- // Also center the swipe-hint rudder marker so it looks amidships
1265
- // when data is missing rather than sticking to some old value.
1314
+ // Centre the swipe-hint rudder marker so it looks amidships when
1315
+ // data is missing rather than sticking to some old value.
1266
1316
  const m = $("#rudder-marker");
1267
1317
  if (m) m.style.left = "50%";
1268
1318
  return;
@@ -1277,12 +1327,8 @@
1277
1327
  }
1278
1328
  const val = $("#rudder-value");
1279
1329
  if (val) val.textContent = deg.toFixed(1) + " deg";
1280
- // Rev65 / 2.0.2: paint the rudder marker on the swipe-hint bar. Range
1281
- // comes from the pypilot catalog (`rudder.range` in degrees) when
1282
- // available, else defaults to 45 deg either side.
1283
1330
  const marker = $("#rudder-marker");
1284
1331
  if (marker) {
1285
- const rangeDeg = Number(state.values?.["steering.autopilot.pypilot.rudder.range"]) || 45;
1286
1332
  const pct = Math.max(-1, Math.min(1, deg / rangeDeg));
1287
1333
  // pct = -1 -> left edge (0%), 0 -> center (50%), +1 -> right edge (100%).
1288
1334
  marker.style.left = (50 + pct * 50).toFixed(1) + "%";
@@ -1718,6 +1764,13 @@
1718
1764
  const sign = String(state.mode || "").includes("wind") ? -1 : 1;
1719
1765
  const newTargetRad = (state.localTargetRad ?? 0) + sign * nudge * DEG2RAD;
1720
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();
1721
1774
  apSetTargetRad(newTargetRad);
1722
1775
  });
1723
1776
  });
@@ -1756,9 +1809,24 @@
1756
1809
  // locally, and the diamond jumped to a third when the real delta
1757
1810
  // arrived. Carlos's report: "el diamante no se fija bien".
1758
1811
  const capturedHeading = state.heading;
1759
- // Optimistic: flip the visual state before waiting on the network.
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;
1760
1823
  state.engaged = !wasEngaged;
1824
+ if (!wasEngaged && capturedHeading != null) {
1825
+ state.target = capturedHeading;
1826
+ state.localTargetRad = capturedHeading;
1827
+ }
1761
1828
  renderEngage();
1829
+ renderTargetArrow();
1762
1830
  let ok = false;
1763
1831
  try {
1764
1832
  if (wasEngaged) {
@@ -1772,17 +1840,21 @@
1772
1840
  capturedHeading != null ? _apRetry(() => apSetTargetRad(capturedHeading)) : Promise.resolve({ ok: true }),
1773
1841
  ]);
1774
1842
  ok = !!(rEng && rEng.ok);
1775
- if (rTgt && rTgt.ok && capturedHeading != null) {
1776
- state.target = capturedHeading;
1777
- state.localTargetRad = capturedHeading;
1778
- renderControl();
1779
- }
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();
1780
1847
  }
1781
1848
  } catch (_) { ok = false; }
1782
1849
  if (!ok) {
1783
- // 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.
1784
1853
  state.engaged = wasEngaged;
1854
+ state.target = prevTarget;
1855
+ state.localTargetRad = prevLocalTarget;
1785
1856
  renderEngage();
1857
+ renderTargetArrow();
1786
1858
  eng.classList.add("failed");
1787
1859
  setTimeout(() => eng.classList.remove("failed"), 1500);
1788
1860
  }
@@ -2583,44 +2655,67 @@
2583
2655
  _dashLoadCfg();
2584
2656
  _dashPopulateSelectors();
2585
2657
  _dashRenderCorners();
2586
- let _dashLongPressTimer = null;
2587
- let _dashPressStartedAt = 0;
2588
- let _dashPressWasLong = false;
2589
- $$(".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;
2590
2669
  const openSelector = (e) => {
2591
2670
  e && e.preventDefault();
2592
- _dashPressWasLong = true;
2671
+ wasLong = true;
2593
2672
  _dashPopulateSelectors();
2594
2673
  const pop = document.getElementById("dash-help-pop");
2595
2674
  if (pop) pop.classList.add("open");
2596
2675
  };
2597
2676
  const startPress = (e) => {
2598
- _dashPressStartedAt = Date.now();
2599
- _dashPressWasLong = false;
2600
- if (_dashLongPressTimer) clearTimeout(_dashLongPressTimer);
2601
- _dashLongPressTimer = setTimeout(() => openSelector(e), 500);
2677
+ pressStartedAt = Date.now();
2678
+ wasLong = false;
2679
+ if (timer) clearTimeout(timer);
2680
+ timer = setTimeout(() => openSelector(e), 500);
2602
2681
  };
2603
2682
  const endPress = () => {
2604
- if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; }
2605
- // Short tap on a wind-corner cell -> toggle AWS/TWS.
2606
- if (_dashPressWasLong) return;
2607
- const elapsed = Date.now() - _dashPressStartedAt;
2683
+ if (timer) { clearTimeout(timer); timer = null; }
2684
+ if (wasLong) return;
2685
+ const elapsed = Date.now() - pressStartedAt;
2608
2686
  if (elapsed >= 500) return;
2609
- const corner = cell.dataset.corner;
2610
- if (_dashCornerCfg[corner] === "wind") _toggleWindCornerShow();
2687
+ onShortTap();
2688
+ };
2689
+ const cancelPress = () => {
2690
+ if (timer) { clearTimeout(timer); timer = null; }
2611
2691
  };
2612
- cell.addEventListener("touchstart", startPress, { passive: true });
2613
- cell.addEventListener("touchend", endPress, { passive: true });
2614
- cell.addEventListener("touchmove", () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } _dashPressWasLong = true; }, { passive: true });
2615
- cell.addEventListener("touchcancel",() => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } }, { passive: true });
2616
- cell.addEventListener("contextmenu", openSelector);
2617
- cell.addEventListener("mousedown", (e) => {
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) => {
2618
2698
  if (e.button !== 0) return;
2619
2699
  startPress(e);
2620
2700
  });
2621
- cell.addEventListener("mouseup", endPress);
2622
- cell.addEventListener("mouseleave", () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } });
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();
2708
+ });
2623
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
+ }
2624
2719
  // Rev43: horizontal swipe between tabs on mobile/tablet.
2625
2720
  // Rules:
2626
2721
  // - Swipe is DISABLED when the Ajustes sliders are unlocked. The
@@ -3113,6 +3208,7 @@
3113
3208
  applyI18n();
3114
3209
  renderEngage();
3115
3210
  renderNudgeLabels();
3211
+ renderRudder(null); // Rev66: pinta los labels ±70 aunque no haya rudder aun
3116
3212
 
3117
3213
  // Login modal wiring (Rev17)
3118
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 210" 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
@@ -134,11 +134,15 @@
134
134
  painted rect crosses the ring line. Number CENTERED on
135
135
  the digits only; degree glyph placed by JS after the
136
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). -->
137
141
  <g id="rose-hdg-chip">
138
- <rect x="-24" y="-100" width="48" height="18" rx="4" ry="4"
142
+ <rect x="-20" y="-100" width="40" height="16" rx="4" ry="4"
139
143
  fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
140
- <text id="rose-hdg-value" x="0" y="-90" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="ui-monospace, monospace">---</text>
141
- <text id="rose-hdg-deg" x="0" y="-90" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="10" font-weight="700">°</text>
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>
142
146
  </g>
143
147
  <!-- Rev50: boat overhead SVG (mareas-ihm), scaled 0.22 so
144
148
  the hull spans ~100 units = 50 % of the 200-unit rose
@@ -146,44 +150,54 @@
146
150
  <g id="rose-boat" transform="rotate(-90) scale(0.22)">
147
151
  <image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
148
152
  </g>
149
- <!-- Rev65 / 2.0.2: two independent wind arrows, both solid
150
- (no more dashed shaft), each labelled AWA / TWA in text
151
- that rotates with the arrow so it always reads from the
152
- head. AWA = amber (the wind the crew feels), big head.
153
- TWA = sea-green (informational), smaller head. Both
154
- toggled independently from the corner popup. -->
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. -->
155
161
  <g id="rose-wind-arrow" style="display:none">
156
162
  <line x1="0" y1="-86" x2="0" y2="-32" stroke="#ffd54a" stroke-width="3" stroke-linecap="round"/>
157
163
  <polygon points="0,-24 -9,-40 9,-40" fill="#ffd54a" stroke="#8a6a00" stroke-width="0.6"/>
158
- <text x="0" y="-58" text-anchor="middle" dominant-baseline="middle"
164
+ <text x="0" y="-72" text-anchor="middle" dominant-baseline="middle"
159
165
  fill="#ffd54a" stroke="#3a2600" stroke-width="0.4" paint-order="stroke fill"
160
166
  font-size="8" font-weight="900" letter-spacing="1">AWA</text>
161
167
  </g>
162
168
  <g id="rose-wind-arrow-twa" style="display:none">
163
169
  <line x1="0" y1="-86" x2="0" y2="-38" stroke="#5ce8c0" stroke-width="2.5" stroke-linecap="round"/>
164
170
  <polygon points="0,-32 -6,-42 6,-42" fill="#5ce8c0" stroke="#1a4438" stroke-width="0.6"/>
165
- <text x="0" y="-62" text-anchor="middle" dominant-baseline="middle"
171
+ <text x="0" y="-52" text-anchor="middle" dominant-baseline="middle"
166
172
  fill="#5ce8c0" stroke="#0f2620" stroke-width="0.4" paint-order="stroke fill"
167
173
  font-size="7" font-weight="900" letter-spacing="1">TWA</text>
168
174
  </g>
169
- <!-- Rev57: target indicator - CYAN diamond just outside the
170
- 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
171
179
  (target - heading) so it always points where pypilot is
172
- steering. -->
180
+ steering (compass/gps/nav modes) or to target directly
181
+ (wind modes, target already in boat frame). -->
173
182
  <g id="rose-target-arrow" style="display:none">
174
- <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>
175
200
  </g>
176
- <!-- Rev65 / 2.0.2: wind speed split into two stacked lines
177
- - a small dim label (AWS/TWS/both) and a large white
178
- number - separated by empty space so the S cardinal and
179
- the surrounding rose stay visible in between. The whole
180
- stack sits below the compass ring: label just outside at
181
- y=+100, number below at y=+108, both fitting inside the
182
- expanded viewBox (extra 10 units at the bottom). -->
183
- <text id="rose-speed-label" x="0" y="98" text-anchor="middle" dominant-baseline="middle"
184
- fill="#8b98a5" font-size="8" font-weight="700" letter-spacing="1.5"></text>
185
- <text id="rose-speed" x="0" y="108" text-anchor="middle" dominant-baseline="middle"
186
- fill="#ffffff" font-size="15" font-weight="900" font-family="ui-monospace, monospace">--- kn</text>
187
201
  </svg>
188
202
  </div>
189
203
  </div>
@@ -288,6 +302,13 @@
288
302
  "zero" reference of the rudder bar. -->
289
303
  <div class="swipe-hint" id="swipe-hint" aria-hidden="true">
290
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>
291
312
  <div class="rudder-marker" id="rudder-marker" style="left:50%"></div>
292
313
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
293
314
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
@@ -508,23 +529,20 @@
508
529
  </div>
509
530
  <div id="cfg-ssh-status" class="hint" style="margin-top:6px"></div>
510
531
 
511
- <!-- Rev63 / 2.0.0: three escalated restart levels. Each fires a
512
- 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). -->
513
538
  <div class="restart-block" style="margin-top:14px">
514
- <div class="restart-title" data-i18n="setup.restart.title">Restart / reboot (escalated)</div>
515
- <div class="restart-row">
516
- <button id="btn-restart-web" class="restart-btn level-1" data-preset="restart.web" data-outage-i18n="setup.restart.outage.web">
517
- <span class="rb-num">1</span>
518
- <span class="rb-title" data-i18n="setup.restart.web.title">restart pypilot_web</span>
519
- <span class="rb-sub" data-i18n="setup.restart.web.sub">~3-5 s &middot; AP keeps steering</span>
520
- </button>
539
+ <div class="restart-title" data-i18n="setup.restart.title">Restart / reboot</div>
540
+ <div class="restart-row two-cols">
521
541
  <button id="cli-restart-pypilot" class="restart-btn level-2" data-outage-i18n="setup.restart.outage.pypilot">
522
- <span class="rb-num">2</span>
523
542
  <span class="rb-title" data-i18n="setup.restart.pypilot.title">RESTART pypilot</span>
524
543
  <span class="rb-sub" data-i18n="setup.restart.pypilot.sub">~10-15 s &middot; AP drops the heading</span>
525
544
  </button>
526
545
  <button id="btn-reboot-pi" class="restart-btn level-3" data-preset="reboot.pi" data-outage-i18n="setup.restart.outage.reboot">
527
- <span class="rb-num">3</span>
528
546
  <span class="rb-title" data-i18n="setup.restart.reboot.title">reboot Pi</span>
529
547
  <span class="rb-sub" data-i18n="setup.restart.reboot.sub">~35-60 s &middot; full outage</span>
530
548
  </button>
@@ -532,7 +550,7 @@
532
550
  </div>
533
551
 
534
552
  <div id="cli-restart-status" class="hint" style="margin-top:8px"></div>
535
- <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>
536
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>
537
555
 
538
556
  <!-- Rev63 / 2.0.0: Debug console. Closed whitelist of preset
@@ -548,6 +566,10 @@
548
566
  <button class="ghost debug-preset" data-preset="uptime">uptime</button>
549
567
  <button class="ghost debug-preset" data-preset="df">df</button>
550
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>
551
573
  </div>
552
574
  <textarea id="debug-output" readonly rows="12"
553
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;"
@@ -918,6 +940,6 @@
918
940
  </div>
919
941
 
920
942
  <!-- Cache-busting: bump the ?v= on every Rev to force Firefox to fetch fresh app.js -->
921
- <script src="app.js?v=Rev65"></script>
943
+ <script src="app.js?v=Rev67"></script>
922
944
  </body>
923
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
@@ -1596,6 +1623,55 @@ main { touch-action: pan-y; overflow-x: hidden; }
1596
1623
  transition: left 180ms ease-out;
1597
1624
  pointer-events: none;
1598
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
+ }
1599
1675
  .swipe-hint-arrow,
1600
1676
  .swipe-hint .nav-toggle {
1601
1677
  position: relative; /* stack above the rudder track */