Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer

Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big
analog watch over whatever page is open, built from the Delphin Uhr mockup
without its swimming dolphin. The overlay stays mounted and is only hidden, so it
appears at once. Beside the watch, only while they run:

- The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25)
  that starts `bus_lead_minutes` (60) before. School days skip weekends and the
  school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily
  from openholidaysapi.org and cached under the library cache. A badge on the
  clock says whether there is school today, and why not.
- The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time).
  It lives in the backend, so a parent's phone can start it for the kiosk. When it
  runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the
  device; it clears after a minute, or at the next key, tap or remote press.

Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button
next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay
bookmarked. The timer comes first there, the device settings below it.

Unmapped IR buttons are logged at debug level, to find out what irserver calls a
new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:45:45 +02:00
parent 30065bb9e2
commit a55cc4f67f
40 changed files with 2723 additions and 185 deletions

View File

@@ -9,7 +9,9 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import time
from collections.abc import AsyncIterator
from datetime import datetime
from pathlib import Path
from typing import TypeAlias
@@ -17,8 +19,15 @@ import httpx2
import pytest
from fastapi import FastAPI
from musicmouse.app import ParentTimer
from musicmouse.config import WebConfig, load_config
from musicmouse.events import ToggleScreenRequested
from musicmouse.events import (
CancelTimerRequested,
ClockStateChanged,
SetTimerRequested,
ToggleClockRequested,
ToggleScreenRequested,
)
from musicmouse.library.analysis import BeatGrid, TrackCurves
from musicmouse.library.models import track_key
from musicmouse.services.web.service import build_app
@@ -318,6 +327,76 @@ async def test_volume_needs_one_of_the_fields(client: Client) -> None:
assert (await client.post("/api/volume", json={})).status_code == 422
# -------------------------------------------------------------------------- clock
async def test_a_timer_in_minutes_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[SetTimerRequested] = []
sim.bus.subscribe(SetTimerRequested, seen.append)
before = time.time()
response = await client.post("/api/timer", json={"minutes": 15})
await sim.bus.drain()
assert response.status_code == 204
assert len(seen) == 1
assert before + 900 <= seen[0].ends_at <= time.time() + 900
async def test_a_timer_until_a_time_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[SetTimerRequested] = []
sim.bus.subscribe(SetTimerRequested, seen.append)
response = await client.post("/api/timer", json={"until": "19:30"})
await sim.bus.drain()
assert response.status_code == 204
end = datetime.fromtimestamp(seen[0].ends_at)
assert (end.hour, end.minute) == (19, 30)
@pytest.mark.parametrize(
"body", [{}, {"minutes": 5, "until": "19:30"}, {"minutes": 0}, {"until": "25:00"}]
)
async def test_a_timer_needs_exactly_one_valid_field(client: Client, body: dict) -> None:
assert (await client.post("/api/timer", json=body)).status_code == 422
async def test_cancelling_the_timer_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[CancelTimerRequested] = []
sim.bus.subscribe(CancelTimerRequested, seen.append)
response = await client.delete("/api/timer")
await sim.bus.drain()
assert response.status_code == 204
assert len(seen) == 1
async def test_the_state_carries_the_clock(client: Client, sim: Simulation) -> None:
sim.app.state.school_day = True
sim.app.state.parent_timer = ParentTimer(started_at=100.0, ends_at=400.0)
clock = (await client.get("/api/state")).json()["clock"]
assert clock == {
"school_day": True,
"school_day_reason": None,
"bus": {"time": "07:25", "lead_minutes": 60},
"parent_timer": {"started_at": 100.0, "ends_at": 400.0, "ringing": False},
}
async def test_no_bus_without_school(client: Client, sim: Simulation) -> None:
sim.app.state.school_day = False
sim.app.state.school_day_reason = "Herbstferien"
clock = (await client.get("/api/state")).json()["clock"]
assert clock["bus"] is None
assert clock["school_day_reason"] == "Herbstferien"
# ---------------------------------------------------------------------- websocket
@@ -383,6 +462,26 @@ async def test_switching_albums_while_playing_reaches_every_client(
assert (await last_state(socket))["album_title"] == "Eule"
async def test_a_clock_change_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
sim.app.state.parent_timer = ParentTimer(started_at=1.0, ends_at=2.0, ringing=True)
await sim.bus.emit_and_wait(ClockStateChanged())
state = await last_state(socket)
assert state["clock"]["parent_timer"]["ringing"] is True
async def test_the_remotes_clock_key_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
await sim.bus.emit_and_wait(ToggleClockRequested(source="lirc"))
assert await socket.next_json() == {"type": "clock_toggle"}
async def test_a_refresh_tells_the_clients_to_reload_the_library(
api: FastAPI, client: Client, sim: Simulation
) -> None: