Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions scripts/discord_cmd_smoke.py
Original file line number Diff line number Diff line change
@@ -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())
51 changes: 44 additions & 7 deletions src/matebot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading