Compare commits
9 Commits
4cb2d085ca
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| cf38ae6fe2 | |||
| de94bd4118 | |||
| 13d14c00aa | |||
| 701e90153c | |||
| e94e39addc | |||
| 8ddfafebcb | |||
| 17db811bba | |||
| b56b41996b | |||
| 7b9e072713 |
@@ -111,7 +111,7 @@ general:
|
||||
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
||||
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
||||
# number keys 0-9 play whatever the "remote:" section below assigns them; Guide
|
||||
# (KEY_EPG) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
|
||||
# (KEY_GUIDE) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
|
||||
# button that does nothing.
|
||||
lirc:
|
||||
host: "musicmouse-pi.local"
|
||||
@@ -136,7 +136,7 @@ general:
|
||||
# auto_off_minutes: 15
|
||||
# timeout: 5.0 # seconds before a hung command is killed
|
||||
|
||||
# The clock overlay (Einfg on the kiosk keyboard, Guide on the remote). Optional; these
|
||||
# The clock overlay (Pos1 on the kiosk keyboard, Guide on the remote). Optional; these
|
||||
# are the defaults. On school days a bus countdown shows for bus_lead_minutes before
|
||||
# bus_time. School days skip weekends and the school and public holidays of
|
||||
# `subdivision`, fetched daily from openholidaysapi.org and cached under
|
||||
|
||||
@@ -47,8 +47,10 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
# Does nothing unless general.screen is configured - see ScreenService.
|
||||
"KEY_POWER": lambda: ToggleScreenRequested(source="lirc"),
|
||||
# "Guide": what time is it. Shows the kiosk's clock overlay.
|
||||
"KEY_EPG": lambda: ToggleClockRequested(source="lirc"),
|
||||
# "Guide": what time is it. Shows the kiosk's clock overlay. KEY_GUIDE is what
|
||||
# irserver's hauppauge.rem calls it (the ansible repo); lircd's own Hauppauge
|
||||
# config says KEY_EPG.
|
||||
"KEY_GUIDE": lambda: ToggleClockRequested(source="lirc"),
|
||||
}
|
||||
|
||||
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -328,8 +328,8 @@ async def test_the_guide_key_asks_for_the_clock(
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("b7c0000000000000 00 key_epg hauppauge")
|
||||
await station.send("b7c0000000000000 00 key_epg hauppauge") # still held
|
||||
await station.send("b7c0000000000000 00 key_guide hauppauge")
|
||||
await station.send("b7c0000000000000 00 key_guide hauppauge") # still held
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
33
web/public/manifest-parents.webmanifest
Normal file
33
web/public/manifest-parents.webmanifest
Normal 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"
|
||||
}
|
||||
@@ -190,14 +190,13 @@ function BrowseViewImpl({
|
||||
/>
|
||||
<div
|
||||
ref={scroller}
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
||||
className={`no-scrollbar${ANIMATE_VIEW_TRANSITIONS ? " view-enter" : ""}`}
|
||||
// Right padding reserves room for the tab rail's own glass bar (see
|
||||
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it. The
|
||||
// three rows fit the screen, so no vertical scrollbar: each row scrolls
|
||||
// sideways on its own.
|
||||
// vertical scrollbar is hidden, but the wheel and touch still scroll.
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
overflow: "auto",
|
||||
minHeight: 0,
|
||||
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
|
||||
}}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* minus the mascot swimming through the scene (too distracting for a clock you read
|
||||
* at a glance).
|
||||
*
|
||||
* Mounted for as long as the app is, and only hidden: Einfg and the remote's Guide key
|
||||
* have to show it at once, not after a first render. The second ticker only runs while
|
||||
* it is on screen. Any click closes it. */
|
||||
* Mounted for as long as the app is, and only hidden (by opacity, see clock.css): Pos1
|
||||
* and the remote's Guide key have to show it at once, not after a first render and
|
||||
* paint. What moves - the second ticker, the bubbles, a ringing countdown card - only
|
||||
* exists while it is on screen, so a hidden clock costs nothing per frame. Any click
|
||||
* closes it. */
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
@@ -17,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;
|
||||
|
||||
@@ -53,10 +71,14 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
|
||||
hidden={!visible}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-hidden={!visible}
|
||||
aria-label="Uhr"
|
||||
>
|
||||
{SHOW_DECORATIVE_ANIMATIONS && (
|
||||
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />
|
||||
{visible && SHOW_DECORATIVE_ANIMATIONS && (
|
||||
<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} />}
|
||||
@@ -66,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 />
|
||||
@@ -79,7 +105,7 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countdowns.length > 0 && (
|
||||
{visible && countdowns.length > 0 && (
|
||||
<div className="clock-countdowns">
|
||||
{countdowns.map((countdown) => (
|
||||
<CountdownCard key={countdown.kind} countdown={countdown} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,14 +522,14 @@ describe("Del", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Einfg", () => {
|
||||
describe("Pos1", () => {
|
||||
it("toggles the clock, on every page", () => {
|
||||
for (const page of ["music", "room", "typing", "parents"] as const) {
|
||||
expect(press("Insert", { ...initialUiState, page })).toContainEqual({
|
||||
expect(press("Home", { ...initialUiState, page })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { showClock: true },
|
||||
});
|
||||
expect(press("Insert", { ...initialUiState, page, showClock: true })).toContainEqual({
|
||||
expect(press("Home", { ...initialUiState, page, showClock: true })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { showClock: false },
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -269,9 +269,9 @@ export function handleKey(
|
||||
// another room.
|
||||
if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }];
|
||||
|
||||
// Einfg, right next to it: the clock, over whatever is on screen - the typing tab
|
||||
// Pos1, in the same block: the clock, over whatever is on screen - the typing tab
|
||||
// included, like Del, since no lesson drills it either.
|
||||
if (key === "Insert" && !event.ctrlKey && !event.metaKey) {
|
||||
if (key === "Home" && !event.ctrlKey && !event.metaKey) {
|
||||
return [{ type: "pop", freq: 520 }, { type: "ui", patch: { showClock: !state.showClock } }];
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
* 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!];
|
||||
}
|
||||
|
||||
@@ -273,11 +273,17 @@ button {
|
||||
focused. */
|
||||
padding: 14px 10px 14px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* Scrolls by wheel, touch and keyboard, but draws no scrollbar. */
|
||||
.shelf-row,
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* old Edge */
|
||||
}
|
||||
|
||||
.shelf-row::-webkit-scrollbar {
|
||||
.shelf-row::-webkit-scrollbar,
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* `display: flex` above would otherwise beat the `hidden` attribute. */
|
||||
/* Hidden by opacity, not `display: none`: the watch stays styled, laid out and
|
||||
rasterized, so Pos1 or the remote's Guide key shows it in the next frame instead of
|
||||
after the Pi has painted every gradient of the dial from scratch. `display: flex`
|
||||
above would otherwise beat the `hidden` attribute anyway. */
|
||||
.clock-overlay[hidden] {
|
||||
display: none;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Upright screens: the countdowns go under the watch instead of beside it. */
|
||||
@@ -62,7 +68,6 @@
|
||||
flex: none;
|
||||
width: 78vmin;
|
||||
height: 96vmin;
|
||||
filter: drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / 0.5));
|
||||
}
|
||||
|
||||
.clock-strap {
|
||||
@@ -72,6 +77,7 @@
|
||||
box-sizing: border-box;
|
||||
border: 0.55vmin solid var(--clock-ink);
|
||||
--strap: oklch(70% 0.16 340);
|
||||
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.35);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--strap) 0%,
|
||||
@@ -144,6 +150,10 @@
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 0.6vmin solid var(--clock-ink);
|
||||
/* The watch's shadow. It used to be a drop-shadow filter on the whole watch, which
|
||||
made the Pi re-render and re-blur the entire dial on every tick of the second
|
||||
hand. */
|
||||
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.5);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle closest-side,
|
||||
@@ -347,10 +357,17 @@
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
transform-origin: 50% 100%;
|
||||
/* Each hand on its own layer: turning it moves a finished picture on the GPU,
|
||||
and the dial underneath is never repainted. */
|
||||
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));
|
||||
@@ -369,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%);
|
||||
}
|
||||
@@ -381,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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user