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

@@ -24,6 +24,7 @@ from wled_controller.storage.audio_source import AudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -42,7 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
description=source.description,
tags=getattr(source, 'tags', []),
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -85,6 +86,9 @@ async def create_audio_source(
)
fire_entity_event("audio_source", "created", source.id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -125,6 +129,9 @@ async def update_audio_source(
)
fire_entity_event("audio_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -148,6 +155,9 @@ async def delete_audio_source(
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -19,6 +19,7 @@ from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -38,7 +39,7 @@ async def list_audio_templates(
responses = [
AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
@@ -66,10 +67,13 @@ async def create_audio_template(
fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type,
engine_config=template.engine_config, tags=getattr(template, 'tags', []),
engine_config=template.engine_config, tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at, description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -90,7 +94,7 @@ async def get_audio_template(
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
@@ -113,10 +117,13 @@ async def update_audio_template(
fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
engine_config=t.engine_config, tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -137,6 +144,9 @@ async def delete_audio_template(
fire_entity_event("audio_template", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -33,6 +33,7 @@ from wled_controller.storage.automation import (
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -113,7 +114,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
is_active=state["is_active"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=getattr(automation, 'tags', []),
tags=automation.tags,
created_at=automation.created_at,
updated_at=automation.updated_at,
)
@@ -163,6 +164,9 @@ async def create_automation(
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -249,6 +253,9 @@ async def update_automation(
if data.conditions is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -28,6 +28,7 @@ from wled_controller.storage.color_strip_processing_template_store import ColorS
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage import DeviceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -43,7 +44,7 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=getattr(t, 'tags', []),
tags=t.tags,
)
@@ -79,6 +80,9 @@ async def create_cspt(
)
fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -119,6 +123,9 @@ async def update_cspt(
)
fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -148,6 +155,9 @@ async def delete_cspt(
fire_entity_event("cspt", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -42,6 +42,7 @@ from wled_controller.storage.picture_source import ProcessedPictureSource, Scree
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -154,6 +155,10 @@ async def create_color_strip_source(
fire_entity_event("color_strip_source", "created", source.id)
return _css_to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -30,6 +30,7 @@ from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -51,7 +52,7 @@ def _device_to_response(device) -> DeviceResponse:
rgbw=device.rgbw,
zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)),
tags=getattr(device, 'tags', []),
tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),

View File

@@ -59,6 +59,7 @@ from wled_controller.storage.key_colors_output_target import (
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -106,7 +107,7 @@ def _target_to_response(target) -> OutputTargetResponse:
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=getattr(target, 'tags', []),
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -119,7 +120,7 @@ def _target_to_response(target) -> OutputTargetResponse:
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
tags=getattr(target, 'tags', []),
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -130,7 +131,7 @@ def _target_to_response(target) -> OutputTargetResponse:
name=target.name,
target_type=target.target_type,
description=target.description,
tags=getattr(target, 'tags', []),
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -188,6 +189,9 @@ async def create_target(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -598,6 +602,9 @@ async def test_kc_target(
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -735,6 +742,9 @@ async def test_kc_target(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:

View File

@@ -19,6 +19,7 @@ from wled_controller.storage.key_colors_output_target import KeyColorRectangle
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -37,7 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=getattr(t, 'tags', []),
tags=t.tags,
)
@@ -76,6 +77,9 @@ async def create_pattern_template(
)
fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -121,6 +125,9 @@ async def update_pattern_template(
)
fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -149,6 +156,9 @@ async def delete_pattern_template(
fire_entity_event("pattern_template", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -41,6 +41,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -62,7 +63,7 @@ def _stream_to_response(s) -> PictureSourceResponse:
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=getattr(s, 'tags', []),
tags=s.tags,
# Video fields
url=getattr(s, "url", None),
loop=getattr(s, "loop", None),
@@ -228,6 +229,9 @@ async def create_picture_source(
return _stream_to_response(stream)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -280,6 +284,9 @@ async def update_picture_source(
)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -309,6 +316,9 @@ async def delete_picture_source(
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -383,6 +393,9 @@ async def test_picture_source(
# Resolve stream chain
try:
chain = store.resolve_stream_chain(stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -541,6 +554,9 @@ async def test_picture_source(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:

View File

@@ -36,6 +36,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -51,7 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
tags=getattr(t, 'tags', []),
tags=t.tags,
)
@@ -61,13 +62,9 @@ async def list_pp_templates(
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
):
"""List all postprocessing templates."""
try:
templates = store.get_all_templates()
responses = [_pp_template_to_response(t) for t in templates]
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list postprocessing templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
templates = store.get_all_templates()
responses = [_pp_template_to_response(t) for t in templates]
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
@@ -87,6 +84,9 @@ async def create_pp_template(
)
fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -127,6 +127,9 @@ async def update_pp_template(
)
fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -156,6 +159,9 @@ async def delete_pp_template(
fire_entity_event("pp_template", "deleted", template_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -184,6 +190,9 @@ async def test_pp_template(
# Resolve source stream chain to get the raw stream
try:
chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -327,6 +336,9 @@ async def test_pp_template(
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:

View File

@@ -28,6 +28,7 @@ from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -46,7 +47,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
"fps": t.fps,
} for t in preset.targets],
order=preset.order,
tags=getattr(preset, 'tags', []),
tags=preset.tags,
created_at=preset.created_at,
updated_at=preset.updated_at,
)
@@ -85,6 +86,9 @@ async def create_scene_preset(
try:
preset = store.create_preset(preset)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -20,6 +20,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -34,7 +35,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
name=clock.name,
speed=rt.speed if rt else clock.speed,
description=clock.description,
tags=getattr(clock, 'tags', []),
tags=clock.tags,
is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at,
@@ -73,6 +74,9 @@ async def create_sync_clock(
)
fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -114,6 +118,9 @@ async def update_sync_clock(
manager.update_speed(clock_id, clock.speed)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -137,6 +144,9 @@ async def delete_sync_clock(
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -68,6 +68,7 @@ psutil.cpu_percent(interval=None)
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.storage.base_store import EntityNotFoundError
def _get_cpu_name() -> str | None:
@@ -96,8 +97,8 @@ def _get_cpu_name() -> str | None:
.decode()
.strip()
)
except Exception:
pass
except Exception as e:
logger.warning("CPU name detection failed: %s", e)
return platform.processor() or None
@@ -157,7 +158,7 @@ async def list_all_tags(_: AuthRequired):
items = fn() if fn else None
if items:
for item in items:
all_tags.update(getattr(item, 'tags', []))
all_tags.update(item.tags)
return {"tags": sorted(all_tags)}
@@ -205,6 +206,10 @@ async def get_displays(
count=len(displays),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -265,8 +270,8 @@ def get_system_performance(_: AuthRequired):
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp),
)
except Exception:
pass
except Exception as e:
logger.debug("NVML query failed: %s", e)
return PerformanceResponse(
cpu_name=_cpu_name,

View File

@@ -41,6 +41,7 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -64,7 +65,7 @@ async def list_templates(
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=getattr(t, 'tags', []),
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
@@ -104,12 +105,16 @@ async def create_template(
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=getattr(template, 'tags', []),
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -134,7 +139,7 @@ async def get_template(
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=getattr(template, 'tags', []),
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
@@ -165,12 +170,16 @@ async def update_template(
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=getattr(template, 'tags', []),
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -210,6 +219,9 @@ async def delete_template(
except HTTPException:
raise # Re-raise HTTP exceptions as-is
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -359,6 +371,10 @@ def test_template(
),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:

View File

@@ -23,6 +23,7 @@ from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -105,6 +106,9 @@ async def create_value_source(
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -158,6 +162,9 @@ async def update_value_source(
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -182,6 +189,9 @@ async def delete_value_source(
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -93,7 +93,7 @@ class Config(BaseSettings):
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, "r") as f:
with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
return cls(**config_data)

View File

@@ -398,8 +398,8 @@ class AutomationEngine:
"automation_id": automation_id,
"action": action,
})
except Exception:
pass
except Exception as e:
logger.error("Automation action failed: %s", e, exc_info=True)
# ===== Public query methods (used by API) =====

View File

@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
# Note: brightness already applied by processor loop (_cached_brightness)
np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes

View File

@@ -40,9 +40,7 @@ class AmbiLEDClient(AdalightClient):
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
# Note: brightness already applied by processor loop (_cached_brightness)
# Clamp to 0250: values >250 are command bytes in AmbiLED protocol
np.clip(arr, 0, 250, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()

View File

@@ -145,7 +145,7 @@ class ChromaClient(LEDClient):
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
device_info = CHROMA_DEVICES.get(self._chroma_device_type)
if not device_info:
return False
@@ -156,10 +156,7 @@ class ChromaClient(LEDClient):
# Chroma uses BGR packed as 0x00BBGGRR integers
colors = []
for i in range(n):
r, g, b = pixel_arr[i]
r = int(r * bri_scale)
g = int(g * bri_scale)
b = int(b * bri_scale)
r, g, b = int(pixel_arr[i][0]), int(pixel_arr[i][1]), int(pixel_arr[i][2])
colors.append(r | (g << 8) | (b << 16))
# Pad to max_leds if needed

View File

@@ -115,7 +115,8 @@ class ESPNowClient(LEDClient):
else:
pixel_bytes = bytes(c for rgb in pixels for c in rgb)
frame = _build_frame(self._peer_mac, pixel_bytes, brightness)
# Note: brightness already applied by processor loop; pass 255 to firmware
frame = _build_frame(self._peer_mac, pixel_bytes, 255)
try:
self._serial.write(frame)
except Exception as e:

View File

@@ -187,7 +187,7 @@ class GameSenseClient(LEDClient):
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
# Use average color for single-zone devices, or first N for multi-zone
if len(pixel_arr) == 0:
@@ -195,9 +195,9 @@ class GameSenseClient(LEDClient):
# Compute average color for the zone
avg = pixel_arr.mean(axis=0)
r = int(avg[0] * bri_scale)
g = int(avg[1] * bri_scale)
b = int(avg[2] * bri_scale)
r = int(avg[0])
g = int(avg[1])
b = int(avg[2])
event_data = {
"game": GAME_NAME,

View File

@@ -46,13 +46,13 @@ def _build_entertainment_frame(
header[15] = 0x00 # reserved
# Light data
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray()
for idx, (r, g, b) in enumerate(lights):
light_id = idx # 0-based light index in entertainment group
r16 = int(r * bri_scale * 257) # scale 0-255 to 0-65535
g16 = int(g * bri_scale * 257)
b16 = int(b * bri_scale * 257)
r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257)
b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16)
return bytes(header) + bytes(data)

View File

@@ -302,9 +302,7 @@ class OpenRGBLEDClient(LEDClient):
return
self._last_sent_pixels = pixel_array.copy()
# Apply brightness scaling after dedup
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
# Separate mode: resample full pixel array independently per zone
if self._zone_mode == "separate" and len(self._target_zones) > 1:

View File

@@ -162,7 +162,7 @@ class SPIClient(LEDClient):
if not self._connected:
return
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
if isinstance(pixels, np.ndarray):
pixel_arr = pixels
@@ -176,7 +176,7 @@ class SPIClient(LEDClient):
except ImportError:
return
self._strip.setBrightness(brightness)
self._strip.setBrightness(255)
for i in range(min(len(pixel_arr), self._led_count)):
r, g, b = pixel_arr[i]
self._strip.setPixelColor(i, Color(int(r), int(g), int(b)))
@@ -185,7 +185,7 @@ class SPIClient(LEDClient):
elif self._spi:
# SPI bitbang path: convert RGB to WS2812 wire format
# Each bit is encoded as 3 SPI bits: 1=110, 0=100
scaled = (pixel_arr[:self._led_count].astype(np.float32) * bri_scale).astype(np.uint8)
scaled = pixel_arr[:self._led_count]
# GRB order for WS2812
grb = scaled[:, [1, 0, 2]]
raw_bytes = grb.tobytes()

View File

@@ -100,7 +100,7 @@ class USBHIDClient(LEDClient):
else:
pixel_list = list(pixels)
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
# Build HID reports — split across multiple reports if needed
# Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...]
@@ -119,9 +119,9 @@ class USBHIDClient(LEDClient):
for i, (r, g, b) in enumerate(chunk):
base = 5 + i * 3
report[base] = int(r * bri_scale)
report[base + 1] = int(g * bri_scale)
report[base + 2] = int(b * bri_scale)
report[base] = int(r)
report[base + 1] = int(g)
report[base + 2] = int(b)
reports.append(bytes(report))
offset += len(chunk)

View File

@@ -378,9 +378,7 @@ class WLEDClient(LEDClient):
True if successful
"""
try:
if brightness < 255:
pixels = (pixels.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
self._ddp_client.send_pixels_numpy(pixels)
logger.debug(f"Successfully sent pixel colors via DDP")
@@ -419,7 +417,7 @@ class WLEDClient(LEDClient):
# Build WLED JSON state
payload = {
"on": True,
"bri": int(brightness),
"bri": 255, # brightness already applied by processor loop
"seg": [
{
"id": segment_id,
@@ -461,9 +459,7 @@ class WLEDClient(LEDClient):
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods =====

View File

@@ -92,7 +92,11 @@ class ApiInputColorStripStream(ColorStripStream):
if n > self._led_count:
self._ensure_capacity(n)
if n == self._led_count:
self._colors = colors.astype(np.uint8)
if self._colors.shape == colors.shape:
np.copyto(self._colors, colors, casting='unsafe')
else:
self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe')
elif n < self._led_count:
# Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8)

View File

@@ -48,12 +48,22 @@ class CompositeColorStripStream(ColorStripStream):
self._latest_colors: Optional[np.ndarray] = None
self._latest_layer_colors: Optional[List[np.ndarray]] = None
self._colors_lock = threading.Lock()
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream)
self._brightness_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
self._sub_streams_version: int = 0 # bumped when _sub_streams changes
self._sub_snapshot_version: int = -1 # version of cached snapshot
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
# Pre-resolved blend methods: blend_mode_str -> bound method
self._blend_methods = {
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
}
self._default_blend_method = self._blend_normal
# Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0
@@ -111,6 +121,7 @@ class CompositeColorStripStream(ColorStripStream):
def get_layer_colors(self) -> Optional[List[np.ndarray]]:
"""Return per-layer color snapshots (after resize/brightness, before blending)."""
self._need_layer_snapshots = True
with self._colors_lock:
return self._latest_layer_colors
@@ -165,6 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
# ── Sub-stream lifecycle ────────────────────────────────────
def _acquire_sub_streams(self) -> None:
self._sub_streams_version += 1
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
continue
@@ -193,6 +205,7 @@ class CompositeColorStripStream(ColorStripStream):
)
def _release_sub_streams(self) -> None:
self._sub_streams_version += 1
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
try:
self._css_manager.release(src_id, consumer_id)
@@ -356,7 +369,10 @@ class CompositeColorStripStream(ColorStripStream):
layer_snapshots: List[np.ndarray] = []
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)
if self._sub_streams_version != self._sub_snapshot_version:
self._sub_snapshot_cache = dict(self._sub_streams)
self._sub_snapshot_version = self._sub_streams_version
sub_snapshot = self._sub_snapshot_cache
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
@@ -412,7 +428,8 @@ class CompositeColorStripStream(ColorStripStream):
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
# Snapshot layer colors before blending (copy — may alias shared buf)
layer_snapshots.append(colors.copy())
if self._need_layer_snapshots:
layer_snapshots.append(colors.copy())
opacity = layer.get("opacity", 1.0)
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
@@ -425,11 +442,11 @@ class CompositeColorStripStream(ColorStripStream):
result_buf[:] = colors
else:
result_buf[:] = 0
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf)
has_result = True
else:
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf)
if has_result:

View File

@@ -91,7 +91,8 @@ class MetricsHistory:
# Per-target metrics from processor states
try:
all_states = self._manager.get_all_target_states()
except Exception:
except Exception as e:
logger.error("Failed to get target states: %s", e)
all_states = {}
now = datetime.now(timezone.utc).isoformat()

View File

@@ -43,6 +43,7 @@ class ProcessingMetrics:
errors_count: int = 0
last_error: Optional[str] = None
last_update: Optional[datetime] = None
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
start_time: Optional[datetime] = None
fps_actual: float = 0.0
fps_potential: float = 0.0

View File

@@ -22,6 +22,7 @@ from __future__ import annotations
import math
import time
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
@@ -43,12 +44,13 @@ logger = get_logger(__name__)
# Base class
# ---------------------------------------------------------------------------
class ValueStream:
class ValueStream(ABC):
"""Abstract base for runtime value streams."""
@abstractmethod
def get_value(self) -> float:
"""Return current scalar value (0.01.0)."""
return 1.0
...
def start(self) -> None:
"""Acquire resources (if any)."""

View File

@@ -382,6 +382,14 @@ class WledTargetProcessor(TargetProcessor):
else:
total_ms = None
# Lazily convert monotonic timestamp to UTC datetime for API
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
)
return {
"target_id": self._target_id,
"device_id": self._device_id,
@@ -405,7 +413,7 @@ class WledTargetProcessor(TargetProcessor):
"display_index": self._resolved_display_index,
"overlay_active": self._overlay_active,
"needs_keepalive": self._needs_keepalive,
"last_update": metrics.last_update,
"last_update": last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
"device_streaming_reachable": self._device_reachable if self._is_running else None,
"fps_effective": self._effective_fps if self._is_running else None,
@@ -419,6 +427,14 @@ class WledTargetProcessor(TargetProcessor):
if metrics.start_time and self._is_running:
uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
# Lazily convert monotonic timestamp to UTC datetime for API
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
)
return {
"target_id": self._target_id,
"device_id": self._device_id,
@@ -429,7 +445,7 @@ class WledTargetProcessor(TargetProcessor):
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
"last_error": metrics.last_error,
"last_update": metrics.last_update,
"last_update": last_update,
}
# ----- Overlay -----
@@ -578,7 +594,8 @@ class WledTargetProcessor(TargetProcessor):
keepalive_interval = self._keepalive_interval
fps_samples: collections.deque = collections.deque(maxlen=10)
send_timestamps: collections.deque = collections.deque()
_fps_sum = 0.0
send_timestamps: collections.deque = collections.deque(maxlen=target_fps + 10)
last_send_time = 0.0
_last_preview_broadcast = 0.0
prev_frame_time_stamp = time.perf_counter()
@@ -728,7 +745,7 @@ class WledTargetProcessor(TargetProcessor):
has_any_frame = False
_diag_device_info_age += 1
if _diag_device_info is None or _diag_device_info_age >= 30:
if _diag_device_info is None or _diag_device_info_age >= 300:
_diag_device_info = self._ctx.get_device_info(self._device_id)
_diag_device_info_age = 0
device_info = _diag_device_info
@@ -822,8 +839,6 @@ class WledTargetProcessor(TargetProcessor):
send_timestamps.append(now)
self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 1
while send_timestamps and send_timestamps[0] < loop_start - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(SKIP_REPOLL)
continue
@@ -849,8 +864,6 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time
@@ -888,7 +901,7 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.timing_send_ms = send_ms
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.now(timezone.utc)
self._metrics.last_update_mono = time.monotonic()
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
logger.info(
@@ -900,14 +913,16 @@ class WledTargetProcessor(TargetProcessor):
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
if self._metrics.frames_processed > 1:
fps_samples.append(1.0 / interval if interval > 0 else 0)
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
new_fps = 1.0 / interval if interval > 0 else 0
if len(fps_samples) == fps_samples.maxlen:
_fps_sum -= fps_samples[0]
fps_samples.append(new_fps)
_fps_sum += new_fps
self._metrics.fps_actual = _fps_sum / len(fps_samples)
processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
except Exception as e:

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: