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:
1
.claude/worktrees/agent-ad77a56dba4e495d7
Submodule
1
.claude/worktrees/agent-ad77a56dba4e495d7
Submodule
Submodule .claude/worktrees/agent-ad77a56dba4e495d7 added at cf663ae417
@@ -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:
|
||||
|
||||
47
db/views/R__097_vw_pool_pump.sql
Normal file
47
db/views/R__097_vw_pool_pump.sql
Normal 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 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;
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
45
docs/discord-ev-interaction.md
Normal file
45
docs/discord-ev-interaction.md
Normal 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: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.
|
||||
117
frontend/src/components/PoolCard.tsx
Normal file
117
frontend/src/components/PoolCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user