Add entity CRUD events over WebSocket with auto-refresh

Broadcast entity_changed and device_health_changed events via the event
bus so the frontend can auto-refresh cards without polling. Adds
exponential backoff on WS reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:09:09 +03:00
parent 1ce25caa35
commit 73562cd525
18 changed files with 169 additions and 10 deletions

View File

@@ -157,6 +157,23 @@ def get_sync_clock_manager() -> SyncClockManager:
return _sync_clock_manager
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
"""
if _processor_manager is not None:
_processor_manager.fire_event({
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
})
def init_dependencies(
device_store: DeviceStore,
template_store: TemplateStore,

View File

@@ -9,6 +9,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_audio_source_store,
get_audio_template_store,
get_color_strip_store,
@@ -84,6 +85,7 @@ async def create_audio_source(
audio_template_id=data.audio_template_id,
tags=data.tags,
)
fire_entity_event("audio_source", "created", source.id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -123,6 +125,7 @@ async def update_audio_source(
audio_template_id=data.audio_template_id,
tags=data.tags,
)
fire_entity_event("audio_source", "updated", source_id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -146,6 +149,7 @@ async def delete_audio_source(
)
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_template_store, get_audio_source_store, get_processor_manager
from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
from wled_controller.api.schemas.audio_templates import (
AudioEngineInfo,
AudioEngineListResponse,
@@ -66,6 +66,7 @@ async def create_audio_template(
engine_config=data.engine_config, description=data.description,
tags=data.tags,
)
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', []),
@@ -112,6 +113,7 @@ async def update_audio_template(
engine_type=data.engine_type, engine_config=data.engine_config,
description=data.description, tags=data.tags,
)
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', []),
@@ -135,6 +137,7 @@ async def delete_audio_template(
"""Delete an audio template."""
try:
store.delete_template(template_id, audio_source_store=audio_source_store)
fire_entity_event("audio_template", "deleted", template_id)
except HTTPException:
raise
except ValueError as e:

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_automation_engine,
get_automation_store,
get_scene_preset_store,
@@ -174,6 +175,7 @@ async def create_automation(
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "created", automation.id)
return _automation_to_response(automation, engine, request)
@@ -273,6 +275,7 @@ async def update_automation(
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "updated", automation_id)
return _automation_to_response(automation, engine, request)
@@ -296,6 +299,8 @@ async def delete_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
# ===== Enable/Disable =====

View File

@@ -12,6 +12,7 @@ from wled_controller.core.devices.led_client import (
get_provider,
)
from wled_controller.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
@@ -146,6 +147,7 @@ async def create_device(
zone_mode=device.zone_mode,
)
fire_entity_event("device", "created", device.id)
return _device_to_response(device)
except HTTPException:
@@ -332,6 +334,7 @@ async def update_device(
if update_data.zone_mode is not None:
ds.zone_mode = update_data.zone_mode
fire_entity_event("device", "updated", device_id)
return _device_to_response(device)
except ValueError as e:
@@ -369,6 +372,7 @@ async def delete_device(
# Delete from storage
store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
logger.info(f"Deleted device {device_id}")
except HTTPException:

View File

@@ -12,6 +12,7 @@ from PIL import Image
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_pattern_template_store,
@@ -181,6 +182,7 @@ async def create_target(
except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
fire_entity_event("output_target", "created", target.id)
return _target_to_response(target)
except HTTPException:
@@ -319,6 +321,7 @@ async def update_target(
except ValueError:
pass
fire_entity_event("output_target", "updated", target_id)
return _target_to_response(target)
except HTTPException:
@@ -354,6 +357,7 @@ async def delete_target(
# Delete from store
target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id)
logger.info(f"Deleted target {target_id}")
except ValueError as e:

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_pattern_template_store,
get_output_target_store,
)
@@ -73,6 +74,7 @@ async def create_pattern_template(
description=data.description,
tags=data.tags,
)
fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -117,6 +119,7 @@ async def update_pattern_template(
description=data.description,
tags=data.tags,
)
fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -143,6 +146,7 @@ async def delete_pattern_template(
"Please reassign those targets before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pattern_template", "deleted", template_id)
except HTTPException:
raise
except ValueError as e:

View File

@@ -12,6 +12,7 @@ from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
@@ -199,6 +200,7 @@ async def create_picture_source(
description=data.description,
tags=data.tags,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
except HTTPException:
raise
@@ -244,6 +246,7 @@ async def update_picture_source(
description=data.description,
tags=data.tags,
)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -271,6 +274,7 @@ async def delete_picture_source(
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException:
raise
except ValueError as e:

View File

@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_pp_template_store,
get_template_store,
@@ -84,6 +85,7 @@ async def create_pp_template(
description=data.description,
tags=data.tags,
)
fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -123,6 +125,7 @@ async def update_pp_template(
description=data.description,
tags=data.tags,
)
fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -150,6 +153,7 @@ async def delete_pp_template(
"Please reassign those streams before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pp_template", "deleted", template_id)
except HTTPException:
raise
except ValueError as e:

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
@@ -87,6 +88,7 @@ async def create_scene_preset(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_preset", "created", preset.id)
return _preset_to_response(preset)
@@ -175,6 +177,7 @@ async def update_scene_preset(
)
except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
fire_entity_event("scene_preset", "updated", preset_id)
return _preset_to_response(preset)
@@ -194,6 +197,7 @@ async def delete_scene_preset(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
# ===== Recapture =====
@@ -259,4 +263,5 @@ async def activate_scene_preset(
if not errors:
logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id)
return ActivateResponse(status=status, errors=errors)

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
@@ -70,6 +71,7 @@ async def create_sync_clock(
description=data.description,
tags=data.tags,
)
fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -110,6 +112,7 @@ async def update_sync_clock(
# Hot-update runtime speed
if data.speed is not None:
manager.update_speed(clock_id, clock.speed)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -133,6 +136,7 @@ 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 ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -152,6 +156,7 @@ async def pause_sync_clock(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.pause(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
@@ -168,6 +173,7 @@ async def resume_sync_clock(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.resume(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)
@@ -184,4 +190,5 @@ async def reset_sync_clock(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
manager.reset(clock_id)
fire_entity_event("sync_clock", "updated", clock_id)
return _to_response(clock, manager)

View File

@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_pp_template_store,
get_template_store,
@@ -96,6 +97,7 @@ async def create_template(
tags=template_data.tags,
)
fire_entity_event("capture_template", "created", template.id)
return TemplateResponse(
id=template.id,
name=template.name,
@@ -156,6 +158,7 @@ async def update_template(
tags=update_data.tags,
)
fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse(
id=template.id,
name=template.name,
@@ -202,6 +205,7 @@ async def delete_template(
# Proceed with deletion
template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id)
except HTTPException:
raise # Re-raise HTTP exceptions as-is

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_value_source_store,
@@ -100,6 +101,7 @@ async def create_value_source(
auto_gain=data.auto_gain,
tags=data.tags,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -150,6 +152,7 @@ async def update_value_source(
)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -174,6 +177,7 @@ async def delete_value_source(
)
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))