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