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>
116 lines
4.0 KiB
Python
116 lines
4.0 KiB
Python
"""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")
|