704 lines
26 KiB
Python
704 lines
26 KiB
Python
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",
|
|
"text",
|
|
"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 = 10
|
|
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,
|
|
}
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
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
|