初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
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
|
||||
Reference in New Issue
Block a user