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
|
||||
|
||||
Reference in New Issue
Block a user