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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
32
web/src/lib/__tests__/lightColor.test.ts
Normal file
32
web/src/lib/__tests__/lightColor.test.ts
Normal 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
24
web/src/lib/lightColor.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user