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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user