diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py index 6f20a54..6fc5651 100644 --- a/server/src/wled_controller/api/routes/audio_sources.py +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -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)) diff --git a/server/src/wled_controller/api/routes/audio_templates.py b/server/src/wled_controller/api/routes/audio_templates.py index 98f72f0..f42ab4b 100644 --- a/server/src/wled_controller/api/routes/audio_templates.py +++ b/server/src/wled_controller/api/routes/audio_templates.py @@ -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: diff --git a/server/src/wled_controller/api/routes/automations.py b/server/src/wled_controller/api/routes/automations.py index 6552504..c9bfcac 100644 --- a/server/src/wled_controller/api/routes/automations.py +++ b/server/src/wled_controller/api/routes/automations.py @@ -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)) diff --git a/server/src/wled_controller/api/routes/color_strip_processing.py b/server/src/wled_controller/api/routes/color_strip_processing.py index 9165d64..d74de07 100644 --- a/server/src/wled_controller/api/routes/color_strip_processing.py +++ b/server/src/wled_controller/api/routes/color_strip_processing.py @@ -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: diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index e73f724..57c3e02 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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: diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 9d8f43e..c6e454d 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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), diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index 66b7d76..e672766 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -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: diff --git a/server/src/wled_controller/api/routes/pattern_templates.py b/server/src/wled_controller/api/routes/pattern_templates.py index 325f3bb..f6d3d23 100644 --- a/server/src/wled_controller/api/routes/pattern_templates.py +++ b/server/src/wled_controller/api/routes/pattern_templates.py @@ -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: diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index 3d916ea..07f13bb 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -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: diff --git a/server/src/wled_controller/api/routes/postprocessing.py b/server/src/wled_controller/api/routes/postprocessing.py index 3a8695a..a956c0c 100644 --- a/server/src/wled_controller/api/routes/postprocessing.py +++ b/server/src/wled_controller/api/routes/postprocessing.py @@ -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: diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py index 2bf3ee9..fda2543 100644 --- a/server/src/wled_controller/api/routes/scene_presets.py +++ b/server/src/wled_controller/api/routes/scene_presets.py @@ -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)) diff --git a/server/src/wled_controller/api/routes/sync_clocks.py b/server/src/wled_controller/api/routes/sync_clocks.py index 1fb2aa3..6992849 100644 --- a/server/src/wled_controller/api/routes/sync_clocks.py +++ b/server/src/wled_controller/api/routes/sync_clocks.py @@ -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)) diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index bc7e8da..fd082e7 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -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, diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index a417b8b..ea88c3c 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -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: diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index 758574d..547fa1d 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -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)) diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 155cbe1..c1f1f41 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -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) diff --git a/server/src/wled_controller/core/automations/automation_engine.py b/server/src/wled_controller/core/automations/automation_engine.py index b11819f..b6ad77d 100644 --- a/server/src/wled_controller/core/automations/automation_engine.py +++ b/server/src/wled_controller/core/automations/automation_engine.py @@ -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) ===== diff --git a/server/src/wled_controller/core/devices/adalight_client.py b/server/src/wled_controller/core/devices/adalight_client.py index 2eade9d..a1b5723 100644 --- a/server/src/wled_controller/core/devices/adalight_client.py +++ b/server/src/wled_controller/core/devices/adalight_client.py @@ -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 diff --git a/server/src/wled_controller/core/devices/ambiled_client.py b/server/src/wled_controller/core/devices/ambiled_client.py index 343dd10..b63f975 100644 --- a/server/src/wled_controller/core/devices/ambiled_client.py +++ b/server/src/wled_controller/core/devices/ambiled_client.py @@ -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 0–250: values >250 are command bytes in AmbiLED protocol np.clip(arr, 0, 250, out=arr) rgb_bytes = arr.astype(np.uint8).tobytes() diff --git a/server/src/wled_controller/core/devices/chroma_client.py b/server/src/wled_controller/core/devices/chroma_client.py index f6f02c4..c2327de 100644 --- a/server/src/wled_controller/core/devices/chroma_client.py +++ b/server/src/wled_controller/core/devices/chroma_client.py @@ -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 diff --git a/server/src/wled_controller/core/devices/espnow_client.py b/server/src/wled_controller/core/devices/espnow_client.py index 4077c80..b798585 100644 --- a/server/src/wled_controller/core/devices/espnow_client.py +++ b/server/src/wled_controller/core/devices/espnow_client.py @@ -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: diff --git a/server/src/wled_controller/core/devices/gamesense_client.py b/server/src/wled_controller/core/devices/gamesense_client.py index 491d100..9e8ee64 100644 --- a/server/src/wled_controller/core/devices/gamesense_client.py +++ b/server/src/wled_controller/core/devices/gamesense_client.py @@ -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, diff --git a/server/src/wled_controller/core/devices/hue_client.py b/server/src/wled_controller/core/devices/hue_client.py index d5d33f2..4320d87 100644 --- a/server/src/wled_controller/core/devices/hue_client.py +++ b/server/src/wled_controller/core/devices/hue_client.py @@ -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) diff --git a/server/src/wled_controller/core/devices/openrgb_client.py b/server/src/wled_controller/core/devices/openrgb_client.py index cd8f2c1..e6c9db6 100644 --- a/server/src/wled_controller/core/devices/openrgb_client.py +++ b/server/src/wled_controller/core/devices/openrgb_client.py @@ -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: diff --git a/server/src/wled_controller/core/devices/spi_client.py b/server/src/wled_controller/core/devices/spi_client.py index 3029295..3a4762c 100644 --- a/server/src/wled_controller/core/devices/spi_client.py +++ b/server/src/wled_controller/core/devices/spi_client.py @@ -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() diff --git a/server/src/wled_controller/core/devices/usbhid_client.py b/server/src/wled_controller/core/devices/usbhid_client.py index f847899..7fd7574 100644 --- a/server/src/wled_controller/core/devices/usbhid_client.py +++ b/server/src/wled_controller/core/devices/usbhid_client.py @@ -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) diff --git a/server/src/wled_controller/core/devices/wled_client.py b/server/src/wled_controller/core/devices/wled_client.py index 1aa8d9c..e091240 100644 --- a/server/src/wled_controller/core/devices/wled_client.py +++ b/server/src/wled_controller/core/devices/wled_client.py @@ -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 ===== diff --git a/server/src/wled_controller/core/processing/api_input_stream.py b/server/src/wled_controller/core/processing/api_input_stream.py index 1b43c6e..9db157a 100644 --- a/server/src/wled_controller/core/processing/api_input_stream.py +++ b/server/src/wled_controller/core/processing/api_input_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index ff61ef0..f73ca6b 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -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: diff --git a/server/src/wled_controller/core/processing/metrics_history.py b/server/src/wled_controller/core/processing/metrics_history.py index 3cf5fa8..25df874 100644 --- a/server/src/wled_controller/core/processing/metrics_history.py +++ b/server/src/wled_controller/core/processing/metrics_history.py @@ -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() diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 40d6dea..6c3fcde 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 7d0467b..e681e61 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -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.0–1.0).""" - return 1.0 + ... def start(self) -> None: """Acquire resources (if any).""" diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 6f4b729..3aca515 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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: diff --git a/server/src/wled_controller/storage/audio_template_store.py b/server/src/wled_controller/storage/audio_template_store.py index 817b32c..d605d39 100644 --- a/server/src/wled_controller/storage/audio_template_store.py +++ b/server/src/wled_controller/storage/audio_template_store.py @@ -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: diff --git a/server/src/wled_controller/storage/base_store.py b/server/src/wled_controller/storage/base_store.py index 2aa9743..9d4e51a 100644 --- a/server/src/wled_controller/storage/base_store.py +++ b/server/src/wled_controller/storage/base_store.py @@ -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") diff --git a/server/src/wled_controller/storage/output_target_store.py b/server/src/wled_controller/storage/output_target_store.py index b7b7f75..82d894f 100644 --- a/server/src/wled_controller/storage/output_target_store.py +++ b/server/src/wled_controller/storage/output_target_store.py @@ -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 diff --git a/server/src/wled_controller/storage/template_store.py b/server/src/wled_controller/storage/template_store.py index b0e8449..602ef64 100644 --- a/server/src/wled_controller/storage/template_store.py +++ b/server/src/wled_controller/storage/template_store.py @@ -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: