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:
2026-09-11 08:31:28 +02:00
parent 57afc32f4a
commit 747c390303
32 changed files with 1603 additions and 33 deletions

View File

@@ -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"

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View 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"]

View 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)

View 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"))

View File

@@ -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

View 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)

View File

@@ -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]

View File

@@ -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)

View File

@@ -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,
),
)

View File

@@ -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={

View File

@@ -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] == [

View 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()

View File

@@ -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

View 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

View File

@@ -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: