A figure folder that holds album folders instead of audio can play any of them, up to six, in folder order (depth-first, alphabetical). Each is its own album, titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays the album it played last, or the first, straight away; another figure placed in between forgets the position in that album, not the album itself. Meanwhile the ring offers all of them: one section per album from a fixed palette, edges shifting towards the neighbours' hue, the selected one bright and wobbling. The knob moves the selection one section per detent and wraps; pressing plays the selected album, and left alone for 8 s the picker fades into the figure's colours and the music carries on. While open, the device hands the knob to the picker (`capture_knob`): turns do not change the volume and the press is not published to Home Assistant. A tick per step and a confirm "pling" go with it. The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23), re-sent on every detent with the section to glide from. The look is tunable through a temporary `general.figure_selection` config section until the right values become constants. Every UI sound - the screen's blubb, the parents' timer, the picker - now follows the music volume with a floor, times a new parent-mode "Effekt-Lautstärke" (`general.effects_volume`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
3.4 KiB
Python
107 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,
|
|
effects_volume=general.effects_volume,
|
|
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)
|