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:
201
web/src/components/ShutterCard.tsx
Normal file
201
web/src/components/ShutterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user