Files
musicmouse/python-backend/tests/test_screen.py
Martin Bauer 077c4e819f 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>
2026-10-03 19:42:59 +02:00

351 lines
12 KiB
Python

"""The screen service: runs the configured command per toggle, and never takes the bus down."""
from __future__ import annotations
import asyncio
import io
import os
import shlex
import sys
import wave
from collections.abc import AsyncIterator
from pathlib import Path
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.effects import EffectStaticConfig
from musicmouse.events import (
LedEffectRequested,
PlaybackChanged,
PlayRemoteSlotRequested,
ToggleScreenRequested,
UserActivity,
)
from musicmouse.hardware import LedZone
from musicmouse.services.screen import ScreenService
def _python(code: str) -> str:
return f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
def _appending(log: Path, *, then: str = "") -> str:
"""A command that appends a line to ``log`` each time it runs."""
return _python(f"open({str(log)!r}, 'a').write('ran\\n'); {then}")
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as bus:
yield bus
async def _settle(service: ScreenService) -> None:
while service._running is not None and not service._running.done():
await asyncio.sleep(0.01)
async def _started(
bus: EventBus,
config: ScreenConfig,
*,
alsa_device: str | None = None,
volume: float = 0.5,
) -> tuple[ScreenService, asyncio.Task[None]]:
service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume)
task = asyncio.create_task(service.run())
await asyncio.sleep(0) # let it subscribe
return service, task
async def test_each_press_runs_the_command(bus: EventBus, tmp_path: Path) -> None:
log = tmp_path / "ran"
service, task = await _started(bus, ScreenConfig(toggle_command=_appending(log)))
try:
for _ in range(2):
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert log.read_text() == "ran\nran\n"
async def test_a_press_while_the_command_runs_is_ignored(bus: EventBus, tmp_path: Path) -> None:
log = tmp_path / "ran"
command = _appending(log, then="import time; time.sleep(0.3)")
service, task = await _started(bus, ScreenConfig(toggle_command=command))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert log.read_text() == "ran\n"
async def test_a_failing_command_is_logged(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
command = _python("import sys; print('no display'); sys.exit(3)")
service, task = await _started(bus, ScreenConfig(toggle_command=command))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert "exit 3" in caplog.text
assert "no display" in caplog.text
async def test_a_missing_command_is_logged(
bus: EventBus, caplog: pytest.LogCaptureFixture, tmp_path: Path
) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=str(tmp_path / "no-such-command"))
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert "could not start" in caplog.text
async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
command = _python("import time; time.sleep(30)")
service, task = await _started(bus, ScreenConfig(toggle_command=command, timeout=0.2))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await asyncio.wait_for(_settle(service), 5)
finally:
task.cancel()
assert "killed" in caplog.text
# ------------------------------------------------------------------------ the blubb
def test_the_blubb_is_a_short_mono_wav() -> None:
with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav:
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
assert 0.1 < wav.getnframes() / RATE < 0.5
def test_on_and_off_sound_different() -> None:
assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5)
@pytest.fixture
def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An ``aplay`` on PATH that records its arguments and what it was fed."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
record = tmp_path / "aplay"
script = bin_dir / "aplay"
script.write_text(
f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n'
f"cat > {shlex.quote(str(record))}.wav\n"
)
script.chmod(0o755)
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
return record
async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects"
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"]
assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5)
@pytest.mark.parametrize(
("output", "alsa_device", "volume"),
[
("print('on')", None, 0.5), # simulated audio
("print('on')", "softvol_effects", 0.0), # muted
("print('done')", "softvol_effects", 0.5), # no direction to go by
],
)
async def test_no_blubb(
bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float
) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
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()