Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
256
python-backend/musicmouse/config.py
Normal file
256
python-backend/musicmouse/config.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Config schema and loading.
|
||||
|
||||
Validation is strict on purpose: unknown keys are rejected (a typo'd setting that is
|
||||
silently ignored is worse than a startup failure), and every problem in the file is
|
||||
reported at once rather than one per run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Self
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PlainValidator,
|
||||
ValidationError,
|
||||
ValidationInfo,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.error import YAMLError
|
||||
|
||||
from musicmouse.color import ColorRGBW, parse_color
|
||||
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||
from musicmouse.media import Playlist, build_playlist
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"FigureColors",
|
||||
"FigureConfig",
|
||||
"GeneralConfig",
|
||||
"MqttConfig",
|
||||
"build_playlists",
|
||||
"format_validation_error",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised with an already human-readable, multi-line message."""
|
||||
|
||||
|
||||
def _parse_tag_id(value: Any) -> bytes:
|
||||
if isinstance(value, bytes):
|
||||
raw = value
|
||||
else:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"expected a hex string, got {type(value).__name__}")
|
||||
text = value.strip().replace(":", "").replace(" ", "")
|
||||
try:
|
||||
raw = bytes.fromhex(text)
|
||||
except ValueError:
|
||||
raise ValueError(f"{value!r} is not a valid hex string") from None
|
||||
if len(raw) != RFID_TAG_LENGTH:
|
||||
raise ValueError(
|
||||
f"expected {RFID_TAG_LENGTH} bytes ({RFID_TAG_LENGTH * 2} hex digits), "
|
||||
f"got {len(raw)} ({raw.hex()!r})"
|
||||
)
|
||||
if raw == NO_FIGURE_TAG:
|
||||
raise ValueError("the all-zero tag id is reserved for 'no figure on the reader'")
|
||||
return raw
|
||||
|
||||
|
||||
Color = Annotated[ColorRGBW, PlainValidator(parse_color)]
|
||||
TagId = Annotated[bytes, PlainValidator(_parse_tag_id)]
|
||||
|
||||
_COLOR_ROLES = ("primary", "secondary", "bg", "accent")
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class FigureColors(_Strict):
|
||||
"""The four colours of a figure, given in config as a list of colour strings."""
|
||||
|
||||
primary: Color
|
||||
secondary: Color
|
||||
bg: Color
|
||||
accent: Color
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _accept_sequence(cls, data: Any) -> Any:
|
||||
if isinstance(data, (list, tuple)):
|
||||
if len(data) != len(_COLOR_ROLES):
|
||||
raise ValueError(
|
||||
f"expected exactly {len(_COLOR_ROLES)} colors "
|
||||
f"({', '.join(_COLOR_ROLES)}), got {len(data)}"
|
||||
)
|
||||
return dict(zip(_COLOR_ROLES, data, strict=True))
|
||||
return data
|
||||
|
||||
|
||||
class MqttConfig(_Strict):
|
||||
server: str
|
||||
port: int = Field(default=1883, ge=1, le=65535)
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
base_topic: str = "musicmouse"
|
||||
discovery_prefix: str = "homeassistant"
|
||||
device_id: str = "musicmouse"
|
||||
device_name: str = "Music Mouse"
|
||||
reconnect_interval: float = Field(default=10.0, gt=0)
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
#: Root folder holding one subfolder per figure.
|
||||
figure_folder: Path
|
||||
|
||||
serial_port: str = "/dev/ttyUSB0"
|
||||
baudrate: int = Field(default=115200, gt=0)
|
||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||
|
||||
#: ALSA output device passed to VLC, e.g. "hw:0,0"; null for VLC's default.
|
||||
alsa_device: str | None = None
|
||||
|
||||
mqtt: MqttConfig | None = None
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
initial_volume: int = Field(default=50, ge=0, le=200)
|
||||
volume_increment: int = Field(default=5, ge=1, le=100)
|
||||
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
||||
|
||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_volumes(self) -> Self:
|
||||
if self.min_volume > self.max_volume:
|
||||
raise ValueError(
|
||||
f"min_volume ({self.min_volume}) must not exceed max_volume ({self.max_volume})"
|
||||
)
|
||||
if not self.min_volume <= self.initial_volume <= self.max_volume:
|
||||
raise ValueError(
|
||||
f"initial_volume ({self.initial_volume}) must lie between "
|
||||
f"min_volume ({self.min_volume}) and max_volume ({self.max_volume})"
|
||||
)
|
||||
return self
|
||||
|
||||
@field_validator("figure_folder")
|
||||
@classmethod
|
||||
def _resolve_figure_folder(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
context = info.context or {}
|
||||
base = context.get("config_dir")
|
||||
if base is not None and not folder.is_absolute():
|
||||
folder = (Path(base) / folder).resolve()
|
||||
if context.get("check_paths", True) and not folder.is_dir():
|
||||
raise ValueError(f"no such directory: {folder}")
|
||||
return folder
|
||||
|
||||
|
||||
class FigureConfig(_Strict):
|
||||
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
||||
id: TagId
|
||||
colors: FigureColors
|
||||
|
||||
|
||||
class Config(_Strict):
|
||||
general: GeneralConfig
|
||||
figures: dict[str, FigureConfig] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_unique_tag_ids(self) -> Self:
|
||||
seen: dict[bytes, str] = {}
|
||||
for name, figure in self.figures.items():
|
||||
if (other := seen.get(figure.id)) is not None:
|
||||
raise ValueError(
|
||||
f"figures {other!r} and {name!r} both use tag id {figure.id.hex()}"
|
||||
)
|
||||
seen[figure.id] = name
|
||||
return self
|
||||
|
||||
@property
|
||||
def tag_map(self) -> dict[bytes, str]:
|
||||
"""Tag id -> figure name, as handed to the device."""
|
||||
return {figure.id: name for name, figure in self.figures.items()}
|
||||
|
||||
def folder_for(self, figure: str) -> Path:
|
||||
return self.general.figure_folder / figure
|
||||
|
||||
|
||||
def build_playlists(config: Config) -> dict[str, Playlist]:
|
||||
"""One playlist per figure, from ``<figure_folder>/<figure_name>``, alphabetically."""
|
||||
return {
|
||||
name: build_playlist(name, config.folder_for(name), config.general.audio_extensions)
|
||||
for name in config.figures
|
||||
}
|
||||
|
||||
|
||||
def format_validation_error(error: ValidationError) -> str:
|
||||
"""Render a pydantic error as one short ``path: message`` line per problem."""
|
||||
lines: list[str] = []
|
||||
for entry in error.errors():
|
||||
location = ".".join(
|
||||
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
|
||||
).replace(".[", "[")
|
||||
message = entry["msg"]
|
||||
for prefix in ("Value error, ", "Assertion failed, "):
|
||||
message = message.removeprefix(prefix)
|
||||
if entry["type"] == "extra_forbidden":
|
||||
message = "unknown option (check the spelling against config.yml.example)"
|
||||
lines.append(f" {location or '<root>'}: {message}")
|
||||
plural = "s" if len(lines) != 1 else ""
|
||||
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def load_config(path: Path, *, check_paths: bool = True) -> Config:
|
||||
"""Load and validate a config file.
|
||||
|
||||
Raises:
|
||||
ConfigError: with a message that can be printed straight to the terminal.
|
||||
"""
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
raise ConfigError(
|
||||
f"{path} is a directory. Pass the config file itself, e.g. {path / 'config.yml'}"
|
||||
)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"Cannot read config file {path}: {exc.strerror}") from exc
|
||||
|
||||
try:
|
||||
data = YAML(typ="safe").load(text)
|
||||
except YAMLError as exc:
|
||||
raise ConfigError(f"{path} is not valid YAML:\n {exc}") from exc
|
||||
|
||||
if data is None:
|
||||
raise ConfigError(f"{path} is empty")
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError(
|
||||
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
context = {"config_dir": path.parent, "check_paths": check_paths}
|
||||
try:
|
||||
config = Config.model_validate(data, context=context)
|
||||
except ValidationError as exc:
|
||||
raise ConfigError(f"{path}\n{format_validation_error(exc)}") from exc
|
||||
|
||||
if check_paths:
|
||||
for name in config.figures:
|
||||
folder = config.folder_for(name)
|
||||
if not folder.is_dir():
|
||||
_log.warning("Figure %r has no media folder at %s", name, folder)
|
||||
return config
|
||||
Reference in New Issue
Block a user