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>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
/** 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>
|
|
);
|
|
}
|