6 Commits

Author SHA1 Message Date
cf38ae6fe2 Room page: colour swatches send their colour, not white
oklchToRgb read the colour back from the browser's CSS engine and expected
rgb(...). Current Chromium (the kiosk's 153) resolves an oklch colour to
oklch(...), so the match failed and every swatch fell back to [255, 255, 255]:
tapping a colour set the light to white. It now does the OKLab maths itself.

Also log a Home Assistant call that fails in the proxy: httpx only logs a
request once it has a response, so a call that timed out left no trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:24:02 +02:00
de94bd4118 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>
2026-10-08 19:23:55 +02:00
13d14c00aa Clock: bubbles on the cards' side while a countdown is showing
With countdown cards the watch moves left and covers roughly 6-49% of the
width, where the plain field's bubbles would rise behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:48:02 +02:00
701e90153c Clock: hand outlines drawn once, bubbles beside the watch
The hands' drop-shadow outline sat on their own compositor layers, so the GPU
recomputed it every frame a bubble moved. It is now on an inner box, painted
once into the hand's layer. The bubbles rise beside the watch, never behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:45:31 +02:00
e94e39addc web README: installing the parents' app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:19:54 +02:00
8ddfafebcb PWA: a home-screen install from ?parentMode=1 keeps parent mode
iOS starts an installed web app at the manifest's start_url ("/"), so the
query parameter was dropped. Opened with ?parentMode=1, the page now points at
a second manifest whose start_url keeps it, and names the app "Musik Eltern".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:19:42 +02:00
13 changed files with 262 additions and 36 deletions

View File

@@ -376,6 +376,10 @@ def build_router(
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
)
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(
status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc

View File

@@ -154,8 +154,13 @@ service worker in `public/sw.js`. On iOS, Share → *Zum Home-Bildschirm*; it th
full-screen with no Safari chrome, because iOS reads the `apple-mobile-web-app-*` meta
tags in `index.html` rather than the manifest.
Two things worth knowing:
A few things worth knowing:
- **Install from `?parentMode=1` to get a parents' app.** iOS starts an installed app at
the manifest's `start_url`, not at the address it was added from, so the plain
manifest's `/` would drop the flag. Opened with it, `index.html` swaps in
`public/manifest-parents.webmanifest` (`start_url: /?parentMode=1`, named "Musik
Eltern"). An app installed before this change has to be removed and added again.
- **The service worker only registers over HTTPS or on `localhost`.** At
`http://<mouse>:8080` on the LAN, iOS will still install the icon and run it
standalone - that part is the meta tags - but there is no offline caching. Put the

View File

@@ -15,10 +15,23 @@
<meta name="theme-color" content="#0d2f3c" />
<meta name="description" content="Musik und Hörbücher für die MusicMouse" />
<!-- iOS does not read the manifest for home-screen installs; these are what it uses. -->
<!-- iOS takes start_url from the manifest, but the title, icon and status bar from
these tags. -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Musik" />
<!-- A home-screen install starts at the manifest's start_url, not at the address it
was added from, so `?parentMode=1` would be lost. Opened with it, the page offers
a second manifest whose start_url keeps it - "Add to Home Screen" from a parent's
URL installs the parents' app, under its own name. Inline, so the tags are
switched before anything reads them. -->
<script>
if (new URLSearchParams(location.search).get("parentMode") === "1") {
document.querySelector('link[rel="manifest"]').href = "/manifest-parents.webmanifest";
document.querySelector('meta[name="apple-mobile-web-app-title"]').content = "Musik Eltern";
document.title = "Musik Delfin Eltern";
}
</script>
<!-- An opaque status bar, so the page starts below it - not black-translucent. That
one ran the page under the clock, which put every top-anchored control behind
it unless each remembered its inset, and since iOS 26 it also sizes the app one

View File

@@ -0,0 +1,33 @@
{
"name": "Musik Delfin Eltern",
"short_name": "Musik Eltern",
"description": "Musik und Hörbücher für die MusicMouse",
"start_url": "/?parentMode=1",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0d2f3c",
"theme_color": "#0d2f3c",
"lang": "de",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"id": "/?parentMode=1"
}

View File

@@ -19,17 +19,33 @@ import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Drift, type DriftField } from "./Drift";
/** Beside the watch only, never behind it: a bubble rising behind it would drag the
* watch into every frame the Pi redraws. On a 1080p kiosk the watch covers roughly
* 28-72% of the width when it is alone... */
const BUBBLES: DriftField = [
[5, 14, 0.5, 9, 0],
[12, 22, 0.5, 12, -3],
[17, 16, 0.5, 11, -2],
[22, 10, 0.5, 8, -6],
[48, 16, 0.5, 11, -2],
[63, 12, 0.5, 10, -7],
[78, 24, 0.5, 14, -5],
[83, 12, 0.5, 10, -7],
[88, 12, 0.5, 9, -1],
[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. */
const HUES = ["205", "175", "245", "pink"] as const;
@@ -59,7 +75,10 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
aria-label="Uhr"
>
{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} />}
@@ -69,10 +88,14 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
<div className="clock-face">
<Dial />
<div className="clock-hand clock-hand-hour" style={{ transform: `rotate(${angles.hour}deg)` }}>
<div />
<div>
<div />
</div>
</div>
<div className="clock-hand clock-hand-minute" style={{ transform: `rotate(${angles.minute}deg)` }}>
<div />
<div>
<div />
</div>
</div>
<div className="clock-hand clock-hand-second" style={{ transform: `rotate(${angles.second}deg)` }}>
<div />

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

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

View File

@@ -1,15 +1,32 @@
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than
* 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.
* Only ever called a handful of times (once per fixed swatch), never per render. */
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch.
*
* Computed here rather than read back from the browser's CSS engine, which is what this
* 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] {
const el = document.createElement("div");
el.style.color = oklch;
document.body.appendChild(el);
const resolved = getComputedStyle(el).color;
document.body.removeChild(el);
const match = /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)/.exec(oklch);
if (!match) throw new Error(`not an oklch(L C H) colour: ${oklch}`);
const lightness = Number(match[1]) / (match[2] ? 100 : 1);
const chroma = Number(match[3]);
const hue = (Number(match[4]) * Math.PI) / 180;
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved);
if (!match) return [255, 255, 255];
return [Number(match[1]), Number(match[2]), Number(match[3])];
const a = chroma * Math.cos(hue);
const b = chroma * Math.sin(hue);
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!];
}

