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
)
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(
status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc

View File

@@ -19,9 +19,9 @@ import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Drift, type DriftField } from "./Drift";
/** Beside the watch only, never behind it: on a 1080p kiosk the watch covers roughly
* 28-72% of the width, and a bubble rising behind it would drag the watch into every
* frame the Pi redraws. */
/** Beside the watch only, never behind it: a bubble rising behind it would drag the
* watch into every frame the Pi redraws. On a 1080p kiosk the watch covers roughly
* 28-72% of the width when it is alone... */
const BUBBLES: DriftField = [
[5, 14, 0.5, 9, 0],
[12, 22, 0.5, 12, -3],
@@ -33,6 +33,19 @@ const BUBBLES: DriftField = [
[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. */
const HUES = ["205", "175", "245", "pink"] as const;
@@ -62,7 +75,10 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
aria-label="Uhr"
>
{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} />}

View File

@@ -5,6 +5,7 @@
import { useMemo } from "react";
import type { HaDevice, HaEntityState } from "../api/types";
import { sameLightColor } from "../lib/lightColor";
import { oklchToRgb } from "../lib/oklch";
import { SKIN } from "../lib/skins";
@@ -23,10 +24,6 @@ interface Props {
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) })),
@@ -116,7 +113,7 @@ export function LightCard({ device, state, callService, optimistic, onManualChan
{isColor && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
{swatchRgb.map((swatch) => {
const selected = on && sameRgb(rgb, swatch.rgb);
const selected = on && sameLightColor(rgb, swatch.rgb);
return (
<button
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 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 statusLabel =
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent);
const statusLabel = unreachable
? "Nicht erreichbar"
: moving === "down"
? "Fährt runter …"
: moving === "up"
? "Fährt hoch …"
: closedLabelFor(closedPercent);
const setPosition = (target: number) => {
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 = () => {
onManualChange();
optimistic(device.entity_id, { state: "opening" });
void callService("cover", "open_cover", { entity_id: device.entity_id });
};
const close = () => {
onManualChange();
optimistic(device.entity_id, { state: "closing" });
void callService("cover", "close_cover", { entity_id: device.entity_id });
};
const stop = () => {
@@ -111,7 +124,20 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
/>
</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 && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{SHUTTER_PRESETS.map((preset) => {
@@ -185,7 +211,7 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
▼
</button>
</div>
</div>
</fieldset>
</div>
</div>
);

View File

@@ -12,6 +12,12 @@ import type { HaConfig, HaEntityState } from "../api/types";
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 {
states: Record<string, HaEntityState>;
loading: boolean;
@@ -52,9 +58,10 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
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)) {
// Only apply this result if it went out well after the last optimistic
// patch - otherwise it may predate the device catching up and would undo a
// newer change. See OPTIMISTIC_HOLD_MS.
if (startedAt >= (optimisticAt.current[entityId] ?? -Infinity) + OPTIMISTIC_HOLD_MS) {
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
* 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. */
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch.
*
* Computed here rather than read back from the browser's CSS engine, which is what this
* 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] {
const el = document.createElement("div");
el.style.color = oklch;
document.body.appendChild(el);
const resolved = getComputedStyle(el).color;
document.body.removeChild(el);
const match = /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)/.exec(oklch);
if (!match) throw new Error(`not an oklch(L C H) colour: ${oklch}`);
const lightness = Number(match[1]) / (match[2] ? 100 : 1);
const chroma = Number(match[3]);
const hue = (Number(match[4]) * Math.PI) / 180;
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])];
const a = chroma * Math.cos(hue);
const b = chroma * Math.sin(hue);
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!];
}