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>
227 lines
8.3 KiB
Python
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())
|