Bazén vizualizace + EV Discord notifikace po příjezdu (fáze A)
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has been cancelled

- 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:
Dusan Vojacek
2026-06-12 10:59:09 +02:00
parent 5d2c09401a
commit 29d854f23d
8 changed files with 321 additions and 0 deletions

Submodule .claude/worktrees/agent-ad77a56dba4e495d7 added at cf663ae417

View File

@@ -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:

View File

@@ -0,0 +1,47 @@
-- Bazénové čerpadlo: poslední stav + denní spotřeba pro UI (PostgREST).
create or replace view ems.vw_latest_pool_pump
with (security_invoker = false)
as
select
pp.site_id,
pp.id as pump_id,
pp.code as pump_code,
pp.rated_power_w,
pp.schedulable,
t.measured_at,
t.is_on,
t.power_w,
t.energy_wh_total,
now() - t.measured_at as data_age
from ems.asset_pool_pump pp
left join lateral (
select tp.measured_at, tp.is_on, tp.power_w, tp.energy_wh_total
from ems.telemetry_pool_pump tp
where tp.pump_id = pp.id
order by tp.measured_at desc
limit 1
) t on true;
comment on view ems.vw_latest_pool_pump is
'Poslední telemetrie bazénového čerpadla (LATERAL per pump). Dashboard karta.';
-- Denní spotřeba z čítače Shelly (delta maxmin za pražský den, posledních 8 dní).
create or replace view ems.vw_pool_pump_day_energy
with (security_invoker = false)
as
select
tp.site_id,
tp.pump_id,
(tp.measured_at at time zone 'Europe/Prague')::date as day,
round((max(tp.energy_wh_total) - min(tp.energy_wh_total)) / 1000.0, 2) as kwh,
sum(case when tp.is_on then 1 else 0 end) as on_minutes
from ems.telemetry_pool_pump tp
where tp.measured_at >= now() - interval '8 days'
group by 1, 2, 3;
comment on view ems.vw_pool_pump_day_energy is
'Denní kWh čerpadla (delta čítače energy_wh_total) a minuty běhu, 8 dní zpět.';
grant select on ems.vw_latest_pool_pump to ems_anon;
grant select on ems.vw_pool_pump_day_energy to ems_anon;

View File

