Port "Mein Zimmer" - the Home Assistant room panel

The second of the web front-end's three pages. Lights get a toggle, five brightness
steps and six colours; shutters get four presets, up/stop/down and a window that
reflects the position Home Assistant reports. Its own violet hue rather than the
player's teal, so the two pages read as siblings rather than one bleeding into the
other - the same reason room.css has its own tokens.

The backend keeps Home Assistant's URL and token and relays the calls, so this client
learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha
means Home Assistant is not configured, which is a normal answer: the tab simply does
not appear.

Polled, not subscribed, and only while the page is on screen. HA's own auth-and-
subscribe websocket is more machinery than a room panel needs, and a lamp's state is
not worth a request every 2.5 seconds when nobody is looking at it - this device has a
music player to stay out of the way of. A tap patches the entity locally and records
when; a poll that was already in flight when the patch landed is dropped for that
entity rather than putting the lamp back to "off" until the next one catches up. That
reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried
over for the same reason.

Nothing on a shutter card animates locally. A real cover reports its own movement and
position, and a client-side guess at where it will end up is precisely what would
fight with that - the design mockup animated it because it had no backend to ask.

Two additions beyond a straight port, both because a kiosk has no pointer:

CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a
front-end whose README claims every screen is reachable from the keyboard. Plain TAB
was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere
else.

--page opens directly on a page, which a kiosk may well want and which is the only way
in with neither keyboard nor pointer.

Verified against the real Home Assistant: the shutter reported open and the lamp off,
and both drew that way. Nothing was switched - those entities are hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 01:02:18 +02:00
parent ec9d617ad8
commit 794126bfdb
9 changed files with 1462 additions and 19 deletions

View File

