Compare commits
3 Commits
701e90153c
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| cf38ae6fe2 | |||
| de94bd4118 | |||
| 13d14c00aa |
@@ -376,6 +376,10 @@ def build_router(
|
|||||||
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||||
)
|
)
|
||||||
except httpx2.HTTPError as exc:
|
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(
|
raise HTTPException(
|
||||||
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { SKIN } from "../lib/skins";
|
|||||||
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||||
import { Drift, type DriftField } from "./Drift";
|
import { Drift, type DriftField } from "./Drift";
|
||||||
|
|
||||||
/** Beside the watch only, never behind it: on a 1080p kiosk the watch covers roughly
|
/** Beside the watch only, never behind it: a bubble rising behind it would drag the
|
||||||
* 28-72% of the width, and a bubble rising behind it would drag the watch into every
|
* watch into every frame the Pi redraws. On a 1080p kiosk the watch covers roughly
|
||||||
* frame the Pi redraws. */
|
* 28-72% of the width when it is alone... */
|
||||||
const BUBBLES: DriftField = [
|
const BUBBLES: DriftField = [
|
||||||
[5, 14, 0.5, 9, 0],
|
[5, 14, 0.5, 9, 0],
|
||||||
[12, 22, 0.5, 12, -3],
|
[12, 22, 0.5, 12, -3],
|
||||||
@@ -33,6 +33,19 @@ const BUBBLES: DriftField = [
|
|||||||
[95, 18, 0.5, 13, -9],
|
[95, 18, 0.5, 13, -9],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** ...and 6-49% when countdown cards share the screen, so then they all rise on the
|
||||||
|
* cards' side. The cards are cheap to redraw (no backdrop blur under `?pi=1`). */
|
||||||
|
const BUBBLES_WITH_CARDS: DriftField = [
|
||||||
|
[52, 14, 0.5, 9, 0],
|
||||||
|
[57, 22, 0.5, 12, -3],
|
||||||
|
[64, 10, 0.5, 8, -6],
|
||||||
|
[70, 16, 0.5, 11, -2],
|
||||||
|
[77, 24, 0.5, 14, -5],
|
||||||
|
[84, 12, 0.5, 10, -7],
|
||||||
|
[90, 12, 0.5, 9, -1],
|
||||||
|
[96, 18, 0.5, 13, -9],
|
||||||
|
];
|
||||||
|
|
||||||
/** The four colours every ring of the dial cycles through, starting at 12. */
|
/** The four colours every ring of the dial cycles through, starting at 12. */
|
||||||
const HUES = ["205", "175", "245", "pink"] as const;
|
const HUES = ["205", "175", "245", "pink"] as const;
|
||||||
|
|
||||||
@@ -62,7 +75,10 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
|
|||||||
aria-label="Uhr"
|
aria-label="Uhr"
|
||||||
>
|
>
|
||||||
{visible && SHOW_DECORATIVE_ANIMATIONS && (
|
{visible && SHOW_DECORATIVE_ANIMATIONS && (
|
||||||
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />
|
<Drift
|
||||||
|
field={countdowns.length > 0 ? BUBBLES_WITH_CARDS : BUBBLES}
|
||||||
|
bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />}
|
{clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import type { HaDevice, HaEntityState } from "../api/types";
|
import type { HaDevice, HaEntityState } from "../api/types";
|
||||||
|
import { sameLightColor } from "../lib/lightColor";
|
||||||
import { oklchToRgb } from "../lib/oklch";
|
import { oklchToRgb } from "../lib/oklch";
|
||||||
import { SKIN } from "../lib/skins";
|
import { SKIN } from "../lib/skins";
|
||||||
|
|
||||||
@@ -23,10 +24,6 @@ interface Props {
|
|||||||
onManualChange: () => void;
|
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) {
|
export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) {
|
||||||
const swatchRgb = useMemo(
|
const swatchRgb = useMemo(
|
||||||
() => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })),
|
() => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })),
|
||||||
@@ -116,7 +113,7 @@ export function LightCard({ device, state, callService, optimistic, onManualChan
|
|||||||
{isColor && (
|
{isColor && (
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
|
||||||
{swatchRgb.map((swatch) => {
|
{swatchRgb.map((swatch) => {
|
||||||
const selected = on && sameRgb(rgb, swatch.rgb);
|
const selected = on && sameLightColor(rgb, swatch.rgb);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={swatch.name}
|
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 supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0;
|
||||||
const closedPercent = closedPercentFromPosition(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 moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null;
|
||||||
const statusLabel =
|
const statusLabel = unreachable
|
||||||
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent);
|
? "Nicht erreichbar"
|
||||||
|
: moving === "down"
|
||||||
|
? "Fährt runter …"
|
||||||
|
: moving === "up"
|
||||||
|
? "Fährt hoch …"
|
||||||
|
: closedLabelFor(closedPercent);
|
||||||
|
|
||||||
const setPosition = (target: number) => {
|
const setPosition = (target: number) => {
|
||||||
onManualChange();
|
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 = () => {
|
const open = () => {
|
||||||
onManualChange();
|
onManualChange();
|
||||||
|
optimistic(device.entity_id, { state: "opening" });
|
||||||
void callService("cover", "open_cover", { entity_id: device.entity_id });
|
void callService("cover", "open_cover", { entity_id: device.entity_id });
|
||||||
};
|
};
|
||||||
const close = () => {
|
const close = () => {
|
||||||
onManualChange();
|
onManualChange();
|
||||||
|
optimistic(device.entity_id, { state: "closing" });
|
||||||
void callService("cover", "close_cover", { entity_id: device.entity_id });
|
void callService("cover", "close_cover", { entity_id: device.entity_id });
|
||||||
};
|
};
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
@@ -111,7 +124,20 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{supportsPosition && (
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
{SHUTTER_PRESETS.map((preset) => {
|
{SHUTTER_PRESETS.map((preset) => {
|
||||||
@@ -185,7 +211,7 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
|
|||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import type { HaConfig, HaEntityState } from "../api/types";
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 2500;
|
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 {
|
export interface HomeAssistant {
|
||||||
states: Record<string, HaEntityState>;
|
states: Record<string, HaEntityState>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -52,9 +58,10 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
|
|||||||
setStates((previous) => {
|
setStates((previous) => {
|
||||||
const next = { ...previous };
|
const next = { ...previous };
|
||||||
for (const [entityId, state] of Object.entries(fresh)) {
|
for (const [entityId, state] of Object.entries(fresh)) {
|
||||||
// Only apply this result if no optimistic patch landed after the request
|
// Only apply this result if it went out well after the last optimistic
|
||||||
// for it went out - otherwise it's stale and would undo a newer change.
|
// patch - otherwise it may predate the device catching up and would undo a
|
||||||
if (startedAt >= (optimisticAt.current[entityId] ?? 0)) {
|
// newer change. See OPTIMISTIC_HOLD_MS.
|
||||||
|
if (startedAt >= (optimisticAt.current[entityId] ?? -Infinity) + OPTIMISTIC_HOLD_MS) {
|
||||||
next[entityId] = state;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
web/src/lib/__tests__/oklch.test.ts
Normal file
32
web/src/lib/__tests__/oklch.test.ts
Normal file
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -1,15 +1,32 @@
|
|||||||
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than
|
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch.
|
||||||
* 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.
|
* Computed here rather than read back from the browser's CSS engine, which is what this
|
||||||
* Only ever called a handful of times (once per fixed swatch), never per render. */
|
* 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] {
|
export function oklchToRgb(oklch: string): [number, number, number] {
|
||||||
const el = document.createElement("div");
|
const match = /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)/.exec(oklch);
|
||||||
el.style.color = oklch;
|
if (!match) throw new Error(`not an oklch(L C H) colour: ${oklch}`);
|
||||||
document.body.appendChild(el);
|
const lightness = Number(match[1]) / (match[2] ? 100 : 1);
|
||||||
const resolved = getComputedStyle(el).color;
|
const chroma = Number(match[3]);
|
||||||
document.body.removeChild(el);
|
const hue = (Number(match[4]) * Math.PI) / 180;
|
||||||
|
|
||||||
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved);
|
const a = chroma * Math.cos(hue);
|
||||||
if (!match) return [255, 255, 255];
|
const b = chroma * Math.sin(hue);
|
||||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
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!];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user