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