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

@@ -0,0 +1,141 @@
"""Which days are school days, from the OpenHolidays API's two lists."""
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import httpx2
import pytest
from musicmouse.school_days import (
HolidayCalendar,
fetch_calendar,
load_calendar,
parse_holidays,
save_calendar,
school_day,
)
def _entry(
start: str, end: str, name: str, *codes: str, nationwide: bool = False
) -> dict[str, Any]:
"""One entry in the API's shape (trimmed to the fields that are read)."""
return {
"startDate": start,
"endDate": end,
"name": [{"language": "DE", "text": name}],
"nationwide": nationwide,
"subdivisions": [{"code": code, "shortName": code[3:]} for code in codes],
}
#: Verbatim dates from the API for Bavaria, 2026/27.
SCHOOL = [
_entry("2026-11-02", "2026-11-06", "Herbstferien", "DE-BY"),
_entry("2026-11-18", "2026-11-18", "Buß- und Bettag", "DE-BY"),
_entry("2026-12-24", "2027-01-08", "Weihnachtsferien", "DE-BY"),
]
PUBLIC = [
_entry("2026-10-03", "2026-10-03", "Tag der Deutschen Einheit", nationwide=True),
_entry("2027-05-27", "2027-05-27", "Fronleichnam", "DE-HE", "DE-BW", "DE-BY"),
_entry("2027-08-09", "2027-08-09", "Friedensfest", "DE-BY-AU"),
]
def _calendar(subdivision: str = "DE-BY") -> HolidayCalendar:
return HolidayCalendar(
fetched=date(2026, 10, 1),
subdivision=subdivision,
school=parse_holidays(SCHOOL),
public=parse_holidays(PUBLIC),
)
@pytest.mark.parametrize(
("day", "expected"),
[
(date(2026, 10, 5), (True, None)), # a plain Monday
(date(2026, 10, 10), (False, "Wochenende")),
(date(2026, 10, 11), (False, "Wochenende")),
(date(2026, 11, 4), (False, "Herbstferien")),
(date(2026, 11, 9), (True, None)), # the Monday after
(date(2026, 11, 18), (False, "Buß- und Bettag")),
(date(2027, 1, 7), (False, "Weihnachtsferien")), # spans the new year
(date(2027, 5, 27), (False, "Fronleichnam")),
],
)
def test_school_days_in_bavaria(day: date, expected: tuple[bool, str | None]) -> None:
assert school_day(day, _calendar()) == expected
def test_a_nationwide_holiday_on_a_weekend_is_still_the_weekend() -> None:
assert school_day(date(2026, 10, 3), _calendar()) == (False, "Wochenende")
def test_a_city_holiday_counts_only_for_that_city() -> None:
friedensfest = date(2027, 8, 9) # a Monday; pretend it is not in the summer holidays
assert school_day(friedensfest, _calendar("DE-BY")) == (True, None)
assert school_day(friedensfest, _calendar("DE-BY-AU")) == (False, "Friedensfest")
def test_a_city_also_gets_its_states_holidays() -> None:
assert school_day(date(2026, 11, 4), _calendar("DE-BY-AU")) == (False, "Herbstferien")
def test_without_a_calendar_only_the_weekend_is_known() -> None:
assert school_day(date(2026, 10, 5), None) == (None, None)
assert school_day(date(2026, 10, 10), None) == (False, "Wochenende")
def test_entries_that_do_not_parse_are_skipped() -> None:
raw = [{"startDate": "nope"}, {"name": []}, SCHOOL[0]]
assert [holiday.name for holiday in parse_holidays(raw)] == ["Herbstferien"]
def test_the_cache_round_trips(tmp_path: Path) -> None:
path = tmp_path / "cache" / "holidays.json"
save_calendar(path, _calendar())
assert load_calendar(path, "DE-BY") == _calendar()
def test_a_cache_for_another_subdivision_is_not_used(tmp_path: Path) -> None:
path = tmp_path / "holidays.json"
save_calendar(path, _calendar())
assert load_calendar(path, "DE-BW") is None
def test_a_broken_or_missing_cache_is_no_cache(tmp_path: Path) -> None:
path = tmp_path / "holidays.json"
assert load_calendar(path, "DE-BY") is None
path.write_text("{not json")
assert load_calendar(path, "DE-BY") is None
async def test_fetching_asks_for_the_state_and_both_lists() -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
body = SCHOOL if request.url.path == "/SchoolHolidays" else PUBLIC
return httpx2.Response(200, json=body)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
calendar = await fetch_calendar(client, "DE-BY-AU", date(2026, 10, 1))
assert [r.url.path for r in requests] == ["/SchoolHolidays", "/PublicHolidays"]
assert all(r.url.params["subdivisionCode"] == "DE-BY" for r in requests)
assert all(r.url.params["countryIsoCode"] == "DE" for r in requests)
assert calendar.subdivision == "DE-BY-AU"
assert calendar.fetched == date(2026, 10, 1)
assert len(calendar.school) == 3
assert len(calendar.public) == 3
async def test_a_failed_fetch_raises() -> None:
transport = httpx2.MockTransport(lambda _: httpx2.Response(503))
async with httpx2.AsyncClient(transport=transport) as client:
with pytest.raises(httpx2.HTTPStatusError):
await fetch_calendar(client, "DE-BY", date(2026, 10, 1))