signalk-pypilot-newui 2.7.3 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.4 — 2026-09-15 — Async control tightening + uppercase accents
4
+
5
+ Closes three findings from the external Rev277 review and finishes
6
+ the Spanish accent pass across UPPERCASE headers and badges.
7
+
8
+ ### Async control
9
+
10
+ - **NAV activation can no longer survive a disengage.** `setNavMode`
11
+ now snapshots the engage generation BEFORE `getCourse()` and
12
+ `setMode("nav")`, and bails silently if a disengage arrives during
13
+ either await. A superseded NAV attempt no longer arms the 500 ms
14
+ timer that could have turned a cancelled request into a fresh
15
+ engage. `engage()` also filters "superseded" errors so its own
16
+ fallback into `setState("enabled")` cannot resurrect the cancelled
17
+ intent.
18
+ - **Stale target and mode retries can no longer overwrite newer
19
+ ones.** `setTarget` / `setMode` / `adjustTarget` now each bump a
20
+ dedicated generation counter and pass an `isStale()` callback into
21
+ `_setWithRetry`. If a first `setTarget(1 rad)` was still in retry
22
+ when a second `setTarget(2 rad)` succeeded, the first no longer
23
+ wakes up and pushes 1 rad. Same class of guard R01 already applied
24
+ to `ap.enabled`; now it covers all three write paths.
25
+ - **Restore verdict actually verifies mode and target.** The 10 s
26
+ poll used to only check `pypilotHealthy` and `engaged`; that let a
27
+ pypilot ignoring the mode / target orders still be announced as
28
+ "restored". Now the verdict also compares `state.mode` against
29
+ the snapshot and `state.target` against it (with a 3° tolerance
30
+ for pypilot's own rounding on the wire) before speaking success.
31
+ A mismatch fires the failure banner with a specific reason
32
+ ("mode did not switch" / "target did not match").
33
+
34
+ ### i18n
35
+
36
+ - Second Spanish accent pass covering UPPERCASE tokens the previous
37
+ pass missed: `ESTADÍSTICAS`, `HISTÓRICO`, `SESIÓN`, `CONEXIÓN`,
38
+ `CONFIGURACIÓN`, `CATÁLOGO`, `GRÁFICA`, `MÁS RÁPIDA`, `TIMÓN`,
39
+ `ÁNGULO`, `MÁXIMA`, and so on.
40
+
3
41
  ## 2.7.3 — 2026-09-14 — Spanish accents restored
4
42
 
5
43
  The Spanish translation now carries the accents it always should have
@@ -73,6 +73,14 @@ class AutopilotProvider {
73
73
  // 500 ms NAV-mode setTimeout that setNavMode arms, so a disengage
74
74
  // during that window cannot be undone by the timer waking up.
75
75
  this.engageGen = 0;
76
+ // Rev279 (audit follow-up A / B): parallel counters for target and
77
+ // mode writes. Bumped on every setTarget / setMode / adjustTarget
78
+ // and consulted by the retry loop, so a stale write that was
79
+ // waiting to retry aborts before overwriting a newer intent. R01
80
+ // only protected ap.enabled; these close the same class of race
81
+ // on the two other write paths.
82
+ this.targetGen = 0;
83
+ this.modeGen = 0;
76
84
  this.navPendingTimer = null;
77
85
  // Rev272 (audit R06): adjustTarget reads data.target BEFORE awaiting
78
86
  // the write, so two concurrent +Δ calls could both start from the
@@ -280,11 +288,20 @@ class AutopilotProvider {
280
288
  !this.data.options.modes.includes(mode)) {
281
289
  throw new Error(`Invalid mode: ${mode}`);
282
290
  }
291
+ // Rev279 (audit follow-up B): bump modeGen so a stale mode retry
292
+ // aborts before overwriting a newer setMode / setState intent.
293
+ const gen = ++this.modeGen;
283
294
  // Rev254 (Carlos audit): fail explicit when pypilot offline.
284
295
  // Rev255: retry with backoff.
285
- if (!(await this._setWithRetry("ap.mode", mode))) {
296
+ if (!(await this._setWithRetry("ap.mode", mode, () => this.modeGen !== gen))) {
297
+ if (this.modeGen !== gen) {
298
+ throw new Error("mode change superseded by newer order");
299
+ }
286
300
  throw new Error("pypilot offline: mode change not delivered");
287
301
  }
302
+ if (this.modeGen !== gen) {
303
+ throw new Error("mode change superseded after write");
304
+ }
288
305
  }
289
306
  async setTarget(rad) {
290
307
  // Rev272 (audit R07): reject at the boundary. Downstream callers
@@ -302,11 +319,22 @@ class AutopilotProvider {
302
319
  // side).
303
320
  // eslint-disable-next-line no-console
304
321
  console.log(`[apProvider.setTarget] rad=${rad.toFixed(4)} deg=${deg.toFixed(2)}`);
322
+ // Rev279 (audit follow-up B): bump targetGen so a stale target
323
+ // retry aborts before overwriting a newer setTarget. Without this
324
+ // guard, target=1 rad → 2 rad → retry(1 rad) ended up leaving
325
+ // 1 rad on pypilot.
326
+ const gen = ++this.targetGen;
305
327
  // Rev254 (Carlos audit): reject BEFORE mutating optimistic target.
306
328
  // Rev255: retry with backoff.
307
- if (!(await this._setWithRetry("ap.heading_command", deg))) {
329
+ if (!(await this._setWithRetry("ap.heading_command", deg, () => this.targetGen !== gen))) {
330
+ if (this.targetGen !== gen) {
331
+ throw new Error("target superseded by newer order");
332
+ }
308
333
  throw new Error("pypilot offline: target not delivered");
309
334
  }
335
+ if (this.targetGen !== gen) {
336
+ throw new Error("target superseded after write");
337
+ }
310
338
  this.data.target = rad;
311
339
  this.pendingTarget = { value: rad, until: Date.now() + TARGET_PENDING_MS };
312
340
  this.notifyChanged("target");
@@ -326,11 +354,21 @@ class AutopilotProvider {
326
354
  if (this.data.engaged) {
327
355
  const base = typeof this.data.target === "number" ? this.data.target : 0;
328
356
  const newRad = base + rad;
357
+ // Rev279 (audit follow-up B): adjustTarget also bumps targetGen
358
+ // so an in-flight absolute setTarget cannot silently overwrite
359
+ // the adjusted value from a retry (and vice versa).
360
+ const gen = ++this.targetGen;
329
361
  // Rev254 (Carlos audit): same reject-before-optimistic guard.
330
362
  // Rev255: retry with backoff.
331
- if (!(await this._setWithRetry("ap.heading_command", newRad * RAD_TO_DEG))) {
363
+ if (!(await this._setWithRetry("ap.heading_command", newRad * RAD_TO_DEG, () => this.targetGen !== gen))) {
364
+ if (this.targetGen !== gen) {
365
+ throw new Error("target adjust superseded by newer order");
366
+ }
332
367
  throw new Error("pypilot offline: target adjust not delivered");
333
368
  }
369
+ if (this.targetGen !== gen) {
370
+ throw new Error("target adjust superseded after write");
371
+ }
334
372
  this.data.target = newRad;
335
373
  this.pendingTarget = { value: newRad, until: Date.now() + TARGET_PENDING_MS };
336
374
  // Rev84: publish ONLY the target delta.
@@ -388,10 +426,19 @@ class AutopilotProvider {
388
426
  await this.setTarget(newRad);
389
427
  }
390
428
  async engage() {
429
+ // Rev279 (audit follow-up A): the catch used to fall through to
430
+ // setState("enabled") on ANY setNavMode failure, including a
431
+ // "superseded by newer order" thrown when a disengage arrived
432
+ // during setMode's retry. That turned the cancellation into a
433
+ // fresh engage. setNavMode now returns silently on supersede
434
+ // (does not throw), but we still filter defensively so a
435
+ // superseded throw from lower layers cannot re-arm engage either.
391
436
  try {
392
437
  await this.setNavMode();
393
438
  }
394
- catch {
439
+ catch (e) {
440
+ if (/superseded/.test(String(e?.message)))
441
+ return;
395
442
  await this.setState("enabled");
396
443
  }
397
444
  }
@@ -399,23 +446,40 @@ class AutopilotProvider {
399
446
  await this.setState("disabled");
400
447
  }
401
448
  async setNavMode() {
449
+ // Rev279 (audit follow-up A): capture engageGen BEFORE the first
450
+ // await. On Rev278 the snapshot was taken after
451
+ // `await this.setMode("nav")`, so a disengage that landed during
452
+ // the mode retry ended up sharing the SAME generation as the
453
+ // arming step — the 500 ms timer then happily fired an engage the
454
+ // user had explicitly cancelled. Now: any bump between here and
455
+ // the timer means we bail silently. engage()'s catch is not
456
+ // supposed to re-fire that either, so we return normally instead
457
+ // of throwing (throwing would trigger engage()'s fallback into a
458
+ // direct setState("enabled"), reintroducing the same bug).
459
+ const gen = this.engageGen;
460
+ const isStale = () => this.engageGen !== gen;
402
461
  const cdata = await this.app.getCourse?.();
462
+ if (isStale())
463
+ return;
403
464
  if (cdata?.nextPoint &&
404
465
  this.getAvailableActionIds().includes("courseCurrentPoint")) {
405
- await this.setMode("nav");
406
- // Rev271 (audit R04): snapshot the generation at the moment we
407
- // arm the delayed engage. A disengage or other setState between
408
- // now and the timer firing will have bumped the counter, and the
409
- // timer will simply exit. Cancelling the timer directly (in
410
- // _bumpEngageGen) is the primary defence; this second check
411
- // covers the tiny window where the bump happens after our
412
- // clearTimeout would have fired.
413
- const gen = this.engageGen;
466
+ try {
467
+ await this.setMode("nav");
468
+ }
469
+ catch (e) {
470
+ // setMode threw because it was superseded — bail without
471
+ // arming the delayed engage.
472
+ if (/superseded/.test(String(e?.message)))
473
+ return;
474
+ throw e;
475
+ }
476
+ if (isStale())
477
+ return;
414
478
  if (this.navPendingTimer)
415
479
  clearTimeout(this.navPendingTimer);
416
480
  this.navPendingTimer = setTimeout(() => {
417
481
  this.navPendingTimer = null;
418
- if (this.engageGen !== gen)
482
+ if (isStale())
419
483
  return; // superseded, do nothing
420
484
  this.setState("enabled").catch(() => { });
421
485
  }, 500);
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ const session_recorder_1 = require("./session-recorder");
15
15
  const trip_recorder_1 = require("./trip-recorder");
16
16
  // Rev counter bumped on every build so the user can distinguish deploys
17
17
  // from the webapp header (feedback_revision_bump_each_build).
18
- const PLUGIN_REVISION = "Rev278";
18
+ const PLUGIN_REVISION = "Rev279";
19
19
  // Rev59: read package.json once at load time so /status can report the
20
20
  // npm package version alongside the internal Rev counter.
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "2.7.3",
3
+ "version": "2.7.4",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with the capability to spread its functions and buttons to other programs like KIP, WilhelmSK and freeboard. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings) as first-class Signal K paths and Simple Switches. Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
package/public/app.js CHANGED
@@ -564,6 +564,8 @@
564
564
  "restore.working": "Restoring... verifying pypilot confirmation",
565
565
  "restore.reason.noPy": "pypilot did not confirm",
566
566
  "restore.reason.noEng":"AP did not engage",
567
+ "restore.reason.modeMismatch": "mode did not switch",
568
+ "restore.reason.targetMismatch": "target did not match",
567
569
  // Alerts / prompts / confirms
568
570
  "alert.saveOk": "Saved.",
569
571
  "alert.saveFail": "Save failed. Are you logged in to Signal K admin?",
@@ -784,7 +786,7 @@
784
786
  "tab.setup": "Setup",
785
787
  "tab.info": "Info",
786
788
  // Chart tab (Rev96)
787
- "chart.stats.title": "ESTADISTICAS SESION",
789
+ "chart.stats.title": "ESTADÍSTICAS SESIÓN",
788
790
  "chart.session.empty": "Sesión no iniciada",
789
791
  "chart.session.started": "Sesión iniciada a las",
790
792
  "chart.session.ago": "hace",
@@ -799,7 +801,7 @@
799
801
  "chart.stat.servoRuntime": "Servo activo",
800
802
  "chart.stat.tacksGybes": "Viradas / Trasluchadas",
801
803
  "chart.stat.maxServoA": "Max servo A",
802
- "chart.history.title": "HISTORICO",
804
+ "chart.history.title": "HISTÓRICO",
803
805
  "chart.history.refresh": "Actualizar",
804
806
  "chart.empty": "Aún sin datos - esperando primera muestra...",
805
807
  "chart.servoHealth.title": "SALUD DEL SERVO",
@@ -1144,7 +1146,7 @@
1144
1146
  "tackLog.bestRet": "Mejor retención",
1145
1147
  "tackLog.bestRec": "Recuperación más rápida",
1146
1148
  "tackLog.badgeRet": "MEJOR RET",
1147
- "tackLog.badgeRec": "MAS RAPIDA",
1149
+ "tackLog.badgeRec": "MÁS RÁPIDA",
1148
1150
  "tackLog.recoverLabel": "Recuperación 95% SOG:",
1149
1151
  "tackLog.clearConfirm": "¿Vaciar todo el historial de bordos?",
1150
1152
  // Veredicto HUD de maniobra
@@ -1219,9 +1221,11 @@
1219
1221
  "restore.doneSpeak": "Target restaurado",
1220
1222
  "restore.failSpeak": "Restauración fallida",
1221
1223
  "restore.failed": "Restauración fallida",
1222
- "restore.working": "Restaurando... esperando confirmacion pypilot",
1224
+ "restore.working": "Restaurando... esperando confirmación pypilot",
1223
1225
  "restore.reason.noPy": "pypilot no confirma",
1224
1226
  "restore.reason.noEng":"AP no enganchó",
1227
+ "restore.reason.modeMismatch": "el modo no cambió",
1228
+ "restore.reason.targetMismatch": "el target no coincide",
1225
1229
  // Alerts / prompts / confirms
1226
1230
  "alert.saveOk": "Guardado.",
1227
1231
  "alert.saveFail": "Guardar fallo. Estas logueado como admin en Signal K?",
@@ -1278,7 +1282,7 @@
1278
1282
  "setup.restart.outage.pypilot": "Se reinician el core pypilot y su servidor web. El AP suelta el rumbo brevemente y se reengancha tras reconectar.",
1279
1283
  "setup.restart.outage.reboot": "Se reinicia la Raspberry Pi entera (kernel, servicios, red). NO hay autopiloto durante ~35-60 segundos. Arranque en frio.",
1280
1284
  "helm.outage": "Corte del piloto:",
1281
- "helm.warn": "ASEGURATE DE QUE ALGUIEN ESTA AL TIMON antes de continuar. No es el momento de estar bajo cubierta.",
1285
+ "helm.warn": "ASEGURATE DE QUE ALGUIEN ESTA AL TIMÓN antes de continuar. No es el momento de estar bajo cubierta.",
1282
1286
  "helm.cancel": "Cancelar",
1283
1287
  "helm.confirm": "Confirmo - hay alguien al timón",
1284
1288
  "helm.op.engage.title": "Enganchar piloto",
@@ -2951,12 +2955,21 @@
2951
2955
  if (!state.engaged) {
2952
2956
  throwIfNotOk(await apEngage(), "engage");
2953
2957
  }
2954
- // Rev274: poll every 500 ms up to 10 s. Bail out as soon
2955
- // as any negative evidence lands (healthy went false,
2956
- // disengage delta undid the engage). Only speak success
2957
- // if BOTH survive the whole window.
2958
+ // Rev279 (audit follow-up C): the previous verdict only
2959
+ // required healthy + engaged after 10 s. That let a pypilot
2960
+ // that ignored the mode / target orders (or that a competing
2961
+ // controller quickly overrode) still be announced as
2962
+ // "restaurado". Now we ALSO compare state.mode and
2963
+ // state.target against the snapshot the user asked to
2964
+ // restore, so we speak success only when the AP is engaged
2965
+ // AND healthy AND actually running with the requested mode
2966
+ // and target (within a 3° tolerance to allow for pypilot's
2967
+ // own rounding on the wire).
2958
2968
  const started = Date.now();
2959
2969
  const maxMs = 10000;
2970
+ const TOL_RAD = 3 * Math.PI / 180;
2971
+ const wantMode = snap.mode ? String(snap.mode).toLowerCase() : null;
2972
+ const wantTarget = typeof snap.target === "number" ? snap.target : null;
2960
2973
  const tick = () => {
2961
2974
  if (state.pypilotHealthy === false) {
2962
2975
  return verdict("fail", t("restore.reason.noPy"));
@@ -2965,6 +2978,18 @@
2965
2978
  return verdict("fail", t("restore.reason.noEng"));
2966
2979
  }
2967
2980
  if (Date.now() - started >= maxMs) {
2981
+ // Final check on mode and target before speaking OK.
2982
+ if (wantMode) {
2983
+ const curMode = String(state.mode || "").toLowerCase();
2984
+ if (curMode !== wantMode) {
2985
+ return verdict("fail", t("restore.reason.modeMismatch"));
2986
+ }
2987
+ }
2988
+ if (wantTarget !== null && typeof state.target === "number") {
2989
+ if (Math.abs(state.target - wantTarget) > TOL_RAD) {
2990
+ return verdict("fail", t("restore.reason.targetMismatch"));
2991
+ }
2992
+ }
2968
2993
  return verdict("ok");
2969
2994
  }
2970
2995
  setTimeout(tick, 500);
package/public/index.html CHANGED
@@ -121,7 +121,7 @@
121
121
  ANTES centro col x=8, DESPUÉS centro col x=48. -->
122
122
  <!-- Rev224 (Carlos): rediseno con separadores finos
123
123
  horizontales, flecha "->" entre valores ANTES y
124
- DESPUES y fila nueva de TIEMPO de ejecucion.
124
+ DESPUÉS y fila nueva de TIEMPO de ejecucion.
125
125
  AWA sin barras de valor absoluto. -->
126
126
  <rect x="-82" y="-84" width="164" height="168" rx="10"
127
127
  fill="rgba(11,15,20,0.98)" stroke="#4a5867" stroke-width="1.5"/>
@@ -2097,6 +2097,6 @@
2097
2097
  </div>
2098
2098
  </div>
2099
2099
 
2100
- <script src="app.js?v=Rev278"></script>
2100
+ <script src="app.js?v=Rev279"></script>
2101
2101
  </body>
2102
2102
  </html>