diff --git a/python-backend/.gitignore b/python-backend/.gitignore new file mode 100644 index 0000000..1d3ed4c --- /dev/null +++ b/python-backend/.gitignore @@ -0,0 +1 @@ +config.yml diff --git a/python-backend/config.yml b/python-backend/config.yml deleted file mode 100644 index aff30e5..0000000 --- a/python-backend/config.yml +++ /dev/null @@ -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: - # - # /Figuren/
/ one folder per figurine - # /Musik/ - / albums, grouped by artist - # /Hörbücher/ - / audiobooks, grouped by character - # /Kinderpodcasts// 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"] diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index 5963eb0..b908f0c 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -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: diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index b15def8..10d2b1d 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -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) diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index 4a09eb7..5803e49 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -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 diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index ccab9fd..009374c 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -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] diff --git a/python-backend/musicmouse/services/web/service.py b/python-backend/musicmouse/services/web/service.py index 7f9ce06..e6d27a9 100644 --- a/python-backend/musicmouse/services/web/service.py +++ b/python-backend/musicmouse/services/web/service.py @@ -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() diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index ba45413..346a86d 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -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" diff --git a/python-backend/tests/test_config.py b/python-backend/tests/test_config.py index aa6965a..06274f5 100644 --- a/python-backend/tests/test_config.py +++ b/python-backend/tests/test_config.py @@ -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 diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index 6d44fcb..79b5130 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -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 diff --git a/web/public/dolphin-remote.png b/web/public/dolphin-remote.png new file mode 100644 index 0000000..a843225 Binary files /dev/null and b/web/public/dolphin-remote.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index eb20e5c..0adb0ef 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,7 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "./api/client"; -import type { Album } from "./api/types"; +import type { Album, HaConfig } from "./api/types"; import { AlbumModal } from "./components/AlbumModal"; import { AppHeader } from "./components/AppHeader"; import { BrowseView } from "./components/BrowseView"; @@ -18,19 +18,23 @@ import { HelpOverlay } from "./components/HelpOverlay"; import { ParentPanel } from "./components/ParentPanel"; import { PlayerBar } from "./components/PlayerBar"; import { PlayView } from "./components/PlayView"; +import { RoomView } from "./components/RoomView"; import { useGridColumns } from "./hooks/useGridColumns"; import { useLibrary } from "./hooks/useLibrary"; -import { usePlaybackClock } from "./hooks/usePlaybackClock"; import { usePlayerState } from "./hooks/usePlayerState"; import type { Action, UiState } from "./lib/keyboard"; import { handleKey, initialUiState } from "./lib/keyboard"; import { playPop } from "./lib/pop"; -import type { Filter, Results, SongHit } from "./lib/search"; -import { results as computeResults } from "./lib/search"; +import type { Group, Results, SongHit } from "./lib/search"; +import { groupOf, results as computeResults } from "./lib/search"; /** Volume when un-muting, matching the mockup. */ const UNMUTE_PERCENT = 60; +/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the + * position instead, the way scrubbing past an ad break usually works. */ +const PODCAST_SKIP_SECONDS = 30; + export function App() { const [ui, setUi] = useState(initialUiState); const library = useLibrary(); @@ -39,9 +43,15 @@ export function App() { const [parentMode, setParentMode] = useState( () => new URLSearchParams(location.search).get("parentMode") === "1", ); + // undefined: not yet resolved (hide the nav pill to avoid a flash). null: confirmed + // absent - the room page is a separate opt-in feature, off by default. + const [haConfig, setHaConfig] = useState(undefined); + + useEffect(() => { + void api.haConfig().then(setHaConfig); + }, []); const state = connection.state; - const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false); useEffect(() => { setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns })); @@ -53,10 +63,10 @@ export function App() { albums: library.albums, search: ui.search, mode: ui.mode, - filter: ui.filter, + group: ui.group, category: ui.category, }), - [library.albums, ui.search, ui.mode, ui.filter, ui.category], + [library.albums, ui.search, ui.mode, ui.group, ui.category], ); const byId = useMemo( @@ -110,27 +120,50 @@ export function App() { break; case "next": playPop(260); - void api.next(); + if (currentAlbum && groupOf(currentAlbum) === "podcasts") { + const target = Math.min( + state?.duration ?? 0, + (state?.position ?? 0) + PODCAST_SKIP_SECONDS, + ); + connection.optimistic({ position: target }); + void api.seek(target); + } else { + connection.optimistic({ position: 0 }); + void api.next(); + } break; case "previous": playPop(260); - void api.previous(); + if (currentAlbum && groupOf(currentAlbum) === "podcasts") { + const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS); + connection.optimistic({ position: target }); + void api.seek(target); + } else { + connection.optimistic({ position: 0 }); + void api.previous(); + } break; case "volume": setVolume((state?.volume ?? 0) + action.delta); break; - case "seek": + case "seek": { if (state?.duration) { - void api.seek(Math.max(0, Math.min(state.duration, position + action.delta))); + const target = Math.max( + 0, + Math.min(state.duration, (state?.position ?? 0) + action.delta), + ); + connection.optimistic({ position: target }); + void api.seek(target); } break; + } case "pop": playPop(action.freq); break; } } }, - [play, position, setVolume, state, toggle], + [connection, currentAlbum, play, setVolume, state, toggle], ); // Held in a ref so the listener is installed once rather than on every state change. @@ -153,15 +186,14 @@ export function App() { return () => window.removeEventListener("keydown", onKeyDown); }, []); - const onFilter = (filter: Filter) => { + const onEnterGroup = (group: Group, category: string | null) => { + playPop(category ? 440 : 380); + setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 })); + }; + + const onBackToRoot = () => { playPop(380); - setUi((previous) => ({ - ...previous, - filter, - selIndex: 0, - view: "browse", - category: null, - })); + setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 })); }; const onCategory = (key: string | null) => { @@ -170,12 +202,28 @@ export function App() { }; const onOpenAlbum = (album: Album, navIndex: number) => { + // A podcast episode is a single track behaving like an audiobook of one chapter - + // there's nothing a metadata popup would add, so it just starts playing. + if (groupOf(album) === "podcasts") { + setUi((previous) => ({ ...previous, selIndex: navIndex })); + play(album.id, 0); + return; + } playPop(420); setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex })); }; + const onOpenCurrentAlbum = () => { + if (!currentAlbum) return; + playPop(420); + setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id })); + }; + const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); - const onSeek = (target: number) => void api.seek(target); + const onSeek = (target: number) => { + connection.optimistic({ position: target }); + void api.seek(target); + }; const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT); return ( @@ -192,17 +240,29 @@ export function App() { flexDirection: "column", }} > - } /> + } + link={ + haConfig + ? { + label: "💡 Mein Zimmer", + onClick: () => setUi((previous) => ({ ...previous, view: "room" })), + } + : undefined + } + /> run([{ type: "next" }])} onPrevious={() => run([{ type: "previous" }])} @@ -222,6 +281,14 @@ export function App() { onVolume={setVolume} onMute={onMute} onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))} + onOpenAlbum={onOpenCurrentAlbum} + /> + )} + + {ui.view === "room" && haConfig && ( + setUi((previous) => ({ ...previous, view: "browse" }))} /> )} @@ -266,7 +333,6 @@ export function App() { run([{ type: "next" }])} onPrevious={() => run([{ type: "previous" }])} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9842672..76d4063 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,6 @@ /** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */ -import type { Album, PlayerState, Settings } from "./types"; +import type { Album, HaConfig, HaEntityState, PlayerState, Settings } from "./types"; async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`/api${path}`, { @@ -20,6 +20,33 @@ function post(path: string, body?: unknown): Promise { }); } +/** `null` means the room-control page isn't configured, not an error - unlike + * `request()`, a 404 here is expected and shouldn't throw. */ +async function fetchHaConfig(): Promise { + const response = await fetch("/api/ha"); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`); + return (await response.json()) as HaConfig; +} + +/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable + * right now" (the backend answers the latter with a 502) - the room page treats a + * device with no state the same way either way, rather than crashing on a poll. */ +async function fetchHaState(entityId: string): Promise { + const response = await fetch(`/api/ha/states/${entityId}`); + if (!response.ok) return null; + return (await response.json()) as HaEntityState; +} + +async function fetchHaStates(entityIds: string[]): Promise> { + const results = await Promise.all(entityIds.map(fetchHaState)); + const byId: Record = {}; + results.forEach((state, index) => { + if (state) byId[entityIds[index]!] = state; + }); + return byId; +} + export const api = { library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums), state: () => request("/state"), @@ -38,6 +65,11 @@ export const api = { settings: () => request("/settings"), saveSettings: (settings: Settings) => request("/settings", { method: "PUT", body: JSON.stringify(settings) }), + + haConfig: fetchHaConfig, + haStates: fetchHaStates, + haCallService: (domain: string, service: string, body: Record) => + post(`/ha/services/${domain}/${service}`, body), }; export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 71574a1..767a997 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -63,3 +63,23 @@ export type ServerMessage = | { type: "state"; state: PlayerState } | { type: "position"; position: number; duration: number } | { type: "library" }; + +export interface HaDevice { + entity_id: string; + name: string | null; +} + +/** No url/token here - the backend proxies Home Assistant calls and keeps the token to + * itself, so the browser only ever learns which entities exist. */ +export interface HaConfig { + devices: HaDevice[]; + scenes: HaDevice[]; +} + +/** A Home Assistant entity's `state`/`attributes`, relayed byte-for-byte through + * `GET /api/ha/states/{entity_id}`. */ +export interface HaEntityState { + entity_id: string; + state: string; + attributes: Record; +} diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx index ddf4915..920aaa6 100644 --- a/web/src/components/AppHeader.tsx +++ b/web/src/components/AppHeader.tsx @@ -1,11 +1,21 @@ import type { ReactNode } from "react"; interface Props { + title?: string; + mascot?: string; /** Shown next to the title when the mouse is reachable but the firmware is not. */ status?: ReactNode; + /** A pill linking to the other page - there's no router, so it's a click handler + * rather than an `href`. */ + link?: { label: string; onClick: () => void }; } -export function AppHeader({ status }: Props) { +export function AppHeader({ + title = "Musik Delphin", + mascot = "/dolphin-mascot.png", + status, + link, +}: Props) { return (
- + {link && ( + + )} +
- Musik Delphin + {title}
{status}
diff --git a/web/src/components/LightCard.tsx b/web/src/components/LightCard.tsx new file mode 100644 index 0000000..487a3db --- /dev/null +++ b/web/src/components/LightCard.tsx @@ -0,0 +1,199 @@ +/** A `light.*` device card - color-capable and brightness-only lights share this one + * component, branching on capability rather than being two components, since HA + * reports the difference on the entity itself (`supported_color_modes`). */ + +import { useMemo } from "react"; + +import type { HaDevice, HaEntityState } from "../api/types"; +import { oklchToRgb } from "../lib/oklch"; + +const SWATCHES: { name: string; oklch: string }[] = [ + { name: "Warmweiß", oklch: "oklch(90% 0.06 85)" }, + { name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" }, + { name: "Korallenrot", oklch: "oklch(65% 0.20 25)" }, + { name: "Delfinblau", oklch: "oklch(70% 0.15 235)" }, + { name: "Riffgrün", oklch: "oklch(75% 0.16 155)" }, + { name: "Quallenlila", oklch: "oklch(65% 0.18 310)" }, +]; + +/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct` + * sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */ +const COLOR_MODES = new Set(["hs", "rgb", "rgbw", "rgbww", "xy"]); + +interface Props { + device: HaDevice; + state: HaEntityState | undefined; + callService: (domain: string, service: string, body: Record) => Promise; + optimistic: (entityId: string, patch: Partial) => void; + onManualChange: () => void; +} + +function sameRgb(a: [number, number, number], b: [number, number, number]): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; +} + +export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) { + const swatchRgb = useMemo( + () => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })), + [], + ); + + const on = state?.state === "on"; + const modes = (state?.attributes.supported_color_modes as string[] | undefined) ?? []; + const isColor = modes.some((mode) => COLOR_MODES.has(mode)); + const brightness = (state?.attributes.brightness as number | undefined) ?? 0; // 0..255 + const level = Math.min(5, Math.max(0, Math.round((brightness / 255) * 5))); + const rgb = (state?.attributes.rgb_color as [number, number, number] | undefined) ?? [ + 255, 214, 140, + ]; + const tint = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + + const turnOn = (body: Record = {}) => { + onManualChange(); + optimistic(device.entity_id, { + state: "on", + attributes: { ...state?.attributes, ...body }, + }); + void callService("light", "turn_on", { entity_id: device.entity_id, ...body }); + }; + + const toggle = () => { + onManualChange(); + if (on) { + optimistic(device.entity_id, { state: "off" }); + void callService("light", "turn_off", { entity_id: device.entity_id }); + } else { + turnOn(); + } + }; + + return ( +
+
+
+ {isColor ? : } +
+
+ {device.name ?? device.entity_id} +
+ +
+ +
+ {[1, 2, 3, 4, 5].map((n) => ( +
+ + {isColor && ( +
+ {swatchRgb.map((swatch) => { + const selected = on && sameRgb(rgb, swatch.rgb); + return ( +
+ )} +
+ ); +} + +function ToggleSwitch({ on, onClick }: { on: boolean; onClick: () => void }) { + return ( + + ); +} + +function PendantIcon() { + return ( + + ); +} + +function CeilingIcon() { + return ( + + ); +} diff --git a/web/src/components/RoomView.tsx b/web/src/components/RoomView.tsx new file mode 100644 index 0000000..2872c61 --- /dev/null +++ b/web/src/components/RoomView.tsx @@ -0,0 +1,91 @@ +/** The room-control page ("Mein Zimmer"). Owns its Home Assistant polling directly - + * unlike BrowseView/PlayView, its data source has nothing to do with the musicmouse + * player state that App.tsx otherwise orchestrates. Ported from + * `claude-design/Mein Zimmer.dc.html`. */ + +import { useState } from "react"; + +import type { HaConfig } from "../api/types"; +import { useHomeAssistant } from "../hooks/useHomeAssistant"; +import { AppHeader } from "./AppHeader"; +import { Bubbles } from "./Bubbles"; +import { LightCard } from "./LightCard"; +import { SceneRow } from "./SceneRow"; +import { ShutterCard } from "./ShutterCard"; + +export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: () => void }) { + const ha = useHomeAssistant(config); + // Purely local: Home Assistant scenes have no "currently active" state of their own. + // Cleared by any manual device change, set by activating a scene. + const [activeScene, setActiveScene] = useState(null); + + const onManualChange = () => setActiveScene(null); + + const onActivateScene = (entityId: string) => { + setActiveScene(entityId); + void ha.callService("scene", "turn_on", { entity_id: entityId }); + }; + + return ( +
+ +
+ +
+
+ {config.scenes.length > 0 && ( + + )} +
+ {config.devices.map((device) => { + const state = ha.states[device.entity_id]; + if (device.entity_id.startsWith("cover.")) { + return ( + + ); + } + if (device.entity_id.startsWith("light.")) { + return ( + + ); + } + // Not a domain this page knows how to draw - skip rather than crash. + return null; + })} +
+
+
+
+
+ ); +} diff --git a/web/src/components/SceneRow.tsx b/web/src/components/SceneRow.tsx new file mode 100644 index 0000000..6fffff8 --- /dev/null +++ b/web/src/components/SceneRow.tsx @@ -0,0 +1,40 @@ +import type { HaDevice } from "../api/types"; + +export function SceneRow({ + scenes, + activeScene, + onActivate, +}: { + scenes: HaDevice[]; + activeScene: string | null; + onActivate: (entityId: string) => void; +}) { + return ( +
+
+ Szenen +
+
+ {scenes.map((scene) => ( + + ))} +
+
+ ); +} diff --git a/web/src/components/ShutterCard.tsx b/web/src/components/ShutterCard.tsx new file mode 100644 index 0000000..1696a4f --- /dev/null +++ b/web/src/components/ShutterCard.tsx @@ -0,0 +1,201 @@ +/** A `cover.*` device card ("Rollo"). Real covers report their own movement and + * position, so this just reflects HA's state - no client-side movement animation like + * the design mockup used (it had no real backend to poll). */ + +import type { HaDevice, HaEntityState } from "../api/types"; +import { + SHUTTER_PRESETS, + closedLabelFor, + closedPercentFromPosition, + positionFromClosedPercent, +} from "../lib/shutter"; + +//: Home Assistant's `CoverEntityFeature.SET_POSITION` bit. +const SUPPORT_SET_POSITION = 4; + +interface Props { + device: HaDevice; + state: HaEntityState | undefined; + callService: (domain: string, service: string, body: Record) => Promise; + optimistic: (entityId: string, patch: Partial) => void; + onManualChange: () => void; +} + +export function ShutterCard({ device, state, callService, optimistic, onManualChange }: Props) { + const position = state?.attributes.current_position as number | undefined; + const supportedFeatures = (state?.attributes.supported_features as number | undefined) ?? 0; + const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0; + const closedPercent = closedPercentFromPosition(position ?? 0); + + const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null; + const statusLabel = + moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent); + + const setPosition = (target: number) => { + onManualChange(); + optimistic(device.entity_id, { + attributes: { ...state?.attributes, current_position: target }, + }); + void callService("cover", "set_cover_position", { + entity_id: device.entity_id, + position: target, + }); + }; + + const open = () => { + onManualChange(); + void callService("cover", "open_cover", { entity_id: device.entity_id }); + }; + const close = () => { + onManualChange(); + void callService("cover", "close_cover", { entity_id: device.entity_id }); + }; + const stop = () => { + onManualChange(); + void callService("cover", "stop_cover", { entity_id: device.entity_id }); + }; + + return ( +
+
+
+ +
+
+
+ {device.name ?? "Rollo"} +
+
+ {statusLabel} +
+
+
+ +
+
+
+
+ +
+ {supportsPosition && ( +
+ {SHUTTER_PRESETS.map((preset) => { + const active = closedLabelFor(closedPercent) === preset.label; + return ( + + ); + })} +
+ )} + +
+ + + +
+
+
+
+ ); +} + +function BlindsIcon() { + return ( + + ); +} diff --git a/web/src/hooks/useHomeAssistant.ts b/web/src/hooks/useHomeAssistant.ts new file mode 100644 index 0000000..97b2850 --- /dev/null +++ b/web/src/hooks/useHomeAssistant.ts @@ -0,0 +1,91 @@ +/** Polls the backend's Home Assistant proxy for the configured devices/scenes and + * exposes a `callService` escape hatch for commands. No websocket: HA's own + * auth/subscribe protocol is more machinery than a room panel needs - a short poll is + * plenty, and it's the backend, not the browser, doing the actual HA calls (see + * `GET/POST /api/ha/...` in the Python backend), so there's no token or Home + * Assistant URL here at all. */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { api } from "../api/client"; +import type { HaConfig, HaEntityState } from "../api/types"; + +const POLL_INTERVAL_MS = 2500; + +export interface HomeAssistant { + states: Record; + loading: boolean; + /** Patch one entity's cached state locally so a tap feels instant; the next poll + * reconciles with the truth. */ + optimistic: (entityId: string, patch: Partial) => void; + callService: (domain: string, service: string, body: Record) => Promise; +} + +export function useHomeAssistant(config: HaConfig | null): HomeAssistant { + const [states, setStates] = useState>({}); + const [loading, setLoading] = useState(true); + + // When each entity last got an optimistic patch. A poll that was already in flight + // when the patch landed resolves with pre-click data - without this, that stale + // response clobbers the optimistic "on" back to "off" until the *next* poll catches + // up, which is what made toggling feel laggy despite the optimistic update existing. + const optimisticAt = useRef>({}); + + const entityIds = useMemo( + () => [...(config?.devices ?? []), ...(config?.scenes ?? [])].map((d) => d.entity_id), + [config], + ); + + useEffect(() => { + if (!config) return; + let cancelled = false; + + const poll = async () => { + const startedAt = Date.now(); + const fresh = await api.haStates(entityIds); + if (cancelled) return; + setStates((previous) => { + const next = { ...previous }; + for (const [entityId, state] of Object.entries(fresh)) { + // Only apply this result if no optimistic patch landed after the request + // for it went out - otherwise it's stale and would undo a newer change. + if (startedAt >= (optimisticAt.current[entityId] ?? 0)) { + next[entityId] = state; + } + } + return next; + }); + setLoading(false); + }; + + void poll(); + const id = setInterval(() => void poll(), POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [config, entityIds]); + + const optimistic = useCallback((entityId: string, patch: Partial) => { + optimisticAt.current[entityId] = Date.now(); + setStates((previous) => { + const current = previous[entityId]; + return current ? { ...previous, [entityId]: { ...current, ...patch } } : previous; + }); + }, []); + + const callService = useCallback( + (domain: string, service: string, body: Record) => { + if (!config) return Promise.resolve(); + // Card components fire this with `void` - swallow a failure here (e.g. HA + // unreachable, proxied as a 502) so it doesn't surface as an unhandled + // rejection; the next poll corrects any optimistic update that didn't take. + return api.haCallService(domain, service, body).catch((cause: unknown) => { + console.error(`${domain}.${service} failed:`, cause); + }); + }, + [config], + ); + + return { states, loading, optimistic, callService }; +} diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index 1a16960..fdaefdd 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Album } from "../../api/types"; import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard"; -import { normalize, results as computeResults } from "../search"; +import { normalize, results as computeResults, type Results } from "../search"; function album(id: string, over: Partial = {}): Album { return { @@ -44,7 +44,7 @@ const resultsFor = (ui: UiState) => albums: ALBUMS, search: ui.search, mode: ui.mode, - filter: ui.filter, + group: ui.group, category: ui.category, }); @@ -59,8 +59,9 @@ describe("normalize", () => { }); describe("search", () => { - it("shows categories with no query, albums once there is one", () => { - expect(resultsFor(initialUiState).categories).toHaveLength(3); + it("shows no categories at the bare root, a group's own once one is chosen", () => { + expect(resultsFor(initialUiState).categories).toHaveLength(0); + expect(resultsFor({ ...initialUiState, group: "music" }).categories).toHaveLength(2); expect(resultsFor(initialUiState).albums).toHaveLength(0); const searching = { ...initialUiState, search: "conni" }; @@ -68,7 +69,7 @@ describe("search", () => { }); it("filters books and music apart", () => { - const books = resultsFor({ ...initialUiState, filter: "book", search: "a" }); + const books = resultsFor({ ...initialUiState, group: "audiobooks", search: "a" }); expect(books.albums.every((a) => a.kind === "book")).toBe(true); }); @@ -104,19 +105,27 @@ describe("keyboard", () => { ]); }); - it("cycles the filter with TAB", () => { + it("cycles the group with TAB", () => { expect(press("Tab")).toContainEqual({ type: "ui", - patch: { filter: "music", selIndex: 0, view: "browse", category: null }, + patch: { group: "music", selIndex: 0, view: "browse", category: null }, }); - expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({ + expect(press("Tab", { ...initialUiState, group: "podcasts" })).toContainEqual({ type: "ui", - patch: { filter: "all", selIndex: 0, view: "browse", category: null }, + patch: { group: "music", selIndex: 0, view: "browse", category: null }, }); }); - it("gives arrows to navigation while browsing and to transport while playing", () => { - expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]); + it("jumps into the first group with arrows at the bare root", () => { + expect(press("ArrowRight")).toEqual([ + { type: "pop", freq: 380 }, + { type: "ui", patch: { group: "music", selIndex: 0 } }, + ]); + }); + + it("gives arrows to navigation once inside a group, and to transport while playing", () => { + const inGroup = { ...initialUiState, group: "music" as const }; + expect(press("ArrowRight", inGroup)).toEqual([{ type: "ui", patch: { selIndex: 1 } }]); const playing: UiState = { ...initialUiState, view: "play" }; expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]); @@ -134,16 +143,26 @@ describe("keyboard", () => { }); it("moves a whole row at a time in the grid", () => { - const ui = { ...initialUiState, cols: 2, selIndex: 0 }; - expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([ + const ui = { ...initialUiState, group: "music" as const, cols: 2, selIndex: 0 }; + const grid: Results = { + songs: [], + categories: [ + { key: "A", albums: [] }, + { key: "B", albums: [] }, + { key: "C", albums: [] }, + ], + albums: [], + total: 3, + }; + expect(handleKey(key("ArrowDown"), ui, grid)).toEqual([ { type: "ui", patch: { selIndex: 2 } }, ]); }); it("clamps the selection to what is on screen", () => { - const ui = { ...initialUiState, selIndex: 2 }; + const ui = { ...initialUiState, group: "music" as const, selIndex: 5 }; expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([ - { type: "ui", patch: { selIndex: 2 } }, + { type: "ui", patch: { selIndex: 1 } }, ]); }); @@ -152,15 +171,20 @@ describe("keyboard", () => { ...initialUiState, showHelp: true, openAlbumId: "a", + view: "play", search: "x", mode: "tracks", category: "Conni", + group: "audiobooks", }; expect(press("Escape", deep)).toEqual([ { type: "ui", patch: { showHelp: false, openAlbumId: null } }, ]); - const searching = { ...deep, showHelp: false, openAlbumId: null }; + const inPlay = { ...deep, showHelp: false, openAlbumId: null }; + expect(press("Escape", inPlay)).toEqual([{ type: "ui", patch: { view: "browse" } }]); + + const searching = { ...inPlay, view: "browse" as const }; expect(press("Escape", searching)).toEqual([ { type: "ui", patch: { search: "", selIndex: 0 } }, ]); @@ -174,6 +198,16 @@ describe("keyboard", () => { expect(press("Escape", inCategory)).toEqual([ { type: "ui", patch: { category: null, selIndex: 0 } }, ]); + + const inGroup = { ...inCategory, category: null }; + expect(press("Escape", inGroup)).toEqual([ + { type: "ui", patch: { group: null, selIndex: 0 } }, + ]); + }); + + it("escapes the room view back to browse, like the play view", () => { + const inRoom: UiState = { ...initialUiState, view: "room" }; + expect(press("Escape", inRoom)).toEqual([{ type: "ui", patch: { view: "browse" } }]); }); it("does not swallow backspace when there is nothing to delete", () => { @@ -210,7 +244,7 @@ describe("selectionAt", () => { }); it("opens a category rather than playing it", () => { - const found = resultsFor(initialUiState); + const found = resultsFor({ ...initialUiState, group: "music" }); expect(selectionAt(found, 0)).toEqual({ type: "ui", patch: { category: found.categories[0]!.key, selIndex: 0 }, diff --git a/web/src/lib/__tests__/shutter.test.ts b/web/src/lib/__tests__/shutter.test.ts new file mode 100644 index 0000000..1444307 --- /dev/null +++ b/web/src/lib/__tests__/shutter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + SHUTTER_PRESETS, + closedLabelFor, + closedPercentFromPosition, + positionFromClosedPercent, +} from "../shutter"; + +describe("closedPercentFromPosition", () => { + it("inverts HA's open-percent into the UI's closed-percent", () => { + expect(closedPercentFromPosition(100)).toBe(0); // fully open + expect(closedPercentFromPosition(0)).toBe(100); // fully closed + expect(closedPercentFromPosition(85)).toBe(15); // mostly open + }); +}); + +describe("positionFromClosedPercent", () => { + it("converts every preset back into HA's open-percent convention", () => { + expect(SHUTTER_PRESETS.map((p) => positionFromClosedPercent(p.closedPercent))).toEqual([ + 100, 50, 15, 0, + ]); + }); + + it("round-trips with closedPercentFromPosition", () => { + expect(positionFromClosedPercent(closedPercentFromPosition(37))).toBe(37); + }); +}); + +describe("closedLabelFor", () => { + it("labels a mostly-open cover (current_position: 85) as mostly open, not closed", () => { + // current_position: 85 means 85% *open* in HA's convention, i.e. 15% closed. + expect(closedLabelFor(closedPercentFromPosition(85))).toBe("15% zu"); + expect(closedLabelFor(closedPercentFromPosition(85))).not.toBe("Fast zu"); + expect(closedLabelFor(closedPercentFromPosition(85))).not.toBe("Ganz zu"); + }); + + it.each([ + [0, "Offen"], + [1, "Offen"], + [39, "39% zu"], + [40, "Halb zu"], + [79, "Halb zu"], + [80, "Fast zu"], + [98, "Fast zu"], + [99, "Ganz zu"], + [100, "Ganz zu"], + ])("closedPercent %i -> %s", (closedPercent, expected) => { + expect(closedLabelFor(closedPercent)).toBe(expected); + }); +}); diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 6234c1e..1d82603 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -10,15 +10,16 @@ playing), and seeking is the one thing a real player can do that the mockup coul */ import type { Album } from "../api/types"; -import type { Filter, Mode, Results } from "./search"; +import type { Group, Mode, Results } from "./search"; export interface UiState { search: string; mode: Mode; - filter: Filter; + /** `null` is the bare root screen (three shelves); otherwise which one is open. */ + group: Group | null; category: string | null; selIndex: number; - view: "browse" | "play"; + view: "browse" | "play" | "room"; openAlbumId: string | null; showHelp: boolean; cols: number; @@ -27,7 +28,7 @@ export interface UiState { export const initialUiState: UiState = { search: "", mode: "albums", - filter: "all", + group: null, category: null, selIndex: 0, view: "browse", @@ -49,7 +50,7 @@ export type Action = /** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */ const SEARCHABLE = /^[\p{L}\p{N}]$/u; -const FILTER_ORDER: Filter[] = ["all", "music", "book"]; +const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"]; export const VOLUME_STEP = 10; export const SEEK_STEP = 15; @@ -91,9 +92,18 @@ function escape(state: UiState): Action[] { if (state.showHelp || state.openAlbumId !== null) { return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }]; } + if (state.view === "play" || state.view === "room") { + return [{ type: "ui", patch: { view: "browse" } }]; + } if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }]; if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }]; - return [{ type: "ui", patch: { category: null, selIndex: 0 } }]; + if (state.category !== null) return [{ type: "ui", patch: { category: null, selIndex: 0 } }]; + return [{ type: "ui", patch: { group: null, selIndex: 0 } }]; +} + +/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */ +function isRootShelf(state: UiState): boolean { + return state.group === null && !state.search && state.mode === "albums" && state.category === null; } export interface KeyEvent { @@ -129,10 +139,11 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac switch (key) { case "Tab": { - const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!; + const currentIndex = state.group ? GROUP_ORDER.indexOf(state.group) : -1; + const next = GROUP_ORDER[(currentIndex + 1) % GROUP_ORDER.length]!; return [ { type: "pop", freq: 380 }, - { type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } }, + { type: "ui", patch: { group: next, selIndex: 0, view: "browse", category: null } }, ]; } case "/": @@ -142,15 +153,27 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac case "ArrowRight": if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }]; + if (browsing && isRootShelf(state)) { + return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }]; + } return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }]; case "ArrowLeft": if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }]; + if (browsing && isRootShelf(state)) { + return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }]; + } return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }]; case "ArrowDown": + if (browsing && isRootShelf(state)) { + return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }]; + } return browsing ? moveSelection(state, results, 0, 1) : [{ type: "volume", delta: -VOLUME_STEP }]; case "ArrowUp": + if (browsing && isRootShelf(state)) { + return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }]; + } return browsing ? moveSelection(state, results, 0, -1) : [{ type: "volume", delta: VOLUME_STEP }]; diff --git a/web/src/lib/oklch.ts b/web/src/lib/oklch.ts new file mode 100644 index 0000000..810d712 --- /dev/null +++ b/web/src/lib/oklch.ts @@ -0,0 +1,15 @@ +/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than + * hand-transcribing the oklch->sRGB math, this leans on the browser's own CSS engine: + * set the string as an element's color and read back what the browser resolved it to. + * Only ever called a handful of times (once per fixed swatch), never per render. */ +export function oklchToRgb(oklch: string): [number, number, number] { + const el = document.createElement("div"); + el.style.color = oklch; + document.body.appendChild(el); + const resolved = getComputedStyle(el).color; + document.body.removeChild(el); + + const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved); + if (!match) return [255, 255, 255]; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} diff --git a/web/src/lib/shutter.ts b/web/src/lib/shutter.ts new file mode 100644 index 0000000..4214202 --- /dev/null +++ b/web/src/lib/shutter.ts @@ -0,0 +1,35 @@ +/** Conversions between the room UI's shutter model and Home Assistant's `cover.*` + * convention. Home Assistant's `current_position`/`position` are percent *open* (100 = + * fully open, 0 = fully closed); the UI (following the design mockup) thinks in percent + * *closed*. Kept pure and separate because that inversion is easy to get backwards. */ + +export interface ShutterPreset { + label: string; + /** Percent closed: 0 = fully open, 100 = fully closed. */ + closedPercent: number; +} + +export const SHUTTER_PRESETS: ShutterPreset[] = [ + { label: "Offen", closedPercent: 0 }, + { label: "Halb zu", closedPercent: 50 }, + { label: "Fast zu", closedPercent: 85 }, + { label: "Ganz zu", closedPercent: 100 }, +]; + +/** HA's `current_position` -> the UI's "how closed" percent. */ +export function closedPercentFromPosition(position: number): number { + return 100 - position; +} + +/** The UI's "how closed" percent -> the `position` `cover.set_cover_position` expects. */ +export function positionFromClosedPercent(closedPercent: number): number { + return 100 - closedPercent; +} + +export function closedLabelFor(closedPercent: number): string { + if (closedPercent <= 1) return "Offen"; + if (closedPercent >= 99) return "Ganz zu"; + if (closedPercent >= 80) return "Fast zu"; + if (closedPercent >= 40) return "Halb zu"; + return `${Math.round(closedPercent)}% zu`; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 0913e5e..fed9e79 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { App } from "./App"; import "./styles/app.css"; +import "./styles/room.css"; createRoot(document.getElementById("root")!).render( diff --git a/web/src/styles/room.css b/web/src/styles/room.css new file mode 100644 index 0000000..62c6e7d --- /dev/null +++ b/web/src/styles/room.css @@ -0,0 +1,54 @@ +/* "Mein Zimmer" (room control). Ported from claude-design/Mein Zimmer.dc.html - the + same oklch-everywhere approach as app.css, but its own violet hue (~298-310) rather + than app.css's teal (~210), so the two pages read as siblings, not one bleeding into + the other. */ + +:root { + --room-ink: oklch(20% 0.03 300); + --room-paper: oklch(97% 0.01 302); + --room-accent: oklch(62% 0.15 302); + --room-card-bg: oklch(96% 0.012 300 / 0.55); + --room-shadow: oklch(15% 0.05 300 / 0.35); +} + +.room-stage { + background: linear-gradient( + 180deg, + oklch(56% 0.14 305) 0%, + oklch(38% 0.12 300) 45%, + oklch(21% 0.08 298) 100% + ); +} + +.room-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +.room-card { + background: var(--room-card-bg); + backdrop-filter: blur(6px); + border-radius: 22px; + padding: 18px 20px 20px; + box-shadow: 0 8px 24px var(--room-shadow); +} + +.room-pill { + border: none; + cursor: pointer; + font-size: 15px; + font-weight: 800; + min-height: 52px; + padding: 12px 20px; + border-radius: 999px; + white-space: nowrap; + background: oklch(97% 0.01 302 / 0.18); + color: var(--room-paper); +} + +.room-pill[data-active="true"] { + background: var(--room-paper); + color: oklch(26% 0.05 300); + box-shadow: 0 6px 18px oklch(15% 0.05 300 / 0.4); +}