# Modbus command journal ## Účel Každý zápis na Modbus TCP (Deye `inverter` i TeltoCharge `ev_charger`) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`. **Výjimka:** Deye **62–64** (systémový čas) se vždy ověřují **jako celek** jedním čtením 62–64 a **tolerančně** podle dekódovaného data/času — řádky 62–64 se **neprohánějí** striktní větví po jednom registru (jinak by zejména **64** způsoboval falešné `mismatch` a SELF_SUSTAIN). Podmnožina `written` řádků (např. jen 64) se sloučí s dotazem na všechny `written` 62–64 pro daný invertor; viz `modbus-registers.md`. ## Schéma `ems.modbus_command` | Sloupec | Význam | |---------|--------| | `asset_type` / `asset_id` / `asset_code` | Typ aktiva (`inverter`, …), FK logicky na příslušnou tabulku, čitelný kód | | `device_*` | Host, port, Modbus unit ID | | `register` | Číslo registru (decimal); v logu též hex | | `register_name` | Např. `charge_limit`, `export_limit` | | `value_to_write` / `value_written` / `value_verified` | Požadavek, potvrzený zápis, ověření čtením | | `status` | `pending`, `written`, `verified`, `failed`, `mismatch`, `retrying` | | `planning_run_id` | Volitelná vazba na aktivní plán | | `deye_physical_mode` | U zápisů z `write_inverter_setpoints`: **PASSIVE** / **SELL** / **CHARGE** (stejná hodnota na všech řádcích daného běhu exportu); jinak NULL | | `attempt_count` | Počet pokusů o zápis (pro limity retry) | Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` / `retrying`. ## Verifikace a bezpečnost 1. Po `mismatch` se odešle **Discord** alert (`notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`. 2. **Retry** zápisu max. **3×** (počítáno přes `attempt_count` po zápisech). 3. **Reg 178** (grid peak shaving switch): journal ukládá **celé 16bit** `value_to_write` (32 nebo 48). Při ověření se za **shodu** považuje shoda **bitů 4–5** maskou **`0x0030`** s očekáváním; `value_verified` = přečtená surová hodnota. Při nesouladu masky se **jednou** znovu přečte reg. 178 (druhé FC3) kvůli glitchům na RS485 — pokud druhé čtení maskou sedí, stav je **`verified`**. 4. **Reg 178** (control board special 1, BA81 GEN cut-off): exporter nastavuje bits **0–1** (2/3) pomocí **read-modify-write**, protože reg 178 je bitové pole i pro další volby (např. peak shaving bits 4–5). Při ověření se za shodu považuje maska **bits 0–1 a 4–5** (`0x0033`) vůči očekávání. 4. **TOU výkon W (154–159):** firmware často vrátí **max. výkon z reg. 108/109 × 51.2 V** místo přesně zapsaného W; verify to akceptuje jako **shodu** (skutečný výkon je stejně omezen proudy 108/109). 5. **Pojistka 62–64**: pokud by se řádek registru **62, 63 nebo 64** omylem dostal do striktní větve po jednom registru, verify to zachytí a zpracuje **jako toleranční celek 62–64** (stejně jako primární clock větev) — bez přepnutí do SELF_SUSTAIN jen kvůli tomu. 6. Po třech neúspěšných cyklech ověření: - **Kritické Deye registry** (**108, 109, 142, 143, 145**): přepnutí lokality na **SELF_SUSTAIN** přes `run_fn_set_mode_with_discord` → `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod). Při skutečné změně `mode_code` jde na Discord **kritická** zpráva (stejný formát jako u ostatních přepnutí režimu). - **Nekritické / soft registry** (např. **178** po vyčerpání druhého čtení, **154–159** bez akceptovaného clampu, ostatní mimo výše uvedené kritické): po 3 pokusech zůstane řádek v **`mismatch`**, jde **Discord** (`notify_modbus_mismatch`), **režim se nemění**. - **Výjimka — systémový čas 62–64:** přepnutí režimu **se neprovádí**. Po 3 neúspěšných ověřeních jde **kritický** Discord (`notify_modbus_clock_verify_exhausted`); střídač a EMS režim zůstávají v aktuálním stavu (čas na sběrnici může vyžadovat ruční kontrolu / firmware). **Po návratu SELF_SUSTAIN → AUTO** (přes `fn_set_mode`): `notification_service` naplánuje na pozadí **rolling replan** (`run_plan_api`, `triggered_by=mode:self_sustain_exit`), aby aktivní plán odpovídal znovu plné optimalizaci v AUTO. **Baseline po deployi (operativa):** např. počet přepnutí na SELF_SUSTAIN z verify za poslední 2 dny: `SELECT count(*) FROM ems.site_operating_mode_log WHERE mode_code = 'SELF_SUSTAIN' AND activated_by = 'system:mismatch' AND activated_at >= now() - interval '2 days';` Pro diagnostiku času Deye po opravě clock logiky používej u `modbus_command` krátké okno (např. `verified_at >= now() - interval '2 days'`). **Discord při jakékoli změně režimu** (nejen Modbus): `notification_service.run_fn_set_mode_with_discord` volá `ems.fn_set_mode` a při změně `mode_code` oproti stavu před voláním pošle zprávu (`notify_operating_mode_changed`). Úroveň: `user:api` → info, obecné `system:*` → warning, `system:mismatch` → critical. Použití: HTTP `POST /api/v1/sites/{site_id}/mode`, `_switch_to_self_sustain` v `control_exporter`. Vypršení `valid_until`: `ems.fn_expire_modes()` vrací řádky `(site_id, site_code, old_mode, new_mode)` pro každé provedené přepnutí; scheduler v `main.py` (a lazy expire v `_fetch_operating_mode`) z nich pošle Discord. Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`, `DEYE_CRITICAL_REGS_SELF_SUSTAIN`, `_deye_tou_power_verify_match`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `_auto_rolling_replan_after_self_sustain_exit`, `notify_operating_mode_changed`. ## EV wallbox (TeltoCharge) `write_ev_setpoints` (každý export tick) a `write_ev_arrival_hold` (po detekci příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout) a **20** (failsafe) — vždy přes journal (`asset_type = 'ev_charger'`). Timeout a failsafe jsou per charger (`asset_ev_charger.watchdog_comm_timeout_s` / `watchdog_failsafe_a`, V106; default 300 s / 8 A). - **Verify job ověřuje všechny asset typy** — `fn_modbus_written_command_ids` nefiltruje podle `asset_type` a registry 15/19/20 jsou dle protokolu R/W (čtou se zpět standardní FC 3 větví). - **Reg 15 (amps) se zapisuje KAŽDÝ tick** (re-asert), **NE write-on-change.** Incident 2026-06-13: TeltoCharge si po výpadku komunikace sám přepíše reg 15 na failsafe (reg 20) bez journal řádku; write-on-change proti journalu (poslední „0 verified") by tichý drift **0 → 8 A** nikdy nezahlédlo (verify čte zpět jen `written`) a nikdy neopravilo. Re-asert každý tick drift opraví a drží verify jobu čerstvý `written` reg-15 řádek. Reg 15 je volatilní řídicí registr (ne EEPROM). - **Reg 19/20 (watchdog config) zůstávají write-on-change:** před zápisem se filtrují proti **`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr; hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ zápis se přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye drop-unchanged) nečeká na verify — `written` stačí, takže pomalý/neúspěšný verify read nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se watchdog 19/20 obnoví jedním zápisem. - **Mismatch po 3 pokusech NEpřepíná SELF_SUSTAIN** — fallback režim je Deye politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch` + Discord (`notify_modbus_mismatch`). - Watchdog wallboxu sytí i FC 3 čtení telemetrie (60 s) — periodické zápisy k udržení spojení nejsou potřeba; detail [`modbus-registers-teltocharge.md`](modbus-registers-teltocharge.md). ## Střídač (Deye) `write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148–177** (bloky 3–6 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`. Pokud je zapnutý feature `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`, exporter nastavuje **MI export cutoff** přes **reg 178 bits0–1** (BA81 GEN port cut-off) — stále jako jeden záznam `modbus_command` pro **reg 178** (spolu s peak shaving bity 4–5). **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/19–20 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 | |-----|-----------|--------| | `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **20 min** a zavolá `verify_modbus_commands`. | | `plan_actual_slot_guard` | **:05, :20, :35, :50** (po `audit_filler`) | `ems.fn_plan_actual_slot_guard_all_active` (+ `plan_actual_slot_guard.py` jen Discord): poslední 2 uzavřené 15min sloty — fatální odchylka **plán vs. audit síť** → **Discord** (`critical`), dedup přes `ems.plan_fatal_deviation_sent`. Kódy: `GRID_SIGN_MISMATCH`, `GRID_EXPORT_SPIKE`, **`NEG_SELL_EXPORT`** (`sell < 0` a skutečný vývoz < −4 kW), `GRID_LARGE_DEVIATION`, … Exekuční pojistka proti opakovanému vývozu: [`control.md`](control.md) — `_apply_export_plan_guard`. | Plná tabulka jobů je v [`lifespan.py`](../../backend/app/lifespan.py). ## Ruční API `GET /api/v1/sites/{site_id}/control/verify?minutes=10` Vrátí počty `checked` / `verified` / `mismatch` a seznam dotčených příkazů s aktuálním stavem po verifikaci. ## `ems.cutoff_switch_log` Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN při záporné prodejní ceně). Záznam při **změně** stavu: `asset_code`, `new_state`, `previous_state`, `reason`, `sell_price_czk`, `triggered_by`. Zatím jen schéma; logika napojení v `control_exporter` je v TODO. Poznámka: **GEN port cut-off na BA81** se aktuálně provádí přímo přes Deye **reg 178 (bits0–1)** a loguje se v `ems.modbus_command`. `cutoff_switch_log` je oddělená tabulka pro budoucí obecnější “cut-off” akce (nezávisle na konkrétním Modbus registru). ## 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 - Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`, `V044__deye_register_max_current_a.sql`; repeatables `db/routines/R__044_fn_set_mode.sql` (`fn_expire_modes` vrací detail přepnutí pro notifikace), `db/routines/R__002_fn_modbus_last_verified_map.sql`, `db/routines/R__100_fn_modbus_device_state_map.sql` (write-on-change pro EV) - Backend: `backend/services/control_exporter.py`, `backend/services/control/outputs.py` (EV write-on-change), `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py` - Registry Deye: `docs/04-modules/modbus-registers.md`; TeltoCharge: `docs/04-modules/modbus-registers-teltocharge.md`