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