From a55cc4f67f1453aa3869a23aa7ebe98680a98c6e Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sat, 3 Oct 2026 19:45:45 +0200 Subject: [PATCH] Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- python-backend/README.md | 5 +- python-backend/config.yml.example | 14 +- python-backend/musicmouse/__main__.py | 17 + python-backend/musicmouse/app.py | 21 +- python-backend/musicmouse/blubb.py | 44 +- python-backend/musicmouse/config.py | 18 + python-backend/musicmouse/events.py | 29 + python-backend/musicmouse/school_days.py | 175 ++++++ .../musicmouse/services/lirc/service.py | 12 +- python-backend/musicmouse/services/timers.py | 227 ++++++++ python-backend/musicmouse/services/web/api.py | 16 + python-backend/musicmouse/services/web/hub.py | 19 +- .../musicmouse/services/web/schemas.py | 50 +- .../musicmouse/services/web/state.py | 33 +- python-backend/musicmouse/sound.py | 45 ++ python-backend/tests/test_lirc.py | 21 + python-backend/tests/test_school_days.py | 141 +++++ python-backend/tests/test_timers.py | 292 ++++++++++ python-backend/tests/test_web.py | 101 +++- web/public/clock/bus.webp | Bin 0 -> 34344 bytes web/public/clock/dolphin-watch.webp | Bin 0 -> 15934 bytes web/src/App.tsx | 156 +++-- web/src/api/client.ts | 4 + web/src/api/types.ts | 17 +- web/src/components/ClockOverlay.tsx | 187 ++++++ web/src/components/HelpOverlay.tsx | 1 + web/src/components/ParentPanel.tsx | 410 ++++++++++---- web/src/components/mobile/MobileDock.tsx | 9 +- web/src/hooks/useNow.ts | 21 + web/src/hooks/usePlayerState.ts | 12 +- web/src/lib/__tests__/clock.test.ts | 112 ++++ web/src/lib/__tests__/keyboard.test.ts | 28 + web/src/lib/clock.ts | 104 ++++ web/src/lib/keyboard.ts | 26 +- web/src/lib/skins/dinosaur.ts | 2 + web/src/lib/skins/dolphin.ts | 1 + web/src/lib/skins/types.ts | 2 + web/src/lib/theme.ts | 2 + web/src/main.tsx | 1 + web/src/styles/clock.css | 533 ++++++++++++++++++ 40 files changed, 2723 insertions(+), 185 deletions(-) create mode 100644 python-backend/musicmouse/school_days.py create mode 100644 python-backend/musicmouse/services/timers.py create mode 100644 python-backend/musicmouse/sound.py create mode 100644 python-backend/tests/test_school_days.py create mode 100644 python-backend/tests/test_timers.py create mode 100644 web/public/clock/bus.webp create mode 100644 web/public/clock/dolphin-watch.webp create mode 100644 web/src/components/ClockOverlay.tsx create mode 100644 web/src/hooks/useNow.ts create mode 100644 web/src/lib/__tests__/clock.test.ts create mode 100644 web/src/lib/clock.ts create mode 100644 web/src/styles/clock.css diff --git a/python-backend/README.md b/python-backend/README.md index 7dd4ea7..fd89433 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -212,8 +212,8 @@ copied to the device. | `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. | | `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. | | `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. | -| `GET /api/state` | Snapshot: what is playing, where, how loud. | -| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. | +| `GET /api/state` | Snapshot: what is playing, where, how loud; plus `clock` - today's school day, the bus, the parents' timer. | +| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. | | `POST /api/play` | `{album_id, track_index?}` | | `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | | `POST /api/volume` | `{percent}`, `{delta_percent}`, or `{steps}` - the IR remote's step (`volume_increment` device units) per step | @@ -221,6 +221,7 @@ copied to the device. | `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show | | `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off | | `GET` `PUT /api/settings` | parent mode | +| `POST` `DELETE /api/timer` | parent mode's countdown: `{minutes}` or `{until: "HH:MM"}`; stop or silence it | Two decisions worth knowing: diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index 00c4634..f8d46cd 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -88,7 +88,9 @@ general: # IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how # 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. + # 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 + # button that does nothing. lirc: host: "musicmouse-pi.local" port: 2222 # this deployment's lircd listens on 2222, not its own @@ -112,6 +114,16 @@ 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 + # 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 + # library.cache - a city code such as DE-BY-AU adds that city's own holidays. + # clock: + # subdivision: "DE-BY" + # bus_time: "07:25" + # bus_lead_minutes: 60 + # Home Assistant integration. Omit the whole section to run without MQTT. # The backend exposes three lights, a player sensor, a volume slider, transport # buttons, device triggers for every button/touch area, and a tag scanner. diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index a34e41c..b462cd7 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -40,6 +40,7 @@ from musicmouse.services.lirc import LircService from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.screen import ScreenService +from musicmouse.services.timers import TimerService from musicmouse.services.web import WebService from musicmouse.tippen.curriculum import CurriculumError from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime @@ -386,6 +387,22 @@ def _build_services( ) ) + # Unconditional, like the overlay it serves: the bus timer and the school-day badge + # need nothing configured, and the parents' timer is only started from parent mode. + general = app.config.general + services.append( + TimerService( + app.bus, + app.state, + general.clock, + cache_file=general.library.cache / "holidays.json", + client=httpx2.AsyncClient(timeout=30.0), + alsa_device=None if general.audio_simulated else general.alsa_device, + volume=lambda: app.player.volume / 100, + clock=clock, + ) + ) + # Unconditional: a show only starts downloading once someone drops a `feed.txt` # into its folder, so there is nothing to gate here with its own config section. web_service = _service_of_type(services, WebService) diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index 99ebf16..f771d50 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -18,7 +18,18 @@ from musicmouse.tippen.runtime import TippenRuntime _log = logging.getLogger(__name__) -__all__ = ["App", "AppState"] +__all__ = ["App", "AppState", "ParentTimer"] + + +@dataclass(frozen=True, slots=True) +class ParentTimer: + """The countdown parents start from parent mode. Wall-clock Unix seconds, so every + browser can draw it against its own clock.""" + + started_at: float + ends_at: float + #: Ran out and is chiming; cleared after a minute or by the next thing anyone does. + ringing: bool = False @dataclass @@ -36,6 +47,14 @@ class AppState: #: Whether the lircd TCP link for the IR remote is currently reachable. lirc_connected: bool = False + #: Owned by :class:`~musicmouse.services.timers.TimerService`. + parent_timer: ParentTimer | None = None + #: Whether today is a school day; ``None`` on a weekday the holiday calendar could + #: not be fetched for (no internet, no cache yet). + school_day: bool | None = None + #: Why not, when it is not: ``"Wochenende"``, ``"Herbstferien"``, ... + school_day_reason: str | None = None + @dataclass class App: diff --git a/python-backend/musicmouse/blubb.py b/python-backend/musicmouse/blubb.py index a39a529..6dac0af 100644 --- a/python-backend/musicmouse/blubb.py +++ b/python-backend/musicmouse/blubb.py @@ -7,6 +7,9 @@ player's volume instead of being fixed in a recording. A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as it reaches the surface). Two of them make the "blubb"; switching on the second one is higher, switching off it is lower, so the two directions are told apart by ear. + +The parents' timer has its own sound here too: a soft "ding-dong" bell +(:func:`chime_wav`), made the same way. """ from __future__ import annotations @@ -16,7 +19,7 @@ import math import wave from array import array -__all__ = ["RATE", "blubb_wav"] +__all__ = ["RATE", "blubb_wav", "chime_wav"] RATE = 44100 @@ -35,6 +38,29 @@ def blubb_wav(*, on: bool, gain: float) -> bytes: peak = max(abs(s) for s in samples) or 1.0 scale = 32767 * max(0.0, min(1.0, gain)) / peak + return _wav(samples, scale) + + +#: (Hz, start s) of each bell strike: a falling major third, "ding-dong". +_CHIME = ((1318.5, 0.0), (1046.5, 0.45)) + +#: How long each strike rings out. +_CHIME_DECAY = 1.6 + + +def chime_wav(*, gain: float) -> bytes: + """A soft two-note bell for the parents' timer running out. Same format as + :func:`blubb_wav`.""" + length = max(start for _, start in _CHIME) + _CHIME_DECAY + samples = [0.0] * (int(length * RATE) + 1) + for frequency, start in _CHIME: + _add_strike(samples, frequency, int(start * RATE)) + peak = max(abs(s) for s in samples) or 1.0 + scale = 32767 * max(0.0, min(1.0, gain)) / peak + return _wav(samples, scale) + + +def _wav(samples: list[float], scale: float) -> bytes: pcm = array("h", (round(s * scale) for s in samples)) buffer = io.BytesIO() with wave.open(buffer, "wb") as out: @@ -60,3 +86,19 @@ def _add_bubble( index = offset + i if index < len(samples): samples[index] += math.sin(phase) * envelope + + +def _add_strike(samples: list[float], frequency: float, offset: int) -> None: + """A bell: the fundamental plus two quieter, faster-fading overtones.""" + count = int(_CHIME_DECAY * RATE) + attack = int(0.006 * RATE) + partials = ((1.0, 1.0, 3.0), (2.0, 0.35, 5.0), (3.0, 0.12, 8.0)) # ratio, level, decay + for i in range(count): + t = i / RATE + value = sum( + level * math.exp(-decay * t) * math.sin(2 * math.pi * frequency * ratio * t) + for ratio, level, decay in partials + ) + index = offset + i + if index < len(samples): + samples[index] += value * min(1.0, i / attack) diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index a666bf1..d04c2a3 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -32,6 +32,7 @@ _log = logging.getLogger(__name__) __all__ = [ "SIMULATE", + "ClockConfig", "Config", "ConfigError", "Digit", @@ -184,6 +185,22 @@ class ScreenConfig(_Strict): timeout: float = Field(default=5.0, gt=0) +class ClockConfig(_Strict): + """The clock overlay's automatic bus countdown, and whose school days it follows. + + Always present: leave the section out for a Bavarian school bus at 7:25. + """ + + #: ISO 3166-2 code the school and public holidays are looked up for, as the + #: OpenHolidays API knows it. A city-level code (``DE-BY-AU``) also counts that + #: city's own holidays; the state's are always included. + subdivision: str = Field(default="DE-BY", pattern=r"^[A-Z]{2}-[A-Z0-9]+(-[A-Z0-9]+)?$") + #: When the bus leaves, on school days only. + bus_time: str = Field(default="07:25", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$") + #: How long before ``bus_time`` the countdown appears. + bus_lead_minutes: int = Field(default=60, ge=1, le=240) + + class LibraryConfig(_Strict): """Where the music lives. @@ -303,6 +320,7 @@ class GeneralConfig(_Strict): lirc: LircConfig | None = None screen: ScreenConfig | None = None tippen: TippenConfig | None = None + clock: ClockConfig = Field(default_factory=ClockConfig) min_volume: int = Field(default=0, ge=0, le=200) max_volume: int = Field(default=100, ge=0, le=200) diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index 9716122..c1d2d32 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -27,6 +27,8 @@ if TYPE_CHECKING: __all__ = [ "ActiveFigureChanged", "ButtonEvent", + "CancelTimerRequested", + "ClockStateChanged", "ConnectionChanged", "DeviceConnected", "DeviceDisconnected", @@ -48,8 +50,10 @@ __all__ = [ "PreviousTrackRequested", "RfidTokenRead", "RotaryTurned", + "SetTimerRequested", "SetVolumeRequested", "StateEvent", + "ToggleClockRequested", "ToggleScreenRequested", "TouchButtonPressed", "TouchButtonReleased", @@ -235,6 +239,25 @@ class ToggleScreenRequested(IntentEvent): """Switch the attached monitor off if it is on, on if it is off (``general.screen``).""" +@dataclass(frozen=True, slots=True, kw_only=True) +class ToggleClockRequested(IntentEvent): + """Show or hide the clock overlay. Only the kiosk acts on it - the overlay is a + front-end thing, the backend just passes the remote's key along.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class SetTimerRequested(IntentEvent): + """Start the parents' countdown, replacing one already running.""" + + #: Wall-clock end, as Unix seconds. + ends_at: float + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CancelTimerRequested(IntentEvent): + """Stop the parents' countdown, or silence it if it is ringing.""" + + @dataclass(frozen=True, slots=True, kw_only=True) class UserActivity(IntentEvent): """Someone is at the device - a key press or tap in the kiosk browser. Requests @@ -294,6 +317,12 @@ class LedEffectChanged(StateEvent): return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})" +@dataclass(frozen=True, slots=True, kw_only=True) +class ClockStateChanged(StateEvent): + """The parents' timer started, rang or stopped, or today's school day changed. + The values themselves live on :class:`~musicmouse.app.AppState`.""" + + @dataclass(frozen=True, slots=True, kw_only=True) class ConnectionChanged(StateEvent): target: Literal["firmware", "mqtt", "lirc"] diff --git a/python-backend/musicmouse/school_days.py b/python-backend/musicmouse/school_days.py new file mode 100644 index 0000000..175ba2d --- /dev/null +++ b/python-backend/musicmouse/school_days.py @@ -0,0 +1,175 @@ +"""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 diff --git a/python-backend/musicmouse/services/lirc/service.py b/python-backend/musicmouse/services/lirc/service.py index 89661c5..8855abb 100644 --- a/python-backend/musicmouse/services/lirc/service.py +++ b/python-backend/musicmouse/services/lirc/service.py @@ -25,6 +25,7 @@ from musicmouse.events import ( PlayRequested, PreviousTrackRequested, SetVolumeRequested, + ToggleClockRequested, ToggleScreenRequested, VolumeChangeRequested, ) @@ -46,6 +47,8 @@ _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"), } #: Acted on every repeat, for a continuous ramp while held - same feel as the rotary @@ -142,8 +145,13 @@ class LircService: elif event.button == "KEY_MUTE": if event.repeat == 0: self._toggle_mute() - elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0: - self._play_digit(digit) + elif (digit := _DIGITS.get(event.button)) is not None: + if event.repeat == 0: + self._play_digit(digit) + elif event.repeat == 0: + # irserver's names do not always match lircd's; this is how to find out + # what a new button is called on the device. + _log.debug("Unmapped IR button %s", event.button) def _toggle_mute(self) -> None: player = self.app.player diff --git a/python-backend/musicmouse/services/timers.py b/python-backend/musicmouse/services/timers.py new file mode 100644 index 0000000..572d1b8 --- /dev/null +++ b/python-backend/musicmouse/services/timers.py @@ -0,0 +1,227 @@ +"""The clock overlay's backend half: the parents' countdown and today's school day. + +The overlay itself lives in the kiosk browser. What has to live here is what several +screens share (a timer set from a parent's phone shows on the kiosk), what has to keep +going with the screen off (the chime), and what needs the internet (the holidays). + +The countdown runs on the injected :class:`~musicmouse.clock.Clock` like everything +else that waits; wall time comes from ``wall`` so a test can pin the date. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from datetime import date, datetime, timedelta +from pathlib import Path + +import httpx2 + +from musicmouse.app import AppState, ParentTimer +from musicmouse.blubb import chime_wav +from musicmouse.bus import EventBus +from musicmouse.clock import Clock, RealClock +from musicmouse.config import ClockConfig +from musicmouse.events import ( + CancelTimerRequested, + ClockStateChanged, + Event, + IntentEvent, + LedEffectRequested, + SetTimerRequested, +) +from musicmouse.school_days import ( + HolidayCalendar, + fetch_calendar, + load_calendar, + save_calendar, + school_day, +) +from musicmouse.sound import play_wav + +_log = logging.getLogger(__name__) + +__all__ = ["RING_SECONDS", "TimerService", "timer_end"] + +#: How long a timer that ran out keeps showing 00:00 before it clears by itself. +RING_SECONDS = 60.0 + +#: The chime's peak at full player volume, as a fraction of full scale - soft. +_CHIME_GAIN = 0.3 +#: Never quieter than at this player volume: a muted player must not mute the timer. +_CHIME_MIN_VOLUME = 0.3 + +#: After a failed holiday fetch, try again this much later rather than at midnight. +_RETRY_SECONDS = 3600.0 +#: Wake this long after midnight, so the new day has surely begun. +_AFTER_MIDNIGHT = 5.0 + + +def timer_end(now: datetime, *, minutes: int | None = None, until: str | None = None) -> datetime: + """When a timer started at ``now`` ends: ``minutes`` from now, or at the next + ``until`` ("HH:MM") - tomorrow if that time has already passed today.""" + if (minutes is None) == (until is None): + raise ValueError("give exactly one of minutes and until") + if minutes is not None: + return now + timedelta(minutes=minutes) + assert until is not None + hour, minute = (int(part) for part in until.split(":")) + end = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + return end if end > now else end + timedelta(days=1) + + +class TimerService: + name = "timers" + + def __init__( + self, + bus: EventBus, + state: AppState, + config: ClockConfig, + *, + cache_file: Path, + client: httpx2.AsyncClient, + alsa_device: str | None, + volume: Callable[[], float], + clock: Clock | None = None, + wall: Callable[[], datetime] = datetime.now, + ) -> None: + self.bus = bus + self.state = state + self.config = config + self.cache_file = cache_file + self.client = client + #: Where the chime plays, or ``None`` for no sound (simulated audio). + self.alsa_device = alsa_device + #: The player's volume as a fraction, read when the chime plays. + self.volume = volume + self._clock = clock or RealClock() + self._wall = wall + self._calendar: HolidayCalendar | None = None + self._countdown: asyncio.Task[None] | None = None + self._chime_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ lifecycle + + async def run(self) -> None: + unsubscribe = [ + self.bus.subscribe(SetTimerRequested, self._on_set), + self.bus.subscribe(CancelTimerRequested, self._on_cancel), + self.bus.subscribe_all(self._on_event), + ] + try: + while True: + fetched = await self.refresh_school_day() + await self._clock.sleep(self._seconds_to_midnight() if fetched else _RETRY_SECONDS) + finally: + for undo in unsubscribe: + undo() + if self._countdown is not None: + self._countdown.cancel() + await self.client.aclose() + + # ----------------------------------------------------------------- school day + + async def refresh_school_day(self) -> bool: + """Work out today's school day, fetching the holidays if the cached ones are + not from today. ``False`` if a fetch was due and failed.""" + today = self._wall().date() + ok = True + if self._calendar is None: + self._calendar = load_calendar(self.cache_file, self.config.subdivision) + if self._calendar is None or self._calendar.fetched < today: + try: + self._calendar = await fetch_calendar(self.client, self.config.subdivision, today) + save_calendar(self.cache_file, self._calendar) + except Exception as exc: + ok = False + _log.warning( + "Could not fetch the school holidays (%s); %s", + exc, + "using the cached ones" if self._calendar else "only weekends are known", + ) + self._set_school_day(today) + return ok + + def _set_school_day(self, today: date) -> None: + is_school_day, reason = school_day(today, self._calendar) + if (is_school_day, reason) == (self.state.school_day, self.state.school_day_reason): + return + self.state.school_day = is_school_day + self.state.school_day_reason = reason + _log.info( + "Today is %s", + "a school day" if is_school_day else f"no school day ({reason or 'unknown'})", + ) + self.bus.emit(ClockStateChanged()) + + def _seconds_to_midnight(self) -> float: + now = self._wall() + midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time()) + return (midnight - now).total_seconds() + _AFTER_MIDNIGHT + + # ---------------------------------------------------------------- the timer + + def _on_set(self, event: SetTimerRequested) -> None: + self._stop_countdown() + now = self._wall().timestamp() + self.state.parent_timer = ParentTimer(started_at=now, ends_at=event.ends_at) + self.bus.emit(ClockStateChanged()) + self._countdown = asyncio.create_task( + self._run_countdown(event.ends_at - now), name="parent-timer" + ) + + def _on_cancel(self, _event: CancelTimerRequested) -> None: + self._stop_countdown() + self._clear() + + def _on_event(self, event: Event) -> None: + """Anything a person does silences a ringing timer - the same notion of "a + person" as the screen's idle auto-off: intents, minus Home Assistant's LED + writes and the backend talking to itself.""" + timer = self.state.parent_timer + if ( + timer is None + or not timer.ringing + or not isinstance(event, IntentEvent) + or isinstance(event, (LedEffectRequested, SetTimerRequested, CancelTimerRequested)) + or event.source == "system" + ): + return + self._stop_countdown() + self._clear() + + async def _run_countdown(self, seconds: float) -> None: + await self._clock.sleep(max(0.0, seconds)) + timer = self.state.parent_timer + if timer is None: + return + self.state.parent_timer = ParentTimer( + started_at=timer.started_at, ends_at=timer.ends_at, ringing=True + ) + _log.info("The parents' timer ran out") + self.bus.emit(ClockStateChanged()) + # Its own task: silencing the timer must not cut the chime off mid-note. + self._chime_task = asyncio.create_task(self._chime(), name="parent-timer-chime") + await self._clock.sleep(RING_SECONDS) + self._countdown = None + self._clear() + + async def _chime(self) -> None: + if self.alsa_device is None: + return + gain = _CHIME_GAIN * max(_CHIME_MIN_VOLUME, min(1.0, self.volume())) + wav = await asyncio.to_thread(chime_wav, gain=gain) + await play_wav(self.alsa_device, wav) + + def _stop_countdown(self) -> None: + if self._countdown is not None: + self._countdown.cancel() + self._countdown = None + + def _clear(self) -> None: + if self.state.parent_timer is None: + return + self.state.parent_timer = None + self.bus.emit(ClockStateChanged()) diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index 8c60398..92c877c 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import logging +from datetime import datetime from pathlib import Path from typing import Any @@ -22,6 +23,7 @@ from fastapi.responses import FileResponse from musicmouse.app import App from musicmouse.config import Digit, HaConfig, RemoteSlotConfig from musicmouse.events import ( + CancelTimerRequested, IntentEvent, NextTrackRequested, PauseRequested, @@ -30,11 +32,13 @@ from musicmouse.events import ( PlayRequested, PreviousTrackRequested, SeekRequested, + SetTimerRequested, SetVolumeRequested, ToggleScreenRequested, UserActivity, VolumeChangeRequested, ) +from musicmouse.services.timers import timer_end from musicmouse.services.web.hub import StateHub from musicmouse.services.web.remote_settings import read_mapping, write_mapping from musicmouse.services.web.schemas import ( @@ -50,6 +54,7 @@ from musicmouse.services.web.schemas import ( SeekIn, SettingsIn, SettingsOut, + TimerIn, TippenCurriculumOut, TippenProgressOut, TippenRunIn, @@ -206,6 +211,17 @@ def build_router( # screen's idle auto-off does not blank it under a child who is browsing. return emit(UserActivity(source="web")) + @router.post("/timer", status_code=204) + def set_timer(body: TimerIn) -> Response: + # The parents' countdown. The new state, ringing and all, arrives over the + # websocket like everything else. + end = timer_end(datetime.now(), minutes=body.minutes, until=body.until) + return emit(SetTimerRequested(ends_at=end.timestamp(), source="web")) + + @router.delete("/timer", status_code=204) + def cancel_timer() -> Response: + return emit(CancelTimerRequested(source="web")) + @router.post("/seek", status_code=204) def seek(body: SeekIn) -> Response: return emit(SeekRequested(position=body.position, source="web")) diff --git a/python-backend/musicmouse/services/web/hub.py b/python-backend/musicmouse/services/web/hub.py index 526212c..464812a 100644 --- a/python-backend/musicmouse/services/web/hub.py +++ b/python-backend/musicmouse/services/web/hub.py @@ -22,7 +22,7 @@ from typing import Any from fastapi import WebSocket from musicmouse.app import App -from musicmouse.events import Event, StateEvent +from musicmouse.events import Event, StateEvent, ToggleClockRequested from musicmouse.services.web.state import snapshot _log = logging.getLogger(__name__) @@ -38,17 +38,21 @@ class StateHub: def __init__(self, app: App) -> None: self.app = app self._clients: set[WebSocket] = set() - self._unsubscribe: Any = None + self._unsubscribe: list[Any] = [] # ------------------------------------------------------------------- lifecycle def start(self) -> None: - self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state) + self._unsubscribe = [ + self.app.bus.subscribe(StateEvent, self._on_state), + # The clock overlay is the kiosk's own; the remote's key only asks for it. + self.app.bus.subscribe(ToggleClockRequested, self._on_clock_toggle), + ] def stop(self) -> None: - if self._unsubscribe is not None: - self._unsubscribe() - self._unsubscribe = None + for unsubscribe in self._unsubscribe: + unsubscribe() + self._unsubscribe = [] async def run(self) -> None: """Push the playback position while anything is playing.""" @@ -102,3 +106,6 @@ class StateHub: async def _on_state(self, _event: Event) -> None: await self.broadcast_state() + + async def _on_clock_toggle(self, _event: ToggleClockRequested) -> None: + await self.broadcast({"type": "clock_toggle"}) diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index d45d311..aa45520 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -8,9 +8,9 @@ configured ceiling is a parent's business, not a child's, so it never crosses th from __future__ import annotations -from typing import Literal +from typing import Literal, Self -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from musicmouse.config import Digit from musicmouse.library import Album @@ -21,6 +21,8 @@ from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint __all__ = [ "AlbumOut", + "BusOut", + "ClockStateOut", "HaConfigOut", "HaDeviceOut", "LibraryOut", @@ -34,6 +36,8 @@ __all__ = [ "SeekIn", "SettingsIn", "SettingsOut", + "TimerIn", + "TimerOut", "TippenCurriculumOut", "TippenGhostStrokeOut", "TippenKeyStatOut", @@ -202,6 +206,33 @@ class ConnectionOut(BaseModel): lirc: bool +class BusOut(BaseModel): + #: "HH:MM", when the bus leaves. + time: str + #: How long before ``time`` its countdown shows. + lead_minutes: int + + +class TimerOut(BaseModel): + #: Wall-clock Unix seconds. + started_at: float + ends_at: float + ringing: bool + + +class ClockStateOut(BaseModel): + """What the clock overlay shows besides the time.""" + + #: ``None`` when the holiday calendar could not be fetched; weekends are known + #: regardless. + school_day: bool | None + #: Why today is no school day: "Wochenende", "Herbstferien", ... + school_day_reason: str | None + #: Today's bus, or ``None`` when there is no school today. + bus: BusOut | None + parent_timer: TimerOut | None + + class PlayerStateOut(BaseModel): playing: bool album_id: str | None @@ -217,6 +248,7 @@ class PlayerStateOut(BaseModel): volume: int active_figure: str | None connected: ConnectionOut + clock: ClockStateOut class PlayIn(BaseModel): @@ -241,6 +273,20 @@ class VolumeIn(BaseModel): steps: int | None = Field(default=None, ge=-10, le=10) +class TimerIn(BaseModel): + """Minutes from now, or a time of day ("HH:MM", tomorrow if already past); exactly + one of the two.""" + + minutes: int | None = Field(default=None, ge=1, le=12 * 60) + until: str | None = Field(default=None, pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$") + + @model_validator(mode="after") + def _exactly_one(self) -> Self: + if (self.minutes is None) == (self.until is None): + raise ValueError("give exactly one of minutes and until") + return self + + class SettingsOut(BaseModel): min_volume: int max_volume: int diff --git a/python-backend/musicmouse/services/web/state.py b/python-backend/musicmouse/services/web/state.py index 576cfe2..32622aa 100644 --- a/python-backend/musicmouse/services/web/state.py +++ b/python-backend/musicmouse/services/web/state.py @@ -7,8 +7,16 @@ read ``mouse.active_figure``, do not keep a second copy of it that can drift. from __future__ import annotations +from datetime import date + from musicmouse.app import App -from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut +from musicmouse.services.web.schemas import ( + BusOut, + ClockStateOut, + ConnectionOut, + PlayerStateOut, + TimerOut, +) from musicmouse.services.web.settings import to_percent __all__ = ["snapshot"] @@ -45,4 +53,27 @@ def snapshot(app: App) -> PlayerStateOut: mqtt=app.state.mqtt_connected, lirc=app.state.lirc_connected, ), + clock=clock_state(app), + ) + + +def clock_state(app: App, today: date | None = None) -> ClockStateOut: + state = app.state + config = app.config.general.clock + # Without a holiday calendar a weekday counts as a school day: a bus countdown on + # a holiday is a smaller surprise than none on the first day back. + today = today or date.today() + school = state.school_day if state.school_day is not None else today.weekday() < 5 + timer = state.parent_timer + return ClockStateOut( + school_day=state.school_day, + school_day_reason=state.school_day_reason, + bus=BusOut(time=config.bus_time, lead_minutes=config.bus_lead_minutes) + if school + else None, + parent_timer=TimerOut( + started_at=timer.started_at, ends_at=timer.ends_at, ringing=timer.ringing + ) + if timer + else None, ) diff --git a/python-backend/musicmouse/sound.py b/python-backend/musicmouse/sound.py new file mode 100644 index 0000000..ea6d50c --- /dev/null +++ b/python-backend/musicmouse/sound.py @@ -0,0 +1,45 @@ +"""Playing a short synthesised sound on the music's own ALSA device. + +``aplay`` in a subprocess, fed the WAV on stdin: no audio library to load, and a hung +or missing ``aplay`` costs a log line, never the event loop. +""" + +from __future__ import annotations + +import asyncio +import logging + +_log = logging.getLogger(__name__) + +__all__ = ["play_wav"] + + +async def play_wav(alsa_device: str, wav: bytes, *, timeout: float = 10.0) -> None: + """Play ``wav`` to the end. Failures are logged, not raised.""" + try: + process = await asyncio.create_subprocess_exec( + "aplay", + "-q", + "-D", + alsa_device, + "-", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + except OSError as exc: + _log.warning("Could not play a sound: %s", exc) + return + try: + _, error = await asyncio.wait_for(process.communicate(wav), timeout) + except TimeoutError: + process.kill() + await process.wait() + _log.warning("A sound took longer than %gs; killed", timeout) + return + if process.returncode != 0: + _log.warning( + "Playing a sound failed (exit %d): %s", + process.returncode, + error.decode(errors="replace").strip(), + ) diff --git a/python-backend/tests/test_lirc.py b/python-backend/tests/test_lirc.py index c783c8d..36dc6c6 100644 --- a/python-backend/tests/test_lirc.py +++ b/python-backend/tests/test_lirc.py @@ -21,6 +21,7 @@ from musicmouse.events import ( PlayRequested, PlaySeriesLatestRequested, PreviousTrackRequested, + ToggleClockRequested, ToggleScreenRequested, VolumeChangeRequested, ) @@ -316,6 +317,26 @@ async def test_the_power_button_toggles_the_screen( assert len(seen) == 1 +async def test_the_guide_key_asks_for_the_clock( + lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation +) -> None: + station, server = lircd + port = server.sockets[0].getsockname()[1] + seen: list[Event] = [] + sim.bus.subscribe(ToggleClockRequested, seen.append) + + 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 sim.bus.drain() + finally: + task.cancel() + + assert [event.source for event in seen] == ["lirc"] + + async def test_mute_toggles_and_restores( lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation ) -> None: diff --git a/python-backend/tests/test_school_days.py b/python-backend/tests/test_school_days.py new file mode 100644 index 0000000..344e02f --- /dev/null +++ b/python-backend/tests/test_school_days.py @@ -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)) diff --git a/python-backend/tests/test_timers.py b/python-backend/tests/test_timers.py new file mode 100644 index 0000000..565a206 --- /dev/null +++ b/python-backend/tests/test_timers.py @@ -0,0 +1,292 @@ +"""The parents' timer and today's school day, on virtual time and a pinned date.""" + +from __future__ import annotations + +import asyncio +import io +import wave +from array import array +from collections.abc import AsyncIterator, Callable +from datetime import datetime, timedelta +from pathlib import Path + +import httpx2 +import pytest + +from musicmouse.app import AppState +from musicmouse.blubb import RATE, chime_wav +from musicmouse.bus import EventBus +from musicmouse.clock import FakeClock +from musicmouse.color import ColorRGBW +from musicmouse.config import ClockConfig +from musicmouse.effects import EffectStaticConfig +from musicmouse.events import ( + CancelTimerRequested, + ClockStateChanged, + LedEffectRequested, + NextTrackRequested, + SetTimerRequested, + UserActivity, +) +from musicmouse.hardware import LedZone +from musicmouse.services.timers import RING_SECONDS, TimerService, timer_end +from tests.test_school_days import PUBLIC, SCHOOL + +#: A Monday in the Herbstferien. +HOLIDAY = datetime(2026, 11, 2, 18, 0) +#: A plain school Monday. +SCHOOL_DAY = datetime(2026, 10, 5, 6, 0) + + +# --------------------------------------------------------------------------- chime + + +def test_the_chime_is_a_short_wav_at_the_requested_level() -> None: + wav = chime_wav(gain=0.3) + with wave.open(io.BytesIO(wav)) as audio: + assert audio.getnchannels() == 1 + assert audio.getframerate() == RATE + frames = audio.readframes(audio.getnframes()) + assert 1.0 < audio.getnframes() / RATE < 3.0 + peak = max(abs(sample) for sample in array("h", frames)) + assert peak == round(32767 * 0.3) + + +# ----------------------------------------------------------------------- timer_end + + +def test_minutes_count_from_now() -> None: + assert timer_end(datetime(2026, 10, 5, 18, 50), minutes=15) == datetime(2026, 10, 5, 19, 5) + + +def test_a_time_later_today_is_today() -> None: + now = datetime(2026, 10, 5, 18, 50, 30) + assert timer_end(now, until="19:30") == datetime(2026, 10, 5, 19, 30) + + +def test_a_time_already_past_is_tomorrow() -> None: + now = datetime(2026, 10, 5, 23, 40) + assert timer_end(now, until="00:10") == datetime(2026, 10, 6, 0, 10) + assert timer_end(now, until="23:40") == datetime(2026, 10, 6, 23, 40) + + +def test_exactly_one_of_minutes_and_until() -> None: + with pytest.raises(ValueError): + timer_end(HOLIDAY) + with pytest.raises(ValueError): + timer_end(HOLIDAY, minutes=5, until="19:00") + + +# ------------------------------------------------------------------------- service + + +class Harness: + def __init__(self, bus: EventBus, clock: FakeClock, service: TimerService) -> None: + self.bus = bus + self.clock = clock + self.service = service + self.state = service.state + self.changes = 0 + bus.subscribe(ClockStateChanged, self._count) + + def _count(self, _event: ClockStateChanged) -> None: + self.changes += 1 + + def now(self) -> float: + return self.service._wall().timestamp() + + +def _holiday_api(status: int = 200) -> Callable[[httpx2.Request], httpx2.Response]: + def handler(request: httpx2.Request) -> httpx2.Response: + if status != 200: + return httpx2.Response(status) + return httpx2.Response(200, json=SCHOOL if "School" in request.url.path else PUBLIC) + + return handler + + +@pytest.fixture +def start() -> datetime: + return HOLIDAY + + +@pytest.fixture +def api_status() -> int: + return 200 + + +@pytest.fixture +async def harness(tmp_path: Path, start: datetime, api_status: int) -> AsyncIterator[Harness]: + async with EventBus() as bus: + clock = FakeClock(idle=bus.drain) + service = TimerService( + bus, + AppState(), + ClockConfig(), + cache_file=tmp_path / "holidays.json", + client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))), + alsa_device=None, + volume=lambda: 0.5, + clock=clock, + wall=lambda: start + timedelta(seconds=clock.now()), + ) + task = asyncio.create_task(service.run()) + await clock.advance(0) + try: + yield Harness(bus, clock, service) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +async def _set(h: Harness, minutes: float) -> None: + await h.bus.emit_and_wait(SetTimerRequested(ends_at=h.now() + minutes * 60, source="web")) + await h.clock.advance(0) + + +async def test_a_timer_counts_down_then_rings(harness: Harness) -> None: + await _set(harness, 5) + timer = harness.state.parent_timer + assert timer is not None and not timer.ringing + assert timer.ends_at - timer.started_at == 300 + + await harness.clock.advance(299) + assert not harness.state.parent_timer.ringing # type: ignore[union-attr] + await harness.clock.advance(1) + assert harness.state.parent_timer.ringing # type: ignore[union-attr] + + +async def test_a_ringing_timer_clears_itself_after_a_minute(harness: Harness) -> None: + await _set(harness, 1) + await harness.clock.advance(60) + assert harness.state.parent_timer is not None + + await harness.clock.advance(RING_SECONDS - 1) + assert harness.state.parent_timer is not None + await harness.clock.advance(1) + assert harness.state.parent_timer is None + + +async def test_anything_a_person_does_silences_it(harness: Harness) -> None: + await _set(harness, 1) + await harness.clock.advance(60) + + await harness.bus.emit_and_wait(NextTrackRequested(source="lirc")) + assert harness.state.parent_timer is None + + +async def test_a_key_press_in_the_kiosk_silences_it(harness: Harness) -> None: + await _set(harness, 1) + await harness.clock.advance(60) + + await harness.bus.emit_and_wait(UserActivity(source="web")) + assert harness.state.parent_timer is None + + +async def test_the_backend_and_home_assistant_do_not_silence_it(harness: Harness) -> None: + await _set(harness, 1) + await harness.clock.advance(60) + + await harness.bus.emit_and_wait(NextTrackRequested(source="system")) + effect = EffectStaticConfig(color=ColorRGBW(1, 0, 0, 0)) + await harness.bus.emit_and_wait( + LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt") + ) + assert harness.state.parent_timer is not None + + +async def test_input_while_counting_down_does_not_stop_it(harness: Harness) -> None: + await _set(harness, 5) + await harness.bus.emit_and_wait(NextTrackRequested(source="lirc")) + assert harness.state.parent_timer is not None + + +async def test_cancel_stops_a_running_timer(harness: Harness) -> None: + await _set(harness, 5) + await harness.bus.emit_and_wait(CancelTimerRequested(source="web")) + assert harness.state.parent_timer is None + + await harness.clock.advance(600) # and it does not come back to ring + assert harness.state.parent_timer is None + + +async def test_a_new_timer_replaces_the_old_one(harness: Harness) -> None: + await _set(harness, 5) + await _set(harness, 30) + + await harness.clock.advance(300) + timer = harness.state.parent_timer + assert timer is not None and not timer.ringing + + +async def test_every_step_is_announced(harness: Harness) -> None: + before = harness.changes + await _set(harness, 1) # set + await harness.clock.advance(60) # ringing + await harness.clock.advance(RING_SECONDS) # cleared + assert harness.changes - before == 3 + + +# -------------------------------------------------------------------- school day + + +async def test_a_holiday_is_no_school_day(harness: Harness) -> None: + assert harness.state.school_day is False + assert harness.state.school_day_reason == "Herbstferien" + + +@pytest.mark.parametrize("start", [SCHOOL_DAY]) +async def test_a_plain_monday_is_a_school_day(harness: Harness) -> None: + assert harness.state.school_day is True + assert harness.state.school_day_reason is None + + +async def test_the_holidays_are_cached(harness: Harness) -> None: + assert harness.service.cache_file.exists() + + +async def test_the_day_changes_at_midnight(harness: Harness) -> None: + # Friday 2026-11-06 is the last day of the Herbstferien; Saturday is the weekend. + await harness.clock.advance(4 * 86400) + assert harness.state.school_day_reason == "Herbstferien" + await harness.clock.advance(86400) + assert harness.state.school_day_reason == "Wochenende" + + +@pytest.mark.parametrize(("start", "api_status"), [(SCHOOL_DAY, 503)]) +async def test_offline_without_a_cache_the_weekday_is_unknown(harness: Harness) -> None: + assert harness.state.school_day is None + + +@pytest.mark.parametrize("api_status", [503]) +async def test_offline_with_a_cache_the_cache_is_used( + tmp_path: Path, start: datetime, api_status: int +) -> None: + from musicmouse.school_days import HolidayCalendar, parse_holidays, save_calendar + + cache = tmp_path / "holidays.json" + save_calendar( + cache, + HolidayCalendar( + fetched=start.date() - timedelta(days=3), + subdivision="DE-BY", + school=parse_holidays(SCHOOL), + public=parse_holidays(PUBLIC), + ), + ) + async with EventBus() as bus: + service = TimerService( + bus, + AppState(), + ClockConfig(), + cache_file=cache, + client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))), + alsa_device=None, + volume=lambda: 0.5, + wall=lambda: start, + ) + assert await service.refresh_school_day() is False + await service.client.aclose() + + assert service.state.school_day_reason == "Herbstferien" diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index 1b9ec0b..1cbe78f 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -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: diff --git a/web/public/clock/bus.webp b/web/public/clock/bus.webp new file mode 100644 index 0000000000000000000000000000000000000000..3ee14d196a59e624766fc2f7c7be7094f1446227 GIT binary patch literal 34344 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 F{|9y#Ru2FG literal 0 HcmV?d00001 diff --git a/web/src/App.tsx b/web/src/App.tsx index 460ef34..bc47a8a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,7 +23,8 @@ import { BrowseView } from "./components/BrowseView"; import { HelpOverlay } from "./components/HelpOverlay"; import { MobileBrowse } from "./components/mobile/MobileBrowse"; import { MobileDock } from "./components/mobile/MobileDock"; -import { ParentPanel } from "./components/ParentPanel"; +import { ClockOverlay } from "./components/ClockOverlay"; +import { ParentPage, ParentPanel } from "./components/ParentPanel"; import { PlayerBar } from "./components/PlayerBar"; import { PlayView } from "./components/PlayView"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; @@ -75,14 +76,22 @@ const PODCAST_SKIP_SECONDS = 30; export function App() { const [ui, setUi] = useState(initialUiState); const library = useLibrary(); - const connection = usePlayerState(library.reload); + // The remote's Guide key. Only the kiosk on the device shows the clock for it - a + // tablet in another room has no business popping one up, same rule as Del. + const onRemoteClockToggle = useCallback(() => { + if (ON_THE_DEVICE) setUi((previous) => ({ ...previous, showClock: !previous.showClock })); + }, []); + const connection = usePlayerState(library.reload, onRemoteClockToggle); const [gridRef, columns] = useGridColumns(); // A phone gets its own touch layout (components/mobile/); everything else - the // kiosk, a desktop browser, a tablet - keeps the keyboard-first one. const phone = usePhoneLayout(); - const [parentMode, setParentMode] = useState( + // `?parentMode=1` only adds a way into parent mode (the 🔧 button, or the phone's + // "Eltern" tab), so the URL can stay bookmarked on a parent's tablet or phone. + const [parentAccess] = useState( () => new URLSearchParams(location.search).get("parentMode") === "1", ); + const [parentPanelOpen, setParentPanelOpen] = useState(false); const [perfOpen, setPerfOpen] = useState( () => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1", ); @@ -419,6 +428,34 @@ export function App() { }; }, []); + // The parents' timer ran out: the kiosk shows the clock with it, chiming 00:00. + const ringing = state?.clock.parent_timer?.ringing ?? false; + useEffect(() => { + if (ringing && ON_THE_DEVICE) setUi((previous) => ({ ...previous, showClock: true })); + }, [ringing]); + + // ... and the next key press or tap anywhere silences it. The backend does the same + // for the remote and the mouse; this is the kiosk's (and any open tab's) share. + useEffect(() => { + if (!ringing) return; + const silence = () => void api.clearTimer().catch(() => {}); + window.addEventListener("keydown", silence, true); + window.addEventListener("pointerdown", silence, true); + return () => { + window.removeEventListener("keydown", silence, true); + window.removeEventListener("pointerdown", silence, true); + }; + }, [ringing]); + + // The "Eltern" tab only exists on a phone; a window that grows out of the phone + // layout while on it goes back to the music, with the sheet open in its place. + useEffect(() => { + if (!phone && ui.page === "parents") { + setUi((previous) => ({ ...previous, page: "music" })); + setParentPanelOpen(true); + } + }, [phone, ui.page]); + // An abandoned "A" press disarms itself rather than leaving the app half-primed. useEffect(() => { if (!ui.assignPending) return; @@ -659,11 +696,22 @@ export function App() { {!phone && } - {phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && ( + {phone && ui.page === "parents" && parentAccess && ( + setPerfOpen(true) : undefined} + /> + )} + + {phone && + (ui.page === "room" || + (ui.page === "parents" && parentAccess) || + (ui.page === "music" && ui.view === "browse")) && ( setUi((previous) => ({ ...previous, searchOpen: true }))} @@ -698,55 +746,42 @@ export function App() { /> )} - {parentMode && setParentMode(false)} />} + setUi((previous) => ({ ...previous, showClock: false }))} + /> - {PERF_ENABLED && !perfOpen && ( - + {parentAccess && parentPanelOpen && ( + setParentPanelOpen(false)} /> )} + {PERF_ENABLED && perfOpen && setPerfOpen(false)} />} - {/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard) - or the typing page (TippenApp has its own help and owns every key there). */} - {!phone && ui.page !== "typing" && !ui.showHelp && ( - + {/* Small tools in the bottom-left corner. Not on a phone: there the dock covers + that corner, and parent mode is a tab in it instead. The F1 sheet's button is + not on the typing page either (TippenApp has its own help and owns every key). */} + {!phone && ( +
+ {PERF_ENABLED && !perfOpen && ( + setPerfOpen(true)}> + ⚡ + + )} + {ui.page !== "typing" && !ui.showHelp && ( + setUi((previous) => ({ ...previous, showHelp: true }))} + > + ⌨ + + )} + {parentAccess && !parentPanelOpen && ( + setParentPanelOpen(true)}> + 🔧 + + )} +
)} {(!state || library.loading) && } @@ -765,6 +800,29 @@ export function App() { ); } +/** One of the small round buttons in the bottom-left corner. */ +function ToolButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + /** The one fixed round button in each top corner: back (left) and the music/room * toggle (right). Same position and shape everywhere they appear, so "top left" and * "top right" always mean the same thing. */ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0f920ba..9953b4b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -103,6 +103,10 @@ export const api = { /** Someone is at the kiosk: holds off the screen's idle auto-off. */ screenActivity: () => post("/screen/activity"), + /** The parents' countdown: minutes from now, or until a time of day ("HH:MM"). */ + setTimer: (timer: { minutes: number } | { until: string }) => post("/timer", timer), + clearTimer: () => request("/timer", { method: "DELETE" }), + settings: () => request("/settings"), saveSettings: (settings: Settings) => request("/settings", { method: "PUT", body: JSON.stringify(settings) }), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 752698b..893d102 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -90,6 +90,19 @@ export interface PlayerState { volume: number; active_figure: string | null; connected: { firmware: boolean; mqtt: boolean; lirc: boolean }; + clock: ClockState; +} + +/** What the clock overlay shows besides the time. Mirrors `ClockStateOut`. */ +export interface ClockState { + /** `null` when the holiday calendar could not be fetched (weekends are still known). */ + school_day: boolean | null; + /** Why there is no school today: "Wochenende", "Herbstferien", ... */ + school_day_reason: string | null; + /** Today's bus, `null` without school. `time` is "HH:MM". */ + bus: { time: string; lead_minutes: number } | null; + /** The parents' countdown, in wall-clock Unix seconds. */ + parent_timer: { started_at: number; ends_at: number; ringing: boolean } | null; } export interface Settings { @@ -106,7 +119,9 @@ export interface Settings { export type ServerMessage = | { type: "state"; state: PlayerState } | { type: "position"; position: number; duration: number } - | { type: "library" }; + | { type: "library" } + /** The IR remote's Guide key: show or hide the clock. */ + | { type: "clock_toggle" }; export interface HaDevice { entity_id: string; diff --git a/web/src/components/ClockOverlay.tsx b/web/src/components/ClockOverlay.tsx new file mode 100644 index 0000000..62786f0 --- /dev/null +++ b/web/src/components/ClockOverlay.tsx @@ -0,0 +1,187 @@ +/** The "Delphin Uhr": a big analog wrist watch over whatever page is showing, with the + * countdowns that are running right now beside it - the school bus on school-day + * mornings, and the parents' timer. Built from `claude-design/Delphin Uhr.dc.html`, + * 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. */ + +import { memo } from "react"; + +import type { ClockState } from "../api/types"; +import { useNow } from "../hooks/useNow"; +import { activeCountdowns, type Countdown, formatCountdown, handAngles } from "../lib/clock"; +import { SKIN } from "../lib/skins"; +import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme"; +import { Drift, type DriftField } from "./Drift"; + +const BUBBLES: DriftField = [ + [5, 14, 0.5, 9, 0], + [12, 22, 0.5, 12, -3], + [22, 10, 0.5, 8, -6], + [48, 16, 0.5, 11, -2], + [63, 12, 0.5, 10, -7], + [78, 24, 0.5, 14, -5], + [88, 12, 0.5, 9, -1], + [95, 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; + +const CARD_IMAGE: Record = { + bus: { src: "/clock/bus.webp", alt: "Bus" }, + timer: { src: SKIN.mascotWatch, alt: "Timer" }, +}; + +interface Props { + visible: boolean; + clock: ClockState | null; + onClose: () => void; +} + +export function ClockOverlay({ visible, clock, onClose }: Props) { + const now = useNow(visible); + const countdowns = clock ? activeCountdowns(now, clock) : []; + const angles = handAngles(now); + + return ( +