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:
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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user