Introduce Picture Targets to separate processing from devices

Add PictureTarget entity that bridges PictureSource to output device,
separating processing settings from device connection/calibration state.
This enables future target types (Art-Net, E1.31) and cleanly decouples
"what to stream" from "where to stream."

- Add PictureTarget/WledPictureTarget dataclasses and storage
- Split ProcessorManager into DeviceState (health) + TargetState (processing)
- Add /api/v1/picture-targets endpoints (CRUD, start/stop, settings, metrics)
- Simplify device API (remove processing/settings/metrics endpoints)
- Auto-migrate existing device settings to picture targets on first startup
- Add Targets tab to WebUI with target cards and editor modal
- Add en/ru locale keys for targets UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 15:27:41 +03:00
parent c3828e10fa
commit 55814a3c30
20 changed files with 1976 additions and 1489 deletions

View File

@@ -7,6 +7,7 @@ from .routes.devices import router as devices_router
from .routes.templates import router as templates_router
from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.picture_targets import router as picture_targets_router
router = APIRouter()
router.include_router(system_router)
@@ -14,5 +15,6 @@ router.include_router(devices_router)
router.include_router(templates_router)
router.include_router(postprocessing_router)
router.include_router(picture_sources_router)
router.include_router(picture_targets_router)
__all__ = ["router"]

View File

