Discord EV: dva výběry (odjezd × cíl) místo řady tlačítek
Arrival zpráva má dva persistent Selecty (custom_id ev:<site>:<charger>:dep a :tgt, obsluha on_interaction + regex → přežijí restart): „Kdy odjíždíš?" za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 | zítra poledne 12:00 | pondělí ráno 7:00; „Kolik potřebuješ?" 30/50/70/100 % | Nenabíjet. Každý výběr okamžitě PATCHne session přes fn_ev_session_apply_patch jen ve své dimenzi (absolutní deadline Europe/Prague, nejbližší budoucí výskyt; pevná volba smí přes 48 h), druhý rozměr zůstává z fn_ev_session_defaults. Pak replan + export a edit zprávy přepočteným plánem (build_ev_plan_summary) + potvrzením. Whitelist DISCORD_ALLOWED_USER_IDS i bot-first/webhook fallback beze změny; legacy tlačítka h2/h4/morning/full/stop starších zpráv dál obsloužená. Testy: mapování výběr→patch, absolutní deadline z voleb (půlnoc, pondělí z pátku >48 h, pondělí ráno v pondělí), parse, legacy akce — bez DB/sítě. Docs: discord-ev-interaction.md (nové UI, no-click = pohotovostní režim 30 % + oportunismus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
"""Discord bot (gateway) — interaktivní EV zprávy s tlačítky.
|
||||
"""Discord bot (gateway) — interaktivní EV zprávy se dvěma výběry.
|
||||
|
||||
Architektura: websocket spojení jde Z BACKENDU VEN (žádný veřejný endpoint,
|
||||
EMS zůstává na VPN). Bot reaguje výhradně na whitelisted user ID a jediné,
|
||||
co umí, je patch otevřené EV session + okamžitý replan — žádné režimy,
|
||||
žádné registry. Bez DISCORD_BOT_TOKEN modul tiše spí (fáze A webhook).
|
||||
|
||||
Tlačítka (custom_id "ev:<site_id>:<charger_code>:<akce>" — DynamicItem
|
||||
template, takže fungují i po restartu backendu):
|
||||
h2 / h4 — odjezd za 2 / 4 hodiny (deadline = teď + N h)
|
||||
morning — ráno (další výskyt default_deadline_hour vozidla, Prague)
|
||||
full — do plna hned (target 100 %, deadline za hodinu → max tempo)
|
||||
stop — nenabíjet (target = SoC při připojení)
|
||||
UI: dva persistent Selecty (custom_id template, takže fungují i po
|
||||
restartu backendu — obsluha jde přes on_interaction + regex, ne přes
|
||||
zaregistrovanou View instanci):
|
||||
ev:<site_id>:<charger_code>:dep — „Kdy odjíždíš?"
|
||||
za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 |
|
||||
zítra poledne 12:00 | pondělí ráno 7:00
|
||||
ev:<site_id>:<charger_code>:tgt — „Kolik potřebuješ?"
|
||||
30 % | 50 % | 70 % | 100 % | Nenabíjet (target = SoC při připojení)
|
||||
|
||||
Každý výběr okamžitě PATCHne session (fn_ev_session_apply_patch) jen v dané
|
||||
dimenzi — druhý rozměr zůstává (default z ems.fn_ev_session_defaults nebo
|
||||
předchozí výběr). Legacy tlačítka h2/h4/morning/full/stop ze starších zpráv
|
||||
zůstávají obsloužená (action_to_patch).
|
||||
|
||||
Postup zřízení bota: docs/discord-ev-interaction.md.
|
||||
"""
|
||||
@@ -34,16 +41,40 @@ _PRAGUE = ZoneInfo("Europe/Prague")
|
||||
_POOL: asyncpg.Pool | None = None
|
||||
_CLIENT: Any = None # discord.Client za lazy importem
|
||||
|
||||
CUSTOM_ID_RE = re.compile(r"^ev:(?P<site>\d+):(?P<charger>[a-z0-9\-]+):(?P<action>h2|h4|morning|full|stop)$")
|
||||
CUSTOM_ID_RE = re.compile(
|
||||
r"^ev:(?P<site>\d+):(?P<charger>[a-z0-9\-]+)"
|
||||
r":(?P<action>dep|tgt|h2|h4|morning|full|stop)$"
|
||||
)
|
||||
|
||||
ACTION_LABELS = [
|
||||
("h2", "🕑 za 2 h"),
|
||||
("h4", "🕓 za 4 h"),
|
||||
("morning", "🌅 ráno"),
|
||||
("full", "⚡ do plna"),
|
||||
("stop", "✋ nenabíjet"),
|
||||
#: Výběr 1 — „Kdy odjíždíš?" (value, label); absolutní čas viz
|
||||
#: departure_choice_to_deadline (Europe/Prague).
|
||||
DEP_CHOICES: list[tuple[str, str]] = [
|
||||
("h2", "za 2 h"),
|
||||
("h4", "za 4 h"),
|
||||
("today18", "dnes večer 18:00"),
|
||||
("tomorrow7", "zítra ráno 7:00"),
|
||||
("tomorrow12", "zítra poledne 12:00"),
|
||||
("monday7", "pondělí ráno 7:00"),
|
||||
]
|
||||
|
||||
#: Výběr 2 — „Kolik potřebuješ?" (value, label); "stop" = nenabíjet.
|
||||
TGT_CHOICES: list[tuple[str, str]] = [
|
||||
("30", "30 %"),
|
||||
("50", "50 %"),
|
||||
("70", "70 %"),
|
||||
("100", "100 %"),
|
||||
("stop", "Nenabíjet"),
|
||||
]
|
||||
|
||||
#: Legacy tlačítka (starší zprávy poslané před přechodem na selecty).
|
||||
LEGACY_ACTION_LABELS = {
|
||||
"h2": "za 2 h",
|
||||
"h4": "za 4 h",
|
||||
"morning": "ráno",
|
||||
"full": "do plna",
|
||||
"stop": "nenabíjet",
|
||||
}
|
||||
|
||||
|
||||
def parse_custom_id(cid: str) -> tuple[int, str, str] | None:
|
||||
m = CUSTOM_ID_RE.match(cid or "")
|
||||
@@ -52,6 +83,73 @@ def parse_custom_id(cid: str) -> tuple[int, str, str] | None:
|
||||
return int(m.group("site")), m.group("charger"), m.group("action")
|
||||
|
||||
|
||||
def departure_choice_to_deadline(choice: str, *, now: datetime) -> datetime:
|
||||
"""Absolutní deadline (Europe/Prague) pro volbu z výběru „Kdy odjíždíš?".
|
||||
|
||||
Čisté a testovatelné. Volby s pevným časem znamenají NEJBLIŽŠÍ budoucí
|
||||
výskyt (dnes 18:00 po 18. hodině → zítra 18:00; pondělí 7:00 z pátku je
|
||||
explicitní volba — smí být i za >48 h).
|
||||
"""
|
||||
local = now.astimezone(_PRAGUE)
|
||||
if choice == "h2":
|
||||
return local + timedelta(hours=2)
|
||||
if choice == "h4":
|
||||
return local + timedelta(hours=4)
|
||||
if choice == "today18":
|
||||
candidate = local.replace(hour=18, minute=0, second=0, microsecond=0)
|
||||
if candidate <= local:
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
if choice == "tomorrow7":
|
||||
return (local + timedelta(days=1)).replace(
|
||||
hour=7, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if choice == "tomorrow12":
|
||||
return (local + timedelta(days=1)).replace(
|
||||
hour=12, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if choice == "monday7":
|
||||
candidate = local.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
candidate += timedelta(days=(0 - local.weekday()) % 7) # 0 = pondělí
|
||||
if candidate <= local:
|
||||
candidate += timedelta(days=7)
|
||||
return candidate
|
||||
raise ValueError(f"unknown departure choice {choice}")
|
||||
|
||||
|
||||
def select_to_patch(
|
||||
kind: str,
|
||||
value: str,
|
||||
*,
|
||||
now: datetime,
|
||||
soc_at_connect: float | None,
|
||||
) -> dict:
|
||||
"""Patch pro fn_ev_session_apply_patch z hodnoty selectu (čisté, testovatelné).
|
||||
|
||||
Patchuje VŽDY jen jednu dimenzi — druhá zůstává beze změny
|
||||
(default z fn_ev_session_defaults, případně dřívější výběr).
|
||||
"""
|
||||
if kind == "dep":
|
||||
deadline = departure_choice_to_deadline(value, now=now)
|
||||
return {"target_deadline": deadline.isoformat()}
|
||||
if kind == "tgt":
|
||||
if value == "stop":
|
||||
return {"target_soc_pct": float(soc_at_connect or 0)}
|
||||
return {"target_soc_pct": float(value)}
|
||||
raise ValueError(f"unknown select kind {kind}")
|
||||
|
||||
|
||||
def choice_label(kind: str, value: str) -> str:
|
||||
"""Lidský popisek volby pro potvrzení ve zprávě."""
|
||||
if kind == "dep":
|
||||
return "odjezd " + dict(DEP_CHOICES).get(value, value)
|
||||
if kind == "tgt":
|
||||
if value == "stop":
|
||||
return "nenabíjet"
|
||||
return "cíl " + dict(TGT_CHOICES).get(value, value)
|
||||
return LEGACY_ACTION_LABELS.get(value, value)
|
||||
|
||||
|
||||
def action_to_patch(
|
||||
action: str,
|
||||
*,
|
||||
@@ -59,7 +157,7 @@ def action_to_patch(
|
||||
soc_at_connect: float | None,
|
||||
default_deadline_hour: int | None,
|
||||
) -> dict:
|
||||
"""Patch pro fn_ev_session_apply_patch dle tlačítka (čisté, testovatelné)."""
|
||||
"""Patch pro legacy tlačítka h2/h4/morning/full/stop (starší zprávy)."""
|
||||
if action == "h2":
|
||||
return {"target_deadline": (now + timedelta(hours=2)).isoformat()}
|
||||
if action == "h4":
|
||||
@@ -96,34 +194,61 @@ def _allowed_user_ids() -> set[int]:
|
||||
return out
|
||||
|
||||
|
||||
def _build_view(site_id: int, charger_code: str):
|
||||
"""View se dvěma selecty (persistent custom_id, timeout=None)."""
|
||||
import discord
|
||||
|
||||
view = discord.ui.View(timeout=None)
|
||||
view.add_item(
|
||||
discord.ui.Select(
|
||||
custom_id=f"ev:{site_id}:{charger_code}:dep",
|
||||
placeholder="🕑 Kdy odjíždíš?",
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
options=[
|
||||
discord.SelectOption(label=label, value=value)
|
||||
for value, label in DEP_CHOICES
|
||||
],
|
||||
)
|
||||
)
|
||||
view.add_item(
|
||||
discord.ui.Select(
|
||||
custom_id=f"ev:{site_id}:{charger_code}:tgt",
|
||||
placeholder="🔋 Kolik potřebuješ?",
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
options=[
|
||||
discord.SelectOption(label=label, value=value)
|
||||
for value, label in TGT_CHOICES
|
||||
],
|
||||
)
|
||||
)
|
||||
return view
|
||||
|
||||
|
||||
async def post_ev_arrival(
|
||||
site_id: int, charger_code: str, session_id: int, text: str
|
||||
) -> bool:
|
||||
"""Pošle zprávu s tlačítky přes bota. False = bot neběží/není kanál (fallback webhook)."""
|
||||
"""Pošle zprávu s výběry přes bota. False = bot neběží/není kanál (fallback webhook)."""
|
||||
if _CLIENT is None or not _CLIENT.is_ready():
|
||||
return False
|
||||
import discord
|
||||
|
||||
channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0)
|
||||
if not channel_id:
|
||||
return False
|
||||
channel = _CLIENT.get_channel(channel_id)
|
||||
if channel is None:
|
||||
return False
|
||||
view = discord.ui.View(timeout=None)
|
||||
for action, label in ACTION_LABELS:
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=label,
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f"ev:{site_id}:{charger_code}:{action}",
|
||||
)
|
||||
)
|
||||
await channel.send(content=text, view=view)
|
||||
await channel.send(content=text, view=_build_view(site_id, charger_code))
|
||||
return True
|
||||
|
||||
|
||||
async def _handle_action(interaction: Any, site_id: int, charger_code: str, action: str) -> None:
|
||||
async def _handle_action(
|
||||
interaction: Any,
|
||||
site_id: int,
|
||||
charger_code: str,
|
||||
action: str,
|
||||
value: str | None,
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
from services.control_exporter import export_setpoints
|
||||
@@ -138,12 +263,25 @@ async def _handle_action(interaction: Any, site_id: int, charger_code: str, acti
|
||||
"Session už není otevřená (auto odpojeno?).", ephemeral=True
|
||||
)
|
||||
return
|
||||
patch = action_to_patch(
|
||||
action,
|
||||
now=datetime.now(timezone.utc),
|
||||
soc_at_connect=sess["soc_at_connect_pct"],
|
||||
default_deadline_hour=sess["default_deadline_hour"],
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
if action in ("dep", "tgt"):
|
||||
if not value:
|
||||
return
|
||||
patch = select_to_patch(
|
||||
action,
|
||||
value,
|
||||
now=now,
|
||||
soc_at_connect=sess["soc_at_connect_pct"],
|
||||
)
|
||||
label = choice_label(action, value)
|
||||
else: # legacy tlačítka starších zpráv
|
||||
patch = action_to_patch(
|
||||
action,
|
||||
now=now,
|
||||
soc_at_connect=sess["soc_at_connect_pct"],
|
||||
default_deadline_hour=sess["default_deadline_hour"],
|
||||
)
|
||||
label = LEGACY_ACTION_LABELS.get(action, action)
|
||||
await conn.fetchval(
|
||||
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
|
||||
site_id,
|
||||
@@ -156,31 +294,16 @@ async def _handle_action(interaction: Any, site_id: int, charger_code: str, acti
|
||||
await export_setpoints(site_id, conn)
|
||||
new_text = await build_ev_plan_summary(site_id, charger_code, conn)
|
||||
|
||||
label = dict(ACTION_LABELS).get(action, action)
|
||||
if new_text:
|
||||
await interaction.message.edit(
|
||||
content=new_text + f"\n_(upraveno: {label})_", view=interaction.message.components and _rebuild_view(site_id, charger_code) or None
|
||||
content=new_text + f"\n_(nastaveno: {label})_",
|
||||
view=_build_view(site_id, charger_code),
|
||||
)
|
||||
await interaction.followup.send(f"Přeplánováno ✓ ({label})", ephemeral=True)
|
||||
|
||||
|
||||
def _rebuild_view(site_id: int, charger_code: str):
|
||||
import discord
|
||||
|
||||
view = discord.ui.View(timeout=None)
|
||||
for action, label in ACTION_LABELS:
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=label,
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f"ev:{site_id}:{charger_code}:{action}",
|
||||
)
|
||||
)
|
||||
return view
|
||||
|
||||
|
||||
async def run_discord_bot() -> None:
|
||||
"""Lifespan task: připojí gateway a obsluhuje tlačítka. Bez tokenu hned končí."""
|
||||
"""Lifespan task: připojí gateway a obsluhuje selecty/tlačítka. Bez tokenu hned končí."""
|
||||
token = (getattr(get_settings(), "discord_bot_token", "") or "").strip()
|
||||
if not token:
|
||||
logger.info("Discord bot: token není nastaven — fáze B vypnuta")
|
||||
@@ -205,22 +328,25 @@ async def run_discord_bot() -> None:
|
||||
async def on_interaction(interaction: discord.Interaction) -> None:
|
||||
if interaction.type != discord.InteractionType.component:
|
||||
return
|
||||
cid = (interaction.data or {}).get("custom_id", "")
|
||||
data = interaction.data or {}
|
||||
cid = data.get("custom_id", "")
|
||||
parsed = parse_custom_id(str(cid))
|
||||
if parsed is None:
|
||||
return
|
||||
allowed = _allowed_user_ids()
|
||||
if allowed and interaction.user.id not in allowed:
|
||||
await interaction.response.send_message(
|
||||
"Tohle tlačítko není pro tebe. 🙂", ephemeral=True
|
||||
"Tenhle výběr není pro tebe. 🙂", ephemeral=True
|
||||
)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
site_id, charger_code, action = parsed
|
||||
values = data.get("values") or []
|
||||
value = str(values[0]) if values else None
|
||||
try:
|
||||
await _handle_action(interaction, site_id, charger_code, action)
|
||||
await _handle_action(interaction, site_id, charger_code, action, value)
|
||||
except Exception:
|
||||
logger.exception("Discord akce selhala (%s)", cid)
|
||||
logger.exception("Discord akce selhala (%s, value=%s)", cid, value)
|
||||
try:
|
||||
await interaction.followup.send(
|
||||
"Akce selhala — mrkni do logů.", ephemeral=True
|
||||
|
||||
@@ -1,27 +1,134 @@
|
||||
"""Discord bot — čisté helpery (custom_id, patch akcí), bez sítě/discord lib."""
|
||||
"""Discord bot — čisté helpery (custom_id, výběry → patch, deadline z voleb),
|
||||
bez sítě/discord lib."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.discord_bot import action_to_patch, parse_custom_id
|
||||
from services.discord_bot import (
|
||||
action_to_patch,
|
||||
choice_label,
|
||||
departure_choice_to_deadline,
|
||||
parse_custom_id,
|
||||
select_to_patch,
|
||||
)
|
||||
|
||||
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc) # 12:00 Prague
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
# 2026-06-12 je pátek; 10:00 UTC = 12:00 Europe/Prague (CEST)
|
||||
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _prague(dt: datetime) -> str:
|
||||
return dt.astimezone(_PRAGUE).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
class ParseCustomIdTests(unittest.TestCase):
|
||||
def test_valid(self) -> None:
|
||||
def test_valid_selects(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_custom_id("ev:2:ev-charger-1:dep"), (2, "ev-charger-1", "dep")
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_custom_id("ev:2:ev-charger-1:tgt"), (2, "ev-charger-1", "tgt")
|
||||
)
|
||||
|
||||
def test_valid_legacy_buttons(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_custom_id("ev:2:ev-charger-1:h2"), (2, "ev-charger-1", "h2")
|
||||
)
|
||||
|
||||
def test_invalid(self) -> None:
|
||||
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2"):
|
||||
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2", "ev:1:c:dep:x"):
|
||||
self.assertIsNone(parse_custom_id(bad))
|
||||
|
||||
|
||||
class ActionPatchTests(unittest.TestCase):
|
||||
class DepartureChoiceTests(unittest.TestCase):
|
||||
"""Absolutní deadline z výběru „Kdy odjíždíš?" (Europe/Prague)."""
|
||||
|
||||
def test_h2(self) -> None:
|
||||
dl = departure_choice_to_deadline("h2", now=_NOW)
|
||||
self.assertEqual(_prague(dl), "2026-06-12 14:00")
|
||||
|
||||
def test_h4(self) -> None:
|
||||
dl = departure_choice_to_deadline("h4", now=_NOW)
|
||||
self.assertEqual(_prague(dl), "2026-06-12 16:00")
|
||||
|
||||
def test_today18_before_18(self) -> None:
|
||||
dl = departure_choice_to_deadline("today18", now=_NOW) # 12:00 Prague
|
||||
self.assertEqual(_prague(dl), "2026-06-12 18:00")
|
||||
|
||||
def test_today18_after_18_rolls_to_next_day(self) -> None:
|
||||
late = datetime(2026, 6, 12, 17, 30, tzinfo=timezone.utc) # 19:30 Prague
|
||||
dl = departure_choice_to_deadline("today18", now=late)
|
||||
self.assertEqual(_prague(dl), "2026-06-13 18:00")
|
||||
|
||||
def test_tomorrow7_crosses_midnight(self) -> None:
|
||||
# 23:30 Prague v pátek → zítra (sobota) 07:00, tj. +7,5 h
|
||||
late = datetime(2026, 6, 12, 21, 30, tzinfo=timezone.utc)
|
||||
dl = departure_choice_to_deadline("tomorrow7", now=late)
|
||||
self.assertEqual(_prague(dl), "2026-06-13 07:00")
|
||||
|
||||
def test_tomorrow12(self) -> None:
|
||||
dl = departure_choice_to_deadline("tomorrow12", now=_NOW)
|
||||
self.assertEqual(_prague(dl), "2026-06-13 12:00")
|
||||
|
||||
def test_monday7_from_friday_allows_over_48h(self) -> None:
|
||||
# explicitní volba smí přes 48h limit fn_ev_session_defaults
|
||||
dl = departure_choice_to_deadline("monday7", now=_NOW) # pátek 12:00
|
||||
self.assertEqual(_prague(dl), "2026-06-15 07:00")
|
||||
self.assertGreater((dl - _NOW).total_seconds(), 48 * 3600)
|
||||
|
||||
def test_monday7_on_monday_before_7_is_today(self) -> None:
|
||||
mon_early = datetime(2026, 6, 15, 3, 0, tzinfo=timezone.utc) # po 05:00
|
||||
dl = departure_choice_to_deadline("monday7", now=mon_early)
|
||||
self.assertEqual(_prague(dl), "2026-06-15 07:00")
|
||||
|
||||
def test_monday7_on_monday_after_7_is_next_week(self) -> None:
|
||||
mon_late = datetime(2026, 6, 15, 8, 0, tzinfo=timezone.utc) # po 10:00
|
||||
dl = departure_choice_to_deadline("monday7", now=mon_late)
|
||||
self.assertEqual(_prague(dl), "2026-06-22 07:00")
|
||||
|
||||
def test_unknown_choice_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
departure_choice_to_deadline("never", now=_NOW)
|
||||
|
||||
|
||||
class SelectPatchTests(unittest.TestCase):
|
||||
"""Mapování výběrů na patch payload pro fn_ev_session_apply_patch."""
|
||||
|
||||
def test_dep_patches_only_deadline(self) -> None:
|
||||
p = select_to_patch("dep", "today18", now=_NOW, soc_at_connect=55.0)
|
||||
self.assertEqual(set(p), {"target_deadline"})
|
||||
self.assertIn("2026-06-12T18:00", p["target_deadline"])
|
||||
self.assertIn("+02:00", p["target_deadline"]) # Europe/Prague (CEST)
|
||||
|
||||
def test_tgt_patches_only_target(self) -> None:
|
||||
for value, expected in (("30", 30.0), ("50", 50.0), ("70", 70.0), ("100", 100.0)):
|
||||
p = select_to_patch("tgt", value, now=_NOW, soc_at_connect=55.0)
|
||||
self.assertEqual(p, {"target_soc_pct": expected})
|
||||
|
||||
def test_tgt_stop_targets_connect_soc(self) -> None:
|
||||
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=42.5)
|
||||
self.assertEqual(p, {"target_soc_pct": 42.5})
|
||||
|
||||
def test_tgt_stop_without_soc(self) -> None:
|
||||
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=None)
|
||||
self.assertEqual(p, {"target_soc_pct": 0.0})
|
||||
|
||||
def test_unknown_kind_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
select_to_patch("foo", "30", now=_NOW, soc_at_connect=None)
|
||||
|
||||
def test_labels(self) -> None:
|
||||
self.assertEqual(choice_label("dep", "monday7"), "odjezd pondělí ráno 7:00")
|
||||
self.assertEqual(choice_label("tgt", "70"), "cíl 70 %")
|
||||
self.assertEqual(choice_label("tgt", "stop"), "nenabíjet")
|
||||
|
||||
|
||||
class LegacyActionPatchTests(unittest.TestCase):
|
||||
"""Legacy tlačítka starších zpráv (h2/h4/morning/full/stop)."""
|
||||
|
||||
def _patch(self, action: str, **kw):
|
||||
return action_to_patch(
|
||||
action,
|
||||
|
||||
@@ -15,34 +15,59 @@ 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 — ✅ IMPLEMENTOVÁNO (2026-06-12)
|
||||
## Fáze B — zpětná vazba dvěma výběry — ✅ IMPLEMENTOVÁNO (2026-06-12)
|
||||
|
||||
**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]`
|
||||
Zpráva z fáze A dostane **dva selecty** (místo dřívější řady tlačítek):
|
||||
|
||||
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).
|
||||
```
|
||||
🔌 Tesla Model Y připojeno
|
||||
Baterie auta: 55 % → cíl 90 % (~26 kWh)
|
||||
Deadline: po 15.06. 07:00
|
||||
Plán nabíjení: 11:30–13:45; 02:15–04:30 — 26.2 kWh, ø 1.85 Kč/kWh
|
||||
[ 🕑 Kdy odjíždíš? ▾ ] za 2 h | za 4 h | dnes večer 18:00 |
|
||||
zítra ráno 7:00 | zítra poledne 12:00 |
|
||||
pondělí ráno 7:00
|
||||
[ 🔋 Kolik potřebuješ? ▾ ] 30 % | 50 % | 70 % | 100 % | Nenabíjet
|
||||
```
|
||||
|
||||
**Sémantika:**
|
||||
- Každý výběr **okamžitě** PATCHne otevřenou session přes
|
||||
`fn_ev_session_apply_patch` — ale jen ve SVÉ dimenzi: výběr 1 nastaví
|
||||
`target_deadline` (absolutní čas Europe/Prague; pevné volby = nejbližší
|
||||
budoucí výskyt, „pondělí ráno" z pátku je validní i přes 48 h),
|
||||
výběr 2 nastaví `target_soc_pct`. Druhý rozměr zůstává beze změny
|
||||
(default z `fn_ev_session_defaults`, případně dřívější výběr).
|
||||
- „Nenabíjet" = stop akce (target = SoC při připojení → solver nic neplánuje).
|
||||
- **No-click = pohotovostní režim:** bez kliknutí jede session na defaultech
|
||||
z `ems.fn_ev_session_defaults` (týdenní požadavek `ev_weekly_requirement`
|
||||
→ forecast z rytmu → defaulty vozidla; viz `docs/04-modules/ev-charging.md`).
|
||||
S `min_target_soc_pct` 30 % to znamená: drž aspoň ~30 % pro pohotovost
|
||||
a zbytek doplňuj oportunisticky v levných/záporných slotech
|
||||
(`opportunistic_value_czk_kwh` — měkký cíl).
|
||||
|
||||
Po každém výběru: patch session → okamžitý `run_rolling_replan` +
|
||||
`export_setpoints` (vzor ev_arrival) → bot **edituje původní zprávu**
|
||||
přepočteným plánem (`build_ev_plan_summary`) + potvrzením
|
||||
`_(nastaveno: odjezd dnes večer 18:00)_` (žá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.
|
||||
na patch session + replan (žádné režimy/registry). Selecty expirují
|
||||
s koncem session (uzavřená session → ephemeral „Session už není otevřená").
|
||||
|
||||
**Implementace:** `services/discord_bot.py` (lifespan task; discord.py
|
||||
gateway), `services/ev_notify.py` (sdílený souhrn plánu; bot-first, webhook
|
||||
fallback). custom_id `ev:<site>:<charger>:<akce>` — tlačítka přežijí restart.
|
||||
Env: `DISCORD_BOT_TOKEN`, `DISCORD_EV_CHANNEL_ID`, `DISCORD_ALLOWED_USER_IDS`
|
||||
(čárkami; prázdné = bot vypnut, jede fáze A webhook). Akce: h2/h4 (deadline
|
||||
teď+N), morning (další default_deadline_hour vozidla, Prague), full (100 % +
|
||||
deadline za 1 h → max tempo), stop (target = SoC při připojení). Po akci:
|
||||
patch session → okamžitý replan + export → bot zedituje zprávu novým plánem.
|
||||
Testy: tests/test_discord_bot.py (parse, patch akcí).
|
||||
fallback). custom_id `ev:<site>:<charger>:dep` / `:tgt` — obsluha přes
|
||||
`on_interaction` + regex (persistent vzor), selecty přežijí restart backendu.
|
||||
Legacy tlačítka `h2|h4|morning|full|stop` ze starších zpráv zůstávají
|
||||
obsloužená (`action_to_patch`). Env: `DISCORD_BOT_TOKEN`,
|
||||
`DISCORD_EV_CHANNEL_ID`, `DISCORD_ALLOWED_USER_IDS` (čárkami; prázdné =
|
||||
bot vypnut, jede fáze A webhook).
|
||||
Testy: tests/test_discord_bot.py (parse, výběr→patch, absolutní deadline
|
||||
z voleb vč. půlnoci a pondělí z pátku, legacy akce).
|
||||
|
||||
## Výhled (fáze C)
|
||||
Stejný bot = kanál pro ranní triáž s dotazy („proč jsi v 19:00 nabíjel?" →
|
||||
|
||||
Reference in New Issue
Block a user