diff --git a/web/src/components/LightCard.tsx b/web/src/components/LightCard.tsx index a3745b2..1481141 100644 --- a/web/src/components/LightCard.tsx +++ b/web/src/components/LightCard.tsx @@ -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 && (
{swatchRgb.map((swatch) => { - const selected = on && sameRgb(rgb, swatch.rgb); + const selected = on && sameLightColor(rgb, swatch.rgb); return (
-
+
{supportsPosition && (
{SHUTTER_PRESETS.map((preset) => { @@ -185,7 +211,7 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh ▼
-
+ ); diff --git a/web/src/hooks/useHomeAssistant.ts b/web/src/hooks/useHomeAssistant.ts index d397956..3ce4417 100644 --- a/web/src/hooks/useHomeAssistant.ts +++ b/web/src/hooks/useHomeAssistant.ts @@ -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; 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; } } diff --git a/web/src/lib/__tests__/lightColor.test.ts b/web/src/lib/__tests__/lightColor.test.ts new file mode 100644 index 0000000..7696e3c --- /dev/null +++ b/web/src/lib/__tests__/lightColor.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/lightColor.ts b/web/src/lib/lightColor.ts new file mode 100644 index 0000000..0e2a786 --- /dev/null +++ b/web/src/lib/lightColor.ts @@ -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); +}