Web frontend
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user