diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index 3a1303e..2b9f9d1 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -124,6 +124,8 @@ async def lifespan(app: FastAPI): rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()") for r in rows: await notify_operating_mode_changed( + conn, + int(r["site_id"]) if r.get("site_id") is not None else None, str(r["site_code"]), str(r["old_mode"]), str(r["new_mode"]), @@ -459,6 +461,8 @@ async def lifespan(app: FastAPI): datetime.now(ZoneInfo("Europe/Prague")) - timedelta(days=1) ).strftime("%Y-%m-%d") await notify_daily_economics( + conn, + site_id, site_code=site_code, day=yesterday, import_kwh=float(row.get("import_kwh") or 0), diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 94523bf..5f2c73d 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -698,6 +698,8 @@ async def _verify_deye_clock_written_bundle( attempts = max(attempts, ac) await notify_modbus_mismatch( + db, + site_id, ac0, 62, "system_time_62_64", @@ -726,6 +728,8 @@ async def _verify_deye_clock_written_bundle( ) site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id) await notify_modbus_clock_verify_exhausted( + db, + site_id, site["code"] if site else str(site_id), ac0, (w62, w63, w64), @@ -858,6 +862,8 @@ async def verify_modbus_commands( ) attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0 await notify_modbus_mismatch( + db, + site_id, cmd["asset_code"], reg, cmd["register_name"] or "", diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py index eb514da..302ce68 100644 --- a/backend/services/notification_service.py +++ b/backend/services/notification_service.py @@ -14,6 +14,40 @@ from app.config import get_settings logger = logging.getLogger(__name__) +_WEBHOOK_CACHE: dict[tuple[int, str], str] = {} + + +async def _get_site_webhook_url( + conn: asyncpg.Connection | None, + site_id: int | None, + kind: str, +) -> str: + """ + kind: 'daily' | 'error' + Fallback: settings.discord_webhook_url + """ + settings = get_settings() + if site_id is None: + return settings.discord_webhook_url + cache_key = (int(site_id), str(kind)) + cached = _WEBHOOK_CACHE.get(cache_key) + if cached is not None: + return cached + if conn is None: + return settings.discord_webhook_url + col = "discord_webhook_daily_url" if kind == "daily" else "discord_webhook_error_url" + try: + url = await conn.fetchval( + f"select {col} from ems.site where id = $1::int", + int(site_id), + ) + except Exception: + logger.exception("Failed to load site webhook url site_id=%s kind=%s", site_id, kind) + url = None + final = str(url or settings.discord_webhook_url or "") + _WEBHOOK_CACHE[cache_key] = final + return final + def _discord_level_for_mode_change(activated_by: str) -> str: if activated_by == "system:mismatch": @@ -24,6 +58,8 @@ def _discord_level_for_mode_change(activated_by: str) -> str: async def notify_operating_mode_changed( + conn: asyncpg.Connection | None, + site_id: int | None, site_code: str, previous_mode: str, new_mode: str, @@ -39,7 +75,7 @@ async def notify_operating_mode_changed( f"**{previous_mode}** → **{new_mode}**\n" f"Aktivoval: `{activated_by}`{note_line}" ) - await send_discord(msg, level=lvl) + await send_discord(conn, site_id, msg, level=lvl) async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None: @@ -100,6 +136,8 @@ async def run_fn_set_mode_with_discord( site_code = ctx.get("site_code") if prev is not None and prev != new: await notify_operating_mode_changed( + conn, + site_id, site_code or str(site_id), str(prev), str(new), @@ -120,6 +158,8 @@ async def run_fn_set_mode_with_discord( async def notify_plan_vs_actual_fatal( + conn: asyncpg.Connection | None, + site_id: int | None, site_code: str, slot_label: str, interval_start_utc: datetime, @@ -137,17 +177,22 @@ async def notify_plan_vs_actual_fatal( f"**{reason_code}**: {detail}\n" f"Plán grid: **{plan_grid_w}** W | Skutečnost: **{actual_grid_w}** W | Δ (act−plan): **{deviation_grid_w}** W" ) - await send_discord(msg, level="critical") + await send_discord(conn, site_id, msg, level="critical") -async def send_discord(message: str, level: str = "info") -> bool: +async def send_discord( + conn: asyncpg.Connection | None, + site_id: int | None, + message: str, + level: str = "info", +) -> bool: """ Pošle notifikaci na Discord webhook. level: 'info', 'warning', 'error', 'critical' Vrátí True při úspěchu. """ - settings = get_settings() - webhook_url = settings.discord_webhook_url + kind = "daily" if level == "info" else "error" + webhook_url = await _get_site_webhook_url(conn, site_id, kind) if not webhook_url: logger.debug("Discord webhook not configured, skipping notification") return False @@ -170,6 +215,8 @@ async def send_discord(message: str, level: str = "info") -> bool: async def notify_modbus_mismatch( + conn: asyncpg.Connection | None, + site_id: int | None, asset_code: str, register: int, register_name: str, @@ -183,18 +230,25 @@ async def notify_modbus_mismatch( f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n" f"Pokus č. {attempt}" ) - await send_discord(msg, level="error") + await send_discord(conn, site_id, msg, level="error") -async def notify_self_sustain_activated(site_code: str, reason: str) -> None: +async def notify_self_sustain_activated( + conn: asyncpg.Connection | None, + site_id: int | None, + site_code: str, + reason: str, +) -> None: msg = ( f"Přepnutí na **SELF_SUSTAIN** – lokalita `{site_code}`\n" f"Důvod: {reason}" ) - await send_discord(msg, level="critical") + await send_discord(conn, site_id, msg, level="critical") async def notify_modbus_clock_verify_exhausted( + conn: asyncpg.Connection | None, + site_id: int | None, site_code: str, asset_code: str, written: tuple[int, int, int], @@ -206,10 +260,12 @@ async def notify_modbus_clock_verify_exhausted( 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") + await send_discord(conn, site_id, msg, level="critical") async def notify_daily_economics( + conn: asyncpg.Connection | None, + site_id: int | None, site_code: str, day: str, import_kwh: float, @@ -236,4 +292,4 @@ async def notify_daily_economics( f" Plán předpokládal: {planned_balance:+.2f} Kč " f"(odchylka {dev_sign}{dev:.2f} Kč)" ) - await send_discord("\n".join(lines), level="info") + await send_discord(conn, site_id, "\n".join(lines), level="info") diff --git a/backend/services/plan_actual_slot_guard.py b/backend/services/plan_actual_slot_guard.py index 44bd44c..2d4e454 100644 --- a/backend/services/plan_actual_slot_guard.py +++ b/backend/services/plan_actual_slot_guard.py @@ -46,6 +46,7 @@ async def _dispatch_site_result(site_payload: dict[str, Any]) -> None: logger.warning("plan_actual_slot_guard: unknown site_id=%s", site_payload.get("site_id")) return site_code = str(site_payload.get("site_code") or site_payload.get("site_id") or "") + site_id = int(site_payload.get("site_id") or 0) or None alerts = site_payload.get("alerts") if not isinstance(alerts, list): return @@ -62,6 +63,8 @@ async def _dispatch_site_result(site_payload: dict[str, Any]) -> None: deviation_grid_w = int(alert.get("deviation_grid_w") or 0) slot_label = _slot_label_prague(interval_start) await notify_plan_vs_actual_fatal( + None, + site_id, site_code=site_code, slot_label=slot_label, interval_start_utc=interval_start, diff --git a/db/migration/V063__site_discord_webhooks.sql b/db/migration/V063__site_discord_webhooks.sql new file mode 100644 index 0000000..272ec91 --- /dev/null +++ b/db/migration/V063__site_discord_webhooks.sql @@ -0,0 +1,10 @@ +alter table ems.site + add column if not exists discord_webhook_daily_url text, + add column if not exists discord_webhook_error_url text; + +comment on column ems.site.discord_webhook_daily_url is +'Discord webhook pro běžné denní zprávy (např. ranní ekonomický report). Per-site konfigurace.'; + +comment on column ems.site.discord_webhook_error_url is +'Discord webhook pro error/critical alerty (mismatch, fatal plan vs actual, clock verify exhausted, apod.). Per-site konfigurace.'; + diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index ed1b3b7..323d312 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -284,6 +284,17 @@ LOXONE_PASSWORD=secret --- +## Discord notifikace + +Discord notifikace jsou volitelné a routované per-site: + +- `ems.site.discord_webhook_daily_url` – denní zprávy (např. ranní ekonomický report) +- `ems.site.discord_webhook_error_url` – error/critical alerty (mismatch, fatal plan vs actual, clock verify exhausted, …) + +Fallback: pokud per-site webhook není vyplněný, použije se env `DISCORD_WEBHOOK_URL`. + +--- + ## Otevřené body - [ ] Doplnit Modbus write registry Deye (charge/discharge/export limit)