# Modbus command journal ## Účel Každý zápis na Modbus TCP (Deye a později další aktiva) 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 179** (control board special 1, BA81 GEN cut-off): exporter zapisuje masked RMW (zachová ostatní bity). Při ověření se za shodu považuje jen maska **bits 0–1** (`0x0003`) vůči očekávání (2 = cutoff ON, 3 = cutoff OFF). 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`. ## 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 navíc zapisuje **reg 179** (masked RMW) podle `planning_interval.deye_gen_cutoff_enabled` (BA81 GEN port cut-off). **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`. ## 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`. | 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 179** 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). ## 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) - Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py` - Registry Deye: `docs/04-modules/modbus-registers.md`