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