+
);
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);
+}