初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -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