signalk-pypilot-newui 2.1.0 → 2.2.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,346 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 — 2026-08-31 — Rev88..Rev127
4
+
5
+ Forty in-session Revs of navigation-oriented features on top of the
6
+ 2.1.0 visual foundation. Themes: turn the plugin into an intelligence
7
+ layer over pypilot (historian → KPIs → alarms → Doctor → pre-departure
8
+ check), give sail-raising its own AP mode ("Aproado"), and rebuild the
9
+ Setup tab as an app-launcher grid of fullscreen tiles.
10
+
11
+ ### English
12
+
13
+ **Added**
14
+
15
+ - **Aproado pseudo-mode** (Rev87-92) — a new "raise the sails"
16
+ autopilot mode not in pypilot upstream. Pick BY BOW / BY STERN
17
+ via a 5 s modal, the plugin switches pypilot to wind mode with
18
+ target 0° (head to wind), and a floating HUD over the boat shows
19
+ elapsed time, straight-line distance from the start and the
20
+ compass heading captured at activation. SALIR restores the
21
+ previous mode + target + engaged state. The BY STERN branch
22
+ forces a stern-through swing by walking a chain of intermediate
23
+ targets 60° apart so pypilot cannot shortcut through the bow.
24
+ - **Telemetry historian + Chart tab** (Rev93-96) — RAM ring buffer
25
+ at 1 Hz for the last 30 min (backend, Pi 5 friendly). New Chart
26
+ tab shows nine Trip Stats cards (AP engaged time, distance,
27
+ energy, mean/RMS/p95 heading error, servo runtime, tacks/gybes,
28
+ peak servo A) all fed live via SK paths, plus a canvas history
29
+ with 30 s / 2 min / 10 min windows and per-path chips.
30
+ - **Sensor Quality panel** (Rev97-98) — age / observed Hz / jitter /
31
+ source for every critical SK path the AP depends on, with a
32
+ green / amber / red pill. Endpoint `/quality` and a poll while
33
+ Setup is visible.
34
+ - **Servo Health card** (Rev99) — baseline current learned from
35
+ the first ~10 min of engaged navigation, then a live deviation
36
+ ratio flags "elevated" (>1.20×) or "high" (>1.65×) — early
37
+ warning for a hard rudder, fouling, or a stiffened linkage. Six
38
+ new SK paths under `steering.autopilot.pypilot.servo.health.*`.
39
+ - **Alarm engine** (Rev100-101) — seven built-in rules (heading
40
+ deviation, unable to steer, servo overcurrent / temp, low
41
+ voltage, sensor lost, pypilot disconnected) publishing
42
+ canonical SK notifications `notifications.autopilot.*`. Sticky
43
+ banner in the visor with ACK + close, WebAudio beeps +
44
+ speech-synthesis of the message, and a full config panel in
45
+ Setup (enable / mute / status per rule).
46
+ - **Pre-departure check** (Rev102) — a single verdict card
47
+ (READY / READY-WITH-CAVEATS / DO-NOT-ENGAGE) that aggregates
48
+ connectivity, sensor quality, servo health and voltage into one
49
+ answer before you leave the dock.
50
+ - **Pypilot Doctor** (Rev103-104, Rev120-121) — press DIAGNOSE,
51
+ the plugin records 2-3 min with the AP engaged, runs heuristic
52
+ rules on the heading error stream (bias / oscillation / low
53
+ authority) and proposes P / I / D adjustments with a plain
54
+ explanation of why and what to expect. Nothing is applied
55
+ automatically — each suggestion has Apply and Discard buttons.
56
+ Rev120: the first Apply of a session forks the current pypilot
57
+ profile into a new `doctor-YYYYMMDD-HHMM` profile so the
58
+ previous gains stay one profile-select away.
59
+ - **Setup as a launcher grid** (Rev105-119, 124) — every setup
60
+ block is now a square tile in a 2 / 3 / 4-column grid. Tap
61
+ opens fullscreen with a small X to close and ESC support.
62
+ Body scroll is locked while a tile is fullscreen. Live summary
63
+ pill on every tile so the state reads at a glance without
64
+ expanding. Connection consolidated (status + LAN scan + manual
65
+ host + Autopilot Provider in one card). "Emergency" renamed to
66
+ "Remote Control Console".
67
+ - **Interactive SSH console** (Rev113-115) — free-form command
68
+ input with on-screen arrow buttons for mobile history
69
+ navigation, accumulative output textarea, clipboard fallback
70
+ for HTTP LAN, and 14 preset buttons with plain human labels
71
+ (Memory / IP table / WiFi / Temperature / Who is connected /
72
+ ...). BusyBox / piCore-safe fallbacks for every command.
73
+ - **Full RangeSetting sliders in Calibration** (Rev116-119) —
74
+ every pypilot `RangeSetting` grouped by category (rudder /
75
+ servo / imu / ap / other) with the same look as the Tune tab,
76
+ a lock checkbox with the padlock emoji, a frozen "before:"
77
+ baseline that never moves as you drag, and a per-slider ↺
78
+ restore button that snaps back to the baseline.
79
+ - **Nudge steps moved inside Calibration** (Rev124) — one card
80
+ less in the launcher grid; heading trim buttons live where
81
+ they belong conceptually.
82
+
83
+ **Changed**
84
+
85
+ - **Gota Chain shapes SWAP** (Rev122-126) — after several iterations
86
+ with Carlos on Tunatunes: the AMBER "A" arrow (apparent wind) is
87
+ now the LARGE piece (its tip reaches deep into the rose), and
88
+ the TEAL "T" arrow (true wind) is the SMALL outer one. Colors
89
+ and letters kept their canonical meaning; only the SVG paths
90
+ moved so the amber piece is visually the dominant one.
91
+ - **Alarm summary semantics** (Rev107-108) — "activa" (fired) was
92
+ ambiguous with "activada" (enabled). New wording separates
93
+ `firing / sonando` (fired now) from `watching / vigilando`
94
+ (enabled and healthy). Summary always shows both counts.
95
+ - **i18n audit** (Rev105, Rev112, Rev121) — every hard-coded
96
+ English string in the ES bundle purged ("engagear" → "activar",
97
+ KIP paths table fully translated, alarm messages), backend
98
+ suggestion reason / effect exposed as i18n keys + args so the
99
+ frontend renders in the user's language.
100
+ - **Sensor Quality thresholds** (Rev98) — dropped `minHz` from the
101
+ defaults; the 1 Hz sampler capped observed Hz, so the threshold
102
+ produced spurious "degraded" flags. Freshness (`age`) is now
103
+ the only signal that penalises status.
104
+
105
+ **Fixed**
106
+
107
+ - **Fullscreen tile portal** (Rev110-111) — Setup fullscreen tiles
108
+ are now portaled to `<body>` so the `.tab-panel` transform
109
+ (used by the tab animation) cannot break `position: fixed`.
110
+ Fullscreen always covers the viewport now, no matter which
111
+ card was scrolled into.
112
+ - **Copy button on HTTP** (Rev114) — `navigator.clipboard` is a
113
+ secure-context API; on plain http://192.168.x.y it silently
114
+ no-ops. Compat helper falls back to `document.execCommand("copy")`.
115
+ - **Doctor `elapsedSec`** (Rev104) — was counting past the target
116
+ duration once the session completed. Now freezes at the target.
117
+ - **Sliders baseline eaten by refresh** (Rev117-118) — the
118
+ Calibration "before:" value used to reset to the just-edited
119
+ number on every `/values` poll. Now captured once per name and
120
+ kept frozen across renders.
121
+ - **Aproado restore** (Rev90) — mode / target / engaged restore on
122
+ SALIR was skipping steps after one failure. Now each step is
123
+ guarded independently and logged in the console.
124
+ - **Sensor Quality parsing** (Rev94) — `navigation.attitude` is a
125
+ composite `{pitch, roll, yaw}` object, not a scalar path.
126
+ Custom subfield reader added for heel.
127
+ - Cosmetic Firefox warnings (`::-moz-focus-inner`, comment-nested
128
+ `*/`) purged (Rev95-106).
129
+
130
+ ### Español
131
+
132
+ **Nuevo**
133
+
134
+ - **Modo Aproado** (Rev87-92): pseudo-modo AP para subir velas.
135
+ Elige POR PROA / POR POPA en un modal de 5 s, el plugin pasa
136
+ pypilot a modo viento con objetivo 0° (proa al viento), y un
137
+ HUD flotante sobre el barco muestra tiempo transcurrido,
138
+ distancia lineal desde el arranque y el rumbo compás capturado
139
+ al activar. SALIR restaura modo + objetivo + engaged previos.
140
+ POR POPA fuerza el swing por popa con una cadena de objetivos
141
+ intermedios cada 60° para que pypilot no atajaje por proa.
142
+ - **Historian de telemetría + pestaña Gráfica** (Rev93-96):
143
+ buffer circular en RAM a 1 Hz durante 30 min (backend, Pi 5).
144
+ Nueva pestaña Gráfica con 9 tarjetas de estadísticas de sesión
145
+ (tiempo activo, distancia, energía, error medio / RMS / p95,
146
+ servo activo, viradas / trasluchadas, pico servo A) y canvas
147
+ con ventanas 30 s / 2 min / 10 min.
148
+ - **Panel Calidad de sensores** (Rev97-98): edad / Hz / jitter /
149
+ fuente por cada ruta SK crítica con semáforo verde / ámbar /
150
+ rojo.
151
+ - **Tarjeta Salud del servo** (Rev99): baseline aprendido de los
152
+ primeros ~10 min de navegación activa, luego ratio de
153
+ desviación marca "elevated" (>1.20×) o "high" (>1.65×) —
154
+ alerta temprana de timón duro, incrustaciones o rozamientos.
155
+ - **Motor de alarmas** (Rev100-101): 7 reglas built-in
156
+ (desviación de rumbo, incapaz de gobernar, sobrecorriente
157
+ servo, temperatura servo, batería baja, sensor perdido,
158
+ pypilot desconectado) publicando notifications SK canónicas.
159
+ Banner sticky en el visor con ACK y cierre X, beeps WebAudio
160
+ + speechSynthesis del mensaje, panel de configuración en
161
+ Setup (enable / mute / estado por regla).
162
+ - **Comprobación del piloto** (Rev102): tarjeta única con
163
+ veredicto (LISTO / LISTO CON RESERVAS / NO ACTIVAR) que
164
+ agrega conectividad, calidad de sensores, salud del servo y
165
+ voltaje.
166
+ - **Asesoramiento y diagnóstico** (Rev103-104, Rev120-121):
167
+ botón DIAGNOSTICAR, el plugin graba 2-3 min con el AP activo,
168
+ aplica reglas heurísticas (sesgo / oscilación / autoridad) y
169
+ propone ajustes P / I / D con explicación clara del porqué y
170
+ del efecto esperado. Nada se aplica automáticamente — cada
171
+ sugerencia tiene botones Aplicar y Descartar. Rev120: el
172
+ primer Aplicar de la sesión duplica el perfil actual en uno
173
+ nuevo `doctor-AAAAMMDD-HHMM` para que el original quede
174
+ intacto a un toque en Ajustes → Perfil.
175
+ - **Setup como launcher grid** (Rev105-119, 124): cada bloque
176
+ de Setup es un tile cuadrado en un grid 2 / 3 / 4 columnas.
177
+ Tap abre pantalla completa con X para cerrar, header sticky
178
+ y bloqueo de scroll de fondo. Etiqueta resumen en vivo en
179
+ cada tile. Conexión pypilot une 4 secciones. "Emergencia"
180
+ renombrada a "Consola control remoto".
181
+ - **Consola SSH interactiva** (Rev113-115): input libre con
182
+ botones ↑▲ ↓▼ para navegar el historial en móvil, textarea
183
+ acumulativo, fallback de portapapeles para LAN HTTP y 14
184
+ presets con etiquetas humanas (Memoria RAM / Tabla IP / WiFi
185
+ / Temperatura / Quien está conectado / ...). Todos los
186
+ comandos con fallbacks BusyBox / piCore.
187
+ - **Sliders completos de calibración** (Rev116-119): todos los
188
+ `RangeSetting` de pypilot agrupados por categoría (timón /
189
+ servo / imu / piloto / otros) con la misma apariencia que
190
+ Ajustes, candado azul con emoji, valor "antes:" congelado
191
+ que NUNCA se mueve al arrastrar, y un botón ↺ por slider
192
+ para restaurar el valor original.
193
+ - **Botones corrección rumbo dentro de Calibración** (Rev124):
194
+ una tarjeta menos en el grid; los pasos ±1 / ±10 quedan
195
+ donde corresponde conceptualmente.
196
+
197
+ **Cambiado**
198
+
199
+ - **Swap de formas en la Gota Chain** (Rev122-126): tras varias
200
+ iteraciones con Carlos en Tunatunes, la flecha AMARILLA "A"
201
+ (viento aparente) es ahora la GRANDE (su punta llega hasta
202
+ el centro de la rosa) y la flecha VERDE "T" (viento real)
203
+ es la PEQUEÑA externa. Colores y letras mantienen su
204
+ significado canónico; solo se movieron los paths SVG para
205
+ que la amarilla domine visualmente.
206
+ - **Semántica de alarmas** (Rev107-108): "activa" era ambiguo.
207
+ Nueva jerga separa `sonando` (disparada ahora) de `vigilando`
208
+ (habilitada y OK). El resumen muestra siempre ambos contadores.
209
+ - **Auditoría i18n** (Rev105, Rev112, Rev121): purga de cadenas
210
+ inglesas hardcoded en el bundle ES ("engagear" → "activar",
211
+ tabla de KIP totalmente traducida, mensajes de alarma).
212
+ Reason / effect de las sugerencias del Doctor expuestos como
213
+ claves i18n para render en el idioma del visor.
214
+ - **Umbrales Calidad de sensores** (Rev98): quitado `minHz` de
215
+ los defaults; el sampler a 1 Hz capaba la Hz observada y
216
+ generaba "degraded" falsos.
217
+
218
+ **Corregido**
219
+
220
+ - **Portal de tile fullscreen** (Rev110-111): tiles fullscreen
221
+ ahora se mueven al `<body>` para que el `transform` de
222
+ `.tab-panel` (animación entre pestañas) no rompa `position:
223
+ fixed`.
224
+ - **Botón Copiar en HTTP** (Rev114): `navigator.clipboard`
225
+ requiere secure context. Fallback a `document.execCommand("copy")`.
226
+ - **Doctor `elapsedSec`** (Rev104): contaba pasado el objetivo.
227
+ Ahora congela al final.
228
+ - **Baseline de sliders comido por refresh** (Rev117-118): el
229
+ valor "antes" se reseteaba a lo recién editado en cada poll.
230
+ Ahora se captura una vez y no se toca.
231
+ - **Restore de Aproado** (Rev90): SALIR saltaba pasos tras un
232
+ fallo. Ahora cada paso protegido por su try/catch.
233
+ - **Parsing Calidad de sensores** (Rev94): `navigation.attitude`
234
+ es objeto compuesto, no path escalar. Fix del subfield heel.
235
+ - Warnings cosméticos Firefox (`::-moz-focus-inner`, `*/` en
236
+ comentarios) purgados (Rev95-106).
237
+
238
+ ### Français
239
+
240
+ **Ajouté**
241
+
242
+ - **Mode Aproado** (Rev87-92) : pseudo-mode AP pour hisser les
243
+ voiles. PAR AVANT / PAR ARRIÈRE, l'AP passe en mode vent avec
244
+ cible 0° (nez au vent), HUD flottant avec temps, distance et
245
+ cap boussole capturé. SORTIR restaure mode / cible / engagé
246
+ précédents. PAR ARRIÈRE force le virage par l'arrière avec
247
+ une chaîne d'objectifs intermédiaires de 60°.
248
+ - **Historian de télémétrie + onglet Graphique** (Rev93-96) :
249
+ buffer 30 min en RAM à 1 Hz, canvas d'historique et 9 cartes
250
+ Trip Stats.
251
+ - **Panneau Qualité des capteurs** (Rev97-98) : âge / Hz /
252
+ jitter / source par chaque chemin SK critique.
253
+ - **Carte Santé du servo** (Rev99) : baseline appris + ratio de
254
+ déviation avec alertes "elevated" et "high".
255
+ - **Moteur d'alarmes** (Rev100-101) : 7 règles built-in,
256
+ notifications SK canoniques, banner avec ACK et fermeture,
257
+ audio WebAudio + parole synthétique, config par règle.
258
+ - **Vérification pré-départ** (Rev102) : verdict READY / avec
259
+ caveats / NE PAS ENGAGER.
260
+ - **Docteur pypilot** (Rev103-104, Rev120-121) : 2-3 min
261
+ d'enregistrement, règles heuristiques, propositions P / I / D
262
+ avec Appliquer et Rejeter. Le premier Appliquer crée un
263
+ nouveau profil `doctor-AAAAMMJJ-HHMM`.
264
+ - **Setup en grille launcher** (Rev105-119, 124) : tiles carrés,
265
+ fullscreen par tap, résumé en direct, connexion consolidée,
266
+ "Consola contrôle remote" (renommé).
267
+ - **Console SSH interactive** (Rev113-115) : entrée libre,
268
+ historique navigable au mobile, 14 presets aux noms humains,
269
+ fallbacks BusyBox / piCore.
270
+ - **Sliders complets de calibration** (Rev116-119) : chaque
271
+ RangeSetting groupé, cadenas, baseline gelée, bouton restaurer.
272
+ - **Boutons correction cap dans Calibration** (Rev124).
273
+
274
+ **Modifié**
275
+
276
+ - **Swap des formes Gota Chain** (Rev122-126) : la flèche AMBRE
277
+ "A" (vent apparent) est maintenant la GRANDE, la TEAL "T"
278
+ (vent réel) la petite. Couleurs et lettres conservent leur
279
+ sens canonique.
280
+ - **Sémantique alarmes** (Rev107-108) : `sonando` vs
281
+ `vigilando`, résumé affiche les deux compteurs.
282
+ - **Audit i18n** (Rev105, Rev112, Rev121) : purge de l'anglais
283
+ dans les bundles ES / FR / DE.
284
+ - **Seuils Qualité capteurs** (Rev98) : `minHz` supprimé.
285
+
286
+ **Corrigé**
287
+
288
+ - Portal du tile fullscreen (Rev110-111).
289
+ - Bouton Copier en HTTP (Rev114).
290
+ - Doctor `elapsedSec` gelé au final (Rev104).
291
+ - Baseline des sliders congelée (Rev117-118).
292
+ - Restore Aproado protégé par try/catch (Rev90).
293
+ - Lecture composite de `navigation.attitude` (Rev94).
294
+ - Warnings cosmétiques Firefox purgés (Rev95-106).
295
+
296
+ ### Deutsch
297
+
298
+ **Neu**
299
+
300
+ - **Aproado-Pseudomodus** (Rev87-92) — AP-Modus zum Segelhissen.
301
+ ÜBER BUG / ÜBER HECK, AP schaltet in Wind-Modus mit Ziel 0°,
302
+ HUD über dem Boot mit Zeit / Distanz / Kompass-Kurs. AUS
303
+ stellt Modus / Ziel / Engaged wieder her. ÜBER HECK erzwingt
304
+ den langen Schwenk mit Zwischen-Zielen alle 60°.
305
+ - **Telemetrie-Historian + Chart-Tab** (Rev93-96) — Ringpuffer
306
+ 30 min RAM bei 1 Hz, 9 Trip-Stats-Karten, Canvas mit Fenstern
307
+ 30 s / 2 min / 10 min.
308
+ - **Sensor-Qualität-Panel** (Rev97-98).
309
+ - **Servo-Health-Karte** (Rev99) — gelernter Baseline mit
310
+ Alarmen "elevated" / "high".
311
+ - **Alarm-Engine** (Rev100-101) — 7 built-in Regeln, SK
312
+ Notifications, Banner mit ACK und X, WebAudio + Sprache,
313
+ Config-Panel.
314
+ - **Pre-Departure-Check** (Rev102).
315
+ - **Pypilot Doctor** (Rev103-104, Rev120-121) — Diagnose-
316
+ Sitzung, Heuristik-Regeln, P/I/D-Vorschläge mit Anwenden /
317
+ Verwerfen. Erstes Anwenden erzeugt neues Profil.
318
+ - **Setup als Launcher-Grid** (Rev105-119, 124) — quadratische
319
+ Tiles, Vollbild-Tap, Live-Zusammenfassung.
320
+ - **Interaktive SSH-Konsole** (Rev113-115) — freie Eingabe,
321
+ Pfeil-Buttons mobil, 14 Presets mit menschlichen Labels.
322
+ - **Vollständige RangeSetting-Slider in Kalibrierung**
323
+ (Rev116-119) — Schloss, eingefrorene Baseline,
324
+ Wiederherstellen-Knopf pro Slider.
325
+ - **Nudge-Buttons in Kalibrierung** (Rev124).
326
+
327
+ **Geändert**
328
+
329
+ - **Gota-Chain-Form-SWAP** (Rev122-126).
330
+ - **Alarm-Semantik** (Rev107-108) — `sonando` / `vigilando`.
331
+ - **i18n-Audit** (Rev105, Rev112, Rev121).
332
+ - **Sensor-Qualität-Schwellen** (Rev98) — `minHz` entfernt.
333
+
334
+ **Behoben**
335
+
336
+ - Fullscreen-Tile-Portal (Rev110-111).
337
+ - Copy-Button auf HTTP (Rev114).
338
+ - Doctor `elapsedSec` (Rev104).
339
+ - Slider-Baseline eingefroren (Rev117-118).
340
+ - Aproado-Restore geschützt (Rev90).
341
+ - `navigation.attitude`-Zusammensetzung (Rev94).
342
+ - Kosmetische Firefox-Warnungen (Rev95-106).
343
+
3
344
  ## 2.1.0 — 2026-08-25 — Rev68..Rev87
