Files
ems/docs/04-modules/modbus-command-journal.md
Dusan Vojacek 7decfebdbd TeltoCharge write-on-change: zápis jen při změně hodnoty (EEPROM wear)
Wallbox dostával zápisy 15/19/20 každý export tick (~8x/hod: control_export
:14,:29,:44,:59 + rolling replan */15 s exportem), protože drop-unchanged
stál na fn_modbus_last_verified_map — dokud verify čtení nedoběhlo/selhalo,
mapa byla prázdná a celá trojice se psala pořád dokola. write_ev_arrival_hold
navíc psal trojici nepodmíněně při každém píchnutí kabelu (docstring lhal).

- nová ems.fn_modbus_device_state_map (R__100): nejnovější řádek journalu
  per registr, hodnota jen pro written/verified; failed/mismatch => registr
  chybí => po výpadku se konfigurace obnoví jedním zápisem
- write_ev_setpoints + write_ev_arrival_hold filtrují přes tuto mapu:
  reg 15 jen při změně plánu, watchdog 19/20 jednou po startu/po výpadku
- verify job EV chargery ověřuje už dnes (fn_modbus_written_command_ids bez
  filtru asset_type); registry 15/19/20 jsou dle oficiálního protokolu R/W
- watchdog Telto sytí jakákoli validní komunikace vč. FC3 čtení telemetrie
  (60 s << 300 s) — periodické zápisy k udržení spojení nejsou potřeba,
  failsafe 8 A nastane jen při skutečném výpadku EMS
- testy: tests/test_ev_write_on_change.py (drop, setpoints, arrival hold)
- docs: modbus-registers-teltocharge.md (sekce Zápis už není "NEimplementováno",
  R/W tabulka, watchdog sémantika), modbus-command-journal.md (sekce EV
  wallbox), CLAUDE.md (fn_modbus_device_state_map)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:21:59 +02:00

113 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 **6264** (systémový čas) se vždy ověřují **jako celek** jedním čtením 6264 a **tolerančně** podle dekódovaného data/času — řádky 6264 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` 6264 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ů 45** 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 **01** (2/3) pomocí
**read-modify-write**, protože reg 178 je bitové pole i pro další volby (např. peak shaving bits 45).
Při ověření se za shodu považuje maska **bits 01 a 45** (`0x0033`) vůči očekávání.
4. **TOU výkon W (154159):** 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 6264**: 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 6264** (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í, **154159** 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 6264:** 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 **6264** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148177** (bloky 36 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 bits01** (BA81 GEN port cut-off) — stále jako jeden záznam `modbus_command`
pro **reg 178** (spolu s peak shaving bity 45).
**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`. 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 (bits01)** 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), `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`