@@ -2,9 +2,10 @@ import { Theme } from "theme.slint";
import { RoundButton } from "widgets.slint";
import { ShelfView, ResultsView } from "browse.slint";
import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint";
import { RoomView } from "room.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab } from "models.slint";
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, Theme }
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab, Theme }
export component AppWindow inherits Window {
title: "Musik Delfin";
@@ -20,6 +21,17 @@ export component AppWindow inherits Window {
Theme.sea-deep 100%);
// ------------------------------------------------------------------- inputs --
// "music" | "room" | "typing" - named rather than an enum so Rust owns the
// ordering and the labels, as it does for every other string on screen.
in property <string> page: "music";
in property <[Tab]> tabs;
in property <[RoomRow]> room-rows;
in property <[SceneTile]> room-scenes;
in property <[Swatch]> room-swatches;
in property <[string]> shutter-presets;
in property <bool> room-loading: true;
in property <bool> loading: true;
in property <string> splash-text;
in property <string> status-label;
@@ -87,6 +99,13 @@ export component AppWindow inherits Window {
callback open-play-view();
callback close-sheet();
callback play-sheet-track(int);
callback select-page(string);
callback toggle-light(string);
callback set-light-level(string, int);
callback set-light-swatch(string, int);
callback set-shutter-preset(string, int);
callback shutter(string, string);
callback activate-scene(string);
forward-focus: keys;
@@ -111,7 +130,7 @@ export component AppWindow inherits Window {
VerticalLayout {
// ------------------------------------------------------------ header --
if !root.play-view: HorizontalLayout {
if root.page == "music" && !root.play-view: HorizontalLayout {
alignment: center;
spacing: 16px;
padding-top: 18px;
@@ -146,7 +165,7 @@ export component AppWindow inherits Window {
}
// -------------------------------------------------------------- body --
if !root.play-view && root.root-screen: ShelfView {
if root.page == "music" && !root.play-view && root.root-screen: ShelfView {
shelves: root.shelves;
shelf-row: root.shelf-row;
sel-index: root.sel-index;
@@ -154,7 +173,7 @@ export component AppWindow inherits Window {
enter-category(s, key) => { root.enter-category(s, key); }
}
if !root.play-view && !root.root-screen: ResultsView {
if root.page == "music" && !root.play-view && !root.root-screen: ResultsView {
group-label: root.group-label;
category-heading: root.category-heading;
album-heading: root.album-heading;
@@ -174,7 +193,39 @@ export component AppWindow inherits Window {
play-album(id) => { root.play-album(id); }
}
if root.play-view: PlayView {
if root.page == "room": RoomView {
rows: root.room-rows;
scenes: root.room-scenes;
swatches: root.room-swatches;
presets: root.shutter-presets;
loading: root.room-loading;
toggle-light(id) => { root.toggle-light(id); }
set-level(id, n) => { root.set-light-level(id, n); }
set-swatch(id, i) => { root.set-light-swatch(id, i); }
set-preset(id, i) => { root.set-shutter-preset(id, i); }
shutter(id, what) => { root.shutter(id, what); }
activate-scene(id) => { root.activate-scene(id); }
}
if root.page == "typing": VerticalLayout {
alignment: center;
Text {
text: "Tippen";
font-size: 34px;
font-weight: 900;
color: Theme.paper;
horizontal-alignment: center;
}
Text {
text: "Noch nicht portiert.";
font-size: Theme.text-heading;
font-weight: 700;
color: Theme.text-dim;
horizontal-alignment: center;
}
}
if root.page == "music" && root.play-view: PlayView {
now: root.now;
playing: root.playing;
progress: root.progress;
@@ -196,7 +247,7 @@ export component AppWindow inherits Window {
// rather than inside the component: a plain element takes its *preferred* width
// from its content, not its parent's width, so a bar that only declared a height
// left gaps for the grid behind it to show through.
if !root.play-view && root.has-bar: PlayerBar {
if root.page == "music" && !root.play-view && root.has-bar: PlayerBar {
x: 0;
y: parent.height - self.height;
width: parent.width;
@@ -223,6 +274,41 @@ export component AppWindow inherits Window {
clicked => { root.go-back(); }
}
// The vertical tab rail. Right edge, vertically centred - the one fixed control
// that is in the same place on every page.
tab-rail := Rectangle {
x: parent.width - self.width - 16px;
y: (parent.height - self.height) / 2;
width: 60px;
height: rail.preferred-height;
border-radius: 30px;
background: Theme.paper.with-alpha(0.22);
border-width: 1px;
border-color: Theme.paper.with-alpha(0.3);
rail := VerticalLayout {
padding: 8px;
spacing: 6px;
for tab[i] in root.tabs: Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
background: root.page == tab.id ? Theme.accent : transparent;
animate background { duration: 150ms; }
Text {
text: tab.icon;
font-size: 20px;
color: root.page == tab.id ? white : Theme.paper;
vertical-alignment: center;
horizontal-alignment: center;
}
TouchArea {
clicked => { root.select-page(tab.id); }
}
}
}
}
if root.show-sheet: AlbumSheet {
width: 100%;
height: 100%;

View File

@@ -89,3 +89,63 @@ export struct NowPlaying {
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
remaining-label: string,
}
// ------------------------------------------------------------- Mein Zimmer --
// One device card on the room page. Lights and shutters share a struct rather than
// having one each, because the grid renders them in the order Home Assistant's config
// lists them and two models would lose that order. `kind` is what the view branches on.
export struct RoomCard {
entity-id: string,
name: string,
// "light" | "shutter"
kind: string,
on: bool,
// --- light ---
is-color: bool,
// 0..5, matching the five brightness buttons.
level: int,
tint: color,
// Which of the six swatches the light is currently set to, or -1.
swatch: int,
// --- shutter ---
// Percent *closed*: 0 is fully open. Home Assistant reports the opposite; the
// inversion happens once, in Rust.
closed-percent: float,
// "Fährt runter …", or the preset name the position matches.
status: string,
// "" | "up" | "down"
moving: string,
supports-position: bool,
// Which of the four presets the position matches, or -1.
preset: int,
}
// The room grid, pre-chunked into rows for the same reason the album grid is: the
// view lays rows out, and how many fit is a function of the window width, which Rust
// is the one that knows.
export struct RoomRow {
cards: [RoomCard],
}
export struct SceneTile {
entity-id: string,
name: string,
active: bool,
}
// One of the six colours a colour-capable light can be set to.
export struct Swatch {
name: string,
tint: color,
}
// One entry in the vertical tab rail. Built in Rust like every other string on
// screen, so the page order, the icons and the German labels live in one place.
export struct Tab {
id: string,
icon: string,
label: string,
}

View File

@@ -0,0 +1,460 @@
// "Mein Zimmer" - the Home Assistant room panel.
//
// Its own violet hue rather than the player's teal, so the two pages read as siblings.
// Lights and shutters are drawn from one `RoomCard` model in Home Assistant's own
// config order; `kind` is what each card branches on.
import { Theme } from "theme.slint";
import { RoomCard, RoomRow, SceneTile, Swatch } from "models.slint";
component ToggleSwitch inherits Rectangle {
in property <bool> on;
callback clicked();
width: 74px;
height: 42px;
border-radius: 21px;
background: root.on ? Theme.room-accent : Theme.room-toggle-off;
animate background { duration: 140ms; }
Rectangle {
x: root.on ? parent.width - self.width - 4px : 4px;
y: 4px;
width: 34px;
height: 34px;
border-radius: 17px;
background: white;
drop-shadow-color: #0f051d66;
drop-shadow-blur: 6px;
drop-shadow-offset-y: 2px;
animate x { duration: 140ms; easing: ease-out; }
}
TouchArea {
clicked => { root.clicked(); }
}
}
// A flat labelled button - the shutter presets and the up/stop/down row.
component RoomButton inherits Rectangle {
in property <string> label;
in property <bool> active;
in property <brush> active-fill: Theme.room-accent;
in property <length> text-size: 14px;
callback clicked();
border-radius: 14px;
background: root.active ? root.active-fill : (touch.pressed ? Theme.room-off-bg : Theme.room-btn);
animate background { duration: 120ms; }
Text {
text: root.label;
font-size: root.text-size;
font-weight: 800;
color: root.active ? white : Theme.room-btn-fg;
vertical-alignment: center;
horizontal-alignment: center;
}
touch := TouchArea {
clicked => { root.clicked(); }
}
}
component RoomCardFrame inherits Rectangle {
in property <bool> glow;
in property <color> glow-tint;
background: Theme.room-card.with-alpha(0.55);
border-radius: 22px;
// Standing in for the web card's `0 0 0 3px color-mix(...)` ring: a lit lamp's own
// colour around its card is how the page reads at a glance from across the room.
border-width: root.glow ? 3px : 0px;
border-color: root.glow-tint;
drop-shadow-color: #0f051d59;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
}
component LightCard inherits RoomCardFrame {
in property <RoomCard> card;
in property <[Swatch]> swatches;
callback toggle();
callback set-level(int);
callback set-swatch(int);
glow: root.card.on;
glow-tint: root.card.tint;
VerticalLayout {
padding-left: 20px;
padding-right: 20px;
padding-top: 18px;
padding-bottom: 20px;
spacing: 14px;
HorizontalLayout {
spacing: 14px;
Rectangle {
width: 56px;
height: 56px;
border-radius: 16px;
background: root.card.on ? root.card.tint : Theme.room-off-bg;
drop-shadow-color: root.card.on ? root.card.tint : transparent;
drop-shadow-blur: 22px;
// A pendant for a colour lamp, a ceiling fitting for a plain one -
// drawn rather than iconographic, because there is no icon font here.
Rectangle {
width: 34px;
height: 34px;
x: 11px;
y: 11px;
if root.card.is-color: Path {
width: 100%;
height: 100%;
viewbox-width: 48;
viewbox-height: 48;
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
stroke-width: 3px;
commands: "M 24 7 A 17 17 0 1 1 23.9 7 Z";
}
if !root.card.is-color: Path {
width: 100%;
height: 100%;
viewbox-width: 48;
viewbox-height: 48;
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
stroke-width: 3px;
commands: "M 9 30 L 24 11 L 39 30 Z M 24 4 L 24 11";
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.card.name;
font-size: 19px;
font-weight: 900;
color: Theme.room-ink;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
ToggleSwitch {
on: root.card.on;
clicked => { root.toggle(); }
}
}
}
// Five steps rather than a slider: a child aiming at a bar hits a level.
HorizontalLayout {
spacing: 6px;
height: 46px;
opacity: root.card.on ? 1.0 : 0.55;
for step[i] in [1, 2, 3, 4, 5]: Rectangle {
border-radius: 12px;
background: root.card.on && root.card.level >= step
? root.card.tint
: Theme.room-off-bg;
animate background { duration: 120ms; }
TouchArea {
clicked => { root.set-level(step); }
}
}
}
if root.card.is-color: HorizontalLayout {
spacing: 10px;
height: 46px;
alignment: start;
for swatch[i] in root.swatches: Rectangle {
width: 46px;
height: 46px;
border-radius: 23px;
background: swatch.tint;
border-width: 4px;
border-color: root.card.on && root.card.swatch == i
? Theme.room-btn-fg
: Theme.room-card.with-alpha(0.8);
drop-shadow-color: #0f051d59;
drop-shadow-blur: 8px;
drop-shadow-offset-y: 3px;
TouchArea {
clicked => { root.set-swatch(i); }
}
}
}
}
}
component ShutterCard inherits RoomCardFrame {
in property <RoomCard> card;
in property <[string]> presets;
callback set-preset(int);
callback open();
callback stop();
callback close();
VerticalLayout {
padding-left: 20px;
padding-right: 20px;
padding-top: 18px;
padding-bottom: 20px;
spacing: 16px;
HorizontalLayout {
spacing: 14px;
alignment: start;
Rectangle {
width: 56px;
height: 56px;
border-radius: 16px;
background: Theme.room-icon-bg;
Path {
x: 11px;
y: 11px;
width: 34px;
height: 34px;
viewbox-width: 48;
viewbox-height: 48;
stroke: Theme.room-icon-fg;
stroke-width: 3px;
commands: "M 7 7 L 41 7 L 41 41 L 7 41 Z M 7 16 L 41 16 M 7 24 L 41 24 M 7 32 L 41 32";
}
}
VerticalLayout {
alignment: center;
Text {
text: root.card.name;
font-size: 19px;
font-weight: 900;
color: Theme.room-ink;
overflow: elide;
}
Text {
text: root.card.status;
font-size: 13px;
font-weight: 700;
color: Theme.room-sub.with-alpha(0.8);
}
}
}
HorizontalLayout {
spacing: 18px;
// The window itself: sky behind, slats drawn down from the top to however
// closed the shutter is. Real covers report their own position, so this
// only ever reflects Home Assistant - no client-side movement animation.
Rectangle {
width: 104px;
height: 140px;
border-radius: 12px;
border-width: 3px;
border-color: Theme.room-icon-fg;
clip: true;
background: @linear-gradient(180deg, Theme.room-window-a 0%, Theme.room-window-b 100%);
Rectangle {
x: 0;
y: 0;
width: 100%;
height: parent.height * clamp(root.card.closed-percent / 100, 0.0, 1.0);
clip: true;
animate height { duration: 200ms; easing: linear; }
// The slats, as a fixed ladder clipped by the parent's height.
for slat[i] in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]: Rectangle {
y: i * 12px;
width: 100%;
height: 12px;
background: Theme.room-slat-a;
Rectangle {
y: 9px;
width: 100%;
height: 3px;
background: Theme.room-slat-b;
}
}
}
}
VerticalLayout {
spacing: 10px;
horizontal-stretch: 1;
if root.card.supports-position: VerticalLayout {
spacing: 8px;
for row[r] in [0, 1]: HorizontalLayout {
spacing: 8px;
height: 48px;
for col[c] in [0, 1]: RoomButton {
label: root.presets[r * 2 + c];
active: root.card.preset == r * 2 + c;
clicked => { root.set-preset(r * 2 + c); }
}
}
}
HorizontalLayout {
spacing: 8px;
height: 52px;
RoomButton {
label: "▲";
text-size: 20px;
active: root.card.moving == "up";
clicked => { root.open(); }
}
RoomButton {
label: "■";
text-size: 18px;
active: true;
active-fill: Theme.room-stop;
clicked => { root.stop(); }
}
RoomButton {
label: "▼";
text-size: 20px;
active: root.card.moving == "down";
clicked => { root.close(); }
}
}
}
}
}
}
export component RoomView inherits Rectangle {
in property <[RoomRow]> rows;
in property <[SceneTile]> scenes;
in property <[Swatch]> swatches;
in property <[string]> presets;
in property <bool> loading;
callback toggle-light(string);
callback set-level(string, int);
callback set-swatch(string, int);
callback set-preset(string, int);
callback shutter(string, string); // entity, "open" | "stop" | "close"
callback activate-scene(string);
background: @linear-gradient(180deg,
Theme.room-top 0%,
Theme.room-mid 45%,
Theme.room-deep 100%);
VerticalLayout {
padding-top: 18px;
spacing: 6px;
Text {
text: "Mein Zimmer";
font-size: 34px;
font-weight: 900;
color: Theme.room-paper;
horizontal-alignment: center;
}
if root.loading: VerticalLayout {
alignment: center;
Text {
text: "Einen Moment …";
font-size: 22px;
font-weight: 800;
color: Theme.room-paper;
horizontal-alignment: center;
}
}
if !root.loading: Flickable {
content-height: body.preferred-height;
content-width: self.width;
body := VerticalLayout {
padding-left: 32px;
padding-right: Theme.rail-clearance;
padding-top: 14px;
padding-bottom: 64px;
spacing: 30px;
alignment: start;
if root.scenes.length > 0: VerticalLayout {
spacing: 14px;
Text {
text: "Szenen";
font-size: 19px;
font-weight: 900;
color: Theme.room-paper;
horizontal-alignment: center;
}
HorizontalLayout {
alignment: center;
spacing: 12px;
for scene in root.scenes: Rectangle {
height: 52px;
width: pill.preferred-width;
border-radius: 26px;
background: scene.active
? Theme.room-paper
: Theme.room-paper.with-alpha(0.18);
animate background { duration: 140ms; }
pill := HorizontalLayout {
padding-left: 20px;
padding-right: 20px;
spacing: 8px;
VerticalLayout {
alignment: center;
Text {
text: "✨";
font-size: 22px;
}
}
VerticalLayout {
alignment: center;
Text {
text: scene.name;
font-size: 15px;
font-weight: 800;
color: scene.active ? Theme.room-btn-fg : Theme.room-paper;
}
}
}
TouchArea {
clicked => { root.activate-scene(scene.entity-id); }
}
}
}
}
// Both kinds share one cell width, so the grid stays a grid whatever
// mix of devices Home Assistant reports.
for row in root.rows: HorizontalLayout {
alignment: center;
spacing: 20px;
for card in row.cards: Rectangle {
width: Theme.room-card-width;
height: card.kind == "shutter" ? 268px : 246px;
if card.kind == "light": LightCard {
width: 100%;
card: card;
swatches: root.swatches;
toggle => { root.toggle-light(card.entity-id); }
set-level(n) => { root.set-level(card.entity-id, n); }
set-swatch(i) => { root.set-swatch(card.entity-id, i); }
}
if card.kind == "shutter": ShutterCard {
width: 100%;
card: card;
presets: root.presets;
set-preset(i) => { root.set-preset(card.entity-id, i); }
open => { root.shutter(card.entity-id, "open"); }
stop => { root.shutter(card.entity-id, "stop"); }
close => { root.shutter(card.entity-id, "close"); }
}
}
}
}
}
}
}

View File

@@ -48,6 +48,32 @@ export global Theme {
out property <color> row-idle: #f7fcfd1a;
out property <color> row-active: #f7fcfd38;
// --------------------------------------------------- Mein Zimmer (violet) --
// Its own hue (~298-310) rather than the player's teal (~210), so the two pages
// read as siblings rather than one bleeding into the other. Same oklch-converted-
// once treatment as above.
out property <color> room-ink: #181322; // oklch(20% 0.03 300)
out property <color> room-paper: #f6f4fb; // oklch(97% 0.01 302)
out property <color> room-accent: #986dd0; // oklch(62% 0.15 302)
out property <color> room-card: #f3f0f9; // oklch(96% 0.012 300)
out property <color> room-top: #885cb5; // oklch(56% 0.14 305)
out property <color> room-mid: #4d2f77; // oklch(38% 0.12 300)
out property <color> room-deep: #1e0c37; // oklch(21% 0.08 298)
out property <color> room-icon-bg: #dad3e9; // oklch(88% 0.03 300)
out property <color> room-icon-fg: #3e3451; // oklch(35% 0.05 300)
out property <color> room-off-bg: #d9d5e3; // oklch(88% 0.02 300)
out property <color> room-off-fg: #736f7c; // oklch(55% 0.02 300)
out property <color> room-btn: #dcd8e6; // oklch(89% 0.02 300)
out property <color> room-btn-fg: #2c243a; // oklch(28% 0.04 300)
out property <color> room-stop: #e85854; // oklch(65% 0.18 25)
out property <color> room-toggle-off: #cbc9d0;
out property <color> room-sub: #585264; // oklch(45% 0.03 300)
out property <color> room-slat-a: #a19baf; // oklch(70% 0.03 300)
out property <color> room-slat-b: #7d778a; // oklch(58% 0.03 300)
out property <color> room-window-a: #87d1e8; // oklch(82% 0.08 220)
out property <color> room-window-b: #50afb4; // oklch(70% 0.09 200)
out property <length> room-card-width: 380px;
// ------------------------------------------------------------------- metrics --
out property <length> radius-panel: 22px;
out property <length> radius-card: 16px;