Add "Mein Zimmer" room-control page (Home Assistant, proxied through the backend)
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>
This commit is contained in:
1
python-backend/.gitignore
vendored
Normal file
1
python-backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
config.yml
|
||||
@@ -1,94 +0,0 @@
|
||||
# Example config for the MusicMouse backend.
|
||||
#
|
||||
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||
#
|
||||
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
||||
# reported at once, so a typo fails at startup with the path to the offending line.
|
||||
# Keep the real config (with credentials) off the repo - on the device only.
|
||||
|
||||
general:
|
||||
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||
# settings, because each one has its own quirks the code already knows about:
|
||||
#
|
||||
# <root>/Figuren/<figure name>/ one folder per figurine
|
||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
#
|
||||
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||
# of the files' tags. Relative paths resolve against this file's directory.
|
||||
library:
|
||||
root: /home/martin/Music
|
||||
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||
# is expensive to recompute.
|
||||
cache: .musicmouse-cache
|
||||
|
||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||
serial_port: "simulate"
|
||||
baudrate: 115200
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||
# commandeer. Startup says so every boot.
|
||||
#
|
||||
# Both of these are required rather than optional on purpose: running blind or silent
|
||||
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||
# into something that looks like it is working.
|
||||
alsa_device: "default"
|
||||
|
||||
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||
min_volume: 0
|
||||
max_volume: 80
|
||||
initial_volume: 40
|
||||
volume_increment: 5 # per rotary-encoder click
|
||||
|
||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||
button_leds_brightness: 0.5
|
||||
|
||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||
# a half-finished .tmp - is ignored.
|
||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||
|
||||
# The web front-end. Omit the whole section to run without it.
|
||||
#
|
||||
# There is no authentication: this is a device on a home network. The settings panel
|
||||
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||
web:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||
static_dir: ../web/dist
|
||||
|
||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||
#mqtt:
|
||||
# server: "homeassistant.local"
|
||||
# port: 1883
|
||||
# user: "musicmouse"
|
||||
# password: "REPLACE_WITH_MQTT_PASSWORD"
|
||||
# base_topic: "musicmouse"
|
||||
# discovery_prefix: "homeassistant"
|
||||
# device_id: "musicmouse"
|
||||
# device_name: "Music Mouse"
|
||||
# reconnect_interval: 10.0
|
||||
|
||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||
figures:
|
||||
fuchs:
|
||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||
id: "04a1b2c3d4"
|
||||
# Exactly four colours: primary, secondary, background, accent.
|
||||
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||
|
||||
eule:
|
||||
id: "04b2c3d4e5"
|
||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||
@@ -80,6 +80,32 @@ general:
|
||||
device_name: "Music Mouse"
|
||||
reconnect_interval: 10.0
|
||||
|
||||
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
|
||||
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
|
||||
# entities, not the other way round. The backend proxies every call to Home
|
||||
# Assistant's REST API with this token attached; the browser never sees it, only
|
||||
# entity ids and display names. Home Assistant's own CORS settings do not need to
|
||||
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
|
||||
ha:
|
||||
url: "http://homeassistant.local:8123"
|
||||
# A long-lived access token, created under the HA user's own profile page.
|
||||
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
|
||||
# Cards on the room page, in this order. "name" is optional; falls back to the
|
||||
# entity id if omitted.
|
||||
devices:
|
||||
- entity_id: cover.kinderzimmer_rollo
|
||||
name: "Rollo"
|
||||
- entity_id: light.kinderzimmer_hue_beyond_links
|
||||
name: "Hue Beyond links"
|
||||
- entity_id: light.kinderzimmer_deckenlampe
|
||||
name: "Deckenlampe"
|
||||
# Scene pill row above the cards, in this order.
|
||||
scenes:
|
||||
- entity_id: scene.kinderzimmer_lesen
|
||||
name: "Lesen"
|
||||
- entity_id: scene.kinderzimmer_gute_nacht
|
||||
name: "Gute Nacht"
|
||||
|
||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||
figures:
|
||||
fuchs:
|
||||
|
||||
@@ -36,6 +36,8 @@ __all__ = [
|
||||
"FigureColors",
|
||||
"FigureConfig",
|
||||
"GeneralConfig",
|
||||
"HaConfig",
|
||||
"HaDeviceConfig",
|
||||
"LibraryConfig",
|
||||
"MqttConfig",
|
||||
"WebConfig",
|
||||
@@ -173,6 +175,36 @@ class WebConfig(_Strict):
|
||||
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
|
||||
class HaDeviceConfig(_Strict):
|
||||
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
|
||||
|
||||
entity_id: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class HaConfig(_Strict):
|
||||
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
||||
|
||||
The backend never calls Home Assistant itself - it only hands the browser the
|
||||
server URL, the token, and these two ordered lists. Control happens directly from
|
||||
the browser to Home Assistant's own REST API, so this token grants full HA control
|
||||
to anything on the LAN that can reach musicmouse. See config.yml.example.
|
||||
"""
|
||||
|
||||
url: str
|
||||
token: str
|
||||
#: Order is preserved and drives the device card grid on the room page.
|
||||
devices: list[HaDeviceConfig] = Field(default_factory=list)
|
||||
#: Order is preserved and drives the scene pill row on the room page.
|
||||
scenes: list[HaDeviceConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_something_configured(self) -> Self:
|
||||
if not self.devices and not self.scenes:
|
||||
raise ValueError("configure at least one device or scene, or omit the ha section")
|
||||
return self
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
library: LibraryConfig
|
||||
|
||||
@@ -188,6 +220,7 @@ class GeneralConfig(_Strict):
|
||||
|
||||
mqtt: MqttConfig | None = None
|
||||
web: WebConfig | None = None
|
||||
ha: HaConfig | None = None
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
|
||||
@@ -13,11 +13,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import HaConfig
|
||||
from musicmouse.events import (
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
@@ -32,6 +35,8 @@ from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.schemas import (
|
||||
AlbumOut,
|
||||
BeatsOut,
|
||||
HaConfigOut,
|
||||
HaDeviceOut,
|
||||
LibraryOut,
|
||||
PlayerStateOut,
|
||||
PlayIn,
|
||||
@@ -52,7 +57,9 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["build_router"]
|
||||
|
||||
def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
||||
def build_router(
|
||||
app: App, hub: StateHub, config_path: Path, ha_client: httpx2.AsyncClient
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
def emit(intent: IntentEvent) -> Response:
|
||||
@@ -189,4 +196,52 @@ def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
||||
await hub.broadcast_state()
|
||||
return read_settings(general)
|
||||
|
||||
# --------------------------------------------------------------- room control
|
||||
#
|
||||
# The browser never sees the Home Assistant token: it stays server-side, attached
|
||||
# to every proxied request below. The browser only gets to know entity ids and
|
||||
# display names (get_ha_config) and can ask this backend to relay a states read or
|
||||
# a service call - the same shape of access the token itself grants, just without
|
||||
# ever leaving the LAN device. Entity ids are not restricted to the configured
|
||||
# list; that would only stop someone who already has enough access to open this
|
||||
# unauthenticated API from asking Home Assistant about a different entity, which
|
||||
# matches the rest of this API's "trusted LAN device" threat model.
|
||||
|
||||
def _require_ha() -> HaConfig:
|
||||
ha = app.config.general.ha
|
||||
if ha is None:
|
||||
raise HTTPException(status_code=404, detail="ha not configured")
|
||||
return ha
|
||||
|
||||
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
|
||||
try:
|
||||
upstream = await ha_client.request(
|
||||
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||
)
|
||||
except httpx2.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||
) from exc
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
media_type=upstream.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
@router.get("/ha")
|
||||
def get_ha_config() -> HaConfigOut:
|
||||
ha = _require_ha()
|
||||
return HaConfigOut(
|
||||
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
|
||||
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
|
||||
)
|
||||
|
||||
@router.get("/ha/states/{entity_id}")
|
||||
async def get_ha_state(entity_id: str) -> Response:
|
||||
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
|
||||
|
||||
@router.post("/ha/services/{domain}/{service}")
|
||||
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
|
||||
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
|
||||
|
||||
return router
|
||||
|
||||
@@ -16,6 +16,8 @@ from musicmouse.library.analysis import TrackAnalysis
|
||||
__all__ = [
|
||||
"AlbumOut",
|
||||
"BeatsOut",
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
"PlayIn",
|
||||
"PlayerStateOut",
|
||||
@@ -154,3 +156,17 @@ class SettingsIn(BaseModel):
|
||||
initial_volume: int = Field(ge=0, le=200)
|
||||
volume_increment: int = Field(ge=1, le=100)
|
||||
button_leds_brightness: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class HaDeviceOut(BaseModel):
|
||||
entity_id: str
|
||||
name: str | None
|
||||
|
||||
|
||||
class HaConfigOut(BaseModel):
|
||||
"""No ``url``/``token`` here on purpose - the browser talks to this backend, which
|
||||
proxies to Home Assistant with the token attached server-side. See
|
||||
``services/web/api.py``'s room-control section."""
|
||||
|
||||
devices: list[HaDeviceOut]
|
||||
scenes: list[HaDeviceOut]
|
||||
|
||||
@@ -13,6 +13,7 @@ import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -27,11 +28,23 @@ _log = logging.getLogger(__name__)
|
||||
__all__ = ["WebService", "build_app"]
|
||||
|
||||
|
||||
def build_app(app: App, config: WebConfig, config_path: Path) -> tuple[FastAPI, StateHub]:
|
||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly."""
|
||||
def build_app(
|
||||
app: App,
|
||||
config: WebConfig,
|
||||
config_path: Path,
|
||||
ha_client: httpx2.AsyncClient | None = None,
|
||||
) -> tuple[FastAPI, StateHub]:
|
||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
|
||||
|
||||
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
|
||||
calls through; tests inject one on a mock transport, production gets a real one
|
||||
that :class:`WebService` closes on shutdown.
|
||||
"""
|
||||
hub = StateHub(app)
|
||||
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
|
||||
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
api.include_router(build_router(app, hub, config_path))
|
||||
api.state.ha_client = ha_client
|
||||
api.include_router(build_router(app, hub, config_path, ha_client))
|
||||
|
||||
if config.static_dir is not None:
|
||||
if config.static_dir.is_dir():
|
||||
@@ -86,3 +99,4 @@ class WebService:
|
||||
position.cancel()
|
||||
serving.cancel()
|
||||
self.hub.stop()
|
||||
await self.api.state.ha_client.aclose()
|
||||
|
||||
@@ -6,6 +6,10 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiomqtt>=2.0",
|
||||
"fastapi>=0.115",
|
||||
# Also what tests drive the ASGI app with - plain httpx is deprecated in favour of
|
||||
# this for exactly that. Runtime uses it to proxy the room page's Home Assistant
|
||||
# calls, so the long-lived token never has to leave the backend.
|
||||
"httpx2>=2.12",
|
||||
"mutagen>=1.47",
|
||||
"pillow>=10.4",
|
||||
"pydantic>=2.7",
|
||||
@@ -19,8 +23,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# httpx2 is what starlette.testclient wants now; plain httpx is deprecated there.
|
||||
dev = ["httpx2>=2.12", "mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
||||
dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
|
||||
|
||||
[project.scripts]
|
||||
musicmouse = "musicmouse.__main__:main"
|
||||
|
||||
@@ -52,6 +52,75 @@ def test_web_section_is_optional(config_dir: Path) -> None:
|
||||
assert config.general.web.static_dir == (config_dir / "dist").resolve()
|
||||
|
||||
|
||||
def test_ha_section_is_optional(config_dir: Path) -> None:
|
||||
assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None
|
||||
|
||||
|
||||
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||
data = _config(
|
||||
ha={
|
||||
"url": "http://homeassistant.local:8123",
|
||||
"token": "abc123",
|
||||
"devices": [{"entity_id": "light.a"}],
|
||||
"scenes": [{"entity_id": "scene.a"}],
|
||||
}
|
||||
)
|
||||
config = load_config(write_config(config_dir, data))
|
||||
assert config.general.ha is not None
|
||||
assert config.general.ha.devices[0].name is None
|
||||
assert config.general.ha.scenes[0].name is None
|
||||
|
||||
|
||||
def test_ha_devices_and_scenes_preserve_config_order(config_dir: Path) -> None:
|
||||
data = _config(
|
||||
ha={
|
||||
"url": "http://homeassistant.local:8123",
|
||||
"token": "abc123",
|
||||
"devices": [
|
||||
{"entity_id": "cover.rollo"},
|
||||
{"entity_id": "light.deckenlampe"},
|
||||
{"entity_id": "light.beyond_links"},
|
||||
],
|
||||
"scenes": [
|
||||
{"entity_id": "scene.aufwachen"},
|
||||
{"entity_id": "scene.lesen"},
|
||||
],
|
||||
}
|
||||
)
|
||||
config = load_config(write_config(config_dir, data))
|
||||
ha = config.general.ha
|
||||
assert ha is not None
|
||||
assert [d.entity_id for d in ha.devices] == [
|
||||
"cover.rollo",
|
||||
"light.deckenlampe",
|
||||
"light.beyond_links",
|
||||
]
|
||||
assert [s.entity_id for s in ha.scenes] == ["scene.aufwachen", "scene.lesen"]
|
||||
|
||||
|
||||
def test_ha_requires_devices_or_scenes(config_dir: Path) -> None:
|
||||
message = _error(
|
||||
config_dir,
|
||||
_config(ha={"url": "http://ha", "token": "abc", "devices": [], "scenes": []}),
|
||||
)
|
||||
assert "configure at least one device or scene" in message
|
||||
|
||||
|
||||
def test_ha_rejects_unknown_key_on_a_device(config_dir: Path) -> None:
|
||||
message = _error(
|
||||
config_dir,
|
||||
_config(
|
||||
ha={
|
||||
"url": "http://ha",
|
||||
"token": "abc",
|
||||
"devices": [{"entity_id": "light.a", "kind": "beyond"}],
|
||||
"scenes": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
assert "unknown option" in message
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- error paths
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
@@ -19,7 +20,7 @@ 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 websocket_connect
|
||||
from tests.websocket_harness import WebSocketSession, websocket_connect
|
||||
|
||||
TRACK_SECONDS = 10.0
|
||||
|
||||
@@ -38,7 +39,7 @@ async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]:
|
||||
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()
|
||||
@@ -46,6 +47,7 @@ def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]:
|
||||
yield application
|
||||
finally:
|
||||
hub.stop()
|
||||
await application.state.ha_client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -71,7 +73,8 @@ async def test_library_lists_every_album_with_its_tracks(client: Client) -> None
|
||||
"Eule",
|
||||
"Kinderparty Lieder",
|
||||
"Conni in den Bergen",
|
||||
"Wissen macht Ah",
|
||||
"Alt",
|
||||
"Neu",
|
||||
}
|
||||
|
||||
album = await album_by_title(client, "Conni in den Bergen")
|
||||
@@ -248,6 +251,23 @@ async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
|
||||
# ---------------------------------------------------------------------- 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:
|
||||
@@ -272,6 +292,26 @@ async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation
|
||||
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:
|
||||
@@ -301,6 +341,137 @@ async def test_the_web_ui_follows_a_figure_placed_on_the_reader(
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user