diff --git a/.gitignore b/.gitignore index 841accc..eb2578d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ dist/ frontend/vendor/ frontend/scripts/.native-tmp/ .claude/settings.local.json +.claude/worktrees/ diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 8ef7c92..6e8e836 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -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: diff --git a/db/views/R__097_vw_pool_pump.sql b/db/views/R__097_vw_pool_pump.sql new file mode 100644 index 0000000..359e456 --- /dev/null +++ b/db/views/R__097_vw_pool_pump.sql @@ -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 max−min 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; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 72b4745..6d8c2f6 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -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`. diff --git a/docs/04-modules/pool-shelly.md b/docs/04-modules/pool-shelly.md index 1764b8f..d72bc0f 100644 --- a/docs/04-modules/pool-shelly.md +++ b/docs/04-modules/pool-shelly.md @@ -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(, '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. diff --git a/docs/discord-ev-interaction.md b/docs/discord-ev-interaction.md new file mode 100644 index 0000000..fb7532b --- /dev/null +++ b/docs/discord-ev-interaction.md @@ -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:30–13:45; 02:15–04: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. diff --git a/frontend/src/components/PoolCard.tsx b/frontend/src/components/PoolCard.tsx new file mode 100644 index 0000000..a1bb955 --- /dev/null +++ b/frontend/src/components/PoolCard.tsx @@ -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(null) + const [days, setDays] = useState([]) + + useEffect(() => { + let alive = true + const load = async () => { + try { + const [l, d] = await Promise.all([ + getJson('/vw_latest_pool_pump', { site_id: `eq.${siteId}` }), + getJson('/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 ( +
+
+
+ 🏊 + Bazénové čerpadlo + 5 min stará' : running ? 'běží' : 'stojí'} + /> +
+ + {latest.schedulable ? 'řízeno plánem' : 'jen měření'} + +
+ +
+
+
+ {stale ? '—' : running ? `${latest.power_w ?? 0} W` : '0 W'} +
+
příkon
+
+
+
+ {today ? `${today.kwh.toFixed(2)}` : '—'} +
+
kWh dnes
+
+
+
+ {today ? `${Math.round(today.on_minutes / 60 * 10) / 10} h` : '—'} +
+
běh dnes
+
+
+ + {days.length > 1 && ( +
+ {[...days].reverse().map((d) => { + const max = Math.max(...days.map((x) => x.kwh), 0.01) + return ( +
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d5e08f8..028d748 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 ? (
+ {siteId != null && ( +
+ +
+ )}
) : null}