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.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -42,7 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
audio_source_id=getattr(source, "audio_source_id", None), audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None), channel=getattr(source, "channel", None),
description=source.description, description=source.description,
tags=getattr(source, 'tags', []), tags=source.tags,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
) )
@@ -85,6 +86,9 @@ async def create_audio_source(
) )
fire_entity_event("audio_source", "created", source.id) fire_entity_event("audio_source", "created", source.id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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) fire_entity_event("audio_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -148,6 +155,9 @@ async def delete_audio_source(
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -38,7 +39,7 @@ async def list_audio_templates(
responses = [ responses = [
AudioTemplateResponse( AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, 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, created_at=t.created_at,
updated_at=t.updated_at, description=t.description, 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) fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type, 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, created_at=template.created_at,
updated_at=template.updated_at, description=template.description, updated_at=template.updated_at, description=template.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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") raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, 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, created_at=t.created_at,
updated_at=t.updated_at, description=t.description, 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) fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, 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, created_at=t.created_at,
updated_at=t.updated_at, description=t.description, updated_at=t.updated_at, description=t.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -137,6 +144,9 @@ async def delete_audio_template(
fire_entity_event("audio_template", "deleted", template_id) fire_entity_event("audio_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -113,7 +114,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
is_active=state["is_active"], is_active=state["is_active"],
last_activated_at=state.get("last_activated_at"), last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
tags=getattr(automation, 'tags', []), tags=automation.tags,
created_at=automation.created_at, created_at=automation.created_at,
updated_at=automation.updated_at, updated_at=automation.updated_at,
) )
@@ -163,6 +164,9 @@ async def create_automation(
try: try:
conditions = [_condition_from_schema(c) for c in data.conditions] 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -249,6 +253,9 @@ async def update_automation(
if data.conditions is not None: if data.conditions is not None:
try: try:
conditions = [_condition_from_schema(c) for c in data.conditions] 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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.color_strip_store import ColorStripStore
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -43,7 +44,7 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, 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) fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template) return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -119,6 +123,9 @@ async def update_cspt(
) )
fire_entity_event("cspt", "updated", template_id) fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template) return _cspt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -148,6 +155,9 @@ async def delete_cspt(
fire_entity_event("cspt", "deleted", template_id) fire_entity_event("cspt", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -154,6 +155,10 @@ async def create_color_strip_source(
fire_entity_event("color_strip_source", "created", source.id) fire_entity_event("color_strip_source", "created", source.id)
return _css_to_response(source) return _css_to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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 import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -51,7 +52,7 @@ def _device_to_response(device) -> DeviceResponse:
rgbw=device.rgbw, rgbw=device.rgbw,
zone_mode=device.zone_mode, zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)), capabilities=sorted(get_device_capabilities(device.device_type)),
tags=getattr(device, 'tags', []), tags=device.tags,
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'), dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
dmx_start_universe=getattr(device, 'dmx_start_universe', 0), dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
dmx_start_channel=getattr(device, 'dmx_start_channel', 1), 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.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -106,7 +107,7 @@ def _target_to_response(target) -> OutputTargetResponse:
adaptive_fps=target.adaptive_fps, adaptive_fps=target.adaptive_fps,
protocol=target.protocol, protocol=target.protocol,
description=target.description, description=target.description,
tags=getattr(target, 'tags', []), tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -119,7 +120,7 @@ def _target_to_response(target) -> OutputTargetResponse:
picture_source_id=target.picture_source_id, picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings), key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description, description=target.description,
tags=getattr(target, 'tags', []), tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -130,7 +131,7 @@ def _target_to_response(target) -> OutputTargetResponse:
name=target.name, name=target.name,
target_type=target.target_type, target_type=target.target_type,
description=target.description, description=target.description,
tags=getattr(target, 'tags', []), tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -188,6 +189,9 @@ async def create_target(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -598,6 +602,9 @@ async def test_kc_target(
try: try:
chain = source_store.resolve_stream_chain(target.picture_source_id) 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -735,6 +742,9 @@ async def test_kc_target(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as 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.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -37,7 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, 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) fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -121,6 +125,9 @@ async def update_pattern_template(
) )
fire_entity_event("pattern_template", "updated", template_id) fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template) return _pat_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -149,6 +156,9 @@ async def delete_pattern_template(
fire_entity_event("pattern_template", "deleted", template_id) fire_entity_event("pattern_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -62,7 +63,7 @@ def _stream_to_response(s) -> PictureSourceResponse:
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
description=s.description, description=s.description,
tags=getattr(s, 'tags', []), tags=s.tags,
# Video fields # Video fields
url=getattr(s, "url", None), url=getattr(s, "url", None),
loop=getattr(s, "loop", None), loop=getattr(s, "loop", None),
@@ -228,6 +229,9 @@ async def create_picture_source(
return _stream_to_response(stream) return _stream_to_response(stream)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -280,6 +284,9 @@ async def update_picture_source(
) )
fire_entity_event("picture_source", "updated", stream_id) fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream) return _stream_to_response(stream)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -309,6 +316,9 @@ async def delete_picture_source(
fire_entity_event("picture_source", "deleted", stream_id) fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -383,6 +393,9 @@ async def test_picture_source(
# Resolve stream chain # Resolve stream chain
try: try:
chain = store.resolve_stream_chain(stream_id) chain = store.resolve_stream_chain(stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -541,6 +554,9 @@ async def test_picture_source(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as 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_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -51,7 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, 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), store: PostprocessingTemplateStore = Depends(get_pp_template_store),
): ):
"""List all postprocessing templates.""" """List all postprocessing templates."""
try:
templates = store.get_all_templates() templates = store.get_all_templates()
responses = [_pp_template_to_response(t) for t in templates] responses = [_pp_template_to_response(t) for t in templates]
return PostprocessingTemplateListResponse(templates=responses, count=len(responses)) 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))
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201) @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) fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -127,6 +127,9 @@ async def update_pp_template(
) )
fire_entity_event("pp_template", "updated", template_id) fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -156,6 +159,9 @@ async def delete_pp_template(
fire_entity_event("pp_template", "deleted", template_id) fire_entity_event("pp_template", "deleted", template_id)
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -184,6 +190,9 @@ async def test_pp_template(
# Resolve source stream chain to get the raw stream # Resolve source stream chain to get the raw stream
try: try:
chain = stream_store.resolve_stream_chain(test_request.source_stream_id) 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -327,6 +336,9 @@ async def test_pp_template(
except HTTPException: except HTTPException:
raise raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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 import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -46,7 +47,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
"fps": t.fps, "fps": t.fps,
} for t in preset.targets], } for t in preset.targets],
order=preset.order, order=preset.order,
tags=getattr(preset, 'tags', []), tags=preset.tags,
created_at=preset.created_at, created_at=preset.created_at,
updated_at=preset.updated_at, updated_at=preset.updated_at,
) )
@@ -85,6 +86,9 @@ async def create_scene_preset(
try: try:
preset = store.create_preset(preset) preset = store.create_preset(preset)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -34,7 +35,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
name=clock.name, name=clock.name,
speed=rt.speed if rt else clock.speed, speed=rt.speed if rt else clock.speed,
description=clock.description, description=clock.description,
tags=getattr(clock, 'tags', []), tags=clock.tags,
is_running=rt.is_running if rt else True, is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0, elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at, created_at=clock.created_at,
@@ -73,6 +74,9 @@ async def create_sync_clock(
) )
fire_entity_event("sync_clock", "created", clock.id) fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager) return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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) manager.update_speed(clock_id, clock.speed)
fire_entity_event("sync_clock", "updated", clock_id) fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager) return _to_response(clock, manager)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -137,6 +144,9 @@ async def delete_sync_clock(
manager.release_all_for(clock_id) manager.release_all_for(clock_id)
store.delete_clock(clock_id) store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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) # 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.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: def _get_cpu_name() -> str | None:
@@ -96,8 +97,8 @@ def _get_cpu_name() -> str | None:
.decode() .decode()
.strip() .strip()
) )
except Exception: except Exception as e:
pass logger.warning("CPU name detection failed: %s", e)
return platform.processor() or None return platform.processor() or None
@@ -157,7 +158,7 @@ async def list_all_tags(_: AuthRequired):
items = fn() if fn else None items = fn() if fn else None
if items: if items:
for item in items: for item in items:
all_tags.update(getattr(item, 'tags', [])) all_tags.update(item.tags)
return {"tags": sorted(all_tags)} return {"tags": sorted(all_tags)}
@@ -205,6 +206,10 @@ async def get_displays(
count=len(displays), count=len(displays),
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -265,8 +270,8 @@ def get_system_performance(_: AuthRequired):
memory_total_mb=round(mem_info.total / 1024 / 1024, 1), memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp), temperature_c=float(temp),
) )
except Exception: except Exception as e:
pass logger.debug("NVML query failed: %s", e)
return PerformanceResponse( return PerformanceResponse(
cpu_name=_cpu_name, 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_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -64,7 +65,7 @@ async def list_templates(
name=t.name, name=t.name,
engine_type=t.engine_type, engine_type=t.engine_type,
engine_config=t.engine_config, engine_config=t.engine_config,
tags=getattr(t, 'tags', []), tags=t.tags,
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
@@ -104,12 +105,16 @@ async def create_template(
name=template.name, name=template.name,
engine_type=template.engine_type, engine_type=template.engine_type,
engine_config=template.engine_config, engine_config=template.engine_config,
tags=getattr(template, 'tags', []), tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -134,7 +139,7 @@ async def get_template(
name=template.name, name=template.name,
engine_type=template.engine_type, engine_type=template.engine_type,
engine_config=template.engine_config, engine_config=template.engine_config,
tags=getattr(template, 'tags', []), tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
@@ -165,12 +170,16 @@ async def update_template(
name=template.name, name=template.name,
engine_type=template.engine_type, engine_type=template.engine_type,
engine_config=template.engine_config, engine_config=template.engine_config,
tags=getattr(template, 'tags', []), tags=template.tags,
created_at=template.created_at, created_at=template.created_at,
updated_at=template.updated_at, updated_at=template.updated_at,
description=template.description, description=template.description,
) )
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -210,6 +219,9 @@ async def delete_template(
except HTTPException: except HTTPException:
raise # Re-raise HTTP exceptions as-is raise # Re-raise HTTP exceptions as-is
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as 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.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -105,6 +106,9 @@ async def create_value_source(
) )
fire_entity_event("value_source", "created", source.id) fire_entity_event("value_source", "created", source.id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -158,6 +162,9 @@ async def update_value_source(
pm.update_value_source(source_id) pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id) fire_entity_event("value_source", "updated", source_id)
return _to_response(source) return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -182,6 +189,9 @@ async def delete_value_source(
store.delete_source(source_id) store.delete_source(source_id)
fire_entity_event("value_source", "deleted", 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -93,7 +93,7 @@ class Config(BaseSettings):
if not config_path.exists(): if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}") 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) config_data = yaml.safe_load(f)
return cls(**config_data) return cls(**config_data)

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,7 +115,8 @@ class ESPNowClient(LEDClient):
else: else:
pixel_bytes = bytes(c for rgb in pixels for c in rgb) 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: try:
self._serial.write(frame) self._serial.write(frame)
except Exception as e: except Exception as e:

View File

@@ -187,7 +187,7 @@ class GameSenseClient(LEDClient):
else: else:
pixel_arr = np.array(pixels, dtype=np.uint8) 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 # Use average color for single-zone devices, or first N for multi-zone
if len(pixel_arr) == 0: if len(pixel_arr) == 0:
@@ -195,9 +195,9 @@ class GameSenseClient(LEDClient):
# Compute average color for the zone # Compute average color for the zone
avg = pixel_arr.mean(axis=0) avg = pixel_arr.mean(axis=0)
r = int(avg[0] * bri_scale) r = int(avg[0])
g = int(avg[1] * bri_scale) g = int(avg[1])
b = int(avg[2] * bri_scale) b = int(avg[2])
event_data = { event_data = {
"game": GAME_NAME, "game": GAME_NAME,

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,7 @@ class USBHIDClient(LEDClient):
else: else:
pixel_list = list(pixels) 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 # 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 ...] # 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): for i, (r, g, b) in enumerate(chunk):
base = 5 + i * 3 base = 5 + i * 3
report[base] = int(r * bri_scale) report[base] = int(r)
report[base + 1] = int(g * bri_scale) report[base + 1] = int(g)
report[base + 2] = int(b * bri_scale) report[base + 2] = int(b)
reports.append(bytes(report)) reports.append(bytes(report))
offset += len(chunk) offset += len(chunk)

View File

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

View File

@@ -92,7 +92,11 @@ class ApiInputColorStripStream(ColorStripStream):
if n > self._led_count: if n > self._led_count:
self._ensure_capacity(n) self._ensure_capacity(n)
if n == self._led_count: 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: elif n < self._led_count:
# Zero-pad to led_count # Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8) 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_colors: Optional[np.ndarray] = None
self._latest_layer_colors: Optional[List[np.ndarray]] = None self._latest_layer_colors: Optional[List[np.ndarray]] = None
self._colors_lock = threading.Lock() 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) # layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {} self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream) # layer_index -> (vs_id, value_stream)
self._brightness_streams: Dict[int, tuple] = {} self._brightness_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams 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) # Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0 self._pool_n = 0
@@ -111,6 +121,7 @@ class CompositeColorStripStream(ColorStripStream):
def get_layer_colors(self) -> Optional[List[np.ndarray]]: def get_layer_colors(self) -> Optional[List[np.ndarray]]:
"""Return per-layer color snapshots (after resize/brightness, before blending).""" """Return per-layer color snapshots (after resize/brightness, before blending)."""
self._need_layer_snapshots = True
with self._colors_lock: with self._colors_lock:
return self._latest_layer_colors return self._latest_layer_colors
@@ -165,6 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
# ── Sub-stream lifecycle ──────────────────────────────────── # ── Sub-stream lifecycle ────────────────────────────────────
def _acquire_sub_streams(self) -> None: def _acquire_sub_streams(self) -> None:
self._sub_streams_version += 1
for i, layer in enumerate(self._layers): for i, layer in enumerate(self._layers):
if not layer.get("enabled", True): if not layer.get("enabled", True):
continue continue
@@ -193,6 +205,7 @@ class CompositeColorStripStream(ColorStripStream):
) )
def _release_sub_streams(self) -> None: def _release_sub_streams(self) -> None:
self._sub_streams_version += 1
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()): for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
try: try:
self._css_manager.release(src_id, consumer_id) self._css_manager.release(src_id, consumer_id)
@@ -356,7 +369,10 @@ class CompositeColorStripStream(ColorStripStream):
layer_snapshots: List[np.ndarray] = [] layer_snapshots: List[np.ndarray] = []
with self._sub_lock: 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): for i, layer in enumerate(self._layers):
if not layer.get("enabled", True): if not layer.get("enabled", True):
@@ -412,6 +428,7 @@ class CompositeColorStripStream(ColorStripStream):
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8) colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
# Snapshot layer colors before blending (copy — may alias shared buf) # Snapshot layer colors before blending (copy — may alias shared buf)
if self._need_layer_snapshots:
layer_snapshots.append(colors.copy()) layer_snapshots.append(colors.copy())
opacity = layer.get("opacity", 1.0) opacity = layer.get("opacity", 1.0)
@@ -425,11 +442,11 @@ class CompositeColorStripStream(ColorStripStream):
result_buf[:] = colors result_buf[:] = colors
else: else:
result_buf[:] = 0 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) blend_fn(result_buf, colors, alpha, result_buf)
has_result = True has_result = True
else: 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) blend_fn(result_buf, colors, alpha, result_buf)
if has_result: if has_result:

View File

@@ -91,7 +91,8 @@ class MetricsHistory:
# Per-target metrics from processor states # Per-target metrics from processor states
try: try:
all_states = self._manager.get_all_target_states() 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 = {} all_states = {}
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()

View File

@@ -43,6 +43,7 @@ class ProcessingMetrics:
errors_count: int = 0 errors_count: int = 0
last_error: Optional[str] = None last_error: Optional[str] = None
last_update: Optional[datetime] = 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 start_time: Optional[datetime] = None
fps_actual: float = 0.0 fps_actual: float = 0.0
fps_potential: float = 0.0 fps_potential: float = 0.0

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,35 +24,11 @@ class OutputTargetStore(BaseJsonStore[OutputTarget]):
_json_key = "output_targets" _json_key = "output_targets"
_entity_name = "Output target" _entity_name = "Output target"
_legacy_json_keys = ["picture_targets"]
def __init__(self, file_path: str): def __init__(self, file_path: str):
super().__init__(file_path, OutputTarget.from_dict) 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 # Backward-compatible aliases
get_all_targets = BaseJsonStore.get_all get_all_targets = BaseJsonStore.get_all
get_target = BaseJsonStore.get get_target = BaseJsonStore.get

View File

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