Room page: taps stay visible, swatches match, an unreachable blind says so

- A tapped light or blind state now holds for 4s against polled state: a poll
  that starts just after the tap can still return the device's old state
  (a Hue light reports about a second after HA answers), which flipped the
  card back and looked like the tap had failed.
- Swatches match what a light reports, not only what it was sent: a Hue
  light hands rgb_color back at full intensity, so the exact comparison never
  showed a swatch as selected.
- The Rollo card says "Nicht erreichbar" and disables its buttons while HA has
  the cover as unavailable, instead of showing "Offen"; up/down show the
  blind as moving at once, so they are not pressed again and again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:23:55 +02:00
parent 13d14c00aa
commit de94bd4118
5 changed files with 98 additions and 12 deletions

View File

@@ -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 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
{swatchRgb.map((swatch) => {
const selected = on && sameRgb(rgb, swatch.rgb);
const selected = on && sameLightColor(rgb, swatch.rgb);
return (
<button
key={swatch.name}

View File

@@ -27,9 +27,18 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0;
const closedPercent = closedPercentFromPosition(position ?? 0);
// A Homematic blind that did not acknowledge a command goes "unavailable" until it is
// heard from again. Showing "Offen" then (no position reads as fully open) invites
// tapping away at buttons that cannot reach it.
const unreachable = state?.state === "unavailable";
const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null;
const statusLabel =
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent);
const statusLabel = unreachable
? "Nicht erreichbar"
: moving === "down"
? "Fährt runter …"
: moving === "up"
? "Fährt hoch …"
: closedLabelFor(closedPercent);
const setPosition = (target: number) => {
onManualChange();
@@ -42,12 +51,16 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
});
};
// Shown as moving at once: the blind can take a while to report, and a button that
// seems to do nothing gets pressed again - which a radio blind copes with badly.
const open = () => {
onManualChange();
optimistic(device.entity_id, { state: "opening" });
void callService("cover", "open_cover", { entity_id: device.entity_id });
};
const close = () => {
onManualChange();
optimistic(device.entity_id, { state: "closing" });
void callService("cover", "close_cover", { entity_id: device.entity_id });
};
const stop = () => {
@@ -111,7 +124,20 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
/>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 10 }}>
<fieldset
disabled={unreachable}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 10,
margin: 0,
padding: 0,
border: "none",
minWidth: 0,
opacity: unreachable ? 0.45 : 1,
}}
>
{supportsPosition && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{SHUTTER_PRESETS.map((preset) => {
@@ -185,7 +211,7 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
▼
</button>
</div>
</div>
</fieldset>
</div>
</div>
);

View File

@@ -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<string, HaEntityState>;
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;
}
}

View File

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

24
web/src/lib/lightColor.ts Normal file
View File

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