Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
217
python-backend/musicmouse/__main__.py
Normal file
217
python-backend/musicmouse/__main__.py
Normal 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())
|
||||
Reference in New Issue
Block a user