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:
@@ -114,6 +114,14 @@ def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
|
||||
assert "track_index" in message
|
||||
|
||||
|
||||
def test_the_screen_switches_off_after_15_minutes_by_default(config_dir: Path) -> None:
|
||||
data = _config(screen={"toggle_command": "kiosk-screen toggle"})
|
||||
screen = load_config(write_config(config_dir, data)).general.screen
|
||||
assert screen is not None
|
||||
assert screen.auto_off_minutes == 15
|
||||
assert screen.off_command is None # ...but only once there is a way to do it
|
||||
|
||||
|
||||
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||
data = _config(
|
||||
ha={
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
from musicmouse.config import WebConfig, load_config
|
||||
from musicmouse.config import ScreenConfig, WebConfig, load_config
|
||||
from musicmouse.services.web.service import build_app
|
||||
from musicmouse.services.web.settings import to_device_volume, to_percent
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
@@ -99,8 +99,46 @@ def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
|
||||
|
||||
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
|
||||
body = (await client.get("/api/settings")).json()
|
||||
assert set(body) == set(SETTINGS)
|
||||
assert set(body) == {*SETTINGS, "screen_auto_off_minutes"}
|
||||
assert body["max_volume"] == 60
|
||||
assert body["screen_auto_off_minutes"] is None # no screen to switch off
|
||||
|
||||
|
||||
async def test_the_screen_auto_off_is_read_and_saved_into_its_section(
|
||||
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
|
||||
) -> None:
|
||||
path = config_dir / "config.yml"
|
||||
path.write_text(
|
||||
COMMENTED.replace(
|
||||
"\nfigures:",
|
||||
" screen:\n toggle_command: kiosk-screen toggle\n"
|
||||
" off_command: kiosk-screen off\n\nfigures:",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sim.app.config.general.screen = ScreenConfig(
|
||||
toggle_command="kiosk-screen toggle", off_command="kiosk-screen off"
|
||||
)
|
||||
assert (await client.get("/api/settings")).json()["screen_auto_off_minutes"] == 15
|
||||
|
||||
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["screen_auto_off_minutes"] == 30
|
||||
assert sim.app.config.general.screen.auto_off_minutes == 30 # live, for ScreenService
|
||||
|
||||
reloaded = load_config(path)
|
||||
assert reloaded.general.screen is not None
|
||||
assert reloaded.general.screen.auto_off_minutes == 30
|
||||
assert "screen_auto_off_minutes" not in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_the_screen_auto_off_without_a_screen_is_ignored(
|
||||
client: httpx2.AsyncClient, config_dir: Path
|
||||
) -> None:
|
||||
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["screen_auto_off_minutes"] is None
|
||||
assert "screen" not in (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- writing
|
||||
|
||||
Reference in New Issue
Block a user