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

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