extend webhook per site
This commit is contained in:
@@ -124,6 +124,8 @@ async def lifespan(app: FastAPI):
|
|||||||
rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()")
|
rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()")
|
||||||
for r in rows:
|
for r in rows:
|
||||||
await notify_operating_mode_changed(
|
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["site_code"]),
|
||||||
str(r["old_mode"]),
|
str(r["old_mode"]),
|
||||||
str(r["new_mode"]),
|
str(r["new_mode"]),
|
||||||
@@ -459,6 +461,8 @@ async def lifespan(app: FastAPI):
|
|||||||
datetime.now(ZoneInfo("Europe/Prague")) - timedelta(days=1)
|
datetime.now(ZoneInfo("Europe/Prague")) - timedelta(days=1)
|
||||||
).strftime("%Y-%m-%d")
|
).strftime("%Y-%m-%d")
|
||||||
await notify_daily_economics(
|
await notify_daily_economics(
|
||||||
|
conn,
|
||||||
|
site_id,
|
||||||
site_code=site_code,
|
site_code=site_code,
|
||||||
day=yesterday,
|
day=yesterday,
|
||||||
import_kwh=float(row.get("import_kwh") or 0),
|
import_kwh=float(row.get("import_kwh") or 0),
|
||||||
|
|||||||
@@ -698,6 +698,8 @@ async def _verify_deye_clock_written_bundle(
|
|||||||
attempts = max(attempts, ac)
|
attempts = max(attempts, ac)
|
||||||
|
|
||||||
await notify_modbus_mismatch(
|
await notify_modbus_mismatch(
|
||||||
|
db,
|
||||||
|
site_id,
|
||||||
ac0,
|
ac0,
|
||||||
62,
|
62,
|
||||||
"system_time_62_64",
|
"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)
|
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
||||||
await notify_modbus_clock_verify_exhausted(
|
await notify_modbus_clock_verify_exhausted(
|
||||||
|
db,
|
||||||
|
site_id,
|
||||||
site["code"] if site else str(site_id),
|
site["code"] if site else str(site_id),
|
||||||
ac0,
|
ac0,
|
||||||
(w62, w63, w64),
|
(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
|
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||||
await notify_modbus_mismatch(
|
await notify_modbus_mismatch(
|
||||||
|
db,
|
||||||
|
site_id,
|
||||||
cmd["asset_code"],
|
cmd["asset_code"],
|
||||||
reg,
|
reg,
|
||||||
cmd["register_name"] or "",
|
cmd["register_name"] or "",
|
||||||
|
|||||||
@@ -14,6 +14,40 @@ from app.config import get_settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _discord_level_for_mode_change(activated_by: str) -> str:
|
||||||
if activated_by == "system:mismatch":
|
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(
|
async def notify_operating_mode_changed(
|
||||||
|
conn: asyncpg.Connection | None,
|
||||||
|
site_id: int | None,
|
||||||
site_code: str,
|
site_code: str,
|
||||||
previous_mode: str,
|
previous_mode: str,
|
||||||
new_mode: str,
|
new_mode: str,
|
||||||
@@ -39,7 +75,7 @@ async def notify_operating_mode_changed(
|
|||||||
f"**{previous_mode}** → **{new_mode}**\n"
|
f"**{previous_mode}** → **{new_mode}**\n"
|
||||||
f"Aktivoval: `{activated_by}`{note_line}"
|
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:
|
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")
|
site_code = ctx.get("site_code")
|
||||||
if prev is not None and prev != new:
|
if prev is not None and prev != new:
|
||||||
await notify_operating_mode_changed(
|
await notify_operating_mode_changed(
|
||||||
|
conn,
|
||||||
|
site_id,
|
||||||
site_code or str(site_id),
|
site_code or str(site_id),
|
||||||
str(prev),
|
str(prev),
|
||||||
str(new),
|
str(new),
|
||||||
@@ -120,6 +158,8 @@ async def run_fn_set_mode_with_discord(
|
|||||||
|
|
||||||
|
|
||||||
async def notify_plan_vs_actual_fatal(
|
async def notify_plan_vs_actual_fatal(
|
||||||
|
conn: asyncpg.Connection | None,
|
||||||
|
site_id: int | None,
|
||||||
site_code: str,
|
site_code: str,
|
||||||
slot_label: str,
|
slot_label: str,
|
||||||
interval_start_utc: datetime,
|
interval_start_utc: datetime,
|
||||||
@@ -137,17 +177,22 @@ async def notify_plan_vs_actual_fatal(
|
|||||||
f"**{reason_code}**: {detail}\n"
|
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"
|
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.
|
Pošle notifikaci na Discord webhook.
|
||||||
level: 'info', 'warning', 'error', 'critical'
|
level: 'info', 'warning', 'error', 'critical'
|
||||||
Vrátí True při úspěchu.
|
Vrátí True při úspěchu.
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
kind = "daily" if level == "info" else "error"
|
||||||
webhook_url = settings.discord_webhook_url
|
webhook_url = await _get_site_webhook_url(conn, site_id, kind)
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
logger.debug("Discord webhook not configured, skipping notification")
|
logger.debug("Discord webhook not configured, skipping notification")
|
||||||
return False
|
return False
|
||||||
@@ -170,6 +215,8 @@ async def send_discord(message: str, level: str = "info") -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def notify_modbus_mismatch(
|
async def notify_modbus_mismatch(
|
||||||
|
conn: asyncpg.Connection | None,
|
||||||
|
site_id: int | None,
|
||||||
asset_code: str,
|
asset_code: str,
|
||||||
register: int,
|
register: int,
|
||||||
register_name: str,
|
register_name: str,
|
||||||
@@ -183,18 +230,25 @@ async def notify_modbus_mismatch(
|
|||||||
f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n"
|
f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n"
|
||||||
f"Pokus č. {attempt}"
|
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 = (
|
msg = (
|
||||||
f"Přepnutí na **SELF_SUSTAIN** – lokalita `{site_code}`\n"
|
f"Přepnutí na **SELF_SUSTAIN** – lokalita `{site_code}`\n"
|
||||||
f"Důvod: {reason}"
|
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(
|
async def notify_modbus_clock_verify_exhausted(
|
||||||
|
conn: asyncpg.Connection | None,
|
||||||
|
site_id: int | None,
|
||||||
site_code: str,
|
site_code: str,
|
||||||
asset_code: str,
|
asset_code: str,
|
||||||
written: tuple[int, int, int],
|
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"Zapsáno: `{written}` | Přečteno: `{actual}`\n"
|
||||||
f"Doporučení: zkontrolovat firmware/RS485; režim EMS se nemění automaticky."
|
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(
|
async def notify_daily_economics(
|
||||||
|
conn: asyncpg.Connection | None,
|
||||||
|
site_id: int | None,
|
||||||
site_code: str,
|
site_code: str,
|
||||||
day: str,
|
day: str,
|
||||||
import_kwh: float,
|
import_kwh: float,
|
||||||
@@ -236,4 +292,4 @@ async def notify_daily_economics(
|
|||||||
f" Plán předpokládal: {planned_balance:+.2f} Kč "
|
f" Plán předpokládal: {planned_balance:+.2f} Kč "
|
||||||
f"(odchylka {dev_sign}{dev:.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")
|
||||||
|
|||||||
@@ -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"))
|
logger.warning("plan_actual_slot_guard: unknown site_id=%s", site_payload.get("site_id"))
|
||||||
return
|
return
|
||||||
site_code = str(site_payload.get("site_code") or site_payload.get("site_id") or "")
|
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")
|
alerts = site_payload.get("alerts")
|
||||||
if not isinstance(alerts, list):
|
if not isinstance(alerts, list):
|
||||||
return
|
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)
|
deviation_grid_w = int(alert.get("deviation_grid_w") or 0)
|
||||||
slot_label = _slot_label_prague(interval_start)
|
slot_label = _slot_label_prague(interval_start)
|
||||||
await notify_plan_vs_actual_fatal(
|
await notify_plan_vs_actual_fatal(
|
||||||
|
None,
|
||||||
|
site_id,
|
||||||
site_code=site_code,
|
site_code=site_code,
|
||||||
slot_label=slot_label,
|
slot_label=slot_label,
|
||||||
interval_start_utc=interval_start,
|
interval_start_utc=interval_start,
|
||||||
|
|||||||
10
db/migration/V063__site_discord_webhooks.sql
Normal file
10
db/migration/V063__site_discord_webhooks.sql
Normal file
@@ -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.';
|
||||||
|
|
||||||
@@ -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
|
## Otevřené body
|
||||||
|
|
||||||
- [ ] Doplnit Modbus write registry Deye (charge/discharge/export limit)
|
- [ ] Doplnit Modbus write registry Deye (charge/discharge/export limit)
|
||||||
|
|||||||
Reference in New Issue
Block a user