fix(modbus): zadne vecne pending v journalu + flock timeout + EV poll backoff

Zivy incident home-01 (TeltoCharge .16): zapis 15/19-20 koncil failed
s prazdnym error_msg, nebo zustal trvale pending a zablokoval exportni ticky.

- _gateway_exclusive: neblokujici flock s deadline (EMS_MODBUS_FLOCK_TIMEOUT_S,
  default 20 s) -> GatewayLockTimeout misto starvation bez limitu
- execute_modbus_commands: invariant written/failed + neprazdny error_msg
  (str(e) or repr(e)); safety net pres BaseException (CancelledError, chyba DB);
  journal update mimo retry cyklus zarizeni; force_disconnect bez zamku brany
- telemetry poll_ev_chargers: po 3 selhanich backoff 5 min per (host,port,unit)
  - mrtvy unit_id drzi branu 4x8=32 s z kazde minuty
- testy backend/tests/test_modbus_execute_failsafe.py; docs
  modbus-command-journal.md (sekce Robustnost zapisu + konfigurace)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-13 00:17:04 +02:00
parent fb9d0f107a
commit b08782525e
5 changed files with 499 additions and 72 deletions

View File

@@ -79,6 +79,46 @@ pro **reg 178** (spolu s peak shaving bity 45).
**Dávky:** `execute_modbus_commands` slučuje souvislé adresy do jednoho **`write_registers`** (FC **0x10**). `verify_modbus_commands` čte zpět po souvislých blocích (`read_holding_registers`, FC 0x03). Detail režimů: `modbus-registers.md`.
## Robustnost zápisu — žádné věčné `pending`
Invariant `execute_modbus_commands`: **každý předaný příkaz skončí `written`
nebo `failed` s neprázdným `error_msg`** — žádná cesta nesmí nechat řádek
trvale `pending` (živý incident home-01 2026-06-12: TeltoCharge trojice
15/1920 zůstala `pending` > 13 min, jiný běh skončil `failed` bez chyby).
1. **Zámek brány s timeoutem.** `_gateway_exclusive` (flock per `host:port`,
`services/modbus_client.py`) už nečeká blokovaně bez limitu, ale
neblokujícími pokusy do **`EMS_MODBUS_FLOCK_TIMEOUT_S`** (default **20 s**).
Po vypršení vyhodí `GatewayLockTimeout` („gateway lock timeout host:port…“)
→ retry cyklus zápisu příkaz označí **`failed`** s touto zprávou. Dřív mohl
exporter na bráně obsazené pollingem mrtvého unit_id viset donekonečna
(flock nemá FIFO — starvation) a protože APScheduler drží
`max_instances=1`, zablokoval i všechny další exportní ticky.
2. **`error_msg` nikdy prázdný.** `_modbus_error_text`: `str(e)` nebo `repr(e)`
(`TimeoutError()` / `ConnectionResetError()` mají prázdný `str` → dřív
vypadalo jako `failed` „bez chyby“).
3. **Safety net.** Celý průchod je v `try/except BaseException`: i při
`CancelledError` (shutdown / zrušený task), chybě DB nebo bugu se zbylé
nerozhodnuté příkazy best-effort označí `failed`
(`execute aborted: …`) a výjimka se propaguje dál.
4. **Journal update mimo retry cyklus zařízení.** Chyba DB při UPDATE na
`written` nevyvolá další (duplicitní) zápis do zařízení — spadne do safety
netu.
5. **`force_disconnect` bez zámku brány** — zavření vlastního TCP socketu není
transakce na RS485; čekání na flock v retry větvi by jinak mohlo samo
timeoutovat.
**Backoff telemetrie pro nedosažitelný wallbox** (`telemetry_collector.poll_ev_chargers`):
čtení mrtvého unit_id drží exkluzivní zámek brány až **(retries+1) × timeout
= 4 × 8 = 32 s** (pymodbus) — při poll smyčce 60 s je brána obsazená ~53 %
času a zápisy exportu se na ní dusí. Po **3** selháních v řadě se poll daného
`(host, port, unit_id)` zkouší jen **1× za 5 min** (`EV_POLL_FAIL_THRESHOLD`,
`EV_POLL_BACKOFF_S`); úspěšné čtení backoff resetuje. Při výpadku čtení se
nadále nic nezapisuje do telemetrie (žádný fabrikovaný `available`).
Testy: `backend/tests/test_modbus_execute_failsafe.py` (prázdný `str(e)`,
gateway lock timeout, CancelledError, chyba DB, backoff pollingu).
## APScheduler
| Job | Frekvence | Popis |
@@ -104,6 +144,11 @@ Poznámka: **GEN port cut-off na BA81** se aktuálně provádí přímo přes De
## Konfigurace
- `.env`: `DISCORD_WEBHOOK_URL` — prázdné = notifikace vypnuté (jen log).
- `.env`: `EMS_MODBUS_FLOCK_TIMEOUT_S` — max. čekání na exkluzivní zámek brány
(default 20 s); po vypršení `GatewayLockTimeout` → příkaz `failed`
s `error_msg = gateway lock timeout …`.
- `.env`: `EMS_MODBUS_LOCK_DIR`, `EMS_MODBUS_DISABLE_FLOCK` — umístění /
vypnutí flock souborů (`services/modbus_client.py`).
## Související soubory