diff --git a/scripts/discord_cmd_smoke.py b/scripts/discord_cmd_smoke.py new file mode 100644 index 0000000..9dfe969 --- /dev/null +++ b/scripts/discord_cmd_smoke.py @@ -0,0 +1,62 @@ +"""Command-routing smoke test for Discord: /help, /status, /bag as plain text.""" +import asyncio +import logging +import os +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) + +from matebot.commands import CommandRouter, make_frame_cache # noqa: E402 +from matebot.config import Config # noqa: E402 +from matebot.messengers.base import TextReply # noqa: E402 +from matebot.messengers.discord import DiscordMessenger # noqa: E402 +from matebot.state import State # noqa: E402 + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") +log = logging.getLogger("cmdsmoke") + + +class FakeMachine: + async def request(self, tp, **f): + return {"msg": "Ok"} + + async def send_event(self, tp, **f): + log.info("MACHINE would receive: %s %s", tp, f) + + +async def main(): + messenger = DiscordMessenger( + os.environ["DISCORD_BOT_TOKEN"], os.environ["DISCORD_CHANNEL_ID"] + ) + state = State("/tmp/discord-cmd-state.json") + state.set("last_shot", {"shot_id": 73, "profile": "Direct Lever v3", + "duration_ms": 42600, "volume_g": 36.4}) + cache, latest = make_frame_cache() + cache({"tp": "evt:status", "m": 0, "ct": 91.2, "tt": 0, "wl": 55}) + router = CommandRouter(FakeMachine(), state, None, messenger, + Config(journal_url="https://alexnly.github.io/GAGGIMATE-0614/"), latest) + await messenger.start() + await messenger.send("Command smoke test ready — send /help, /status, /last, /wake as messages.") + + async def pump(): + handled = 0 + async for event in messenger.events(): + log.info("EVENT: %r", event) + if isinstance(event, TextReply) and event.text.strip().startswith("/"): + consumed = await router.handle(event.text.strip()) + log.info("router consumed=%s", consumed) + handled += 1 + if handled >= 4: + log.info("✅ command routing works on Discord") + return + + try: + await asyncio.wait_for(pump(), timeout=600) + except TimeoutError: + log.error("timed out") + finally: + await messenger.stop() + + +asyncio.run(main()) diff --git a/src/matebot/commands.py b/src/matebot/commands.py index 23fe15e..c5ed19a 100644 --- a/src/matebot/commands.py +++ b/src/matebot/commands.py @@ -44,6 +44,8 @@ def __init__(self, client, state, convo, messenger, config, latest_frame) -> Non self.config = config self.latest_frame = latest_frame # () -> (frame dict | None, age seconds) self._awaiting_ready = False + self._ensure_brew_until = 0.0 # keep resending change-mode until brew is observed + self._last_brew_send = 0.0 self._args: list[str] = [] # ------------------------------------------------------------- dispatch @@ -92,6 +94,20 @@ async def _run_hook(self, hook: str, label: str) -> bool: await asyncio.sleep(3 * (attempt + 1)) return False + async def _start_brew(self) -> None: + """Send change-mode and keep enforcing it via the status stream. + + The firmware never acknowledges mode changes, and a command sent + seconds after boot is silently swallowed while the controller still + applies startupMode. So: fire, then verify against frames and resend + until the machine actually reports brew. + """ + await self.client.send_event("req:change-mode", mode=1) + log.info("brew requested; enforcing until confirmed") + self._last_brew_send = time.monotonic() + self._ensure_brew_until = self._last_brew_send + 90 + self._awaiting_ready = True + async def _machine_online(self) -> bool: _, age = self.latest_frame() return age < 20 @@ -102,8 +118,7 @@ async def _cmd_wake(self) -> None: if self.config.wake_hook: hook_ok = await self._run_hook(self.config.wake_hook, "wake") if online: - await self.client.send_event("req:change-mode", mode=1) - self._awaiting_ready = True + await self._start_brew() await self.messenger.send("🔥 Waking the machine — I'll tell you when it's hot.") return # Machine is off: arm the wake and act the moment it appears — no @@ -134,6 +149,7 @@ async def _cmd_sleep(self) -> None: if not self.config.sleep_hook: raise self._awaiting_ready = False + self._ensure_brew_until = 0.0 if self.config.sleep_hook: await asyncio.sleep(3) # let the machine settle into standby first if await self._run_hook(self.config.sleep_hook, "sleep"): @@ -237,9 +253,10 @@ async def _cmd_digest(self) -> None: # ------------------------------------------------------------- frames async def on_frame(self, frame: dict[str, Any]) -> None: - """Called for every status frame: ready ping after /wake, tank watch.""" + """Called for every status frame: brew enforcement, ready ping, tank watch.""" if frame.get("tp") != "evt:status": return + await self._enforce_brew(frame) await self._check_water(frame) if not self._awaiting_ready: return @@ -263,10 +280,9 @@ async def on_machine_online(self, frame: dict[str, Any]) -> None: if frame.get("m") == 0 and self.state.get("pending_wake_until", 0) > time.time(): self.state.set("pending_wake_until", 0) try: - await self.client.send_event("req:change-mode", mode=1) + await self._start_brew() except MachineError: return - self._awaiting_ready = True await self.messenger.send("🔥 Machine is up — switching to brew. I'll ping when hot.") return if not self.config.autoheat_window or frame.get("m") != 0: @@ -278,15 +294,36 @@ async def on_machine_online(self, frame: dict[str, Any]) -> None: return self.state.set("autoheat_date", today) try: - await self.client.send_event("req:change-mode", mode=1) + await self._start_brew() except MachineError: return - self._awaiting_ready = True await self.messenger.send( "🌅 Good morning — the machine is on, switching it to brew. " "I'll ping when it's hot." ) + async def _enforce_brew(self, frame: dict[str, Any]) -> None: + if not self._ensure_brew_until: + return + now = time.monotonic() + if frame.get("m") == 1: + log.info("brew mode confirmed") + self._ensure_brew_until = 0.0 + elif now >= self._ensure_brew_until: + self._ensure_brew_until = 0.0 + self._awaiting_ready = False + await self.messenger.send( + "⚠️ I kept asking, but the machine won't switch to brew — " + "check it (or tap the screen)." + ) + elif frame.get("m") == 0 and now - self._last_brew_send >= 8: + self._last_brew_send = now + log.info("machine still in standby; resending change-mode") + try: + await self.client.send_event("req:change-mode", mode=1) + except MachineError: + pass + async def _check_water(self, frame: dict[str, Any]) -> None: """Warn once when the tank runs low; re-arm after a refill.""" wl = frame.get("wl") diff --git a/tests/test_commands.py b/tests/test_commands.py index c43886f..a06e750 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -305,3 +305,48 @@ def now(): router.config.autoheat_window = "" await router.on_machine_online(standby) # disabled assert client.requests == [] + + +@pytest.mark.asyncio +async def test_brew_enforcer_resends_until_confirmed(setup, monkeypatch): + router, client, state, convo, fm, cache = setup + clock = {"t": 1000.0} + monkeypatch.setattr("matebot.commands.time.monotonic", lambda: clock["t"]) + + cache({"tp": "evt:status", "m": 0, "ct": 60.0, "tt": 0}) + await router.handle("/wake") # machine online -> first send + enforcer armed + assert client.requests == [("req:change-mode", {"mode": 1})] + + standby = {"tp": "evt:status", "m": 0, "ct": 25.0, "tt": 0} + await router.on_frame(standby) # 0s later: too soon to resend + assert len(client.requests) == 1 + + clock["t"] += 9 # command was swallowed during boot; resend kicks in + await router.on_frame(standby) + assert len(client.requests) == 2 + + await router.on_frame({"tp": "evt:status", "m": 1, "ct": 30.0, "tt": 93.0}) # confirmed + clock["t"] += 9 + await router.on_frame({"tp": "evt:status", "m": 1, "ct": 40.0, "tt": 93.0}) + assert len(client.requests) == 2 # no more resends after confirmation + + # ready ping still works at temperature + await router.on_frame({"tp": "evt:status", "m": 1, "ct": 92.4, "tt": 93.0}) + assert any("ready when you are" in t for t in fm.sent) + + +@pytest.mark.asyncio +async def test_brew_enforcer_gives_up_loudly(setup, monkeypatch): + router, client, state, convo, fm, cache = setup + clock = {"t": 1000.0} + monkeypatch.setattr("matebot.commands.time.monotonic", lambda: clock["t"]) + + cache({"tp": "evt:status", "m": 0, "ct": 60.0, "tt": 0}) + await router.handle("/wake") + clock["t"] += 91 # machine never leaves standby + await router.on_frame({"tp": "evt:status", "m": 0, "ct": 25.0, "tt": 0}) + assert any("won't switch to brew" in t for t in fm.sent) + # enforcer disarmed: nothing further happens + clock["t"] += 9 + await router.on_frame({"tp": "evt:status", "m": 0, "ct": 25.0, "tt": 0}) + assert len(client.requests) == 1