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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user