Zivy incident home-01 (TeltoCharge .16): od ~22:45 UTC 12.6. nevznikl zadny telto journal radek (ani failed), auto jelo failsafe 8 A misto planovanych 0 A. Root cause: reg 15 (amps) byl write-on-change proti journalu (fn_modbus_device_state_map). Jakmile mel reg 15 radek "0 verified" a plan dal chtel 0, NIKDY nevznikl novy prikaz -- a TeltoCharge si po vypadku komunikace sam prepsal reg 15 na failsafe (reg 20) BEZ journal radku. Verify cte zpet jen 'written' radky, takze tichy drift 0 -> 8 A nikdo nevidel ani neopravil. - reg 15 (amps to use) se zapisuje VZDY (re-asert) -- volatilni ridici registr, ne EEPROM; drzi verify jobu cerstvy written radek -> drift se zachyti a hned opravi. _split_amps_and_watchdog odděluje 15 od 19/20. - reg 19/20 (watchdog config, EEPROM) zustavaji write-on-change. - per-charger failsafe/timeout: asset_ev_charger.watchdog_failsafe_a / watchdog_comm_timeout_s (V106; default 8 A / 300 s). "Zakaz nabijeni" = reg 15 = 0 (protokol rev 0.5 nema samostatny enable registr). - testy test_ev_write_on_change.py; docs teltocharge + journal + data-model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
14 KiB
Markdown
167 lines
14 KiB
Markdown
# 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`
|