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

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

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

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

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

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

View File

@@ -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

View File

@@ -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())

View File

@@ -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"))

View File

@@ -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"})

View File

@@ -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

View File

@@ -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,
)