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 0000000..3ee14d1 Binary files /dev/null and b/web/public/clock/bus.webp differ diff --git a/web/public/clock/dolphin-watch.webp b/web/public/clock/dolphin-watch.webp new file mode 100644 index 0000000..a61ad05 Binary files /dev/null and b/web/public/clock/dolphin-watch.webp differ 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 ( +