Drogheda/02-Architecture-Software/htracker-patches/2026-09-18_retranslator_phlg-v5-extra_phev_alertas.patch

153 lines
8.1 KiB
Diff

--- a/retranslator.py
+++ b/retranslator.py
@@ -712,6 +712,24 @@
'tair', 'rh', 'tbat', 'fan_rpm', 'fan_pwm', 'fan_fault',
)
_PHLG_FIELDS_V3_EXTRA = ('tntc', 'chg', 'cflt', 'rxbad')
+# fw 0.5.1-fm (EinarBoard v5, bring-up/field-minimum) — append-only, campos 25..29:
+# up_s segundos desde el arranque del MCU (si baja ⇒ reinicio)
+# pwr VEH (5 V del vehículo presente) | BATT (en respaldo LTC4040)
+# v5min mínimo instantáneo del riel 5 V en la ventana de 30 s (caídas de ms por ráfaga LTE)
+# vbat_min mínimo instantáneo del paquete en la misma ventana
+# bkp_n respaldos de batería desde el arranque
+_PHLG_FIELDS_V5_EXTRA = ('up_s', 'pwr', 'v5min', 'vbat_min', 'bkp_n')
+PHLG_V5MIN_WARN_V = 4.60 # zona de disparo PFI del LTC4040 (divisor 270k/100k ⇒ 4.44 V)
+PHLG_VBAT_LOW_V = 3.50
+
+# ── $PHEV — eventos de la unidad, en banda (fw 0.5.1+) ──────────────────────
+# $PHEV,<up_s>,<CODIGO>,<detalle>*CS — una línea por transición de estado
+# (STACK_ON/OFF, BACKUP_START/END, RECOVERY, LIMP, TEMP_CUT/REARM, V5_LOW/LOST/OK,
+# IGN_ON/OFF, SLEEP, MODOB_ON/OFF, BOOT). Se guardan los últimos PHEV_MAX por
+# unidad y se exponen en el status JSON como 'events' para el monitor.
+PHEV_MAX = 200
+DEVICE_EVENTS: dict[str, deque] = {} # cell_id → deque de {'ts','up_s','code','detail'}
+_PHEV_WARN = {'RECOVERY', 'LIMP', 'TEMP_CUT', 'V5_LOW', 'SLEEP', 'BACKUP_START'}
def _nmea_checksum_ok(sentence: str) -> bool:
@@ -740,9 +758,33 @@
names = list(_PHLG_FIELDS_V2)
if len(vals) >= len(_PHLG_FIELDS_V2) + len(_PHLG_FIELDS_V3_EXTRA):
names += list(_PHLG_FIELDS_V3_EXTRA)
+ if len(vals) >= len(names) + len(_PHLG_FIELDS_V5_EXTRA):
+ names += list(_PHLG_FIELDS_V5_EXTRA)
return dict(zip(names, vals))
+def _parse_phev(sentence: str) -> dict | None:
+ """$PHEV,<up_s>,<CODIGO>,<detalle> → dict, o None si el checksum/forma no cuadra."""
+ if not _nmea_checksum_ok(sentence):
+ return None
+ parts = sentence[1:].partition('*')[0].split(',', 3)
+ if len(parts) < 3 or parts[0] != 'PHEV':
+ return None
+ try:
+ up_s = int(parts[1])
+ except ValueError:
+ return None
+ return {'up_s': up_s, 'code': parts[2], 'detail': parts[3] if len(parts) > 3 else ''}
+
+
+def _note_event(cell_id: str, ev: dict, now_ts: float) -> None:
+ """Guarda el evento y lo registra en el log (WARNING para los que piden acción)."""
+ ev = dict(ev); ev['ts'] = now_ts
+ DEVICE_EVENTS.setdefault(cell_id, deque(maxlen=PHEV_MAX)).append(ev)
+ msg = f"[{cell_id}] $PHEV up={ev['up_s']}s {ev['code']} {ev['detail']}".rstrip()
+ (log.warning if ev['code'] in _PHEV_WARN else log.info)(msg)
+
+
def _parse_pubx00(sentence: str) -> dict | None:
"""$PUBX,00 (u-blox propietaria): hAcc/vAcc/edad-DGPS/sats usados.
Índices verificados contra capturas reales de la flota (2026-08-10)."""
@@ -785,17 +827,58 @@
f"(¿cargador pausado por NTC?).")
except ValueError:
pass
+ # ── Reglas de diagnóstico (fw 0.4.0+ / 0.5.1+) ──────────────────────────
+ # 1. reset_cause=IWDG ⇒ el MCU se colgó (watchdog). 2. up_s bajó ⇒ reinicio
+ # (contamos reinicios/hora: ≥2 = bucle de arranque). 3. recoveries subió ⇒
+ # el F9P dejó de hablar. 4. v5min en zona PFI mientras sigue reportando.
+ # 5. pwr=BATT ⇒ en respaldo (con cargador OFF el paquete es de un solo uso).
+ if health.get('reset_cause') == 'IWDG' and (not prev or prev.get('reset_cause') != 'IWDG'):
+ log.warning(f"[{cell_id}] $PHLG reinicio por WATCHDOG (IWDG): el MCU se colgó.")
+ try:
+ up_now = int(health.get('up_s') or -1)
+ up_prev = int(prev.get('up_s') or -1) if prev else -1
+ if 0 <= up_now < up_prev:
+ reboots = health['_reboots'] = [t for t in (prev.get('_reboots') or []) if now_ts - t < 3600] + [now_ts]
+ lvl = log.warning if len(reboots) >= 2 else log.info
+ lvl(f"[{cell_id}] $PHLG reinicio detectado (up {up_prev}s → {up_now}s), "
+ f"{len(reboots)} en la última hora{' — ¿BUCLE DE ARRANQUE?' if len(reboots) >= 2 else ''}.")
+ elif prev and prev.get('_reboots'):
+ health['_reboots'] = [t for t in prev['_reboots'] if now_ts - t < 3600]
+ rec_now = int(health.get('recoveries') or 0)
+ rec_prev = int(prev.get('recoveries') or 0) if prev else 0
+ if rec_now > rec_prev:
+ log.warning(f"[{cell_id}] $PHLG recovery #{rec_now}: la unidad cicló la etapa RTK (F9P sin NMEA / RTK perdido).")
+ v5min = float(health.get('v5min') or 99)
+ if v5min < PHLG_V5MIN_WARN_V and float(health.get('v5v') or 0) > 3.8:
+ log.warning(f"[{cell_id}] $PHLG riel 5 V cayó a {v5min:.2f} V en los últimos 30 s "
+ f"(zona PFI del LTC4040): ajustar la fuente del gabinete a ~5.3 V.")
+ if health.get('pwr') == 'BATT' and (not prev or prev.get('pwr') != 'BATT'):
+ log.warning(f"[{cell_id}] $PHLG en RESPALDO de batería (vbat={health.get('vbat')} V, bkp #{health.get('bkp_n')}).")
+ if health.get('pwr') == 'VEH' and prev and prev.get('pwr') == 'BATT':
+ log.info(f"[{cell_id}] $PHLG regresó a alimentación del vehículo.")
+ except (ValueError, TypeError):
+ pass
health['ts'] = now_ts
DEVICE_HEALTH[cell_id] = health
+def _events_snapshot(cell_id: str, now: float, n: int = 20) -> list:
+ """Últimos n eventos $PHEV de la unidad (más reciente al final), con edad."""
+ evs = DEVICE_EVENTS.get(cell_id)
+ if not evs:
+ return []
+ return [{'age_s': round(now - e['ts'], 1), 'up_s': e['up_s'], 'code': e['code'], 'detail': e['detail']}
+ for e in list(evs)[-n:]]
+
+
def _health_snapshot(cell_id: str, now: float) -> dict | None:
"""Copia del último $PHLG con su edad, para el status JSON (None si nunca)."""
h = DEVICE_HEALTH.get(cell_id)
if not h:
return None
- out = {k: v for k, v in h.items() if k != 'ts'}
+ out = {k: v for k, v in h.items() if k != 'ts' and not k.startswith('_')}
out['age_s'] = round(now - h['ts'], 1)
+ out['reboots_1h'] = len(h.get('_reboots') or [])
return out
@@ -916,6 +999,7 @@
'jump_rejects': device_jump_rejects.get(cid, 0),
# ── Telemetría en banda, si la unidad la manda ───────────
'health': _health_snapshot(cid, now), # $PHLG | None
+ 'events': _events_snapshot(cid, now), # $PHEV (fw 0.5.1+) | []
'hacc_m': info.get('hacc_m'), # $PUBX,00
'vacc_m': info.get('vacc_m'),
'diff_age_s': info.get('diff_age_s'),
@@ -1436,6 +1520,22 @@
device_total_online_s, device_last_seen_ever)
_note_health(cell_id, health, now_ts)
+ # ── $PHEV — eventos de la unidad, en banda (firmware 0.5.1+) ──
+ elif raw.startswith('$PHEV'):
+ ev = _parse_phev(raw)
+ if ev:
+ entry = addr_to_cell_id.get(addr)
+ if not entry:
+ continue
+ cell_id = entry[0]
+ now_ts = time.time()
+ addr_to_cell_id[addr] = (cell_id, now_ts)
+ _mark_seen(cell_id, now_ts, device_last_seen,
+ device_first_seen, device_packet_count,
+ device_disconnected, device_first_connected,
+ device_total_online_s, device_last_seen_ever)
+ _note_event(cell_id, ev, now_ts)
+
# ── $PUBX,00 — precisión estimada del F9P (configs nuevas) ────
elif raw.startswith('$PUBX'):
pubx = _parse_pubx00(raw)