Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

View 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