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>
This commit is contained in:
2026-10-08 22:24:02 +02:00
parent de94bd4118
commit cf38ae6fe2
3 changed files with 65 additions and 12 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

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

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!];
} }