diff --git a/CLAUDE.md b/CLAUDE.md index 6da1ac6..04a1418 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá 17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. -18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního **ověřeného** syncu (`deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` se doplňují až po **úspěšné toleranční verifikaci** v `_verify_deye_clock_command_run`, ne po samotném zápisu); při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verifikace journalu pro souvislý blok 62–64 je **toleranční** (odchylka dekódovaného času až **120 s**); po 3 neúspěšných ověřeních **bez** přepnutí do SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`. +18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 62–64 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 62–64 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`. 19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce). @@ -179,6 +179,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` | | Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` | | Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) | +| **MCP read-only SQL na EMS DB** | Cursor MCP server **`postgres-ems`**, nástroj **`query`**. | --- diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index ac2f255..a716ad4 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -49,6 +49,9 @@ _DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset( ] ) +# Systémový čas Deye — vždy toleranční verify jako celek 62–64 (reg 64 sám nesmí do striktní větve). +DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64}) + DEYE_REGISTER_NAMES: dict[int, str] = { 108: "max_charge_a (max nabíjecí proud baterie)", 109: "max_discharge_a (max vybíjecí proud baterie)", @@ -178,9 +181,9 @@ def _deye_should_skip_time_sync_after_read( r64: int, ) -> bool: """ - True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného ověření času - (status verified v journalu 62–64) neuplynul 24h — sloupec deye_last_system_time_sync_at - se doplňuje jen po tolerančním ověření v _verify_deye_clock_command_run. + True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu (FC 0x10 ACK) + nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje + write_inverter_setpoints po úspěšném zápisu batche obsahujícího 62–64 a znovu po úspěšném verify. """ dev = _deye_registers_to_prague_datetime(r62, r63, r64) if dev is None: @@ -202,11 +205,35 @@ def _deye_should_skip_time_sync_after_read( return True -def _is_deye_contiguous_clock_run(run: list[asyncpg.Record]) -> bool: - if len(run) != 3: - return False - regs = sorted(int(c["register"]) for c in run) - return regs == [62, 63, 64] +async def _fetch_written_deye_clock_commands( + site_id: int, + asset_id: int, + host: str, + port: int, + unit_id: int, + db: asyncpg.Connection, +) -> list[asyncpg.Record]: + """Všechny řádky journalu 62–64 ve stavu written pro daný invertor/endpoint.""" + rows = await db.fetch( + """ + SELECT * FROM ems.modbus_command + WHERE site_id = $1 + AND asset_type = 'inverter' + AND asset_id = $2 + AND device_host = $3 + AND device_port = $4 + AND device_unit_id = $5 + AND register IN (62, 63, 64) + AND status = 'written' + ORDER BY register + """, + site_id, + asset_id, + host, + port, + unit_id, + ) + return list(rows) async def _fetch_last_verified_inverter_registers( @@ -453,30 +480,72 @@ async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason) -async def _verify_deye_clock_command_run( - run: list[asyncpg.Record], - values: list[int], - db: asyncpg.Connection, +def _modbus_cmd_register(cmd: Any) -> int: + """asyncpg.Record má __getitem__; objekty s atributem .register též (testy).""" + try: + return int(cmd["register"]) + except (KeyError, TypeError): + return int(cmd.register) + + +def _deye_expected_clock_triplet_for_verify( + bundle: list[asyncpg.Record], + last_verified: dict[int, int], + a62: int, + a63: int, + a64: int, +) -> tuple[int, int, int]: + """ + Sestaví očekávané (w62,w63,w64) pro toleranční verify. + Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení + (aby osiřelý zápis např. jen 64 nešel do striktního porovnání reg64). + """ + by_reg = {_modbus_cmd_register(c): c for c in bundle} + def _vtw(c: Any) -> int: + try: + return int(c["value_to_write"]) + except (KeyError, TypeError): + return int(c.value_to_write) + + w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62) + w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63) + w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64) + return (int(w62), int(w63), int(w64)) + + +async def _verify_deye_clock_written_bundle( site_id: int, + bundle: list[asyncpg.Record], + a62: int, + a63: int, + a64: int, + db: asyncpg.Connection, ) -> bool: """ - Ověření souvislého bloku 62–64: porovnání času z trojice registrů s tolerancí (sekundy na Deye běží). - Při mismatch retry všech tří řádků journalu společně. + Toleranční ověření pro jeden až tři řádky journalu 62–64 ve stavu written. + Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech. """ from services.notification_service import ( notify_modbus_clock_verify_exhausted, notify_modbus_mismatch, ) - run_s = sorted(run, key=lambda c: int(c["register"])) - w62 = int(run_s[0]["value_to_write"]) - w63 = int(run_s[1]["value_to_write"]) - w64 = int(run_s[2]["value_to_write"]) - a62, a63, a64 = (int(values[0]), int(values[1]), int(values[2])) + cmds_s = sorted(bundle, key=_modbus_cmd_register) + try: + asset_id = int(cmds_s[0]["asset_id"]) + except (KeyError, TypeError): + asset_id = int(cmds_s[0].asset_id) + last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db) + w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64) clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64) + actual_by_reg = {62: a62, 63: a63, 64: a64} - for cmd, actual in zip(run_s, values): - cid = int(cmd["id"]) + for cmd in cmds_s: + try: + cid = int(cmd["id"]) + except (KeyError, TypeError): + cid = int(cmd.id) + r = _modbus_cmd_register(cmd) await db.execute( """ UPDATE ems.modbus_command @@ -484,13 +553,12 @@ async def _verify_deye_clock_command_run( status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END WHERE id=$3::int """, - int(actual), + actual_by_reg[r], clock_ok, cid, ) if clock_ok: - inv_asset_id = int(run_s[0]["asset_id"]) await db.execute( """ UPDATE ems.asset_inverter @@ -499,22 +567,35 @@ async def _verify_deye_clock_command_run( WHERE id = $2 """, _prague_minute_start_utc(), - inv_asset_id, + asset_id, ) - for cmd, actual in zip(run_s, values): + for cmd in cmds_s: + try: + cid_l = int(cmd["id"]) + except (KeyError, TypeError): + cid_l = int(cmd.id) + try: + code_l = str(cmd["asset_code"]) + except (KeyError, TypeError): + code_l = str(cmd.asset_code) + rr = _modbus_cmd_register(cmd) logger.info( "[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s", - int(cmd["id"]), - cmd["asset_code"], - int(cmd["register"]), - int(actual), + cid_l, + code_l, + rr, + actual_by_reg[rr], ) return True - cmd0 = run_s[0] + cmd0 = cmds_s[0] + try: + ac0 = str(cmd0["asset_code"]) + except (KeyError, TypeError): + ac0 = str(cmd0.asset_code) logger.error( "[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)", - cmd0["asset_code"], + ac0, w62, w63, w64, @@ -524,15 +605,19 @@ async def _verify_deye_clock_command_run( ) attempts = 0 - for cmd in run_s: + for cmd in cmds_s: + try: + cid_q = int(cmd["id"]) + except (KeyError, TypeError): + cid_q = int(cmd.id) row_ac = await db.fetchrow( - "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", int(cmd["id"]) + "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q ) ac = int(row_ac["attempt_count"] or 0) if row_ac else 0 attempts = max(attempts, ac) await notify_modbus_mismatch( - str(cmd0["asset_code"]), + ac0, 62, "system_time_62_64", w62, @@ -540,7 +625,12 @@ async def _verify_deye_clock_command_run( attempts, ) - ids_ordered = [int(c["id"]) for c in run_s] + ids_ordered = [] + for c in cmds_s: + try: + ids_ordered.append(int(c["id"])) + except (KeyError, TypeError): + ids_ordered.append(int(c.id)) if attempts < 3: for cid in ids_ordered: await db.execute( @@ -556,7 +646,7 @@ async def _verify_deye_clock_command_run( site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id) await notify_modbus_clock_verify_exhausted( site["code"] if site else str(site_id), - str(cmd0["asset_code"]), + ac0, (w62, w63, w64), (a62, a63, a64), ) @@ -663,7 +753,40 @@ async def verify_modbus_commands( all_ok = True for (host, port, unit), group in by_gw.items(): client = await get_modbus_client(host, port) - for run in _modbus_command_contiguous_runs(group): + clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS] + rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS] + + if clock_cmds: + asset_id = int(clock_cmds[0]["asset_id"]) + bundle = await _fetch_written_deye_clock_commands( + site_id, asset_id, host, port, unit, db + ) + if not bundle: + bundle = clock_cmds + try: + cvals = await client.read_holding_registers(62, 3, unit) + except Exception as e: + logger.error("verify clock read 62–64 failed: %s", e) + all_ok = False + else: + if len(cvals) != 3: + logger.error( + "verify clock read: expected 3 regs, got %s", len(cvals) + ) + all_ok = False + else: + matched = await _verify_deye_clock_written_bundle( + site_id, + bundle, + int(cvals[0]), + int(cvals[1]), + int(cvals[2]), + db, + ) + if not matched: + all_ok = False + + for run in _modbus_command_contiguous_runs(rest): start_reg = int(run[0]["register"]) n = len(run) try: @@ -683,11 +806,6 @@ async def verify_modbus_commands( ) all_ok = False continue - if _is_deye_contiguous_clock_run(run): - matched = await _verify_deye_clock_command_run(run, values, db, site_id) - if not matched: - all_ok = False - continue for cmd, actual in zip(run, values): matched = await _apply_verify_result(cmd, int(actual)) if not matched: @@ -1172,7 +1290,7 @@ async def write_inverter_setpoints( if skip_time: logger.info( - "Deye clock 62–64 skipped (drift ≤ %ss, last write < %sh ago): %s CET", + "Deye clock 62–64 skipped (drift ≤ %ss, last sync < %sh ago): %s CET", DEYE_CLOCK_DRIFT_OK_SEC, DEYE_CLOCK_RESYNC_INTERVAL_HOURS, now_cet.strftime("%Y-%m-%d %H:%M:%S"), @@ -1290,6 +1408,19 @@ async def write_inverter_setpoints( return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)" logger.info("[control] Inverter %s journal write OK", inv.code) + will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers) + if will_write_time: + await db.execute( + """ + UPDATE ems.asset_inverter + SET deye_last_system_time_sync_minute = $1, + deye_last_system_time_sync_at = now() + WHERE id = $2 + """, + _prague_minute_start_utc(), + inv.id, + ) + if need_inactive_tou or will_write_inactive: await db.execute( """ diff --git a/backend/tests/test_deye_clock.py b/backend/tests/test_deye_clock.py index 3ba42fa..d5fe7e0 100644 --- a/backend/tests/test_deye_clock.py +++ b/backend/tests/test_deye_clock.py @@ -4,6 +4,7 @@ from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from services.control_exporter import ( DEYE_CLOCK_DRIFT_OK_SEC, @@ -12,6 +13,7 @@ from services.control_exporter import ( InverterConfig, PRAGUE_TZ, _deye_clock_registers_verify_match, + _deye_expected_clock_triplet_for_verify, _deye_registers_to_prague_datetime, _deye_should_skip_time_sync_after_read, _deye_system_time_register_rows, @@ -121,5 +123,23 @@ class DeyeSkipTimeSyncPolicyTests(unittest.TestCase): self.assertGreater(DEYE_CLOCK_DRIFT_OK_SEC, 5) +class DeyeClockTripletForVerifyTests(unittest.TestCase): + def test_orphan_reg64_fills_w62_w63_from_device_read(self) -> None: + a62 = (2026 - 2000) << 8 | 4 + a63 = 10 << 8 | 12 + a64 = 45 << 8 | 30 + bundle = [SimpleNamespace(register=64, value_to_write=(45 << 8) | 0)] + w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, {}, a62, a63, a64) + self.assertEqual(w62, a62) + self.assertEqual(w63, a63) + self.assertEqual(w64, 45 << 8) + + def test_last_verified_used_when_not_in_bundle(self) -> None: + bundle: list[SimpleNamespace] = [] + last = {62: 1, 63: 2, 64: 3} + w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last, 9, 8, 7) + self.assertEqual((w62, w63, w64), (1, 2, 3)) + + if __name__ == "__main__": unittest.main() diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index 69d8aa0..d56f830 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -2,7 +2,7 @@ ## Úč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:** souvislý blok Deye **62–64** (systémový čas) se ověřuje **tolerančně** podle dekódovaného data/času (kvůli tikajícím sekundám na invertoru); viz `modbus-registers.md`. +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` @@ -30,7 +30,7 @@ Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` **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_command_run`, `_switch_to_self_sustain`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `notify_operating_mode_changed`. +Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `notify_operating_mode_changed`. ## Střídač (Deye) diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index f7c2950..214f3e8 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -100,11 +100,11 @@ Registry **62–64** nastavují invertoru čas v **Europe/Prague**. - reg **63:** `den << 8 | hodina` - reg **64:** `minuta << 8 | sekunda` — při zápisu z EMS jsou **sekundy vždy 0** (stabilnější hodnota; na zařízení pak sekundy dál běží). -**Řidší zápis:** před každým exportem setpointů EMS **přečte** 62–64 (FC 0x03). Do journalu **62–64 nezařadí**, pokud je dekódovaný čas invertoru vůči aktuální **Europe/Prague** v odchylce **≤ 60 s** *a zároveň* od posledního **úspěšného ověření** trojice 62–64 v journalu (stav `verified` po toleranční kontrole níže) neuplynulo **24 h**. Tomu odpovídají sloupce `asset_inverter.deye_last_system_time_sync_at` a `deye_last_system_time_sync_minute`: doplňují se **až po úspěšné toleranční verifikaci** v `_verify_deye_clock_command_run`, nikoli po samotném FC 0x10 zápisu (aby řídicí logika ne„myslela“, že je čas na invertoru sjednocený, dokud to verify nepotvrdí). Je-li `deye_last_system_time_sync_at` **NULL** (první provoz / nikdy neověřený sync), zápis času se **nevynechá**. Při **selhání čtení** 62–64 před rozhodnutím se čas **zařadí do journalu** (bezpečný fallback). Při scénáři „žádný řádek journalu, všechny hodnoty jako poslední `verified`“ se **čas v DB neaktualizuje** (žádný fiktivní sync). +**Řidší zápis:** před každým exportem setpointů EMS **přečte** 62–64 (FC 0x03). Do journalu **62–64 nezařadí**, pokud je dekódovaný čas invertoru vůči aktuální **Europe/Prague** v odchylce **≤ 60 s** *a zároveň* od posledního **úspěšného syncu** neuplynulo **24 h**. Sloupce `asset_inverter.deye_last_system_time_sync_at` a `deye_last_system_time_sync_minute` se doplňují po **úspěšném FC 0x10 zápisu** batche obsahujícího 62–64 (`write_inverter_setpoints`) a znovu po **úspěšné toleranční verifikaci** (`_verify_deye_clock_written_bundle`) — obojí drží řidší zápis i když verify občas selže. Je-li `deye_last_system_time_sync_at` **NULL** (první provoz), zápis času se **nevynechá**. Při **selhání čtení** 62–64 před rozhodnutím se čas **zařadí do journalu** (bezpečný fallback). Při scénáři „žádný řádek journalu, všechny hodnoty jako poslední `verified`“ se **čas v DB neaktualizuje** (žádný fiktivní sync). Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**. -**Verifikace (journal):** u souvislého bloku **62–64** není porovnání bajt po bajtu — invertor mezi zápisem a čtením posune sekundy. EMS považuje zápis za úspěšný, pokud se dekódované časy (z `value_to_write` trojice vs. přečtené hodnoty) liší nejvýše o **120 s** (`control_exporter._verify_deye_clock_command_run`). Po **třech neúspěšných ověřeních** bloku 62–64 EMS **nepřepíná** provozní režim na SELF_SUSTAIN (na rozdíl od ostatních registrů); pošle **kritický Discord** (`notify_modbus_clock_verify_exhausted`) a příkazy zůstanou ve stavu vhodném pro diagnostiku — viz `modbus-command-journal.md`. +**Verifikace (journal):** registry **62–64** se **nikdy** neověřují striktním porovnáním po jednotlivých registrech (reg **64** by kvůli běžícím sekundám padal do `mismatch` a spouštěl SELF_SUSTAIN). Verifikační job vždy přečte **FC 0x03** souvisle **62–64** a použije toleranci **120 s** na dekódovaný čas (`_deye_clock_registers_verify_match`). Je-li ve stavu `written` jen podmnožina řádků (např. jen **64**), očekávané hodnoty pro chybějící registry se doplní z posledního `verified` nebo z aktuálního přečtení na sběrnici (`_deye_expected_clock_triplet_for_verify`). Po **třech neúspěšných ověřeních** bloku 62–64 EMS **nepřepíná** provozní režim na SELF_SUSTAIN; pošle **kritický Discord** (`notify_modbus_clock_verify_exhausted`) — viz `modbus-command-journal.md`. **Před vytvořením journalu:** pokud je navrhovaná hodnota **shodná s posledním `verified`** záznamem daného registru v `modbus_command`, EMS **řádek nevytvoří** a na Modbus neposílá (žádný „X → X“ zápis jen kvůli periodickému exportu). Výjimky řeší stávající logika (řidší 62–64 výše, denní TOU 3–6 + meta sloupce na `asset_inverter`).