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