@@ -5,12 +5,14 @@ from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
# Global instances (initialized in main.py)
_device_store: DeviceStore | None = None
_template_store: TemplateStore | None = None
_pp_template_store: PostprocessingTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None
_processor_manager: ProcessorManager | None = None
@@ -42,6 +44,13 @@ def get_picture_source_store() -> PictureSourceStore:
return _picture_source_store
def get_picture_target_store() -> PictureTargetStore:
"""Get picture target store dependency."""
if _picture_target_store is None:
raise RuntimeError("Picture target store not initialized")
return _picture_target_store
def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency."""
if _processor_manager is None:
@@ -55,11 +64,14 @@ def init_dependencies(
processor_manager: ProcessorManager,
pp_template_store: PostprocessingTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager, _pp_template_store, _picture_source_store
global _device_store, _template_store, _processor_manager
global _pp_template_store, _picture_source_store, _picture_target_store
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
_pp_template_store = pp_template_store
_picture_source_store = picture_source_store
_picture_target_store = picture_target_store

View File

@@ -1,6 +1,4 @@
"""Device routes: CRUD, processing control, settings, brightness, calibration, metrics."""
from datetime import datetime
"""Device routes: CRUD, health state, brightness, calibration."""
import httpx
from fastapi import APIRouter, HTTPException, Depends
@@ -8,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_picture_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
@@ -17,17 +16,16 @@ from wled_controller.api.schemas.devices import (
DeviceCreate,
DeviceListResponse,
DeviceResponse,
DeviceStateResponse,
DeviceUpdate,
MetricsResponse,
ProcessingSettings as ProcessingSettingsSchema,
ProcessingState,
)
from wled_controller.core.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -35,6 +33,20 @@ logger = get_logger(__name__)
router = APIRouter()
def _device_to_response(device) -> DeviceResponse:
"""Convert a Device to DeviceResponse."""
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
updated_at=device.updated_at,
)
# ===== DEVICE MANAGEMENT ENDPOINTS =====
@router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201)
@@ -75,6 +87,8 @@ async def create_device(
status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity."
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=422,
@@ -88,37 +102,18 @@ async def create_device(
led_count=wled_led_count,
)
# Add to processor manager
# Register in processor manager for health monitoring
manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
)
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status="disconnected",
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
picture_source_id=device.picture_source_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
return _device_to_response(device)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create device: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -130,39 +125,9 @@ async def list_devices(
store: DeviceStore = Depends(get_device_store),
):
"""List all attached WLED devices."""
try:
devices = store.get_all_devices()
device_responses = [
DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status="disconnected",
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
picture_source_id=device.picture_source_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
for device in devices
]
return DeviceListResponse(devices=device_responses, count=len(device_responses))
except Exception as e:
logger.error(f"Failed to list devices: {e}")
raise HTTPException(status_code=500, detail=str(e))
devices = store.get_all_devices()
responses = [_device_to_response(d) for d in devices]
return DeviceListResponse(devices=responses, count=len(responses))
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
@@ -170,37 +135,12 @@ async def get_device(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get device details by ID."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Determine status
status = "connected" if manager.is_processing(device_id) else "disconnected"
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status=status,
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
picture_source_id=device.picture_source_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
return _device_to_response(device)
@router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
@@ -213,76 +153,24 @@ async def update_device(
):
"""Update device information."""
try:
# Check if stream changed and device is processing (for hot-swap)
old_device = store.get_device(device_id)
stream_changed = (
update_data.picture_source_id is not None
and update_data.picture_source_id != old_device.picture_source_id
)
was_processing = manager.is_processing(device_id)
# Update device
device = store.update_device(
device_id=device_id,
name=update_data.name,
url=update_data.url,
enabled=update_data.enabled,
picture_source_id=update_data.picture_source_id,
)
# Sync processor state when stream changed
if stream_changed:
if was_processing:
# Hot-swap: restart with new settings
logger.info(f"Hot-swapping stream for device {device_id}")
try:
await manager.stop_processing(device_id)
manager.remove_device(device_id)
manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
picture_source_id=device.picture_source_id,
)
await manager.start_processing(device_id)
logger.info(f"Successfully hot-swapped stream for device {device_id}")
except Exception as e:
logger.error(f"Error during stream hot-swap: {e}")
else:
# Not processing -- update processor state so next start uses new values
manager.remove_device(device_id)
manager.add_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
settings=device.settings,
calibration=device.calibration,
picture_source_id=device.picture_source_id,
)
# Sync connection info in processor manager
try:
manager.update_device_info(
device_id,
device_url=update_data.url,
led_count=None,
)
except ValueError:
pass
return DeviceResponse(
id=device.id,
name=device.name,
url=device.url,
led_count=device.led_count,
enabled=device.enabled,
status="disconnected",
settings=ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
picture_source_id=device.picture_source_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
return _device_to_response(device)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -296,22 +184,33 @@ async def delete_device(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Delete/detach a device."""
"""Delete/detach a device. Returns 409 if referenced by a target."""
try:
# Stop processing if running
if manager.is_processing(device_id):
await manager.stop_processing(device_id)
# Check if any target references this device
refs = target_store.get_targets_for_device(device_id)
if refs:
names = ", ".join(t.name for t in refs)
raise HTTPException(
status_code=409,
detail=f"Device is referenced by target(s): {names}. Delete the target(s) first."
)
# Remove from manager
manager.remove_device(device_id)
try:
manager.remove_device(device_id)
except (ValueError, RuntimeError):
pass
# Delete from storage
store.delete_device(device_id)
logger.info(f"Deleted device {device_id}")
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
@@ -319,169 +218,28 @@ async def delete_device(
raise HTTPException(status_code=500, detail=str(e))
# ===== PROCESSING CONTROL ENDPOINTS =====
# ===== DEVICE STATE (health only) =====
@router.post("/api/v1/devices/{device_id}/start", tags=["Processing"])
async def start_processing(
@router.get("/api/v1/devices/{device_id}/state", response_model=DeviceStateResponse, tags=["Devices"])
async def get_device_state(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start screen processing for a device."""
try:
# Verify device exists
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
await manager.start_processing(device_id)
logger.info(f"Started processing for device {device_id}")
return {"status": "started", "device_id": device_id}
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/devices/{device_id}/stop", tags=["Processing"])
async def stop_processing(
device_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop screen processing for a device."""
try:
await manager.stop_processing(device_id)
logger.info(f"Stopped processing for device {device_id}")
return {"status": "stopped", "device_id": device_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/devices/{device_id}/state", response_model=ProcessingState, tags=["Processing"])
async def get_processing_state(
device_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a device."""
try:
state = manager.get_state(device_id)
return ProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get state: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== SETTINGS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def get_settings(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Get processing settings for a device."""
"""Get device health/connection state."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval,
)
@router.put("/api/v1/devices/{device_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def update_settings(
device_id: str,
settings: ProcessingSettingsSchema,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update processing settings for a device.
Merges with existing settings so callers can send partial updates.
Only fields explicitly included in the request body are applied.
"""
try:
# Get existing device to merge settings
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
existing = device.settings
sent = settings.model_fields_set # fields the client actually sent
# Merge: only override fields the client explicitly provided
new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps if 'fps' in sent else existing.fps,
border_width=settings.border_width if 'border_width' in sent else existing.border_width,
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=existing.gamma,
saturation=existing.saturation,
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
)
# Apply color_correction fields if explicitly sent
if 'color_correction' in sent and settings.color_correction:
cc_sent = settings.color_correction.model_fields_set
if 'brightness' in cc_sent:
new_settings.brightness = settings.color_correction.brightness
if 'gamma' in cc_sent:
new_settings.gamma = settings.color_correction.gamma
if 'saturation' in cc_sent:
new_settings.saturation = settings.color_correction.saturation
# Update in storage
device = store.update_device(device_id, settings=new_settings)
# Update in manager if device exists
try:
manager.update_settings(device_id, new_settings)
except ValueError:
# Device not in manager yet, that's ok
pass
return ProcessingSettingsSchema(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval,
)
state = manager.get_device_health_dict(device_id)
return DeviceStateResponse(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== WLED BRIGHTNESS ENDPOINT =====
# ===== WLED BRIGHTNESS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def get_device_brightness(
@@ -568,11 +326,10 @@ async def update_calibration(
# Update in storage
device = store.update_device(device_id, calibration=calibration)
# Update in manager if device exists
# Update in manager (also updates active target's cached calibration)
try:
manager.update_calibration(device_id, calibration)
except ValueError:
# Device not in manager yet, that's ok
pass
return CalibrationSchema(**calibration_to_dict(device.calibration))
@@ -596,11 +353,7 @@ async def set_calibration_test_mode(
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Toggle calibration test mode for specific edges.
Send edges with colors to light them up, or empty edges dict to exit test mode.
While test mode is active, screen capture processing is paused.
"""
"""Toggle calibration test mode for specific edges."""
try:
device = store.get_device(device_id)
if not device:
@@ -641,23 +394,3 @@ async def set_calibration_test_mode(
except Exception as e:
logger.error(f"Failed to set test mode: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== METRICS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/metrics", response_model=MetricsResponse, tags=["Metrics"])
async def get_metrics(
device_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a device."""
try:
metrics = manager.get_metrics(device_id)
return MetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -14,6 +14,7 @@ from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_picture_source_store,
get_picture_target_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
@@ -36,6 +37,7 @@ from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -256,16 +258,16 @@ async def delete_picture_source(
stream_id: str,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
device_store: DeviceStore = Depends(get_device_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Delete a picture source."""
try:
# Check if any device references this stream
if store.is_referenced_by_device(stream_id, device_store):
# Check if any target references this stream
if store.is_referenced_by_target(stream_id, target_store):
raise HTTPException(
status_code=409,
detail="Cannot delete picture source: it is assigned to one or more devices. "
"Please reassign those devices before deleting.",
detail="Cannot delete picture source: it is assigned to one or more targets. "
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
except HTTPException:

View File

@@ -0,0 +1,415 @@
"""Picture target routes: CRUD, processing control, settings, state, metrics."""
from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_picture_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.picture_targets import (
PictureTargetCreate,
PictureTargetListResponse,
PictureTargetResponse,
PictureTargetUpdate,
ProcessingSettings as ProcessingSettingsSchema,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target import WledPictureTarget
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
"""Convert schema ProcessingSettings to core ProcessingSettings."""
settings = ProcessingSettings(
display_index=schema.display_index,
fps=schema.fps,
border_width=schema.border_width,
interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness,
smoothing=schema.smoothing,
state_check_interval=schema.state_check_interval,
)
if schema.color_correction:
settings.gamma = schema.color_correction.gamma
settings.saturation = schema.color_correction.saturation
# color_correction.brightness maps to settings.brightness
settings.brightness = schema.color_correction.brightness
return settings
def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema:
"""Convert core ProcessingSettings to schema ProcessingSettings."""
from wled_controller.api.schemas.picture_targets import ColorCorrection
return ProcessingSettingsSchema(
display_index=settings.display_index,
fps=settings.fps,
border_width=settings.border_width,
interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness,
smoothing=settings.smoothing,
state_check_interval=settings.state_check_interval,
color_correction=ColorCorrection(
gamma=settings.gamma,
saturation=settings.saturation,
brightness=settings.brightness,
),
)
def _target_to_response(target) -> PictureTargetResponse:
"""Convert a PictureTarget to PictureTargetResponse."""
settings_schema = _settings_to_schema(target.settings) if isinstance(target, WledPictureTarget) else ProcessingSettingsSchema()
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id if isinstance(target, WledPictureTarget) else "",
picture_source_id=target.picture_source_id if isinstance(target, WledPictureTarget) else "",
settings=settings_schema,
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
# ===== CRUD ENDPOINTS =====
@router.post("/api/v1/picture-targets", response_model=PictureTargetResponse, tags=["Targets"], status_code=201)
async def create_target(
data: PictureTargetCreate,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Create a new picture target."""
try:
# Validate device exists if provided
if data.device_id:
device = device_store.get_device(data.device_id)
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
# Create in store
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
picture_source_id=data.picture_source_id,
settings=core_settings,
description=data.description,
)
# Register in processor manager
if isinstance(target, WledPictureTarget) and target.device_id:
try:
manager.add_target(
target_id=target.id,
device_id=target.device_id,
settings=target.settings,
picture_source_id=target.picture_source_id,
)
except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
return _target_to_response(target)
except HTTPException:
raise
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/picture-targets", response_model=PictureTargetListResponse, tags=["Targets"])
async def list_targets(
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""List all picture targets."""
targets = target_store.get_all_targets()
responses = [_target_to_response(t) for t in targets]
return PictureTargetListResponse(targets=responses, count=len(responses))
@router.get("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
async def get_target(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Get a picture 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/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
async def update_target(
target_id: str,
data: PictureTargetUpdate,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update a picture target."""
try:
# Validate device exists if changing
if data.device_id is not None and data.device_id:
device = device_store.get_device(data.device_id)
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
picture_source_id=data.picture_source_id,
settings=core_settings,
description=data.description,
)
# Sync processor manager
if isinstance(target, WledPictureTarget):
try:
if data.settings is not None:
manager.update_target_settings(target_id, target.settings)
if data.picture_source_id is not None:
manager.update_target_source(target_id, target.picture_source_id)
if data.device_id is not None:
manager.update_target_device(target_id, target.device_id)
except ValueError:
# Target may not be registered in manager yet
pass
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/picture-targets/{target_id}", status_code=204, tags=["Targets"])
async def delete_target(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Delete a picture target. Stops processing first if active."""
try:
# Stop processing if running
try:
if manager.is_target_processing(target_id):
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)
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))
# ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/picture-targets/{target_id}/start", tags=["Processing"])
async def start_processing(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a picture target."""
try:
# Verify target exists
target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/picture-targets/{target_id}/stop", tags=["Processing"])
async def stop_processing(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a picture target."""
try:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/picture-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
async def get_target_state(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get current processing state for a target."""
try:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def get_target_settings(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Get processing settings for a target."""
try:
target = target_store.get_target(target_id)
if isinstance(target, WledPictureTarget):
return _settings_to_schema(target.settings)
return ProcessingSettingsSchema()
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def update_target_settings(
target_id: str,
settings: ProcessingSettingsSchema,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update processing settings for a target.
Merges with existing settings so callers can send partial updates.
"""
try:
target = target_store.get_target(target_id)
if not isinstance(target, WledPictureTarget):
raise HTTPException(status_code=400, detail="Target does not support processing settings")
existing = target.settings
sent = settings.model_fields_set
# Merge: only override fields the client explicitly provided
new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps if 'fps' in sent else existing.fps,
border_width=settings.border_width if 'border_width' in sent else existing.border_width,
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=existing.gamma,
saturation=existing.saturation,
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
)
# Apply color_correction fields if explicitly sent
if 'color_correction' in sent and settings.color_correction:
cc_sent = settings.color_correction.model_fields_set
if 'brightness' in cc_sent:
new_settings.brightness = settings.color_correction.brightness
if 'gamma' in cc_sent:
new_settings.gamma = settings.color_correction.gamma
if 'saturation' in cc_sent:
new_settings.saturation = settings.color_correction.saturation
# Update in store
target_store.update_target(target_id, settings=new_settings)
# Update in manager
try:
manager.update_target_settings(target_id, new_settings)
except ValueError:
pass
return _settings_to_schema(new_settings)
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 settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing metrics for a target."""
try:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -17,14 +17,21 @@ from .devices import (
Calibration,
CalibrationTestModeRequest,
CalibrationTestModeResponse,
ColorCorrection,
DeviceCreate,
DeviceListResponse,
DeviceResponse,
DeviceStateResponse,
DeviceUpdate,
MetricsResponse,
)
from .picture_targets import (
ColorCorrection,
PictureTargetCreate,
PictureTargetListResponse,
PictureTargetResponse,
PictureTargetUpdate,
ProcessingSettings,
ProcessingState,
TargetMetricsResponse,
TargetProcessingState,
)
from .templates import (
EngineInfo,
@@ -72,14 +79,19 @@ __all__ = [
"Calibration",
"CalibrationTestModeRequest",
"CalibrationTestModeResponse",
"ColorCorrection",
"DeviceCreate",
"DeviceListResponse",
"DeviceResponse",
"DeviceStateResponse",
"DeviceUpdate",
"MetricsResponse",
"ColorCorrection",
"PictureTargetCreate",
"PictureTargetListResponse",
"PictureTargetResponse",
"PictureTargetUpdate",
"ProcessingSettings",
"ProcessingState",
"TargetMetricsResponse",
"TargetProcessingState",
"EngineInfo",
"EngineListResponse",
"TemplateAssignment",

View File

@@ -1,12 +1,10 @@
"""Device-related schemas (CRUD, settings, calibration, processing state, metrics)."""
"""Device-related schemas (CRUD, calibration, device state)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
class DeviceCreate(BaseModel):
"""Request to create/attach a WLED device."""
@@ -21,34 +19,6 @@ class DeviceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="WLED device URL")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
class ColorCorrection(BaseModel):
"""Color correction settings."""
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
class ProcessingSettings(BaseModel):
"""Processing settings for a device."""
display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
description="Seconds between WLED health checks"
)
color_correction: Optional[ColorCorrection] = Field(
default_factory=ColorCorrection,
description="Color correction settings"
)
class Calibration(BaseModel):
@@ -109,12 +79,7 @@ class DeviceResponse(BaseModel):
url: str = Field(description="WLED device URL")
led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled")
status: Literal["connected", "disconnected", "error"] = Field(
description="Connection status"
)
settings: ProcessingSettings = Field(description="Processing settings")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
picture_source_id: str = Field(default="", description="ID of assigned picture source")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -126,16 +91,10 @@ class DeviceListResponse(BaseModel):
count: int = Field(description="Number of devices")
class ProcessingState(BaseModel):
"""Processing state for a device."""
class DeviceStateResponse(BaseModel):
"""Device health/connection state response."""
device_id: str = Field(description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_target: int = Field(description="Target FPS")
display_index: int = Field(description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name")
@@ -145,17 +104,5 @@ class ProcessingState(BaseModel):
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time")
wled_error: Optional[str] = Field(None, description="Last health check error")
class MetricsResponse(BaseModel):
"""Device metrics response."""
device_id: str = Field(description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")

View File

@@ -0,0 +1,114 @@
"""Picture target schemas (CRUD, processing state, settings, metrics)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
class ColorCorrection(BaseModel):
"""Color correction settings."""
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
class ProcessingSettings(BaseModel):
"""Processing settings for a picture target."""
display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
description="Seconds between WLED health checks"
)
color_correction: Optional[ColorCorrection] = Field(
default_factory=ColorCorrection,
description="Color correction settings"
)
class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="wled", description="Target type (wled)")
device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class PictureTargetUpdate(BaseModel):
"""Request to update a picture target."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
device_id: Optional[str] = Field(None, description="WLED device ID")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class PictureTargetResponse(BaseModel):
"""Picture target response."""
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
target_type: str = Field(description="Target type")
device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: ProcessingSettings = Field(description="Processing settings")
description: Optional[str] = Field(None, description="Description")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class PictureTargetListResponse(BaseModel):
"""List of picture targets response."""
targets: List[PictureTargetResponse] = Field(description="List of picture targets")
count: int = Field(description="Number of targets")
class TargetProcessingState(BaseModel):
"""Processing state for a picture target."""
target_id: str = Field(description="Target ID")
device_id: str = Field(description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_target: int = Field(description="Target FPS")
display_index: int = Field(description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name")
wled_version: Optional[str] = Field(None, description="WLED firmware version")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time")
wled_error: Optional[str] = Field(None, description="Last health check error")
class TargetMetricsResponse(BaseModel):
"""Target metrics response."""
target_id: str = Field(description="Target ID")
device_id: str = Field(description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")