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

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