# 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 300 s)
a **20** (failsafe 8 A) — vždy přes journal (`asset_type = 'ev_charger'`).

- **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í).
- **Write-on-change:** před zápisem se registry 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
  konfigurace (vč. 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 &lt; −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`
