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 +38 -0
- package/dist/autopilot-provider.js +78 -14
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/public/app.js +34 -9
- package/public/index.html +2 -2
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 (
|
|
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 = "
|
|
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
|
+
"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": "
|
|
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": "
|
|
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": "
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2955
|
-
//
|
|
2956
|
-
//
|
|
2957
|
-
//
|
|
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
|
-
|
|
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=
|
|
2100
|
+
<script src="app.js?v=Rev279"></script>
|
|
2101
2101
|
</body>
|
|
2102
2102
|
</html>
|