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:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
415
server/src/wled_controller/api/routes/picture_targets.py
Normal file
415
server/src/wled_controller/api/routes/picture_targets.py
Normal 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))
|
||||
Reference in New Issue
Block a user