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

@@ -1,4 +1,5 @@
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request.
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request,
and switches it off by itself when nothing is going on.
A service rather than a reaction because the command is a subprocess: the bus awaits
async handlers, so running it there would hold every other event up behind ``xset``.
@@ -7,6 +8,13 @@ Here each press gets its own task, and a hung or failing command is only logged.
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
ALSA device, at the player's volume. The command's last word of output says which way
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
With an ``off_command`` it also switches the monitor off by itself once nothing has
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
it would tell may be asleep. "Doing anything" is any intent a person could have caused:
a remote key, a web command, a key press in the kiosk browser (:class:`UserActivity`).
The first such intent after an auto-off runs ``on_command``, so the monitor wakes up
for the remote too, not only for the keyboard X already wakes it for.
"""
from __future__ import annotations
@@ -18,8 +26,15 @@ from collections.abc import Callable
from musicmouse.blubb import blubb_wav
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import ScreenConfig
from musicmouse.events import ToggleScreenRequested
from musicmouse.events import (
Event,
IntentEvent,
LedEffectRequested,
PlaybackChanged,
ToggleScreenRequested,
)
_log = logging.getLogger(__name__)
@@ -29,6 +44,10 @@ __all__ = ["ScreenService"]
#: mastered near full scale; a notification sound sits well under it.
_BLUBB_GAIN = 0.35
#: How often the idle auto-off looks at the clock. Coarse on purpose: nobody notices
#: whether a screen went dark after 15 minutes or 15 and a half.
_IDLE_CHECK_INTERVAL = 30.0
class ScreenService:
name = "screen"
@@ -40,40 +59,97 @@ class ScreenService:
*,
alsa_device: str | None,
volume: Callable[[], float],
is_playing: Callable[[], bool] = lambda: False,
clock: Clock | None = None,
) -> None:
self.bus = bus
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
#: very object.
self.config = config
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device
#: The player's volume as a fraction, read at the moment the sound plays.
self.volume = volume
self.is_playing = is_playing
self._clock = clock or RealClock()
self._last_activity = self._clock.now()
#: The monitor was last switched by the idle auto-off, not by a person.
self._auto_off = False
self._running: asyncio.Task[None] | None = None
async def run(self) -> None:
unsubscribe = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
unsubscribe_toggle = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
unsubscribe_all = self.bus.subscribe_all(self._on_event)
try:
await asyncio.Event().wait()
while True:
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
self.check_idle()
finally:
unsubscribe()
unsubscribe_toggle()
unsubscribe_all()
if self._running is not None:
self._running.cancel()
def _on_toggle(self, _event: ToggleScreenRequested) -> None:
# A second press while the first command is still running would read the
# Whichever way the press goes, a person decided it; nothing to wake from.
self._auto_off = False
self._start(self.config.toggle_command, sound=True)
def _on_event(self, event: Event) -> None:
if isinstance(event, PlaybackChanged):
self._last_activity = self._clock.now()
return
# LED intents arrive from Home Assistant automations as well as people, and
# "system" intents are the backend talking to itself.
if (
not isinstance(event, IntentEvent)
or isinstance(event, LedEffectRequested)
or event.source == "system"
):
return
self._last_activity = self._clock.now()
if self._auto_off and not isinstance(event, ToggleScreenRequested):
self._auto_off = False
if self.config.on_command:
self._start(self.config.on_command, sound=True)
def check_idle(self) -> None:
"""Switch the monitor off if it has been idle long enough. Called every
:data:`_IDLE_CHECK_INTERVAL` by :meth:`run`."""
now = self._clock.now()
if self.is_playing():
# The countdown starts when the music stops, not when it started.
self._last_activity = now
return
minutes = self.config.auto_off_minutes
if self._auto_off or not minutes or not self.config.off_command:
return
if now - self._last_activity < minutes * 60:
return
if self._start(self.config.off_command, sound=False):
_log.info("Nothing played for %d minutes; switching the screen off", minutes)
self._auto_off = True
def _start(self, command: str, *, sound: bool) -> bool:
# A second switch while the first command is still running would read the
# monitor's state before the first one changed it, and undo it.
if self._running is not None and not self._running.done():
_log.debug("Screen toggle still running; ignoring this press")
return
self._running = asyncio.create_task(self.toggle(), name="screen-toggle")
_log.debug("Screen command still running; ignoring this one")
return False
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
return True
async def toggle(self) -> None:
argv = shlex.split(self.config.toggle_command)
await self._run(self.config.toggle_command, sound=True)
async def _run(self, command: str, *, sound: bool) -> None:
argv = shlex.split(command)
try:
process = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
except OSError as exc:
_log.warning("Screen toggle %r could not start: %s", self.config.toggle_command, exc)
_log.warning("Screen command %r could not start: %s", command, exc)
return
try:
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
@@ -81,23 +157,18 @@ class ScreenService:
process.kill()
await process.wait()
_log.warning(
"Screen toggle %r took longer than %gs; killed",
self.config.toggle_command,
self.config.timeout,
"Screen command %r took longer than %gs; killed", command, self.config.timeout
)
return
text = output.decode(errors="replace").strip()
if process.returncode != 0:
_log.warning(
"Screen toggle %r failed (exit %d): %s",
self.config.toggle_command,
process.returncode,
text,
"Screen command %r failed (exit %d): %s", command, process.returncode, text
)
return
_log.info("Screen toggled%s", f": {text}" if text else "")
_log.info("Screen switched%s", f": {text}" if text else "")
state = text.split()[-1] if text else ""
if state in ("on", "off"):
if sound and state in ("on", "off"):
await self.blubb(on=state == "on")
async def blubb(self, *, on: bool) -> None:

View File

@@ -32,6 +32,7 @@ from musicmouse.events import (
SeekRequested,
SetVolumeRequested,
ToggleScreenRequested,
UserActivity,
)
from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
@@ -198,6 +199,12 @@ def build_router(
# no-op, like the remote's power key.
return emit(ToggleScreenRequested(source="web"))
@router.post("/screen/activity", status_code=204)
def screen_activity() -> Response:
# The kiosk page on the device reports key presses and taps here, so the
# screen's idle auto-off does not blank it under a child who is browsing.
return emit(UserActivity(source="web"))
@router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response:
return emit(SeekRequested(position=body.position, source="web"))
@@ -237,8 +244,13 @@ def build_router(
general = app.config.general
# Applied live as well as saved: a parent lowering the ceiling expects the next
# song to be quieter, not the next boot.
for key, value in body.model_dump().items():
values = body.model_dump()
auto_off = values.pop("screen_auto_off_minutes")
for key, value in values.items():
setattr(general, key, value)
if auto_off is not None and general.screen is not None:
# The same object ScreenService reads on every tick.
general.screen.auto_off_minutes = auto_off
app.player.set_volume_limits(general.min_volume, general.max_volume)
await asyncio.to_thread(write_settings, config_path, body)
await hub.broadcast_state()

View File

@@ -241,6 +241,9 @@ class SettingsOut(BaseModel):
initial_volume: int
volume_increment: int
button_leds_brightness: float
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
#: switch off (no ``screen`` section, or one without an ``off_command``).
screen_auto_off_minutes: int | None
class SettingsIn(BaseModel):
@@ -249,6 +252,8 @@ class SettingsIn(BaseModel):
initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100)
button_leds_brightness: float = Field(ge=0, le=1)
#: Ignored without a screen section. ``None`` leaves it as it is.
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
class HaDeviceOut(BaseModel):

View File

@@ -48,12 +48,16 @@ def to_percent(volume: int, general: GeneralConfig) -> int:
def read_settings(general: GeneralConfig) -> SettingsOut:
screen = general.screen
return SettingsOut(
min_volume=general.min_volume,
max_volume=general.max_volume,
initial_volume=general.initial_volume,
volume_increment=general.volume_increment,
button_leds_brightness=general.button_leds_brightness,
screen_auto_off_minutes=(
screen.auto_off_minutes if screen is not None and screen.off_command else None
),
)
@@ -91,6 +95,11 @@ def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place."""
document = load_document(path)
general = document["general"]
for key, value in settings.model_dump().items():
values = settings.model_dump()
# The one setting that lives in a subsection rather than flat under `general`.
auto_off = values.pop("screen_auto_off_minutes")
for key, value in values.items():
general[key] = value
if auto_off is not None and general.get("screen") is not None:
general["screen"]["auto_off_minutes"] = auto_off
atomic_write(path, document)