from __future__ import annotations import json import logging import math import os import re import threading from datetime import datetime from pathlib import Path from typing import Any from uuid import UUID from app.persistence import atomic_write_bytes logger = logging.getLogger(__name__) DEFAULT_COLOR_PALETTE = [ "#000000", "#FFFFFF", "#FF0000", "#FF8000", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", "#8000FF", "#FF00FF", "#808080", "#C0C0C0", ] COLOR_PALETTE_LIMIT = 24 DEFAULT_PREVIEW_REFRESH_INTERVAL_MS = 1000 MIN_PREVIEW_REFRESH_INTERVAL_MS = 1 MAX_PREVIEW_REFRESH_INTERVAL_MS = 60000 DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT = 2 MIN_ANIMATION_PREVIEW_MAX_CONCURRENT = 1 MAX_ANIMATION_PREVIEW_MAX_CONCURRENT = 50 DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ = 100 DEFAULT_DISPLAY = { "type": "animation", "id": "00000000-0000-4000-8000-000000000102", } DEFAULT_WORKSPACE_ORDER = [ "device", "settings", "canvas", "media-import", "animations", "templates", ] MAX_WORKSPACE_ORDER_ITEMS = 128 MATRIX_REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100}) HEX_COLOR_RE = re.compile(r"^#([0-9A-Fa-f]{6})$") WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") DEFAULT_CONFIG: dict[str, Any] = { "orientation": 0, "brightness": 40, "default_font": "default", "default_text_size": 12, "last_mode": "clear", "color_palette": DEFAULT_COLOR_PALETTE, "voltage_calibration_factor": 1.0, "voltage_calibrated_at": None, "voltage_calibration_reference": None, "voltage_calibration_uncalibrated": None, "preview_refresh_interval_ms": DEFAULT_PREVIEW_REFRESH_INTERVAL_MS, "low_voltage_protection_enabled": False, "matrix_refresh_rate_limit_hz": DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ, "custom_test_color": "#40A0FF", "default_display": DEFAULT_DISPLAY, "workspace_order": DEFAULT_WORKSPACE_ORDER, "performance_mode_enabled": False, "animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT, } CONFIG_SCHEMA_VERSION = 11 CONFIG_V1_FIELDS = frozenset({ "orientation", "brightness", "default_font", "default_text_size", "last_mode", "color_palette", "voltage_calibration_factor", "voltage_calibrated_at", "voltage_calibration_reference", "voltage_calibration_uncalibrated", }) CONFIG_V2_FIELDS = CONFIG_V1_FIELDS | {"preview_refresh_interval_ms"} CONFIG_V3_FIELDS = CONFIG_V2_FIELDS | {"low_voltage_protection_enabled"} CONFIG_V4_FIELDS = CONFIG_V3_FIELDS | {"matrix_refresh_rate_limit_hz"} CONFIG_V5_FIELDS = CONFIG_V4_FIELDS | {"custom_test_color"} CONFIG_V6_FIELDS = CONFIG_V5_FIELDS | {"default_display"} CONFIG_V7_FIELDS = CONFIG_V6_FIELDS | {"workspace_order"} CONFIG_V8_FIELDS = CONFIG_V7_FIELDS CONFIG_V9_FIELDS = CONFIG_V8_FIELDS | {"performance_mode_enabled"} CONFIG_V10_FIELDS = CONFIG_V9_FIELDS | {"animation_preview_max_concurrent"} CONFIG_FIELDS = CONFIG_V10_FIELDS ORIENTATIONS = {0, 90, 180, 270} class ConfigError(ValueError): """Raised when a persisted or requested configuration is invalid.""" class ConfigStore: def __init__(self, project_root: Path | None = None) -> None: self.project_root = ( Path(project_root) if project_root is not None else Path(__file__).resolve().parents[2] ) self.data_dir = resolve_data_dir(project_root) self.runtime_dir = resolve_runtime_dir(project_root, data_dir=self.data_dir) self.path = self.data_dir / "config.json" self._lock = threading.RLock() self.data_dir.mkdir(parents=True, exist_ok=True) self.runtime_dir.mkdir(parents=True, exist_ok=True) self._config = self.load() @property def config(self) -> dict[str, Any]: with self._lock: return { **self._config, "color_palette": list(self._config["color_palette"]), "default_display": dict(self._config["default_display"]), "workspace_order": list(self._config["workspace_order"]), } def load(self) -> dict[str, Any]: if not self.path.exists(): config = default_config() self._write(config) logger.info("Created default config at %s", self.path) return config try: raw = json.loads(self.path.read_bytes().decode("utf-8")) config, migrated = load_persisted_config(raw) if migrated: self._write(config) logger.info("Migrated config at %s", self.path) logger.info("Loaded config from %s", self.path) return config except ConfigError: raise except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ConfigError(f"persisted config is unreadable: {self.path}") from exc except Exception as exc: raise ConfigError(f"persisted config is invalid: {self.path}") from exc def update(self, values: dict[str, Any]) -> dict[str, Any]: with self._lock: unknown = set(values) - CONFIG_FIELDS if unknown: raise ConfigError(f"unknown config fields: {', '.join(sorted(unknown))}") merged = {**self._config, **values} config = validate_config(merged) if config == self._config: return { **config, "color_palette": list(config["color_palette"]), "default_display": dict(config["default_display"]), "workspace_order": list(config["workspace_order"]), } self._write(config) self._config = config logger.info("Saved config to %s", self.path) return { **config, "color_palette": list(config["color_palette"]), "default_display": dict(config["default_display"]), "workspace_order": list(config["workspace_order"]), } def add_palette_color(self, color: str) -> dict[str, Any]: normalized = normalize_hex_color(color) with self._lock: palette = list(self._config["color_palette"]) if normalized in palette: return self.config if len(palette) >= COLOR_PALETTE_LIMIT: raise ConfigError(f"color palette is limited to {COLOR_PALETTE_LIMIT} colors") palette.append(normalized) return self.update({"color_palette": palette}) def remove_palette_color(self, color: str) -> dict[str, Any]: normalized = normalize_hex_color(color) with self._lock: palette = [item for item in self._config["color_palette"] if item != normalized] if len(palette) == len(self._config["color_palette"]): return self.config return self.update({"color_palette": palette}) def set_last_mode(self, mode: str) -> None: self.update({"last_mode": mode}) def _write(self, config: dict[str, Any]) -> None: persisted = {"schema_version": CONFIG_SCHEMA_VERSION, **config} atomic_write_bytes( self.path, (json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8"), ) def _configured_path(name: str) -> Path | None: value = os.environ.get(name) return Path(value) if value else None def resolve_data_dir(project_root: Path | None = None) -> Path: if project_root is not None: return Path(project_root) / "data" return ( _configured_path("MATRIX_DATA_DIR") or _configured_path("STATE_DIRECTORY") or Path(__file__).resolve().parents[2] / "data" ) def resolve_runtime_dir( project_root: Path | None = None, *, data_dir: Path | None = None, ) -> Path: if project_root is not None: return Path(project_root) / "data" / "runtime" return ( _configured_path("MATRIX_RUNTIME_DIR") or _configured_path("RUNTIME_DIRECTORY") or Path(__file__).resolve().parents[2] / "data" / "runtime" ) def default_config() -> dict[str, Any]: return { **DEFAULT_CONFIG, "color_palette": list(DEFAULT_COLOR_PALETTE), "default_display": dict(DEFAULT_DISPLAY), "workspace_order": list(DEFAULT_WORKSPACE_ORDER), } def _check_known_fields( document: dict[str, Any], allowed_fields: frozenset[str] = CONFIG_FIELDS, ) -> None: unknown = set(document) - allowed_fields if unknown: raise ConfigError(f"unknown persisted config fields: {', '.join(sorted(unknown))}") def _validate_current_config(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document) missing = CONFIG_FIELDS - set(document) if missing: raise ConfigError(f"persisted config is missing fields: {', '.join(sorted(missing))}") _validate_current_field_types(document) try: checked = validate_config(document) except (TypeError, ValueError) as exc: raise ConfigError("persisted config values are invalid") from exc if checked != document: raise ConfigError( f"persisted config does not match schema version {CONFIG_SCHEMA_VERSION}" ) return checked def _validate_current_field_types(document: dict[str, Any]) -> None: integer_fields = ( "orientation", "brightness", "default_text_size", "preview_refresh_interval_ms", "matrix_refresh_rate_limit_hz", "animation_preview_max_concurrent", ) for field in integer_fields: if type(document[field]) is not int: raise ConfigError(f"persisted config field {field} must be an integer") for field in ("default_font", "last_mode"): if not isinstance(document[field], str): raise ConfigError(f"persisted config field {field} must be a string") if type(document["low_voltage_protection_enabled"]) is not bool: raise ConfigError( "persisted config field low_voltage_protection_enabled must be a boolean" ) if type(document["performance_mode_enabled"]) is not bool: raise ConfigError( "persisted config field performance_mode_enabled must be a boolean" ) if not isinstance(document["custom_test_color"], str): raise ConfigError("persisted config field custom_test_color must be a string") default_display = document["default_display"] if not isinstance(default_display, dict): raise ConfigError("persisted config field default_display must be an object") workspace_order = document["workspace_order"] if not isinstance(workspace_order, list) or any( not isinstance(value, str) for value in workspace_order ): raise ConfigError("persisted config field workspace_order must be an array of strings") palette = document["color_palette"] if not isinstance(palette, list) or any(not isinstance(value, str) for value in palette): raise ConfigError("persisted config field color_palette must be an array of strings") factor = document["voltage_calibration_factor"] if isinstance(factor, bool) or not isinstance(factor, (int, float)): raise ConfigError("persisted config field voltage_calibration_factor must be a number") calibrated_at = document["voltage_calibrated_at"] if calibrated_at is not None and not isinstance(calibrated_at, str): raise ConfigError("persisted config field voltage_calibrated_at must be a string or null") for field in ("voltage_calibration_reference", "voltage_calibration_uncalibrated"): value = document[field] if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))): raise ConfigError(f"persisted config field {field} must be a number or null") def _migrate_config_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V1_FIELDS) try: migrated = validate_config({**default_config(), **document}) except (TypeError, ValueError) as exc: raise ConfigError("legacy config cannot be migrated safely") from exc calibration_fields = { "voltage_calibration_factor", "voltage_calibrated_at", "voltage_calibration_reference", "voltage_calibration_uncalibrated", } present_calibration_fields = calibration_fields & set(document) if present_calibration_fields: if present_calibration_fields != calibration_fields: raise ConfigError("legacy voltage calibration is incomplete") if any(migrated[field] != document[field] for field in calibration_fields): raise ConfigError("legacy voltage calibration cannot be migrated safely") migrated.pop("preview_refresh_interval_ms") migrated.pop("low_voltage_protection_enabled") migrated.pop("matrix_refresh_rate_limit_hz") migrated.pop("custom_test_color") migrated.pop("default_display") migrated.pop("workspace_order") migrated.pop("performance_mode_enabled") migrated.pop("animation_preview_max_concurrent") return migrated def _migrate_config_v1_to_v2(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V1_FIELDS) missing = CONFIG_V1_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "preview_refresh_interval_ms": DEFAULT_PREVIEW_REFRESH_INTERVAL_MS, } def _migrate_config_v2_to_v3(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V2_FIELDS) missing = CONFIG_V2_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "low_voltage_protection_enabled": False, } def _migrate_config_v3_to_v4(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V3_FIELDS) missing = CONFIG_V3_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "matrix_refresh_rate_limit_hz": DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ, } def _migrate_config_v4_to_v5(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V4_FIELDS) missing = CONFIG_V4_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "custom_test_color": DEFAULT_CONFIG["custom_test_color"], } def _migrate_config_v5_to_v6(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V5_FIELDS) missing = CONFIG_V5_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "default_display": dict(DEFAULT_DISPLAY), } def _migrate_config_v6_to_v7(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V6_FIELDS) missing = CONFIG_V6_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "workspace_order": [ "device", "settings", "canvas", "text", "animations", "templates", ], } def _migrate_config_v7_to_v8(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V7_FIELDS) missing = CONFIG_V7_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) order = normalize_workspace_order(document["workspace_order"]) if "media-import" not in order: insert_at = order.index("animations") if "animations" in order else len(order) order.insert(insert_at, "media-import") return {**document, "workspace_order": order} def _migrate_config_v8_to_v9(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V8_FIELDS) missing = CONFIG_V8_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return {**document, "performance_mode_enabled": False} def _migrate_config_v9_to_v10(document: dict[str, Any]) -> dict[str, Any]: _check_known_fields(document, CONFIG_V9_FIELDS) missing = CONFIG_V9_FIELDS - set(document) if missing: raise ConfigError( f"persisted config is missing fields: {', '.join(sorted(missing))}" ) return { **document, "animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT, } def _migrate_config_v10_to_v11(document: dict[str, Any]) -> dict[str, Any]: checked = validate_config(document) checked["workspace_order"] = [item for item in checked["workspace_order"] if item != "text"] or list(DEFAULT_WORKSPACE_ORDER) return checked CONFIG_MIGRATIONS = { 0: _migrate_config_v0_to_v1, 1: _migrate_config_v1_to_v2, 2: _migrate_config_v2_to_v3, 3: _migrate_config_v3_to_v4, 4: _migrate_config_v4_to_v5, 5: _migrate_config_v5_to_v6, 6: _migrate_config_v6_to_v7, 7: _migrate_config_v7_to_v8, 8: _migrate_config_v8_to_v9, 9: _migrate_config_v9_to_v10, 10: _migrate_config_v10_to_v11, } def load_persisted_config(raw: Any) -> tuple[dict[str, Any], bool]: if not isinstance(raw, dict): raise ConfigError("persisted config must be a JSON object") document = dict(raw) if "schema_version" in document: version = document.pop("schema_version") if type(version) is not int: raise ConfigError("config schema_version must be an integer") else: version = 0 if version > CONFIG_SCHEMA_VERSION: raise ConfigError( f"config schema version {version} is newer than supported version " f"{CONFIG_SCHEMA_VERSION}" ) if version < 0: raise ConfigError(f"unsupported config schema version {version}") if version == CONFIG_SCHEMA_VERSION: return _validate_current_config(document), False while version < CONFIG_SCHEMA_VERSION: migrator = CONFIG_MIGRATIONS.get(version) if migrator is None: raise ConfigError(f"no config migration is registered for version {version}") document = migrator(document) version += 1 return _validate_current_config(document), True def validate_config(config: dict[str, Any]) -> dict[str, Any]: orientation = int(config.get("orientation", DEFAULT_CONFIG["orientation"])) if orientation not in ORIENTATIONS: raise ConfigError("orientation must be one of 0, 90, 180, 270") brightness = int(config.get("brightness", DEFAULT_CONFIG["brightness"])) if brightness < 1 or brightness > 100: raise ConfigError("brightness must be in 1..100") default_font = str(config.get("default_font", DEFAULT_CONFIG["default_font"])).strip() if not default_font: default_font = DEFAULT_CONFIG["default_font"] default_text_size = int(config.get("default_text_size", DEFAULT_CONFIG["default_text_size"])) if default_text_size < 1 or default_text_size > 64: raise ConfigError("default_text_size must be in 1..64") preview_refresh_interval = config.get( "preview_refresh_interval_ms", DEFAULT_PREVIEW_REFRESH_INTERVAL_MS, ) if type(preview_refresh_interval) is not int: raise ConfigError("preview_refresh_interval_ms must be an integer") if not ( MIN_PREVIEW_REFRESH_INTERVAL_MS <= preview_refresh_interval <= MAX_PREVIEW_REFRESH_INTERVAL_MS ): raise ConfigError( "preview_refresh_interval_ms must be in " f"{MIN_PREVIEW_REFRESH_INTERVAL_MS}..{MAX_PREVIEW_REFRESH_INTERVAL_MS}" ) low_voltage_protection_enabled = config.get( "low_voltage_protection_enabled", DEFAULT_CONFIG["low_voltage_protection_enabled"], ) if type(low_voltage_protection_enabled) is not bool: raise ConfigError("low_voltage_protection_enabled must be a boolean") performance_mode_enabled = config.get( "performance_mode_enabled", DEFAULT_CONFIG["performance_mode_enabled"], ) if type(performance_mode_enabled) is not bool: raise ConfigError("performance_mode_enabled must be a boolean") animation_preview_max_concurrent = config.get( "animation_preview_max_concurrent", DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT, ) if type(animation_preview_max_concurrent) is not int: raise ConfigError("animation_preview_max_concurrent must be an integer") if not ( MIN_ANIMATION_PREVIEW_MAX_CONCURRENT <= animation_preview_max_concurrent <= MAX_ANIMATION_PREVIEW_MAX_CONCURRENT ): raise ConfigError( "animation_preview_max_concurrent must be in " f"{MIN_ANIMATION_PREVIEW_MAX_CONCURRENT}.." f"{MAX_ANIMATION_PREVIEW_MAX_CONCURRENT}" ) matrix_refresh_rate_limit = config.get( "matrix_refresh_rate_limit_hz", DEFAULT_CONFIG["matrix_refresh_rate_limit_hz"], ) if type(matrix_refresh_rate_limit) is not int: raise ConfigError("matrix_refresh_rate_limit_hz must be an integer") if matrix_refresh_rate_limit not in MATRIX_REFRESH_RATE_LIMITS: allowed = ", ".join(str(value) for value in sorted(MATRIX_REFRESH_RATE_LIMITS)) raise ConfigError( f"matrix_refresh_rate_limit_hz must be one of {allowed}" ) custom_test_color = normalize_hex_color( config.get("custom_test_color", DEFAULT_CONFIG["custom_test_color"]) ) default_display = normalize_default_display( config.get("default_display", DEFAULT_DISPLAY) ) workspace_order = normalize_workspace_order( config.get("workspace_order", DEFAULT_WORKSPACE_ORDER) ) last_mode = str(config.get("last_mode", DEFAULT_CONFIG["last_mode"])) raw_palette = config.get("color_palette", DEFAULT_COLOR_PALETTE) if not isinstance(raw_palette, list): raw_palette = DEFAULT_COLOR_PALETTE color_palette: list[str] = [] for value in raw_palette: try: normalized = normalize_hex_color(value) except ConfigError: continue if normalized not in color_palette: color_palette.append(normalized) if len(color_palette) == COLOR_PALETTE_LIMIT: break voltage_factor = config.get("voltage_calibration_factor", 1.0) voltage_calibrated_at = config.get("voltage_calibrated_at") voltage_reference = config.get("voltage_calibration_reference") voltage_uncalibrated = config.get("voltage_calibration_uncalibrated") try: voltage_factor = float(voltage_factor) factor_valid = math.isfinite(voltage_factor) and 0.8 <= voltage_factor <= 1.2 except (TypeError, ValueError): factor_valid = False calibration_values = (voltage_calibrated_at, voltage_reference, voltage_uncalibrated) if all(value is None for value in calibration_values): calibration_valid = factor_valid and voltage_factor == 1.0 elif all(value is not None for value in calibration_values) and factor_valid: try: datetime.fromisoformat(str(voltage_calibrated_at).replace("Z", "+00:00")) voltage_reference = float(voltage_reference) voltage_uncalibrated = float(voltage_uncalibrated) calibration_valid = ( math.isfinite(voltage_reference) and 4.5 <= voltage_reference <= 5.5 and math.isfinite(voltage_uncalibrated) and 0.0 < voltage_uncalibrated <= 12.0 ) except (TypeError, ValueError): calibration_valid = False else: calibration_valid = False if not calibration_valid: voltage_factor = 1.0 voltage_calibrated_at = None voltage_reference = None voltage_uncalibrated = None return { "orientation": orientation, "brightness": brightness, "default_font": default_font, "default_text_size": default_text_size, "last_mode": last_mode, "color_palette": color_palette, "voltage_calibration_factor": voltage_factor, "voltage_calibrated_at": voltage_calibrated_at, "voltage_calibration_reference": voltage_reference, "voltage_calibration_uncalibrated": voltage_uncalibrated, "preview_refresh_interval_ms": preview_refresh_interval, "low_voltage_protection_enabled": low_voltage_protection_enabled, "matrix_refresh_rate_limit_hz": matrix_refresh_rate_limit, "custom_test_color": custom_test_color, "default_display": default_display, "workspace_order": workspace_order, "performance_mode_enabled": performance_mode_enabled, "animation_preview_max_concurrent": animation_preview_max_concurrent, } def normalize_hex_color(value: Any) -> str: if not isinstance(value, str): raise ConfigError("palette color must be #RRGGBB") match = HEX_COLOR_RE.fullmatch(value.strip()) if not match: raise ConfigError("palette color must be #RRGGBB") return f"#{match.group(1).upper()}" def normalize_default_display(value: Any) -> dict[str, str]: if not isinstance(value, dict) or set(value) != {"type", "id"}: raise ConfigError("default_display must contain only type and id") display_type = value.get("type") if display_type not in {"template", "animation"}: raise ConfigError("default_display type must be template or animation") raw_id = value.get("id") if not isinstance(raw_id, str): raise ConfigError("default_display id must be a UUID string") try: display_id = str(UUID(raw_id)) except (ValueError, AttributeError) as exc: raise ConfigError("default_display id must be a UUID string") from exc return {"type": display_type, "id": display_id} def normalize_workspace_order(value: Any) -> list[str]: if not isinstance(value, list): raise ConfigError("workspace_order must be an array") if not 1 <= len(value) <= MAX_WORKSPACE_ORDER_ITEMS: raise ConfigError( f"workspace_order must contain 1..{MAX_WORKSPACE_ORDER_ITEMS} items" ) normalized: list[str] = [] seen: set[str] = set() for workspace_id in value: if not isinstance(workspace_id, str) or not WORKSPACE_ID_RE.fullmatch(workspace_id): raise ConfigError("workspace_order contains an invalid workspace id") if workspace_id in seen: raise ConfigError("workspace_order items must be unique") seen.add(workspace_id) normalized.append(workspace_id) return normalized