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

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