Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/output_targets.py
alexei.dolgolyov cb9289f01f
Some checks failed
Lint & Test / test (push) Has been cancelled
feat: HA light output targets — cast LED colors to Home Assistant lights
New output target type `ha_light` that sends averaged LED colors to HA
light entities via WebSocket service calls (light.turn_on/turn_off):

Backend:
- HARuntime.call_service(): fire-and-forget WS service calls
- HALightOutputTarget: data model with light mappings, update rate, transition
- HALightTargetProcessor: processing loop with delta detection, rate limiting
- ProcessorManager.add_ha_light_target(): registration
- API schemas/routes updated for ha_light target type

Frontend:
- HA Light Targets section in Targets tab tree nav
- Modal editor: HA source picker, CSS source picker, light entity mappings
- Target cards with start/stop/clone/edit actions
- i18n keys for all new UI strings
2026-03-28 00:08:49 +03:00

412 lines
15 KiB
Python

"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
KeyColorsSettingsSchema,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
OutputTargetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
KeyColorsOutputTarget,
)
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.api.schemas.output_targets import HALightMappingSchema
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
pattern_template_id=settings.pattern_template_id,
brightness=settings.brightness,
brightness_value_source_id=settings.brightness_value_source_id,
)
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
"""Convert schema KeyColorsSettings to core."""
return KeyColorsSettings(
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
pattern_template_id=schema.pattern_template_id,
brightness=schema.brightness,
brightness_value_source_id=schema.brightness_value_source_id,
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert an OutputTarget to OutputTargetResponse."""
if isinstance(target, WledOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "",
fps=target.fps,
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
min_brightness_threshold=target.min_brightness_threshold,
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, KeyColorsOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, HALightOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
)
for m in target.light_mappings
],
update_rate=target.update_rate,
ha_transition=target.transition,
color_tolerance=target.color_tolerance,
min_brightness_threshold=target.min_brightness_threshold,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
else:
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
# ===== CRUD ENDPOINTS =====
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
async def create_target(
data: OutputTargetCreate,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Create a new output target."""
try:
# Validate device exists if provided
if data.device_id:
try:
device_store.get_device(data.device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
kc_settings = (
_kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
)
ha_mappings = (
[
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
)
for m in data.ha_light_mappings
]
if data.ha_light_mappings
else None
)
# Create in store
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings,
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
)
# Register in processor manager
try:
target.register_with_manager(manager)
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:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to create target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
async def list_targets(
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""List all output targets."""
targets = target_store.get_all_targets()
responses = [_target_to_response(t) for t in targets]
return OutputTargetListResponse(targets=responses, count=len(responses))
@router.get("/api/v1/output-targets/batch/states", tags=["Processing"])
async def batch_target_states(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing state for all targets in a single request."""
return {"states": manager.get_all_target_states()}
@router.get("/api/v1/output-targets/batch/metrics", tags=["Metrics"])
async def batch_target_metrics(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get metrics for all targets in a single request."""
return {"metrics": manager.get_all_target_metrics()}
@router.get(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
async def get_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Get a output target by ID."""
try:
target = target_store.get_target(target_id)
return _target_to_response(target)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
async def update_target(
target_id: str,
data: OutputTargetUpdate,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update a output target."""
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
try:
device_store.get_device(data.device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Build KC settings with partial-update support: only apply fields that were
# explicitly provided in the request body, merging with the existing settings.
kc_settings = None
if data.key_colors_settings is not None:
incoming = data.key_colors_settings.model_dump(exclude_unset=True)
try:
existing_target = target_store.get_target(target_id)
except ValueError:
existing_target = None
if isinstance(existing_target, KeyColorsOutputTarget):
ex = existing_target.settings
merged = KeyColorsSettingsSchema(
fps=incoming.get("fps", ex.fps),
interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode),
smoothing=incoming.get("smoothing", ex.smoothing),
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
brightness=incoming.get("brightness", ex.brightness),
brightness_value_source_id=incoming.get(
"brightness_value_source_id", ex.brightness_value_source_id
),
)
kc_settings = _kc_schema_to_settings(merged)
else:
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
key_colors_settings=kc_settings,
description=data.description,
tags=data.tags,
)
# Detect KC brightness VS change (inside key_colors_settings)
kc_brightness_vs_changed = False
if data.key_colors_settings is not None:
kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True)
if "brightness_value_source_id" in kc_incoming:
kc_brightness_vs_changed = True
# Sync processor manager (run in thread — css release/acquire can block)
try:
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(
data.fps is not None
or data.keepalive_interval is not None
or data.state_check_interval is not None
or data.min_brightness_threshold is not None
or data.adaptive_fps is not None
or data.key_colors_settings is not None
),
css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=(
data.brightness_value_source_id is not None or kc_brightness_vs_changed
),
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError as e:
logger.debug("Device update skipped for target %s: %s", target_id, e)
pass
fire_entity_event("output_target", "updated", target_id)
return _target_to_response(target)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to update target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
async def delete_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Delete a output target. Stops processing first if active."""
try:
# Stop processing if running
try:
await manager.stop_processing(target_id)
except ValueError as e:
logger.debug("Stop processing skipped for target %s (not running): %s", target_id, e)
pass
# Remove from manager
try:
manager.remove_target(target_id)
except (ValueError, RuntimeError) as e:
logger.debug("Remove target from manager skipped for %s: %s", target_id, e)
pass
# 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:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")