Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

View File

@@ -1,12 +1,13 @@
"""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" and "simulated mouse" is which transport and which
player get built here.
difference between "real mouse", "no mouse attached" and "simulated mouse" is which
transport and which player get built here.
"""
from __future__ import annotations
@@ -24,13 +25,16 @@ 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.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
from musicmouse.reactions import register_all
from musicmouse.services.base import Service
from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.web import WebService
_log = logging.getLogger("musicmouse")
@@ -48,6 +52,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
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,
@@ -64,6 +74,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
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
@@ -85,7 +97,11 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {exc}", file=sys.stderr)
return 2
runner = run_simulated(config, args.script) if args.simulate else run_real(config)
runner = (
run_simulated(config, args.config, args.script)
if args.simulate
else run_real(config, args.config, hardware=wants_hardware(config, args.no_hardware))
)
try:
asyncio.run(runner)
except KeyboardInterrupt:
@@ -96,43 +112,53 @@ def main(argv: list[str] | None = None) -> int:
# ------------------------------------------------------------------------- real
async def run_real(config: Config) -> None:
async def run_real(config: Config, config_path: Path, *, hardware: bool = True) -> 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
)
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: Player = VlcPlayer(
bus,
alsa_device=general.alsa_device,
clock=clock,
**VlcPlayer.volume_kwargs(general),
)
player = _build_player(bus, general, clock=clock)
app = _build_app(config, bus, mouse, player, clock=clock)
services = _build_services(app, mouse, player, clock=clock)
library = await build_library(config)
app = _build_app(config, bus, mouse, player, library, clock=clock)
services = _build_services(
app, mouse, player, clock=clock, config_path=config_path
)
_log.info(
"MusicMouse %s starting: %d figures, serial %s, mqtt %s",
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
__version__,
len(config.figures),
general.serial_port,
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(), player.run(), *(service.run() for service in services)]
[
*([link.run()] if link else []),
player.run(),
*(service.run() for service in services),
]
)
finally:
player.close()
@@ -142,7 +168,7 @@ async def run_real(config: Config) -> None:
# -------------------------------------------------------------------- simulated
async def run_simulated(config: Config, script: Path | None) -> None:
async def run_simulated(config: Config, config_path: Path, 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
@@ -154,7 +180,9 @@ async def run_simulated(config: Config, script: Path | None) -> None:
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())
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]
try:
if script is not None:
@@ -170,11 +198,68 @@ async def run_simulated(config: Config, script: Path | None) -> None:
# ---------------------------------------------------------------------- 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
return await MusicLibrary.build(
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
figure_kinds=config.figure_kinds,
)
def _build_app(
config: Config,
bus: EventBus,
mouse: MusicMouseDevice,
player: Player,
library: MusicLibrary,
*,
clock: RealClock,
) -> App:
@@ -183,7 +268,11 @@ def _build_app(
bus=bus,
mouse=mouse,
player=player,
playlists=build_playlists(config),
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,
)
register_all(bus, app)
@@ -191,15 +280,30 @@ def _build_app(
def _build_services(
app: App, mouse: MusicMouseDevice, player: Player, *, clock: RealClock
app: App,
mouse: MusicMouseDevice,
player: Player,
*,
clock: RealClock,
config_path: Path,
) -> 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 []
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
services: list[Service] = []
mqtt_config = app.config.general.mqtt
entities = build_entities(app.bus, mqtt_config, mouse, player)
return [MqttService(app.bus, mqtt_config, entities, clock=clock)]
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))
return services
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None: