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:
@@ -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
|
||||
|
||||
227
python-backend/musicmouse/services/timers.py
Normal file
227
python-backend/musicmouse/services/timers.py
Normal 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())
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user