3 Commits

Author SHA1 Message Date
cf38ae6fe2 Room page: colour swatches send their colour, not white
oklchToRgb read the colour back from the browser's CSS engine and expected
rgb(...). Current Chromium (the kiosk's 153) resolves an oklch colour to
oklch(...), so the match failed and every swatch fell back to [255, 255, 255]:
tapping a colour set the light to white. It now does the OKLab maths itself.

Also log a Home Assistant call that fails in the proxy: httpx only logs a
request once it has a response, so a call that timed out left no trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:24:02 +02:00
de94bd4118 Room page: taps stay visible, swatches match, an unreachable blind says so
- A tapped light or blind state now holds for 4s against polled state: a poll
  that starts just after the tap can still return the device's old state
  (a Hue light reports about a second after HA answers), which flipped the
  card back and looked like the tap had failed.
- Swatches match what a light reports, not only what it was sent: a Hue
  light hands rgb_color back at full intensity, so the exact comparison never
  showed a swatch as selected.
- The Rollo card says "Nicht erreichbar" and disables its buttons while HA has
  the cover as unavailable, instead of showing "Offen"; up/down show the
  blind as moving at once, so they are not pressed again and again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:23:55 +02:00
13d14c00aa Clock: bubbles on the cards' side while a countdown is showing
With countdown cards the watch moves left and covers roughly 6-49% of the
width, where the plain field's bubbles would rise behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:48:02 +02:00
9 changed files with 183 additions and 28 deletions

View File

@@ -376,6 +376,10 @@ def build_router(
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
) )
except httpx2.HTTPError as exc: except httpx2.HTTPError as exc:
# httpx logs a request only once it has a response, so without this a call
# that times out - a Homematic command the device never acknowledges can
# keep Home Assistant from answering - leaves no trace at all.
_log.warning("Home Assistant %s %s failed: %r", method, path, exc)
raise HTTPException( raise HTTPException(
status_code=502, detail=f"Home Assistant unreachable: {exc}" status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc ) from exc

View File

@@ -19,9 +19,9 @@ import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme"; import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Drift, type DriftField } from "./Drift"; import { Drift, type DriftField } from "./Drift";
/** Beside the watch only, never behind it: on a 1080p kiosk the watch covers roughly /** Beside the watch only, never behind it: a bubble rising behind it would drag the
* 28-72% of the width, and a bubble rising behind it would drag the watch into every * watch into every frame the Pi redraws. On a 1080p kiosk the watch covers roughly
* frame the Pi redraws. */ * 28-72% of the width when it is alone... */
const BUBBLES: DriftField = [ const BUBBLES: DriftField = [
[5, 14, 0.5, 9, 0], [5, 14, 0.5, 9, 0],
[12, 22, 0.5, 12, -3], [12, 22, 0.5, 12, -3],
@@ -33,6 +33,19 @@ const BUBBLES: DriftField = [
[95, 18, 0.5, 13, -9], [95, 18, 0.5, 13, -9],
]; ];
/** ...and 6-49% when countdown cards share the screen, so then they all rise on the
* cards' side. The cards are cheap to redraw (no backdrop blur under `?pi=1`). */
const BUBBLES_WITH_CARDS: DriftField = [
[52, 14, 0.5, 9, 0],
[57, 22, 0.5, 12, -3],
[64, 10, 0.5, 8, -6],
[70, 16, 0.5, 11, -2],
[77, 24, 0.5, 14, -5],
[84, 12, 0.5, 10, -7],
[90, 12, 0.5, 9, -1],
[96, 18, 0.5, 13, -9],
];
/** The four colours every ring of the dial cycles through, starting at 12. */ /** The four colours every ring of the dial cycles through, starting at 12. */
const HUES = ["205", "175", "245", "pink"] as const; const HUES = ["205", "175", "245", "pink"] as const;
@@ -62,7 +75,10 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
aria-label="Uhr" aria-label="Uhr"
> >
{visible && SHOW_DECORATIVE_ANIMATIONS && ( {visible && SHOW_DECORATIVE_ANIMATIONS && (
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} /> <Drift
field={countdowns.length > 0 ? BUBBLES_WITH_CARDS : BUBBLES}
bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`}
/>
)} )}
{clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />} {clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />}

View File

@@ -5,6 +5,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { HaDevice, HaEntityState } from "../api/types"; import type { HaDevice, HaEntityState } from "../api/types";
import { sameLightColor } from "../lib/lightColor";
import { oklchToRgb } from "../lib/oklch"; import { oklchToRgb } from "../lib/oklch";
import { SKIN } from "../lib/skins"; import { SKIN } from "../lib/skins";
@@ -23,10 +24,6 @@ interface Props {
onManualChange: () => 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) { export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) {
const swatchRgb = useMemo( const swatchRgb = useMemo(
() => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })), () => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })),
@@ -116,7 +113,7 @@ export function LightCard({ device, state, callService, optimistic, onManualChan
{isColor && ( {isColor && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
{swatchRgb.map((swatch) => { {swatchRgb.map((swatch) => {
const selected = on && sameRgb(rgb, swatch.rgb); const selected = on && sameLightColor(rgb, swatch.rgb);
return ( return (
<button <button
key={swatch.name} key={swatch.name}

View File

@@ -27,9 +27,18 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0; const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0;
const closedPercent = closedPercentFromPosition(position ?? 0); const closedPercent = closedPercentFromPosition(position ?? 0);
// A Homematic blind that did not acknowledge a command goes "unavailable" until it is
// heard from again. Showing "Offen" then (no position reads as fully open) invites
// tapping away at buttons that cannot reach it.
const unreachable = state?.state === "unavailable";
const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null; const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null;
const statusLabel = const statusLabel = unreachable
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent); ? "Nicht erreichbar"
: moving === "down"
? "Fährt runter …"
: moving === "up"
? "Fährt hoch …"
: closedLabelFor(closedPercent);
const setPosition = (target: number) => { const setPosition = (target: number) => {
onManualChange(); onManualChange();
@@ -42,12 +51,16 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
}); });
}; };
// Shown as moving at once: the blind can take a while to report, and a button that
// seems to do nothing gets pressed again - which a radio blind copes with badly.
const open = () => { const open = () => {
onManualChange(); onManualChange();
optimistic(device.entity_id, { state: "opening" });
void callService("cover", "open_cover", { entity_id: device.entity_id }); void callService("cover", "open_cover", { entity_id: device.entity_id });
}; };
const close = () => { const close = () => {
onManualChange(); onManualChange();
optimistic(device.entity_id, { state: "closing" });
void callService("cover", "close_cover", { entity_id: device.entity_id }); void callService("cover", "close_cover", { entity_id: device.entity_id });
}; };
const stop = () => { const stop = () => {
@@ -111,7 +124,20 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
/> />
</div> </div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 10 }}> <fieldset
disabled={unreachable}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 10,
margin: 0,
padding: 0,
border: "none",
minWidth: 0,
opacity: unreachable ? 0.45 : 1,
}}
>
{supportsPosition && ( {supportsPosition && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{SHUTTER_PRESETS.map((preset) => { {SHUTTER_PRESETS.map((preset) => {
@@ -185,7 +211,7 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
▼ ▼
</button> </button>
</div> </div>
</div> </fieldset>
</div> </div>
</div> </div>
); );

View File

@@ -12,6 +12,12 @@ import type { HaConfig, HaEntityState } from "../api/types";
const POLL_INTERVAL_MS = 2500; const POLL_INTERVAL_MS = 2500;
/** How long a tap's optimistic state wins over polled state. A device reports back
* after HA has already answered the service call - a Hue light about a second later -
* so a poll that *starts* just after the tap can still return the old colour and flip
* the card back, which reads as "the tap did not work". */
const OPTIMISTIC_HOLD_MS = 4000;
export interface HomeAssistant { export interface HomeAssistant {
states: Record<string, HaEntityState>; states: Record<string, HaEntityState>;
loading: boolean; loading: boolean;
@@ -52,9 +58,10 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
setStates((previous) => { setStates((previous) => {
const next = { ...previous }; const next = { ...previous };
for (const [entityId, state] of Object.entries(fresh)) { for (const [entityId, state] of Object.entries(fresh)) {
// Only apply this result if no optimistic patch landed after the request // Only apply this result if it went out well after the last optimistic
// for it went out - otherwise it's stale and would undo a newer change. // patch - otherwise it may predate the device catching up and would undo a
if (startedAt >= (optimisticAt.current[entityId] ?? 0)) { // newer change. See OPTIMISTIC_HOLD_MS.
if (startedAt >= (optimisticAt.current[entityId] ?? -Infinity) + OPTIMISTIC_HOLD_MS) {
next[entityId] = state; next[entityId] = state;
} }
} }

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { sameLightColor } from "../lightColor";
// What the Hue "Fluter" on musicdolphin reported back for each dolphin swatch it was sent.
const SENT_AND_REPORTED = [
[[240, 220, 177], [255, 233, 188]],
[[239, 204, 54], [255, 218, 60]],
[[241, 77, 76], [255, 85, 83]],
[[0, 171, 237], [0, 185, 255]],
[[66, 203, 128], [85, 255, 162]],
[[177, 106, 224], [203, 124, 255]],
] as const;
describe("sameLightColor", () => {
it("recognises each swatch in what a Hue light reports back", () => {
for (const [sent, reported] of SENT_AND_REPORTED) {
expect(sameLightColor(reported, sent)).toBe(true);
}
});
it("tells the swatches apart", () => {
for (const [, reported] of SENT_AND_REPORTED) {
const matches = SENT_AND_REPORTED.filter(([sent]) => sameLightColor(reported, sent));
expect(matches).toHaveLength(1);
}
});
it("does not take black for every colour", () => {
expect(sameLightColor([0, 0, 0], [241, 77, 76])).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { DINOSAUR } from "../skins/dinosaur";
import { DOLPHIN } from "../skins/dolphin";
import { oklchToRgb } from "../oklch";
describe("oklchToRgb", () => {
it("converts the dolphin swatches", () => {
// Cross-checked against an independent OKLab implementation.
expect(DOLPHIN.lightSwatches.map((swatch) => oklchToRgb(swatch.oklch))).toEqual([
[240, 220, 177],
[239, 204, 54],
[241, 77, 76],
[0, 171, 237],
[66, 203, 128],
[177, 106, 224],
]);
});
it("gives every swatch of every skin its own colour", () => {
for (const skin of [DOLPHIN, DINOSAUR]) {
const colours = skin.lightSwatches.map((swatch) => oklchToRgb(swatch.oklch).join());
expect(new Set(colours).size).toBe(colours.length);
}
});
it("knows black and white", () => {
expect(oklchToRgb("oklch(0% 0 0)")).toEqual([0, 0, 0]);
expect(oklchToRgb("oklch(100% 0 0)")).toEqual([255, 255, 255]);
expect(oklchToRgb("oklch(1 0 0)")).toEqual([255, 255, 255]);
});
});

24
web/src/lib/lightColor.ts Normal file
View File

@@ -0,0 +1,24 @@
/** Whether a light is showing one of the room page's colour swatches.
*
* Home Assistant does not hand back the `rgb_color` it was sent. A light that works in
* xy (Hue, for one) reports its colour at full intensity - brightness is a separate
* attribute - and rounded through its own colour space: coral sent as [241, 77, 76]
* comes back as [255, 85, 83]. So both sides are scaled to full intensity first and
* then compared with some slack. */
export type Rgb = readonly [number, number, number];
/** Per channel, after scaling. The six dolphin swatches came back within 4. */
const TOLERANCE = 10;
function atFullIntensity(rgb: Rgb): Rgb {
const max = Math.max(...rgb);
if (max === 0) return rgb;
return [(rgb[0] * 255) / max, (rgb[1] * 255) / max, (rgb[2] * 255) / max];
}
export function sameLightColor(reported: Rgb, swatch: Rgb): boolean {
const a = atFullIntensity(reported);
const b = atFullIntensity(swatch);
return [0, 1, 2].every((i) => Math.abs(a[i]! - b[i]!) <= TOLERANCE);
}

View File

@@ -1,15 +1,32 @@
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than /** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch.
* 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. * Computed here rather than read back from the browser's CSS engine, which is what this
* Only ever called a handful of times (once per fixed swatch), never per render. */ * used to do: current Chromium resolves an oklch colour to `oklch(...)`, not `rgb(...)`
* (CSS Color 4), so reading it back failed on the kiosk and every swatch became white.
* The maths is Björn Ottosson's OKLab -> linear sRGB, then the sRGB transfer curve;
* colours outside sRGB are clipped per channel. */
export function oklchToRgb(oklch: string): [number, number, number] { export function oklchToRgb(oklch: string): [number, number, number] {
const el = document.createElement("div"); const match = /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)/.exec(oklch);
el.style.color = oklch; if (!match) throw new Error(`not an oklch(L C H) colour: ${oklch}`);
document.body.appendChild(el); const lightness = Number(match[1]) / (match[2] ? 100 : 1);
const resolved = getComputedStyle(el).color; const chroma = Number(match[3]);
document.body.removeChild(el); const hue = (Number(match[4]) * Math.PI) / 180;
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved); const a = chroma * Math.cos(hue);
if (!match) return [255, 255, 255]; const b = chroma * Math.sin(hue);
return [Number(match[1]), Number(match[2]), Number(match[3])]; const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const linear = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
] as const;
const [red, green, blue] = linear.map((channel) => {
const clipped = Math.min(1, Math.max(0, channel));
const encoded = clipped <= 0.0031308 ? 12.92 * clipped : 1.055 * clipped ** (1 / 2.4) - 0.055;
return Math.round(encoded * 255);
});
return [red!, green!, blue!];
} }