Implements the room-control page from the design mockup: a scenes row above cards for shutters, color lamps, and brightness-only lamps, all driven by a new `general.ha` config section (server URL, token, ordered device/scene lists). The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...) rather than the browser calling Home Assistant directly, so the long-lived token never leaves the LAN device and Home Assistant's own CORS settings don't need to know about musicmouse at all. Card kind (shutter/color/brightness-only) is inferred at runtime from what Home Assistant reports about each entity, not configured explicitly. Also stops tracking python-backend/config.yml, which had drifted into the repo despite its own header saying it shouldn't be - it now carries real credentials locally and needs to stay untracked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
529 lines
19 KiB
Python
529 lines
19 KiB
Python
"""The web front-end, driven against the simulator.
|
|
|
|
Everything below the HTTP layer is production code: the same bus, the same reactions,
|
|
the same player interface - only the serial link and VLC are fake.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import httpx2
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
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
|
|
from tests.websocket_harness import WebSocketSession, websocket_connect
|
|
|
|
TRACK_SECONDS = 10.0
|
|
|
|
#: Shorthand: every test takes the same client type.
|
|
type Client = httpx2.AsyncClient
|
|
|
|
|
|
@pytest.fixture
|
|
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
|
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
|
|
try:
|
|
yield simulation
|
|
finally:
|
|
await simulation.aclose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def api(sim: Simulation, config_dir: Path) -> AsyncIterator[FastAPI]:
|
|
"""The real ASGI app, on the test's own event loop."""
|
|
application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml")
|
|
hub.start()
|
|
try:
|
|
yield application
|
|
finally:
|
|
hub.stop()
|
|
await application.state.ha_client.aclose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(api: FastAPI) -> AsyncIterator[httpx2.AsyncClient]:
|
|
transport = httpx2.ASGITransport(app=api)
|
|
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
|
|
yield http
|
|
|
|
|
|
async def album_by_title(client: httpx2.AsyncClient, title: str) -> dict:
|
|
albums = (await client.get("/api/library")).json()["albums"]
|
|
return next(album for album in albums if album["title"] == title)
|
|
|
|
|
|
# ------------------------------------------------------------------------- library
|
|
|
|
|
|
async def test_library_lists_every_album_with_its_tracks(client: Client) -> None:
|
|
body = (await client.get("/api/library")).json()
|
|
titles = {album["title"] for album in body["albums"]}
|
|
assert titles == {
|
|
"Fuchs",
|
|
"Eule",
|
|
"Kinderparty Lieder",
|
|
"Conni in den Bergen",
|
|
"Alt",
|
|
"Neu",
|
|
}
|
|
|
|
album = await album_by_title(client, "Conni in den Bergen")
|
|
assert album["kind"] == "book"
|
|
assert album["category"] == "Conni"
|
|
assert len(album["colors"]) == 3
|
|
assert [track["title"] for track in album["tracks"]] == ["Teil 0", "Teil 1"]
|
|
|
|
|
|
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
|
|
album = await album_by_title(client, "Fuchs")
|
|
assert album["has_cover"] is False
|
|
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404
|
|
|
|
|
|
async def test_unanalyzed_tracks_report_no_analysis(client: Client) -> None:
|
|
album = await album_by_title(client, "Eule")
|
|
assert all(track["analysis"] is None for track in album["tracks"])
|
|
assert (await client.get(f"/api/tracks/{album['id']}/0/analysis")).status_code == 404
|
|
|
|
|
|
# -------------------------------------------------------------------------- state
|
|
|
|
|
|
async def test_state_starts_idle(client: Client) -> None:
|
|
state = (await client.get("/api/state")).json()
|
|
assert state["playing"] is False
|
|
assert state["album_id"] is None
|
|
assert state["active_figure"] is None
|
|
assert state["connected"] == {"firmware": True, "mqtt": False}
|
|
|
|
|
|
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:
|
|
album = await album_by_title(client, "Kinderparty Lieder")
|
|
response = await client.post("/api/play", json={"album_id": album["id"]})
|
|
assert response.status_code == 204
|
|
await sim.bus.drain()
|
|
|
|
assert sim.player.is_playing
|
|
assert sim.player.playlist is not None
|
|
assert sim.player.playlist.album_id == album["id"]
|
|
assert (await client.get("/api/state")).json()["album_title"] == "Kinderparty Lieder"
|
|
|
|
|
|
async def test_track_titles_come_from_the_tags_not_the_filename(
|
|
client: Client, sim: Simulation
|
|
) -> None:
|
|
"""``01 - So ein schoener Tag.mp3`` is a filename, not a title."""
|
|
album = await album_by_title(client, "Fuchs")
|
|
await client.post("/api/play", json={"album_id": album["id"]})
|
|
await sim.bus.drain()
|
|
|
|
state = (await client.get("/api/state")).json()
|
|
assert state["track_title"] == "Track 0"
|
|
assert state["duration"] > 0
|
|
|
|
|
|
async def test_play_can_start_at_a_track(client: Client, sim: Simulation) -> None:
|
|
album = await album_by_title(client, "Fuchs")
|
|
await client.post("/api/play", json={"album_id": album["id"], "track_index": 2})
|
|
await sim.bus.drain()
|
|
|
|
assert sim.player.track_index == 2
|
|
assert (await client.get("/api/state")).json()["track_index"] == 2
|
|
|
|
|
|
async def test_playing_a_figure_album_reuses_the_figure_playlist(
|
|
client: Client, sim: Simulation
|
|
) -> None:
|
|
"""Identity matters: ``play_figure`` resumes on an ``is`` check."""
|
|
album = await album_by_title(client, "Fuchs")
|
|
await client.post("/api/play", json={"album_id": album["id"]})
|
|
await sim.bus.drain()
|
|
|
|
assert sim.player.playlist is sim.app.playlists["fuchs"]
|
|
|
|
|
|
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
|
response = await client.post("/api/play", json={"album_id": "nope"})
|
|
assert response.status_code == 404
|
|
|
|
|
|
async def test_transport_commands_reach_the_player(client: Client, sim: Simulation) -> None:
|
|
album = await album_by_title(client, "Fuchs")
|
|
await client.post("/api/play", json={"album_id": album["id"]})
|
|
await sim.bus.drain()
|
|
|
|
await client.post("/api/next")
|
|
await sim.bus.drain()
|
|
assert sim.player.track_index == 1
|
|
|
|
await client.post("/api/previous")
|
|
await sim.bus.drain()
|
|
assert sim.player.track_index == 0
|
|
|
|
await client.post("/api/pause")
|
|
await sim.bus.drain()
|
|
assert not sim.player.is_playing
|
|
|
|
await client.post("/api/resume")
|
|
await sim.bus.drain()
|
|
assert sim.player.is_playing
|
|
|
|
|
|
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
|
|
album = await album_by_title(client, "Fuchs")
|
|
await client.post("/api/play", json={"album_id": album["id"]})
|
|
await sim.bus.drain()
|
|
|
|
response = await client.post("/api/seek", json={"position": 4.0})
|
|
assert response.status_code == 204
|
|
await sim.bus.drain()
|
|
assert sim.player.position == pytest.approx(4.0, abs=0.1)
|
|
|
|
|
|
async def test_seeking_backwards_is_rejected(client: Client) -> None:
|
|
assert (await client.post("/api/seek", json={"position": -1})).status_code == 422
|
|
|
|
|
|
# ------------------------------------------------------------------------- volume
|
|
|
|
|
|
async def test_full_volume_means_the_configured_ceiling(
|
|
client: Client, sim: Simulation
|
|
) -> None:
|
|
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
|
|
response = await client.post("/api/volume", json={"percent": 100})
|
|
assert response.status_code == 204
|
|
await sim.bus.drain()
|
|
|
|
assert sim.player.volume == 60
|
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
|
|
|
|
|
async def test_volume_scales_across_the_allowed_range(
|
|
client: Client, sim: Simulation
|
|
) -> None:
|
|
await client.post("/api/volume", json={"percent": 50})
|
|
await sim.bus.drain()
|
|
assert sim.player.volume == 30
|
|
assert (await client.get("/api/state")).json()["volume"] == 50
|
|
|
|
|
|
async def test_volume_steps_are_relative_to_the_percentage(
|
|
client: Client, sim: Simulation
|
|
) -> None:
|
|
await client.post("/api/volume", json={"percent": 50})
|
|
await sim.bus.drain()
|
|
await client.post("/api/volume", json={"delta_percent": 10})
|
|
await sim.bus.drain()
|
|
|
|
assert (await client.get("/api/state")).json()["volume"] == 60
|
|
assert sim.player.volume == 36
|
|
|
|
|
|
async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None:
|
|
await client.post("/api/volume", json={"percent": 95})
|
|
await sim.bus.drain()
|
|
await client.post("/api/volume", json={"delta_percent": 20})
|
|
await sim.bus.drain()
|
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
|
|
|
|
|
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
|
|
state = (await client.get("/api/state")).json()
|
|
assert "volume_min" not in state
|
|
assert "volume_max" not in state
|
|
|
|
|
|
async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
|
|
assert (await client.post("/api/volume", json={})).status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------- websocket
|
|
|
|
|
|
async def last_state(socket: WebSocketSession) -> dict:
|
|
"""Drain every currently queued frame and return the most recent state payload.
|
|
|
|
A single backend action can trigger more than one broadcast (e.g. a reaction to
|
|
``PlaybackChanged`` announcing its own state change); callers only care about
|
|
where things ended up, not how many frames it took to get there. Call only after
|
|
the bus has been drained, so every frame the action produced is already queued.
|
|
"""
|
|
state = None
|
|
while not socket.from_app.empty():
|
|
message = await socket.next_json()
|
|
if message["type"] == "state":
|
|
state = message["state"]
|
|
assert state is not None
|
|
return state
|
|
|
|
|
|
async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None:
|
|
"""State events only fire on change, so a tab that connects mid-track needs this."""
|
|
async with websocket_connect(api, "/api/ws") as socket:
|
|
message = await socket.next_json()
|
|
assert message["type"] == "state"
|
|
assert message["state"]["playing"] is False
|
|
|
|
|
|
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
|
|
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
|
|
api, "/api/ws"
|
|
) as second:
|
|
await first.next_json()
|
|
await second.next_json()
|
|
|
|
await sim.driver.place("fuchs")
|
|
await sim.bus.drain()
|
|
|
|
for socket in (first, second):
|
|
message = await socket.next_json()
|
|
assert message["type"] == "state"
|
|
assert message["state"]["active_figure"] == "fuchs"
|
|
|
|
|
|
async def test_switching_albums_while_playing_reaches_every_client(
|
|
api: FastAPI, client: Client, sim: Simulation
|
|
) -> None:
|
|
"""Regression: playing album B from track 0 while album A was already playing used
|
|
to never broadcast, leaving PlayView stuck on album A until a reload."""
|
|
first = await album_by_title(client, "Fuchs")
|
|
second = await album_by_title(client, "Eule")
|
|
|
|
async with websocket_connect(api, "/api/ws") as socket:
|
|
await socket.next_json()
|
|
|
|
await client.post("/api/play", json={"album_id": first["id"]})
|
|
await sim.bus.drain()
|
|
assert (await last_state(socket))["album_title"] == "Fuchs"
|
|
|
|
await client.post("/api/play", json={"album_id": second["id"]})
|
|
await sim.bus.drain()
|
|
assert (await last_state(socket))["album_title"] == "Eule"
|
|
|
|
|
|
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
|
api: FastAPI, client: Client, sim: Simulation
|
|
) -> None:
|
|
async with websocket_connect(api, "/api/ws") as socket:
|
|
await socket.next_json()
|
|
|
|
response = await client.post("/api/library/refresh")
|
|
assert response.status_code == 202
|
|
|
|
message = await socket.next_json(timeout=5.0)
|
|
assert message["type"] == "library"
|
|
assert set(sim.app.playlists) == {"fuchs", "eule"}
|
|
|
|
|
|
# ------------------------------------------------------------------------ figures
|
|
|
|
|
|
async def test_the_web_ui_follows_a_figure_placed_on_the_reader(
|
|
client: Client, sim: Simulation
|
|
) -> None:
|
|
await sim.driver.place("fuchs")
|
|
await sim.bus.drain()
|
|
|
|
state = (await client.get("/api/state")).json()
|
|
assert state["active_figure"] == "fuchs"
|
|
assert state["playing"] is True
|
|
assert state["album_title"] == "Fuchs"
|
|
|
|
|
|
# ------------------------------------------------------------------------------- ha
|
|
|
|
HA_URL = "http://homeassistant.local:8123"
|
|
HA_TOKEN = "abc123"
|
|
|
|
VALID_CONFIG_WITH_HA: dict = {
|
|
**VALID_CONFIG,
|
|
"general": {
|
|
**VALID_CONFIG["general"],
|
|
"ha": {
|
|
"url": HA_URL,
|
|
"token": HA_TOKEN,
|
|
"devices": [
|
|
{"entity_id": "cover.rollo", "name": "Rollo"},
|
|
{"entity_id": "light.deckenlampe"},
|
|
],
|
|
"scenes": [{"entity_id": "scene.lesen", "name": "Lesen"}],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class FakeHomeAssistant:
|
|
"""Records every request the proxy makes and answers a couple of fixed routes."""
|
|
|
|
def __init__(self) -> None:
|
|
self.requests: list[httpx2.Request] = []
|
|
self.unreachable = False
|
|
|
|
def handler(self, request: httpx2.Request) -> httpx2.Response:
|
|
self.requests.append(request)
|
|
if self.unreachable:
|
|
raise httpx2.ConnectError("mock: connection refused")
|
|
if request.url.path == "/api/states/light.deckenlampe":
|
|
return httpx2.Response(200, json={"entity_id": "light.deckenlampe", "state": "on"})
|
|
if request.url.path == "/api/states/light.missing":
|
|
return httpx2.Response(404, json={"message": "Entity not found"})
|
|
if request.url.path == "/api/services/light/turn_on":
|
|
return httpx2.Response(200, json=[{"entity_id": "light.deckenlampe", "state": "on"}])
|
|
return httpx2.Response(404, json={"message": "unhandled in test"})
|
|
|
|
|
|
@pytest.fixture
|
|
async def fake_ha() -> FakeHomeAssistant:
|
|
return FakeHomeAssistant()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client_with_ha(
|
|
config_dir: Path, fake_ha: FakeHomeAssistant
|
|
) -> AsyncIterator[httpx2.AsyncClient]:
|
|
"""A second client, built from a config with an ``ha:`` section and an ``ha_client``
|
|
pointed at ``fake_ha`` instead of the network, without touching the shared
|
|
``sim``/``api``/``client`` fixtures every other test in this file uses."""
|
|
config = load_config(write_config(config_dir, VALID_CONFIG_WITH_HA))
|
|
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
|
|
try:
|
|
ha_client = httpx2.AsyncClient(transport=httpx2.MockTransport(fake_ha.handler))
|
|
application, hub = build_app(
|
|
simulation.app, WebConfig(), config_dir / "config.yml", ha_client=ha_client
|
|
)
|
|
hub.start()
|
|
try:
|
|
transport = httpx2.ASGITransport(app=application)
|
|
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
|
|
yield http
|
|
finally:
|
|
hub.stop()
|
|
await ha_client.aclose()
|
|
finally:
|
|
await simulation.aclose()
|
|
|
|
|
|
async def test_ha_is_a_404_when_not_configured(client: Client) -> None:
|
|
assert (await client.get("/api/ha")).status_code == 404
|
|
assert (await client.get("/api/ha/states/light.x")).status_code == 404
|
|
assert (await client.post("/api/ha/services/light/turn_on", json={})).status_code == 404
|
|
|
|
|
|
async def test_ha_config_returns_ordered_lists_without_the_token(client_with_ha: Client) -> None:
|
|
body = (await client_with_ha.get("/api/ha")).json()
|
|
assert "url" not in body
|
|
assert "token" not in body
|
|
assert body["devices"] == [
|
|
{"entity_id": "cover.rollo", "name": "Rollo"},
|
|
{"entity_id": "light.deckenlampe", "name": None},
|
|
]
|
|
assert body["scenes"] == [{"entity_id": "scene.lesen", "name": "Lesen"}]
|
|
|
|
|
|
async def test_ha_states_proxies_to_home_assistant_with_the_token_attached(
|
|
client_with_ha: Client, fake_ha: FakeHomeAssistant
|
|
) -> None:
|
|
response = await client_with_ha.get("/api/ha/states/light.deckenlampe")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"entity_id": "light.deckenlampe", "state": "on"}
|
|
|
|
assert len(fake_ha.requests) == 1
|
|
upstream = fake_ha.requests[0]
|
|
assert str(upstream.url) == f"{HA_URL}/api/states/light.deckenlampe"
|
|
assert upstream.headers["authorization"] == f"Bearer {HA_TOKEN}"
|
|
|
|
|
|
async def test_ha_states_passes_through_a_404_from_home_assistant(client_with_ha: Client) -> None:
|
|
response = await client_with_ha.get("/api/ha/states/light.missing")
|
|
assert response.status_code == 404
|
|
|
|
|
|
async def test_ha_services_proxies_the_body_with_the_token_attached(
|
|
client_with_ha: Client, fake_ha: FakeHomeAssistant
|
|
) -> None:
|
|
response = await client_with_ha.post(
|
|
"/api/ha/services/light/turn_on",
|
|
json={"entity_id": "light.deckenlampe", "brightness_pct": 80},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
upstream = fake_ha.requests[0]
|
|
assert str(upstream.url) == f"{HA_URL}/api/services/light/turn_on"
|
|
assert upstream.headers["authorization"] == f"Bearer {HA_TOKEN}"
|
|
assert json.loads(upstream.content) == {"entity_id": "light.deckenlampe", "brightness_pct": 80}
|
|
|
|
|
|
async def test_ha_proxy_reports_an_unreachable_home_assistant_as_a_502(
|
|
client_with_ha: Client, fake_ha: FakeHomeAssistant
|
|
) -> None:
|
|
fake_ha.unreachable = True
|
|
response = await client_with_ha.get("/api/ha/states/light.deckenlampe")
|
|
assert response.status_code == 502
|
|
|
|
|
|
# ------------------------------------------------- through a real uvicorn, not just ASGI
|
|
|
|
|
|
async def test_websockets_survive_a_real_server(sim: Simulation, config_dir: Path) -> None:
|
|
"""Boot the actual service and open an actual websocket.
|
|
|
|
Everything above drives the ASGI app directly, which cannot see whether uvicorn is
|
|
able to answer an upgrade at all - and it is not, unless a websocket implementation
|
|
is installed alongside it. That gap answered ``/api/ws`` with a 404 in production
|
|
while every other test passed.
|
|
"""
|
|
import json
|
|
import socket
|
|
|
|
import websockets
|
|
|
|
from musicmouse.services.web.service import WebService
|
|
|
|
with socket.socket() as probe:
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = int(probe.getsockname()[1])
|
|
|
|
service = WebService(
|
|
sim.app,
|
|
WebConfig(host="127.0.0.1", port=port),
|
|
config_dir / "config.yml",
|
|
)
|
|
server = asyncio.create_task(service.run(), name="web-service")
|
|
try:
|
|
await _wait_for_port(port)
|
|
async with websockets.connect(f"ws://127.0.0.1:{port}/api/ws") as client:
|
|
snapshot = json.loads(await asyncio.wait_for(client.recv(), 5.0))
|
|
assert snapshot["type"] == "state"
|
|
assert snapshot["state"]["playing"] is False
|
|
finally:
|
|
server.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await server
|
|
|
|
|
|
async def _wait_for_port(port: int, timeout: float = 5.0) -> None:
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
try:
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
except OSError:
|
|
await asyncio.sleep(0.05)
|
|
continue
|
|
del reader
|
|
writer.close()
|
|
with contextlib.suppress(Exception):
|
|
await writer.wait_closed()
|
|
return
|
|
raise AssertionError(f"nothing listening on {port} after {timeout}s")
|