Discord bot fáze B: tlačítka na EV zprávě → patch session + okamžitý replan
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Has been skipped

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>
This commit is contained in:
Dusan Vojacek
2026-06-12 11:41:05 +02:00
parent 08a43aa236
commit 0e7f7b69ae
8 changed files with 435 additions and 88 deletions

View File

@@ -49,7 +49,9 @@ async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
)
await export_setpoints(site_id, conn)
try:
await _notify_ev_arrival_plan(site_id, charger_code, conn)
from services.ev_notify import send_ev_arrival
await send_ev_arrival(site_id, charger_code, conn)
except Exception:
logger.exception("EV arrival Discord notify failed (%s)", charger_code)
logger.info(
@@ -106,87 +108,6 @@ async def _on_ev_departure(site_id: int, charger_code: str) -> None:
)
async def _notify_ev_arrival_plan(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> None:
"""Discord souhrn po příjezdu EV: stav baterie auta + kdy se bude nabíjet.
Čte čerstvý aktivní plán (ev sloty s výkonem > 0) a otevřenou session;
sloty shlukuje do souvislých oken. Fáze B (interakce „odjíždím za 2h"
tlačítkem) = Discord bot, viz docs/discord-ev-interaction.md.
"""
from services.notification_service import send_discord
row = await conn.fetchrow(
"""
select es.soc_at_connect_pct, es.target_soc_pct, es.target_deadline,
v.battery_capacity_kwh, v.name as vehicle_name
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,
)
if row is None:
return
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_TZ_NOTIFY).strftime("%H:%M")
windows: list[str] = []
if slots:
start = prev = slots[0]["interval_start"]
kwh = 0.0
prices: list[float] = []
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)")
avg_p = sum(prices) / max(1, len(prices))
else:
kwh, avg_p = 0.0, 0.0
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)
dl = row["target_deadline"]
lines = [
f"🔌 **{row['vehicle_name'] or charger_code} připojeno**",
f"Baterie auta: **{soc or '?'} %** → cíl {tgt or '?'} %"
+ (f" (~{need:.0f} kWh)" if need else ""),
]
if dl is not None:
lines.append(f"Deadline: {dl.astimezone(_PRAGUE_TZ_NOTIFY).strftime('%a %d.%m. %H:%M')}")
if windows:
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)")
await send_discord(conn, site_id, "\n".join(lines), level="info")
async def _patch_session_from_tesla(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> None: