Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

View File

@@ -0,0 +1,217 @@
"""Entry point and composition root.
python -m musicmouse --config /media/musicmouse/config.yml
python -m musicmouse --config ./config.yml --simulate
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
This is the only module that knows which concrete implementations are in play; the
difference between "real mouse" and "simulated mouse" is which transport and which
player get built here.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import logging
import sys
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
from musicmouse import __version__
from musicmouse.app import App
from musicmouse.bus import EventBus
from musicmouse.clock import RealClock
from musicmouse.config import Config, ConfigError, build_playlists, load_config
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink
from musicmouse.reactions import register_all
from musicmouse.services.base import Service
from musicmouse.services.mqtt import MqttService, build_entities
_log = logging.getLogger("musicmouse")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="musicmouse", description="Host backend for the MusicMouse RFID music player."
)
parser.add_argument(
"-c", "--config", type=Path, required=True, help="path to config.yml"
)
parser.add_argument(
"-s",
"--simulate",
action="store_true",
help="run against fake hardware and a fake player (no serial port, no audio)",
)
parser.add_argument(
"--script",
type=Path,
help="with --simulate: run a scenario file instead of the interactive prompt",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="default: INFO",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = parser.parse_args(argv)
if args.script and not args.simulate:
parser.error("--script only makes sense together with --simulate")
return args
def setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level),
format="%(asctime)s %(levelname)-7s %(name)-28s %(message)s",
datefmt="%H:%M:%S",
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
setup_logging(args.log_level)
try:
config = load_config(args.config, check_paths=True)
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
runner = run_simulated(config, args.script) if args.simulate else run_real(config)
try:
asyncio.run(runner)
except KeyboardInterrupt:
_log.info("Interrupted")
return 0
# ------------------------------------------------------------------------- real
async def run_real(config: Config) -> None:
bus = EventBus()
await bus.start()
clock = RealClock()
general = config.general
link = SerialLink(
general.serial_port,
general.baudrate,
reconnect_interval=general.reconnect_interval,
clock=clock,
)
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
link.attach(
mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected
)
player: Player = VlcPlayer(
bus,
alsa_device=general.alsa_device,
clock=clock,
**VlcPlayer.volume_kwargs(general),
)
app = _build_app(config, bus, mouse, player, clock=clock)
services = _build_services(app, mouse, player, clock=clock)
_log.info(
"MusicMouse %s starting: %d figures, serial %s, mqtt %s",
__version__,
len(config.figures),
general.serial_port,
general.mqtt.server if general.mqtt else "disabled",
)
try:
await _run_forever(
[link.run(), player.run(), *(service.run() for service in services)]
)
finally:
player.close()
await bus.stop()
# -------------------------------------------------------------------- simulated
async def run_simulated(config: Config, script: Path | None) -> None:
# Imported here so the production path never touches the simulator.
from musicmouse.simulator.harness import build_simulation
from musicmouse.simulator.repl import run_repl
from musicmouse.simulator.script import run_script_file
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
# real clock, so playback ticks along while you watch it.
sim = await build_simulation(
config, clock=RealClock() if script is None else None, track_duration=5.0
)
services = _build_services(sim.app, sim.app.mouse, sim.player, clock=RealClock())
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
try:
if script is not None:
await run_script_file(sim, script)
else:
await run_repl(sim)
finally:
for task in tasks:
task.cancel()
await sim.aclose()
# ---------------------------------------------------------------------- wiring
def _build_app(
config: Config,
bus: EventBus,
mouse: MusicMouseDevice,
player: Player,
*,
clock: RealClock,
) -> App:
app = App(
config=config,
bus=bus,
mouse=mouse,
player=player,
playlists=build_playlists(config),
clock=clock,
)
register_all(bus, app)
return app
def _build_services(
app: App, mouse: MusicMouseDevice, player: Player, *, clock: RealClock
) -> list[Service]:
"""Every front-end. A web service would be one more line here."""
if app.config.general.mqtt is None:
_log.info("No mqtt section in the config: Home Assistant integration is off")
return []
mqtt_config = app.config.general.mqtt
entities = build_entities(app.bus, mqtt_config, mouse, player)
return [MqttService(app.bus, mqtt_config, entities, clock=clock)]
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
tasks = [asyncio.create_task(coro) for coro in coroutines]
try:
await asyncio.gather(*tasks)
finally:
for task in tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*tasks, return_exceptions=True)
if __name__ == "__main__":
raise SystemExit(main())