Backend performance and code quality improvements

Performance (hot path):
- Fix double brightness: removed duplicate scaling from 9 device clients
  (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid,
  espnow) — processor loop is now the single source of brightness
- Bounded send_timestamps deque with maxlen, removed 3 cleanup loops
- Running FPS sum O(1) instead of sum()/len() O(n) per frame
- datetime.now(timezone.utc) → time.monotonic() with lazy conversion
- Device info refresh interval 30 → 300 iterations
- Composite: gate layer_snapshots copy on preview client flag
- Composite: versioned sub_streams snapshot (copy only on change)
- Composite: pre-resolved blend methods (dict lookup vs getattr)
- ApiInput: np.copyto in-place instead of astype allocation

Code quality:
- BaseJsonStore: RLock on get/delete/get_all/count (was created but unused)
- EntityNotFoundError → proper 404 responses across 15 route files
- Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models
- Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores
- Log 4 silenced exceptions (automation engine, metrics, system)
- ValueStream.get_value() now @abstractmethod
- Config.from_yaml: add encoding="utf-8"
- OutputTargetStore: remove 25-line _load override, use _legacy_json_keys
- BaseJsonStore: add _legacy_json_keys for migration support
- Remove unnecessary except Exception→500 from postprocessing list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:06:29 +03:00
parent 1f047d6561
commit cdba98813b
37 changed files with 296 additions and 137 deletions

View File

@@ -2,7 +2,7 @@
import uuid
from datetime import datetime, timezone
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template import AudioCaptureTemplate
@@ -73,7 +73,7 @@ class AudioTemplateStore(BaseJsonStore[AudioCaptureTemplate]):
self,
name: str,
engine_type: str,
engine_config: Dict[str, any],
engine_config: Dict[str, Any],
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> AudioCaptureTemplate:
@@ -102,7 +102,7 @@ class AudioTemplateStore(BaseJsonStore[AudioCaptureTemplate]):
template_id: str,
name: Optional[str] = None,
engine_type: Optional[str] = None,
engine_config: Optional[Dict[str, any]] = None,
engine_config: Optional[Dict[str, Any]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> AudioCaptureTemplate:

View File

@@ -3,7 +3,7 @@
import json
import threading
from pathlib import Path
from typing import Callable, Dict, Generic, List, TypeVar
from typing import Callable, ClassVar, Dict, Generic, List, TypeVar
from wled_controller.utils import atomic_write_json, get_logger
@@ -11,6 +11,11 @@ T = TypeVar("T")
logger = get_logger(__name__)
class EntityNotFoundError(ValueError):
"""Raised when an entity is not found in the store."""
pass
class BaseJsonStore(Generic[T]):
"""JSON-file-backed entity store with common CRUD helpers.
@@ -23,17 +28,19 @@ class BaseJsonStore(Generic[T]):
- ``_json_key``: root key in JSON file (e.g. ``"sync_clocks"``)
- ``_entity_name``: human label for errors (e.g. ``"Sync clock"``)
- ``_version``: schema version string (default ``"1.0.0"``)
- ``_legacy_json_keys``: fallback root keys for migration (default ``[]``)
"""
_json_key: str
_entity_name: str
_version: str = "1.0.0"
_legacy_json_keys: ClassVar[List[str]] = []
def __init__(self, file_path: str, deserializer: Callable[[dict], T]):
self.file_path = Path(file_path)
self._items: Dict[str, T] = {}
self._deserializer = deserializer
self._lock = threading.Lock()
self._lock = threading.RLock()
self._load()
# ── I/O ────────────────────────────────────────────────────────
@@ -47,7 +54,15 @@ class BaseJsonStore(Generic[T]):
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Try primary key, then legacy keys for migration
items_data = data.get(self._json_key, {})
if not items_data:
for legacy_key in self._legacy_json_keys:
items_data = data.get(legacy_key, {})
if items_data:
logger.info(f"Migrating {self._entity_name} from legacy key '{legacy_key}'")
break
loaded = 0
for item_id, item_dict in items_data.items():
try:
@@ -76,6 +91,7 @@ class BaseJsonStore(Generic[T]):
Note: This is synchronous blocking I/O. When called from async route
handlers, it briefly blocks the event loop (typically < 5ms for small
stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
Callers must hold ``self._lock``.
"""
try:
data = {
@@ -93,30 +109,33 @@ class BaseJsonStore(Generic[T]):
# ── Common CRUD ────────────────────────────────────────────────
def get_all(self) -> List[T]:
return list(self._items.values())
with self._lock:
return list(self._items.values())
def get(self, item_id: str) -> T:
if item_id not in self._items:
raise ValueError(f"{self._entity_name} not found: {item_id}")
return self._items[item_id]
with self._lock:
if item_id not in self._items:
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
return self._items[item_id]
def delete(self, item_id: str) -> None:
if item_id not in self._items:
raise ValueError(f"{self._entity_name} not found: {item_id}")
del self._items[item_id]
self._save()
with self._lock:
if item_id not in self._items:
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
del self._items[item_id]
self._save()
logger.info(f"Deleted {self._entity_name}: {item_id}")
def count(self) -> int:
return len(self._items)
with self._lock:
return len(self._items)
# ── Helpers ────────────────────────────────────────────────────
def _check_name_unique(self, name: str, exclude_id: str = None) -> None:
"""Raise ValueError if *name* is empty or already taken.
Callers should hold ``self._lock`` when calling this + mutating
``_items`` to prevent race conditions between concurrent requests.
Must be called while holding ``self._lock``.
"""
if not name or not name.strip():
raise ValueError("Name is required")

View File

@@ -24,35 +24,11 @@ class OutputTargetStore(BaseJsonStore[OutputTarget]):
_json_key = "output_targets"
_entity_name = "Output target"
_legacy_json_keys = ["picture_targets"]
def __init__(self, file_path: str):
super().__init__(file_path, OutputTarget.from_dict)
def _load(self) -> None:
"""Override to support legacy 'picture_targets' JSON key."""
import json as _json
from pathlib import Path
if not self.file_path.exists():
logger.info(f"{self._entity_name} store file not found — starting empty")
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = _json.load(f)
targets_data = data.get("output_targets") or data.get("picture_targets", {})
loaded = 0
for target_id, target_dict in targets_data.items():
try:
self._items[target_id] = self._deserializer(target_dict)
loaded += 1
except Exception as e:
logger.error(f"Failed to load {self._entity_name} {target_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} {self._json_key} from storage")
except Exception as e:
logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}")
raise
logger.info(f"{self._entity_name} store initialized with {len(self._items)} items")
# Backward-compatible aliases
get_all_targets = BaseJsonStore.get_all
get_target = BaseJsonStore.get

View File

@@ -2,7 +2,7 @@
import uuid
from datetime import datetime, timezone
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from wled_controller.core.capture_engines.factory import EngineRegistry
from wled_controller.storage.base_store import BaseJsonStore
@@ -65,7 +65,7 @@ class TemplateStore(BaseJsonStore[CaptureTemplate]):
self,
name: str,
engine_type: str,
engine_config: Dict[str, any],
engine_config: Dict[str, Any],
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> CaptureTemplate:
@@ -95,7 +95,7 @@ class TemplateStore(BaseJsonStore[CaptureTemplate]):
template_id: str,
name: Optional[str] = None,
engine_type: Optional[str] = None,
engine_config: Optional[Dict[str, any]] = None,
engine_config: Optional[Dict[str, Any]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> CaptureTemplate: