Files
musicmouse/python-backend/musicmouse/school_days.py
Martin Bauer a55cc4f67f 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>
2026-10-03 19:45:45 +02:00

176 lines
6.1 KiB
Python

"""Whether today is a school day: weekends, public holidays and school holidays.
The holidays come from the OpenHolidays API (https://openholidaysapi.org), which has
both kinds per German state - Bavaria's ``DE-BY`` school holidays include the
Buß- und Bettag, which is a school day off but not a public holiday. Public holidays
carry their subdivisions, so a city-only one like Augsburg's Friedensfest
(``DE-BY-AU``) counts for Augsburg and not for the rest of Bavaria.
Fetched once a day and kept in a JSON file, so a device that loses its internet keeps
knowing the holidays for the rest of the school year.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import httpx2
_log = logging.getLogger(__name__)
__all__ = [
"API_URL",
"Holiday",
"HolidayCalendar",
"fetch_calendar",
"load_calendar",
"parse_holidays",
"save_calendar",
"school_day",
]
API_URL = "https://openholidaysapi.org"
#: How far back and ahead each fetch reaches. Ahead covers a whole school year from
#: any date, so the cache outlives a long outage.
_FETCH_BEFORE = timedelta(days=30)
_FETCH_AHEAD = timedelta(days=400)
_WEEKEND = "Wochenende"
@dataclass(frozen=True, slots=True)
class Holiday:
start: date
end: date
name: str
nationwide: bool
subdivisions: tuple[str, ...]
def covers(self, day: date) -> bool:
return self.start <= day <= self.end
def applies_to(self, subdivision: str) -> bool:
"""``DE-BY-AU`` gets ``DE-BY``'s holidays as well as its own; ``DE-BY`` does
not get ``DE-BY-AU``'s."""
return self.nationwide or any(
subdivision == code or subdivision.startswith(code + "-") for code in self.subdivisions
)
@dataclass(frozen=True, slots=True)
class HolidayCalendar:
#: The day it was fetched, to know when to fetch again.
fetched: date
subdivision: str
school: tuple[Holiday, ...]
public: tuple[Holiday, ...]
def parse_holidays(raw: list[dict[str, Any]]) -> tuple[Holiday, ...]:
"""The API's JSON list, either endpoint. Entries that do not parse are skipped."""
holidays = []
for entry in raw:
try:
names = entry.get("name") or []
name = next(
(n["text"] for n in names if n.get("language") == "DE"),
names[0]["text"] if names else "Feiertag",
)
holidays.append(
Holiday(
start=date.fromisoformat(entry["startDate"]),
end=date.fromisoformat(entry["endDate"]),
name=name,
nationwide=bool(entry.get("nationwide")),
subdivisions=tuple(s["code"] for s in entry.get("subdivisions") or []),
)
)
except (KeyError, TypeError, ValueError, IndexError):
_log.debug("Skipping a holiday entry that does not parse: %r", entry)
return tuple(holidays)
def school_day(day: date, calendar: HolidayCalendar | None) -> tuple[bool | None, str | None]:
"""``(is_school_day, why_not)``. ``None`` when there is no calendar to ask -
the weekend is still known then, a holiday is not."""
if day.weekday() >= 5:
return False, _WEEKEND
if calendar is None:
return None, None
for holiday in (*calendar.public, *calendar.school):
if holiday.covers(day) and holiday.applies_to(calendar.subdivision):
return False, holiday.name
return True, None
async def fetch_calendar(
client: httpx2.AsyncClient, subdivision: str, today: date
) -> HolidayCalendar:
"""Both lists for ``subdivision``'s state. Raises on any network or HTTP error."""
# The API files school holidays under the state; city-level ones are filtered here.
state = "-".join(subdivision.split("-")[:2])
params = {
"countryIsoCode": state.split("-")[0],
"subdivisionCode": state,
"languageIsoCode": "DE",
"validFrom": (today - _FETCH_BEFORE).isoformat(),
"validTo": (today + _FETCH_AHEAD).isoformat(),
}
lists = []
for endpoint in ("SchoolHolidays", "PublicHolidays"):
response = await client.get(f"{API_URL}/{endpoint}", params=params)
response.raise_for_status()
lists.append(parse_holidays(response.json()))
school, public = lists
return HolidayCalendar(fetched=today, subdivision=subdivision, school=school, public=public)
def save_calendar(path: Path, calendar: HolidayCalendar) -> None:
def dump(holidays: tuple[Holiday, ...]) -> list[dict[str, Any]]:
return [
{
"startDate": h.start.isoformat(),
"endDate": h.end.isoformat(),
"name": [{"language": "DE", "text": h.name}],
"nationwide": h.nationwide,
"subdivisions": [{"code": code} for code in h.subdivisions],
}
for h in holidays
]
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched": calendar.fetched.isoformat(),
"subdivision": calendar.subdivision,
"school": dump(calendar.school),
"public": dump(calendar.public),
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload), encoding="utf-8")
temporary.replace(path)
def load_calendar(path: Path, subdivision: str) -> HolidayCalendar | None:
"""The cached calendar, or ``None`` if there is none for this subdivision."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if payload["subdivision"] != subdivision:
return None
return HolidayCalendar(
fetched=date.fromisoformat(payload["fetched"]),
subdivision=subdivision,
school=parse_holidays(payload["school"]),
public=parse_holidays(payload["public"]),
)
except FileNotFoundError:
return None
except (OSError, KeyError, TypeError, ValueError):
_log.warning("Ignoring an unreadable holiday cache at %s", path)
return None