Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.
The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:
type X = Y -> X: TypeAlias = Y
type Handler[E: Event] -> E = TypeVar("E", bound=Event) plus a plain alias,
which is generic anyway because it carries a TypeVar
def f[T: Bound](...) -> a module-level TypeVar
Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.
Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""Entry point and composition root.
|
|
|
|
python -m musicmouse --config /media/musicmouse/config.yml
|
|
python -m musicmouse --config ./config.yml --no-hardware
|
|
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", "no mouse attached" 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 Awaitable, Callable, Coroutine
|
|
from pathlib import Path
|
|
from typing import Any, TypeVar
|
|
|
|
import httpx2
|
|
|
|
from musicmouse import __version__
|
|
from musicmouse.app import App
|
|
from musicmouse.bus import EventBus
|
|
from musicmouse.clock import RealClock
|
|
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
|
|
from musicmouse.devices.mouse import MusicMouseDevice
|
|
from musicmouse.devices.null_transport import NullTransport
|
|
from musicmouse.devices.player import Player, VlcPlayer
|
|
from musicmouse.devices.serial_link import SerialLink
|
|
from musicmouse.library import MusicLibrary, default_worker_count
|
|
from musicmouse.library.analysis import build_analyzer
|
|
from musicmouse.reactions import register_all
|
|
from musicmouse.services.base import Service
|
|
from musicmouse.services.lirc import LircService
|
|
from musicmouse.services.mqtt import MqttService, build_entities
|
|
from musicmouse.services.podcasts import PodcastFeedService
|
|
from musicmouse.services.web import WebService
|
|
from musicmouse.tippen.curriculum import CurriculumError
|
|
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
|
|
|
_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(
|
|
"--no-hardware",
|
|
action="store_true",
|
|
help="real audio and a real web front-end, but no serial port: for a host with "
|
|
"no mouse attached",
|
|
)
|
|
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")
|
|
if args.simulate and args.no_hardware:
|
|
parser.error("--simulate already runs without hardware; drop --no-hardware")
|
|
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
|
|
|
|
tippen: TippenRuntime | None = None
|
|
if config.general.tippen is not None:
|
|
try:
|
|
tippen = build_tippen_runtime(config.general.tippen)
|
|
except CurriculumError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
runner = (
|
|
run_simulated(config, args.config, args.script, tippen=tippen)
|
|
if args.simulate
|
|
else run_real(
|
|
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
|
|
)
|
|
)
|
|
try:
|
|
asyncio.run(runner)
|
|
except KeyboardInterrupt:
|
|
_log.info("Interrupted")
|
|
return 0
|
|
|
|
|
|
# ------------------------------------------------------------------------- real
|
|
|
|
|
|
async def run_real(
|
|
config: Config,
|
|
config_path: Path,
|
|
*,
|
|
hardware: bool = True,
|
|
tippen: TippenRuntime | None = None,
|
|
) -> None:
|
|
bus = EventBus()
|
|
await bus.start()
|
|
clock = RealClock()
|
|
general = config.general
|
|
|
|
link: SerialLink | None = None
|
|
if hardware:
|
|
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)
|
|
else:
|
|
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
|
|
|
|
player = _build_player(bus, general, clock=clock)
|
|
|
|
library = await build_library(config)
|
|
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
|
|
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
|
|
|
|
_log.info(
|
|
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
|
|
__version__,
|
|
len(config.figures),
|
|
len(library.albums),
|
|
# Report what the config says, with a marker when --no-hardware overrode it,
|
|
# so the line never disagrees with the file it was started from.
|
|
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
|
|
general.alsa_device,
|
|
general.mqtt.server if general.mqtt else "disabled",
|
|
)
|
|
try:
|
|
await _run_forever(
|
|
[
|
|
*([link.run()] if link else []),
|
|
player.run(),
|
|
# `is_busy` reads `player.is_playing` directly rather than the library
|
|
# knowing about playback at all: analysis must never compete with audio
|
|
# decoding for CPU, and this device is otherwise idle most of the day, so
|
|
# the pass simply resumes once it is. `on_batch` is unset unless the web
|
|
# front-end is on - nothing else has a use for the notification.
|
|
library.run_analysis(
|
|
is_busy=lambda: player.is_playing,
|
|
on_batch=_analysis_batch_hook(services),
|
|
),
|
|
*(service.run() for service in services),
|
|
]
|
|
)
|
|
finally:
|
|
player.close()
|
|
await bus.stop()
|
|
|
|
|
|
# -------------------------------------------------------------------- simulated
|
|
|
|
|
|
async def run_simulated(
|
|
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = 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, tippen=tippen
|
|
)
|
|
|
|
services = _build_services(
|
|
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
|
|
)
|
|
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
|
|
tasks.append(
|
|
asyncio.create_task(
|
|
sim.app.library.run_analysis(
|
|
is_busy=lambda: sim.player.is_playing,
|
|
on_batch=_analysis_batch_hook(services),
|
|
),
|
|
name="library-analysis",
|
|
)
|
|
)
|
|
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 wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
|
|
"""Whether to open a serial port at all.
|
|
|
|
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
|
|
for rather than inferred from a missing key, but it is still worth saying out loud
|
|
every boot - a mouse whose RFID reader does nothing should say why.
|
|
"""
|
|
if no_hardware_flag:
|
|
return False
|
|
if config.general.serial_simulated:
|
|
_log.warning(
|
|
'general.serial_port is "%s": running without the mouse. '
|
|
"The web front-end still works; RFID, buttons and LEDs do not.",
|
|
SIMULATE,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
|
|
"""A real player, or a silent one when the config asked for that.
|
|
|
|
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
|
|
identically, it just makes no sound. Useful for working on the web UI without
|
|
commandeering the machine's audio.
|
|
"""
|
|
if not general.audio_simulated:
|
|
return VlcPlayer(
|
|
bus,
|
|
alsa_device=general.alsa_device,
|
|
clock=clock,
|
|
**VlcPlayer.volume_kwargs(general),
|
|
)
|
|
|
|
_log.warning(
|
|
'general.alsa_device is "%s": using a simulated player, so nothing will be '
|
|
'audible. Set it to "default" for the system default output.',
|
|
SIMULATE,
|
|
)
|
|
# Imported here rather than at module scope so the real audio path never loads the
|
|
# simulator - and so this still runs on a machine without libVLC at all.
|
|
from musicmouse.simulator.fake_player import FakePlayer
|
|
|
|
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
|
|
|
|
|
|
async def build_library(config: Config) -> MusicLibrary:
|
|
library_config = config.general.library
|
|
workers = library_config.analysis_workers
|
|
return await MusicLibrary.build(
|
|
library_config.root,
|
|
library_config.cache,
|
|
frozenset(config.general.audio_extensions),
|
|
analyzer=build_analyzer(),
|
|
# Unset in the config means "use the machine": a first pass over an unanalyzed
|
|
# library is hours of DSP, and there is no reason for a desktop to do it one
|
|
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
|
|
analysis_workers=default_worker_count() if workers is None else workers,
|
|
figure_kinds=config.figure_kinds,
|
|
)
|
|
|
|
|
|
def _build_app(
|
|
config: Config,
|
|
bus: EventBus,
|
|
mouse: MusicMouseDevice,
|
|
player: Player,
|
|
library: MusicLibrary,
|
|
*,
|
|
clock: RealClock,
|
|
tippen: TippenRuntime | None = None,
|
|
) -> App:
|
|
app = App(
|
|
config=config,
|
|
bus=bus,
|
|
mouse=mouse,
|
|
player=player,
|
|
library=library,
|
|
# One source of truth: the figure path and the web path must hand the player the
|
|
# same Playlist object for the same folder, because `play_figure` resumes on an
|
|
# identity check.
|
|
playlists=library.figure_playlists(),
|
|
clock=clock,
|
|
tippen=tippen,
|
|
)
|
|
register_all(bus, app)
|
|
return app
|
|
|
|
|
|
T = TypeVar("T", bound=Service)
|
|
|
|
|
|
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
|
|
return next((s for s in services if isinstance(s, kind)), None)
|
|
|
|
|
|
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
|
|
"""The web front-end's own hub, if it is running - so open tabs refetch the library
|
|
as background analysis lands, instead of only after a manual reload. `None` when
|
|
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
|
|
tell".
|
|
"""
|
|
web_service = _service_of_type(services, WebService)
|
|
return web_service.hub.broadcast_library if web_service else None
|
|
|
|
|
|
def _build_services(
|
|
app: App,
|
|
mouse: MusicMouseDevice,
|
|
player: Player,
|
|
*,
|
|
clock: RealClock,
|
|
config_path: Path,
|
|
) -> list[Service]:
|
|
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
|
|
services: list[Service] = []
|
|
|
|
mqtt_config = app.config.general.mqtt
|
|
if mqtt_config is None:
|
|
_log.info("No mqtt section in the config: Home Assistant integration is off")
|
|
else:
|
|
entities = build_entities(app.bus, mqtt_config, mouse, player)
|
|
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
|
|
|
|
web_config = app.config.general.web
|
|
if web_config is None:
|
|
_log.info("No web section in the config: the web front-end is off")
|
|
else:
|
|
services.append(WebService(app, web_config, config_path))
|
|
|
|
lirc_config = app.config.general.lirc
|
|
if lirc_config is None:
|
|
_log.info("No lirc section in the config: the IR remote is off")
|
|
else:
|
|
services.append(LircService(app, lirc_config, clock=clock))
|
|
|
|
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
|
# into its folder, so there is nothing to gate here with its own config section.
|
|
web_service = _service_of_type(services, WebService)
|
|
services.append(
|
|
PodcastFeedService(
|
|
app.library,
|
|
client=httpx2.AsyncClient(timeout=30.0),
|
|
on_change=lambda: app.rescan_library(
|
|
broadcast=web_service.hub.broadcast_library if web_service else None
|
|
),
|
|
episode_limit=app.config.general.podcast_episode_limit,
|
|
)
|
|
)
|
|
|
|
return services
|
|
|
|
|
|
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())
|