Some checks failed
Lint & Test / test (push) Failing after 9s
Auto-fixed 138 unused imports and f-string issues. Manually fixed: ambiguous variable names (l→layer), availability-check imports using importlib.util.find_spec, unused Color import, ImagePool forward ref via TYPE_CHECKING, multi-statement semicolons, and E402 suppression.
348 lines
13 KiB
Python
348 lines
13 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.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,
|
|
)
|
|
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
|
|
|
|
# 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,
|
|
)
|
|
|
|
# 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(f"Failed to create target: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@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:
|
|
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:
|
|
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(f"Failed to update target: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@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:
|
|
pass
|
|
|
|
# Remove from manager
|
|
try:
|
|
manager.remove_target(target_id)
|
|
except (ValueError, RuntimeError):
|
|
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(f"Failed to delete target: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|