signalk-pypilot-newui 2.10.0 → 2.11.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/dist/alarms.js +24 -1
- package/dist/autopilot-provider.js +172 -29
- package/dist/index.js +247 -10
- package/dist/maneuver-trace.js +22 -0
- package/dist/trip-recorder.js +68 -1
- package/package.json +25 -7
- package/public/app.js +1965 -200
- package/public/index.html +59 -10
- package/public/style.css +51 -10
package/dist/alarms.js
CHANGED
|
@@ -473,6 +473,20 @@ class AlarmEngine {
|
|
|
473
473
|
}
|
|
474
474
|
return changed;
|
|
475
475
|
}
|
|
476
|
+
/** Rev342 (Carlos, 2026-09-28): override the severity of one rule
|
|
477
|
+
* at runtime. The RuleDef default is preserved so subsequent boots
|
|
478
|
+
* without an override recover the shipped severity. Returns true
|
|
479
|
+
* on success, false if the rule id or severity is unknown. */
|
|
480
|
+
setRuleSeverity(ruleId, severity) {
|
|
481
|
+
const rule = this.rules.get(ruleId);
|
|
482
|
+
const s = this.state.get(ruleId);
|
|
483
|
+
if (!rule || !s)
|
|
484
|
+
return false;
|
|
485
|
+
if (severity !== "info" && severity !== "warn" && severity !== "alarm")
|
|
486
|
+
return false;
|
|
487
|
+
s.severity = severity;
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
476
490
|
/** Rev292: change the sustain window of one rule at runtime, e.g.
|
|
477
491
|
* when the sailor updates alarmPypilotDiscSec in /supervisor/config.
|
|
478
492
|
* Returns true on success, false if the rule id is unknown. Values
|
|
@@ -570,7 +584,16 @@ class AlarmEngine {
|
|
|
570
584
|
out.push({
|
|
571
585
|
id,
|
|
572
586
|
label: rule.label,
|
|
573
|
-
|
|
587
|
+
// Rev375 (Carlos, 2026-09-30): was `rule.severity` (default),
|
|
588
|
+
// which made the frontend severity dropdown revert to default
|
|
589
|
+
// on every _alFetch poll. `s.severity` reflects setRuleSeverity
|
|
590
|
+
// overrides applied at boot from props.alarmSeverityOverrides
|
|
591
|
+
// and by runtime POSTs to /alarms/severity/:id.
|
|
592
|
+
severity: s.severity,
|
|
593
|
+
// Rev376 (Carlos, 2026-09-30): expose the shipped default so
|
|
594
|
+
// the frontend dropdown can mark it (★) — sailor asked to see
|
|
595
|
+
// which option is "the default" before changing it.
|
|
596
|
+
defaultSeverity: rule.severity,
|
|
574
597
|
enabled: s.enabled,
|
|
575
598
|
description: rule.description,
|
|
576
599
|
sustainSec: rule.sustainSec,
|
|
@@ -101,6 +101,7 @@ class AutopilotProvider {
|
|
|
101
101
|
if (opts?.allowDodge)
|
|
102
102
|
this.allowDodge = true;
|
|
103
103
|
this.onDataChanged = opts?.onDataChanged;
|
|
104
|
+
this.onStageEvent = opts?.onStageEvent;
|
|
104
105
|
}
|
|
105
106
|
notifyChanged(fields) {
|
|
106
107
|
try {
|
|
@@ -108,10 +109,23 @@ class AutopilotProvider {
|
|
|
108
109
|
}
|
|
109
110
|
catch { /* silent */ }
|
|
110
111
|
}
|
|
111
|
-
// Called from PypilotClient 'value' event. Returns
|
|
112
|
-
//
|
|
112
|
+
// Called from PypilotClient 'value' event. Returns the field that
|
|
113
|
+
// changed (so the caller can push a field-scoped delta and avoid
|
|
114
|
+
// dragging siblings with stale values), or null if nothing changed.
|
|
115
|
+
//
|
|
116
|
+
// Rev352 (Carlos, 2026-09-30, trace analysis): previously returned
|
|
117
|
+
// boolean and the caller line 729 defaulted to fields="all" — that
|
|
118
|
+
// emitted state+engaged+target+mode+availableActions with whatever
|
|
119
|
+
// apProvider.data held at that instant. When pypilot took seconds
|
|
120
|
+
// to echo an engage (external tack from mando/nativo), any other
|
|
121
|
+
// pypilot value arriving in the meantime triggered fields="all"
|
|
122
|
+
// publishes with the stale engaged=false. The next echo produced
|
|
123
|
+
// fields="all" with engaged=true, but subscription manager's
|
|
124
|
+
// minPeriod:500 collapsed the pair and the visor saw only false.
|
|
125
|
+
// Field-scoping the push kills the collision at source.
|
|
113
126
|
receiveValue(name, value) {
|
|
114
127
|
let changed = false;
|
|
128
|
+
let changedField = null;
|
|
115
129
|
switch (name) {
|
|
116
130
|
case "ap.heading_command":
|
|
117
131
|
if (typeof value === "number") {
|
|
@@ -132,30 +146,105 @@ class AutopilotProvider {
|
|
|
132
146
|
// Real echo landed - clear pending, keep our value (may be
|
|
133
147
|
// slightly more precise than the echo). No change signal
|
|
134
148
|
// needed since data.target already equals the pending value.
|
|
149
|
+
this.onStageEvent?.("provider", {
|
|
150
|
+
path: "ap.heading_command",
|
|
151
|
+
decision: "echo_dropped_match",
|
|
152
|
+
reason: "diff_within_tol",
|
|
153
|
+
rawValue: value,
|
|
154
|
+
normalizedValue: rad,
|
|
155
|
+
pendingValue: this.pendingTarget.value,
|
|
156
|
+
diffRad: diff,
|
|
157
|
+
});
|
|
135
158
|
this.pendingTarget = null;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
// Rev350 (Carlos, 2026-09-29, cross-check LLM audit): diff
|
|
162
|
+
// GRANDE dentro de la ventana pending. Antes: descartábamos
|
|
163
|
+
// el valor y no limpiábamos pending → si pypilot no volvía
|
|
164
|
+
// a publicar tras el drop, data.target quedaba rancio hasta
|
|
165
|
+
// que expiraba pending Y pypilot re-emitía. Confirmado por
|
|
166
|
+
// GPT-Codex: "la ventana dura 2.5 s pero el valor descartado
|
|
167
|
+
// puede perderse indefinidamente". Ver memoria
|
|
168
|
+
// [[backend-echo-cancel-drops-external-override]].
|
|
169
|
+
//
|
|
170
|
+
// Ahora: interpretamos diff > TOL como OVERRIDE EXTERNO
|
|
171
|
+
// genuino (mando físico / UI nativo cambió target mientras
|
|
172
|
+
// nuestra escritura estaba pendiente). Aplicamos el valor,
|
|
173
|
+
// liberamos el gate, notificamos cambio. El caso Rev68
|
|
174
|
+
// original (echo con mismo valor) sigue funcionando: cae en
|
|
175
|
+
// la rama `<= TOL` y limpia pending sin cambiar target.
|
|
176
|
+
const pendingSnap = this.pendingTarget.value;
|
|
177
|
+
this.pendingTarget = null;
|
|
178
|
+
this.onStageEvent?.("provider", {
|
|
179
|
+
path: "ap.heading_command",
|
|
180
|
+
decision: "external_override_applied",
|
|
181
|
+
reason: "diff_over_tol_in_pending_window",
|
|
182
|
+
rawValue: value,
|
|
183
|
+
normalizedValue: rad,
|
|
184
|
+
pendingValue: pendingSnap,
|
|
185
|
+
diffRad: diff,
|
|
186
|
+
});
|
|
187
|
+
if (rad !== this.data.target) {
|
|
188
|
+
this.data.target = rad;
|
|
189
|
+
changed = true;
|
|
190
|
+
changedField = "target";
|
|
136
191
|
}
|
|
137
|
-
// Whether match or stale, do not overwrite data.target inside
|
|
138
|
-
// the pending window.
|
|
139
192
|
break;
|
|
140
193
|
}
|
|
141
|
-
|
|
142
|
-
this.data.target
|
|
143
|
-
|
|
194
|
+
{
|
|
195
|
+
const prev = this.data.target;
|
|
196
|
+
if (rad !== prev) {
|
|
197
|
+
this.data.target = rad;
|
|
198
|
+
changed = true;
|
|
199
|
+
changedField = "target";
|
|
200
|
+
}
|
|
201
|
+
this.onStageEvent?.("provider", {
|
|
202
|
+
path: "ap.heading_command",
|
|
203
|
+
decision: changed ? "accepted_change" : "accepted_noop",
|
|
204
|
+
reason: "no_pending_window",
|
|
205
|
+
rawValue: value,
|
|
206
|
+
normalizedValue: rad,
|
|
207
|
+
prevValue: prev,
|
|
208
|
+
});
|
|
144
209
|
}
|
|
145
210
|
}
|
|
146
211
|
else if (value === false && this.data.target !== null) {
|
|
212
|
+
const prev = this.data.target;
|
|
147
213
|
this.data.target = null;
|
|
148
214
|
this.pendingTarget = null;
|
|
149
215
|
changed = true;
|
|
216
|
+
changedField = "target";
|
|
217
|
+
this.onStageEvent?.("provider", {
|
|
218
|
+
path: "ap.heading_command",
|
|
219
|
+
decision: "accepted_change",
|
|
220
|
+
reason: "value_false_cleared_target",
|
|
221
|
+
rawValue: value,
|
|
222
|
+
prevValue: prev,
|
|
223
|
+
});
|
|
150
224
|
}
|
|
151
225
|
break;
|
|
152
226
|
case "ap.mode":
|
|
153
|
-
if (typeof value === "string"
|
|
154
|
-
this.data.mode
|
|
155
|
-
if (
|
|
156
|
-
this.data.
|
|
227
|
+
if (typeof value === "string") {
|
|
228
|
+
const prev = this.data.mode;
|
|
229
|
+
if (value !== prev) {
|
|
230
|
+
this.data.mode = value;
|
|
231
|
+
if (this.data.options.modes.length === 0) {
|
|
232
|
+
this.data.options.modes.push(value);
|
|
233
|
+
}
|
|
234
|
+
changed = true;
|
|
235
|
+
// Rev352: mode change also affects recomputeActions (some
|
|
236
|
+
// actions like courseCurrentPoint gate on mode). Push "all"
|
|
237
|
+
// so state+engaged+target+mode+availableActions stay in
|
|
238
|
+
// sync. This IS a genuine multi-field change, not a
|
|
239
|
+
// spurious drag.
|
|
240
|
+
changedField = "all";
|
|
157
241
|
}
|
|
158
|
-
|
|
242
|
+
this.onStageEvent?.("provider", {
|
|
243
|
+
path: "ap.mode",
|
|
244
|
+
decision: changed ? "accepted_change" : "accepted_noop",
|
|
245
|
+
rawValue: value,
|
|
246
|
+
prevValue: prev,
|
|
247
|
+
});
|
|
159
248
|
}
|
|
160
249
|
break;
|
|
161
250
|
case "ap.modes":
|
|
@@ -171,6 +260,7 @@ class AutopilotProvider {
|
|
|
171
260
|
// stayed unavailable until the next engage/disengage.
|
|
172
261
|
this.recomputeActions();
|
|
173
262
|
changed = true;
|
|
263
|
+
changedField = "all";
|
|
174
264
|
}
|
|
175
265
|
break;
|
|
176
266
|
case "ap.enabled": {
|
|
@@ -185,23 +275,51 @@ class AutopilotProvider {
|
|
|
185
275
|
// engage-disengage-engage. Ignore echoes that don't match the
|
|
186
276
|
// pending intent while the pending window is open.
|
|
187
277
|
if (this.pendingEngaged && Date.now() < this.pendingEngaged.until) {
|
|
188
|
-
|
|
278
|
+
const pendVal = this.pendingEngaged.value;
|
|
279
|
+
if (eng === pendVal) {
|
|
189
280
|
this.pendingEngaged = null;
|
|
281
|
+
this.onStageEvent?.("provider", {
|
|
282
|
+
path: "ap.enabled",
|
|
283
|
+
decision: "echo_dropped_match",
|
|
284
|
+
rawValue: value,
|
|
285
|
+
normalizedValue: eng,
|
|
286
|
+
pendingValue: pendVal,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
this.onStageEvent?.("provider", {
|
|
291
|
+
path: "ap.enabled",
|
|
292
|
+
decision: "echo_dropped_stale",
|
|
293
|
+
rawValue: value,
|
|
294
|
+
normalizedValue: eng,
|
|
295
|
+
pendingValue: pendVal,
|
|
296
|
+
});
|
|
190
297
|
}
|
|
191
298
|
break;
|
|
192
299
|
}
|
|
193
300
|
const st = eng ? "enabled" : "disabled";
|
|
301
|
+
const prevState = this.data.state;
|
|
302
|
+
const prevEngaged = this.data.engaged;
|
|
194
303
|
if (this.data.state !== st || this.data.engaged !== eng) {
|
|
195
304
|
this.data.state = st;
|
|
196
305
|
this.data.engaged = eng;
|
|
197
306
|
changed = true;
|
|
307
|
+
changedField = "engaged";
|
|
198
308
|
}
|
|
309
|
+
this.onStageEvent?.("provider", {
|
|
310
|
+
path: "ap.enabled",
|
|
311
|
+
decision: changed ? "accepted_change" : "accepted_noop",
|
|
312
|
+
rawValue: value,
|
|
313
|
+
normalizedValue: eng,
|
|
314
|
+
prevValue: prevEngaged,
|
|
315
|
+
prevState,
|
|
316
|
+
});
|
|
199
317
|
break;
|
|
200
318
|
}
|
|
201
319
|
}
|
|
202
320
|
if (changed)
|
|
203
321
|
this.recomputeActions();
|
|
204
|
-
return
|
|
322
|
+
return changedField;
|
|
205
323
|
}
|
|
206
324
|
markOffline() {
|
|
207
325
|
if (this.data.state !== "off-line" || this.data.engaged) {
|
|
@@ -266,33 +384,58 @@ class AutopilotProvider {
|
|
|
266
384
|
// the retry backoff, the stale isStale() check aborts the retry
|
|
267
385
|
// and we throw without mutating optimistic state.
|
|
268
386
|
const gen = this._bumpEngageGen();
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
387
|
+
// Rev351 (Carlos, 2026-09-29, sea-trial QA Rev350): optimist STATE
|
|
388
|
+
// + pending window BEFORE the await. Antes: pendingEngaged sólo se
|
|
389
|
+
// seteaba tras el ACK de pypilot. Durante ese await (~10 s en engage
|
|
390
|
+
// observados por Carlos en localhost), el watch 2 Hz de pypilot
|
|
391
|
+
// seguía emitiendo `ap.enabled=false` (stale) y el receiveValue lo
|
|
392
|
+
// aplicaba como estado real → push SK engaged=false → visor
|
|
393
|
+
// revertía la UI optimista. Disengage no lo sufría porque pypilot
|
|
394
|
+
// aplica disable en < 500 ms y el echo real llegaba inmediato.
|
|
395
|
+
//
|
|
396
|
+
// Fix: setear pendingEngaged + data.state/engaged + notifyChanged
|
|
397
|
+
// ANTES del _setWithRetry, con ventana extendida a 15 s para cubrir
|
|
398
|
+
// el peor caso de latencia pypilot. Si el set falla, revertimos.
|
|
399
|
+
const prevState = this.data.state;
|
|
400
|
+
const prevEngaged = this.data.engaged;
|
|
401
|
+
this.pendingEngaged = { value: eng, until: Date.now() + 15000 };
|
|
402
|
+
this.data.state = apSt;
|
|
403
|
+
this.data.engaged = eng;
|
|
404
|
+
this.recomputeActions();
|
|
405
|
+
// Rev84: publish ONLY the engaged/state/actions delta - do NOT
|
|
406
|
+
// include target. If setTarget was called in parallel, its own
|
|
407
|
+
// notifyChanged("target") will publish the new target value
|
|
408
|
+
// independently. Publishing target here would carry the STALE
|
|
409
|
+
// apProvider.data.target and clobber JS state.target if this
|
|
410
|
+
// delta happens to land second.
|
|
411
|
+
this.notifyChanged("engaged");
|
|
412
|
+
// Rev254 (Carlos audit): reject the write on offline socket.
|
|
272
413
|
// Rev255: retry with backoff to tolerate socket.io reconnects.
|
|
414
|
+
// Rev351: on failure, revert the optimist so the UI reflects
|
|
415
|
+
// reality (unless a newer bump has already overwritten us).
|
|
273
416
|
if (!(await this._setWithRetry("ap.enabled", eng, () => this.engageGen !== gen))) {
|
|
417
|
+
if (this.engageGen === gen) {
|
|
418
|
+
this.pendingEngaged = null;
|
|
419
|
+
this.data.state = prevState;
|
|
420
|
+
this.data.engaged = prevEngaged;
|
|
421
|
+
this.recomputeActions();
|
|
422
|
+
this.notifyChanged("engaged");
|
|
423
|
+
}
|
|
274
424
|
if (this.engageGen !== gen) {
|
|
275
425
|
throw new Error("engage/disengage superseded by newer order");
|
|
276
426
|
}
|
|
277
427
|
throw new Error("pypilot offline: engage/disengage not delivered");
|
|
278
428
|
}
|
|
279
429
|
// Rev271: last-check after the successful set - a newer bump could
|
|
280
|
-
// have won the race between the emit and this line.
|
|
281
|
-
//
|
|
430
|
+
// have won the race between the emit and this line. If superseded,
|
|
431
|
+
// the winning order has already published its own optimist; leave
|
|
432
|
+
// state alone and let the winner's echo reconcile.
|
|
282
433
|
if (this.engageGen !== gen) {
|
|
283
434
|
throw new Error("engage/disengage superseded after write");
|
|
284
435
|
}
|
|
285
|
-
|
|
286
|
-
|
|
436
|
+
// Rev351: pypilot ACK-eó el set. Reduce la ventana pending al
|
|
437
|
+
// valor normal (2.5 s) porque el eco real llegará en < 1 s típico.
|
|
287
438
|
this.pendingEngaged = { value: eng, until: Date.now() + TARGET_PENDING_MS };
|
|
288
|
-
this.recomputeActions();
|
|
289
|
-
// Rev84: publish ONLY the engaged/state/actions delta - do NOT
|
|
290
|
-
// include target. If setTarget was called in parallel, its own
|
|
291
|
-
// notifyChanged("target") will publish the new target value
|
|
292
|
-
// independently. Publishing target here would carry the STALE
|
|
293
|
-
// apProvider.data.target and clobber JS state.target if this
|
|
294
|
-
// delta happens to land second.
|
|
295
|
-
this.notifyChanged("engaged");
|
|
296
439
|
return eng;
|
|
297
440
|
}
|
|
298
441
|
async setMode(mode) {
|