signalk-pypilot-newui 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.6.0 — 2026-10-19 — AUTO WIND and a logbook button on the rose
4
+
5
+ Rolls up Rev517 → Rev520 on top of 3.5.0. Minor bump: new AUTO WIND
6
+ pseudo-mode. No breaking changes; AUTO WIND is off until chosen.
7
+
8
+ ### Added
9
+ - **AUTO WIND pseudo-mode**: apparent wind below 120° TWA, true wind
10
+ above (hysteresis ±5°, 10 s sustained, at most one switch per 30 s;
11
+ never during a tack, head-to-wind, head-downwind or with the AP off).
12
+ Each side has its own pypilot profile, chosen in the mode window, so
13
+ the true-wind side can be the more reactive one. Runs in the backend
14
+ (works with the visor closed); picking any other mode, or compass from
15
+ the remote, turns it off. Mode button shows "AUTO · REAL / APPARENT".
16
+ Without GPS pypilot offers no true wind: the entry is shown greyed out
17
+ ("needs GPS for true wind"), and pypilot's own drop to compass on a
18
+ GPS loss does not turn AUTO WIND off.
19
+ Listed right below compass / GPS in the mode window.
20
+ - **Logbook button**: while a trip is being recorded it opens that trip;
21
+ in port it opens the logbook list.
22
+ - **Logbook button on the rose**: a helm icon on the left, mirroring the
23
+ tide button on the right, opens the Logbook full screen; its x goes
24
+ back to the rose.
25
+
3
26
  ## 3.5.0 — 2026-10-19 — Fixes from two sea trials, logbook while sailing
4
27
 
5
28
  Rolls up Rev509 → Rev516 on top of 3.4.0. Minor bump: live logbook,
package/README.md CHANGED
@@ -131,7 +131,8 @@ up automatically.
131
131
  13. **Tide at a glance** (with `signalk-mareas-ihm`) — button on the
132
132
  rose opens a popup with an interactive tide curve, current height,
133
133
  trend, next HW/LW and coefficient, and can open the anchor-watch
134
- visor inside the rose.
134
+ visor inside the rose. A helm button on the other side opens the
135
+ logbook (the trip in progress while sailing).
135
136
 
136
137
  ### Autopilot enhancements — 12 new added functions
137
138
 
@@ -142,7 +143,10 @@ up automatically.
142
143
  original stays one tap away.
143
144
  15. **Smart Pilot — auto profile by wind** — switches the pypilot
144
145
  profile between Light / Medium / Heavy according to the averaged
145
- TWS.
146
+ TWS. **AUTO WIND pseudo-mode**: apparent wind below 120° TWA, true
147
+ wind above (with hysteresis), one pypilot profile for each side so
148
+ the true-wind side can be the more reactive one. Needs GPS (pypilot
149
+ only offers true wind with it).
146
150
  16. **Smart Pilot — gust strategy** — freeze target, boost D +20%
147
151
  for 20 s, temporarily switch to the Heavy profile for 30 s, or
148
152
  just warn.
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ // Rev518 (Carlos 2026-10-19): pseudo-mode AUTOWIND. Apparent wind
3
+ // (pypilot "wind") when |TWA| < threshold, true wind ("true wind") when
4
+ // above: downwind the apparent angle swings with boat speed and the
5
+ // pilot chases it (2026-10-19 18:48: +-40 deg in apparent at TWA ~140,
6
+ // steady in true wind). Each side has its own pypilot profile, so the
7
+ // true-wind side can be the more reactive one.
8
+ //
9
+ // Pure decision logic: fed once per second, no I/O.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.AutowindDecider = exports.AUTOWIND_MIN_GAP_MS = exports.AUTOWIND_SUSTAIN_MS = exports.AUTOWIND_HYSTERESIS_DEG = exports.AUTOWIND_DEFAULT_THRESHOLD_DEG = void 0;
12
+ exports.desiredSubMode = desiredSubMode;
13
+ exports.AUTOWIND_DEFAULT_THRESHOLD_DEG = 120;
14
+ exports.AUTOWIND_HYSTERESIS_DEG = 5; // +-5 around the threshold
15
+ exports.AUTOWIND_SUSTAIN_MS = 10000; // condition held this long
16
+ exports.AUTOWIND_MIN_GAP_MS = 30000; // between two switches
17
+ /** Side of the threshold the wind is on, with hysteresis; null in the band. */
18
+ function desiredSubMode(twaDeg, thresholdDeg = exports.AUTOWIND_DEFAULT_THRESHOLD_DEG) {
19
+ const a = Math.abs(twaDeg);
20
+ if (a >= thresholdDeg + exports.AUTOWIND_HYSTERESIS_DEG)
21
+ return "true wind";
22
+ if (a <= thresholdDeg - exports.AUTOWIND_HYSTERESIS_DEG)
23
+ return "wind";
24
+ return null;
25
+ }
26
+ class AutowindDecider {
27
+ constructor() {
28
+ this.pending = null;
29
+ this.pendingSince = null;
30
+ this.lastSwitchMs = null;
31
+ }
32
+ /** Returns the mode to switch to now, or null. */
33
+ tick(i) {
34
+ const cur = String(i.mode || "").toLowerCase();
35
+ const curSub = cur === "true wind" ? "true wind" : cur === "wind" ? "wind" : null;
36
+ if (!i.engaged || i.blocked || typeof i.twaDeg !== "number" || !isFinite(i.twaDeg)) {
37
+ this.pending = null;
38
+ this.pendingSince = null;
39
+ return null;
40
+ }
41
+ const want = desiredSubMode(i.twaDeg, i.thresholdDeg);
42
+ if (want == null || want === curSub) {
43
+ this.pending = null;
44
+ this.pendingSince = null;
45
+ return null;
46
+ }
47
+ if (this.pending !== want) {
48
+ this.pending = want;
49
+ this.pendingSince = i.nowMs;
50
+ return null;
51
+ }
52
+ if (this.pendingSince != null && i.nowMs - this.pendingSince < exports.AUTOWIND_SUSTAIN_MS)
53
+ return null;
54
+ if (this.lastSwitchMs != null && i.nowMs - this.lastSwitchMs < exports.AUTOWIND_MIN_GAP_MS)
55
+ return null;
56
+ this.pending = null;
57
+ this.pendingSince = null;
58
+ this.lastSwitchMs = i.nowMs;
59
+ return want;
60
+ }
61
+ /** Called when Autowind is switched on: no sustain, no gap. */
62
+ initial(twaDeg, thresholdDeg) {
63
+ this.pending = null;
64
+ this.pendingSince = null;
65
+ if (typeof twaDeg !== "number" || !isFinite(twaDeg))
66
+ return "wind";
67
+ return Math.abs(twaDeg) >= (thresholdDeg ?? exports.AUTOWIND_DEFAULT_THRESHOLD_DEG) ? "true wind" : "wind";
68
+ }
69
+ noteSwitch(nowMs) { this.lastSwitchMs = nowMs; }
70
+ }
71
+ exports.AutowindDecider = AutowindDecider;
package/dist/index.js CHANGED
@@ -28,11 +28,12 @@ const profile_metadata_1 = require("./profile-metadata");
28
28
  const nav_bearing_1 = require("./nav-bearing");
