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:
@@ -157,6 +157,23 @@ def get_sync_clock_manager() -> SyncClockManager:
|
|||||||
return _sync_clock_manager
|
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(
|
def init_dependencies(
|
||||||
device_store: DeviceStore,
|
device_store: DeviceStore,
|
||||||
template_store: TemplateStore,
|
template_store: TemplateStore,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_audio_source_store,
|
get_audio_source_store,
|
||||||
get_audio_template_store,
|
get_audio_template_store,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
@@ -84,6 +85,7 @@ async def create_audio_source(
|
|||||||
audio_template_id=data.audio_template_id,
|
audio_template_id=data.audio_template_id,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("audio_source", "created", source.id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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,
|
audio_template_id=data.audio_template_id,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("audio_source", "updated", source_id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -146,6 +149,7 @@ async def delete_audio_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("audio_source", "deleted", source_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
|
|||||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
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 (
|
from wled_controller.api.schemas.audio_templates import (
|
||||||
AudioEngineInfo,
|
AudioEngineInfo,
|
||||||
AudioEngineListResponse,
|
AudioEngineListResponse,
|
||||||
@@ -66,6 +66,7 @@ async def create_audio_template(
|
|||||||
engine_config=data.engine_config, description=data.description,
|
engine_config=data.engine_config, description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
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=getattr(template, 'tags', []),
|
||||||
@@ -112,6 +113,7 @@ async def update_audio_template(
|
|||||||
engine_type=data.engine_type, engine_config=data.engine_config,
|
engine_type=data.engine_type, engine_config=data.engine_config,
|
||||||
description=data.description, tags=data.tags,
|
description=data.description, tags=data.tags,
|
||||||
)
|
)
|
||||||
|
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=getattr(t, 'tags', []),
|
||||||
@@ -135,6 +137,7 @@ async def delete_audio_template(
|
|||||||
"""Delete an audio template."""
|
"""Delete an audio template."""
|
||||||
try:
|
try:
|
||||||
store.delete_template(template_id, audio_source_store=audio_source_store)
|
store.delete_template(template_id, audio_source_store=audio_source_store)
|
||||||
|
fire_entity_event("audio_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_automation_engine,
|
get_automation_engine,
|
||||||
get_automation_store,
|
get_automation_store,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
@@ -174,6 +175,7 @@ async def create_automation(
|
|||||||
if automation.enabled:
|
if automation.enabled:
|
||||||
await engine.trigger_evaluate()
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
fire_entity_event("automation", "created", automation.id)
|
||||||
return _automation_to_response(automation, engine, request)
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +275,7 @@ async def update_automation(
|
|||||||
if automation.enabled:
|
if automation.enabled:
|
||||||
await engine.trigger_evaluate()
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
fire_entity_event("automation", "updated", automation_id)
|
||||||
return _automation_to_response(automation, engine, request)
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +299,8 @@ async def delete_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("automation", "deleted", automation_id)
|
||||||
|
|
||||||
|
|
||||||
# ===== Enable/Disable =====
|
# ===== Enable/Disable =====
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from wled_controller.core.devices.led_client import (
|
|||||||
get_provider,
|
get_provider,
|
||||||
)
|
)
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
@@ -146,6 +147,7 @@ async def create_device(
|
|||||||
zone_mode=device.zone_mode,
|
zone_mode=device.zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("device", "created", device.id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -332,6 +334,7 @@ async def update_device(
|
|||||||
if update_data.zone_mode is not None:
|
if update_data.zone_mode is not None:
|
||||||
ds.zone_mode = update_data.zone_mode
|
ds.zone_mode = update_data.zone_mode
|
||||||
|
|
||||||
|
fire_entity_event("device", "updated", device_id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -369,6 +372,7 @@ async def delete_device(
|
|||||||
# Delete from storage
|
# Delete from storage
|
||||||
store.delete_device(device_id)
|
store.delete_device(device_id)
|
||||||
|
|
||||||
|
fire_entity_event("device", "deleted", device_id)
|
||||||
logger.info(f"Deleted device {device_id}")
|
logger.info(f"Deleted device {device_id}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
@@ -181,6 +182,7 @@ async def create_target(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Could not register target {target.id} in processor manager: {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)
|
return _target_to_response(target)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -319,6 +321,7 @@ async def update_target(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
fire_entity_event("output_target", "updated", target_id)
|
||||||
return _target_to_response(target)
|
return _target_to_response(target)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -354,6 +357,7 @@ async def delete_target(
|
|||||||
# Delete from store
|
# Delete from store
|
||||||
target_store.delete_target(target_id)
|
target_store.delete_target(target_id)
|
||||||
|
|
||||||
|
fire_entity_event("output_target", "deleted", target_id)
|
||||||
logger.info(f"Deleted target {target_id}")
|
logger.info(f"Deleted target {target_id}")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
)
|
)
|
||||||
@@ -73,6 +74,7 @@ async def create_pattern_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pattern_template", "created", template.id)
|
||||||
return _pat_template_to_response(template)
|
return _pat_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -117,6 +119,7 @@ async def update_pattern_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pattern_template", "updated", template_id)
|
||||||
return _pat_template_to_response(template)
|
return _pat_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -143,6 +146,7 @@ async def delete_pattern_template(
|
|||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_template(template_id)
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("pattern_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.responses import Response
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
@@ -199,6 +200,7 @@ async def create_picture_source(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("picture_source", "created", stream.id)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -244,6 +246,7 @@ async def update_picture_source(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("picture_source", "updated", stream_id)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -271,6 +274,7 @@ async def delete_picture_source(
|
|||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_stream(stream_id)
|
store.delete_stream(stream_id)
|
||||||
|
fire_entity_event("picture_source", "deleted", stream_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
get_template_store,
|
get_template_store,
|
||||||
@@ -84,6 +85,7 @@ async def create_pp_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pp_template", "created", template.id)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -123,6 +125,7 @@ async def update_pp_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pp_template", "updated", template_id)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -150,6 +153,7 @@ async def delete_pp_template(
|
|||||||
"Please reassign those streams before deleting.",
|
"Please reassign those streams before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_template(template_id)
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("pp_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
@@ -87,6 +88,7 @@ async def create_scene_preset(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "created", preset.id)
|
||||||
return _preset_to_response(preset)
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +177,7 @@ async def update_scene_preset(
|
|||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(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)
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ async def delete_scene_preset(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "deleted", preset_id)
|
||||||
|
|
||||||
|
|
||||||
# ===== Recapture =====
|
# ===== Recapture =====
|
||||||
@@ -259,4 +263,5 @@ async def activate_scene_preset(
|
|||||||
if not errors:
|
if not errors:
|
||||||
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "updated", preset_id)
|
||||||
return ActivateResponse(status=status, errors=errors)
|
return ActivateResponse(status=status, errors=errors)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_sync_clock_manager,
|
get_sync_clock_manager,
|
||||||
get_sync_clock_store,
|
get_sync_clock_store,
|
||||||
@@ -70,6 +71,7 @@ async def create_sync_clock(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("sync_clock", "created", clock.id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -110,6 +112,7 @@ async def update_sync_clock(
|
|||||||
# Hot-update runtime speed
|
# Hot-update runtime speed
|
||||||
if data.speed is not None:
|
if data.speed is not None:
|
||||||
manager.update_speed(clock_id, clock.speed)
|
manager.update_speed(clock_id, clock.speed)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -133,6 +136,7 @@ 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)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
@@ -152,6 +156,7 @@ async def pause_sync_clock(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
manager.pause(clock_id)
|
manager.pause(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +173,7 @@ async def resume_sync_clock(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
manager.resume(clock_id)
|
manager.resume(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
|
|
||||||
|
|
||||||
@@ -184,4 +190,5 @@ async def reset_sync_clock(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
manager.reset(clock_id)
|
manager.reset(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
get_template_store,
|
get_template_store,
|
||||||
@@ -96,6 +97,7 @@ async def create_template(
|
|||||||
tags=template_data.tags,
|
tags=template_data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("capture_template", "created", template.id)
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
@@ -156,6 +158,7 @@ async def update_template(
|
|||||||
tags=update_data.tags,
|
tags=update_data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("capture_template", "updated", template_id)
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
@@ -202,6 +205,7 @@ async def delete_template(
|
|||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
template_store.delete_template(template_id)
|
template_store.delete_template(template_id)
|
||||||
|
fire_entity_event("capture_template", "deleted", template_id)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise # Re-raise HTTP exceptions as-is
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
get_value_source_store,
|
get_value_source_store,
|
||||||
@@ -100,6 +101,7 @@ async def create_value_source(
|
|||||||
auto_gain=data.auto_gain,
|
auto_gain=data.auto_gain,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("value_source", "created", source.id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -150,6 +152,7 @@ async def update_value_source(
|
|||||||
)
|
)
|
||||||
# Hot-reload running value streams
|
# Hot-reload running value streams
|
||||||
pm.update_value_source(source_id)
|
pm.update_value_source(source_id)
|
||||||
|
fire_entity_event("value_source", "updated", source_id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -174,6 +177,7 @@ async def delete_value_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("value_source", "deleted", source_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ class AutomationEngine:
|
|||||||
|
|
||||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||||
try:
|
try:
|
||||||
self._manager._fire_event({
|
self._manager.fire_event({
|
||||||
"type": "automation_state_changed",
|
"type": "automation_state_changed",
|
||||||
"automation_id": automation_id,
|
"automation_id": automation_id,
|
||||||
"action": action,
|
"action": action,
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class ProcessorManager:
|
|||||||
device_store=self._device_store,
|
device_store=self._device_store,
|
||||||
color_strip_stream_manager=self._color_strip_stream_manager,
|
color_strip_stream_manager=self._color_strip_stream_manager,
|
||||||
value_stream_manager=self._value_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,
|
get_device_info=self._get_device_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -203,8 +203,8 @@ class ProcessorManager:
|
|||||||
if queue in self._event_queues:
|
if queue in self._event_queues:
|
||||||
self._event_queues.remove(queue)
|
self._event_queues.remove(queue)
|
||||||
|
|
||||||
def _fire_event(self, event: dict) -> None:
|
def fire_event(self, event: dict) -> None:
|
||||||
"""Push event to all subscribers (non-blocking)."""
|
"""Push event to all subscribers (non-blocking). Public API for route handlers."""
|
||||||
for q in self._event_queues:
|
for q in self._event_queues:
|
||||||
try:
|
try:
|
||||||
q.put_nowait(event)
|
q.put_nowait(event)
|
||||||
@@ -854,7 +854,7 @@ class ProcessorManager:
|
|||||||
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
|
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
|
||||||
f"in {now - rs.first_crash_time:.0f}s — giving up"
|
f"in {now - rs.first_crash_time:.0f}s — giving up"
|
||||||
)
|
)
|
||||||
self._fire_event({
|
self.fire_event({
|
||||||
"type": "state_change",
|
"type": "state_change",
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": False,
|
"processing": False,
|
||||||
@@ -872,7 +872,7 @@ class ProcessorManager:
|
|||||||
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
|
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._fire_event({
|
self.fire_event({
|
||||||
"type": "state_change",
|
"type": "state_change",
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": False,
|
"processing": False,
|
||||||
@@ -916,7 +916,7 @@ class ProcessorManager:
|
|||||||
await self.start_processing(target_id)
|
await self.start_processing(target_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
|
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
|
||||||
self._fire_event({
|
self.fire_event({
|
||||||
"type": "state_change",
|
"type": "state_change",
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": False,
|
"processing": False,
|
||||||
@@ -1050,11 +1050,21 @@ class ProcessorManager:
|
|||||||
state = self._devices.get(device_id)
|
state = self._devices.get(device_id)
|
||||||
if not state:
|
if not state:
|
||||||
return
|
return
|
||||||
|
prev_online = state.health.online
|
||||||
client = await self._get_http_client()
|
client = await self._get_http_client()
|
||||||
state.health = await check_device_health(
|
state.health = await check_device_health(
|
||||||
state.device_type, state.device_url, client, state.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
|
# Auto-sync LED count
|
||||||
reported = state.health.device_led_count
|
reported = state.health.device_led_count
|
||||||
if reported and reported != state.led_count and self._device_store:
|
if reported and reported != state.led_count and self._device_store:
|
||||||
|
|||||||
61
server/src/wled_controller/static/js/core/entity-events.js
Normal file
61
server/src/wled_controller/static/js/core/entity-events.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -2,13 +2,20 @@
|
|||||||
* Global events WebSocket — stays connected while logged in,
|
* Global events WebSocket — stays connected while logged in,
|
||||||
* dispatches DOM custom events that feature modules can listen to.
|
* 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';
|
import { apiKey } from './state.js';
|
||||||
|
|
||||||
let _ws = null;
|
let _ws = null;
|
||||||
let _reconnectTimer = 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() {
|
export function startEventsWS() {
|
||||||
stopEventsWS();
|
stopEventsWS();
|
||||||
@@ -19,6 +26,9 @@ export function startEventsWS() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
_ws = new WebSocket(url);
|
_ws = new WebSocket(url);
|
||||||
|
_ws.onopen = () => {
|
||||||
|
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
|
||||||
|
};
|
||||||
_ws.onmessage = (event) => {
|
_ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -27,7 +37,8 @@ export function startEventsWS() {
|
|||||||
};
|
};
|
||||||
_ws.onclose = () => {
|
_ws.onclose = () => {
|
||||||
_ws = null;
|
_ws = null;
|
||||||
_reconnectTimer = setTimeout(startEventsWS, 3000);
|
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||||
|
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
||||||
};
|
};
|
||||||
_ws.onerror = () => {};
|
_ws.onerror = () => {};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -45,4 +56,5 @@ export function stopEventsWS() {
|
|||||||
_ws.close();
|
_ws.close();
|
||||||
_ws = null;
|
_ws = null;
|
||||||
}
|
}
|
||||||
|
_reconnectDelay = _RECONNECT_MIN;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -840,6 +840,13 @@ function _debouncedDashboardReload(forceFullRender = false) {
|
|||||||
|
|
||||||
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
||||||
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
|
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
|
// Re-render dashboard when language changes
|
||||||
document.addEventListener('languageChanged', () => {
|
document.addEventListener('languageChanged', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user