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>
14 KiB
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
- Po
mismatchse odešle Discord alert (notify_modbus_mismatch), pokud je nastavenDISCORD_WEBHOOK_URL. - Retry zápisu max. 3× (počítáno přes
attempt_countpo zápisech). - 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 maskou0x0030s 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 jeverified. - 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í. - 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).
- 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.
- 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_codejde 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).
- Kritické Deye registry (108, 109, 142, 143, 145): přepnutí lokality na SELF_SUSTAIN přes
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_idsnefiltruje podleasset_typea 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ýwrittenreg-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 stavwritten/verified). Shodná hodnota ⇒ zápis se přeskočí. Na rozdíl odfn_modbus_last_verified_map(Deye drop-unchanged) nečeká na verify —writtenstačí, takže pomalý/neúspěšný verify read nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější řádekfailed/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 řádekmismatch- Discord (
notify_modbus_mismatch).
- Discord (
- 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.
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).
- Zámek brány s timeoutem.
_gateway_exclusive(flock perhost:port,services/modbus_client.py) už nečeká blokovaně bez limitu, ale neblokujícími pokusy doEMS_MODBUS_FLOCK_TIMEOUT_S(default 20 s). Po vypršení vyhodíGatewayLockTimeout(„gateway lock timeout host:port…“) → retry cyklus zápisu příkaz označífaileds 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. error_msgnikdy prázdný._modbus_error_text:str(e)neborepr(e)(TimeoutError()/ConnectionResetError()mají prázdnýstr→ dřív vypadalo jakofailed„bez chyby“).- Safety net. Celý průchod je v
try/except BaseException: i přiCancelledError(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. - Journal update mimo retry cyklus zařízení. Chyba DB při UPDATE na
writtennevyvolá další (duplicitní) zápis do zařízení — spadne do safety netu. force_disconnectbez 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 — _apply_export_plan_guard. |
Plná tabulka jobů je v 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říkazfailedserror_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; repeatablesdb/routines/R__044_fn_set_mode.sql(fn_expire_modesvrací 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