29
29
  const session_recorder_1 = require("./session-recorder");
30
30
  const steer_watch_1 = require("./steer-watch");
31
+ const autowind_1 = require("./autowind");
31
32
  const trip_recorder_1 = require("./trip-recorder");
32
33
  const maneuver_trace_1 = require("./maneuver-trace");
33
34
  // Rev counter bumped on every build so the user can distinguish deploys
34
35
  // from the webapp header (feedback_revision_bump_each_build).
35
- const PLUGIN_REVISION = "Rev516";
36
+ const PLUGIN_REVISION = "Rev520";
36
37
  // Rev59: read package.json once at load time so /status can report the
37
38
  // npm package version alongside the internal Rev counter.
38
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -168,6 +169,11 @@ module.exports = function (app) {
168
169
  let _steerRecoveryTotal = 0;
169
170
  let _steerRecoveryLastTs = null;
170
171
  let _steerRecoveryLastResult = null;
172
+ // Rev518: AUTOWIND state.
173
+ let autowind = new autowind_1.AutowindDecider();
174
+ let _autowindLastSwitchTs = null;
175
+ let _autowindLastReason = null;
176
+ let _autowindOffFamilySince = null;
171
177
  // Rev176 (Carlos): debounce the raw `engaged` flag so a rebound
172
178
  // caused by a competing client (pypilot_web on another device, a KIP
173
179
  // widget cycling, socket races) does not open+close a JSONL every 5 s.
@@ -722,6 +728,11 @@ module.exports = function (app) {
722
728
  // land in /alarms/state for the visor banner.
723
729
  alarms = new alarms_1.AlarmEngine();
724
730
  steerWatch = new steer_watch_1.SteerWatch();
731
+ autowind = new autowind_1.AutowindDecider();
732
+ setTimeout(() => { try {
733
+ _autowindPublish();
734
+ }
735
+ catch { /* silent */ } }, 5000);
725
736
  // Rev292 (Carlos, navigating): apply the user's chosen pypilot-
726
737
  // disconnect sustain window on start-up, in case pluginOptions
727
738
  // already carries a value.
@@ -943,6 +954,13 @@ module.exports = function (app) {
943
954
  catch (e) {
944
955
  app.debug?.(`[steer-watch] tick: ${e?.message || e}`);
945
956
  }
957
+ // Rev518: AUTOWIND (apparent <-> true wind by TWA).
958
+ try {
959
+ _autowindTick();
960
+ }
961
+ catch (e) {
962
+ app.debug?.(`[autowind] tick: ${e?.message || e}`);
963
+ }
946
964
  // Rev100: run the alarm engine last so it has every input up to
947
965
  // date. Changed rules trigger SK notification deltas.
948
966
  try {
@@ -2526,6 +2544,51 @@ module.exports = function (app) {
2526
2544
  // Rev167: config read + write for the Smart Pilot card. All the
2527
2545
  // toggles live in the visor, not in the SK Admin schema. Writes
2528
2546
  // go through app.savePluginOptions so they survive restart.
2547
+ // Rev518: AUTOWIND config + state.
2548
+ router.get("/autowind", (_req, res) => {
2549
+ res.json(_autowindState());
2550
+ });
2551
+ router.post("/autowind", async (req, res) => {
2552
+ if (!props.allowWrites)
2553
+ return res.status(403).json({ error: "allowWrites disabled" });
2554
+ const b = req.body || {};
2555
+ let changed = false;
2556
+ if (typeof b.profileApparent === "string") {
2557
+ props.autowindProfileApparent = b.profileApparent.trim();
2558
+ changed = true;
2559
+ }
2560
+ if (typeof b.profileTrue === "string") {
2561
+ props.autowindProfileTrue = b.profileTrue.trim();
2562
+ changed = true;
2563
+ }
2564
+ if (typeof b.thresholdDeg === "number") {
2565
+ props.autowindThresholdDeg = clampNumber(b.thresholdDeg, 90, 150, autowind_1.AUTOWIND_DEFAULT_THRESHOLD_DEG);
2566
+ changed = true;
2567
+ }
2568
+ if (changed) {
2569
+ try {
2570
+ app.savePluginOptions?.(props, () => { });
2571
+ }
2572
+ catch { /* silent */ }
2573
+ }
2574
+ if (typeof b.enabled === "boolean") {
2575
+ const was = !!props.autowindEnabled;
2576
+ _autowindSetEnabled(b.enabled, b.enabled ? "selected" : "another mode selected");
2577
+ if (b.enabled && !was) {
2578
+ // Pick the side now, no waiting.
2579
+ const sub = autowind.initial(_autowindTwaDeg(), props.autowindThresholdDeg);
2580
+ await _autowindApply(sub, "AUTOWIND on");
2581
+ }
2582
+ }
2583
+ // New profile for the side that is active right now: apply it.
2584
+ if (props.autowindEnabled && changed && apProvider) {
2585
+ const mode = String(apProvider.data.mode || "").toLowerCase();
2586
+ if (mode === "wind" || mode === "true wind")
2587
+ _autowindApplyProfile(mode, "profile chosen");
2588
+ }
2589
+ _autowindPublish();
2590
+ res.json(_autowindState());
2591
+ });
2529
2592
  router.get("/supervisor/config", (_req, res) => {
2530
2593
  res.json({
2531
2594
  autoProfileEnabled: !!props.autoProfileEnabled,
@@ -3821,6 +3884,10 @@ module.exports = function (app) {
3821
3884
  .includes(options.gustStrategy) ? options.gustStrategy : "off",
3822
3885
  autoDisengageOnLostAuthority: options.autoDisengageOnLostAuthority === true,
3823
3886
  steerRecoveryEnabled: options.steerRecoveryEnabled === true,
3887
+ autowindEnabled: options.autowindEnabled === true,
3888
+ autowindProfileApparent: typeof options.autowindProfileApparent === "string" ? options.autowindProfileApparent : "",
3889
+ autowindProfileTrue: typeof options.autowindProfileTrue === "string" ? options.autowindProfileTrue : "",
3890
+ autowindThresholdDeg: clampNumber(options.autowindThresholdDeg, 90, 150, autowind_1.AUTOWIND_DEFAULT_THRESHOLD_DEG),
3824
3891
  rollFfGain: clampNumber(options.rollFfGain, 0, 2, 0),
3825
3892
  rollFfTauSec: clampNumber(options.rollFfTauSec, 0.5, 30, 3),
3826
3893
  rollFfTwaGateDeg: clampNumber(options.rollFfTwaGateDeg, 30, 179, 90),
@@ -4320,6 +4387,121 @@ module.exports = function (app) {
4320
4387
  .catch((e) => { _steerRecoveryLastResult = `error: ${e?.message || e}`; })
4321
4388
  .finally(() => { _steerRecoveryInFlight = false; });
4322
4389
  }
4390
+ // Rev518 (Carlos 2026-10-19): AUTOWIND. See src/autowind.ts.
4391
+ function _autowindState() {
4392
+ const mode = apProvider ? String(apProvider.data.mode || "") : "";
4393
+ return {
4394
+ enabled: !!props.autowindEnabled,
4395
+ subMode: mode === "wind" || mode === "true wind" ? mode : null,
4396
+ profileApparent: props.autowindProfileApparent || "",
4397
+ profileTrue: props.autowindProfileTrue || "",
4398
+ thresholdDeg: props.autowindThresholdDeg ?? autowind_1.AUTOWIND_DEFAULT_THRESHOLD_DEG,
4399
+ lastSwitchTs: _autowindLastSwitchTs,
4400
+ lastReason: _autowindLastReason,
4401
+ };
4402
+ }
4403
+ function _autowindPublish() {
4404
+ try {
4405
+ app.handleMessage(PLUGIN_ID, {
4406
+ context: "vessels." + app.selfId,
4407
+ updates: [{ $source: SOURCE_LABEL, timestamp: new Date().toISOString(), values: [
4408
+ { path: "steering.autopilot.autowind", value: _autowindState() },
4409
+ ] }],
4410
+ });
4411
+ }
4412
+ catch { /* silent */ }
4413
+ }
4414
+ function _autowindProfileFor(sub) {
4415
+ return (sub === "true wind" ? props.autowindProfileTrue : props.autowindProfileApparent) || "";
4416
+ }
4417
+ function _autowindApplyProfile(sub, reason) {
4418
+ const want = _autowindProfileFor(sub);
4419
+ if (!want || !client || !client.connected)
4420
+ return;
4421
+ if (client.getValues()["profile"] === want)
4422
+ return;
4423
+ try {
4424
+ profileChangeLog.markPlannedWrite(want, "autowind", reason);
4425
+ client.set("profile", want);
4426
+ }
4427
+ catch { /* silent */ }
4428
+ }
4429
+ async function _autowindApply(sub, reason) {
4430
+ if (!apProvider)
4431
+ return;
4432
+ _autowindLastReason = reason;
4433
+ const cur = String(apProvider.data.mode || "").toLowerCase();
4434
+ if (cur !== sub) {
4435
+ // wind <-> true wind share the angle space: pypilot keeps the
4436
+ // heading itself on the mode change, nothing else is written.
4437
+ try {
4438
+ await apProvider.toProviderInterface().setMode(sub, apProvider.deviceId);
4439
+ }
4440
+ catch (e) {
4441
+ app.debug?.(`[autowind] setMode ${sub}: ${e?.message || e}`);
4442
+ return;
4443
+ }
4444
+ _autowindLastSwitchTs = Date.now();
4445
+ autowind.noteSwitch(_autowindLastSwitchTs);
4446
+ }
4447
+ _autowindApplyProfile(sub, reason);
4448
+ // eslint-disable-next-line no-console
4449
+ console.log(`[autowind] ${reason} -> ${sub}${_autowindProfileFor(sub) ? ` / profile ${_autowindProfileFor(sub)}` : ""}`);
4450
+ _autowindPublish();
4451
+ }
4452
+ function _autowindSetEnabled(on, why) {
4453
+ if (!!props.autowindEnabled === on)
4454
+ return;
4455
+ props.autowindEnabled = on;
4456
+ try {
4457
+ app.savePluginOptions?.(props, () => { });
4458
+ }
4459
+ catch { /* silent */ }
4460
+ // eslint-disable-next-line no-console
4461
+ console.log(`[autowind] ${on ? "on" : "off"} (${why})`);
4462
+ _autowindLastReason = why;
4463
+ _autowindPublish();
4464
+ }
4465
+ function _autowindTwaDeg() {
4466
+ const r = _readTwaRad();
4467
+ return r == null ? null : r * 180 / Math.PI;
4468
+ }
4469
+ function _autowindTick() {
4470
+ if (!props.autowindEnabled || !apProvider)
4471
+ return;
4472
+ const mode = String(apProvider.data.mode || "").toLowerCase();
4473
+ const blocked = apProvider.isVirtualTackActive()
4474
+ || !!(_tackStatePrev && _tackStatePrev !== "none")
4475
+ || _maneuverPseudoActive;
4476
+ // Sailor (remote, TinyPilot, KIP) moved to compass/gps: AUTOWIND
4477
+ // ends, like picking another mode in the visor. 5 s so a transient
4478
+ // write race never switches it off.
4479
+ // pypilot drops to compass on its own when it loses GPS / true wind
4480
+ // (and returns when it is back): that is not the sailor leaving.
4481
+ const trueWindAvailable = (apProvider.pypilotModes || []).includes("true wind");
4482
+ if (!blocked && trueWindAvailable && mode !== "wind" && mode !== "true wind" && mode !== "") {
4483
+ if (_autowindOffFamilySince == null)
4484
+ _autowindOffFamilySince = Date.now();
4485
+ else if (Date.now() - _autowindOffFamilySince > 5000) {
4486
+ _autowindOffFamilySince = null;
4487
+ _autowindSetEnabled(false, `mode changed to ${mode}`);
4488
+ }
4489
+ return;
4490
+ }
4491
+ _autowindOffFamilySince = null;
4492
+ const want = autowind.tick({
4493
+ nowMs: Date.now(),
4494
+ twaDeg: _autowindTwaDeg(),
4495
+ mode,
4496
+ engaged: !!apProvider.data.engaged,
4497
+ blocked,
4498
+ thresholdDeg: props.autowindThresholdDeg,
4499
+ });
4500
+ if (want) {
4501
+ const twa = _autowindTwaDeg();
4502
+ void _autowindApply(want, `TWA ${twa == null ? "?" : Math.abs(twa).toFixed(0)} deg`);
4503
+ }
4504
+ }
4323
4505
  // Rev164/165/166: supervisor layer. Runs on every historian tick.
4324
4506
  // Three independent responsibilities:
4325
4507
  // - Auto-profile switch by wind band (only while engaged, opt-in).
@@ -4412,7 +4594,7 @@ module.exports = function (app) {
4412
4594
  }
4413
4595
  }
4414
4596
  // ------------------ Rev164: auto-profile by wind band ------------------
4415
- if (props.autoProfileEnabled && apProvider && client && historian) {
4597
+ if (props.autoProfileEnabled && !props.autowindEnabled && apProvider && client && historian) {
4416
4598
  const engaged = !!apProvider.data.engaged;
4417
4599
  if (!engaged) {
4418
4600
  _autoProfilePendingBand = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with first-class Signal K paths and Simple Switches so any downstream Signal K client can drive or monitor the pilot. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings). Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "files": [
package/public/app.js CHANGED
@@ -467,6 +467,12 @@
467
467
  "vt.longJibe.title": "Gybe the long way?",
468
468
  "vt.longJibe.body": "The wind is on the other bow: this button turns through the stern ({deg}°). To tack, use the other button.",
469
469
  "mode.label.true-wind": "TRUE WIND",
470
+ "mode.label.autowind": "AUTO WIND",
471
+ "mode.label.autowind.long": "AUTO WIND (apparent / true at 120°)",
472
+ "autowind.short": "AUTO",
473
+ "autowind.needsGps": "needs GPS for true wind",
474
+ "autowind.prof.apparent": "Apparent wind",
475
+ "autowind.prof.true": "True wind",
470
476
  "mode.label.nav": "WAYPOINT",
471
477
  "mode.label.aproado": "HEAD TO WIND",
472
478
  "mode.label.empopado": "HEAD DOWNWIND",
@@ -627,6 +633,7 @@
627
633
  "info.comp.collision": "AIS collision targets on the rose with name, distance and ACK.",
628
634
  "tide.title": "Tide",
629
635
  "tide.btn": "Tide",
636
+ "logbook.btn": "Logbook",
630
637
  "tide.now": "Now",
631
638
  "tide.rising": "Rising",
632
639
  "tide.falling": "Falling",
@@ -1406,6 +1413,12 @@
1406
1413
  "vt.longJibe.title": "¿Trasluchar por popa?",
1407
1414
  "vt.longJibe.body": "El viento está en la otra amura: este botón da la vuelta por popa ({deg}°). Para virar, usa el otro botón.",
1408
1415
  "mode.label.true-wind": "REAL",
1416
+ "mode.label.autowind": "AUTO VIENTO",
1417
+ "mode.label.autowind.long": "AUTO VIENTO (aparente / real a 120°)",
1418
+ "autowind.short": "AUTO",
1419
+ "autowind.needsGps": "necesita GPS para el viento real",
1420
+ "autowind.prof.apparent": "Viento aparente",
1421
+ "autowind.prof.true": "Viento real",
1409
1422
  "mode.label.nav": "RUMBO A WAYPOINT",
1410
1423
  "mode.label.aproado": "APROADO",
1411
1424
  "mode.label.empopado": "EMPOPADO",
@@ -1572,6 +1585,7 @@
1572
1585
  "info.comp.collision": "Barcos AIS con riesgo de colisión en la rosa, con nombre, distancia y ACK.",
1573
1586
  "tide.title": "Marea",
1574
1587
  "tide.btn": "Marea",
1588
+ "logbook.btn": "Bitácora",
1575
1589
  "tide.now": "Ahora",
1576
1590
  "tide.rising": "Subiendo",
1577
1591
  "tide.falling": "Bajando",
@@ -2730,6 +2744,8 @@
2730
2744
  // Rev388 Fase B: backend FSM publishes snapshots here; visor
2731
2745
  // opens the read-only HUD + pins mode/target when active.
2732
2746
  "steering.autopilot.virtualTack",
2747
+ // Rev518: AUTOWIND state (enabled, sub-mode, profile pair).
2748
+ "steering.autopilot.autowind",
2733
2749
  "steering.autopilot.pypilot.ap.tack.state",
2734
2750
  "steering.autopilot.pypilot.ap.tack.direction",
2735
2751
  "steering.autopilot.pypilot.ap.mode",
@@ -2943,6 +2959,12 @@
2943
2959
  state.target = numericOrNull(value);
2944
2960
  renderTargetArrow();
2945
2961
  break;
2962
+ case "steering.autopilot.autowind": {
2963
+ state.autowind = (value && typeof value === "object") ? value : null;
2964
+ try { _renderModeSelectBtn(); } catch { /* silent */ }
2965
+ try { _rebuildModePopupProfiles(); } catch { /* silent */ }
2966
+ break;
2967
+ }
2946
2968
  case "steering.autopilot.virtualTack": {
2947
2969
  // Rev388 Fase B: structured snapshot from the backend FSM.
2948
2970
  // Open the HUD overlay on first active snapshot, close it on
@@ -8231,6 +8253,7 @@
8231
8253
  const modeList = Array.isArray(state.modeList) ? state.modeList : [];
8232
8254
  let withPseudo = modeList.includes("aproado") ? modeList : [...modeList, "aproado"];
8233
8255
  if (!withPseudo.includes("empopado")) withPseudo = [...withPseudo, "empopado"];
8256
+ if (_autowindAvailable() && !withPseudo.includes("autowind")) withPseudo = _withAutowindAfterCompass(withPseudo);
8234
8257
  el.textContent = "";
8235
8258
  // Hidden placeholder (Rev250 race-guard carryover).
8236
8259
  const ph = document.createElement("option");
@@ -8267,8 +8290,46 @@
8267
8290
  return;
8268
8291
  }
8269
8292
  const m = state.mode || "";
8293
+ // Rev518: AUTOWIND -> "AUTO · REAL" / "AUTO · APARENTE".
8294
+ if (state.autowind && state.autowind.enabled && (m === "wind" || m === "true wind")) {
8295
+ btn.textContent = `${t("autowind.short")} · ${_modeLabel(m)}`;
8296
+ return;
8297
+ }
8270
8298
  btn.textContent = m ? _modeLabel(m) : "—";
8271
8299
  }
8300
+ // Rev520 (Carlos): AUTO WIND right below compass / GPS.
8301
+ function _withAutowindAfterCompass(list) {
8302
+ const out = list.slice();
8303
+ let at = -1;
8304
+ for (const m of ["compass", "gps"]) { const i = out.indexOf(m); if (i > at) at = i; }
8305
+ out.splice(at + 1, 0, "autowind");
8306
+ return out;
8307
+ }
8308
+ // Rev518: AUTOWIND needs both wind modes from pypilot.
8309
+ function _autowindAvailable() {
8310
+ const ml = Array.isArray(state.modeList) ? state.modeList : [];
8311
+ return ml.includes("wind") && ml.includes("true wind");
8312
+ }
8313
+ async function _autowindPost(body) {
8314
+ try {
8315
+ const r = await skFetch(`/plugins/${PLUGIN_ID}/autowind`, {
8316
+ method: "POST",
8317
+ headers: { "Content-Type": "application/json" },
8318
+ body: JSON.stringify(body),
8319
+ });
8320
+ if (r.ok) {
8321
+ state.autowind = await r.json();
8322
+ _renderModeSelectBtn();
8323
+ _rebuildModePopupProfiles();
8324
+ }
8325
+ } catch (e) { console.warn("[autowind] post", e); }
8326
+ }
8327
+ async function _autowindFetch() {
8328
+ try {
8329
+ const r = await skFetch(`/plugins/${PLUGIN_ID}/autowind`);
8330
+ if (r.ok) { state.autowind = await r.json(); _renderModeSelectBtn(); }
8331
+ } catch { /* silent */ }
8332
+ }
8272
8333
  // Rev427 (Carlos QA Rev426, 2026-10-08): "es mejor que se abra una
8273
8334
  // ventana grande centrada". Replaced the anchored popup with a
8274
8335
  // centered modal (same backdrop pattern as the fork + unlock
@@ -8282,6 +8343,19 @@
8282
8343
  // produce visible flashes (Rev428 lesson applied to the new DOM).
8283
8344
  const btn = document.getElementById("mode-popup-profile-btn");
8284
8345
  const list = document.getElementById("mode-popup-profile-list");
8346
+ // Rev518: with AUTOWIND on, the profile area shows the pair.
8347
+ const aw = document.getElementById("mode-popup-aw");
8348
+ if (aw) {
8349
+ const awOn = !!(state.autowind && state.autowind.enabled);
8350
+ aw.hidden = !awOn;
8351
+ const plainWrap = btn ? btn.closest(".mode-popup-profile-wrap") : null;
8352
+ if (plainWrap) plainWrap.hidden = awOn;
8353
+ const set = (id, txt) => { const el = document.getElementById(id); if (el && el.textContent !== txt) el.textContent = txt; };
8354
+ set("mode-popup-aw-lbl-app", t("autowind.prof.apparent"));
8355
+ set("mode-popup-aw-lbl-true", t("autowind.prof.true"));
8356
+ set("mode-popup-aw-btn-app", (state.autowind && state.autowind.profileApparent) || "—");
8357
+ set("mode-popup-aw-btn-true", (state.autowind && state.autowind.profileTrue) || "—");
8358
+ }
8285
8359
  if (!btn && !list) return;
8286
8360
  const profiles = Array.isArray(state.profiles) ? state.profiles : [];
8287
8361
  if (btn) {
@@ -8334,6 +8408,13 @@
8334
8408
  ` <ul id="mode-popup-profile-list" class="mode-popup-list mode-popup-profile-list" role="listbox" hidden></ul>` +
8335
8409
  ` <button type="button" id="mode-popup-profile-btn" class="mode-popup-profile-btn"></button>` +
8336
8410
  ` </div>` +
8411
+ ` <div class="mode-popup-profile-wrap" id="mode-popup-aw" hidden>` +
8412
+ ` <div class="mode-popup-group-label" id="mode-popup-aw-lbl-app"></div>` +
8413
+ ` <button type="button" id="mode-popup-aw-btn-app" class="mode-popup-profile-btn" data-side="apparent"></button>` +
8414
+ ` <div class="mode-popup-group-label" id="mode-popup-aw-lbl-true"></div>` +
8415
+ ` <button type="button" id="mode-popup-aw-btn-true" class="mode-popup-profile-btn" data-side="true"></button>` +
8416
+ ` <ul id="mode-popup-aw-list" class="mode-popup-list mode-popup-profile-list" role="listbox" hidden></ul>` +
8417
+ ` </div>` +
8337
8418
  ` </div>` +
8338
8419
  `</div>`;
8339
8420
  document.body.appendChild(m);
@@ -8350,6 +8431,7 @@
8350
8431
  const li = ev.target.closest(".mode-popup-item");
8351
8432
  if (!li) return;
8352
8433
  const chosen = li.dataset.mode;
8434
+ if (!chosen) return; // Rev519: greyed-out entry (AUTO WIND without GPS)
8353
8435
  _modeSelectPopupClose();
8354
8436
  const nativeSel = document.getElementById("mode-select");
8355
8437
  if (nativeSel) {
@@ -8383,6 +8465,38 @@
8383
8465
  _modeSelectPopupClose();
8384
8466
  });
8385
8467
  }
8468
+ // Rev518: AUTOWIND profile pair. Each button opens the shared list
8469
+ // for its side; picking a profile stores it in the backend.
8470
+ const awList = m.querySelector("#mode-popup-aw-list");
8471
+ for (const id of ["#mode-popup-aw-btn-app", "#mode-popup-aw-btn-true"]) {
8472
+ const b = m.querySelector(id);
8473
+ if (!b || !awList) continue;
8474
+ b.addEventListener("click", (ev) => {
8475
+ ev.stopPropagation();
8476
+ const side = b.dataset.side;
8477
+ if (!awList.hidden && awList.dataset.side === side) { awList.hidden = true; return; }
8478
+ awList.dataset.side = side;
8479
+ const cur = side === "true" ? state.autowind?.profileTrue : state.autowind?.profileApparent;
8480
+ awList.textContent = "";
8481
+ for (const pName of (Array.isArray(state.profiles) ? state.profiles : [])) {
8482
+ const li = document.createElement("li");
8483
+ li.className = "mode-popup-item";
8484
+ li.dataset.profile = String(pName);
8485
+ li.textContent = String(pName);
8486
+ if (pName === cur) li.classList.add("active");
8487
+ awList.appendChild(li);
8488
+ }
8489
+ b.after(awList);
8490
+ awList.hidden = false;
8491
+ });
8492
+ }
8493
+ if (awList) awList.addEventListener("click", (ev) => {
8494
+ const li = ev.target.closest(".mode-popup-item");
8495
+ if (!li) return;
8496
+ const key = awList.dataset.side === "true" ? "profileTrue" : "profileApparent";
8497
+ _autowindPost({ [key]: li.dataset.profile });
8498
+ awList.hidden = true;
8499
+ });
8386
8500
  return m;
8387
8501
  }
8388
8502
  function _modeSelectPopupOpen() {
@@ -8406,18 +8520,29 @@
8406
8520
  const modeList = Array.isArray(state.modeList) ? state.modeList : [];
8407
8521
  let withPseudo = modeList.includes("aproado") ? modeList : [...modeList, "aproado"];
8408
8522
  if (!withPseudo.includes("empopado")) withPseudo = [...withPseudo, "empopado"];
8523
+ // Rev519: AUTO WIND always listed when pypilot has wind; greyed out
8524
+ // with the reason when true wind is missing (no GPS).
8525
+ const awNoTrue = !_autowindAvailable() && (Array.isArray(state.modeList) && state.modeList.includes("wind"));
8526
+ if ((_autowindAvailable() || awNoTrue) && !withPseudo.includes("autowind")) withPseudo = _withAutowindAfterCompass(withPseudo);
8527
+ const awOn = !!(state.autowind && state.autowind.enabled);
8409
8528
  list.textContent = "";
8410
8529
  for (const v of withPseudo) {
8411
8530
  const li = document.createElement("li");
8412
8531
  li.className = "mode-popup-item";
8413
8532
  li.setAttribute("role", "option");
8414
8533
  li.dataset.mode = String(v);
8415
- if (v === state.mode) li.classList.add("active");
8534
+ if (awOn ? v === "autowind" : v === state.mode) li.classList.add("active");
8416
8535
  // Rev441 (Carlos 2026-10-10): the modal shows the LONG labels
8417
8536
  // ("Viento Aparente", "Viento Real") so wind modes are explicit
8418
8537
  // on first open. The button on the bbar still uses the SHORT
8419
8538
  // label ("Aparente" / "Real") to fit the narrow chip.
8420
8539
  li.textContent = _modeLabel(v, true);
8540
+ if (v === "autowind" && awNoTrue) {
8541
+ li.dataset.mode = "";
8542
+ li.style.opacity = "0.45";
8543
+ li.style.cursor = "default";
8544
+ li.textContent = `${_modeLabel(v)} (${t("autowind.needsGps")})`;
8545
+ }
8421
8546
  list.appendChild(li);
8422
8547
  }
8423
8548
  // Refresh the profile sub-select so its header shows the active one.
@@ -10507,6 +10632,14 @@
10507
10632
  $("#mode-select").addEventListener("change", (e) => {
10508
10633
  _msCollapse();
10509
10634
  const chosen = e.target.value;
10635
+ // Rev518: pseudo-mode AUTOWIND lives in the backend.
10636
+ if (chosen === "autowind") {
10637
+ _autowindPost({ enabled: true });
10638
+ return;
10639
+ }
10640
+ if (state.autowind && state.autowind.enabled) {
10641
+ _autowindPost({ enabled: false });
10642
+ }
10510
10643
  // Rev88: pseudo-mode "aproado" is intercepted locally - opens the
10511
10644
  // choice modal (bow / stern) and starts the maneuver on confirmation.
10512
10645
  if (chosen === "aproado") {
@@ -11387,6 +11520,40 @@
11387
11520
  try { new ResizeObserver(placeTideBtn).observe(brCell); } catch { /* fallback bottom */ }
11388
11521
  }
11389
11522
  }
11523
+ // Rev517 (Carlos 2026-10-19): logbook button on the left, same look
11524
+ // and placement rule as the tide button (above the bottom-left tile).
11525
+ // Opens the Logbook card full screen; its x returns to the rose.
11526
+ const logBtn = document.getElementById("rose-log-btn");
11527
+ if (logBtn) {
11528
+ logBtn.setAttribute("aria-label", t("logbook.btn"));
11529
+ logBtn.setAttribute("title", t("logbook.btn"));
11530
+ logBtn.addEventListener("click", (ev) => {
11531
+ ev.preventDefault(); ev.stopPropagation();
11532
+ // Rev518 (Carlos): sailing -> the trip being recorded; in port ->
11533
+ // the logbook list as before.
11534
+ (async () => {
11535
+ try {
11536
+ const r = await skFetch(`/plugins/${PLUGIN_ID}/trip-recorder/status`);
11537
+ const st = r.ok ? await r.json() : null;
11538
+ if (st && st.recording && st.tripId) { _tripsOpenDetail(st.tripId); return; }
11539
+ } catch { /* fall back to the list */ }
11540
+ const block = document.querySelector('.setup-block[data-block-key="trips"]');
11541
+ if (!block) return;
11542
+ _setupFullscreenOn(block);
11543
+ try { _tripsRefresh(); } catch { /* silent */ }
11544
+ })();
11545
+ });
11546
+ logBtn.addEventListener("dblclick", (ev) => { ev.preventDefault(); ev.stopPropagation(); });
11547
+ const blCell = document.querySelector(".dash-rose .dash-cell.corner-bl");
11548
+ const placeLogBtn = () => {
11549
+ if (!blCell) return;
11550
+ logBtn.style.bottom = `${6 + blCell.offsetHeight + 14}px`;
11551
+ };
11552
+ placeLogBtn();
11553
+ if (blCell && typeof ResizeObserver === "function") {
11554
+ try { new ResizeObserver(placeLogBtn).observe(blCell); } catch { /* fallback bottom */ }
11555
+ }
11556
+ }
11390
11557
  // Rev479 (Carlos QA Rev478): "Mareas completo" = the anchor-watch
11391
11558
  // visor of signalk-mareas-ihm, opened INSIDE the rose like
11392
11559
  // Freeboard (same container classes, ⤢ maximize, × close).
@@ -16844,6 +17011,7 @@
16844
17011
  } catch { /* silent */ }
16845
17012
  // Rev498: shared visor prefs ride the same 5 s poll.
16846
17013
  _vpFetch();
17014
+ _autowindFetch();
16847
17015
  }
16848
17016
  function _alSeverityIcon(sev) {
16849
17017
  return sev === "alarm" ? "!" : sev === "warn" ? "⚠" : "ℹ";
package/public/index.html CHANGED
@@ -27,7 +27,7 @@
27
27
  style.css never shows the rose unstyled on a white page. The rose
28
28
  is hidden until style.css (loaded below) makes it visible again. -->
29
29
  <style>html,body{background:#0a1220;color:#e6edf3}#wind-rose{visibility:hidden}</style>
30
- <link rel="stylesheet" href="style.css?v=Rev516" />
30
+ <link rel="stylesheet" href="style.css?v=Rev520" />
31
31
  </head>
32
32
  <body class="theme-dark">
33
33
  <!-- Rev490 (GPU audit 2026-10-15): flag the Raspberry Pi's own screen
@@ -156,6 +156,17 @@
156
156
  <path d="M0,22 C5,22 7,8 12,8 S19,24 24,24 S30,14 32,12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
157
157
  </svg>
158
158
  </button>
159
+ <!-- Rev517 (Carlos 2026-10-19): logbook button, mirror of the tide
160
+ button on the LEFT, just above the bottom-left corner tile.
161
+ Opens the Logbook card full screen without leaving the rose. -->
162
+ <button type="button" class="freeboard-embed-btn rose-corner-btn rose-corner-left" id="rose-log-btn"
163
+ aria-label="Logbook" title="Logbook">
164
+ <svg viewBox="0 0 32 32" aria-hidden="true">
165
+ <circle cx="16" cy="16" r="9.5" fill="none" stroke="currentColor" stroke-width="2.2"/>
166
+ <circle cx="16" cy="16" r="2.6" fill="currentColor"/>
167
+ <path d="M16.0,11.8 L16.0,2.0 M19.0,13.0 L25.9,6.1 M20.2,16.0 L30.0,16.0 M19.0,19.0 L25.9,25.9 M16.0,20.2 L16.0,30.0 M13.0,19.0 L6.1,25.9 M11.8,16.0 L2.0,16.0 M13.0,13.0 L6.1,6.1" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
168
+ </svg>
169
+ </button>
159
170
  <!-- Rev432 (Carlos QA Rev431): AIS collision infobox + ACK as
160
171
  an HTML absolute div (not SVG) so the ACK click is 100 %
161
172
  reliable (no re-render-swallows-tap race), the box always
@@ -2651,6 +2662,6 @@
2651
2662
  </div>
2652
2663
  </div>
2653
2664
 
2654
- <script src="app.js?v=Rev516"></script>
2665
+ <script src="app.js?v=Rev520"></script>
2655
2666
  </body>
2656
2667
  </html>
package/public/style.css CHANGED
@@ -4377,6 +4377,16 @@ body.pypilot-offline #pypilot-offline-banner { display: block; }
4377
4377
  overflow: hidden;
4378
4378
  }
4379
4379
  #rose-tide-btn svg { width: 100%; height: 100%; display: block; }
4380
+ /* Rev517: logbook button = same look as the tide button, on the left. */
4381
+ .rose-corner-btn.rose-corner-left { right: auto; left: 6px; }
4382
+ #rose-log-btn {
4383
+ background: #000;
4384
+ border-color: var(--accent);
4385
+ color: var(--accent);
4386
+ padding: 2px;
4387
+ overflow: hidden;
4388
+ }
4389
+ #rose-log-btn svg { width: 100%; height: 100%; display: block; }
4380
4390
  .rose-corner-btn[hidden] { display: none; }
4381
4391
  /* Rev486: tide popup content. Popup chrome comes from .popup-box; only
4382
4392
  the curve is styled here, with theme variables (UI_STYLE_GUIDE r.10). */