signalk-pypilot-newui 3.1.2 → 3.1.3
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 +84 -0
- package/README.md +5 -2
- package/dist/index.js +38 -7
- package/dist/trip-recorder.js +17 -3
- package/package.json +1 -1
- package/public/app.js +446 -75
- package/public/index.html +49 -9
- package/public/style.css +63 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.1.3 — 2026-10-14 — Safety: offline-aware write gating + batched trace + AIS polish
|
|
4
|
+
|
|
5
|
+
Rolls up Rev464 → Rev468 (5 revs) on top of 3.1.2. Patch bump: no
|
|
6
|
+
breaking changes; set of safety-oriented fixes triggered by the
|
|
7
|
+
2026-10-12 sea trial where the sailor "pasó miedo" when the 4G link
|
|
8
|
+
dropped repeatedly and the visor's controls were still clickable while
|
|
9
|
+
pypilot_web was silent.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Auto-OFF harbor-mode toggle in the AIS overlay card** (Rev464).
|
|
14
|
+
The 2-min SOG≈0 auto-silencer can now be flipped off from the
|
|
15
|
+
overlay-card without opening the plugin config, so sailors can keep
|
|
16
|
+
testing AIS behaviour while moored.
|
|
17
|
+
- **AIS MMSI normalisation — root-cause fix for "no sale en posición
|
|
18
|
+
relativa"** (Rev463). `signalk-collision-alerts` publishes the full
|
|
19
|
+
URN as the path tail (`notifications.navigation.closestApproach.urn:mrn:imo:mmsi:XXXXX`);
|
|
20
|
+
our extractor used to return the URN verbatim, which was then
|
|
21
|
+
concatenated again when fetching the target's REST position → double
|
|
22
|
+
URN → 404. The hull fell back to `cpaPositions.target` (future CPA
|
|
23
|
+
position) instead of the real current position. Fixed by extracting
|
|
24
|
+
only the trailing numeric MMSI (`/(\d{6,})$/`). Hulls now paint at
|
|
25
|
+
the real bearing with the real COG, and the dashed track line
|
|
26
|
+
reflects reality.
|
|
27
|
+
- **Pypilot-offline bloqueante banner + write gate across the board**
|
|
28
|
+
(Rev465). When the plugin backend reports pypilot core offline, a
|
|
29
|
+
centred red banner blinks over everything and every write-side
|
|
30
|
+
control is simultaneously:
|
|
31
|
+
1. Visually dimmed + `pointer-events: none` (CSS).
|
|
32
|
+
2. Gated in JS: `pluginRaw()`, `apEngage()`, `apDisengage()`,
|
|
33
|
+
`apSetMode()`, `apSetTargetRad()`, `apTack()` all return
|
|
34
|
+
`{ok:false, blocked:true}` before firing any HTTP write.
|
|
35
|
+
No stale commands can be queued while the pilot is unreachable.
|
|
36
|
+
See `docs/_audit_sea_trial_2026-10-12_network.md`.
|
|
37
|
+
- **Batched maneuver-trace** (Rev468). New backend endpoint
|
|
38
|
+
`POST /plugins/signalk-pypilot-newui/maneuver-trace/events` accepts
|
|
39
|
+
`{events: [...]}`. The visor now queues events locally and flushes
|
|
40
|
+
every 2 s (or on 50-event cap, visibilitychange hidden, beforeunload)
|
|
41
|
+
instead of one POST per event. Active navigation went from
|
|
42
|
+
~5-10 POST/s to ~0.5 POST/s on `maneuver-trace`; the sailor's 4G
|
|
43
|
+
link survives a lot longer under load.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **WS close → pypilot marked offline immediately** (Rev466). The
|
|
48
|
+
visor's SK WebSocket drop used to leave `pypilotHealthy` stale
|
|
49
|
+
until the next `/status` poll — the controls stayed enabled during
|
|
50
|
+
the gap. The `ws.addEventListener('close')` now flips the state and
|
|
51
|
+
activates the banner within milliseconds. `_pypilotWriteBlocked()`
|
|
52
|
+
also checks `ws.readyState !== OPEN` so a write cannot slip through
|
|
53
|
+
on a dead socket.
|
|
54
|
+
- **Setup-tab slider values no longer "tardan"** (Rev460). The
|
|
55
|
+
callback that applied the plugin's `/values` response to the Tune
|
|
56
|
+
sliders was missing after Rev459; sliders stayed on `---` until the
|
|
57
|
+
next render cycle. Now the response updates the slider in place.
|
|
58
|
+
- **Freeboard-SK embed stays mounted** (Rev461). Closing the embed
|
|
59
|
+
no longer blanks the `<iframe src>`; reopens are instant without a
|
|
60
|
+
full chart + tile reload.
|
|
61
|
+
- **Background refresh of plugin values every 10 s** (Rev461). The
|
|
62
|
+
initial boot loop stopped as soon as one AutopilotGain landed, so
|
|
63
|
+
the rest of the catalog filled in only when Setup was opened. A
|
|
64
|
+
low-rate background tick now keeps the cache warm, so Setup opens
|
|
65
|
+
pre-populated regardless of how long the sailor stayed on Control.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- **README — chart provider recommendation** now puts
|
|
70
|
+
[`signalk-charts-provider-simple`](https://github.com/dirkwa/signalk-charts-provider-simple)
|
|
71
|
+
(by @dirkwa, same maintainer as `signalk-collision-alerts`) as the
|
|
72
|
+
**recommended** pairing for the embedded Freeboard-SK, with
|
|
73
|
+
`@signalk/charts-plugin` kept as an alternative. Pointed by Carlos
|
|
74
|
+
as the simpler path for sailors new to Signal K charts.
|
|
75
|
+
|
|
76
|
+
### Upstream coordination
|
|
77
|
+
|
|
78
|
+
- `hkapanen/ais-forwarder#14` — added impact comment: 11+ days without
|
|
79
|
+
MarineTraffic / VesselFinder presence on Tunatunes while the plugin
|
|
80
|
+
stays disabled to prevent the uncaughtException cascade. Posted a
|
|
81
|
+
concrete minimal fix (`socket.on('error', ...)` + send callback) and
|
|
82
|
+
offered a PR.
|
|
83
|
+
- `dirkwa/signalk-collision-alerts#17` — closed by maintainer with a
|
|
84
|
+
fix. Opened `#24` with proposal to suppress alerts when either
|
|
85
|
+
vessel is effectively stationary (SOG-aware threat check).
|
|
86
|
+
|
|
3
87
|
## 3.1.2 — 2026-10-10 — Compatibility with pypilot 0.71 (issue #3)
|
|
4
88
|
|
|
5
89
|
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
|
|
34
|
-
`~/.signalk/charts/` (OpenCPN-style).
|
|
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 = "
|
|
31
|
+
const PLUGIN_REVISION = "Rev468";
|
|
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) => {
|
package/dist/trip-recorder.js
CHANGED
|
@@ -210,9 +210,23 @@ class TripRecorder {
|
|
|
210
210
|
else
|
|
211
211
|
this.downwindN += 1;
|
|
212
212
|
this.polN += 1;
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
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.
|
|
3
|
+
"version": "3.1.3",
|
|
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();
|
|
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
|
-
//
|
|
3903
|
-
//
|
|
3904
|
-
//
|
|
3905
|
-
|
|
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
|
-
|
|
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(
|
|
3910
|
-
|
|
3911
|
-
|
|
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
|
|
3914
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4059
|
-
//
|
|
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 =
|
|
4089
|
-
const y =
|
|
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
|
-
//
|
|
4109
|
-
// its
|
|
4110
|
-
//
|
|
4111
|
-
//
|
|
4112
|
-
//
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4115
|
-
|
|
4116
|
-
|
|
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(${
|
|
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
|
-
//
|
|
7218
|
-
//
|
|
7219
|
-
//
|
|
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
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
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)
|
|
7905
|
-
// fire-and-forget -
|
|
7906
|
-
//
|
|
7907
|
-
// from the Setup card.
|
|
7908
|
-
//
|
|
7909
|
-
|
|
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
|
-
|
|
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(
|
|
7916
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
12356
|
-
|
|
12357
|
-
|
|
12358
|
-
|
|
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
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
1 s. Visual cue so the sailor can tell "red intermitente" from
|
|
29
29
|
"bug del pilot" in agua. -->
|
|
30
30
|
<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>
|
|
31
|
+
<!-- Rev465 (Carlos safety audit 2026-10-12): bloqueante centrado que
|
|
32
|
+
aparece cuando el plugin backend reporta pypilot core offline.
|
|
33
|
+
Deshabilita visualmente los controles write-side (via CSS) +
|
|
34
|
+
pluginRaw() en app.js aborta los writes mientras esté activo. -->
|
|
35
|
+
<div id="pypilot-offline-banner">
|
|
36
|
+
<span data-i18n="pypilot.offline.title">SIN CONEXIÓN AL AUTOPILOTO</span>
|
|
37
|
+
<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>
|
|
38
|
+
</div>
|
|
31
39
|
|
|
32
40
|
<!-- Login modal: opens when SK returns 401 on a write. Gets a JWT from
|
|
33
41
|
/signalk/v1/auth/login and caches it in localStorage - no more cookie
|
|
@@ -536,12 +544,20 @@
|
|
|
536
544
|
0 0 0 0 0.56
|
|
537
545
|
0 0 0 1 0"/>
|
|
538
546
|
</filter>
|
|
547
|
+
<!-- Rev459: clip the AIS alarm layer to the rose circle so
|
|
548
|
+
target hulls + their projected course lines never spill
|
|
549
|
+
outside the compass ring. -->
|
|
550
|
+
<clipPath id="rose-clip">
|
|
551
|
+
<circle cx="0" cy="0" r="94"/>
|
|
552
|
+
</clipPath>
|
|
539
553
|
</defs>
|
|
540
554
|
<!-- Rev449: AIS targets as the LAST element inside the rose
|
|
541
555
|
SVG, so they are the top-most layer above wind arrows,
|
|
542
556
|
sectors, boat, countdown HUD, heading chip and rotation
|
|
543
|
-
arc. Keeps the hull sprite always visible.
|
|
544
|
-
|
|
557
|
+
arc. Keeps the hull sprite always visible.
|
|
558
|
+
Rev459: clipped to the rose circle so projected course
|
|
559
|
+
lines never leak outside. -->
|
|
560
|
+
<g id="ais-alarm-layer" clip-path="url(#rose-clip)"></g>
|
|
545
561
|
</svg>
|
|
546
562
|
<!-- Rev71: wind-speed chip promoted from SVG group to an HTML div
|
|
547
563
|
overlaid on the rose bottom-center. Text baseline aligned
|
|
@@ -796,22 +812,39 @@
|
|
|
796
812
|
enabled. Opens signalk-collision-alerts plugin config
|
|
797
813
|
in the SK admin UI from "Config" so Carlos can change
|
|
798
814
|
Harbour/Coastal/Offshore + per-field thresholds. -->
|
|
799
|
-
|
|
800
|
-
|
|
815
|
+
<!-- Rev456 (Carlos QA Rev455, 2026-10-11): single ON/OFF
|
|
816
|
+
toggle + auto "harbor mode" were replaced by a 3-way
|
|
817
|
+
selector so the sailor explicitly tells the visor what
|
|
818
|
+
situation he is in — Puerto / Fondeo / Navegacion.
|
|
819
|
+
See _aisMode() + _handleAisAlarmDelta in app.js. -->
|
|
820
|
+
<label class="overlay-card" style="cursor:default">
|
|
801
821
|
<svg class="overlay-preview" viewBox="-30 -60 60 60" preserveAspectRatio="xMidYMid meet">
|
|
802
822
|
<polygon points="0,-50 10,-20 -10,-20" fill="#ff3b30" stroke="#fff" stroke-width="1" stroke-linejoin="round"/>
|
|
803
823
|
<text x="0" y="-35" text-anchor="middle" dominant-baseline="middle"
|
|
804
824
|
fill="#fff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">!</text>
|
|
805
825
|
</svg>
|
|
806
826
|
<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
|
|
808
|
-
<div style="display:flex;gap:
|
|
827
|
+
<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>
|
|
828
|
+
<div style="display:flex;gap:12px;margin-top:8px;flex-wrap:wrap"
|
|
829
|
+
onclick="event.stopPropagation();">
|
|
830
|
+
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
|
831
|
+
<input type="radio" name="ais-mode" value="off" id="dash-ais-mode-off" />
|
|
832
|
+
<span data-i18n="dash.overlay.ais.mode.off">Puerto (sin alarmas)</span>
|
|
833
|
+
</label>
|
|
834
|
+
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
|
835
|
+
<input type="radio" name="ais-mode" value="anchor" id="dash-ais-mode-anchor" />
|
|
836
|
+
<span data-i18n="dash.overlay.ais.mode.anchor">Fondeo (solo targets en movimiento)</span>
|
|
837
|
+
</label>
|
|
838
|
+
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
|
839
|
+
<input type="radio" name="ais-mode" value="underway" id="dash-ais-mode-underway" checked />
|
|
840
|
+
<span data-i18n="dash.overlay.ais.mode.underway">Navegacion (completo)</span>
|
|
841
|
+
</label>
|
|
842
|
+
</div>
|
|
843
|
+
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
|
|
809
844
|
<button type="button" id="dash-ais-alarm-config" class="ghost"
|
|
810
845
|
style="padding:6px 10px;font-size:12px"
|
|
811
846
|
data-i18n="dash.overlay.ais.config"
|
|
812
847
|
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
848
|
<button type="button" id="dash-ais-alarm-silenced" class="ghost"
|
|
816
849
|
style="padding:6px 10px;font-size:12px"
|
|
817
850
|
data-i18n="dash.overlay.ais.silenced"
|
|
@@ -821,6 +854,13 @@
|
|
|
821
854
|
<input type="checkbox" id="dash-ais-alarm-voice" checked />
|
|
822
855
|
<span data-i18n="dash.overlay.ais.voice">Voice alert</span>
|
|
823
856
|
</label>
|
|
857
|
+
<!-- Rev464 (Carlos QA Rev463, 2026-10-11): toggle for
|
|
858
|
+
the auto-silence-when-moored machine. Default ON. -->
|
|
859
|
+
<label style="display:inline-flex;align-items:center;gap:6px;padding:4px 8px;font-size:12px;color:var(--fg-dim);cursor:pointer"
|
|
860
|
+
onclick="event.stopPropagation();">
|
|
861
|
+
<input type="checkbox" id="dash-ais-alarm-harbor-auto" checked />
|
|
862
|
+
<span data-i18n="dash.overlay.ais.harborAuto">Auto-OFF when moored (SOG=0 for 2 min)</span>
|
|
863
|
+
</label>
|
|
824
864
|
</div>
|
|
825
865
|
</label>
|
|
826
866
|
</div>
|
|
@@ -2443,6 +2483,6 @@
|
|
|
2443
2483
|
</div>
|
|
2444
2484
|
</div>
|
|
2445
2485
|
|
|
2446
|
-
<script src="app.js?v=
|
|
2486
|
+
<script src="app.js?v=Rev468"></script>
|
|
2447
2487
|
</body>
|
|
2448
2488
|
</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
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
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;
|