Files
musicmouse/python-backend/musicmouse/services/timers.py
Martin Bauer d465a74cde Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:18 +02:00

227 lines
8.3 KiB
Python

"""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
#: 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
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
#: 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(0.0, 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())