--- 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,,,*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,,, → 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)