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

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