Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big analog watch over whatever page is open, built from the Delphin Uhr mockup without its swimming dolphin. The overlay stays mounted and is only hidden, so it appears at once. Beside the watch, only while they run: - The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25) that starts `bus_lead_minutes` (60) before. School days skip weekends and the school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily from openholidaysapi.org and cached under the library cache. A badge on the clock says whether there is school today, and why not. - The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time). It lives in the backend, so a parent's phone can start it for the kiosk. When it runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the device; it clears after a minute, or at the next key, tap or remote press. Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay bookmarked. The timer comes first there, the device settings below it. Unmapped IR buttons are logged at debug level, to find out what irserver calls a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
436 lines
15 KiB
Python
436 lines
15 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.screen import ScreenService
|
|
from musicmouse.services.timers import TimerService
|
|
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))
|
|
|
|
screen_config = app.config.general.screen
|
|
if screen_config is not None:
|
|
general = app.config.general
|
|
services.append(
|
|
ScreenService(
|
|
app.bus,
|
|
screen_config,
|
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
|
volume=lambda: app.player.volume / 100,
|
|
is_playing=lambda: app.player.is_playing,
|
|
clock=clock,
|
|
)
|
|
)
|
|
|
|
# Unconditional, like the overlay it serves: the bus timer and the school-day badge
|
|
# need nothing configured, and the parents' timer is only started from parent mode.
|
|
general = app.config.general
|
|
services.append(
|
|
TimerService(
|
|
app.bus,
|
|
app.state,
|
|
general.clock,
|
|
cache_file=general.library.cache / "holidays.json",
|
|
client=httpx2.AsyncClient(timeout=30.0),
|
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
|
volume=lambda: app.player.volume / 100,
|
|
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())
|