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:
@@ -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 },
|
||||
|
||||
51
web/src/lib/__tests__/shutter.test.ts
Normal file
51
web/src/lib/__tests__/shutter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
15
web/src/lib/oklch.ts
Normal 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
35
web/src/lib/shutter.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user