Files
musicmouse/python-backend/musicmouse/services/web/settings.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

106 lines
3.4 KiB
Python

"""Volume as a percentage, and the handful of settings parent mode may change.
Two jobs that both come down to "keep the config file's numbers out of the child's UI".
The volume mapping lives here rather than in the frontend so that MQTT, the rotary
encoder and the firmware carry on speaking device units, unaware that a browser is
using a different scale.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from musicmouse.config import GeneralConfig
from musicmouse.services.web.schemas import SettingsIn, SettingsOut
_log = logging.getLogger(__name__)
__all__ = [
"atomic_write",
"load_document",
"read_settings",
"to_device_volume",
"to_percent",
"write_settings",
]
def to_device_volume(percent: int, general: GeneralConfig) -> int:
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
span = general.max_volume - general.min_volume
if span <= 0:
return general.min_volume
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
def to_percent(volume: int, general: GeneralConfig) -> int:
"""The inverse, for the state snapshot."""
span = general.max_volume - general.min_volume
if span <= 0:
return 100
return max(0, min(100, round((volume - general.min_volume) / span * 100)))
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
),
)
def _yaml() -> YAML:
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
return yaml
def load_document(path: Path) -> Any:
"""The config file, round-trip parsed so it keeps its comments and formatting.
Shared by every writer that patches ``config.yml`` in place - a config that
explains itself is worth more than one a save could rewrite from scratch.
"""
with path.open(encoding="utf-8") as handle:
return _yaml().load(handle)
def atomic_write(path: Path, document: Any) -> None:
"""Write a round-trip-loaded document back, through a sibling temp file so an
interrupted save cannot truncate the real one."""
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
try:
with temp.open("w", encoding="utf-8") as handle:
_yaml().dump(document, handle)
temp.replace(path)
except BaseException:
temp.unlink(missing_ok=True)
raise
_log.info("Wrote settings to %s", path)
def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place."""
document = load_document(path)
general = document["general"]
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)