From cf38ae6fe2c2c03db2164df1d9fbf0391918f7e8 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Thu, 8 Oct 2026 22:24:02 +0200 Subject: [PATCH] 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 --- python-backend/musicmouse/services/web/api.py | 4 ++ web/src/lib/__tests__/oklch.test.ts | 32 +++++++++++++++ web/src/lib/oklch.ts | 41 +++++++++++++------ 3 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 web/src/lib/__tests__/oklch.test.ts diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index 5084b66..781aedb 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -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 diff --git a/web/src/lib/__tests__/oklch.test.ts b/web/src/lib/__tests__/oklch.test.ts new file mode 100644 index 0000000..ed0b029 --- /dev/null +++ b/web/src/lib/__tests__/oklch.test.ts @@ -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]); + }); +}); diff --git a/web/src/lib/oklch.ts b/web/src/lib/oklch.ts index 810d712..0fbf15d 100644 --- a/web/src/lib/oklch.ts +++ b/web/src/lib/oklch.ts @@ -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!]; }