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))

View File

@@ -393,7 +393,7 @@ class AutomationEngine:
def _fire_event(self, automation_id: str, action: str) -> None:
try:
self._manager._fire_event({
self._manager.fire_event({
"type": "automation_state_changed",
"automation_id": automation_id,
"action": action,

View File

@@ -158,7 +158,7 @@ class ProcessorManager:
device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager,
value_stream_manager=self._value_stream_manager,
fire_event=self._fire_event,
fire_event=self.fire_event,
get_device_info=self._get_device_info,
)
@@ -203,8 +203,8 @@ class ProcessorManager:
if queue in self._event_queues:
self._event_queues.remove(queue)
def _fire_event(self, event: dict) -> None:
"""Push event to all subscribers (non-blocking)."""
def fire_event(self, event: dict) -> None:
"""Push event to all subscribers (non-blocking). Public API for route handlers."""
for q in self._event_queues:
try:
q.put_nowait(event)
@@ -854,7 +854,7 @@ class ProcessorManager:
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
f"in {now - rs.first_crash_time:.0f}s — giving up"
)
self._fire_event({
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
@@ -872,7 +872,7 @@ class ProcessorManager:
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
)
self._fire_event({
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
@@ -916,7 +916,7 @@ class ProcessorManager:
await self.start_processing(target_id)
except Exception as e:
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
self._fire_event({
self.fire_event({
"type": "state_change",
"target_id": target_id,
"processing": False,
@@ -1050,11 +1050,21 @@ class ProcessorManager:
state = self._devices.get(device_id)
if not state:
return
prev_online = state.health.online
client = await self._get_http_client()
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# Fire event when online status changes
if state.health.online != prev_online:
self.fire_event({
"type": "device_health_changed",
"device_id": device_id,
"online": state.health.online,
"latency_ms": state.health.latency_ms,
})
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:

View File

@@ -0,0 +1,61 @@
/**
* Entity event listeners — reacts to server-pushed entity_changed and
* device_health_changed WebSocket events by invalidating the relevant
* DataCache and dispatching an `entity:reload` DOM event so active
* feature modules can refresh their UI.
*/
import {
devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache,
} from './state.js';
/** Maps entity_type string from the server event to its DataCache instance. */
const ENTITY_CACHE_MAP = {
device: devicesCache,
output_target: outputTargetsCache,
color_strip_source: colorStripSourcesCache,
picture_source: streamsCache,
audio_source: audioSourcesCache,
value_source: valueSourcesCache,
sync_clock: syncClocksCache,
automation: automationsCacheObj,
scene_preset: scenePresetsCache,
capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache,
pattern_template: patternTemplatesCache,
};
function _invalidateAndReload(entityType) {
const cache = ENTITY_CACHE_MAP[entityType];
if (cache) {
cache.invalidate();
cache.fetch();
}
document.dispatchEvent(new CustomEvent('entity:reload', {
detail: { entity_type: entityType },
}));
}
function _onEntityChanged(e) {
const { entity_type } = e.detail || {};
if (!entity_type) return;
_invalidateAndReload(entity_type);
}
function _onDeviceHealthChanged() {
_invalidateAndReload('device');
}
/**
* Register listeners for server-pushed entity events.
* Call once during app initialization, after startEventsWS().
*/
export function startEntityEventListeners() {
document.addEventListener('server:entity_changed', _onEntityChanged);
document.addEventListener('server:device_health_changed', _onDeviceHealthChanged);
}

View File

@@ -2,13 +2,20 @@
* Global events WebSocket — stays connected while logged in,
* dispatches DOM custom events that feature modules can listen to.
*
* Events dispatched: server:state_change, server:automation_state_changed
* Events dispatched:
* server:state_change — target processing start/stop/crash
* server:automation_state_changed — automation activated/deactivated
* server:entity_changed — entity CRUD (create/update/delete)
* server:device_health_changed — device online/offline status change
*/
import { apiKey } from './state.js';
let _ws = null;
let _reconnectTimer = null;
let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s
const _RECONNECT_MIN = 1000;
const _RECONNECT_MAX = 30000;
export function startEventsWS() {
stopEventsWS();
@@ -19,6 +26,9 @@ export function startEventsWS() {
try {
_ws = new WebSocket(url);
_ws.onopen = () => {
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
};
_ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
@@ -27,7 +37,8 @@ export function startEventsWS() {
};
_ws.onclose = () => {
_ws = null;
_reconnectTimer = setTimeout(startEventsWS, 3000);
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
};
_ws.onerror = () => {};
} catch {
@@ -45,4 +56,5 @@ export function stopEventsWS() {
_ws.close();
_ws = null;
}
_reconnectDelay = _RECONNECT_MIN;
}

View File

@@ -840,6 +840,13 @@ function _debouncedDashboardReload(forceFullRender = false) {
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']);
document.addEventListener('server:entity_changed', (e) => {
const { entity_type } = e.detail || {};
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
});
// Re-render dashboard when language changes
document.addEventListener('languageChanged', () => {