Bazén vizualizace + EV Discord notifikace po příjezdu (fáze A)
- R__097: vw_latest_pool_pump + vw_pool_pump_day_energy (denní kWh z delty čítače, minuty běhu) + ems_anon granty - PoolCard na Dashboardu: stav/W/dnešní kWh+hodiny/7denní mini sloupce - _notify_ev_arrival_plan: po příjezdu EV Discord souhrn (SoC auta → cíl, deadline, nabíjecí okna shlukovaná ze slotů aktivního plánu, ø cena) - docs/discord-ev-interaction.md: fáze B (bot s tlačítky přes gateway — žádný veřejný endpoint; čeká na DISCORD_BOT_TOKEN od uživatele) - docs: pool-shelly + ev-charging aktualizovány (pravidlo docs 1:1) První commit na dev větvi (nová kadence: deploy až s milníkovým merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,12 @@ from datetime import datetime, timezone
|
||||
import asyncpg
|
||||
from app.ws_manager import manager
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
_PRAGUE_TZ_NOTIFY = ZoneInfo("Europe/Prague")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Pool pro fire-and-forget akce mimo hlavní poll spojení (např. replan po
|
||||
@@ -44,6 +48,10 @@ async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
|
||||
site_id, conn, triggered_by=f"ev_arrival:{charger_code}"
|
||||
)
|
||||
await export_setpoints(site_id, conn)
|
||||
try:
|
||||
await _notify_ev_arrival_plan(site_id, charger_code, conn)
|
||||
except Exception:
|
||||
logger.exception("EV arrival Discord notify failed (%s)", charger_code)
|
||||
logger.info(
|
||||
"EV arrival replan+export done (site=%s, charger=%s)",
|
||||
site_id,
|
||||
@@ -98,6 +106,87 @@ 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:
|
||||
|
||||
Reference in New Issue
Block a user