Screen: switch off by itself when nothing is going on

With `general.screen.off_command` set, the monitor switches off once nothing has
played and nobody has done anything for `auto_off_minutes` (15 by default, 0 for
never, editable in parent mode). "Doing anything" is any intent a person could
have caused: a remote key, a web command, or a key press or tap in the kiosk,
which the page reports at most every 30s via POST /api/screen/activity. The first
such intent after an auto-off runs `on_command`, so the remote wakes the screen
too. The auto-off itself is silent; switches a person asks for still blubb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:42:59 +02:00
parent 459599d2ba
commit 077c4e819f
16 changed files with 455 additions and 49 deletions

View File

@@ -15,8 +15,18 @@ import pytest
from musicmouse.blubb import RATE, blubb_wav
from musicmouse.bus import EventBus
from musicmouse.clock import FakeClock
from musicmouse.color import ColorRGBW
from musicmouse.config import ScreenConfig
from musicmouse.events import ToggleScreenRequested
from musicmouse.effects import EffectStaticConfig
from musicmouse.events import (
LedEffectRequested,
PlaybackChanged,
PlayRemoteSlotRequested,
ToggleScreenRequested,
UserActivity,
)
from musicmouse.hardware import LedZone
from musicmouse.services.screen import ScreenService
@@ -184,3 +194,157 @@ async def test_no_blubb(
task.cancel()
assert not Path(f"{fake_aplay}.wav").exists()
# ------------------------------------------------------------------- idle auto-off
class _Idle:
"""A started service with a fake clock and an ``off`` and ``on`` command that log."""
def __init__(self, bus: EventBus, tmp_path: Path, **config: object) -> None:
self.bus = bus
self.off_log = tmp_path / "off"
self.on_log = tmp_path / "on"
self.playing = False
self.clock = FakeClock(idle=bus.drain)
settings: dict[str, object] = {
"toggle_command": _python("print('toggled')"),
"off_command": _appending(self.off_log, then="print('off')"),
"on_command": _appending(self.on_log, then="print('on')"),
"auto_off_minutes": 15,
}
settings.update(config)
self.service = ScreenService(
bus,
ScreenConfig.model_validate(settings),
alsa_device=None,
volume=lambda: 0.5,
is_playing=lambda: self.playing,
clock=self.clock,
)
self.task: asyncio.Task[None] | None = None
async def __aenter__(self) -> _Idle:
self.task = asyncio.create_task(self.service.run())
await asyncio.sleep(0)
return self
async def __aexit__(self, *_: object) -> None:
assert self.task is not None
self.task.cancel()
async def wait(self, minutes: float) -> None:
await self.clock.advance(minutes * 60)
await _settle(self.service)
def offs(self) -> int:
return len(self.off_log.read_text().splitlines()) if self.off_log.exists() else 0
def ons(self) -> int:
return len(self.on_log.read_text().splitlines()) if self.on_log.exists() else 0
async def test_the_screen_goes_off_after_the_idle_time(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(14)
assert idle.offs() == 0
await idle.wait(1)
assert idle.offs() == 1
await idle.wait(60)
assert idle.offs() == 1 # once, not every half minute after
async def test_not_while_music_plays(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.playing = True
await idle.wait(60)
assert idle.offs() == 0
# The countdown starts when the music stops.
idle.playing = False
await bus.emit_and_wait(PlaybackChanged(playing=False, source="player"))
await idle.wait(14)
assert idle.offs() == 0
await idle.wait(1)
assert idle.offs() == 1
@pytest.mark.parametrize(
"event",
[UserActivity(source="web"), PlayRemoteSlotRequested(digit="3", source="lirc")],
)
async def test_activity_restarts_the_countdown(
bus: EventBus, tmp_path: Path, event: UserActivity | PlayRemoteSlotRequested
) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(10)
await bus.emit_and_wait(event)
await idle.wait(10)
assert idle.offs() == 0
await idle.wait(5)
assert idle.offs() == 1
async def test_automations_do_not_count_as_activity(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(10)
effect = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
automation = LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
await bus.emit_and_wait(automation)
await idle.wait(5)
assert idle.offs() == 1
async def test_someone_coming_back_switches_it_on(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(15)
assert idle.offs() == 1
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
assert idle.ons() == 1 # only the first wakes it
await idle.wait(15)
assert idle.offs() == 2 # and the countdown runs again
async def test_the_power_key_after_an_auto_off_is_left_to_the_toggle(
bus: EventBus, tmp_path: Path
) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(15)
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(idle.service)
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
assert idle.ons() == 0
async def test_zero_minutes_means_never(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path, auto_off_minutes=0) as idle:
await idle.wait(600)
assert idle.offs() == 0
async def test_no_off_command_means_never(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path, off_command=None) as idle:
await idle.wait(600)
assert idle.offs() == 0
async def test_a_new_idle_time_applies_at_once(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.service.config.auto_off_minutes = 5 # what parent mode does
await idle.wait(5)
assert idle.offs() == 1
async def test_the_auto_off_is_silent(bus: EventBus, tmp_path: Path, fake_aplay: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.service.alsa_device = "softvol_effects"
await idle.wait(15)
assert idle.offs() == 1
assert not Path(f"{fake_aplay}.wav").exists()