Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/ volume/mute map to the same intents every other front-end already emits, and number keys 0-9 play an assigned album/audiobook from the start or a podcast show's newest episode, resolved fresh on every press. The mapping is configured in config.yml and editable from the frontend: a small "Taste zuweisen" button on the play screen (or the A+digit keyboard shortcut) opens a 10-key picker to assign whatever is currently playing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,18 @@ general:
|
||||
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||
static_dir: ../web/dist
|
||||
|
||||
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
|
||||
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
||||
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
||||
# number keys 0-9 play whatever the "remote:" section below assigns them.
|
||||
lirc:
|
||||
host: "musicmouse-pi.local"
|
||||
port: 2222 # this deployment's lircd listens on 2222, not its own
|
||||
# default of 8765 - see the ansible role
|
||||
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
||||
# remote, say) are ignored
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||
@@ -124,3 +136,20 @@ figures:
|
||||
id: "04b2c3d4e5"
|
||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||
kind: book
|
||||
|
||||
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
|
||||
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
||||
# and that is not an error. Editable from the web front-end, which writes back here.
|
||||
#
|
||||
# target_kind: album -> always starts from the first track (music, audiobooks).
|
||||
# target is an album id, as shown at GET /api/library.
|
||||
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||
# fresh on every press - never a fixed episode. target is the
|
||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||
remote:
|
||||
"1":
|
||||
target_kind: album
|
||||
target: "3f9a0c12ab44"
|
||||
"2":
|
||||
target_kind: series
|
||||
target: "Wissen macht Ah"
|
||||
|
||||
@@ -36,6 +36,7 @@ from musicmouse.library import MusicLibrary
|
||||
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
|
||||
@@ -330,6 +331,12 @@ def _build_services(
|
||||
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 = next((s for s in services if isinstance(s, WebService)), None)
|
||||
|
||||
@@ -31,6 +31,9 @@ class AppState:
|
||||
#: readable off the transport; a broker's is not, so it is remembered here.
|
||||
mqtt_connected: bool = False
|
||||
|
||||
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
||||
lirc_connected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class App:
|
||||
|
||||
@@ -33,18 +33,24 @@ __all__ = [
|
||||
"SIMULATE",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"Digit",
|
||||
"FigureColors",
|
||||
"FigureConfig",
|
||||
"GeneralConfig",
|
||||
"HaConfig",
|
||||
"HaDeviceConfig",
|
||||
"LibraryConfig",
|
||||
"LircConfig",
|
||||
"MqttConfig",
|
||||
"RemoteSlotConfig",
|
||||
"WebConfig",
|
||||
"format_validation_error",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
||||
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
||||
|
||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||
|
||||
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
|
||||
@@ -133,6 +139,22 @@ class MqttConfig(_Strict):
|
||||
reconnect_interval: float = Field(default=10.0, gt=0)
|
||||
|
||||
|
||||
class LircConfig(_Strict):
|
||||
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
|
||||
|
||||
Omit the whole section to run without an IR remote.
|
||||
"""
|
||||
|
||||
host: str
|
||||
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
|
||||
#: default is 8765, so this is worth overriding rather than assuming.
|
||||
port: int = Field(default=2222, ge=1, le=65535)
|
||||
#: Only button events from this remote are acted on - other remotes registered
|
||||
#: with the same lircd (an LED remote, say) are ignored.
|
||||
remote_name: str = "Hauppauge"
|
||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||
|
||||
|
||||
class LibraryConfig(_Strict):
|
||||
"""Where the music lives.
|
||||
|
||||
@@ -221,6 +243,7 @@ class GeneralConfig(_Strict):
|
||||
mqtt: MqttConfig | None = None
|
||||
web: WebConfig | None = None
|
||||
ha: HaConfig | None = None
|
||||
lirc: LircConfig | None = None
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
@@ -262,9 +285,26 @@ class FigureConfig(_Strict):
|
||||
kind: Literal["music", "book"] = "music"
|
||||
|
||||
|
||||
class RemoteSlotConfig(_Strict):
|
||||
"""What a number key on the IR remote plays.
|
||||
|
||||
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
|
||||
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
|
||||
``Album.series``); resolved to that show's newest episode fresh on every press,
|
||||
since a podcast show is not itself one playable thing in this library - each
|
||||
episode is its own album.
|
||||
"""
|
||||
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
|
||||
|
||||
class Config(_Strict):
|
||||
general: GeneralConfig
|
||||
figures: dict[str, FigureConfig] = Field(min_length=1)
|
||||
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
|
||||
#: assignments, and that is not an error.
|
||||
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_unique_tag_ids(self) -> Self:
|
||||
|
||||
@@ -35,8 +35,10 @@ __all__ = [
|
||||
"LedEffectRequested",
|
||||
"NextTrackRequested",
|
||||
"PauseRequested",
|
||||
"PlayAlbumRequested",
|
||||
"PlayFigureRequested",
|
||||
"PlayRequested",
|
||||
"PlaySeriesLatestRequested",
|
||||
"PlaybackChanged",
|
||||
"PlaylistFinished",
|
||||
"PrevTrackRequested",
|
||||
@@ -51,7 +53,7 @@ __all__ = [
|
||||
"VolumeChanged",
|
||||
]
|
||||
|
||||
type EventSource = Literal["device", "player", "mqtt", "web", "simulator", "system"]
|
||||
type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
@@ -186,6 +188,17 @@ class PlayAlbumRequested(IntentEvent):
|
||||
track_index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaySeriesLatestRequested(IntentEvent):
|
||||
"""Start the newest episode of a podcast show.
|
||||
|
||||
The IR remote's number-key mapping assigns a whole show rather than one fixed
|
||||
episode, so this is resolved to an actual album fresh on every press.
|
||||
"""
|
||||
|
||||
series: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SeekRequested(IntentEvent):
|
||||
#: Seconds from the start of the current track.
|
||||
@@ -257,5 +270,5 @@ class LedEffectChanged(StateEvent):
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ConnectionChanged(StateEvent):
|
||||
target: Literal["firmware", "mqtt"]
|
||||
target: Literal["firmware", "mqtt", "lirc"]
|
||||
connected: bool
|
||||
|
||||
@@ -124,6 +124,26 @@ class MusicLibrary:
|
||||
if album.figure is not None
|
||||
}
|
||||
|
||||
def latest_episode(self, series: str) -> Album | None:
|
||||
"""The newest episode-unit album of a podcast show, by filename.
|
||||
|
||||
Episode files are named ``YYYYMMDD - Title``, so filename order is
|
||||
chronological - the same fact ``Kinderpodcasts``' ``order="newest_first"``
|
||||
already relies on at scan time. ``None`` if the show is unknown or empty.
|
||||
"""
|
||||
candidates = [
|
||||
album
|
||||
for album in self.albums
|
||||
if album.series == series
|
||||
and (section := SECTIONS.get(album.section)) is not None
|
||||
and section.album_unit == "episode"
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(
|
||||
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
|
||||
)
|
||||
|
||||
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
||||
album = self.get(identifier)
|
||||
if album is None or not 0 <= index < len(album.tracks):
|
||||
|
||||
@@ -19,6 +19,7 @@ from musicmouse.events import (
|
||||
PlayFigureRequested,
|
||||
PlaylistFinished,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
@@ -26,6 +27,7 @@ from musicmouse.events import (
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.reactions.registry import on
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -74,6 +76,14 @@ def play_figure(event: PlayFigureRequested, app: App) -> None:
|
||||
app.player.play_from_start()
|
||||
|
||||
|
||||
def _start_album(app: App, album: Album, track_index: int) -> None:
|
||||
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
|
||||
# Playlist object either way, because both come from the library index.
|
||||
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
|
||||
app.player.set_playlist(playlist or album.to_playlist())
|
||||
app.player.play_track(track_index)
|
||||
|
||||
|
||||
@on(PlayAlbumRequested)
|
||||
def play_album(event: PlayAlbumRequested, app: App) -> None:
|
||||
"""Play any album in the library. This is the web front-end's way in."""
|
||||
@@ -81,12 +91,17 @@ def play_album(event: PlayAlbumRequested, app: App) -> None:
|
||||
if album is None:
|
||||
_log.warning("No album %r in the library", event.album_id)
|
||||
return
|
||||
_start_album(app, album, event.track_index)
|
||||
|
||||
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
|
||||
# Playlist object either way, because both come from the library index.
|
||||
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
|
||||
app.player.set_playlist(playlist or album.to_playlist())
|
||||
app.player.play_track(event.track_index)
|
||||
|
||||
@on(PlaySeriesLatestRequested)
|
||||
def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
|
||||
"""Play the newest episode of a podcast show - the IR remote's number-key way in."""
|
||||
album = app.library.latest_episode(event.series)
|
||||
if album is None:
|
||||
_log.warning("No episodes for series %r", event.series)
|
||||
return
|
||||
_start_album(app, album, 0)
|
||||
|
||||
|
||||
@on(PlaylistFinished)
|
||||
|
||||
@@ -16,3 +16,5 @@ from musicmouse.reactions.registry import on
|
||||
def connection_changed(event: ConnectionChanged, app: App) -> None:
|
||||
if event.target == "mqtt":
|
||||
app.state.mqtt_connected = event.connected
|
||||
elif event.target == "lirc":
|
||||
app.state.lirc_connected = event.connected
|
||||
|
||||
6
python-backend/musicmouse/services/lirc/__init__.py
Normal file
6
python-backend/musicmouse/services/lirc/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""IR remote control, via lircd's TCP socket."""
|
||||
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
from musicmouse.services.lirc.service import LircService
|
||||
|
||||
__all__ = ["LircButtonEvent", "LircService", "parse_line"]
|
||||
37
python-backend/musicmouse/services/lirc/protocol.py
Normal file
37
python-backend/musicmouse/services/lirc/protocol.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""lircd's classic network protocol: one line per button press or repeat.
|
||||
|
||||
A line looks like::
|
||||
|
||||
0000000000001781 00 BTN_1 Hauppauge
|
||||
|
||||
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
|
||||
the first press and increments while the button is held - lircd has no separate
|
||||
key-up event, just repeats stopping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ["LircButtonEvent", "parse_line"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LircButtonEvent:
|
||||
code: str
|
||||
repeat: int
|
||||
button: str
|
||||
remote: str
|
||||
|
||||
|
||||
def parse_line(line: str) -> LircButtonEvent | None:
|
||||
"""One broadcast line, or ``None`` if it does not look like one."""
|
||||
parts = line.strip().split()
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
code, repeat_hex, button, remote = parts
|
||||
try:
|
||||
repeat = int(repeat_hex, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
return LircButtonEvent(code=code, repeat=repeat, button=button, remote=remote)
|
||||
140
python-backend/musicmouse/services/lirc/service.py
Normal file
140
python-backend/musicmouse/services/lirc/service.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""The IR remote: a TCP client for lircd, translated into the same intents every other
|
||||
front-end emits.
|
||||
|
||||
Connect, read lines until the link drops, wait, repeat - the same reconnect shape as
|
||||
:class:`~musicmouse.devices.serial_link.SerialLink`, over a plain socket instead of a
|
||||
serial port because lircd speaks its classic protocol on a bare TCP connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import Digit, LircConfig
|
||||
from musicmouse.events import (
|
||||
ConnectionChanged,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
SetVolumeRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LircService"]
|
||||
|
||||
#: Acted on only at repeat 0 (the first press) - holding the button must not replay it.
|
||||
_TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
"KEY_PLAY": lambda: PlayRequested(source="lirc"),
|
||||
# No separate "stop" concept exists in this player; the remote's stop button just
|
||||
# pauses, like its pause button.
|
||||
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_STOP": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PrevTrackRequested(source="lirc"),
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
}
|
||||
|
||||
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
|
||||
#: encoder (see ``reactions.playback.rotary_turned``).
|
||||
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
|
||||
|
||||
_DIGITS: dict[str, Digit] = {
|
||||
"BTN_0": "0",
|
||||
"BTN_1": "1",
|
||||
"BTN_2": "2",
|
||||
"BTN_3": "3",
|
||||
"BTN_4": "4",
|
||||
"BTN_5": "5",
|
||||
"BTN_6": "6",
|
||||
"BTN_7": "7",
|
||||
"BTN_8": "8",
|
||||
"BTN_9": "9",
|
||||
}
|
||||
|
||||
|
||||
class LircService:
|
||||
name = "lirc"
|
||||
|
||||
def __init__(self, app: App, config: LircConfig, *, clock: Clock | None = None) -> None:
|
||||
self.app = app
|
||||
self.config = config
|
||||
self._clock = clock or RealClock()
|
||||
#: Volume to restore on the next mute press, remembered the way the frontend's
|
||||
#: own mute toggle does (App.tsx) rather than via any new Player API.
|
||||
self._pre_mute_volume: int | None = None
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
|
||||
while True:
|
||||
try:
|
||||
await self._session()
|
||||
except OSError as exc:
|
||||
_log.warning(
|
||||
"lircd link to %s:%d lost (%s); retrying in %gs",
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
exc,
|
||||
self.config.reconnect_interval,
|
||||
)
|
||||
await self._clock.sleep(self.config.reconnect_interval)
|
||||
|
||||
async def _session(self) -> None:
|
||||
reader, writer = await asyncio.open_connection(self.config.host, self.config.port)
|
||||
_log.info("Connected to lircd at %s:%d", self.config.host, self.config.port)
|
||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=True, source="lirc"))
|
||||
try:
|
||||
while True:
|
||||
raw = await reader.readline()
|
||||
if not raw:
|
||||
return
|
||||
event = parse_line(raw.decode(errors="replace"))
|
||||
if event is None or event.remote != self.config.remote_name:
|
||||
continue
|
||||
self._handle(event)
|
||||
finally:
|
||||
writer.close()
|
||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
|
||||
|
||||
def _handle(self, event: LircButtonEvent) -> None:
|
||||
if event.button in _TRANSPORT:
|
||||
if event.repeat == 0:
|
||||
self.app.bus.emit(_TRANSPORT[event.button]())
|
||||
elif event.button in _VOLUME:
|
||||
step = self.app.config.general.volume_increment * _VOLUME[event.button]
|
||||
self.app.bus.emit(VolumeChangeRequested(delta=step, source="lirc"))
|
||||
elif event.button == "KEY_MUTE":
|
||||
if event.repeat == 0:
|
||||
self._toggle_mute()
|
||||
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0:
|
||||
self._play_digit(digit)
|
||||
|
||||
def _toggle_mute(self) -> None:
|
||||
player = self.app.player
|
||||
if player.volume > 0:
|
||||
self._pre_mute_volume = player.volume
|
||||
self.app.bus.emit(SetVolumeRequested(volume=0, source="lirc"))
|
||||
else:
|
||||
restore = self._pre_mute_volume or self.app.config.general.initial_volume
|
||||
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
||||
|
||||
def _play_digit(self, digit: Digit) -> None:
|
||||
slot = self.app.config.remote.get(digit)
|
||||
if slot is None:
|
||||
return
|
||||
if slot.target_kind == "album":
|
||||
self.app.bus.emit(
|
||||
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
|
||||
)
|
||||
else:
|
||||
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisc
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import HaConfig
|
||||
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
|
||||
from musicmouse.events import (
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
@@ -32,13 +32,17 @@ from musicmouse.events import (
|
||||
SetVolumeRequested,
|
||||
)
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||
from musicmouse.services.web.schemas import (
|
||||
AlbumOut,
|
||||
HaConfigOut,
|
||||
HaDeviceOut,
|
||||
LibraryOut,
|
||||
LircConfigOut,
|
||||
PlayerStateOut,
|
||||
PlayIn,
|
||||
RemoteMappingIn,
|
||||
RemoteMappingOut,
|
||||
SeekIn,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
@@ -196,6 +200,38 @@ def build_router(
|
||||
await hub.broadcast_state()
|
||||
return read_settings(general)
|
||||
|
||||
# -------------------------------------------------------------- IR remote
|
||||
|
||||
@router.get("/lirc")
|
||||
def get_lirc_config() -> LircConfigOut:
|
||||
if app.config.general.lirc is None:
|
||||
raise HTTPException(status_code=404, detail="lirc not configured")
|
||||
return LircConfigOut(connected=app.state.lirc_connected)
|
||||
|
||||
@router.get("/remote/mapping")
|
||||
def get_remote_mapping() -> RemoteMappingOut:
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
|
||||
@router.put("/remote/mapping")
|
||||
async def put_remote_mapping(body: RemoteMappingIn) -> RemoteMappingOut:
|
||||
resolved: dict[Digit, RemoteSlotConfig] = {}
|
||||
for digit, slot in body.slots.items():
|
||||
found = (
|
||||
app.library.get(slot.target)
|
||||
if slot.target_kind == "album"
|
||||
else app.library.latest_episode(slot.target)
|
||||
)
|
||||
if found is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
|
||||
)
|
||||
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target)
|
||||
|
||||
app.config.remote = resolved
|
||||
await asyncio.to_thread(write_mapping, config_path, resolved)
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
|
||||
# --------------------------------------------------------------- room control
|
||||
#
|
||||
# The browser never sees the Home Assistant token: it stays server-side, attached
|
||||
|
||||
48
python-backend/musicmouse/services/web/remote_settings.py
Normal file
48
python-backend/musicmouse/services/web/remote_settings.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Reading and writing the IR remote's number-key mapping.
|
||||
|
||||
Same job as :mod:`musicmouse.services.web.settings`, for a dict-shaped config section
|
||||
rather than flat scalars: the ``remote:`` top-level key, not something under
|
||||
``general``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.config import Digit, RemoteSlotConfig
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.services.web.schemas import RemoteMappingOut, RemoteSlotOut
|
||||
from musicmouse.services.web.settings import atomic_write, load_document
|
||||
|
||||
__all__ = ["read_mapping", "write_mapping"]
|
||||
|
||||
|
||||
def _resolve(slot: RemoteSlotConfig, library: MusicLibrary) -> str | None:
|
||||
if slot.target_kind == "album":
|
||||
album = library.get(slot.target)
|
||||
else:
|
||||
album = library.latest_episode(slot.target)
|
||||
return album.id if album else None
|
||||
|
||||
|
||||
def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -> RemoteMappingOut:
|
||||
slots = [
|
||||
RemoteSlotOut(
|
||||
digit=digit,
|
||||
target_kind=slot.target_kind,
|
||||
target=slot.target,
|
||||
resolved_album_id=_resolve(slot, library),
|
||||
)
|
||||
for digit, slot in sorted(remote.items())
|
||||
]
|
||||
return RemoteMappingOut(slots=slots)
|
||||
|
||||
|
||||
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
|
||||
document = load_document(path)
|
||||
remote = {
|
||||
digit: {"target_kind": slot.target_kind, "target": slot.target}
|
||||
for digit, slot in mapping.items()
|
||||
}
|
||||
document["remote"] = remote
|
||||
atomic_write(path, document)
|
||||
@@ -8,8 +8,11 @@ configured ceiling is a parent's business, not a child's, so it never crosses th
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from musicmouse.config import Digit
|
||||
from musicmouse.library import Album
|
||||
from musicmouse.library.analysis import TrackAnalysis
|
||||
|
||||
@@ -18,8 +21,13 @@ __all__ = [
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
"LircConfigOut",
|
||||
"PlayIn",
|
||||
"PlayerStateOut",
|
||||
"RemoteMappingIn",
|
||||
"RemoteMappingOut",
|
||||
"RemoteSlotIn",
|
||||
"RemoteSlotOut",
|
||||
"SeekIn",
|
||||
"SettingsIn",
|
||||
"SettingsOut",
|
||||
@@ -125,6 +133,7 @@ class TrackDetailOut(BaseModel):
|
||||
class ConnectionOut(BaseModel):
|
||||
firmware: bool
|
||||
mqtt: bool
|
||||
lirc: bool
|
||||
|
||||
|
||||
class PlayerStateOut(BaseModel):
|
||||
@@ -188,3 +197,36 @@ class HaConfigOut(BaseModel):
|
||||
|
||||
devices: list[HaDeviceOut]
|
||||
scenes: list[HaDeviceOut]
|
||||
|
||||
|
||||
class LircConfigOut(BaseModel):
|
||||
"""Presence-only, like ``HaConfigOut``: there is nothing secret in a host/port,
|
||||
but the frontend only needs to know whether the remote is set up and connected."""
|
||||
|
||||
connected: bool
|
||||
|
||||
|
||||
class RemoteSlotOut(BaseModel):
|
||||
digit: str
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
#: The album this slot resolves to *right now* - the fixed album for "album" slots,
|
||||
#: or today's newest episode for "series" slots. `None` when the target no longer
|
||||
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
||||
#: broken-assignment state instead of silently dropping it.
|
||||
resolved_album_id: str | None
|
||||
|
||||
|
||||
class RemoteMappingOut(BaseModel):
|
||||
slots: list[RemoteSlotOut]
|
||||
|
||||
|
||||
class RemoteSlotIn(BaseModel):
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
|
||||
|
||||
class RemoteMappingIn(BaseModel):
|
||||
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
|
||||
|
||||
slots: dict[Digit, RemoteSlotIn]
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -20,7 +21,14 @@ from musicmouse.services.web.schemas import SettingsIn, SettingsOut
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["read_settings", "to_device_volume", "to_percent", "write_settings"]
|
||||
__all__ = [
|
||||
"atomic_write",
|
||||
"load_document",
|
||||
"read_settings",
|
||||
"to_device_volume",
|
||||
"to_percent",
|
||||
"write_settings",
|
||||
]
|
||||
|
||||
|
||||
def to_device_volume(percent: int, general: GeneralConfig) -> int:
|
||||
@@ -47,30 +55,40 @@ def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||
)
|
||||
|
||||
|
||||
def write_settings(path: Path, settings: SettingsIn) -> None:
|
||||
"""Patch the settings into ``config.yml`` in place.
|
||||
|
||||
Loaded round-trip rather than with the safe loader :mod:`musicmouse.config` uses,
|
||||
so the file keeps its comments, ordering and formatting - a config that explains
|
||||
itself is worth more than one this could rewrite from scratch. Written through a
|
||||
sibling temp file so an interrupted save cannot truncate the real one.
|
||||
"""
|
||||
def _yaml() -> YAML:
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
return yaml
|
||||
|
||||
|
||||
def load_document(path: Path) -> Any:
|
||||
"""The config file, round-trip parsed so it keeps its comments and formatting.
|
||||
|
||||
Shared by every writer that patches ``config.yml`` in place - a config that
|
||||
explains itself is worth more than one a save could rewrite from scratch.
|
||||
"""
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
document = yaml.load(handle)
|
||||
return _yaml().load(handle)
|
||||
|
||||
general = document["general"]
|
||||
for key, value in settings.model_dump().items():
|
||||
general[key] = value
|
||||
|
||||
def atomic_write(path: Path, document: Any) -> None:
|
||||
"""Write a round-trip-loaded document back, through a sibling temp file so an
|
||||
interrupted save cannot truncate the real one."""
|
||||
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
||||
try:
|
||||
with temp.open("w", encoding="utf-8") as handle:
|
||||
yaml.dump(document, handle)
|
||||
_yaml().dump(document, handle)
|
||||
temp.replace(path)
|
||||
except BaseException:
|
||||
temp.unlink(missing_ok=True)
|
||||
raise
|
||||
_log.info("Wrote settings to %s", path)
|
||||
|
||||
|
||||
def write_settings(path: Path, settings: SettingsIn) -> None:
|
||||
"""Patch the settings into ``config.yml`` in place."""
|
||||
document = load_document(path)
|
||||
general = document["general"]
|
||||
for key, value in settings.model_dump().items():
|
||||
general[key] = value
|
||||
atomic_write(path, document)
|
||||
|
||||
@@ -43,5 +43,6 @@ def snapshot(app: App) -> PlayerStateOut:
|
||||
connected=ConnectionOut(
|
||||
firmware=app.mouse.connected,
|
||||
mqtt=app.state.mqtt_connected,
|
||||
lirc=app.state.lirc_connected,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -56,6 +56,46 @@ def test_ha_section_is_optional(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None
|
||||
|
||||
|
||||
def test_lirc_section_is_optional(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).general.lirc is None
|
||||
|
||||
data = _config(lirc={"host": "musicmouse-pi.local"})
|
||||
config = load_config(write_config(config_dir, data))
|
||||
assert config.general.lirc is not None
|
||||
assert config.general.lirc.port == 2222
|
||||
assert config.general.lirc.remote_name == "Hauppauge"
|
||||
|
||||
|
||||
def test_remote_mapping_is_optional_and_empty_by_default(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).remote == {}
|
||||
|
||||
|
||||
def test_remote_mapping_loads_album_and_series_slots(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["remote"] = {
|
||||
"1": {"target_kind": "album", "target": "abc123"},
|
||||
"7": {"target_kind": "series", "target": "Wissen macht Ah"},
|
||||
}
|
||||
config = load_config(write_config(config_dir, data))
|
||||
assert config.remote["1"].target_kind == "album"
|
||||
assert config.remote["1"].target == "abc123"
|
||||
assert config.remote["7"].target_kind == "series"
|
||||
|
||||
|
||||
def test_remote_mapping_rejects_an_out_of_range_digit(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["remote"] = {"10": {"target_kind": "album", "target": "abc123"}}
|
||||
message = _error(config_dir, data)
|
||||
assert "remote" in message
|
||||
|
||||
|
||||
def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None:
|
||||
data = copy.deepcopy(VALID_CONFIG)
|
||||
data["remote"] = {"1": {"target_kind": "playlist", "target": "abc123"}}
|
||||
message = _error(config_dir, data)
|
||||
assert "remote" in message
|
||||
|
||||
|
||||
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||
data = _config(
|
||||
ha={
|
||||
|
||||
@@ -128,6 +128,28 @@ async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
|
||||
assert [a.title for a in episodes] == ["Neu", "Alt"]
|
||||
|
||||
|
||||
async def test_latest_episode_finds_the_newest_episode_of_a_show(config_dir: Path) -> None:
|
||||
library = await build(config_dir)
|
||||
latest = library.latest_episode("Wissen macht Ah")
|
||||
assert latest is not None
|
||||
assert latest.title == "Neu"
|
||||
|
||||
|
||||
async def test_latest_episode_is_none_for_an_unknown_show(config_dir: Path) -> None:
|
||||
library = await build(config_dir)
|
||||
assert library.latest_episode("no such show") is None
|
||||
|
||||
|
||||
async def test_latest_episode_ignores_series_that_are_not_episode_unit(config_dir: Path) -> None:
|
||||
"""A music/book section groups by `series` too (an audiobook's character), but it
|
||||
is a folder-unit shelf, not an episode-unit one - `latest_episode` must not treat
|
||||
an audiobook as if it had "episodes"."""
|
||||
library = await build(config_dir)
|
||||
audiobook = album_named(library, "Conni in den Bergen")
|
||||
assert audiobook.series is not None
|
||||
assert library.latest_episode(audiobook.series) is None
|
||||
|
||||
|
||||
async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
|
||||
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||
assert [track.path.name for track in album.tracks] == [
|
||||
|
||||
342
python-backend/tests/test_lirc.py
Normal file
342
python-backend/tests/test_lirc.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""The IR remote: line parsing, and the service against a scripted lircd."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.clock import FakeClock
|
||||
from musicmouse.config import LircConfig, RemoteSlotConfig, load_config
|
||||
from musicmouse.events import (
|
||||
ConnectionChanged,
|
||||
Event,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc import LircService
|
||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from tests.conftest import VALID_CONFIG, write_config
|
||||
|
||||
# ---------------------------------------------------------------------- parse_line
|
||||
|
||||
|
||||
def test_a_valid_line_is_parsed() -> None:
|
||||
assert parse_line("0000000000001781 00 BTN_1 Hauppauge") == LircButtonEvent(
|
||||
code="0000000000001781", repeat=0, button="BTN_1", remote="Hauppauge"
|
||||
)
|
||||
|
||||
|
||||
def test_a_repeat_field_is_hexadecimal() -> None:
|
||||
event = parse_line("0000000000001781 0a BTN_1 Hauppauge")
|
||||
assert event is not None
|
||||
assert event.repeat == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"",
|
||||
"0000000000001781 00 BTN_1", # missing remote
|
||||
"0000000000001781 00 BTN_1 Hauppauge extra", # extra token
|
||||
"0000000000001781 zz BTN_1 Hauppauge", # non-hex repeat
|
||||
],
|
||||
)
|
||||
def test_malformed_lines_are_rejected(line: str) -> None:
|
||||
assert parse_line(line) is None
|
||||
|
||||
|
||||
def test_surrounding_whitespace_is_ignored() -> None:
|
||||
assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- service
|
||||
|
||||
|
||||
class ScriptedLircd:
|
||||
"""A tiny stand-in for lircd: accepts one connection at a time and lets the test
|
||||
push lines to whoever is currently connected."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._connected = asyncio.Event()
|
||||
self.connection_count = 0
|
||||
|
||||
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
self._writer = writer
|
||||
self.connection_count += 1
|
||||
self._connected.set()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.Event().wait() # held open until the test drops it
|
||||
|
||||
async def wait_connected(self) -> None:
|
||||
await self._connected.wait()
|
||||
|
||||
async def send(self, line: str) -> None:
|
||||
await self._connected.wait()
|
||||
assert self._writer is not None
|
||||
self._writer.write(f"{line}\n".encode())
|
||||
await self._writer.drain()
|
||||
# Real socket I/O, not the fake clock: give the reader a moment to actually
|
||||
# see the bytes before the caller checks what happened.
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
def drop(self) -> None:
|
||||
"""Simulate the link dying: close the socket, and wait for a new connect."""
|
||||
assert self._writer is not None
|
||||
self._writer.close()
|
||||
self._writer = None
|
||||
self._connected = asyncio.Event()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Server]]:
|
||||
station = ScriptedLircd()
|
||||
server = await asyncio.start_server(station._handle, "127.0.0.1", 0)
|
||||
try:
|
||||
yield station, server
|
||||
finally:
|
||||
server.close()
|
||||
# The scripted connection handler holds its connection open forever (it does
|
||||
# not know the test is done), so `wait_closed()` alone would hang here.
|
||||
server.close_clients()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
# Real time for the socket I/O itself, virtual time for the reconnect sleep - tests
|
||||
# drive that explicitly via `sim.clock.advance()` and drain the bus after.
|
||||
simulation = await build_simulation(config, clock=FakeClock())
|
||||
try:
|
||||
yield simulation
|
||||
finally:
|
||||
await simulation.aclose()
|
||||
|
||||
|
||||
def _lirc_config(port: int, *, reconnect_interval: float = 5.0) -> LircConfig:
|
||||
return LircConfig(host="127.0.0.1", port=port, reconnect_interval=reconnect_interval)
|
||||
|
||||
|
||||
async def _run_service(sim: Simulation, config: LircConfig) -> asyncio.Task[None]:
|
||||
service = LircService(sim.app, config, clock=sim.clock)
|
||||
return asyncio.create_task(service.run())
|
||||
|
||||
|
||||
async def test_transport_and_volume_buttons_emit_intents(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe_all(seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_PLAY Hauppauge")
|
||||
await station.send("0 00 KEY_PAUSE Hauppauge")
|
||||
await station.send("0 00 KEY_STOP Hauppauge")
|
||||
await station.send("0 00 KEY_PREVIOUS Hauppauge")
|
||||
await station.send("0 00 KEY_FORWARD Hauppauge")
|
||||
await station.send("0 00 KEY_VOLUMEUP Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
kinds = [type(event) for event in seen if isinstance(event, IntentEvent)]
|
||||
assert kinds == [
|
||||
PlayRequested,
|
||||
PauseRequested,
|
||||
PauseRequested,
|
||||
PrevTrackRequested,
|
||||
NextTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
]
|
||||
|
||||
|
||||
async def test_events_from_another_remote_are_ignored(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe_all(seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_PLAY small_led_remote")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert not any(isinstance(event, IntentEvent) for event in seen)
|
||||
|
||||
|
||||
async def test_transport_buttons_only_act_on_the_first_press(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe(NextTrackRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_FORWARD Hauppauge")
|
||||
await station.send("0 01 KEY_FORWARD Hauppauge")
|
||||
await station.send("0 02 KEY_FORWARD Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
async def test_volume_buttons_act_on_every_repeat(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[VolumeChangeRequested] = []
|
||||
sim.bus.subscribe(VolumeChangeRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 KEY_VOLUMEDOWN Hauppauge")
|
||||
await station.send("0 01 KEY_VOLUMEDOWN Hauppauge")
|
||||
await station.send("0 02 KEY_VOLUMEDOWN Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert len(seen) == 3
|
||||
assert all(event.delta < 0 for event in seen)
|
||||
|
||||
|
||||
async def test_mute_toggles_and_restores(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
sim.player.set_volume(37)
|
||||
await sim.bus.drain()
|
||||
|
||||
await station.send("0 00 KEY_MUTE Hauppauge")
|
||||
await sim.bus.drain()
|
||||
assert sim.player.volume == 0
|
||||
|
||||
await station.send("0 00 KEY_MUTE Hauppauge")
|
||||
await sim.bus.drain()
|
||||
assert sim.player.volume == 37
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def test_a_digit_with_no_assignment_does_nothing(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe(PlayAlbumRequested, seen.append)
|
||||
sim.bus.subscribe(PlaySeriesLatestRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 BTN_5 Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
async def test_an_album_digit_plays_from_the_beginning(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
|
||||
sim.app.config.remote = {"3": RemoteSlotConfig(target_kind="album", target=album.id)}
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 BTN_3 Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert sim.player.playlist is not None
|
||||
assert sim.player.playlist.album_id == album.id
|
||||
assert sim.player.track_index == 0
|
||||
|
||||
|
||||
async def test_a_series_digit_plays_the_latest_episode(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
sim.app.config.remote = {"7": RemoteSlotConfig(target_kind="series", target="Wissen macht Ah")}
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("0 00 BTN_7 Hauppauge")
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert sim.player.playlist is not None
|
||||
played = sim.app.library.get(sim.player.playlist.album_id)
|
||||
assert played is not None
|
||||
assert played.title == "Neu"
|
||||
|
||||
|
||||
async def test_a_dropped_link_reconnects(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[ConnectionChanged] = []
|
||||
sim.bus.subscribe(ConnectionChanged, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port, reconnect_interval=10.0))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await sim.bus.drain()
|
||||
assert station.connection_count == 1
|
||||
|
||||
station.drop()
|
||||
await asyncio.sleep(0.05) # let the client notice EOF
|
||||
await sim.bus.drain()
|
||||
|
||||
assert isinstance(sim.clock, FakeClock)
|
||||
await sim.clock.advance(10.0)
|
||||
await station.wait_connected()
|
||||
await sim.bus.drain()
|
||||
|
||||
assert station.connection_count == 2
|
||||
assert [event.connected for event in seen] == [True, False, True]
|
||||
finally:
|
||||
task.cancel()
|
||||
@@ -18,7 +18,7 @@ from musicmouse.effects import (
|
||||
EffectStaticConfig,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import Event, LedEffectChanged, VolumeChanged
|
||||
from musicmouse.events import Event, LedEffectChanged, PlaySeriesLatestRequested, VolumeChanged
|
||||
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
|
||||
from musicmouse.simulator.driver import SimulatorDriver
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
@@ -367,3 +367,24 @@ async def test_reconnecting_restores_the_leds(
|
||||
# equivalent-but-not-equal colours.
|
||||
assert restored.as_bytes() == before.as_bytes()
|
||||
assert sim.transport.brightness() == pytest.approx(0.5)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- podcast shows
|
||||
|
||||
|
||||
async def test_playing_a_series_starts_its_newest_episode(sim: Simulation) -> None:
|
||||
await sim.bus.emit_and_wait(PlaySeriesLatestRequested(series="Wissen macht Ah", source="web"))
|
||||
|
||||
playlist = sim.player.playlist
|
||||
assert playlist is not None
|
||||
played = sim.app.library.get(playlist.album_id)
|
||||
assert played is not None
|
||||
assert played.title == "Neu"
|
||||
assert sim.player.is_playing
|
||||
|
||||
|
||||
async def test_playing_an_unknown_series_does_nothing(sim: Simulation) -> None:
|
||||
await sim.bus.emit_and_wait(PlaySeriesLatestRequested(series="no such show", source="web"))
|
||||
|
||||
assert sim.player.playlist is None
|
||||
assert not sim.player.is_playing
|
||||
|
||||
155
python-backend/tests/test_remote_api.py
Normal file
155
python-backend/tests/test_remote_api.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""The IR remote's number-key mapping, over the REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
from musicmouse.config import WebConfig, load_config
|
||||
from musicmouse.services.web.service import build_app
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from tests.conftest import VALID_CONFIG, write_config
|
||||
|
||||
#: A config file with comments, to prove saving does not flatten them.
|
||||
COMMENTED = """\
|
||||
# The MusicMouse config.
|
||||
general:
|
||||
library:
|
||||
root: music
|
||||
cache: .cache
|
||||
serial_port: "/dev/ttyUSB0"
|
||||
alsa_device: simulate
|
||||
min_volume: 0
|
||||
max_volume: 60
|
||||
initial_volume: 40
|
||||
|
||||
figures:
|
||||
fuchs:
|
||||
id: "04a1b2c3d4"
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||
eule:
|
||||
id: "04b2c3d4e5"
|
||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
simulation = await build_simulation(config)
|
||||
try:
|
||||
yield simulation
|
||||
finally:
|
||||
await simulation.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]:
|
||||
path = config_dir / "config.yml"
|
||||
path.write_text(COMMENTED, encoding="utf-8")
|
||||
api, hub = build_app(sim.app, WebConfig(), path)
|
||||
hub.start()
|
||||
transport = httpx2.ASGITransport(app=api)
|
||||
try:
|
||||
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
|
||||
yield http
|
||||
finally:
|
||||
hub.stop()
|
||||
await api.state.ha_client.aclose()
|
||||
|
||||
|
||||
async def album_id(sim: Simulation, title: str) -> str:
|
||||
return next(a.id for a in sim.app.library.albums if a.title == title)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------ lirc
|
||||
|
||||
|
||||
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
|
||||
assert (await client.get("/api/lirc")).status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- reading
|
||||
|
||||
|
||||
async def test_the_mapping_is_empty_by_default(client: httpx2.AsyncClient) -> None:
|
||||
body = (await client.get("/api/remote/mapping")).json()
|
||||
assert body["slots"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- writing
|
||||
|
||||
|
||||
async def test_saving_an_album_slot_round_trips(
|
||||
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
|
||||
) -> None:
|
||||
target = await album_id(sim, "Kinderparty Lieder")
|
||||
response = await client.put(
|
||||
"/api/remote/mapping", json={"slots": {"3": {"target_kind": "album", "target": target}}}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
slots = response.json()["slots"]
|
||||
assert slots == [
|
||||
{"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target}
|
||||
]
|
||||
|
||||
text = (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||
assert "# The MusicMouse config." in text # comments survive
|
||||
reloaded = load_config(config_dir / "config.yml")
|
||||
assert reloaded.remote["3"].target == target
|
||||
|
||||
|
||||
async def test_saving_a_series_slot_resolves_to_the_latest_episode(
|
||||
client: httpx2.AsyncClient,
|
||||
) -> None:
|
||||
response = await client.put(
|
||||
"/api/remote/mapping",
|
||||
json={"slots": {"7": {"target_kind": "series", "target": "Wissen macht Ah"}}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
slot = response.json()["slots"][0]
|
||||
assert slot["target_kind"] == "series"
|
||||
assert slot["target"] == "Wissen macht Ah"
|
||||
assert slot["resolved_album_id"] is not None
|
||||
|
||||
|
||||
async def test_saving_clears_digits_left_out(
|
||||
client: httpx2.AsyncClient, sim: Simulation
|
||||
) -> None:
|
||||
target = await album_id(sim, "Kinderparty Lieder")
|
||||
await client.put(
|
||||
"/api/remote/mapping", json={"slots": {"1": {"target_kind": "album", "target": target}}}
|
||||
)
|
||||
response = await client.put("/api/remote/mapping", json={"slots": {}})
|
||||
assert response.json()["slots"] == []
|
||||
|
||||
|
||||
async def test_an_unresolvable_album_target_is_rejected(client: httpx2.AsyncClient) -> None:
|
||||
response = await client.put(
|
||||
"/api/remote/mapping",
|
||||
json={"slots": {"1": {"target_kind": "album", "target": "no-such-album"}}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "1" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_an_unresolvable_series_target_is_rejected(client: httpx2.AsyncClient) -> None:
|
||||
response = await client.put(
|
||||
"/api/remote/mapping",
|
||||
json={"slots": {"2": {"target_kind": "series", "target": "no such show"}}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_a_rejected_save_leaves_the_file_alone(
|
||||
client: httpx2.AsyncClient, config_dir: Path
|
||||
) -> None:
|
||||
before = (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||
await client.put(
|
||||
"/api/remote/mapping",
|
||||
json={"slots": {"1": {"target_kind": "album", "target": "no-such-album"}}},
|
||||
)
|
||||
assert (config_dir / "config.yml").read_text(encoding="utf-8") == before
|
||||
@@ -150,7 +150,7 @@ async def test_state_starts_idle(client: Client) -> None:
|
||||
assert state["playing"] is False
|
||||
assert state["album_id"] is None
|
||||
assert state["active_figure"] is None
|
||||
assert state["connected"] == {"firmware": True, "mqtt": False}
|
||||
assert state["connected"] == {"firmware": True, "mqtt": False, "lirc": False}
|
||||
|
||||
|
||||
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:
|
||||
|
||||
Reference in New Issue
Block a user