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

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

View File

@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client";
import type { Album } from "./api/types";
import type { Album, HaConfig } from "./api/types";
import { AlbumModal } from "./components/AlbumModal";
import { AppHeader } from "./components/AppHeader";
import { BrowseView } from "./components/BrowseView";
@@ -18,19 +18,23 @@ import { HelpOverlay } from "./components/HelpOverlay";
import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView";
import { RoomView } from "./components/RoomView";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
import { usePlaybackClock } from "./hooks/usePlaybackClock";
import { usePlayerState } from "./hooks/usePlayerState";
import type { Action, UiState } from "./lib/keyboard";
import { handleKey, initialUiState } from "./lib/keyboard";
import { playPop } from "./lib/pop";
import type { Filter, Results, SongHit } from "./lib/search";
import { results as computeResults } from "./lib/search";
import type { Group, Results, SongHit } from "./lib/search";
import { groupOf, results as computeResults } from "./lib/search";
/** Volume when un-muting, matching the mockup. */
const UNMUTE_PERCENT = 60;
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
* position instead, the way scrubbing past an ad break usually works. */
const PODCAST_SKIP_SECONDS = 30;
export function App() {
const [ui, setUi] = useState<UiState>(initialUiState);
const library = useLibrary();
@@ -39,9 +43,15 @@ export function App() {
const [parentMode, setParentMode] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1",
);
// undefined: not yet resolved (hide the nav pill to avoid a flash). null: confirmed
// absent - the room page is a separate opt-in feature, off by default.
const [haConfig, setHaConfig] = useState<HaConfig | null | undefined>(undefined);
useEffect(() => {
void api.haConfig().then(setHaConfig);
}, []);
const state = connection.state;
const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false);
useEffect(() => {
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
@@ -53,10 +63,10 @@ export function App() {
albums: library.albums,
search: ui.search,
mode: ui.mode,
filter: ui.filter,
group: ui.group,
category: ui.category,
}),
[library.albums, ui.search, ui.mode, ui.filter, ui.category],
[library.albums, ui.search, ui.mode, ui.group, ui.category],
);
const byId = useMemo(
@@ -110,27 +120,50 @@ export function App() {
break;
case "next":
playPop(260);
void api.next();
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.min(
state?.duration ?? 0,
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
);
connection.optimistic({ position: target });
void api.seek(target);
} else {
connection.optimistic({ position: 0 });
void api.next();
}
break;
case "previous":
playPop(260);
void api.previous();
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
connection.optimistic({ position: target });
void api.seek(target);
} else {
connection.optimistic({ position: 0 });
void api.previous();
}
break;
case "volume":
setVolume((state?.volume ?? 0) + action.delta);
break;
case "seek":
case "seek": {
if (state?.duration) {
void api.seek(Math.max(0, Math.min(state.duration, position + action.delta)));
const target = Math.max(
0,
Math.min(state.duration, (state?.position ?? 0) + action.delta),
);
connection.optimistic({ position: target });
void api.seek(target);
}
break;
}
case "pop":
playPop(action.freq);
break;
}
}
},
[play, position, setVolume, state, toggle],
[connection, currentAlbum, play, setVolume, state, toggle],
);
// Held in a ref so the listener is installed once rather than on every state change.
@@ -153,15 +186,14 @@ export function App() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const onFilter = (filter: Filter) => {
const onEnterGroup = (group: Group, category: string | null) => {
playPop(category ? 440 : 380);
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
};
const onBackToRoot = () => {
playPop(380);
setUi((previous) => ({
...previous,
filter,
selIndex: 0,
view: "browse",
category: null,
}));
setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 }));
};
const onCategory = (key: string | null) => {
@@ -170,12 +202,28 @@ export function App() {
};
const onOpenAlbum = (album: Album, navIndex: number) => {
// A podcast episode is a single track behaving like an audiobook of one chapter -
// there's nothing a metadata popup would add, so it just starts playing.
if (groupOf(album) === "podcasts") {
setUi((previous) => ({ ...previous, selIndex: navIndex }));
play(album.id, 0);
return;
}
playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
};
const onOpenCurrentAlbum = () => {
if (!currentAlbum) return;
playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id }));
};
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
const onSeek = (target: number) => void api.seek(target);
const onSeek = (target: number) => {
connection.optimistic({ position: target });
void api.seek(target);
};
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
return (
@@ -192,17 +240,29 @@ export function App() {
flexDirection: "column",
}}
>
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
<AppHeader
status={<ConnectionDot online={connection.online} state={state} />}
link={
haConfig
? {
label: "💡 Mein Zimmer",
onClick: () => setUi((previous) => ({ ...previous, view: "room" })),
}
: undefined
}
/>
<BrowseView
results={results}
filter={ui.filter}
group={ui.group}
albums={library.albums}
mode={ui.mode}
search={ui.search}
category={ui.category}
selIndex={ui.selIndex}
currentAlbumId={state.album_id}
gridRef={gridRef}
onFilter={onFilter}
onEnterGroup={onEnterGroup}
onBackToRoot={onBackToRoot}
onCategory={onCategory}
onOpenAlbum={onOpenAlbum}
onPlaySong={onPlaySong}
@@ -214,7 +274,6 @@ export function App() {
<PlayView
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
@@ -222,6 +281,14 @@ export function App() {
onVolume={setVolume}
onMute={onMute}
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
onOpenAlbum={onOpenCurrentAlbum}
/>
)}
{ui.view === "room" && haConfig && (
<RoomView
config={haConfig}
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
/>
)}
@@ -266,7 +333,6 @@ export function App() {
<PlayerBar
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}

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

