Files
musicmouse/python-backend/musicmouse/simulator/driver.py
Martin Bauer d44c24ec97 Full rearchitecture using Claude
- event bus systen
- all components are independent
- preparation for web frontend
2026-08-26 13:22:28 +02:00

315 lines
11 KiB
Python

"""One vocabulary for driving a simulated mouse, shared by three front-ends.
The same verbs are typed at the interactive prompt, listed in a scenario file, and
called from pytest - so a bug reproduced by hand becomes a regression test by pasting
the session into a ``.txt`` file.
place fuchs
wait 1s
press right
expect track 1
Under :class:`~musicmouse.clock.FakeClock` ``wait 1s`` costs microseconds, so scenarios
are cheap enough to run on every commit.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from musicmouse.app import App
from musicmouse.clock import Clock, FakeClock
from musicmouse.events import (
ButtonEvent,
NextTrackRequested,
PauseRequested,
PlayRequested,
PrevTrackRequested,
RfidTokenRead,
RotaryTurned,
SetVolumeRequested,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import (
NO_FIGURE_TAG,
Button,
ButtonAction,
LedZone,
RotaryDirection,
TouchButton,
)
from musicmouse.simulator.fake_transport import FakeTransport
_log = logging.getLogger(__name__)
__all__ = ["ExpectationError", "ScriptError", "SimulatorDriver"]
class ScriptError(Exception):
"""A scenario line could not be understood."""
class ExpectationError(AssertionError):
"""An ``expect`` line did not hold."""
@dataclass(frozen=True, slots=True)
class Duration:
seconds: float
@classmethod
def parse(cls, text: str) -> Duration:
raw = text.strip().lower()
scale = 1.0
for suffix, factor in (("ms", 0.001), ("s", 1.0), ("m", 60.0)):
if raw.endswith(suffix):
raw = raw.removesuffix(suffix)
scale = factor
break
try:
return cls(float(raw) * scale)
except ValueError:
raise ScriptError(f"{text!r} is not a duration (try '1s', '500ms', '2')") from None
class SimulatorDriver:
def __init__(self, app: App, transport: FakeTransport, clock: Clock) -> None:
self.app = app
self.transport = transport
self.clock = clock
# ------------------------------------------------------------------- inputs
async def place(self, figure: str) -> None:
"""Put a figure on the reader."""
try:
tag = self.app.config.figures[figure].id
except KeyError:
known = ", ".join(sorted(self.app.config.figures))
raise ScriptError(f"unknown figure {figure!r} (configured: {known})") from None
await self.tag(tag)
async def tag(self, tag_id: bytes) -> None:
self.transport.inject(RfidTokenRead(tag_id=tag_id, source="simulator"))
await self.settle()
async def remove(self) -> None:
"""Take whatever is on the reader off it."""
await self.tag(NO_FIGURE_TAG)
async def press(self, button: str, action: str = "pressed") -> None:
self.transport.inject(
ButtonEvent(
button=_enum_by_name(Button, button, "button"),
action=_enum_by_name(ButtonAction, action, "button action"),
source="simulator",
)
)
await self.settle()
async def touch(self, button: str) -> None:
self.transport.inject(
TouchButtonPressed(
button=_enum_by_name(TouchButton, button, "touch button"), source="simulator"
)
)
await self.settle()
async def release(self, button: str) -> None:
self.transport.inject(
TouchButtonReleased(
button=_enum_by_name(TouchButton, button, "touch button"), source="simulator"
)
)
await self.settle()
async def turn(self, steps: int) -> None:
"""Turn the rotary encoder; negative steps turn it down."""
self.transport.inject(
RotaryTurned(
position=0,
increment=abs(steps),
direction=RotaryDirection.UP if steps >= 0 else RotaryDirection.DOWN,
source="simulator",
)
)
await self.settle()
async def disconnect(self) -> None:
self.transport.disconnect()
self.app.mouse.on_disconnected("simulated disconnect")
await self.settle()
async def reconnect(self) -> None:
self.transport.reconnect()
self.app.mouse.on_connected()
await self.settle()
# ------------------------------------------------------------------ intents
async def emit_play(self) -> None:
self.app.bus.emit(PlayRequested(source="simulator"))
await self.settle()
async def emit_pause(self) -> None:
self.app.bus.emit(PauseRequested(source="simulator"))
await self.settle()
async def emit_next(self) -> None:
self.app.bus.emit(NextTrackRequested(source="simulator"))
await self.settle()
async def emit_prev(self) -> None:
self.app.bus.emit(PrevTrackRequested(source="simulator"))
await self.settle()
async def set_volume(self, volume: int) -> None:
self.app.bus.emit(SetVolumeRequested(volume=volume, source="simulator"))
await self.settle()
# -------------------------------------------------------------------- time
async def wait(self, seconds: float) -> None:
await self.clock.advance(seconds)
await self.settle()
async def settle(self) -> None:
"""Let every queued event, and everything it triggers, be handled."""
await self.app.bus.drain()
# ------------------------------------------------------------------ queries
def status(self) -> str:
player = self.app.player
track = player.current_track
return (
f"figure={self.app.mouse.active_figure or '-'} "
f"{'playing' if player.is_playing else 'paused'} "
f"track={player.track_index}{f' ({track.title})' if track else ''} "
f"volume={player.volume} "
f"buttons={self.app.mouse.button_led_brightness:.2f}"
)
def leds(self) -> str:
return "\n".join(
f" {zone:>5}: {self.app.mouse.effect(zone) or '-'}" for zone in LedZone
)
def check(self, key: str, value: str) -> None:
"""Assert one property. Raises :class:`ExpectationError` if it does not hold."""
actual = self._lookup(key)
expected = value.strip()
if actual != expected:
raise ExpectationError(f"expected {key} to be {expected!r}, but it is {actual!r}")
def _lookup(self, key: str) -> str:
player = self.app.player
match key:
case "playing":
return "true" if player.is_playing else "false"
case "figure":
return self.app.mouse.active_figure or "none"
case "playlist":
return player.playlist.name if player.playlist else "none"
case "track":
return str(player.track_index)
case "title":
track = player.current_track
return track.title if track else "none"
case "volume":
return str(player.volume)
case "brightness":
return f"{self.app.mouse.button_led_brightness:.2f}"
case "ring" | "mouse" | "shelf":
effect = self.app.mouse.effect(LedZone(key))
return type(effect).__name__ if effect is not None else "none"
case _:
raise ScriptError(
f"unknown property {key!r} (try: playing, figure, playlist, track, "
f"title, volume, brightness, ring, mouse, shelf)"
)
# ------------------------------------------------------------------ scripts
async def execute(self, line: str) -> str | None:
"""Run one scenario line. Returns text to show, if any."""
stripped = line.split("#", 1)[0].strip()
if not stripped:
return None
verb, *args = stripped.split()
return await self._dispatch(verb.lower(), args)
async def run_script(self, text: str) -> None:
for number, line in enumerate(text.splitlines(), start=1):
try:
if (output := await self.execute(line)) is not None:
print(output)
except (ScriptError, ExpectationError) as exc:
raise type(exc)(f"line {number}: {exc}\n {line.strip()}") from None
async def _dispatch(self, verb: str, args: list[str]) -> str | None:
match verb, args:
case ("place" | "rfid", [figure]):
await self.place(figure)
case ("remove", []):
await self.remove()
case ("press", [button]):
await self.press(button)
case ("press", [button, action]):
await self.press(button, action)
case ("touch", [button]):
await self.touch(button)
case ("release", [button]):
await self.release(button)
case ("turn", [steps]):
await self.turn(_int(steps))
case ("next", []):
await self.emit_next()
case ("prev" | "previous", []):
await self.emit_prev()
case ("play", []):
await self.emit_play()
case ("pause", []):
await self.emit_pause()
case ("volume", [level]):
await self.set_volume(_int(level))
case ("disconnect", []):
await self.disconnect()
case ("reconnect", []):
await self.reconnect()
case ("wait", [duration]):
await self.wait(Duration.parse(duration).seconds)
case ("expect", [key, *rest]) if rest:
self.check(key, " ".join(rest))
case ("status", []):
return self.status()
case ("leds", []):
return self.leds()
case _:
raise ScriptError(f"don't know how to {' '.join([verb, *args])!r}")
return None
def _int(text: str) -> int:
try:
return int(text)
except ValueError:
raise ScriptError(f"{text!r} is not a whole number") from None
def _enum_by_name[T: (Button, ButtonAction, TouchButton)](
enum: type[T], name: str, what: str
) -> T:
try:
return enum[name.upper()]
except KeyError:
options = ", ".join(member.name.lower() for member in enum)
raise ScriptError(f"unknown {what} {name!r} (try: {options})") from None
def fake_clock_for(app: App) -> FakeClock:
"""A clock whose ``advance`` also drains the bus, for deterministic scenarios."""
return FakeClock(idle=app.bus.drain)