Files
ems/backend/services/ev_notify.py
Dusan Vojacek 0e7f7b69ae
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Has been skipped
Discord bot fáze B: tlačítka na EV zprávě → patch session + okamžitý replan
services/discord_bot.py: gateway klient jako lifespan task (spojení ven,
žádný veřejný endpoint; bez DISCORD_BOT_TOKEN tiše spí). Tlačítka
[za 2h][za 4h][ráno][do plna][nenabíjet] s custom_id ev:<site>:<charger>:<akce>
(přežijí restart); whitelist DISCORD_ALLOWED_USER_IDS; akce = fn_ev_session_
apply_patch → run_rolling_replan → export_setpoints → edit zprávy novým plánem.

services/ev_notify.py: sdílený builder souhrnu (vyčleněno z collectoru),
send bot-first s webhook fallbackem. requirements: discord.py>=2.4.
7 testů helperů (parse, deadline akce vč. morning přes Prague TZ).

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

116 lines
4.0 KiB
Python
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.
"""Souhrn EV nabíjecího plánu pro notifikace (Discord webhook i bot).
Sdílené mezi telemetry_collector (zpráva po příjezdu) a discord_bot
(přestavba zprávy po akci tlačítkem).
"""
from __future__ import annotations
import logging
from zoneinfo import ZoneInfo
import asyncpg
logger = logging.getLogger(__name__)
_PRAGUE = ZoneInfo("Europe/Prague")
async def get_open_session(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> asyncpg.Record | None:
return await conn.fetchrow(
"""
select es.id as session_id, es.soc_at_connect_pct, es.target_soc_pct,
es.target_deadline, v.battery_capacity_kwh, v.name as vehicle_name,
v.default_deadline_hour
from ems.ev_session es
join ems.asset_ev_charger c on c.id = es.charger_id
left join ems.asset_vehicle v on v.id = es.vehicle_id
where es.site_id = $1 and c.code = $2 and es.session_end is null
order by es.id desc limit 1
""",
site_id,
charger_code,
)
async def build_ev_plan_summary(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> str | None:
"""Markdown souhrn: stav baterie auta → cíl, deadline, nabíjecí okna z plánu."""
row = await get_open_session(site_id, charger_code, conn)
if row is None:
return None
ev_col = "ev1_setpoint_w" if charger_code.endswith("1") else "ev2_setpoint_w"
slots = await conn.fetch(
f"""
select pi.interval_start, pi.{ev_col} as w, pi.effective_buy_price
from ems.planning_interval pi
join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = $1 and pr.status = 'active'
and coalesce(pi.{ev_col}, 0) > 0
order by pi.interval_start
""",
site_id,
)
def _fmt(dt) -> str:
return dt.astimezone(_PRAGUE).strftime("%H:%M")
windows: list[str] = []
kwh = 0.0
prices: list[float] = []
if slots:
start = prev = slots[0]["interval_start"]
for r in slots:
ts = r["interval_start"]
if (ts - prev).total_seconds() > 900:
windows.append(f"{_fmt(start)}{_fmt(prev)} (+15m)")
start = ts
prev = ts
kwh += float(r["w"]) * 0.25 / 1000.0
prices.append(float(r["effective_buy_price"] or 0))
windows.append(f"{_fmt(start)}{_fmt(prev)} (+15m)")
soc = row["soc_at_connect_pct"]
tgt = row["target_soc_pct"]
cap = float(row["battery_capacity_kwh"] or 0)
need = max(0.0, (float(tgt or 0) - float(soc or 0)) / 100.0 * cap)
lines = [
f"🔌 **{row['vehicle_name'] or charger_code} připojeno**",
f"Baterie auta: **{soc if soc is not None else '?'} %** → cíl {tgt if tgt is not None else '?'} %"
+ (f" (~{need:.0f} kWh)" if need else ""),
]
dl = row["target_deadline"]
if dl is not None:
lines.append(f"Deadline: {dl.astimezone(_PRAGUE).strftime('%a %d.%m. %H:%M')}")
if windows:
avg_p = sum(prices) / max(1, len(prices))
lines.append(
f"Plán nabíjení: {'; '.join(windows[:4])}{kwh:.1f} kWh, ø {avg_p:.2f} Kč/kWh"
)
else:
lines.append("Plán nabíjení: zatím žádné sloty (čeká na levné okno / PV)")
return "\n".join(lines)
async def send_ev_arrival(site_id: int, charger_code: str, conn: asyncpg.Connection) -> None:
"""Pošle souhrn po příjezdu: přednostně bot s tlačítky, jinak webhook."""
from services.notification_service import send_discord
text = await build_ev_plan_summary(site_id, charger_code, conn)
if text is None:
return
try:
from services.discord_bot import post_ev_arrival
row = await get_open_session(site_id, charger_code, conn)
if row is not None and await post_ev_arrival(
site_id, charger_code, int(row["session_id"]), text
):
return
except Exception:
logger.exception("Discord bot post failed — fallback webhook")
await send_discord(conn, site_id, text, level="info")