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:
@@ -13,11 +13,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import HaConfig
|
||||
from musicmouse.events import (
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
@@ -32,6 +35,8 @@ from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.schemas import (
|
||||
AlbumOut,
|
||||
BeatsOut,
|
||||
HaConfigOut,
|
||||
HaDeviceOut,
|
||||
LibraryOut,
|
||||
PlayerStateOut,
|
||||
PlayIn,
|
||||
@@ -52,7 +57,9 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["build_router"]
|
||||
|
||||
def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
||||
def build_router(
|
||||
app: App, hub: StateHub, config_path: Path, ha_client: httpx2.AsyncClient
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
def emit(intent: IntentEvent) -> Response:
|
||||
@@ -189,4 +196,52 @@ def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
|
||||
await hub.broadcast_state()
|
||||
return read_settings(general)
|
||||
|
||||
# --------------------------------------------------------------- room control
|
||||
#
|
||||
# The browser never sees the Home Assistant token: it stays server-side, attached
|
||||
# to every proxied request below. The browser only gets to know entity ids and
|
||||
# display names (get_ha_config) and can ask this backend to relay a states read or
|
||||
# a service call - the same shape of access the token itself grants, just without
|
||||
# ever leaving the LAN device. Entity ids are not restricted to the configured
|
||||
# list; that would only stop someone who already has enough access to open this
|
||||
# unauthenticated API from asking Home Assistant about a different entity, which
|
||||
# matches the rest of this API's "trusted LAN device" threat model.
|
||||
|
||||
def _require_ha() -> HaConfig:
|
||||
ha = app.config.general.ha
|
||||
if ha is None:
|
||||
raise HTTPException(status_code=404, detail="ha not configured")
|
||||
return ha
|
||||
|
||||
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
|
||||
try:
|
||||
upstream = await ha_client.request(
|
||||
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||
)
|
||||
except httpx2.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||
) from exc
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
media_type=upstream.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
@router.get("/ha")
|
||||
def get_ha_config() -> HaConfigOut:
|
||||
ha = _require_ha()
|
||||
return HaConfigOut(
|
||||
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
|
||||
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
|
||||
)
|
||||
|
||||
@router.get("/ha/states/{entity_id}")
|
||||
async def get_ha_state(entity_id: str) -> Response:
|
||||
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
|
||||
|
||||
@router.post("/ha/services/{domain}/{service}")
|
||||
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
|
||||
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
|
||||
|
||||
return router
|
||||
|
||||
@@ -16,6 +16,8 @@ from musicmouse.library.analysis import TrackAnalysis
|
||||
__all__ = [
|
||||
"AlbumOut",
|
||||
"BeatsOut",
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
"PlayIn",
|
||||
"PlayerStateOut",
|
||||
@@ -154,3 +156,17 @@ class SettingsIn(BaseModel):
|
||||
initial_volume: int = Field(ge=0, le=200)
|
||||
volume_increment: int = Field(ge=1, le=100)
|
||||
button_leds_brightness: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class HaDeviceOut(BaseModel):
|
||||
entity_id: str
|
||||
name: str | None
|
||||
|
||||
|
||||
class HaConfigOut(BaseModel):
|
||||
"""No ``url``/``token`` here on purpose - the browser talks to this backend, which
|
||||
proxies to Home Assistant with the token attached server-side. See
|
||||
``services/web/api.py``'s room-control section."""
|
||||
|
||||
devices: list[HaDeviceOut]
|
||||
scenes: list[HaDeviceOut]
|
||||
|
||||
@@ -13,6 +13,7 @@ import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -27,11 +28,23 @@ _log = logging.getLogger(__name__)
|
||||
__all__ = ["WebService", "build_app"]
|
||||
|
||||
|
||||
def build_app(app: App, config: WebConfig, config_path: Path) -> tuple[FastAPI, StateHub]:
|
||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly."""
|
||||
def build_app(
|
||||
app: App,
|
||||
config: WebConfig,
|
||||
config_path: Path,
|
||||
ha_client: httpx2.AsyncClient | None = None,
|
||||
) -> tuple[FastAPI, StateHub]:
|
||||
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
|
||||
|
||||
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
|
||||
calls through; tests inject one on a mock transport, production gets a real one
|
||||
that :class:`WebService` closes on shutdown.
|
||||
"""
|
||||
hub = StateHub(app)
|
||||
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
|
||||
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
api.include_router(build_router(app, hub, config_path))
|
||||
api.state.ha_client = ha_client
|
||||
api.include_router(build_router(app, hub, config_path, ha_client))
|
||||
|
||||
if config.static_dir is not None:
|
||||
if config.static_dir.is_dir():
|
||||
@@ -86,3 +99,4 @@ class WebService:
|
||||
position.cancel()
|
||||
serving.cancel()
|
||||
self.hub.stop()
|
||||
await self.api.state.ha_client.aclose()
|
||||
|
||||
Reference in New Issue
Block a user