View File

@@ -63,3 +63,23 @@ export type ServerMessage =
| { type: "state"; state: PlayerState }
| { type: "position"; position: number; duration: number }
| { type: "library" };
export interface HaDevice {
entity_id: string;
name: string | null;
}
/** No url/token here - the backend proxies Home Assistant calls and keeps the token to
* itself, so the browser only ever learns which entities exist. */
export interface HaConfig {
devices: HaDevice[];
scenes: HaDevice[];
}
/** A Home Assistant entity's `state`/`attributes`, relayed byte-for-byte through
* `GET /api/ha/states/{entity_id}`. */
export interface HaEntityState {
entity_id: string;
state: string;
attributes: Record<string, unknown>;
}

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

View File

@@ -0,0 +1,91 @@
/** Polls the backend's Home Assistant proxy for the configured devices/scenes and
* exposes a `callService` escape hatch for commands. No websocket: HA's own
* auth/subscribe protocol is more machinery than a room panel needs - a short poll is
* plenty, and it's the backend, not the browser, doing the actual HA calls (see
* `GET/POST /api/ha/...` in the Python backend), so there's no token or Home
* Assistant URL here at all. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "../api/client";
import type { HaConfig, HaEntityState } from "../api/types";
const POLL_INTERVAL_MS = 2500;
export interface HomeAssistant {
states: Record<string, HaEntityState>;
loading: boolean;
/** Patch one entity's cached state locally so a tap feels instant; the next poll
* reconciles with the truth. */
optimistic: (entityId: string, patch: Partial<HaEntityState>) => void;
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
}
export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
const [states, setStates] = useState<Record<string, HaEntityState>>({});
const [loading, setLoading] = useState(true);
// When each entity last got an optimistic patch. A poll that was already in flight
// when the patch landed resolves with pre-click data - without this, that stale
// response clobbers the optimistic "on" back to "off" until the *next* poll catches
// up, which is what made toggling feel laggy despite the optimistic update existing.
const optimisticAt = useRef<Record<string, number>>({});
const entityIds = useMemo(
() => [...(config?.devices ?? []), ...(config?.scenes ?? [])].map((d) => d.entity_id),
[config],
);
useEffect(() => {
if (!config) return;
let cancelled = false;
const poll = async () => {
const startedAt = Date.now();
const fresh = await api.haStates(entityIds);
if (cancelled) return;
setStates((previous) => {
const next = { ...previous };
for (const [entityId, state] of Object.entries(fresh)) {
// Only apply this result if no optimistic patch landed after the request
// for it went out - otherwise it's stale and would undo a newer change.
if (startedAt >= (optimisticAt.current[entityId] ?? 0)) {
next[entityId] = state;
}
}
return next;
});
setLoading(false);
};
void poll();
const id = setInterval(() => void poll(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [config, entityIds]);
const optimistic = useCallback((entityId: string, patch: Partial<HaEntityState>) => {
optimisticAt.current[entityId] = Date.now();
setStates((previous) => {
const current = previous[entityId];
return current ? { ...previous, [entityId]: { ...current, ...patch } } : previous;
});
}, []);
const callService = useCallback(
(domain: string, service: string, body: Record<string, unknown>) => {
if (!config) return Promise.resolve();
// Card components fire this with `void` - swallow a failure here (e.g. HA
// unreachable, proxied as a 502) so it doesn't surface as an unhandled
// rejection; the next poll corrects any optimistic update that didn't take.
return api.haCallService(domain, service, body).catch((cause: unknown) => {
console.error(`${domain}.${service} failed:`, cause);
});
},
[config],
);
return { states, loading, optimistic, callService };
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
import { normalize, results as computeResults } from "../search";
import { normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album {
return {
@@ -44,7 +44,7 @@ const resultsFor = (ui: UiState) =>
albums: ALBUMS,
search: ui.search,
mode: ui.mode,
filter: ui.filter,
group: ui.group,
category: ui.category,
});
@@ -59,8 +59,9 @@ describe("normalize", () => {
});
describe("search", () => {
it("shows categories with no query, albums once there is one", () => {
expect(resultsFor(initialUiState).categories).toHaveLength(3);
it("shows no categories at the bare root, a group's own once one is chosen", () => {
expect(resultsFor(initialUiState).categories).toHaveLength(0);
expect(resultsFor({ ...initialUiState, group: "music" }).categories).toHaveLength(2);
expect(resultsFor(initialUiState).albums).toHaveLength(0);
const searching = { ...initialUiState, search: "conni" };
@@ -68,7 +69,7 @@ describe("search", () => {
});
it("filters books and music apart", () => {
const books = resultsFor({ ...initialUiState, filter: "book", search: "a" });
const books = resultsFor({ ...initialUiState, group: "audiobooks", search: "a" });
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
});
@@ -104,19 +105,27 @@ describe("keyboard", () => {
]);
});
it("cycles the filter with TAB", () => {
it("cycles the group with TAB", () => {
expect(press("Tab")).toContainEqual({
type: "ui",
patch: { filter: "music", selIndex: 0, view: "browse", category: null },
patch: { group: "music", selIndex: 0, view: "browse", category: null },
});
expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({
expect(press("Tab", { ...initialUiState, group: "podcasts" })).toContainEqual({
type: "ui",
patch: { filter: "all", selIndex: 0, view: "browse", category: null },
patch: { group: "music", selIndex: 0, view: "browse", category: null },
});
});
it("gives arrows to navigation while browsing and to transport while playing", () => {
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
it("jumps into the first group with arrows at the bare root", () => {
expect(press("ArrowRight")).toEqual([
{ type: "pop", freq: 380 },
{ type: "ui", patch: { group: "music", selIndex: 0 } },
]);
});
it("gives arrows to navigation once inside a group, and to transport while playing", () => {
const inGroup = { ...initialUiState, group: "music" as const };
expect(press("ArrowRight", inGroup)).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
const playing: UiState = { ...initialUiState, view: "play" };
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
@@ -134,16 +143,26 @@ describe("keyboard", () => {
});
it("moves a whole row at a time in the grid", () => {
const ui = { ...initialUiState, cols: 2, selIndex: 0 };
expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([
const ui = { ...initialUiState, group: "music" as const, cols: 2, selIndex: 0 };
const grid: Results = {
songs: [],
categories: [
{ key: "A", albums: [] },
{ key: "B", albums: [] },
{ key: "C", albums: [] },
],
albums: [],
total: 3,
};
expect(handleKey(key("ArrowDown"), ui, grid)).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
]);
});
it("clamps the selection to what is on screen", () => {
const ui = { ...initialUiState, selIndex: 2 };
const ui = { ...initialUiState, group: "music" as const, selIndex: 5 };
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
{ type: "ui", patch: { selIndex: 1 } },
]);
});
@@ -152,15 +171,20 @@ describe("keyboard", () => {
...initialUiState,
showHelp: true,
openAlbumId: "a",
view: "play",
search: "x",
mode: "tracks",
category: "Conni",
group: "audiobooks",
};
expect(press("Escape", deep)).toEqual([
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
]);
const searching = { ...deep, showHelp: false, openAlbumId: null };
const inPlay = { ...deep, showHelp: false, openAlbumId: null };
expect(press("Escape", inPlay)).toEqual([{ type: "ui", patch: { view: "browse" } }]);
const searching = { ...inPlay, view: "browse" as const };
expect(press("Escape", searching)).toEqual([
{ type: "ui", patch: { search: "", selIndex: 0 } },
]);
@@ -174,6 +198,16 @@ describe("keyboard", () => {
expect(press("Escape", inCategory)).toEqual([
{ type: "ui", patch: { category: null, selIndex: 0 } },
]);
const inGroup = { ...inCategory, category: null };
expect(press("Escape", inGroup)).toEqual([
{ type: "ui", patch: { group: null, selIndex: 0 } },
]);
});
it("escapes the room view back to browse, like the play view", () => {
const inRoom: UiState = { ...initialUiState, view: "room" };
expect(press("Escape", inRoom)).toEqual([{ type: "ui", patch: { view: "browse" } }]);
});
it("does not swallow backspace when there is nothing to delete", () => {
@@ -210,7 +244,7 @@ describe("selectionAt", () => {
});
it("opens a category rather than playing it", () => {
const found = resultsFor(initialUiState);
const found = resultsFor({ ...initialUiState, group: "music" });
expect(selectionAt(found, 0)).toEqual({
type: "ui",
patch: { category: found.categories[0]!.key, selIndex: 0 },

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
SHUTTER_PRESETS,
closedLabelFor,
closedPercentFromPosition,
positionFromClosedPercent,
} from "../shutter";
describe("closedPercentFromPosition", () => {
it("inverts HA's open-percent into the UI's closed-percent", () => {
expect(closedPercentFromPosition(100)).toBe(0); // fully open
expect(closedPercentFromPosition(0)).toBe(100); // fully closed
expect(closedPercentFromPosition(85)).toBe(15); // mostly open
});
});
describe("positionFromClosedPercent", () => {
it("converts every preset back into HA's open-percent convention", () => {
expect(SHUTTER_PRESETS.map((p) => positionFromClosedPercent(p.closedPercent))).toEqual([
100, 50, 15, 0,
]);
});
it("round-trips with closedPercentFromPosition", () => {
expect(positionFromClosedPercent(closedPercentFromPosition(37))).toBe(37);
});
});
describe("closedLabelFor", () => {
it("labels a mostly-open cover (current_position: 85) as mostly open, not closed", () => {
// current_position: 85 means 85% *open* in HA's convention, i.e. 15% closed.
expect(closedLabelFor(closedPercentFromPosition(85))).toBe("15% zu");
expect(closedLabelFor(closedPercentFromPosition(85))).not.toBe("Fast zu");
expect(closedLabelFor(closedPercentFromPosition(85))).not.toBe("Ganz zu");
});
it.each([
[0, "Offen"],
[1, "Offen"],
[39, "39% zu"],
[40, "Halb zu"],
[79, "Halb zu"],
[80, "Fast zu"],
[98, "Fast zu"],
[99, "Ganz zu"],
[100, "Ganz zu"],
])("closedPercent %i -> %s", (closedPercent, expected) => {
expect(closedLabelFor(closedPercent)).toBe(expected);
});
});

View File

@@ -10,15 +10,16 @@ playing), and seeking is the one thing a real player can do that the mockup coul
*/
import type { Album } from "../api/types";
import type { Filter, Mode, Results } from "./search";
import type { Group, Mode, Results } from "./search";
export interface UiState {
search: string;
mode: Mode;
filter: Filter;
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
group: Group | null;
category: string | null;
selIndex: number;
view: "browse" | "play";
view: "browse" | "play" | "room";
openAlbumId: string | null;
showHelp: boolean;
cols: number;
@@ -27,7 +28,7 @@ export interface UiState {
export const initialUiState: UiState = {
search: "",
mode: "albums",
filter: "all",
group: null,
category: null,
selIndex: 0,
view: "browse",
@@ -49,7 +50,7 @@ export type Action =
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const VOLUME_STEP = 10;
export const SEEK_STEP = 15;
@@ -91,9 +92,18 @@ function escape(state: UiState): Action[] {
if (state.showHelp || state.openAlbumId !== null) {
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
}
if (state.view === "play" || state.view === "room") {
return [{ type: "ui", patch: { view: "browse" } }];
}
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
if (state.category !== null) return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
return [{ type: "ui", patch: { group: null, selIndex: 0 } }];
}
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
function isRootShelf(state: UiState): boolean {
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
}
export interface KeyEvent {
@@ -129,10 +139,11 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
switch (key) {
case "Tab": {
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
const currentIndex = state.group ? GROUP_ORDER.indexOf(state.group) : -1;
const next = GROUP_ORDER[(currentIndex + 1) % GROUP_ORDER.length]!;
return [
{ type: "pop", freq: 380 },
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
{ type: "ui", patch: { group: next, selIndex: 0, view: "browse", category: null } },
];
}
case "/":
@@ -142,15 +153,27 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
case "ArrowLeft":
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
case "ArrowDown":
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
return browsing
? moveSelection(state, results, 0, 1)
: [{ type: "volume", delta: -VOLUME_STEP }];
case "ArrowUp":
if (browsing && isRootShelf(state)) {
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
}
return browsing
? moveSelection(state, results, 0, -1)
: [{ type: "volume", delta: VOLUME_STEP }];

15
web/src/lib/oklch.ts Normal file
View File

@@ -0,0 +1,15 @@
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than
* hand-transcribing the oklch->sRGB math, this leans on the browser's own CSS engine:
* set the string as an element's color and read back what the browser resolved it to.
* Only ever called a handful of times (once per fixed swatch), never per render. */
export function oklchToRgb(oklch: string): [number, number, number] {
const el = document.createElement("div");
el.style.color = oklch;
document.body.appendChild(el);
const resolved = getComputedStyle(el).color;
document.body.removeChild(el);
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved);
if (!match) return [255, 255, 255];
return [Number(match[1]), Number(match[2]), Number(match[3])];
}

35
web/src/lib/shutter.ts Normal file
View File

@@ -0,0 +1,35 @@
/** Conversions between the room UI's shutter model and Home Assistant's `cover.*`
* convention. Home Assistant's `current_position`/`position` are percent *open* (100 =
* fully open, 0 = fully closed); the UI (following the design mockup) thinks in percent
* *closed*. Kept pure and separate because that inversion is easy to get backwards. */
export interface ShutterPreset {
label: string;
/** Percent closed: 0 = fully open, 100 = fully closed. */
closedPercent: number;
}
export const SHUTTER_PRESETS: ShutterPreset[] = [
{ label: "Offen", closedPercent: 0 },
{ label: "Halb zu", closedPercent: 50 },
{ label: "Fast zu", closedPercent: 85 },
{ label: "Ganz zu", closedPercent: 100 },
];
/** HA's `current_position` -> the UI's "how closed" percent. */
export function closedPercentFromPosition(position: number): number {
return 100 - position;
}
/** The UI's "how closed" percent -> the `position` `cover.set_cover_position` expects. */
export function positionFromClosedPercent(closedPercent: number): number {
return 100 - closedPercent;
}
export function closedLabelFor(closedPercent: number): string {
if (closedPercent <= 1) return "Offen";
if (closedPercent >= 99) return "Ganz zu";
if (closedPercent >= 80) return "Fast zu";
if (closedPercent >= 40) return "Halb zu";
return `${Math.round(closedPercent)}% zu`;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/app.css";
import "./styles/room.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>

54
web/src/styles/room.css Normal file
View File

@@ -0,0 +1,54 @@
/* "Mein Zimmer" (room control). Ported from claude-design/Mein Zimmer.dc.html - the
same oklch-everywhere approach as app.css, but its own violet hue (~298-310) rather
than app.css's teal (~210), so the two pages read as siblings, not one bleeding into
the other. */
:root {
--room-ink: oklch(20% 0.03 300);
--room-paper: oklch(97% 0.01 302);
--room-accent: oklch(62% 0.15 302);
--room-card-bg: oklch(96% 0.012 300 / 0.55);
--room-shadow: oklch(15% 0.05 300 / 0.35);
}
.room-stage {
background: linear-gradient(
180deg,
oklch(56% 0.14 305) 0%,
oklch(38% 0.12 300) 45%,
oklch(21% 0.08 298) 100%
);
}
.room-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.room-card {
background: var(--room-card-bg);
backdrop-filter: blur(6px);
border-radius: 22px;
padding: 18px 20px 20px;
box-shadow: 0 8px 24px var(--room-shadow);
}
.room-pill {
border: none;
cursor: pointer;
font-size: 15px;
font-weight: 800;
min-height: 52px;
padding: 12px 20px;
border-radius: 999px;
white-space: nowrap;
background: oklch(97% 0.01 302 / 0.18);
color: var(--room-paper);
}
.room-pill[data-active="true"] {
background: var(--room-paper);
color: oklch(26% 0.05 300);
box-shadow: 0 6px 18px oklch(15% 0.05 300 / 0.4);
}