Merge branch 'worktree-agent-a288972b643cdefcc' into dev
This commit is contained in:
@@ -161,7 +161,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
|
||||
| `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). |
|
||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
db/migration/V098__ev_weekly_requirement.sql
Normal file
36
db/migration/V098__ev_weekly_requirement.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Týdenní požadavky na EV: explicitní cíl (target SoC) + deadline pro den
|
||||
-- v týdnu (0 = pondělí .. 6 = neděle; čas Europe/Prague). Při příjezdu vozidla
|
||||
-- je čte ems.fn_ev_session_defaults (R__099): nejbližší budoucí výskyt do 48 h
|
||||
-- má přednost před forecastem z ev_usage_stats i před defaulty vozidla.
|
||||
-- Ruční přepis (Discord výběry / UI → fn_ev_session_apply_patch) vždy vyhrává.
|
||||
|
||||
create table ems.ev_weekly_requirement (
|
||||
id serial primary key,
|
||||
vehicle_id int not null references ems.asset_vehicle (id),
|
||||
dow int not null check (dow between 0 and 6),
|
||||
target_soc_pct numeric(5, 2) not null check (target_soc_pct between 0 and 100),
|
||||
deadline_hour int not null check (deadline_hour between 0 and 23),
|
||||
enabled boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (vehicle_id, dow)
|
||||
);
|
||||
|
||||
comment on table ems.ev_weekly_requirement is
|
||||
'Týdenní požadavek na nabití vozidla: v den dow (0 = pondělí .. 6 = neděle) v deadline_hour (Europe/Prague) má mít vozidlo target_soc_pct. Vstup ems.fn_ev_session_defaults při zakládání ev_session (přednost před forecastem i defaulty).';
|
||||
comment on column ems.ev_weekly_requirement.vehicle_id is
|
||||
'Vozidlo (ems.asset_vehicle), max. 1 řádek na den v týdnu.';
|
||||
comment on column ems.ev_weekly_requirement.dow is
|
||||
'Den v týdnu DEADLINE: 0 = pondělí .. 6 = neděle (ISO pořadí, POZOR: ne postgres extract(dow) ani ev_usage_stats, kde 0 = neděle).';
|
||||
comment on column ems.ev_weekly_requirement.target_soc_pct is
|
||||
'Cílový SoC vozidla (%) v okamžiku deadline.';
|
||||
comment on column ems.ev_weekly_requirement.deadline_hour is
|
||||
'Hodina deadline v Europe/Prague (7 = 07:00 daného dne dow).';
|
||||
comment on column ems.ev_weekly_requirement.enabled is
|
||||
'false = řádek se při výběru defaultů ignoruje (požadavek dočasně vypnut bez smazání).';
|
||||
|
||||
-- Seed: Tesla Model Y (home-01) — pondělí 07:00 nabitá na 90 % (služebka).
|
||||
insert into ems.ev_weekly_requirement (vehicle_id, dow, target_soc_pct, deadline_hour)
|
||||
select av.id, 0, 90.0, 7
|
||||
from ems.asset_vehicle av
|
||||
join ems.site s on s.id = av.site_id
|
||||
where s.code = 'home-01' and av.code = 'tesla-my';
|
||||
@@ -45,23 +45,14 @@ begin
|
||||
ac.id,
|
||||
av.id,
|
||||
now(),
|
||||
-- forecast z týdenního rytmu (ev_usage_stats), fallback defaulty;
|
||||
-- ruční přepis přes fn_ev_session_apply_patch vždy vyhrává.
|
||||
coalesce(fc.required_soc, av.default_target_soc_pct),
|
||||
coalesce(
|
||||
fc.expected_departure,
|
||||
case
|
||||
when av.default_deadline_hour is not null then
|
||||
(
|
||||
(timezone('Europe/Prague', now()))::date + interval '1 day'
|
||||
+ make_interval(hours => av.default_deadline_hour)
|
||||
)::timestamp at time zone 'Europe/Prague'
|
||||
end
|
||||
)
|
||||
-- kaskáda fn_ev_session_defaults: týdenní požadavek (ev_weekly_requirement)
|
||||
-- → forecast (ev_usage_stats) → defaulty vozidla; ruční přepis přes
|
||||
-- fn_ev_session_apply_patch vždy vyhrává.
|
||||
(d.defaults ->> 'target_soc_pct')::double precision,
|
||||
(d.defaults ->> 'deadline')::timestamptz
|
||||
from ems.asset_ev_charger ac
|
||||
left join lateral (
|
||||
select v.id, v.default_target_soc_pct, v.default_deadline_hour,
|
||||
v.target_soc_forecast_enabled
|
||||
select v.id
|
||||
from ems.asset_vehicle v
|
||||
where v.default_charger_id = ac.id
|
||||
and v.site_id = ac.site_id
|
||||
@@ -69,15 +60,9 @@ begin
|
||||
order by v.id
|
||||
limit 1
|
||||
) av on true
|
||||
left join lateral (
|
||||
select dep.expected_departure,
|
||||
ems.fn_ev_required_soc(av.id, dep.expected_departure) as required_soc
|
||||
from (
|
||||
select ems.fn_ev_next_departure(av.id, now()) as expected_departure
|
||||
) dep
|
||||
where av.target_soc_forecast_enabled
|
||||
and dep.expected_departure is not null
|
||||
) fc on true
|
||||
cross join lateral (
|
||||
select ems.fn_ev_session_defaults(av.id, now()) as defaults
|
||||
) d
|
||||
where ac.id = p_charger_id
|
||||
and ac.site_id = p_site_id
|
||||
on conflict (charger_id) where session_end is null do nothing;
|
||||
@@ -98,5 +83,5 @@ begin
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_transition(int, int, text, text, timestamptz) is
|
||||
'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector).';
|
||||
comment on function ems.fn_ev_session_transition is
|
||||
'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector); defaulty nové session z ems.fn_ev_session_defaults.';
|
||||
|
||||
103
db/routines/R__099_fn_ev_session_defaults.sql
Normal file
103
db/routines/R__099_fn_ev_session_defaults.sql
Normal file
@@ -0,0 +1,103 @@
|
||||
-- Defaulty nové ev_session pro vozidlo: kaskáda
|
||||
-- 1) ems.ev_weekly_requirement — nejbližší budoucí výskyt enabled řádku
|
||||
-- do 48 h od příjezdu (deadline_hour v den dow, Europe/Prague),
|
||||
-- 2) forecast z týdenního rytmu (V089: fn_ev_next_departure +
|
||||
-- fn_ev_required_soc), jen při asset_vehicle.target_soc_forecast_enabled,
|
||||
-- 3) defaulty vozidla (default_target_soc_pct; deadline = příští výskyt
|
||||
-- default_deadline_hour v Europe/Prague).
|
||||
-- Volá fn_ev_session_transition při založení session; ruční přepis přes
|
||||
-- fn_ev_session_apply_patch (Discord / UI) vždy vyhrává.
|
||||
|
||||
create or replace function ems.fn_ev_session_defaults(
|
||||
p_vehicle_id int,
|
||||
p_arrival timestamptz
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
v_vehicle record;
|
||||
v_weekly record;
|
||||
v_forecast_departure timestamptz;
|
||||
v_deadline timestamptz;
|
||||
begin
|
||||
select av.default_target_soc_pct, av.default_deadline_hour,
|
||||
av.target_soc_forecast_enabled
|
||||
into v_vehicle
|
||||
from ems.asset_vehicle av
|
||||
where av.id = p_vehicle_id;
|
||||
|
||||
if not found then
|
||||
return jsonb_build_object(
|
||||
'target_soc_pct', null, 'deadline', null, 'source', 'none'
|
||||
);
|
||||
end if;
|
||||
|
||||
-- 1) týdenní požadavek: nejbližší budoucí výskyt do 48 h (Europe/Prague)
|
||||
select wr.target_soc_pct, occ.deadline
|
||||
into v_weekly
|
||||
from generate_series(0, 2) as offs
|
||||
cross join lateral (
|
||||
select ((p_arrival at time zone 'Europe/Prague')::date + offs) as d
|
||||
) day
|
||||
join ems.ev_weekly_requirement wr
|
||||
on wr.vehicle_id = p_vehicle_id
|
||||
and wr.enabled
|
||||
and wr.dow = extract(isodow from day.d)::int - 1
|
||||
cross join lateral (
|
||||
select (day.d::timestamp + make_interval(hours => wr.deadline_hour))
|
||||
at time zone 'Europe/Prague' as deadline
|
||||
) occ
|
||||
where occ.deadline > p_arrival
|
||||
and occ.deadline <= p_arrival + interval '48 hours'
|
||||
order by occ.deadline
|
||||
limit 1;
|
||||
|
||||
if v_weekly.deadline is not null then
|
||||
return jsonb_build_object(
|
||||
'target_soc_pct', v_weekly.target_soc_pct,
|
||||
'deadline', v_weekly.deadline,
|
||||
'source', 'weekly'
|
||||
);
|
||||
end if;
|
||||
|
||||
-- 2) forecast z týdenního rytmu (chování shodné s dřívějším
|
||||
-- fn_ev_session_transition: deadline = typický odjezd; target P80,
|
||||
-- při málo datech default target)
|
||||
if v_vehicle.target_soc_forecast_enabled then
|
||||
v_forecast_departure := ems.fn_ev_next_departure(p_vehicle_id, p_arrival);
|
||||
if v_forecast_departure is not null then
|
||||
return jsonb_build_object(
|
||||
'target_soc_pct', coalesce(
|
||||
ems.fn_ev_required_soc(p_vehicle_id, v_forecast_departure),
|
||||
v_vehicle.default_target_soc_pct
|
||||
),
|
||||
'deadline', v_forecast_departure,
|
||||
'source', 'forecast'
|
||||
);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- 3) defaulty vozidla: deadline = příští výskyt default_deadline_hour
|
||||
v_deadline := (
|
||||
(p_arrival at time zone 'Europe/Prague')::date::timestamp
|
||||
+ make_interval(hours => v_vehicle.default_deadline_hour)
|
||||
) at time zone 'Europe/Prague';
|
||||
if v_deadline <= p_arrival then
|
||||
v_deadline := (
|
||||
((p_arrival at time zone 'Europe/Prague')::date + 1)::timestamp
|
||||
+ make_interval(hours => v_vehicle.default_deadline_hour)
|
||||
) at time zone 'Europe/Prague';
|
||||
end if;
|
||||
|
||||
return jsonb_build_object(
|
||||
'target_soc_pct', v_vehicle.default_target_soc_pct,
|
||||
'deadline', v_deadline,
|
||||
'source', 'default'
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_defaults is
|
||||
'Target SoC + deadline pro novou ev_session: jsonb {target_soc_pct, deadline, source}. Kaskáda ev_weekly_requirement (výskyt do 48 h, Europe/Prague) → forecast (target_soc_forecast_enabled) → defaulty vozidla (deadline = příští výskyt default_deadline_hour). p_vehicle_id null/neznámé → null hodnoty.';
|
||||
@@ -327,8 +327,9 @@ avg/stddev kWh, km, hodina prvního odjezdu.
|
||||
|
||||
**Použití:** `fn_ev_next_departure` (příští typický odjezd: DOW s ≥4 vzorky
|
||||
a ≥3 km) + `fn_ev_required_soc` (P80 spotřeby dne + 10 p.b., clamp
|
||||
[`min_target_soc_pct`, 100]) → `fn_ev_session_transition` při příjezdu
|
||||
(fallback defaulty; ruční patch `fn_ev_session_apply_patch` vždy vyhrává).
|
||||
[`min_target_soc_pct`, 100]) — od V098 zapojeno jako 2. stupeň kaskády
|
||||
`fn_ev_session_defaults` (viz níže); ruční patch `fn_ev_session_apply_patch`
|
||||
vždy vyhrává.
|
||||
|
||||
**Aktivace per vozidlo** (po ~měsíci dat):
|
||||
`update ems.asset_vehicle set target_soc_forecast_enabled = true where code = 'tesla-my';`
|
||||
@@ -336,6 +337,34 @@ 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`.
|
||||
|
||||
## Týdenní požadavky + fn_ev_session_defaults (2026-06-12)
|
||||
|
||||
Explicitní týdenní rytmus „v pondělí v 7:00 chci 90 %" bez čekání na
|
||||
naučený forecast: tabulka **`ems.ev_weekly_requirement`** (V098) —
|
||||
max 1 řádek na (vozidlo, den): `dow` (**0 = pondělí .. 6 = neděle**, ISO
|
||||
pořadí — POZOR, jiné než postgres `extract(dow)` v `ev_usage_stats`),
|
||||
`target_soc_pct`, `deadline_hour` (Europe/Prague), `enabled`.
|
||||
Seed: tesla-my (home-01) pondělí 07:00 → 90 %.
|
||||
|
||||
Defaulty nové session dává **`ems.fn_ev_session_defaults(vehicle_id,
|
||||
arrival)`** (R__099) → jsonb `{target_soc_pct, deadline, source}`, kaskáda:
|
||||
|
||||
1. **weekly** — nejbližší budoucí výskyt enabled řádku
|
||||
`ev_weekly_requirement` do **48 h** od příjezdu (deadline = den `dow`
|
||||
v `deadline_hour`, Europe/Prague). Páteční příjezd tedy pondělní
|
||||
požadavek NEvyzvedne (>48 h) — nedělní večer už ano; dřívější nabití
|
||||
na pondělí zajistí levné víkendové sloty samy (v2 + oportunismus),
|
||||
explicitně jde vybrat „pondělí ráno 7:00" v Discordu.
|
||||
2. **forecast** — `fn_ev_next_departure` + `fn_ev_required_soc`, jen při
|
||||
`asset_vehicle.target_soc_forecast_enabled` (chování V089 beze změny).
|
||||
3. **default** — `default_target_soc_pct`; deadline = příští výskyt
|
||||
`default_deadline_hour` (Europe/Prague; dnešní, pokud je ještě před ní).
|
||||
|
||||
Volá ji `fn_ev_session_transition` při založení session (SQL-first; Python
|
||||
nic nepřepočítává). Ruční přepis (Discord selecty / UI →
|
||||
`fn_ev_session_apply_patch`) má vždy přednost — defaulty se aplikují jen
|
||||
při vzniku session.
|
||||
|
||||
## Discord notifikace po příjezdu (2026-06-12, dev)
|
||||
|
||||
Po detekci příjezdu + Tesla SoC + replanu odejde na site webhook souhrn:
|
||||
|
||||
@@ -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