@@ -335,3 +335,10 @@ a ≥3 km) + `fn_ev_required_soc` (P80 spotřeby dne + 10 p.b., clamp
Tesla napojení (SoC při příjezdu → `soc_at_connect_pct`): `docs/tesla-fleet-api.md`.
Registry wallboxu: `docs/04-modules/modbus-registers-teltocharge.md`.
## Discord notifikace po příjezdu (2026-06-12, dev)
Po detekci příjezdu + Tesla SoC + replanu odejde na site webhook souhrn:
stav baterie auta → cíl (+kWh), deadline, plánovaná nabíjecí okna s ø cenou
(`_notify_ev_arrival_plan` v telemetry_collector). Interaktivní fáze B
(tlačítka „odjíždím za 2 h" → patch session + replan): `docs/discord-ev-interaction.md`.

View File

@@ -110,3 +110,12 @@ V `solver_v2.py` je TČ spojitá proměnná `hp[t] ∈ [0, rated_w]` vstupujíc
6. [ ] Test zapnutí: `select ems.fn_signal_enqueue_bool(<site_id>, 'POOL_PUMP_ON', true);` → do ~30 s `signal_outbound_journal.status = 'verified'` a relé sepnuté; pak vypnout (`false`).
7. [ ] Zkontrolovat `power_w` v telemetrii při běhu ≈ `rated_power_w` (případně upravit).
8. [ ] Nastavit dočasné spínání (cron / ručně) do doby solver integrace (§5).
## Vizualizace (2026-06-12, dev)
- `vw_latest_pool_pump` (LATERAL poslední vzorek + data_age) a
`vw_pool_pump_day_energy` (denní kWh z delty čítače + minuty běhu, 8 dní) —
PostgREST grant `ems_anon` (R__097).
- Dashboard: `PoolCard` (frontend/src/components/PoolCard.tsx) pod StatePanel —
stav (běží/stojí/stale), aktuální W, dnešní kWh a hodiny běhu, mini sloupce
7 dní. Poll 60 s.

View File

@@ -0,0 +1,45 @@
# Discord EV interakce — návrh (fáze A nasazena, fáze B čeká na bot token)
## Fáze A — notifikace po příjezdu (HOTOVO, `dev`)
Píchneš auto → detekce (~60 s) → Tesla SoC → replan → **Discord zpráva**
(webhook `discord_webhook_daily_url`):
```
🔌 Tesla Model Y připojeno
Baterie auta: 55 % → cíl 100 % (~34 kWh)
Deadline: po 15.06. 06:30
Plán nabíjení: 11:3013:45; 02:1504:30 — 34.2 kWh, ø 1.85 Kč/kWh
```
Implementace: `_notify_ev_arrival_plan` v `telemetry_collector.py` (sloty
`ev*_setpoint_w > 0` z aktivního plánu shlukované do oken).
## Fáze B — zpětná vazba tlačítkem („odjíždím za 2 h")
**Architektura: Discord BOT přes gateway** — spojení jde Z backendu VEN
(websocket), žádný veřejný endpoint do EMS (na rozdíl od interactions
webhooku). Knihovna `discord.py`, token v `/opt/ems-deploy/.env`.
Zpráva z fáze A dostane tlačítka:
`[Odjezd za 2 h] [za 4 h] [Ráno (typicky)] [Do plna hned] [Nenabíjet]`
Callback tlačítka:
1. `fn_ev_session_apply_patch(site, session, {"target_deadline": now+2h, …})`
(„Do plna hned" navíc `target_soc_pct=100`; „Nenabíjet" `target_soc_pct=soc`),
2. okamžitý `run_rolling_replan` + `export_setpoints` (vzor ev_arrival),
3. bot **edituje původní zprávu** novým plánem (žádný spam).
Bezpečnost: bot reaguje jen na whitelisted user ID (majitel), akce omezené
na patch session + replan (žádné režimy/registry). Tlačítka expirují
s koncem session.
**Co je potřeba od uživatele:** vytvořit Discord aplikaci + bota
(discord.com/developers → New Application → Bot → token), pozvat na server
(scope `bot`, oprávnění Send Messages + Read History), token jako
`DISCORD_BOT_TOKEN` do `.env`. Pak implementuju `services/discord_bot.py`
(lifespan task vedle telemetry smyčky).
## Výhled (fáze C)
Stejný bot = kanál pro ranní triáž s dotazy („proč jsi v 19:00 nabíjel?" →
delta-triage skill) a rychlé akce (bazén, režimy) — viz noční roadmapa.

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { getJson } from '../api/postgrest'
type LatestRow = {
site_id: number
pump_id: number
pump_code: string
rated_power_w: number
schedulable: boolean
measured_at: string | null
is_on: boolean | null
power_w: number | null
energy_wh_total: number | null
}
type DayRow = { day: string; kwh: number; on_minutes: number }
const POLL_MS = 60_000
/** Karta bazénového čerpadla (Shelly): stav, příkon, dnešní kWh + týdenní mini přehled. */
export function PoolCard({ siteId }: { siteId: number }) {
const [latest, setLatest] = useState<LatestRow | null>(null)
const [days, setDays] = useState<DayRow[]>([])
useEffect(() => {
let alive = true
const load = async () => {
try {
const [l, d] = await Promise.all([
getJson<LatestRow[]>('/vw_latest_pool_pump', { site_id: `eq.${siteId}` }),
getJson<DayRow[]>('/vw_pool_pump_day_energy', {
site_id: `eq.${siteId}`,
order: 'day.desc',
limit: '7',
}),
])
if (!alive) return
setLatest(l[0] ?? null)
setDays(d)
} catch {
/* karta je doplňková — chybu nechováme */
}
}
void load()
const t = setInterval(load, POLL_MS)
return () => {
alive = false
clearInterval(t)
}
}, [siteId])
if (!latest) return null
const stale =
latest.measured_at != null &&
Date.now() - new Date(latest.measured_at).getTime() > 5 * 60_000
const running = latest.is_on === true
const today = days[0]
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">🏊</span>
<span className="text-sm font-medium text-slate-200">Bazénové čerpadlo</span>
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${
stale ? 'bg-slate-600' : running ? 'bg-emerald-400' : 'bg-slate-500'
}`}
title={stale ? 'telemetrie >5 min stará' : running ? 'běží' : 'stojí'}
/>
</div>
<span className="text-xs text-slate-400">
{latest.schedulable ? 'řízeno plánem' : 'jen měření'}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div>
<div className="text-lg font-semibold text-slate-100">
{stale ? '—' : running ? `${latest.power_w ?? 0} W` : '0 W'}
</div>
<div className="text-[11px] text-slate-400">příkon</div>
</div>
<div>
<div className="text-lg font-semibold text-slate-100">
{today ? `${today.kwh.toFixed(2)}` : '—'}
</div>
<div className="text-[11px] text-slate-400">kWh dnes</div>
</div>
<div>
<div className="text-lg font-semibold text-slate-100">
{today ? `${Math.round(today.on_minutes / 60 * 10) / 10} h` : '—'}
</div>
<div className="text-[11px] text-slate-400">běh dnes</div>
</div>
</div>
{days.length > 1 && (
<div className="mt-3 flex items-end gap-1" title="kWh po dnech (7 dní)">
{[...days].reverse().map((d) => {
const max = Math.max(...days.map((x) => x.kwh), 0.01)
return (
<div key={d.day} className="flex-1">
<div
className="rounded-sm bg-sky-700/70"
style={{ height: `${Math.max(3, (d.kwh / max) * 36)}px` }}
title={`${d.day}: ${d.kwh.toFixed(2)} kWh`}
/>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -18,6 +18,7 @@ import { ControlPanel } from '../components/ControlPanel'
import { ModeBar } from '../components/ModeBar'
import { NotificationBar } from '../components/NotificationBar'
import { StatePanel } from '../components/StatePanel'
import { PoolCard } from '../components/PoolCard'
import { useDashboardData } from '../hooks/useDashboardData'
import { useFullStatus } from '../hooks/useFullStatus'
import { useNotifications } from '../hooks/useNotifications'
@@ -351,6 +352,11 @@ export function Dashboard() {
{data.slots.length > 0 && data.slotsReady ? (
<section>
<StatePanel slots={data.slots} nowIndex={data.nowIndex} />
{siteId != null && (
<div className="mt-4">
<PoolCard siteId={siteId} />
</div>
)}
</section>
) : null}