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:
2026-08-27 23:45:29 +02:00
parent edb6e5e027
commit a8ed350aec
27 changed files with 1437 additions and 163 deletions

1
python-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
config.yml

View File

@@ -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"]

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

View File

@@ -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<UiState>(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<HaConfig | null | undefined>(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);
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);
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",
}}
>
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
<AppHeader
status={<ConnectionDot online={connection.online} state={state} />}
link={
haConfig
? {
label: "💡 Mein Zimmer",
onClick: () => setUi((previous) => ({ ...previous, view: "room" })),
}
: undefined
}
/>
<BrowseView
results={results}
filter={ui.filter}
group={ui.group}
albums={library.albums}
mode={ui.mode}
search={ui.search}
category={ui.category}
selIndex={ui.selIndex}
currentAlbumId={state.album_id}
gridRef={gridRef}
onFilter={onFilter}
onEnterGroup={onEnterGroup}
onBackToRoot={onBackToRoot}
onCategory={onCategory}
onOpenAlbum={onOpenAlbum}
onPlaySong={onPlaySong}
@@ -214,7 +274,6 @@ export function App() {
<PlayView
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => 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 && (
<RoomView
config={haConfig}
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
/>
)}
@@ -266,7 +333,6 @@ export function App() {
<PlayerBar
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api${path}`, {
@@ -20,6 +20,33 @@ function post(path: string, body?: unknown): Promise<void> {
});
}
/** `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<HaConfig | null> {
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<HaEntityState | null> {
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<Record<string, HaEntityState>> {
const results = await Promise.all(entityIds.map(fetchHaState));
const byId: Record<string, HaEntityState> = {};
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<PlayerState>("/state"),
@@ -38,6 +65,11 @@ export const api = {
settings: () => request<Settings>("/settings"),
saveSettings: (settings: Settings) =>
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
haConfig: fetchHaConfig,
haStates: fetchHaStates,
haCallService: (domain: string, service: string, body: Record<string, unknown>) =>
post(`/ha/services/${domain}/${service}`, body),
};
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;

View File

@@ -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<string, unknown>;
}

View File

@@ -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 (
<div
style={{
@@ -16,13 +26,30 @@ export function AppHeader({ status }: Props) {
// The status bar is translucent in standalone mode, so make room for it.
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
flex: "none",
position: "relative",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{ width: 64, height: 64, objectFit: "contain" }}
/>
{link && (
<button
onClick={link.onClick}
style={{
position: "absolute",
left: 24,
border: "none",
cursor: "pointer",
background: "oklch(97% 0.01 230 / .95)",
color: "oklch(30% 0.03 230)",
fontWeight: 800,
fontSize: 14,
padding: "9px 16px",
borderRadius: 999,
boxShadow: "0 6px 18px oklch(15% 0.04 230 / .4)",
}}
>
{link.label}
</button>
)}
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
<div
style={{
fontSize: 34,
@@ -31,7 +58,7 @@ export function AppHeader({ status }: Props) {
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
}}
>
Musik Delphin
{title}
</div>
{status}
</div>

View File

@@ -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<string, unknown>) => Promise<void>;
optimistic: (entityId: string, patch: Partial<HaEntityState>) => 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<string, unknown> = {}) => {
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 (
<div
className="room-card"
style={{
boxShadow: on
? `0 8px 24px var(--room-shadow), 0 0 0 3px color-mix(in srgb, ${tint} 55%, transparent)`
: undefined,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div
style={{
width: 56,
height: 56,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
flex: "none",
background: on ? tint : "oklch(88% 0.02 300)",
color: on ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)",
boxShadow: on
? `0 0 22px color-mix(in srgb, ${tint} 70%, transparent)`
: undefined,
}}
>
{isColor ? <PendantIcon /> : <CeilingIcon />}
</div>
<div style={{ flex: 1, fontSize: 19, fontWeight: 900, color: "var(--room-ink)" }}>
{device.name ?? device.entity_id}
</div>
<ToggleSwitch on={on} onClick={toggle} />
</div>
<div style={{ display: "flex", gap: 6, marginTop: 16, opacity: on ? 1 : 0.55 }}>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => turnOn({ brightness_pct: n * 20 })}
style={{
flex: 1,
height: 46,
border: "none",
cursor: "pointer",
borderRadius: 12,
background: on && level >= n ? tint : "oklch(88% 0.02 300)",
}}
/>
))}
</div>
{isColor && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
{swatchRgb.map((swatch) => {
const selected = on && sameRgb(rgb, swatch.rgb);
return (
<button
key={swatch.name}
title={swatch.name}
onClick={() => turnOn({ rgb_color: swatch.rgb })}
style={{
width: 46,
height: 46,
borderRadius: 999,
cursor: "pointer",
background: swatch.oklch,
border: selected
? "4px solid oklch(28% 0.05 300)"
: "4px solid oklch(96% 0.012 300 / .8)",
boxShadow: "0 3px 8px oklch(20% 0.03 300 / .35)",
}}
/>
);
})}
</div>
)}
</div>
);
}
function ToggleSwitch({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
aria-pressed={on}
style={{
width: 74,
height: 42,
padding: 4,
border: "none",
borderRadius: 999,
cursor: "pointer",
display: "flex",
justifyContent: on ? "flex-end" : "flex-start",
background: on ? "var(--room-accent)" : "oklch(84% 0.01 300)",
}}
>
<span
style={{
width: 34,
height: 34,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 2px 6px oklch(20% 0.03 300 / .4)",
}}
/>
</button>
);
}
function PendantIcon() {
return (
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" strokeWidth="3" />
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85" />
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" strokeWidth="1.5" opacity=".4" />
</svg>
);
}
function CeilingIcon() {
return (
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<path d="M24 4v7" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" strokeWidth="3" strokeLinejoin="round" />
<path d="M16 37h16" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity=".55" />
<path d="M20 43h8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity=".3" />
</svg>
);
}

View File

@@ -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<string | null>(null);
const onManualChange = () => setActiveScene(null);
const onActivateScene = (entityId: string) => {
setActiveScene(entityId);
void ha.callService("scene", "turn_on", { entity_id: entityId });
};
return (
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
<Bubbles />
<div
style={{
position: "relative",
zIndex: 1,
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<AppHeader
title="Mein Zimmer"
mascot="/dolphin-remote.png"
link={{ label: "♪ Musik", onClick: onBrowse }}
/>
<div style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 64px" }}>
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
{config.scenes.length > 0 && (
<SceneRow
scenes={config.scenes}
activeScene={activeScene}
onActivate={onActivateScene}
/>
)}
<div className="room-grid" style={{ marginTop: 30 }}>
{config.devices.map((device) => {
const state = ha.states[device.entity_id];
if (device.entity_id.startsWith("cover.")) {
return (
<ShutterCard
key={device.entity_id}
device={device}
state={state}
callService={ha.callService}
optimistic={ha.optimistic}
onManualChange={onManualChange}
/>
);
}
if (device.entity_id.startsWith("light.")) {
return (
<LightCard
key={device.entity_id}
device={device}
state={state}
callService={ha.callService}
optimistic={ha.optimistic}
onManualChange={onManualChange}
/>
);
}
// Not a domain this page knows how to draw - skip rather than crash.
return null;
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div>
<div
style={{
fontSize: 19,
fontWeight: 900,
color: "var(--room-paper)",
textAlign: "center",
marginBottom: 14,
}}
>
Szenen
</div>
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: 12 }}>
{scenes.map((scene) => (
<button
key={scene.entity_id}
className="room-pill"
data-active={scene.entity_id === activeScene}
onClick={() => onActivate(scene.entity_id)}
>
<span style={{ fontSize: 22, marginRight: 8 }}></span>
{scene.name ?? scene.entity_id}
</button>
))}
</div>
</div>
);
}

View File

@@ -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<string, unknown>) => Promise<void>;
optimistic: (entityId: string, patch: Partial<HaEntityState>) => 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 (
<div className="room-card">
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div
style={{
width: 56,
height: 56,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
flex: "none",
background: "oklch(88% 0.03 300)",
color: "oklch(35% 0.05 300)",
}}
>
<BlindsIcon />
</div>
<div>
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--room-ink)" }}>
{device.name ?? "Rollo"}
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: "oklch(45% 0.03 300 / .8)" }}>
{statusLabel}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 18, marginTop: 16 }}>
<div
style={{
width: 104,
height: 140,
flex: "none",
borderRadius: 12,
border: "3px solid oklch(35% 0.04 300)",
overflow: "hidden",
position: "relative",
background: "linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200))",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: `${closedPercent}%`,
background:
"repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px)",
boxShadow: "0 4px 10px oklch(20% 0.03 300 / .4)",
transition: "height .2s linear",
}}
/>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 10 }}>
{supportsPosition && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{SHUTTER_PRESETS.map((preset) => {
const active = closedLabelFor(closedPercent) === preset.label;
return (
<button
key={preset.label}
onClick={() => setPosition(positionFromClosedPercent(preset.closedPercent))}
style={{
height: 48,
border: "none",
borderRadius: 14,
cursor: "pointer",
fontWeight: 800,
fontSize: 14,
background: active ? "var(--room-accent)" : "oklch(89% 0.02 300)",
color: active ? "#fff" : "oklch(28% 0.04 300)",
}}
>
{preset.label}
</button>
);
})}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<button
onClick={open}
style={{
height: 52,
border: "none",
borderRadius: 14,
cursor: "pointer",
fontWeight: 900,
fontSize: 20,
background: moving === "up" ? "var(--room-accent)" : "oklch(89% 0.02 300)",
color: moving === "up" ? "#fff" : "oklch(28% 0.04 300)",
}}
>
</button>
<button
onClick={stop}
style={{
height: 52,
border: "none",
borderRadius: 14,
cursor: "pointer",
fontWeight: 900,
fontSize: 18,
background: "oklch(65% 0.18 25)",
color: "#fff",
}}
>
</button>
<button
onClick={close}
style={{
height: 52,
border: "none",
borderRadius: 14,
cursor: "pointer",
fontWeight: 900,
fontSize: 20,
background: moving === "down" ? "var(--room-accent)" : "oklch(89% 0.02 300)",
color: moving === "down" ? "#fff" : "oklch(28% 0.04 300)",
}}
>
</button>
</div>
</div>
</div>
</div>
);
}
function BlindsIcon() {
return (
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" strokeWidth="3" />
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" strokeWidth="2.5" />
</svg>
);
}

View File

@@ -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<string, HaEntityState>;
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<HaEntityState>) => void;
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
}
export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
const [states, setStates] = useState<Record<string, HaEntityState>>({});
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<Record<string, number>>({});
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<HaEntityState>) => {
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<string, unknown>) => {
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 };
}

View File

@@ -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> = {}): 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 },

View File

@@ -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);
});
});

View File

@@ -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 }];

15
web/src/lib/oklch.ts Normal file
View File

@@ -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])];
}

35
web/src/lib/shutter.ts Normal file
View File

@@ -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`;
}

View File

@@ -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(
<StrictMode>

54
web/src/styles/room.css Normal file
View File

@@ -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);
}