View File

@@ -362,8 +362,12 @@
will-change: transform;
}
.clock-hand-hour,
.clock-hand-minute {
/* The outline and shadow, on the hand's inner box rather than the hand: a filter on the
layer itself would be recomputed on the GPU every frame anything on screen moves (the
bubbles), while one inside it is drawn once into the layer's picture. The shape is
one level further in, so the outline goes round the clipped arrow, not its box. */
.clock-hand-hour > div,
.clock-hand-minute > div {
filter: drop-shadow(0.35vmin 0 0 var(--clock-ink)) drop-shadow(-0.35vmin 0 0 var(--clock-ink))
drop-shadow(0 0.35vmin 0 var(--clock-ink)) drop-shadow(0 -0.35vmin 0 var(--clock-ink))
drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / 0.3));
@@ -382,8 +386,18 @@
margin-left: -2.7vmin;
}
.clock-hand-hour > div {
.clock-hand-hour > div,
.clock-hand-minute > div {
bottom: -3.5vmin;
}
.clock-hand-hour > div > div,
.clock-hand-minute > div > div {
position: absolute;
inset: 0;
}
.clock-hand-hour > div > div {
background: oklch(92% 0.06 345);
clip-path: polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);
}
@@ -394,8 +408,7 @@
margin-left: -2.5vmin;
}
.clock-hand-minute > div {
bottom: -3.5vmin;
.clock-hand-minute > div > div {
background: oklch(92% 0.05 200);
clip-path: polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);
}