feat(ev): proaktivní notifikace 'píchni auto' (default-off)

job ev_presence_notify + fn_ev_presence_nudge_due (SQL-first rozhodnutí+dedup);
asset_vehicle.presence_nudge_enabled default false=inertní (V110). Worktree agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-14 22:55:17 +02:00
parent fc6d9833a7
commit c03f9dd9d6
4 changed files with 374 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho".
Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena,
SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá
funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě
neposlaný) řádek pošle jeden Discord nudge.
Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent
(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy
jeden nudge na "epizodu" auta doma+odpojeno. Opakované 2030min ticky proto
nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní).
DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle
asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně.
"""
from __future__ import annotations
import logging
from typing import Any
import asyncpg
from app.db_json import fetch_json
from services.notification_service import send_discord
logger = logging.getLogger(__name__)
def _fmt_price(value: Any) -> str:
try:
return f"{float(value):.2f}"
except (TypeError, ValueError):
return "?"
def _build_message(row: asyncpg.Record) -> str:
name = row["vehicle_name"] or "EV"
reason = str(row["trigger_reason"] or "")
sell = row["effective_sell_price_czk_kwh"]
buy = row["effective_buy_price_czk_kwh"]
soc = row["battery_level_pct"]
tgt = row["target_soc_pct"]
if reason == "NEG_OR_ZERO_SELL":
why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta"
else:
why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh"
soc_line = ""
if soc is not None:
soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + (
f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else ""
)
return (
f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n"
f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)."
)
async def run_ev_presence_nudge_for_site(
site_id: int, conn: asyncpg.Connection
) -> int:
"""Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo.
Vrátí počet odeslaných notifikací.
"""
try:
rows = await conn.fetch(
"select * from ems.fn_ev_presence_nudge_due($1::int)",
site_id,
)
except Exception:
logger.exception(
"ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id
)
return 0
sent = 0
for row in rows:
try:
await send_discord(conn, site_id, _build_message(row), level="info")
sent += 1
logger.info(
"ev_presence_nudge sent site=%s vehicle=%s reason=%s",
site_id,
row["vehicle_id"],
row["trigger_reason"],
)
except Exception:
logger.exception(
"ev_presence_nudge: Discord send failed site=%s vehicle=%s",
site_id,
row["vehicle_id"],
)
return sent
async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None:
"""Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge."""
async with pool.acquire() as conn:
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
sites = raw if isinstance(raw, list) else []
for site in sites:
if not isinstance(site, dict) or site.get("id") is None:
continue
site_id = int(site["id"])
try:
await run_ev_presence_nudge_for_site(site_id, conn)
except Exception:
logger.exception("ev_presence_nudge site=%s failed", site_id)