diff --git a/CLAUDE.md b/CLAUDE.md index 9593792..6da1ac6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,9 +81,9 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá 16. **Rozšířený horizont plánování (96h):** denní plán pokrývá **96h** od začátku aktuálního 15min slotu. Sloty v prvních **36h** používají přesné efektivní ceny z `vw_site_effective_price` (OTE). Sloty **36–96h** doplňuje **predikovaná cena** z `market_price_stats` přes `fn_get_predicted_price` (prodejní strana hrubý faktor 0,85 vs. nákupní predikce). V účelové funkci LP se uplatní **váhy nejistoty** podle vzdálenosti od začátku okna: **1,0** (0–36h), **0,7** (36–72h), **0,4** (72–96h). Statistiky cen plní `fn_update_market_price_stats` (job 14:45), TUV delta `fn_update_tuv_usage_stats` (job 00:45). Detail: `docs/04-modules/planning-extended-horizon.md`. -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 → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. +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 zápisu času (`deye_last_system_time_sync_at` se mění jen při 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**). **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 **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`. 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). @@ -129,7 +129,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá | `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`. --- diff --git a/backend/app/main.py b/backend/app/main.py index 1a7afc7..b203f99 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -40,6 +40,10 @@ from services.control_exporter import ( ) from services.heartbeat_service import send_heartbeat from services.forecast_service import fetch_pv_forecast +from services.notification_service import ( + notify_operating_mode_changed, + run_fn_set_mode_with_discord, +) from services.price_importer import import_ote_prices from services.telemetry_collector import run_telemetry_loop_wrapper from pydantic import BaseModel, Field @@ -123,7 +127,15 @@ async def lifespan(app: FastAPI): async def scheduled_expire_modes() -> None: async with app.state.pg_pool.acquire() as conn: try: - await conn.fetchval("SELECT ems.fn_expire_modes()") + rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()") + for r in rows: + await notify_operating_mode_changed( + str(r["site_code"]), + str(r["old_mode"]), + str(r["new_mode"]), + "system:expiry", + "Automatické vypršení dočasného režimu", + ) except Exception: logger.exception("scheduled_expire_modes failed") @@ -1215,8 +1227,8 @@ async def set_site_mode( raise HTTPException(status_code=404, detail="Site not found") try: - await conn.execute( - "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", + await run_fn_set_mode_with_discord( + conn, site_id, mode, "user:api", diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index 56ca8ca..ac2f255 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -178,8 +178,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 zápisu času - neuplynul 24h (deye_last_system_time_sync_at se mění jen při zápisu, ne při přeskočení). + 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. """ dev = _deye_registers_to_prague_datetime(r62, r63, r64) if dev is None: @@ -438,9 +439,11 @@ async def execute_modbus_commands( async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None: - """Přepne lokalitu na SELF_SUSTAIN a zaloguje důvod.""" - await db.execute( - "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", + """Přepne lokalitu na SELF_SUSTAIN, zaloguje důvod a při změně pošle Discord.""" + from services.notification_service import run_fn_set_mode_with_discord + + await run_fn_set_mode_with_discord( + db, site_id, "SELF_SUSTAIN", "system:mismatch", @@ -461,8 +464,8 @@ async def _verify_deye_clock_command_run( Při mismatch retry všech tří řádků journalu společně. """ from services.notification_service import ( + notify_modbus_clock_verify_exhausted, notify_modbus_mismatch, - notify_self_sustain_activated, ) run_s = sorted(run, key=lambda c: int(c["register"])) @@ -487,6 +490,17 @@ async def _verify_deye_clock_command_run( ) if clock_ok: + inv_asset_id = int(run_s[0]["asset_id"]) + 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_asset_id, + ) for cmd, actual in zip(run_s, values): logger.info( "[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s", @@ -537,26 +551,15 @@ async def _verify_deye_clock_command_run( await verify_modbus_commands(ids_ordered, db, site_id) else: logger.critical( - "[cmd clock] 3 failed attempts (62–64 batch), switching to SELF_SUSTAIN" + "[cmd clock] 3 failed verify attempts (62–64); režim se nemění automaticky" ) site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id) - await _switch_to_self_sustain( - site_id, - db, - reason=( - f"Modbus mismatch po 3 pokusech: {cmd0['asset_code']} " - "regs 62–64 (system time)" - ), + await notify_modbus_clock_verify_exhausted( + site["code"] if site else str(site_id), + str(cmd0["asset_code"]), + (w62, w63, w64), + (a62, a63, a64), ) - if site: - await notify_self_sustain_activated( - site["code"], - ( - f"Modbus mismatch: {cmd0['asset_code']} " - f"regs 62–64 (system time) written=({w62},{w63},{w64}) " - f"actual=({a62},{a63},{a64})" - ), - ) return False @@ -569,10 +572,7 @@ async def verify_modbus_commands( Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write. Při mismatch: retry → SELF_SUSTAIN + Discord. """ - from services.notification_service import ( - notify_modbus_mismatch, - notify_self_sustain_activated, - ) + from services.notification_service import notify_modbus_mismatch async def _apply_verify_result(cmd: asyncpg.Record, actual_i: int) -> bool: """Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN).""" @@ -624,9 +624,6 @@ async def verify_modbus_commands( "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", cmd_id, ) - site = await db.fetchrow( - "SELECT code FROM ems.site WHERE id=$1", site_id - ) await _switch_to_self_sustain( site_id, db, @@ -635,15 +632,6 @@ async def verify_modbus_commands( f"reg 0x{cmd['register']:04X}" ), ) - if site: - await notify_self_sustain_activated( - site["code"], - ( - f"Modbus mismatch: {cmd['asset_code']} " - f"0x{cmd['register']:04X} expected={expected_i} " - f"actual={actual_i}" - ), - ) return False logger.info( @@ -726,7 +714,17 @@ async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> Operati if vu.tzinfo is None: vu = vu.replace(tzinfo=timezone.utc) if vu <= now_utc: - await db.execute("SELECT ems.fn_expire_modes()") + exp_rows = await db.fetch("SELECT * FROM ems.fn_expire_modes()") + from services.notification_service import notify_operating_mode_changed + + for er in exp_rows: + await notify_operating_mode_changed( + str(er["site_code"]), + str(er["old_mode"]), + str(er["new_mode"]), + "system:expiry", + "Automatické vypršení dočasného režimu", + ) row = await db.fetchrow(sql, site_id) if row is None: return None @@ -1183,7 +1181,6 @@ async def write_inverter_setpoints( logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S")) registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows) - time_rows_were_scheduled = not skip_time sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now hh_cur = current_slot_hhmm() @@ -1268,22 +1265,10 @@ async def write_inverter_setpoints( inactive_sig, inv.id, ) - if time_rows_were_scheduled: - 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, - ) return ( f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)" ) - will_write_time = any(int(r) in (62, 63, 64) for r, _, _ in registers) will_write_inactive = any( int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers ) @@ -1305,18 +1290,6 @@ 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) - minute_utc = _prague_minute_start_utc() - 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 - """, - minute_utc, - inv.id, - ) if need_inactive_tou or will_write_inactive: await db.execute( """ diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py index 9d3b59e..109d427 100644 --- a/backend/services/notification_service.py +++ b/backend/services/notification_service.py @@ -3,7 +3,9 @@ from __future__ import annotations import logging +from datetime import datetime +import asyncpg import httpx from app.config import get_settings @@ -11,6 +13,80 @@ from app.config import get_settings logger = logging.getLogger(__name__) +def _discord_level_for_mode_change(activated_by: str) -> str: + if activated_by == "system:mismatch": + return "critical" + if activated_by.startswith("system:"): + return "warning" + return "info" + + +async def notify_operating_mode_changed( + site_code: str, + previous_mode: str, + new_mode: str, + activated_by: str, + notes: str | None, + *, + level: str | None = None, +) -> None: + lvl = level or _discord_level_for_mode_change(activated_by) + note_line = f"\nPoznámka: {notes}" if notes else "" + msg = ( + f"Přepnutí provozního režimu – lokalita `{site_code}`\n" + f"**{previous_mode}** → **{new_mode}**\n" + f"Aktivoval: `{activated_by}`{note_line}" + ) + await send_discord(msg, level=lvl) + + +async def run_fn_set_mode_with_discord( + conn: asyncpg.Connection, + site_id: int, + mode_code: str, + activated_by: str, + valid_until: datetime | None, + notes: str | None, + *, + notify_level: str | None = None, +) -> str: + """ + Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook). + Vrátí aktuální mode_code z DB po volání. + """ + prev = await conn.fetchval( + "SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", + site_id, + ) + await conn.execute( + "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", + site_id, + mode_code, + activated_by, + valid_until, + notes, + ) + new = await conn.fetchval( + "SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1", + site_id, + ) + if new is None: + new = mode_code + if prev is not None and prev != new: + site_code = await conn.fetchval( + "SELECT code FROM ems.site WHERE id = $1", site_id + ) + await notify_operating_mode_changed( + site_code or str(site_id), + str(prev), + str(new), + activated_by, + notes, + level=notify_level, + ) + return str(new) + + async def send_discord(message: str, level: str = "info") -> bool: """ Pošle notifikaci na Discord webhook. @@ -65,6 +141,21 @@ async def notify_self_sustain_activated(site_code: str, reason: str) -> None: await send_discord(msg, level="critical") +async def notify_modbus_clock_verify_exhausted( + site_code: str, + asset_code: str, + written: tuple[int, int, int], + actual: tuple[int, int, int], +) -> None: + msg = ( + f"Modbus **systémový čas 62–64** – po 3 neúspěšných ověřeních **bez** přepnutí režimu.\n" + f"Lokalita `{site_code}`, zařízení `{asset_code}`\n" + f"Zapsáno: `{written}` | Přečteno: `{actual}`\n" + f"Doporučení: zkontrolovat firmware/RS485; režim EMS se nemění automaticky." + ) + await send_discord(msg, level="critical") + + async def notify_daily_economics( site_code: str, day: str, diff --git a/db/routines/R__fn_set_mode.sql b/db/routines/R__fn_set_mode.sql index 4dfb976..bec59f1 100644 --- a/db/routines/R__fn_set_mode.sql +++ b/db/routines/R__fn_set_mode.sql @@ -102,38 +102,44 @@ Pokud předchozí režim neexistuje, přepne na AUTO. Používat po skončení d -- ============================================================ CREATE OR REPLACE FUNCTION ems.fn_expire_modes() -RETURNS INT +RETURNS TABLE(site_id INT, site_code TEXT, old_mode TEXT, new_mode TEXT) LANGUAGE plpgsql AS $$ DECLARE - v_count INT := 0; - v_rec RECORD; + v_rec RECORD; + v_new_mode TEXT; BEGIN - -- Najít lokality kde vypršel valid_until a přepnout na AUTO FOR v_rec IN - SELECT site_id, previous_mode - FROM ems.site_operating_mode - WHERE valid_until IS NOT NULL - AND valid_until <= now() - AND mode_code <> 'AUTO' + SELECT som.site_id, + s.code AS site_code, + som.mode_code AS old_mode, + som.previous_mode + FROM ems.site_operating_mode som + JOIN ems.site s ON s.id = som.site_id + WHERE som.valid_until IS NOT NULL + AND som.valid_until <= now() + AND som.mode_code <> 'AUTO' LOOP + v_new_mode := COALESCE(v_rec.previous_mode, 'AUTO'); PERFORM ems.fn_set_mode( v_rec.site_id, - COALESCE(v_rec.previous_mode, 'AUTO'), + v_new_mode, 'system:expiry', NULL, 'Automatické vypršení dočasného režimu' ); - v_count := v_count + 1; + site_id := v_rec.site_id; + site_code := v_rec.site_code; + old_mode := v_rec.old_mode; + new_mode := v_new_mode; + RETURN NEXT; END LOOP; - - RETURN v_count; END; $$; COMMENT ON FUNCTION ems.fn_expire_modes() IS 'Zkontroluje všechny lokality s dočasným režimem (valid_until IS NOT NULL) a přepne zpět ty s prosahlým časem. -Volat každou minutu jako scheduled task. Vrátí počet přepnutých lokalit.'; +Volat každou minutu jako scheduled task. Vrátí řádky (site_id, site_code, old_mode, new_mode) pro každé provedené přepnutí — backend z toho pošle Discord notifikace.'; -- ============================================================ diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index 4f26c91..69d8aa0 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -22,11 +22,15 @@ Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` ## Verifikace a bezpečnost -1. Po `mismatch` se odešle **Discord** alert (`notification_service.send_discord` / `notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`. +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. Po třech neúspěšných cyklech: přepnutí lokality na **SELF_SUSTAIN** přes `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod) a **kritický** Discord alert (`notify_self_sustain_activated`). +3. Po třech neúspěšných cyklech ověření: + - **Obyčejné registry** (mimo souvislý blok Deye **62–64**): 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). + - **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). -Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_switch_to_self_sustain`. +**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`. ## Střídač (Deye) @@ -56,6 +60,6 @@ Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN ## Související soubory -- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql` +- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`; repeatables `db/routines/R__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` diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 60fd29a..f7c2950 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 zápisu** 62–64 neuplynulo **24 h** (`asset_inverter.deye_last_system_time_sync_at`, mění se jen při zápisu). Je-li sloupec NULL (např. první provoz), zápis času se **nevynechá**. Při selhání čtení se čas **zapíše** (bezpečný fallback). Sloupec `deye_last_system_time_sync_minute` doplňuje začátek pražské minuty u **úspěšného zápisu** 62–64. +**Ř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). 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`). +**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`. **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`). diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index 778d282..be02049 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -38,7 +38,7 @@ Tyto politiky jsou parametrizace AUTO/SELF_SUSTAIN, ne samostatné fyzické stav EMS a Loxone sdílí pojmenované provozní režimy; Loxone dostává číslo režimu přes Virtual Input a může fungovat autonomně (watchdog při výpadku EMS). ``` -POST /api/sites/{site_id}/mode +POST /api/v1/sites/{site_id}/mode { "mode": "SELF_SUSTAIN", "valid_until": null, @@ -46,9 +46,9 @@ POST /api/sites/{site_id}/mode } ``` -Backend: `ems.fn_set_mode` + HTTP na Loxone `/dev/sps/io/EMS_Mode/{loxone_mode_value}`. Dočasné přepisy s `valid_until` ruší `fn_expire_modes()`. +Backend: `ems.fn_set_mode` přes `run_fn_set_mode_with_discord` (při skutečné změně `mode_code` → Discord, pokud je `DISCORD_WEBHOOK_URL`) + HTTP na Loxone `/dev/sps/io/EMS_Mode/{loxone_mode_value}`. Dočasné přepisy s `valid_until` ruší `ems.fn_expire_modes()`, která vrací řádky `(site_id, site_code, old_mode, new_mode)` pro každé přepnutí — scheduler je použije pro stejné Discord upozornění. -**Klíčový princip:** Loxone watchdog nečte DB – sleduje pulzy `EMS_Heartbeat`. Detail: `docs/loxone-integration.md`. +**Klíčový princip:** Loxone watchdog nečte DB – sleduje pulzy `EMS_Heartbeat`. Detail: `docs/loxone-integration.md`. Detail Modbus / Discord: `docs/04-modules/modbus-command-journal.md`. ### Tabulka režimů (Loxone / zátěže) @@ -62,4 +62,4 @@ Backend: `ems.fn_set_mode` + HTTP na Loxone `/dev/sps/io/EMS_Mode/{loxone_mode_v ### Otevřené body -- [ ] Doplnit alerty při `ems_heartbeat_status = 'stale'` +- [ ] Doplnit alerty při `ems_heartbeat_status = 'stale'` (Discord při změně provozního režimu z backendu je popsán v `modbus-command-journal.md`) diff --git a/docs/05-todo.md b/docs/05-todo.md index 051fabe..b0e09ea 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -45,7 +45,7 @@ Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smyslu | Ověřit **Modbus registr Output Power Limit** (curtailment pole A) na Deye SUN-20K. | `docs/04-modules/planning.md` ř. 422 | programátor (+ dokumentace od majitele) | | Doplnit **skutečnou sazbu zeleného bonusu** do `asset_pv_array.green_bonus_czk_kwh` pro `pv-b` (aktuální placeholder: **7.135** Kč/kWh – ověřit ze smlouvy s EG.D). | `db/migration/V017__green_bonus.sql` (seed `pv-b`) | majitel (smlouva) → programátor | | Doplnit **`green_bonus_meter_code`** (EAN zeleného elektroměru) pro `pv-b` v `asset_pv_array`. | `db/migration/V017__green_bonus.sql` / přímá úprava DB | majitel → programátor | -| Nastavit **`DISCORD_WEBHOOK_URL`** pro produkční alerty (Modbus mismatch, přepnutí SELF_SUSTAIN). | `.env` / `backend/app/config.py` | majitel → programátor | +| Nastavit **`DISCORD_WEBHOOK_URL`** pro produkční alerty (Modbus mismatch, vyčerpaná verifikace času 62–64, přepnutí provozního režimu včetně SELF_SUSTAIN z mismatch, API, vypršení `valid_until`). | `.env` / `backend/app/config.py` | majitel → programátor | | **Cut-off přepínač** pro mikroinvertory (druhá instalace) – napojit logiku na `ems.cutoff_switch_log` a řízení. | `docs/04-modules/modbus-command-journal.md` | programátor | ---