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 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
- severity: rule.severity,
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 true if apData changed
112
- // and an autopilotUpdate should be pushed.
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
- if (rad !== this.data.target) {
142
- this.data.target = rad;
143
- changed = true;
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" && value !== this.data.mode) {
154
- this.data.mode = value;
155
- if (this.data.options.modes.length === 0) {
156
- this.data.options.modes.push(value);
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
- changed = true;
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
- if (eng === this.pendingEngaged.value) {
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 changed;
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
- // Rev254 (Carlos audit): reject the write BEFORE mutating optimistic
270
- // state if the pypilot socket is offline. Previously the visor was
271
- // told the order succeeded even though nothing left the plugin.
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. Do not mutate
281
- // optimistic state in that case; the winner's own setState will.
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
- this.data.state = apSt;
286
- this.data.engaged = eng;
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) {