4
345
 
5
346
  Twenty in-session Revs of iterative visual + reliability work rolled up
package/dist/alarms.js ADDED
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ // Rev100: Alarm engine. Runs once per sampler tick (1 Hz), evaluates a
3
+ // small set of built-in rules against the latest Sample + KPI snapshot
4
+ // + Sensor Quality + Servo Health, and fires SK-canonical notifications
5
+ // under notifications.autopilot.<rule-id>. Ack / mute state is kept in
6
+ // memory only for Rev100; persistence lands with the disk snapshots.
7
+ //
8
+ // Design principles:
9
+ // - Each rule is a pure evaluator that returns true if the condition
10
+ // is met THIS tick. Sustain filtering (needs to be met for N s
11
+ // before firing) is applied uniformly by the engine, not per-rule.
12
+ // - Rules never share state - a rule that says "true" this tick can
13
+ // later say "false" without any cleanup dance.
14
+ // - Every fire increments a per-rule counter used to add jitter to
15
+ // the emitted notification timestamp, so KIP/WilhelmSK never
16
+ // collapse two consecutive alerts into one.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.AlarmEngine = exports.DEFAULT_RULES = exports.RULE_PYPILOT_DISC_SEC = exports.RULE_LOW_VOLTAGE_V = exports.RULE_SERVO_TEMP_C = exports.RULE_SERVO_OVERCURR_A = exports.RULE_UNABLE_DUTY_MIN = exports.RULE_UNABLE_ERR_RAD = exports.RULE_HEADING_ERR_RAD = void 0;
19
+ const SEV_TO_SK = {
20
+ info: "nominal",
21
+ warn: "warn",
22
+ alarm: "alarm",
23
+ };
24
+ // -------- Built-in rules --------
25
+ // Thresholds picked to be sensible defaults for a cruising sailboat.
26
+ // A future Rev exposes them via plugin schema.
27
+ exports.RULE_HEADING_ERR_RAD = 20 * Math.PI / 180; // 20 deg
28
+ exports.RULE_UNABLE_ERR_RAD = 30 * Math.PI / 180; // 30 deg
29
+ exports.RULE_UNABLE_DUTY_MIN = 0.7; // 70% duty
30
+ exports.RULE_SERVO_OVERCURR_A = 5.0;
31
+ exports.RULE_SERVO_TEMP_C = 60;
32
+ exports.RULE_LOW_VOLTAGE_V = 11.0;
33
+ exports.RULE_PYPILOT_DISC_SEC = 10;
34
+ exports.DEFAULT_RULES = [
35
+ {
36
+ id: "heading-deviation",
37
+ label: "Heading deviation",
38
+ severity: "warn",
39
+ defaultEnabled: true,
40
+ sustainSec: 15,
41
+ description: `RMS heading error exceeds ${(exports.RULE_HEADING_ERR_RAD * 180 / Math.PI) | 0}° with the AP engaged.`,
42
+ evaluate: (c) => {
43
+ if (!c.kpis || !c.sample || !c.sample.engaged)
44
+ return false;
45
+ const r = c.kpis.window1m.rmsErrorRad;
46
+ return typeof r === "number" && r > exports.RULE_HEADING_ERR_RAD;
47
+ },
48
+ message: (c) => {
49
+ const r = c.kpis?.window1m.rmsErrorRad ?? 0;
50
+ return `AP heading error ${(r * 180 / Math.PI).toFixed(1)}° sustained`;
51
+ },
52
+ },
53
+ {
54
+ id: "unable-to-steer",
55
+ label: "Unable to steer",
56
+ severity: "alarm",
57
+ defaultEnabled: true,
58
+ sustainSec: 20,
59
+ description: `Sustained high error AND servo running at ${(exports.RULE_UNABLE_DUTY_MIN * 100) | 0}%+ duty - the AP is losing authority.`,
60
+ evaluate: (c) => {
61
+ if (!c.kpis || !c.sample || !c.sample.engaged)
62
+ return false;
63
+ const r = c.kpis.window1m.rmsErrorRad;
64
+ const d = c.kpis.window1m.servoDutyPct;
65
+ return typeof r === "number" && r > exports.RULE_UNABLE_ERR_RAD
66
+ && typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN;
67
+ },
68
+ message: (c) => {
69
+ const r = c.kpis?.window1m.rmsErrorRad ?? 0;
70
+ const d = c.kpis?.window1m.servoDutyPct ?? 0;
71
+ return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo ${(d * 100).toFixed(0)}% duty`;
72
+ },
73
+ },
74
+ {
75
+ id: "servo-overcurrent",
76
+ label: "Servo overcurrent",
77
+ severity: "warn",
78
+ defaultEnabled: true,
79
+ sustainSec: 2,
80
+ description: `Servo current spike above ${exports.RULE_SERVO_OVERCURR_A} A.`,
81
+ evaluate: (c) => {
82
+ if (!c.sample || typeof c.sample.servoCurrent !== "number")
83
+ return false;
84
+ return c.sample.servoCurrent > exports.RULE_SERVO_OVERCURR_A;
85
+ },
86
+ message: (c) => `Servo overcurrent ${(c.sample?.servoCurrent ?? 0).toFixed(1)} A`,
87
+ },
88
+ {
89
+ id: "servo-temp-high",
90
+ label: "Servo temperature high",
91
+ severity: "warn",
92
+ defaultEnabled: true,
93
+ sustainSec: 10,
94
+ description: `Servo controller temperature above ${exports.RULE_SERVO_TEMP_C} °C.`,
95
+ evaluate: (c) => {
96
+ if (!c.sample || typeof c.sample.servoTemp !== "number")
97
+ return false;
98
+ return c.sample.servoTemp > exports.RULE_SERVO_TEMP_C;
99
+ },
100
+ message: (c) => `Servo temp ${(c.sample?.servoTemp ?? 0).toFixed(0)} °C`,
101
+ },
102
+ {
103
+ id: "low-voltage",
104
+ label: "Low battery voltage",
105
+ severity: "warn",
106
+ defaultEnabled: true,
107
+ sustainSec: 10,
108
+ description: `Servo battery voltage below ${exports.RULE_LOW_VOLTAGE_V} V.`,
109
+ evaluate: (c) => {
110
+ if (!c.sample || typeof c.sample.servoVoltage !== "number")
111
+ return false;
112
+ return c.sample.servoVoltage < exports.RULE_LOW_VOLTAGE_V;
113
+ },
114
+ message: (c) => `Low voltage ${(c.sample?.servoVoltage ?? 0).toFixed(1)} V`,
115
+ },
116
+ {
117
+ id: "sensor-lost",
118
+ label: "Sensor lost",
119
+ severity: "warn",
120
+ defaultEnabled: true,
121
+ sustainSec: 5,
122
+ description: "One or more critical SK paths have gone stale beyond their lost threshold.",
123
+ evaluate: (c) => {
124
+ if (!c.quality)
125
+ return false;
126
+ return c.quality.items.some((it) => it.level === "lost");
127
+ },
128
+ message: (c) => {
129
+ const lost = (c.quality?.items || []).filter((i) => i.level === "lost").map((i) => i.label);
130
+ return lost.length > 0 ? `Sensor lost: ${lost.join(", ")}` : "Sensor lost";
131
+ },
132
+ },
133
+ {
134
+ id: "pypilot-disconnected",
135
+ label: "pypilot disconnected",
136
+ severity: "alarm",
137
+ defaultEnabled: true,
138
+ sustainSec: exports.RULE_PYPILOT_DISC_SEC,
139
+ description: `pypilot_web socket has been down for ${exports.RULE_PYPILOT_DISC_SEC} seconds.`,
140
+ evaluate: (c) => !c.connected,
141
+ message: (c) => {
142
+ if (c.disconnectedSinceMs == null)
143
+ return "pypilot_web disconnected";
144
+ const secs = Math.floor((c.nowMs - c.disconnectedSinceMs) / 1000);
145
+ return `pypilot_web disconnected ${secs}s`;
146
+ },
147
+ },
148
+ ];
149
+ class AlarmEngine {
150
+ constructor(rules = exports.DEFAULT_RULES, opts = {}) {
151
+ this.rules = new Map();
152
+ this.state = new Map();
153
+ this.history = [];
154
+ this.historySize = Math.max(10, opts.historySize ?? 50);
155
+ for (const r of rules) {
156
+ this.rules.set(r.id, r);
157
+ this.state.set(r.id, {
158
+ ruleId: r.id,
159
+ enabled: r.defaultEnabled,
160
+ matched: false,
161
+ matchedSinceMs: null,
162
+ active: false,
163
+ activeSinceMs: null,
164
+ clearedAtMs: null,
165
+ ackedAtMs: null,
166
+ mutedUntilMs: null,
167
+ message: "",
168
+ severity: r.severity,
169
+ label: r.label,
170
+ });
171
+ }
172
+ }
173
+ /** Tick the engine. Returns the list of rule ids whose state changed
174
+ * this tick (fired, cleared, or message updated) so the caller can
175
+ * publish only the deltas as SK notifications. */
176
+ tick(ctx) {
177
+ const changed = [];
178
+ for (const [id, rule] of this.rules) {
179
+ const s = this.state.get(id);
180
+ if (!s.enabled) {
181
+ if (s.active) {
182
+ this.resolve(s, ctx.nowMs);
183
+ changed.push(id);
184
+ }
185
+ continue;
186
+ }
187
+ if (s.mutedUntilMs != null && ctx.nowMs < s.mutedUntilMs) {
188
+ continue; // muted - skip evaluation entirely
189
+ }
190
+ let matched = false;
191
+ try {
192
+ matched = rule.evaluate(ctx);
193
+ }
194
+ catch { /* silent */ }
195
+ if (matched) {
196
+ if (s.matchedSinceMs == null)
197
+ s.matchedSinceMs = ctx.nowMs;
198
+ s.matched = true;
199
+ const held = ctx.nowMs - (s.matchedSinceMs ?? ctx.nowMs);
200
+ if (!s.active && held >= rule.sustainSec * 1000) {
201
+ this.fire(s, rule, ctx);
202
+ changed.push(id);
203
+ }
204
+ else if (s.active) {
205
+ // Refresh message so callers see the latest values.
206
+ const newMsg = safeMessage(rule, ctx);
207
+ if (newMsg !== s.message) {
208
+ s.message = newMsg;
209
+ changed.push(id);
210
+ }
211
+ }
212
+ }
213
+ else {
214
+ s.matched = false;
215
+ s.matchedSinceMs = null;
216
+ if (s.active) {
217
+ this.resolve(s, ctx.nowMs);
218
+ changed.push(id);
219
+ }
220
+ }
221
+ }
222
+ return changed;
223
+ }
224
+ fire(s, rule, ctx) {
225
+ s.active = true;
226
+ s.activeSinceMs = ctx.nowMs;
227
+ s.clearedAtMs = null;
228
+ s.ackedAtMs = null;
229
+ s.message = safeMessage(rule, ctx);
230
+ }
231
+ resolve(s, nowMs) {
232
+ if (s.active && s.activeSinceMs != null) {
233
+ const entry = {
234
+ ruleId: s.ruleId,
235
+ label: s.label,
236
+ severity: s.severity,
237
+ message: s.message,
238
+ activeSinceMs: s.activeSinceMs,
239
+ ackedAtMs: s.ackedAtMs,
240
+ clearedAtMs: nowMs,
241
+ };
242
+ this.history.unshift(entry);
243
+ if (this.history.length > this.historySize)
244
+ this.history.length = this.historySize;
245
+ }
246
+ s.active = false;
247
+ s.activeSinceMs = null;
248
+ s.clearedAtMs = nowMs;
249
+ s.matched = false;
250
+ s.matchedSinceMs = null;
251
+ s.ackedAtMs = null;
252
+ }
253
+ ack(ruleId, nowMs = Date.now()) {
254
+ const s = this.state.get(ruleId);
255
+ if (!s || !s.active)
256
+ return false;
257
+ s.ackedAtMs = nowMs;
258
+ return true;
259
+ }
260
+ mute(ruleId, durationMs, nowMs = Date.now()) {
261
+ const s = this.state.get(ruleId);
262
+ if (!s)
263
+ return false;
264
+ s.mutedUntilMs = nowMs + Math.max(1000, durationMs);
265
+ return true;
266
+ }
267
+ setEnabled(ruleId, enabled) {
268
+ const s = this.state.get(ruleId);
269
+ if (!s)
270
+ return false;
271
+ s.enabled = enabled;
272
+ return true;
273
+ }
274
+ ruleState(ruleId) {
275
+ return this.state.get(ruleId) ?? null;
276
+ }
277
+ snapshot(nowMs = Date.now()) {
278
+ const active = [];
279
+ for (const s of this.state.values()) {
280
+ if (s.active && s.activeSinceMs != null) {
281
+ active.push({
282
+ ruleId: s.ruleId,
283
+ label: s.label,
284
+ severity: s.severity,
285
+ message: s.message,
286
+ activeSinceMs: s.activeSinceMs,
287
+ ackedAtMs: s.ackedAtMs,
288
+ });
289
+ }
290
+ }
291
+ return {
292
+ computedTs: nowMs,
293
+ active,
294
+ recent: this.history.slice(),
295
+ };
296
+ }
297
+ /** Describe every rule for the future config UI: label, severity,
298
+ * enabled, mute status, description. Cheap - just iterates the map. */
299
+ describe() {
300
+ const out = [];
301
+ for (const [id, rule] of this.rules) {
302
+ const s = this.state.get(id);
303
+ out.push({
304
+ id,
305
+ label: rule.label,
306
+ severity: rule.severity,
307
+ enabled: s.enabled,
308
+ description: rule.description,
309
+ sustainSec: rule.sustainSec,
310
+ active: s.active,
311
+ mutedUntilMs: s.mutedUntilMs,
312
+ });
313
+ }
314
+ return out;
315
+ }
316
+ /** Convert Severity to the SK notification state string. */
317
+ static skState(sev) {
318
+ return SEV_TO_SK[sev];
319
+ }
320
+ }
321
+ exports.AlarmEngine = AlarmEngine;
322
+ function safeMessage(rule, ctx) {
323
+ try {
324
+ return rule.message(ctx);
325
+ }
326
+ catch {
327
+ return rule.label;
328
+ }
329
+ }