"""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")