signalk-pypilot-newui 3.1.2 → 3.1.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,138 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.1.4 — 2026-10-14 — iOS "Add to Home Screen" PWA basics (issue #5)
4
+
5
+ Rev469. Patch bump: no code changes, just the HTML shell. Thanks to
6
+ [@eric-fitzsimon](https://github.com/eric-fitzsimon) for the detailed
7
+ report in [#5](https://github.com/Aitonos/signalk-pypilot-newui/issues/5):
8
+ on iOS, adding the webapp to the home screen worked the first launch,
9
+ then subsequent launches loaded the page but Tune gain sliders stayed
10
+ empty and Chart did not populate.
11
+
12
+ ### Added — PWA meta tags in the shell
13
+
14
+ ```html
15
+ <meta name="apple-mobile-web-app-capable" content="yes" />
16
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
17
+ <meta name="apple-mobile-web-app-title" content="PyPilot" />
18
+ <meta name="mobile-web-app-capable" content="yes" />
19
+ <meta name="theme-color" content="#0a1220" />
20
+ <link rel="apple-touch-icon" href="icon.svg" />
21
+ ```
22
+
23
+ Previously the shell had none of these, so iOS treated the home-screen
24
+ bookmark as a half-baked WebView bookmark with a storage context
25
+ distinct from Safari and no explicit standalone contract.
26
+
27
+ ### Fixed — stale `app.js` cached indefinitely by iOS WebView
28
+
29
+ Added `Cache-Control: no-cache, no-store, must-revalidate` + legacy
30
+ `Pragma` / `Expires` HTTP-equiv metas to the shell. iOS Safari (both
31
+ browser and standalone WebView) was caching the first successful load
32
+ of `app.js` and ignoring the `?v=RevNNN` query bump that `deploy.ps1`
33
+ writes on every deploy. Result: user's bookmark served a stale JS
34
+ bundle for days, mismatching the backend API and leaving features
35
+ unpopulated. These metas force the WebView to re-fetch the shell on
36
+ every launch; the actual `app.js?v=RevNNN` request then matches the
37
+ current backend.
38
+
39
+ See `docs/_audit_ios_pwa_issue_5.md` for the full forensics.
40
+
41
+ ### Known limitations
42
+
43
+ - `apple-touch-icon` points to `icon.svg`. iOS prefers PNG for the
44
+ home-screen icon; with SVG only, iOS may use a page-screenshot
45
+ fallback. A proper PNG set is TODO for a future release.
46
+ - No Service Worker. Rev-A path only — if the Cache-Control metas do
47
+ not fully fix the issue on Eric's device, Rev-B/C/D will add a
48
+ `visibilitychange` reconnect handler, a standalone-mode banner with
49
+ "Reset app data" button, and eventually a proper SW. Not shipped
50
+ yet because they are not confirmed necessary.
51
+
52
+ ## 3.1.3 — 2026-10-14 — Safety: offline-aware write gating + batched trace + AIS polish
53
+
54
+ Rolls up Rev464 → Rev468 (5 revs) on top of 3.1.2. Patch bump: no
55
+ breaking changes; set of safety-oriented fixes triggered by the
56
+ 2026-10-12 sea trial where the sailor "pasó miedo" when the 4G link
57
+ dropped repeatedly and the visor's controls were still clickable while
58
+ pypilot_web was silent.
59
+
60
+ ### Added
61
+
62
+ - **Auto-OFF harbor-mode toggle in the AIS overlay card** (Rev464).
63
+ The 2-min SOG≈0 auto-silencer can now be flipped off from the
64
+ overlay-card without opening the plugin config, so sailors can keep
65
+ testing AIS behaviour while moored.
66
+ - **AIS MMSI normalisation — root-cause fix for "no sale en posición
67
+ relativa"** (Rev463). `signalk-collision-alerts` publishes the full
68
+ URN as the path tail (`notifications.navigation.closestApproach.urn:mrn:imo:mmsi:XXXXX`);
69
+ our extractor used to return the URN verbatim, which was then
70
+ concatenated again when fetching the target's REST position → double
71
+ URN → 404. The hull fell back to `cpaPositions.target` (future CPA
72
+ position) instead of the real current position. Fixed by extracting
73
+ only the trailing numeric MMSI (`/(\d{6,})$/`). Hulls now paint at
74
+ the real bearing with the real COG, and the dashed track line
75
+ reflects reality.
76
+ - **Pypilot-offline bloqueante banner + write gate across the board**
77
+ (Rev465). When the plugin backend reports pypilot core offline, a
78
+ centred red banner blinks over everything and every write-side
79
+ control is simultaneously:
80
+ 1. Visually dimmed + `pointer-events: none` (CSS).
81
+ 2. Gated in JS: `pluginRaw()`, `apEngage()`, `apDisengage()`,
82
+ `apSetMode()`, `apSetTargetRad()`, `apTack()` all return
83
+ `{ok:false, blocked:true}` before firing any HTTP write.
84
+ No stale commands can be queued while the pilot is unreachable.
85
+ See `docs/_audit_sea_trial_2026-10-12_network.md`.
86
+ - **Batched maneuver-trace** (Rev468). New backend endpoint
87
+ `POST /plugins/signalk-pypilot-newui/maneuver-trace/events` accepts
88
+ `{events: [...]}`. The visor now queues events locally and flushes
89
+ every 2 s (or on 50-event cap, visibilitychange hidden, beforeunload)
90
+ instead of one POST per event. Active navigation went from
91
+ ~5-10 POST/s to ~0.5 POST/s on `maneuver-trace`; the sailor's 4G
92
+ link survives a lot longer under load.
93
+
94
+ ### Fixed
95
+
96
+ - **WS close → pypilot marked offline immediately** (Rev466). The
97
+ visor's SK WebSocket drop used to leave `pypilotHealthy` stale
98
+ until the next `/status` poll — the controls stayed enabled during
99
+ the gap. The `ws.addEventListener('close')` now flips the state and
100
+ activates the banner within milliseconds. `_pypilotWriteBlocked()`
101
+ also checks `ws.readyState !== OPEN` so a write cannot slip through
102
+ on a dead socket.
103
+ - **Setup-tab slider values no longer "tardan"** (Rev460). The
104
+ callback that applied the plugin's `/values` response to the Tune
105
+ sliders was missing after Rev459; sliders stayed on `---` until the
106
+ next render cycle. Now the response updates the slider in place.
107
+ - **Freeboard-SK embed stays mounted** (Rev461). Closing the embed
108
+ no longer blanks the `<iframe src>`; reopens are instant without a
109
+ full chart + tile reload.
110
+ - **Background refresh of plugin values every 10 s** (Rev461). The
111
+ initial boot loop stopped as soon as one AutopilotGain landed, so
112
+ the rest of the catalog filled in only when Setup was opened. A
113
+ low-rate background tick now keeps the cache warm, so Setup opens
114
+ pre-populated regardless of how long the sailor stayed on Control.
115
+
116
+ ### Changed
117
+
118
+ - **README — chart provider recommendation** now puts
119
+ [`signalk-charts-provider-simple`](https://github.com/dirkwa/signalk-charts-provider-simple)
120
+ (by @dirkwa, same maintainer as `signalk-collision-alerts`) as the
121
+ **recommended** pairing for the embedded Freeboard-SK, with
122
+ `@signalk/charts-plugin` kept as an alternative. Pointed by Carlos
123
+ as the simpler path for sailors new to Signal K charts.
124
+
125
+ ### Upstream coordination
126
+
127
+ - `hkapanen/ais-forwarder#14` — added impact comment: 11+ days without
128
+ MarineTraffic / VesselFinder presence on Tunatunes while the plugin
129
+ stays disabled to prevent the uncaughtException cascade. Posted a
130
+ concrete minimal fix (`socket.on('error', ...)` + send callback) and
131
+ offered a PR.
132
+ - `dirkwa/signalk-collision-alerts#17` — closed by maintainer with a
133
+ fix. Opened `#24` with proposal to suppress alerts when either
134
+ vessel is effectively stationary (SOG-aware threat check).
135
+
3
136
  ## 3.1.2 — 2026-10-10 — Compatibility with pypilot 0.71 (issue #3)
4
137
 
5
138
  Patch: swap socket.io transport order from
package/README.md CHANGED
@@ -29,9 +29,12 @@ The chartplotter embedded in the rose uses Freeboard-SK, which itself
29
29
  is only a renderer — it needs a Signal K **chart provider** plugin to
30
30
  know where to fetch tiles from. Any of the standard providers works:
31
31
 
32
+ - **[`signalk-charts-provider-simple`](https://github.com/dirkwa/signalk-charts-provider-simple)**
33
+ by @dirkwa — a simple provider that serves local chart tilesets.
34
+ **Recommended** for Freeboard-SK embedded inside pypilot-newui.
32
35
  - **[`@signalk/charts-plugin`](https://www.npmjs.com/package/@signalk/charts-plugin)**
33
- — serves local MBTiles / S57 / XYZ chart sets you drop into
34
- `~/.signalk/charts/` (OpenCPN-style). Recommended default.
36
+ — the official Signal K provider; serves local MBTiles / S57 / XYZ
37
+ chart sets you drop into `~/.signalk/charts/` (OpenCPN-style).
35
38
  - **[`signalk-chart-tiler`](https://www.npmjs.com/package/signalk-chart-tiler)**
36
39
  or any other upstream raster/vector provider.
37
40
 
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ const trip_recorder_1 = require("./trip-recorder");
28
28
  const maneuver_trace_1 = require("./maneuver-trace");
29
29
  // Rev counter bumped on every build so the user can distinguish deploys
30
30
  // from the webapp header (feedback_revision_bump_each_build).
31
- const PLUGIN_REVISION = "Rev450";
31
+ const PLUGIN_REVISION = "Rev469";
32
32
  // Rev59: read package.json once at load time so /status can report the
33
33
  // npm package version alongside the internal Rev counter.
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -2971,17 +2971,17 @@ module.exports = function (app) {
2971
2971
  }
2972
2972
  res.json({ ok: true, pseudoActive: _maneuverPseudoActive });
2973
2973
  });
2974
+ const validKinds = [
2975
+ "aproado_start", "aproado_pick", "aproado_teardown",
2976
+ "empopado_start", "empopado_pick", "empopado_teardown",
2977
+ "tack_tap", "tack_cancel",
2978
+ "mode_change", "target_put", "engage", "disengage", "nudge", "other",
2979
+ ];
2974
2980
  router.post("/maneuver-trace/event", (req, res) => {
2975
2981
  if (!maneuverTrace)
2976
2982
  return res.status(503).json({ error: "not initialised" });
2977
2983
  const body = req.body || {};
2978
2984
  const kind = String(body.kind || "").trim();
2979
- const validKinds = [
2980
- "aproado_start", "aproado_pick", "aproado_teardown",
2981
- "empopado_start", "empopado_pick", "empopado_teardown",
2982
- "tack_tap", "tack_cancel",
2983
- "mode_change", "target_put", "engage", "disengage", "nudge", "other",
2984
- ];
2985
2985
  if (!validKinds.includes(kind)) {
2986
2986
  return res.status(400).json({ error: "unknown kind", kind });
2987
2987
  }
@@ -2991,6 +2991,37 @@ module.exports = function (app) {
2991
2991
  const entry = maneuverTrace.record({ kind, payload, visorRev }, pre, () => _captureManeuverContext());
2992
2992
  res.json({ ok: true, ts: entry.ts, kind: entry.kind });
2993
2993
  });
2994
+ // Rev468 (Carlos safety audit 2026-10-12): batch endpoint. The
2995
+ // single-event endpoint above was generating hundreds of POSTs
2996
+ // per minute during active navigation, saturating the sailor's
2997
+ // flaky 4G link. This endpoint accepts {events: [{kind, payload,
2998
+ // visorRev}, ...]} and records them in order. Malformed events
2999
+ // are skipped silently (never block the batch). The visor side
3000
+ // (public/app.js _mtLog + _mtFlush) encola localmente y envía
3001
+ // cada 2 s en vez de uno a uno — ~20x menos HTTP overhead en
3002
+ // navegación con muchos deltas.
3003
+ router.post("/maneuver-trace/events", (req, res) => {
3004
+ if (!maneuverTrace)
3005
+ return res.status(503).json({ error: "not initialised" });
3006
+ const body = req.body || {};
3007
+ const events = Array.isArray(body.events) ? body.events : null;
3008
+ if (!events)
3009
+ return res.status(400).json({ error: "events must be an array" });
3010
+ let count = 0, skipped = 0;
3011
+ for (const ev of events) {
3012
+ const kind = String(ev && ev.kind || "").trim();
3013
+ if (!validKinds.includes(kind)) {
3014
+ skipped++;
3015
+ continue;
3016
+ }
3017
+ const payload = (ev.payload && typeof ev.payload === "object") ? ev.payload : {};
3018
+ const visorRev = typeof ev.visorRev === "string" ? ev.visorRev : undefined;
3019
+ const pre = _captureManeuverContext();
3020
+ maneuverTrace.record({ kind, payload, visorRev }, pre, () => _captureManeuverContext());
3021
+ count++;
3022
+ }
3023
+ res.json({ ok: true, count, skipped });
3024
+ });
2994
3025
  // Rev176 (Carlos): manual purge endpoint - lets the user clear
2995
3026
  // legacy short-session noise on demand from Setup.
2996
3027
  router.post("/session-recorder/purge-short", (_req, res) => {
@@ -210,9 +210,23 @@ class TripRecorder {
210
210
  else
211
211
  this.downwindN += 1;
212
212
  this.polN += 1;
213
- // Sustained sign-flip = tack. Require >= 8 s since last flip so
214
- // choppy AWA does not inflate the counter.
215
- const sgn = s.awa > 0.05 ? 1 : (s.awa < -0.05 ? -1 : 0);
213
+ // Rev453 (Carlos sea trial 2026-10-10): "cualquier modificación de
214
+ // rumbo queda grabada como tack. Solo hay que marcar tacks que son
215
+ // verdaderamente tacks." The old threshold of 0.05 rad (2.8°) was
216
+ // way too permissive — a boat on a run can oscillate ±5° with
217
+ // every wave and each flip past 8s was counted. A gybe (|AWA|>90°)
218
+ // was also indistinguishable from a tack. Fix: only consider the
219
+ // boat "upwind-tacked" when |AWA| sits inside the close-hauled /
220
+ // tight-reach band on BOTH sides of the flip:
221
+ // 15° <= |AWA| <= 70° → sign is meaningful (we are sailing a
222
+ // tack on this side).
223
+ // Everything else (|AWA|<15° drift at head-to-wind, |AWA|>70° reach
224
+ // or run) gets sgn=0 and does not drive the lastAwaSign history —
225
+ // so a wave in a dead run or a nudge at head-to-wind is a no-op.
226
+ const absAwaDeg = Math.abs(s.awa * RAD2DEG);
227
+ const sgn = (absAwaDeg >= 15 && absAwaDeg <= 70)
228
+ ? (s.awa > 0 ? 1 : -1)
229
+ : 0;
216
230
  if (sgn !== 0 && this.lastAwaSign !== 0 && sgn !== this.lastAwaSign
217
231
  && s.ts - this.lastTackSignFlipTs > 8000) {
218
232
  this.tackCount += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with first-class Signal K paths and Simple Switches so any downstream Signal K client can drive or monitor the pilot. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings). Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
package/public/app.js CHANGED
@@ -585,9 +585,13 @@
585
585
  "dash.overlays.title": "ROSE OVERLAYS",
586
586
  "dash.overlays.hint": "Toggle what floats on the compass rose. Each card lists what data it needs to render.",
587
587
  "dash.overlay.ais.title": "AIS collision alarm",
588
+ "pypilot.offline.title": "AUTOPILOT NOT REACHABLE",
589
+ "pypilot.offline.hint": "Controls disabled. The autopilot keeps its last course. Check the connection to the pypilot Pi.",
588
590
  "dash.overlay.ais.needs": "Needs signalk-collision-alerts plugin installed in your SK server. Reads notifications.navigation.closestApproach.* and renders a red/orange triangle on the rose with an infobox + ACK. Double-tap the rose to open Freeboard-SK embedded.",
589
591
  "dash.overlay.ais.config": "Open plugin config",
590
592
  "dash.overlay.ais.voice": "Voice alert",
593
+ "dash.overlay.ais.harborAuto": "Auto-OFF when moored (SOG=0 for 2 min)",
594
+ "dash.overlay.ais.harbor": "Marina mode (auto-silence when moored)",
591
595
  "ais.objetivo": "target",
592
596
  "ais.voice.alarm": "AIS alarm",
593
597
  "ais.voice.warn": "AIS warning",
@@ -1465,9 +1469,13 @@
1465
1469
  "dash.overlays.title": "CAPAS SOBRE LA ROSA",
1466
1470
  "dash.overlays.hint": "Activa las que quieras ver sobre la rosa. Cada tarjeta indica qué datos necesita.",
1467
1471
  "dash.overlay.ais.title": "Alarma de colisión AIS",
1472
+ "pypilot.offline.title": "SIN CONEXIÓN AL AUTOPILOTO",
1473
+ "pypilot.offline.hint": "Los comandos están deshabilitados. El autopilot sigue en su último rumbo. Comprueba la conexión al Pi del pypilot.",
1468
1474
  "dash.overlay.ais.needs": "Requiere el plugin signalk-collision-alerts instalado en tu servidor SK. Lee notifications.navigation.closestApproach.* y pinta un triángulo rojo/naranja sobre la rosa con infobox + ACK. Doble tap en la rosa abre Freeboard-SK embebido.",
1469
1475
  "dash.overlay.ais.config": "Abrir ajustes del plugin",
1470
1476
  "dash.overlay.ais.voice": "Aviso de voz",
1477
+ "dash.overlay.ais.harborAuto": "Auto-OFF al estar amarrado (SOG=0 durante 2 min)",
1478
+ "dash.overlay.ais.harbor": "Modo marina (silencia al estar amarrados)",
1471
1479
  "ais.objetivo": "objetivo",
1472
1480
  "ais.voice.alarm": "Alarma AIS",
1473
1481
  "ais.voice.warn": "Aviso AIS",
@@ -2537,6 +2545,19 @@
2537
2545
  try { handleDelta(JSON.parse(evt.data)); } catch { /* ignore */ }
2538
2546
  });
2539
2547
  ws.addEventListener("close", () => {
2548
+ // Rev466 (Carlos safety 2026-10-12 trip): mark pypilot offline
2549
+ // immediately when the WS drops, without waiting for the next
2550
+ // /status poll (which may be seconds away). This activates the
2551
+ // banner + write gate instantly so the sailor cannot blindly
2552
+ // press controls during the gap. On successful reconnect the
2553
+ // next /status poll will set pypilotHealthy back to true.
2554
+ try {
2555
+ if (state && state.pypilotHealthy !== false) {
2556
+ state.pypilotHealthy = false;
2557
+ document.body.classList.add("pypilot-offline");
2558
+ console.warn("[pypilot-newui] WS closed → pypilot marked offline (banner activated)");
2559
+ }
2560
+ } catch { /* silent */ }
2540
2561
  setTimeout(connectSK, 2500);
2541
2562
  });
2542
2563
  ws.addEventListener("error", () => { try { ws.close(); } catch {} });
@@ -3119,7 +3140,8 @@
3119
3140
  if (state.windSpeedTrue == null) state.windSpeedTrue = numericOrNull(value);
3120
3141
  break;
3121
3142
  case "navigation.speedOverGround":
3122
- state.sog = numericOrNull(value); renderCogAndCurrent(); renderRoseBottomChip(); break;
3143
+ state.sog = numericOrNull(value); renderCogAndCurrent(); renderRoseBottomChip();
3144
+ try { _aisHarborTick(); } catch { /* silent */ } break;
3123
3145
  case "navigation.courseOverGroundTrue":
3124
3146
  state.cog = numericOrNull(value); renderCogAndCurrent(); break;
3125
3147
  case "navigation.speedThroughWater":
@@ -3882,16 +3904,22 @@
3882
3904
  const cur = state.aisTargetPos.get(mmsi);
3883
3905
  if (cur && (now - cur.fetchedAt) < AIS_TARGET_POS_FRESH_MS) return cur;
3884
3906
  const ctx = `urn:mrn:imo:mmsi:${mmsi}`;
3885
- const [posR, sogR] = await Promise.all([
3907
+ const [posR, sogR, cogR] = await Promise.all([
3886
3908
  skFetch(`/signalk/v1/api/vessels/${encodeURIComponent(ctx)}/navigation/position/value`),
3887
3909
  skFetch(`/signalk/v1/api/vessels/${encodeURIComponent(ctx)}/navigation/speedOverGround/value`).catch(() => null),
3910
+ // Rev457 (Carlos QA Rev456): fetch the target's true COG so the
3911
+ // hull can face its actual course + draw a projected track
3912
+ // line out of its bow instead of pointing at us.
3913
+ skFetch(`/signalk/v1/api/vessels/${encodeURIComponent(ctx)}/navigation/courseOverGroundTrue/value`).catch(() => null),
3888
3914
  ]);
3889
- let pos = null, sog = null;
3915
+ let pos = null, sog = null, cog = null;
3890
3916
  if (posR && posR.ok) pos = await posR.json().catch(() => null);
3891
3917
  if (sogR && sogR.ok) sog = await sogR.json().catch(() => null);
3918
+ if (cogR && cogR.ok) cog = await cogR.json().catch(() => null);
3892
3919
  const rec = {
3893
3920
  pos: (pos && typeof pos.latitude === "number") ? pos : null,
3894
3921
  sog: (typeof sog === "number") ? sog : null,
3922
+ cog: (typeof cog === "number") ? cog : null, // radians (SK convention)
3895
3923
  fetchedAt: now,
3896
3924
  };
3897
3925
  state.aisTargetPos.set(mmsi, rec);
@@ -3899,24 +3927,53 @@
3899
3927
  return rec;
3900
3928
  } catch { return null; }
3901
3929
  }
3902
- // Rev434 (Carlos QA Rev433): toggles for the AIS overlay. Both default
3903
- // ON so the feature works out of the box; sailor can disable from the
3904
- // "AIS collision alarm" overlay-card in the rose overlays modal.
3905
- const AIS_ALARM_LS_KEY = "pypilotnewui.aisAlarm";
3930
+ // Rev456 (Carlos QA Rev455, 2026-10-11): the single on/off toggle
3931
+ // (Rev434) + the auto "harbor mode" (Rev451/454) are replaced by a
3932
+ // 3-state selector the sailor flips manually in the AIS overlay-card:
3933
+ // "off" → PUERTO: never pint, never speak, never pop up.
3934
+ // "en puerto si alguien llega nos dará un golpe sí o sí
3935
+ // porque el 99,99 % no estaremos en el barco" (Carlos).
3936
+ // "anchor" → FONDEO: alarms ONLY for targets moving at or above
3937
+ // AIS_ANCHOR_MIN_TARGET_SOG_MS. A stopped neighbour is
3938
+ // not a collision threat when WE are stopped either.
3939
+ // "underway" → NAVEGACIÓN: original behaviour, every active alarm
3940
+ // goes through (minus the long-standing stationary
3941
+ // filter at AIS_IGNORE_SOG_MS = 0.08 m/s).
3942
+ const AIS_MODE_LS_KEY = "pypilotnewui.aisMode";
3943
+ const AIS_ALARM_LS_KEY = "pypilotnewui.aisAlarm"; // legacy; migrated
3944
+ const AIS_HARBOR_LS_KEY = "pypilotnewui.aisHarborAuto"; // legacy; ignored
3906
3945
  const AIS_ALARM_VOICE_LS_KEY = "pypilotnewui.aisAlarmVoice";
3907
- function _aisAlarmEnabled() {
3946
+ const AIS_ANCHOR_MIN_TARGET_SOG_MS = 1.0; // ~ 2 kn
3947
+ const AIS_MODES = ["off", "anchor", "underway"];
3948
+ function _aisModeStored() {
3908
3949
  try {
3909
- const v = localStorage.getItem(AIS_ALARM_LS_KEY);
3910
- return v == null ? true : v === "true";
3911
- } catch { return true; }
3950
+ const v = localStorage.getItem(AIS_MODE_LS_KEY);
3951
+ if (AIS_MODES.includes(v)) return v;
3952
+ const legacy = localStorage.getItem(AIS_ALARM_LS_KEY);
3953
+ if (legacy === "false") return "off";
3954
+ return "underway";
3955
+ } catch { return "underway"; }
3956
+ }
3957
+ // Rev462 (Carlos QA Rev461, 2026-10-11): "en puerto siempre OFF".
3958
+ // The stored mode is the sailor's manual preference; but when WE are
3959
+ // moored (SOG=0 for ≥ AIS_HARBOR_ENTER_HOLD_MS, i.e. 2 min) we
3960
+ // override to "off" automatically — Carlos's rule for marina: no
3961
+ // alarms at all, period. The auto only forces LESS-sensitive than the
3962
+ // sailor's stored choice (never pushes from off/anchor up to underway).
3963
+ function _aisMode() {
3964
+ const stored = _aisModeStored();
3965
+ if (state._aisHarborActive && stored !== "off") return "off";
3966
+ return stored;
3967
+ }
3968
+ function _aisSetMode(mode) {
3969
+ if (!AIS_MODES.includes(mode)) return;
3970
+ try { localStorage.setItem(AIS_MODE_LS_KEY, mode); } catch { /* silent */ }
3971
+ // Switching mode clears any stale hull on the rose; the next delta
3972
+ // will repopulate under the new rules.
3973
+ try { state.aisAlarms.clear(); renderAisAlarms(); } catch { /* silent */ }
3912
3974
  }
3913
- function _aisAlarmSetEnabled(on) {
3914
- try { localStorage.setItem(AIS_ALARM_LS_KEY, on ? "true" : "false"); } catch { /* silent */ }
3915
- // Clear everything when disabled, so a stale triangle does not
3916
- // linger on the rose until the next delta.
3917
- if (!on) {
3918
- try { state.aisAlarms.clear(); renderAisAlarms(); } catch { /* silent */ }
3919
- }
3975
+ function _aisAlarmEnabled() {
3976
+ return _aisMode() !== "off";
3920
3977
  }
3921
3978
  function _aisAlarmVoiceEnabled() {
3922
3979
  try {
@@ -3927,9 +3984,92 @@
3927
3984
  function _aisAlarmSetVoiceEnabled(on) {
3928
3985
  try { localStorage.setItem(AIS_ALARM_VOICE_LS_KEY, on ? "true" : "false"); } catch { /* silent */ }
3929
3986
  }
3987
+ // Rev451 (Carlos sea trial 2026-10-10): "en Marina sigue siendo una
3988
+ // locura los avisos de AIS". When our own SOG is near zero for a
3989
+ // sustained window, treat it as "we are moored / inside a marina"
3990
+ // and auto-silence AIS alarms. Resumes automatically when we start
3991
+ // moving again. Opt-in default ON; toggled from the AIS overlay-card.
3992
+ const AIS_HARBOR_ENTER_SOG_MS = 0.30; // < 0.6 kn
3993
+ const AIS_HARBOR_EXIT_SOG_MS = 0.60; // > 1.2 kn
3994
+ const AIS_HARBOR_ENTER_HOLD_MS = 120_000;
3995
+ const AIS_HARBOR_EXIT_HOLD_MS = 30_000;
3996
+ // Rev454 (Carlos QA 2026-10-11, PIRATA DE ONZA at 10 kn silenced in
3997
+ // marina): even in harbor mode let threats from targets travelling
3998
+ // at or above this SOG through — a fast approaching vessel is a
3999
+ // genuine danger regardless of whether we are moored.
4000
+ const AIS_HARBOR_PASS_TARGET_SOG_MS = 1.0; // ~ 2 kn
4001
+ state._aisHarborActive = false;
4002
+ state._aisHarborSlowSince = null;
4003
+ state._aisHarborFastSince = null;
4004
+ function _aisHarborAutoEnabled() {
4005
+ // Rev464 (Carlos QA Rev463, 2026-10-11): re-add the ability to turn
4006
+ // the SOG-based auto-silencer OFF so we can keep testing the AIS
4007
+ // overlay while physically moored. Default ON preserves Rev462
4008
+ // behaviour; sailor flips the checkbox in the AIS overlay-card to
4009
+ // let alarms through even when SOG=0.
4010
+ try {
4011
+ const v = localStorage.getItem(AIS_HARBOR_LS_KEY);
4012
+ return v == null ? true : v === "true";
4013
+ } catch { return true; }
4014
+ }
4015
+ function _aisHarborSetAutoEnabled(on) {
4016
+ try { localStorage.setItem(AIS_HARBOR_LS_KEY, on ? "true" : "false"); } catch { /* silent */ }
4017
+ if (!on) {
4018
+ state._aisHarborActive = false;
4019
+ state._aisHarborSlowSince = null;
4020
+ state._aisHarborFastSince = null;
4021
+ }
4022
+ }
4023
+ // Called by the SOG delta handler. Updates the harbor-mode machine.
4024
+ function _aisHarborTick() {
4025
+ if (!_aisHarborAutoEnabled()) return;
4026
+ const sog = (typeof state.sog === "number") ? state.sog : null;
4027
+ if (sog == null) return;
4028
+ const now = Date.now();
4029
+ if (!state._aisHarborActive) {
4030
+ if (sog < AIS_HARBOR_ENTER_SOG_MS) {
4031
+ if (!state._aisHarborSlowSince) state._aisHarborSlowSince = now;
4032
+ if (now - state._aisHarborSlowSince >= AIS_HARBOR_ENTER_HOLD_MS) {
4033
+ state._aisHarborActive = true;
4034
+ state._aisHarborFastSince = null;
4035
+ console.info("[ais] harbor mode ON — silencing AIS alarms (SOG near zero)");
4036
+ try { state.aisAlarms.clear(); renderAisAlarms(); } catch { /* silent */ }
4037
+ }
4038
+ } else {
4039
+ state._aisHarborSlowSince = null;
4040
+ }
4041
+ } else {
4042
+ if (sog > AIS_HARBOR_EXIT_SOG_MS) {
4043
+ if (!state._aisHarborFastSince) state._aisHarborFastSince = now;
4044
+ if (now - state._aisHarborFastSince >= AIS_HARBOR_EXIT_HOLD_MS) {
4045
+ state._aisHarborActive = false;
4046
+ state._aisHarborSlowSince = null;
4047
+ console.info("[ais] harbor mode OFF — resuming AIS alarms (underway)");
4048
+ }
4049
+ } else {
4050
+ state._aisHarborFastSince = null;
4051
+ }
4052
+ }
4053
+ }
3930
4054
  function _mmsiFromAlarmPath(path) {
4055
+ // Rev463 (Carlos QA Rev462, 2026-10-11, forensics from DevTools log):
4056
+ // signalk-collision-alerts publishes the FULL URN as the last path
4057
+ // segment, so path =
4058
+ // "notifications.navigation.closestApproach.urn:mrn:imo:mmsi:224261460"
4059
+ // not just "...224261460". The old `path.slice(prefix.length)` yielded
4060
+ // "urn:mrn:imo:mmsi:224261460", which then prefixed itself again when
4061
+ // _aisRefreshTargetPos built `urn:mrn:imo:mmsi:${mmsi}` → the REST
4062
+ // fetches came back 404 (double URN), the pos/sog/cog cache stayed
4063
+ // empty, and the bearing calc fell back to `cpaPositions.target` —
4064
+ // the FUTURE position at CPA. That is why the hull showed up "at the
4065
+ // meeting point" instead of "where the target is now". Normalise to
4066
+ // the pure numeric MMSI so the REST call is clean regardless of
4067
+ // which path shape the upstream plugin emits now or later.
3931
4068
  const prefix = "notifications.navigation.closestApproach.";
3932
- return path.startsWith(prefix) ? path.slice(prefix.length) : null;
4069
+ if (!path.startsWith(prefix)) return null;
4070
+ const tail = path.slice(prefix.length);
4071
+ const m = /(\d{6,})$/.exec(tail);
4072
+ return m ? m[1] : tail;
3933
4073
  }
3934
4074
  function _mmsiFromAlarmValue(v) {
3935
4075
  try {
@@ -3985,19 +4125,45 @@
3985
4125
  // declares the threat gone (!isActive) or a stationary target
3986
4126
  // drops the entry. The ACK state is a per-render visual, not a
3987
4127
  // reason to remove the target from the rose.
3988
- if (!value || !isActive || targetStationary) {
4128
+ // Rev451 → Rev454: harbor mode silences AIS alarms when WE are
4129
+ // moored, but only from OTHER slow / moored vessels. A fast
4130
+ // approaching target (SOG >= AIS_HARBOR_PASS_TARGET_SOG_MS, i.e.
4131
+ // >= 2 kn) still gets through — it is a real collision threat even
4132
+ // when we are stopped (Carlos QA 2026-10-11, PIRATA DE ONZA at
4133
+ // 10 kn going straight at the dock). This keeps the "marina
4134
+ // avalanche" of parked boats silenced without blinding us to
4135
+ // genuine danger.
4136
+ // Rev456 (Carlos 2026-10-11): mode-driven gate replaces harbor-auto.
4137
+ // PUERTO mode "off" : always silence.
4138
+ // FONDEO mode "anchor" : silence SLOW / MOORED targets only;
4139
+ // moving targets (SOG >= 2 kn) still
4140
+ // get through — the real danger when
4141
+ // we are at anchor.
4142
+ // NAVEGACION mode "underway" : regular behaviour; only the
4143
+ // long-standing stationary filter
4144
+ // (SOG < 0.08 m/s) drops entries.
4145
+ const aisMode = _aisMode();
4146
+ const targetMoving = cached && typeof cached.sog === "number"
4147
+ && cached.sog >= AIS_ANCHOR_MIN_TARGET_SOG_MS;
4148
+ let modeSilenced = false;
4149
+ if (aisMode === "off") {
4150
+ modeSilenced = true;
4151
+ } else if (aisMode === "anchor" && !targetMoving) {
4152
+ modeSilenced = true;
4153
+ }
4154
+ if (modeSilenced) {
4155
+ state.aisAlarms.delete(mmsi);
4156
+ } else if (!value || !isActive || targetStationary) {
3989
4157
  state.aisAlarms.delete(mmsi);
3990
4158
  } else {
3991
4159
  state.aisAlarms.set(mmsi, { value, mmsi });
3992
- // Fire a background refresh of the target's current position and
3993
- // SOG so the bearing render uses "where it is now" instead of
3994
- // cpaPositions.target (= future position at CPA time).
3995
4160
  _aisRefreshTargetPos(mmsi).catch(() => {});
3996
4161
  }
3997
4162
  try {
3998
4163
  if (enabled && _aisAlarmVoiceEnabled()
3999
4164
  && isActive && newState !== prevState
4000
4165
  && !locallyAcked
4166
+ && !modeSilenced
4001
4167
  && !(value.status && value.status.acknowledged)) {
4002
4168
  _alSpeak(_aisVoiceMessage(value, mmsi, newState));
4003
4169
  }
@@ -4055,9 +4221,15 @@
4055
4221
  // The infobox nav still walks ALL alarms so the sailor can scroll
4056
4222
  // through hidden ones.
4057
4223
  const topN = alarms.slice(0, 3);
4058
- // Draw one small red boat per active threat. r = 92 lands on the
4059
- // outer edge of the compass circle (SVG viewBox -100 -110 200 220).
4224
+ // Rev455 (Carlos QA Rev454): the hull used to be clamped to the
4225
+ // compass circle (R=92) regardless of real distance, giving no
4226
+ // sense of "cerca vs lejos". Now the radial position reflects the
4227
+ // actual distance to the target, linearly up to AIS_RADIAL_MAX_NM
4228
+ // (further out it saturates at the ring). A dashed line from the
4229
+ // self (center) to the hull makes the collision bearing explicit.
4060
4230
  const R = 92;
4231
+ const AIS_RADIAL_MAX_NM = 2.0;
4232
+ const AIS_RADIAL_MIN_FRAC = 0.18; // avoid overlapping with own boat
4061
4233
  for (const a of topN) {
4062
4234
  const d = a.value.data || {};
4063
4235
  // Rev436 (Carlos QA Rev435): use NOW positions, not cpaPositions
@@ -4084,9 +4256,42 @@
4084
4256
  const tgtPos = (cached && cached.pos) || d.cpaPositions?.target;
4085
4257
  const relDeg = _aisRelativeBearingDeg(selfPos, tgtPos, state.heading);
4086
4258
  if (relDeg == null) continue;
4259
+ // Rev462 (Carlos QA 2026-10-11, "no sale en la posición relativa"):
4260
+ // one-shot log per render so Carlos can confirm the geometry the
4261
+ // visor sees vs what the chart shows. Capped at 1 Hz per mmsi.
4262
+ try {
4263
+ const now = Date.now();
4264
+ state._aisDebugTs = state._aisDebugTs || new Map();
4265
+ const last = state._aisDebugTs.get(a.mmsi) || 0;
4266
+ if (now - last > 1000) {
4267
+ state._aisDebugTs.set(a.mmsi, now);
4268
+ const h = typeof state.heading === "number" ? (state.heading * RAD2DEG).toFixed(1) : "?";
4269
+ console.info(
4270
+ `[ais pos] mmsi=${a.mmsi}`,
4271
+ `self=${selfPos && selfPos.latitude ? selfPos.latitude.toFixed(5)+","+selfPos.longitude.toFixed(5) : "?"}`,
4272
+ `tgt=${tgtPos && tgtPos.latitude ? tgtPos.latitude.toFixed(5)+","+tgtPos.longitude.toFixed(5) : "?"}`,
4273
+ `hdg=${h}° relDeg=${relDeg.toFixed(1)}°`,
4274
+ `cachedCog=${cached && typeof cached.cog === "number" ? (cached.cog * RAD2DEG).toFixed(1)+"°" : "?"}`,
4275
+ `cachedSog=${cached && typeof cached.sog === "number" ? (cached.sog * 1.94384).toFixed(1)+"kn" : "?"}`,
4276
+ );
4277
+ }
4278
+ } catch { /* silent */ }
4279
+ // Rev455: radial position reflects REAL distance from self to
4280
+ // target, saturating at the compass ring beyond AIS_RADIAL_MAX_NM.
4281
+ // _distanceNm uses short-key {lat, lon} shape — adapt both sides.
4282
+ let rPx = R;
4283
+ if (selfPos && tgtPos
4284
+ && typeof selfPos.latitude === "number" && typeof tgtPos.latitude === "number") {
4285
+ const distNm = _distanceNm(
4286
+ { lat: selfPos.latitude, lon: selfPos.longitude },
4287
+ { lat: tgtPos.latitude, lon: tgtPos.longitude },
4288
+ );
4289
+ const frac = Math.min(1, Math.max(AIS_RADIAL_MIN_FRAC, distNm / AIS_RADIAL_MAX_NM));
4290
+ rPx = R * frac;
4291
+ }
4087
4292
  const rad = (relDeg - 90) * Math.PI / 180; // SVG 0deg = east, we want 0deg = up
4088
- const x = R * Math.cos(rad);
4089
- const y = R * Math.sin(rad);
4293
+ const x = rPx * Math.cos(rad);
4294
+ const y = rPx * Math.sin(rad);
4090
4295
  // Pointing INWARD toward the self: rotate the triangle so its tip
4091
4296
  // points to (0,0). The triangle is drawn pointing up (0,-h), so
4092
4297
  // we rotate by relDeg + 180 to flip.
@@ -4105,17 +4310,29 @@
4105
4310
  // centred on the target position and the hull points toward
4106
4311
  // self (bow forward of the relative bearing).
4107
4312
  const scale = acked ? 1.0 : (a.value.state === "alarm" ? 1.5 : 1.0);
4108
- // Rev447 (Carlos QA Rev445): the triangle used in Rev441-442 had
4109
- // its tip at (0,-10) — "forward = -y local" — so `rotate(relDeg+180)`
4110
- // pointed the tip at self. The boat-cenital.svg hull has its BOW
4111
- // at +x local (the asset is drawn horizontal, bow to the right of
4112
- // the viewBox). The correct SVG rotation to point +x local at the
4113
- // origin from relative bearing B is `rotate(B + 90)`:
4114
- // B=0 (target to N): rotate 90° → bow faces +y global → origin
4115
- // B=90 (target to E): rotate 180° → bow faces -x global → origin
4116
- // B=180 (target to S): rotate 270° → bow faces -y global → origin
4313
+ // Rev457 (Carlos QA Rev455/456, 2026-10-11): the hull now faces
4314
+ // its REAL course (COG) relative to our heading, not toward us.
4315
+ // From the bow we draw the projected track as a dashed line, so
4316
+ // the sailor sees instantly "where this guy is going" instead of
4317
+ // just "where he is". When the COG is unknown (REST cache not
4318
+ // populated yet) we fall back to the collision bearing (points at
4319
+ // self) so the hull is still readable.
4320
+ const cogAbsRad = cached && typeof cached.cog === "number" ? cached.cog : null;
4321
+ let hullRotDeg;
4322
+ if (cogAbsRad != null && typeof state.heading === "number") {
4323
+ // COG relative to boat frame: cogAbsRad - heading (both rad).
4324
+ let cogRelDeg = (cogAbsRad - state.heading) * RAD2DEG;
4325
+ while (cogRelDeg > 180) cogRelDeg -= 360;
4326
+ while (cogRelDeg < -180) cogRelDeg += 360;
4327
+ // boat-cenital.svg draws bow at +x local; same formula as the
4328
+ // bearing rotation gives +x pointing at the "forward" direction.
4329
+ hullRotDeg = cogRelDeg + 90;
4330
+ } else {
4331
+ // Fallback: point at self (previous behaviour).
4332
+ hullRotDeg = relDeg + 90;
4333
+ }
4117
4334
  g.setAttribute("transform",
4118
- `translate(${x.toFixed(1)},${y.toFixed(1)}) rotate(${(relDeg + 90).toFixed(1)}) scale(${scale})`);
4335
+ `translate(${x.toFixed(1)},${y.toFixed(1)}) rotate(${hullRotDeg.toFixed(1)}) scale(${scale})`);
4119
4336
  const filterId = acked ? "ais-tint-acked"
4120
4337
  : (a.value.state === "alarm" ? "ais-tint-alarm" : "ais-tint-warn");
4121
4338
  // Base image: 28 × 9.3 units (viewBox 456×152 → aspect ≈ 3:1),
@@ -4130,6 +4347,24 @@
4130
4347
  img.setAttribute("filter", `url(#${filterId})`);
4131
4348
  img.setAttribute("preserveAspectRatio", "xMidYMid meet");
4132
4349
  g.appendChild(img);
4350
+ // Rev459 (Carlos QA Rev458, 2026-10-11): "proyección de puntos
4351
+ // desde su proa que atraviese la rosa para saber si el riesgo es
4352
+ // por proa, por popa o directo a nuestro casco". A long dashed
4353
+ // track line out of the bow (+x local, same axis as the hull
4354
+ // bow). The ais-alarm-layer is clip-path'd to the rose circle,
4355
+ // so the line visually ends at the ring regardless of how long
4356
+ // we draw it. We cancel scale on stroke-width so the alarm 1.5x
4357
+ // hull does not thicken its own line.
4358
+ const bowStart = 14; // half the hull width (28)
4359
+ const projLenLocal = 400 / scale; // guaranteed to cross the rose
4360
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
4361
+ line.setAttribute("class", `ais-track-line ais-${a.value.state}${acked ? " ais-acked" : ""}`);
4362
+ line.setAttribute("x1", String(bowStart));
4363
+ line.setAttribute("y1", "0");
4364
+ line.setAttribute("x2", String(bowStart + projLenLocal));
4365
+ line.setAttribute("y2", "0");
4366
+ line.setAttribute("stroke-width", String(1.4 / scale));
4367
+ g.appendChild(line);
4133
4368
  layer.appendChild(g);
4134
4369
  }
4135
4370
  // Rev432: infobox + ACK as a persistent HTML div.
@@ -5088,6 +5323,8 @@
5088
5323
  if (overlay && overlay.parentNode) overlay.parentNode.appendChild(overlay);
5089
5324
  _tackCountdownPhase = "executing";
5090
5325
  _tackCountdownExecuteStartTs = Date.now();
5326
+ // Rev453: arm the stats trigger flag — see _tackCountdownHide.
5327
+ _tackStats.firedThisCountdown = false;
5091
5328
  _tackCountdownEndTs = Date.now(); // no arming countdown
5092
5329
  // NOTE: NO _tackCountdownTimer / _tackCountdownTick. That timer
5093
5330
  // would have rate-of-turn fallback that closes prematurely and
@@ -5402,6 +5639,7 @@
5402
5639
  && (now - _lastTackStateSeenTs) > PYPILOT_TACK_SILENCE_MS
5403
5640
  && elapsed >= 15;
5404
5641
  if (arrived) {
5642
+ _tackStats.firedThisCountdown = true;
5405
5643
  try { _tackStatsOnTackCompleted(); } catch { /* silent */ }
5406
5644
  // Rev345: NO mandar state=none al éxito — el pilot puede seguir
5407
5645
  // asentando; el próximo pypilot's own transition tack.state →
@@ -5426,6 +5664,7 @@
5426
5664
  el._hideTimer = setTimeout(() => { el.classList.remove("show"); }, 6000);
5427
5665
  try { _alSpeak(t("tack.pypilotSilentSpeak") || "Pypilot sin respuesta"); } catch { /* silent */ }
5428
5666
  } catch { /* silent */ }
5667
+ _tackStats.firedThisCountdown = true;
5429
5668
  try { _tackStatsOnTackCancelled(); } catch { /* silent */ }
5430
5669
  // Try to tell pypilot to stop, best-effort (may fail if socket
5431
5670
  // is dead — that's the whole point).
@@ -5575,6 +5814,9 @@
5575
5814
  async function _tackCountdownToExecuting() {
5576
5815
  _tackCountdownPhase = "executing";
5577
5816
  _tackCountdownExecuteStartTs = Date.now();
5817
+ // Rev453: arm the stats trigger flag so _tackCountdownHide knows
5818
+ // whether a trigger fired during this countdown.
5819
+ _tackStats.firedThisCountdown = false;
5578
5820
  // Rev197 (LLM audit): arming just ended - NOW send the queued
5579
5821
  // heading_command write to the AP. If cancel fired during arming,
5580
5822
  // _tackPendingFinalTargetRad was cleared by _tackCountdownHide and
@@ -5641,6 +5883,17 @@
5641
5883
  // start of executing so no arming-phase noise leaks into progress.
5642
5884
  _tackRotationDoneDeg = 0;
5643
5885
  _tackLastSampleRad = null;
5886
+ // Rev452 (Carlos sea trial 2026-10-10, 17:37 bordo largo cancelled
5887
+ // falsely): _lastTackStateSeenTs tracks the last time pypilot
5888
+ // published an ap.tack.state delta. During normal navigation that
5889
+ // last tick can be MINUTES in the past (pypilot only publishes on
5890
+ // transitions). Without this reset the pypilot-silent watchdog
5891
+ // (PYPILOT_TACK_SILENCE_MS = 40 s) is born already expired, so a
5892
+ // long tack trips the "Pypilot sin respuesta: virada cancelada"
5893
+ // toast at elapsed=15 s even when pypilot is working perfectly.
5894
+ // Reset the clock at the start of the countdown so the 40 s window
5895
+ // is measured from the maneuver kick-off, not from history.
5896
+ _lastTackStateSeenTs = Date.now();
5644
5897
  const overlay = document.getElementById("tack-countdown-overlay");
5645
5898
  const phase = document.getElementById("tack-countdown-phase");
5646
5899
  const num = document.getElementById("tack-countdown-num");
@@ -5674,6 +5927,18 @@
5674
5927
  _closeExternalTackOverlay();
5675
5928
  return;
5676
5929
  }
5930
+ // Rev453 (Carlos QA 2026-10-10): if this countdown exits WITHOUT
5931
+ // having fired a stats trigger (arrived / silent / cancelled did
5932
+ // not run — e.g. compass tack where pypilot never publishes a
5933
+ // state transition and the user cancelled via overlay tap, or the
5934
+ // 120s timeout kicked in silently), fire a completed trigger as a
5935
+ // fallback so the stats HUD still shows. Idempotent: the flag
5936
+ // ensures a trigger that already ran does not fire twice.
5937
+ if (_tackStats.firedThisCountdown === false) {
5938
+ _tackStats.firedThisCountdown = true;
5939
+ try { _tackStatsOnTackCompleted(); } catch { /* silent */ }
5940
+ }
5941
+ _tackStats.firedThisCountdown = false;
5677
5942
  if (_tackCountdownTimer) { clearInterval(_tackCountdownTimer); _tackCountdownTimer = null; }
5678
5943
  _stopTackStepper(); // Rev214: real stop of long-arc stepper.
5679
5944
  _tackForceLongArcSign = 0;
@@ -5835,6 +6100,13 @@
5835
6100
  hideAt: 0,
5836
6101
  hudTimer: null,
5837
6102
  lastTackState: null,
6103
+ // Rev453 (Carlos QA 2026-10-10): tracks whether the current
6104
+ // countdown already fired a stats trigger (completed OR cancelled).
6105
+ // When the countdown hides without any trigger (e.g. compass tack
6106
+ // where pypilot never publishes a state transition and arrived /
6107
+ // silent did not fire), _tackCountdownHide fires completed as a
6108
+ // fallback so the HUD still appears.
6109
+ firedThisCountdown: false,
5838
6110
  };
5839
6111
  const TACK_STATS_MAX_MS = 120_000;
5840
6112
  // Rev371 (Carlos, 2026-09-30): reduced post-tack measurement timing.
@@ -7214,30 +7486,34 @@
7214
7486
  // Rev139 (Carlos): plain-language explanation right under the row.
7215
7487
  _attachGainHelp(row, shortName);
7216
7488
  cont.appendChild(row);
7217
- // If cache is empty (stream started after gain deltas were emitted),
7218
- // fetch the current value via REST so the slider position reflects
7219
- // reality on first paint.
7489
+ // Rev459 → Rev460 (Carlos QA 2026-10-11): "el plugin residente en
7490
+ // la Pi5 debe siempre tener todos los datos listos para dispararlos
7491
+ // al frontend, no el frontend pedirlos a la Pizero". The old path
7492
+ // did skFetch(/signalk/v1/api/vessels/self/<skPath>) which SK
7493
+ // served as 404 for pypilot gains (not published under
7494
+ // navigation/*). The plugin ALREADY aggregates every pypilot
7495
+ // value under /plugins/signalk-pypilot-newui/values (populated by
7496
+ // refreshPypilotValues on boot + after mode/profile changes).
7497
+ // Read from that cache; if it has not landed yet, trigger a
7498
+ // refresh AND apply the result when it arrives — Rev459 only
7499
+ // triggered without back-filling the slider, which is why the
7500
+ // sliders "tardaban desde que he abierto la pestaña".
7501
+ const applyToSlider = (pv) => {
7502
+ if (!(typeof pv === "number" && isFinite(pv))) return false;
7503
+ state.values[skPath] = pv;
7504
+ if (!row.classList.contains("active")) rng.value = String(pv);
7505
+ val.textContent = pv.toFixed(4);
7506
+ if (!_tuneBaselines.has(baselineKey)) {
7507
+ _tuneBaselines.set(baselineKey, pv);
7508
+ }
7509
+ return true;
7510
+ };
7220
7511
  if (cur == null) {
7221
- skFetch(`/signalk/v1/api/vessels/self/${skPath.replace(/\./g, "/")}`)
7222
- .then((r) => (r.ok ? r.json() : null))
7223
- .then((j) => {
7224
- if (!j) return;
7225
- const v = j.value ?? (j.values && j.values.value);
7226
- if (typeof v === "number") {
7227
- state.values[skPath] = v;
7228
- if (!row.classList.contains("active")) rng.value = String(v);
7229
- val.textContent = v.toFixed(4);
7230
- // Rev132: also capture the baseline once the async fetch
7231
- // arrives. The synchronous branch above only fires when
7232
- // the SK stream cached the gain BEFORE render; on first
7233
- // paint (Tune reset broken bug) that map was empty and
7234
- // the ↺ button had nothing to restore to.
7235
- if (!_tuneBaselines.has(baselineKey)) {
7236
- _tuneBaselines.set(baselineKey, v);
7237
- }
7238
- }
7239
- })
7240
- .catch(() => {});
7512
+ if (!applyToSlider(state.pypilotValues ? state.pypilotValues[g] : null)) {
7513
+ refreshPypilotValues()
7514
+ .then(() => applyToSlider(state.pypilotValues ? state.pypilotValues[g] : null))
7515
+ .catch(() => {});
7516
+ }
7241
7517
  }
7242
7518
  }
7243
7519
  }
@@ -7901,21 +8177,57 @@
7901
8177
  }
7902
8178
  return true;
7903
8179
  }
7904
- // Rev322 (Carlos, 2026-09-27): maneuver-trace helper. Fires
7905
- // fire-and-forget - a failure never blocks the actual AP command.
7906
- // The backend is a no-op unless the sailor turned the toggle on
7907
- // from the Setup card. Callers just have to name the kind and pass
7908
- // whatever payload is useful for post-trial forensics.
7909
- function _mtLog(kind, payload) {
8180
+ // Rev322 (Carlos, 2026-09-27) → Rev468 (safety audit 2026-10-12):
8181
+ // maneuver-trace helper. Fires fire-and-forget - failure never blocks
8182
+ // the actual AP command. Backend is a no-op unless the sailor turned
8183
+ // the toggle on from the Setup card.
8184
+ //
8185
+ // Rev468 batches events locally and flushes to the backend every
8186
+ // _MT_FLUSH_MS, instead of one POST per event (which saturated the
8187
+ // 4G link on the 2026-10-12 sea trial). On busy navigation we were
8188
+ // generating hundreds of POSTs per minute; batching gives ~20x less
8189
+ // HTTP overhead. The queue is capped at _MT_QUEUE_MAX to bound
8190
+ // memory + size of a batch; if a caller is bursty we flush as soon
8191
+ // as the cap is reached.
8192
+ const _MT_FLUSH_MS = 2000;
8193
+ const _MT_QUEUE_MAX = 50;
8194
+ let _mtQueue = [];
8195
+ let _mtFlushTimer = null;
8196
+ async function _mtFlush() {
8197
+ if (_mtQueue.length === 0) return;
8198
+ const batch = _mtQueue;
8199
+ _mtQueue = [];
7910
8200
  try {
7911
- const body = { kind, payload: payload || {}, visorRev: (window.PLUGIN_REV || null) };
7912
- skFetch(`/plugins/${PLUGIN_ID}/maneuver-trace/event`, {
8201
+ await skFetch(`/plugins/${PLUGIN_ID}/maneuver-trace/events`, {
7913
8202
  method: "POST",
7914
8203
  headers: { "Content-Type": "application/json" },
7915
- body: JSON.stringify(body),
7916
- }).catch(() => { /* silent - forensics is best-effort */ });
8204
+ body: JSON.stringify({ events: batch }),
8205
+ });
8206
+ } catch { /* silent - forensics is best-effort */ }
8207
+ }
8208
+ function _mtLog(kind, payload) {
8209
+ try {
8210
+ _mtQueue.push({ kind, payload: payload || {}, visorRev: (window.PLUGIN_REV || null) });
8211
+ if (_mtQueue.length >= _MT_QUEUE_MAX) {
8212
+ _mtFlush();
8213
+ return;
8214
+ }
8215
+ if (!_mtFlushTimer) {
8216
+ _mtFlushTimer = setTimeout(() => {
8217
+ _mtFlushTimer = null;
8218
+ _mtFlush();
8219
+ }, _MT_FLUSH_MS);
8220
+ }
7917
8221
  } catch { /* silent */ }
7918
8222
  }
8223
+ // Flush on tab hide so forensics survive if the user closes the tab
8224
+ // mid-maneuver (unload) or navigates away.
8225
+ try {
8226
+ document.addEventListener("visibilitychange", () => {
8227
+ if (document.visibilityState === "hidden") _mtFlush();
8228
+ });
8229
+ window.addEventListener("beforeunload", () => { _mtFlush(); });
8230
+ } catch { /* silent */ }
7919
8231
  // Rev323 (Carlos, 2026-09-27): tell the backend a visor-side
7920
8232
  // pseudo-modo is active/inactive. Backend uses this to silence the
7921
8233
  // heading-deviation family of alarms during Aproado / Empopado. In
@@ -7931,19 +8243,43 @@
7931
8243
  }).catch(() => { /* silent - alarm gate is best-effort */ });
7932
8244
  } catch { /* silent */ }
7933
8245
  }
8246
+ // Rev465 → Rev466 (Carlos safety audit 2026-10-12): belt-and-suspenders
8247
+ // gate for every write that reaches pypilot. Reasons to block:
8248
+ // 1. pypilot core reported offline by the plugin backend.
8249
+ // 2. The SK WebSocket is not OPEN (we have no confirmation that
8250
+ // the server is reachable at all, so even if the HTTP side still
8251
+ // answered, we would be flying blind without delta feedback).
8252
+ // In either case we abort BEFORE the HTTP POST, so the sailor's click
8253
+ // produces zero traffic and zero potential stale command.
8254
+ function _pypilotWriteBlocked(callerName) {
8255
+ const wsOk = typeof ws !== "undefined" && ws && ws.readyState === WebSocket.OPEN;
8256
+ const pypilotOk = !(state && state.pypilotHealthy === false);
8257
+ if (wsOk && pypilotOk) return false;
8258
+ const reason = !wsOk ? "ws_closed" : "pypilot_offline";
8259
+ try {
8260
+ console.warn(`[pypilot-newui] write blocked (${reason}): ${callerName}`);
8261
+ if (typeof _mtLog === "function") {
8262
+ _mtLog("other", { via: callerName, blocked: reason });
8263
+ }
8264
+ } catch { /* silent */ }
8265
+ return true;
8266
+ }
7934
8267
  async function apEngage() {
8268
+ if (_pypilotWriteBlocked("apEngage")) return { ok: false, blocked: true };
7935
8269
  if (!await ensureAutopilotId()) return;
7936
8270
  _mtLog("engage", { via: "apEngage" });
7937
8271
  const url = `/signalk/v2/api/vessels/self/autopilots/${state.autopilotId}/engage`;
7938
8272
  return handleAuth(await skFetch(url, { method: "POST" }));
7939
8273
  }
7940
8274
  async function apDisengage() {
8275
+ if (_pypilotWriteBlocked("apDisengage")) return { ok: false, blocked: true };
7941
8276
  if (!await ensureAutopilotId()) return;
7942
8277
  _mtLog("disengage", { via: "apDisengage" });
7943
8278
  const url = `/signalk/v2/api/vessels/self/autopilots/${state.autopilotId}/disengage`;
7944
8279
  return handleAuth(await skFetch(url, { method: "POST" }));
7945
8280
  }
7946
8281
  async function apSetMode(mode) {
8282
+ if (_pypilotWriteBlocked("apSetMode")) return { ok: false, blocked: true };
7947
8283
  if (!await ensureAutopilotId()) return;
7948
8284
  _mtLog("mode_change", { requested: mode });
7949
8285
  const url = `/signalk/v2/api/vessels/self/autopilots/${state.autopilotId}/mode`;
@@ -7987,6 +8323,7 @@
7987
8323
  return r;
7988
8324
  }
7989
8325
  async function apSetTargetRad(rad) {
8326
+ if (_pypilotWriteBlocked("apSetTargetRad")) return { ok: false, blocked: true };
7990
8327
  if (!await ensureAutopilotId()) return;
7991
8328
  // Rev415 (Carlos sea trial 2026-10-04, obs #1 "nudges silenciosos"):
7992
8329
  // enrich the target_put trace so a stale UI vs real-write mismatch
@@ -8018,6 +8355,7 @@
8018
8355
  return res;
8019
8356
  }
8020
8357
  async function apTack(direction) {
8358
+ if (_pypilotWriteBlocked("apTack")) return { ok: false, blocked: true };
8021
8359
  if (!await ensureAutopilotId()) return;
8022
8360
  _mtLog("tack_tap", { direction });
8023
8361
  // Rev346: stamp so the delta observer knows this tack originated
@@ -9021,6 +9359,12 @@
9021
9359
  // For pypilot-specific paths that the Autopilot API does NOT cover (gains,
9022
9360
  // profiles, calibration, tack detail), route through our own /raw endpoint.
9023
9361
  async function pluginRaw(name, value) {
9362
+ // Rev465/466 (Carlos safety audit 2026-10-12 trip): abort BEFORE
9363
+ // POST when pypilot offline OR WebSocket closed. See
9364
+ // _pypilotWriteBlocked for the full rationale.
9365
+ if (_pypilotWriteBlocked(`pluginRaw(${name})`)) {
9366
+ return { ok: false, blocked: true, reason: "pypilot_or_ws_down" };
9367
+ }
9024
9368
  // Rev344 (Carlos, 2026-09-28 port test): tack en modo wind viaja
9025
9369
  // por este endpoint (ap.tack.direction + ap.tack.state=begin), no
9026
9370
  // por apTack(). Como no emitía trace, en el forensics del sea trial
@@ -9963,7 +10307,13 @@
9963
10307
  fbBox.hidden = true;
9964
10308
  fbBox.classList.remove("maximized");
9965
10309
  if (fbMax) fbMax.textContent = "⤢";
9966
- fbIframe.src = "about:blank";
10310
+ // Rev461 (Carlos QA Rev460, 2026-10-11): keep the Freeboard-SK
10311
+ // iframe mounted after close so a reopen is instant. Previously
10312
+ // we set src = "about:blank" to release Freeboard's socket, but
10313
+ // the trade-off was that every reopen reloaded Freeboard from
10314
+ // scratch (slow: chart tiles + SK handshake + AIS redraw).
10315
+ // Keeping it mounted means Freeboard holds a websocket while
10316
+ // hidden; worth the few extra bytes on an always-on tablet.
9967
10317
  };
9968
10318
  const toggleMax = () => {
9969
10319
  if (!fbBox) return;
@@ -12352,16 +12702,26 @@
12352
12702
  // Rev434 (Carlos QA Rev433): wire the AIS collision alarm overlay
12353
12703
  // card. Toggle on/off, toggle voice, and a shortcut to open the
12354
12704
  // collision-alerts plugin config in the SK admin UI.
12355
- const aisCb = document.getElementById("dash-ais-alarm");
12356
- if (aisCb) {
12357
- aisCb.checked = _aisAlarmEnabled();
12358
- aisCb.addEventListener("change", () => _aisAlarmSetEnabled(aisCb.checked));
12705
+ // Rev456: 3-way mode selector replaces the single on/off checkbox.
12706
+ const curMode = _aisMode();
12707
+ for (const m of AIS_MODES) {
12708
+ const r = document.getElementById(`dash-ais-mode-${m}`);
12709
+ if (r) {
12710
+ r.checked = (m === curMode);
12711
+ r.addEventListener("change", () => { if (r.checked) _aisSetMode(m); });
12712
+ }
12359
12713
  }
12360
12714
  const aisVoiceCb = document.getElementById("dash-ais-alarm-voice");
12361
12715
  if (aisVoiceCb) {
12362
12716
  aisVoiceCb.checked = _aisAlarmVoiceEnabled();
12363
12717
  aisVoiceCb.addEventListener("change", () => _aisAlarmSetVoiceEnabled(aisVoiceCb.checked));
12364
12718
  }
12719
+ // Rev464: toggle for the SOG-based auto-silencer. Default ON.
12720
+ const aisHarborAutoCb = document.getElementById("dash-ais-alarm-harbor-auto");
12721
+ if (aisHarborAutoCb) {
12722
+ aisHarborAutoCb.checked = _aisHarborAutoEnabled();
12723
+ aisHarborAutoCb.addEventListener("change", () => _aisHarborSetAutoEnabled(aisHarborAutoCb.checked));
12724
+ }
12365
12725
  const aisCfg = document.getElementById("dash-ais-alarm-config");
12366
12726
  if (aisCfg) {
12367
12727
  aisCfg.addEventListener("click", (ev) => {
@@ -16704,6 +17064,17 @@
16704
17064
  const gotCatalog = state.catalog && Object.keys(state.catalog).length > 5;
16705
17065
  if (gotCatalog && gainReady()) break;
16706
17066
  }
17067
+ // Rev461 (Carlos QA Rev460, 2026-10-11): keep the plugin values
17068
+ // cache warm with a low-rate background refresh. The initial boot
17069
+ // loop above only runs until ONE AutopilotGain lands; the rest of
17070
+ // the catalog (and any value updated by the pilot while the sailor
17071
+ // was not watching) will not reach the cache until the next trigger.
17072
+ // A 10 s background tick is cheap (~70 ms per call per the sea
17073
+ // trial logs) and makes "open Setup → values already there" the
17074
+ // common case regardless of how long the tab stayed on CONTROL.
17075
+ setInterval(() => {
17076
+ try { refreshPypilotValues().catch(() => {}); } catch { /* silent */ }
17077
+ }, 10_000);
16707
17078
  })();
16708
17079
  connectSK();
16709
17080
  // Rev342 (Carlos, 2026-09-28): net health monitor. Independent of
package/public/index.html CHANGED
@@ -5,6 +5,24 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
6
  <title>PyPilot New-UI</title>
7
7
  <link rel="icon" href="icon.svg" type="image/svg+xml" />
8
+ <!-- Rev469 (issue #5, @eric-fitzsimon): iOS Add to Home Screen
9
+ support. Without these metas iOS creates a half-baked bookmark
10
+ whose WebView aggressively caches a stale app.js and desyncs
11
+ from the backend on subsequent launches. See
12
+ docs/_audit_ios_pwa_issue_5.md for the full analysis. -->
13
+ <meta name="apple-mobile-web-app-capable" content="yes" />
14
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
15
+ <meta name="apple-mobile-web-app-title" content="PyPilot" />
16
+ <meta name="mobile-web-app-capable" content="yes" />
17
+ <meta name="theme-color" content="#0a1220" />
18
+ <link rel="apple-touch-icon" href="icon.svg" />
19
+ <!-- Rev469: force the standalone WebView to always re-fetch the
20
+ shell + app.js on launch. Without this, iOS caches the first
21
+ successful load indefinitely and ignores the `?v=RevNNN`
22
+ query bump that deploy.ps1 writes on every deploy. -->
23
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
24
+ <meta http-equiv="Pragma" content="no-cache" />
25
+ <meta http-equiv="Expires" content="0" />
8
26
  <link rel="stylesheet" href="style.css" />
9
27
  </head>
10
28
  <body class="theme-dark">
@@ -28,6 +46,14 @@
28
46
  1 s. Visual cue so the sailor can tell "red intermitente" from
29
47
  "bug del pilot" in agua. -->
30
48
  <div id="net-health-banner" hidden style="position:fixed;top:0;left:0;right:0;padding:8px 14px;text-align:center;font-size:13px;font-weight:600;letter-spacing:0.3px;z-index:9998;background:rgba(255,149,0,0.92);color:#0a1220;box-shadow:0 2px 8px rgba(0,0,0,0.35)"></div>
49
+ <!-- Rev465 (Carlos safety audit 2026-10-12): bloqueante centrado que
50
+ aparece cuando el plugin backend reporta pypilot core offline.
51
+ Deshabilita visualmente los controles write-side (via CSS) +
52
+ pluginRaw() en app.js aborta los writes mientras esté activo. -->
53
+ <div id="pypilot-offline-banner">
54
+ <span data-i18n="pypilot.offline.title">SIN CONEXIÓN AL AUTOPILOTO</span>
55
+ <span class="subtle" data-i18n="pypilot.offline.hint">Los comandos están deshabilitados. El autopilot sigue en su último rumbo. Comprueba la conexión al Pi del pypilot.</span>
56
+ </div>
31
57
 
32
58
  <!-- Login modal: opens when SK returns 401 on a write. Gets a JWT from
33
59
  /signalk/v1/auth/login and caches it in localStorage - no more cookie
@@ -536,12 +562,20 @@
536
562
  0 0 0 0 0.56
537
563
  0 0 0 1 0"/>
538
564
  </filter>
565
+ <!-- Rev459: clip the AIS alarm layer to the rose circle so
566
+ target hulls + their projected course lines never spill
567
+ outside the compass ring. -->
568
+ <clipPath id="rose-clip">
569
+ <circle cx="0" cy="0" r="94"/>
570
+ </clipPath>
539
571
  </defs>
540
572
  <!-- Rev449: AIS targets as the LAST element inside the rose
541
573
  SVG, so they are the top-most layer above wind arrows,
542
574
  sectors, boat, countdown HUD, heading chip and rotation
543
- arc. Keeps the hull sprite always visible. -->
544
- <g id="ais-alarm-layer"></g>
575
+ arc. Keeps the hull sprite always visible.
576
+ Rev459: clipped to the rose circle so projected course
577
+ lines never leak outside. -->
578
+ <g id="ais-alarm-layer" clip-path="url(#rose-clip)"></g>
545
579
  </svg>
546
580
  <!-- Rev71: wind-speed chip promoted from SVG group to an HTML div
547
581
  overlaid on the rose bottom-center. Text baseline aligned
@@ -796,22 +830,39 @@
796
830
  enabled. Opens signalk-collision-alerts plugin config
797
831
  in the SK admin UI from "Config" so Carlos can change
798
832
  Harbour/Coastal/Offshore + per-field thresholds. -->
799
- <label class="overlay-card">
800
- <input type="checkbox" id="dash-ais-alarm" checked />
833
+ <!-- Rev456 (Carlos QA Rev455, 2026-10-11): single ON/OFF
834
+ toggle + auto "harbor mode" were replaced by a 3-way
835
+ selector so the sailor explicitly tells the visor what
836
+ situation he is in — Puerto / Fondeo / Navegacion.
837
+ See _aisMode() + _handleAisAlarmDelta in app.js. -->
838
+ <label class="overlay-card" style="cursor:default">
801
839
  <svg class="overlay-preview" viewBox="-30 -60 60 60" preserveAspectRatio="xMidYMid meet">
802
840
  <polygon points="0,-50 10,-20 -10,-20" fill="#ff3b30" stroke="#fff" stroke-width="1" stroke-linejoin="round"/>
803
841
  <text x="0" y="-35" text-anchor="middle" dominant-baseline="middle"
804
842
  fill="#fff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">!</text>
805
843
  </svg>
806
844
  <div class="overlay-title" data-i18n="dash.overlay.ais.title">AIS collision alarm</div>
807
- <div class="overlay-needs" data-i18n="dash.overlay.ais.needs">Needs signalk-collision-alerts plugin installed in your SK server. Reads notifications.navigation.closestApproach.* and renders a red/orange triangle on the rose with an infobox + ACK. Double-tap the rose to open Freeboard-SK embedded.</div>
808
- <div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap">
845
+ <div class="overlay-needs" data-i18n="dash.overlay.ais.needs">Needs signalk-collision-alerts plugin installed in your SK server. Reads notifications.navigation.closestApproach.* and renders a red/orange hull on the rose with an infobox + ACK. Double-tap the rose to open Freeboard-SK embedded.</div>
846
+ <div style="display:flex;gap:12px;margin-top:8px;flex-wrap:wrap"
847
+ onclick="event.stopPropagation();">
848
+ <label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
849
+ <input type="radio" name="ais-mode" value="off" id="dash-ais-mode-off" />
850
+ <span data-i18n="dash.overlay.ais.mode.off">Puerto (sin alarmas)</span>
851
+ </label>
852
+ <label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
853
+ <input type="radio" name="ais-mode" value="anchor" id="dash-ais-mode-anchor" />
854
+ <span data-i18n="dash.overlay.ais.mode.anchor">Fondeo (solo targets en movimiento)</span>
855
+ </label>
856
+ <label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
857
+ <input type="radio" name="ais-mode" value="underway" id="dash-ais-mode-underway" checked />
858
+ <span data-i18n="dash.overlay.ais.mode.underway">Navegacion (completo)</span>
859
+ </label>
860
+ </div>
861
+ <div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
809
862
  <button type="button" id="dash-ais-alarm-config" class="ghost"
810
863
  style="padding:6px 10px;font-size:12px"
811
864
  data-i18n="dash.overlay.ais.config"
812
865
  onclick="event.preventDefault();event.stopPropagation();">Open plugin config</button>
813
- <!-- Rev438: open the "silenciados" modal so Carlos can
814
- un-silence an MMSI before its 10-min TTL expires. -->
815
866
  <button type="button" id="dash-ais-alarm-silenced" class="ghost"
816
867
  style="padding:6px 10px;font-size:12px"
817
868
  data-i18n="dash.overlay.ais.silenced"
@@ -821,6 +872,13 @@
821
872
  <input type="checkbox" id="dash-ais-alarm-voice" checked />
822
873
  <span data-i18n="dash.overlay.ais.voice">Voice alert</span>
823
874
  </label>
875
+ <!-- Rev464 (Carlos QA Rev463, 2026-10-11): toggle for
876
+ the auto-silence-when-moored machine. Default ON. -->
877
+ <label style="display:inline-flex;align-items:center;gap:6px;padding:4px 8px;font-size:12px;color:var(--fg-dim);cursor:pointer"
878
+ onclick="event.stopPropagation();">
879
+ <input type="checkbox" id="dash-ais-alarm-harbor-auto" checked />
880
+ <span data-i18n="dash.overlay.ais.harborAuto">Auto-OFF when moored (SOG=0 for 2 min)</span>
881
+ </label>
824
882
  </div>
825
883
  </label>
826
884
  </div>
@@ -2443,6 +2501,6 @@
2443
2501
  </div>
2444
2502
  </div>
2445
2503
 
2446
- <script src="app.js?v=Rev449"></script>
2504
+ <script src="app.js?v=Rev469"></script>
2447
2505
  </body>
2448
2506
  </html>
package/public/style.css CHANGED
@@ -3812,15 +3812,21 @@ td.sq-action {
3812
3812
  .setup-block-status:empty { display: none; }
3813
3813
  .setup-block-status b { color: var(--fg); font-weight: 700; }
3814
3814
 
3815
- /* Rev260/261 (Carlos): passive feedback when pypilot is offline.
3816
- Body carries the class; every write-side control below reads it.
3817
- No pointer-events:none - a stubborn click still travels and the
3818
- backend's authoritative Rev254/257 guard rejects it in 503. */
3815
+ /* Rev260/261 → Rev465 (Carlos sea trial 2026-10-12, safety audit):
3816
+ when pypilot is offline we now BLOCK clicks on every write-side
3817
+ control (pointer-events:none) AND show a centred red banner so the
3818
+ sailor can see at a glance that his inputs are not reaching the
3819
+ autopilot. Previously controls only dimmed visually but clicks
3820
+ still travelled → backend answered 503 but the sailor had already
3821
+ pressed several buttons blindly, and the queued writes could all
3822
+ fire the moment pypilot_web came back online (Carlos: "pasé miedo"
3823
+ on the 2026-10-12 trip). */
3819
3824
  body.pypilot-offline #engage-toggle,
3820
3825
  body.pypilot-offline #tack-port-btn,
3821
3826
  body.pypilot-offline #tack-star-btn,
3822
3827
  body.pypilot-offline .steer-btn[data-nudge-kind],
3823
3828
  body.pypilot-offline #mode-select,
3829
+ body.pypilot-offline #mode-select-btn,
3824
3830
  body.pypilot-offline #pilot-select,
3825
3831
  body.pypilot-offline #profile-select,
3826
3832
  body.pypilot-offline #calib-unlock,
@@ -3834,6 +3840,41 @@ body.pypilot-offline .pypilot-write {
3834
3840
  opacity: 0.35;
3835
3841
  filter: grayscale(1);
3836
3842
  cursor: not-allowed;
3843
+ pointer-events: none;
3844
+ }
3845
+ /* Rev465: full-width centred banner. Hidden unless pypilot-offline. */
3846
+ #pypilot-offline-banner {
3847
+ position: fixed;
3848
+ top: 50%;
3849
+ left: 50%;
3850
+ transform: translate(-50%, -50%);
3851
+ background: #ff3b30;
3852
+ color: #fff;
3853
+ padding: 18px 28px;
3854
+ border-radius: 10px;
3855
+ font-size: 18px;
3856
+ font-weight: 900;
3857
+ letter-spacing: 0.5px;
3858
+ text-align: center;
3859
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
3860
+ z-index: 10000;
3861
+ animation: pypilot-offline-blink 1.4s ease-in-out infinite;
3862
+ display: none;
3863
+ max-width: 80vw;
3864
+ line-height: 1.3;
3865
+ }
3866
+ body.pypilot-offline #pypilot-offline-banner { display: block; }
3867
+ #pypilot-offline-banner .subtle {
3868
+ display: block;
3869
+ margin-top: 6px;
3870
+ font-size: 13px;
3871
+ font-weight: 500;
3872
+ letter-spacing: 0;
3873
+ opacity: 0.88;
3874
+ }
3875
+ @keyframes pypilot-offline-blink {
3876
+ 0%, 100% { opacity: 1; }
3877
+ 50% { opacity: 0.6; }
3837
3878
  }
3838
3879
 
3839
3880
  /* Rev261 (Carlos): green transient toast when pypilot comes back. */
@@ -4086,6 +4127,20 @@ body.pypilot-offline .pypilot-write {
4086
4127
  }
4087
4128
  .ais-target.ais-alarm { animation: ais-blink 900ms infinite; }
4088
4129
  .ais-target.ais-acked { animation: none !important; }
4130
+ /* Rev457: projected track line out of the AIS target's bow. Lives
4131
+ inside the hull <g>, so the parent rotation already orients it
4132
+ along the target's COG. Length is driven by its SOG × 3 min
4133
+ horizon. Colour matches the hull tint by state. */
4134
+ .ais-track-line {
4135
+ fill: none;
4136
+ stroke: #ff3b30;
4137
+ stroke-dasharray: 3 3;
4138
+ stroke-linecap: round;
4139
+ opacity: 0.85;
4140
+ pointer-events: none;
4141
+ }
4142
+ .ais-track-line.ais-warn { stroke: #f5a623; }
4143
+ .ais-track-line.ais-acked { stroke: #6b7a8f; opacity: 0.55; }
4089
4144
  @keyframes ais-blink {
4090
4145
  0%, 100% { opacity: 1; }
4091
4146
  50% { opacity: 0.35; }
@@ -4140,6 +4195,10 @@ body.pypilot-offline .pypilot-write {
4140
4195
  overflow: hidden;
4141
4196
  text-overflow: ellipsis;
4142
4197
  white-space: nowrap;
4198
+ /* Rev462 (Carlos QA Rev461, 2026-10-11): the X close button sits at
4199
+ top-right in position:absolute; reserve space for it so the MMSI /
4200
+ target name text does not slide underneath. */
4201
+ padding-right: 22px;
4143
4202
  }
4144
4203
  .ais-infobox-body {
4145
4204
  font-size: 12px;