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))
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}")
# Sync connection info in processor manager
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,
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
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
"""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")
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}
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 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."""
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,
)
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")

View File

@@ -57,6 +57,7 @@ class StorageConfig(BaseSettings):
templates_file: str = "data/capture_templates.json"
postprocessing_templates_file: str = "data/postprocessing_templates.json"
picture_sources_file: str = "data/picture_sources.json"
picture_targets_file: str = "data/picture_targets.json"
class LoggingConfig(BaseSettings):

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,13 @@ from wled_controller import __version__
from wled_controller.api import router
from wled_controller.api.dependencies import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
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
from wled_controller.storage.picture_target import WledPictureTarget
from wled_controller.utils import setup_logging, get_logger
# Initialize logging
@@ -32,6 +34,7 @@ device_store = DeviceStore(config.storage.devices_file)
template_store = TemplateStore(config.storage.templates_file)
pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file)
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
processor_manager = ProcessorManager(
picture_source_store=picture_source_store,
@@ -40,6 +43,63 @@ processor_manager = ProcessorManager(
)
def _migrate_devices_to_targets():
"""One-time migration: create picture targets from legacy device settings.
If the target store is empty and any device has legacy picture_source_id
or settings in raw JSON, migrate them to WledPictureTargets.
"""
if picture_target_store.count() > 0:
return # Already have targets, skip migration
raw = device_store.load_raw()
devices_raw = raw.get("devices", {})
if not devices_raw:
return
migrated = 0
for device_id, device_data in devices_raw.items():
legacy_source_id = device_data.get("picture_source_id", "")
legacy_settings = device_data.get("settings", {})
if not legacy_source_id and not legacy_settings:
continue
# Build ProcessingSettings from legacy data
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings = ProcessingSettings(
display_index=legacy_settings.get("display_index", 0),
fps=legacy_settings.get("fps", 30),
border_width=legacy_settings.get("border_width", 10),
brightness=legacy_settings.get("brightness", 1.0),
gamma=legacy_settings.get("gamma", 2.2),
saturation=legacy_settings.get("saturation", 1.0),
smoothing=legacy_settings.get("smoothing", 0.3),
interpolation_mode=legacy_settings.get("interpolation_mode", "average"),
state_check_interval=legacy_settings.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
device_name = device_data.get("name", device_id)
target_name = f"{device_name} Target"
try:
target = picture_target_store.create_target(
name=target_name,
target_type="wled",
device_id=device_id,
picture_source_id=legacy_source_id,
settings=settings,
description=f"Auto-migrated from device {device_name}",
)
migrated += 1
logger.info(f"Migrated device {device_id} -> target {target.id}")
except Exception as e:
logger.error(f"Failed to migrate device {device_id} to target: {e}")
if migrated > 0:
logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager.
@@ -69,14 +129,18 @@ async def lifespan(app: FastAPI):
logger.info(f"Authorized clients: {client_labels}")
logger.info("All API requests require valid Bearer token authentication")
# Run migration from legacy device settings to picture targets
_migrate_devices_to_targets()
# Initialize API dependencies
init_dependencies(
device_store, template_store, processor_manager,
pp_template_store=pp_template_store,
picture_source_store=picture_source_store,
picture_target_store=picture_target_store,
)
# Load existing devices into processor manager
# Register devices in processor manager for health monitoring
devices = device_store.get_all_devices()
for device in devices:
try:
@@ -84,15 +148,32 @@ async def lifespan(app: FastAPI):
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,
)
logger.info(f"Loaded device: {device.name} ({device.id})")
logger.info(f"Registered device: {device.name} ({device.id})")
except Exception as e:
logger.error(f"Failed to load device {device.id}: {e}")
logger.error(f"Failed to register device {device.id}: {e}")
logger.info(f"Loaded {len(devices)} devices from storage")
logger.info(f"Registered {len(devices)} devices for health monitoring")
# Register picture targets in processor manager
targets = picture_target_store.get_all_targets()
registered_targets = 0
for target in targets:
if isinstance(target, WledPictureTarget) and target.device_id:
try:
processor_manager.add_target(
target_id=target.id,
device_id=target.device_id,
settings=target.settings,
picture_source_id=target.picture_source_id,
)
registered_targets += 1
logger.info(f"Registered target: {target.name} ({target.id})")
except Exception as e:
logger.error(f"Failed to register target {target.id}: {e}")
logger.info(f"Registered {registered_targets} picture target(s)")
# Start background health monitoring for all devices
await processor_manager.start_health_monitoring()

File diff suppressed because it is too large Load Diff

View File

@@ -35,8 +35,8 @@
<div class="tabs">
<div class="tab-bar">
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
</div>
<div class="tab-panel active" id="tab-devices">
@@ -45,6 +45,11 @@
</div>
</div>
<div class="tab-panel" id="tab-targets">
<div id="targets-list" class="devices-grid">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-streams">
<div id="streams-list">
@@ -228,67 +233,80 @@
</div>
</div>
<!-- Stream Settings Modal (picture source + LED projection settings) -->
<div id="stream-selector-modal" class="modal">
<!-- Target Editor Modal (name, device, source, settings) -->
<div id="target-editor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="device.stream_settings.title">📺 Source Settings</h2>
<button class="modal-close-btn" onclick="closeStreamSelectorModal()" title="Close">&#x2715;</button>
<h2 id="target-editor-title" data-i18n="targets.add">🎯 Add Target</h2>
<button class="modal-close-btn" onclick="closeTargetEditorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="stream-selector-form">
<input type="hidden" id="stream-selector-device-id">
<form id="target-editor-form">
<input type="hidden" id="target-editor-id">
<div class="form-group">
<div class="label-row">
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_selector.hint">Select a source that defines what this device captures and processes</small>
<select id="stream-selector-stream"></select>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<label for="target-editor-name" data-i18n="targets.name">Target Name:</label>
<input type="text" id="target-editor-name" data-i18n-placeholder="targets.name.placeholder" placeholder="My Target" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
<label for="target-editor-device" data-i18n="targets.device">WLED Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the WLED device to stream to</small>
<select id="target-editor-device"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
<label for="target-editor-source" data-i18n="targets.source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
<select id="stream-selector-interpolation">
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
<small class="input-hint" style="display:none" data-i18n="targets.source.hint">Select a source that defines what to capture</small>
<select id="target-editor-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-border-width" data-i18n="targets.border_width">Border Width (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.border_width.hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
<input type="number" id="target-editor-border-width" min="1" max="100" value="10">
</div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.interpolation.hint">How to calculate LED color from sampled pixels</small>
<select id="target-editor-interpolation">
<option value="average">Average</option>
<option value="median">Median</option>
<option value="dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-selector-smoothing">
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
<span id="stream-selector-smoothing-value">0.3</span>
<label for="target-editor-smoothing">
<span data-i18n="targets.smoothing">Smoothing:</span>
<span id="target-editor-smoothing-value">0.3</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
<small class="input-hint" style="display:none" data-i18n="targets.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value">
</div>
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
<div id="target-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeStreamSelectorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveStreamSelector()" title="Save">&#x2713;</button>
<button class="btn btn-icon btn-secondary" onclick="closeTargetEditorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveTargetEditor()" title="Save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -298,5 +298,45 @@
"streams.image_source.hint": "Enter a URL (http/https) or local file path to an image",
"streams.validate_image.validating": "Validating...",
"streams.validate_image.valid": "Image accessible",
"streams.validate_image.invalid": "Image not accessible"
"streams.validate_image.invalid": "Image not accessible",
"targets.title": "⚡ Targets",
"targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.",
"targets.add": "Add Target",
"targets.edit": "Edit Target",
"targets.loading": "Loading targets...",
"targets.none": "No targets configured",
"targets.failed": "Failed to load targets",
"targets.name": "Target Name:",
"targets.name.placeholder": "My Target",
"targets.device": "Device:",
"targets.device.hint": "Which WLED device to send LED data to",
"targets.device.none": "-- Select a device --",
"targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --",
"targets.border_width": "Border Width (px):",
"targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"targets.interpolation": "Interpolation Mode:",
"targets.interpolation.hint": "How to calculate LED color from sampled pixels",
"targets.interpolation.average": "Average",
"targets.interpolation.median": "Median",
"targets.interpolation.dominant": "Dominant",
"targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"targets.created": "Target created successfully",
"targets.updated": "Target updated successfully",
"targets.deleted": "Target deleted successfully",
"targets.delete.confirm": "Are you sure you want to delete this target?",
"targets.error.load": "Failed to load targets",
"targets.error.required": "Please fill in all required fields",
"targets.error.delete": "Failed to delete target",
"targets.button.start": "Start",
"targets.button.stop": "Stop",
"targets.status.processing": "Processing",
"targets.status.idle": "Idle",
"targets.status.error": "Error",
"targets.metrics.actual_fps": "Actual FPS",
"targets.metrics.target_fps": "Target FPS",
"targets.metrics.frames": "Frames",
"targets.metrics.errors": "Errors"
}

View File

@@ -298,5 +298,45 @@
"streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению",
"streams.validate_image.validating": "Проверка...",
"streams.validate_image.valid": "Изображение доступно",
"streams.validate_image.invalid": "Изображение недоступно"
"streams.validate_image.invalid": "Изображение недоступно",
"targets.title": "⚡ Цели",
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
"targets.add": "Добавить Цель",
"targets.edit": "Редактировать Цель",
"targets.loading": "Загрузка целей...",
"targets.none": "Цели не настроены",
"targets.failed": "Не удалось загрузить цели",
"targets.name": "Имя Цели:",
"targets.name.placeholder": "Моя Цель",
"targets.device": "Устройство:",
"targets.device.hint": "На какое WLED устройство отправлять данные LED",
"targets.device.none": "-- Выберите устройство --",
"targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --",
"targets.border_width": "Ширина границы (px):",
"targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"targets.interpolation": "Режим интерполяции:",
"targets.interpolation.hint": "Как вычислять цвет LED из выбранных пикселей",
"targets.interpolation.average": "Среднее",
"targets.interpolation.median": "Медиана",
"targets.interpolation.dominant": "Доминантный",
"targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.created": "Цель успешно создана",
"targets.updated": "Цель успешно обновлена",
"targets.deleted": "Цель успешно удалена",
"targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?",
"targets.error.load": "Не удалось загрузить цели",
"targets.error.required": "Пожалуйста, заполните все обязательные поля",
"targets.error.delete": "Не удалось удалить цель",
"targets.button.start": "Запустить",
"targets.button.stop": "Остановить",
"targets.status.processing": "Обработка",
"targets.status.idle": "Ожидание",
"targets.status.error": "Ошибка",
"targets.metrics.actual_fps": "Факт. FPS",
"targets.metrics.target_fps": "Целев. FPS",
"targets.metrics.frames": "Кадры",
"targets.metrics.errors": "Ошибки"
}

View File

@@ -387,9 +387,11 @@ section {
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
align-items: center;
}
.btn {
@@ -2534,9 +2536,7 @@ input:-webkit-autofill:focus {
}
.stream-card-prop {
display: inline-flex;
align-items: center;
gap: 3px;
display: inline-block;
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--border-color);
@@ -2546,6 +2546,7 @@ input:-webkit-autofill:focus {
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
vertical-align: middle;
}
.stream-card-prop-full {

View File

@@ -12,14 +12,17 @@ from wled_controller.core.calibration import (
calibration_to_dict,
create_default_calibration,
)
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL, ProcessingSettings
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class Device:
"""Represents a WLED device configuration."""
"""Represents a WLED device configuration.
A device is a holder of connection state and calibration options.
Processing settings and picture source assignments live on PictureTargets.
"""
def __init__(
self,
@@ -28,62 +31,28 @@ class Device:
url: str,
led_count: int,
enabled: bool = True,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
picture_source_id: str = "",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
"""Initialize device.
Args:
device_id: Unique device identifier
name: Device name
url: WLED device URL
led_count: Number of LEDs
enabled: Whether device is enabled
settings: Processing settings
calibration: Calibration configuration
picture_source_id: ID of assigned picture source
created_at: Creation timestamp
updated_at: Last update timestamp
"""
self.id = device_id
self.name = name
self.url = url
self.led_count = led_count
self.enabled = enabled
self.settings = settings or ProcessingSettings()
self.calibration = calibration or create_default_calibration(led_count)
self.picture_source_id = picture_source_id
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
def to_dict(self) -> dict:
"""Convert device to dictionary.
Returns:
Dictionary representation
"""
"""Convert device to dictionary."""
return {
"id": self.id,
"name": self.name,
"url": self.url,
"led_count": self.led_count,
"enabled": self.enabled,
"settings": {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
},
"calibration": calibration_to_dict(self.calibration),
"picture_source_id": self.picture_source_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -92,25 +61,9 @@ class Device:
def from_dict(cls, data: dict) -> "Device":
"""Create device from dictionary.
Args:
data: Dictionary with device data
Returns:
Device instance
Backward-compatible: ignores legacy 'settings' and 'picture_source_id'
fields that have been migrated to PictureTarget.
"""
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
calibration_data = data.get("calibration")
calibration = (
calibration_from_dict(calibration_data)
@@ -118,17 +71,13 @@ class Device:
else create_default_calibration(data["led_count"])
)
picture_source_id = data.get("picture_source_id", "")
return cls(
device_id=data["id"],
name=data["name"],
url=data["url"],
led_count=data["led_count"],
enabled=data.get("enabled", True),
settings=settings,
calibration=calibration,
picture_source_id=picture_source_id,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
@@ -138,11 +87,6 @@ class DeviceStore:
"""Persistent storage for WLED devices."""
def __init__(self, storage_file: str | Path):
"""Initialize device store.
Args:
storage_file: Path to JSON storage file
"""
self.storage_file = Path(storage_file)
self._devices: Dict[str, Device] = {}
@@ -179,6 +123,16 @@ class DeviceStore:
logger.error(f"Failed to load devices: {e}")
raise
def load_raw(self) -> dict:
"""Load raw JSON data from storage (for migration)."""
if not self.storage_file.exists():
return {}
try:
with open(self.storage_file, "r") as f:
return json.load(f)
except Exception:
return {}
def save(self):
"""Save devices to storage file."""
try:
@@ -189,12 +143,10 @@ class DeviceStore:
}
}
# Write to temporary file first
temp_file = self.storage_file.with_suffix(".tmp")
with open(temp_file, "w") as f:
json.dump(data, f, indent=2)
# Atomic rename
temp_file.replace(self.storage_file)
logger.debug(f"Saved {len(self._devices)} devices to storage")
@@ -208,41 +160,19 @@ class DeviceStore:
name: str,
url: str,
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
picture_source_id: str = "",
) -> Device:
"""Create a new device.
Args:
name: Device name
url: WLED device URL
led_count: Number of LEDs
settings: Processing settings
calibration: Calibration configuration
picture_source_id: ID of assigned picture source
Returns:
Created device
Raises:
ValueError: If validation fails
"""
# Generate unique ID
"""Create a new device."""
device_id = f"device_{uuid.uuid4().hex[:8]}"
# Create device
device = Device(
device_id=device_id,
name=name,
url=url,
led_count=led_count,
settings=settings,
calibration=calibration,
picture_source_id=picture_source_id,
)
# Store
self._devices[device_id] = device
self.save()
@@ -250,22 +180,11 @@ class DeviceStore:
return device
def get_device(self, device_id: str) -> Optional[Device]:
"""Get device by ID.
Args:
device_id: Device identifier
Returns:
Device or None if not found
"""
"""Get device by ID."""
return self._devices.get(device_id)
def get_all_devices(self) -> List[Device]:
"""Get all devices.
Returns:
List of all devices
"""
"""Get all devices."""
return list(self._devices.values())
def update_device(
@@ -275,73 +194,38 @@ class DeviceStore:
url: Optional[str] = None,
led_count: Optional[int] = None,
enabled: Optional[bool] = None,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
picture_source_id: Optional[str] = None,
) -> Device:
"""Update device.
Args:
device_id: Device identifier
name: New name (optional)
url: New URL (optional)
led_count: New LED count (optional)
enabled: New enabled state (optional)
settings: New settings (optional)
calibration: New calibration (optional)
picture_source_id: New picture source ID (optional)
Returns:
Updated device
Raises:
ValueError: If device not found or validation fails
"""
"""Update device."""
device = self._devices.get(device_id)
if not device:
raise ValueError(f"Device {device_id} not found")
# Update fields
if name is not None:
device.name = name
if url is not None:
device.url = url
if led_count is not None:
device.led_count = led_count
# Reset calibration if LED count changed
device.calibration = create_default_calibration(led_count)
if enabled is not None:
device.enabled = enabled
if settings is not None:
device.settings = settings
if calibration is not None:
# Validate LED count matches
if calibration.get_total_leds() != device.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({device.led_count})"
)
device.calibration = calibration
if picture_source_id is not None:
device.picture_source_id = picture_source_id
device.updated_at = datetime.utcnow()
# Save
self.save()
logger.info(f"Updated device {device_id}")
return device
def delete_device(self, device_id: str):
"""Delete device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
"""
"""Delete device."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
@@ -351,22 +235,11 @@ class DeviceStore:
logger.info(f"Deleted device {device_id}")
def device_exists(self, device_id: str) -> bool:
"""Check if device exists.
Args:
device_id: Device identifier
Returns:
True if device exists
"""
"""Check if device exists."""
return device_id in self._devices
def count(self) -> int:
"""Get number of devices.
Returns:
Device count
"""
"""Get number of devices."""
return len(self._devices)
def clear(self):

View File

@@ -301,20 +301,17 @@ class PictureSourceStore:
logger.info(f"Deleted picture source: {stream_id}")
def is_referenced_by_device(self, stream_id: str, device_store) -> bool:
"""Check if this stream is referenced by any device.
def is_referenced_by_target(self, stream_id: str, target_store) -> bool:
"""Check if this stream is referenced by any picture target.
Args:
stream_id: Stream ID to check
device_store: DeviceStore instance
target_store: PictureTargetStore instance
Returns:
True if any device references this stream
True if any target references this stream
"""
for device in device_store.get_all_devices():
if getattr(device, "picture_source_id", None) == stream_id:
return True
return False
return target_store.is_referenced_by_source(stream_id)
def resolve_stream_chain(self, stream_id: str) -> dict:
"""Resolve a stream chain to get the terminal stream and collected postprocessing templates.

View File

@@ -0,0 +1,96 @@
"""Picture target data models."""
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
@dataclass
class PictureTarget:
"""Base class for picture targets."""
id: str
name: str
target_type: str # "wled" (future: "artnet", "e131", ...)
created_at: datetime
updated_at: datetime
description: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"id": self.id,
"name": self.name,
"target_type": self.target_type,
"description": self.description,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict) -> "PictureTarget":
"""Create from dictionary, dispatching to the correct subclass."""
target_type = data.get("target_type", "wled")
if target_type == "wled":
return WledPictureTarget.from_dict(data)
raise ValueError(f"Unknown target type: {target_type}")
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -0,0 +1,220 @@
"""Picture target storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget, WledPictureTarget
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class PictureTargetStore:
"""Persistent storage for picture targets."""
def __init__(self, file_path: str):
"""Initialize picture target store.
Args:
file_path: Path to targets JSON file
"""
self.file_path = Path(file_path)
self._targets: Dict[str, PictureTarget] = {}
self._load()
def _load(self) -> None:
"""Load targets from file."""
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
targets_data = data.get("picture_targets", {})
loaded = 0
for target_id, target_dict in targets_data.items():
try:
target = PictureTarget.from_dict(target_dict)
self._targets[target_id] = target
loaded += 1
except Exception as e:
logger.error(f"Failed to load picture target {target_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} picture targets from storage")
except Exception as e:
logger.error(f"Failed to load picture targets from {self.file_path}: {e}")
raise
logger.info(f"Picture target store initialized with {len(self._targets)} targets")
def _save(self) -> None:
"""Save all targets to file."""
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
targets_dict = {
target_id: target.to_dict()
for target_id, target in self._targets.items()
}
data = {
"version": "1.0.0",
"picture_targets": targets_dict,
}
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Failed to save picture targets to {self.file_path}: {e}")
raise
def get_all_targets(self) -> List[PictureTarget]:
"""Get all picture targets."""
return list(self._targets.values())
def get_target(self, target_id: str) -> PictureTarget:
"""Get target by ID.
Raises:
ValueError: If target not found
"""
if target_id not in self._targets:
raise ValueError(f"Picture target not found: {target_id}")
return self._targets[target_id]
def create_target(
self,
name: str,
target_type: str,
device_id: str = "",
picture_source_id: str = "",
settings: Optional[ProcessingSettings] = None,
description: Optional[str] = None,
) -> PictureTarget:
"""Create a new picture target.
Args:
name: Target name
target_type: Target type ("wled")
device_id: WLED device ID (for wled targets)
picture_source_id: Picture source ID
settings: Processing settings
description: Optional description
Raises:
ValueError: If validation fails
"""
if target_type not in ("wled",):
raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name
for target in self._targets.values():
if target.name == name:
raise ValueError(f"Picture target with name '{name}' already exists")
target_id = f"pt_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
if target_type == "wled":
target: PictureTarget = WledPictureTarget(
id=target_id,
name=name,
target_type="wled",
device_id=device_id,
picture_source_id=picture_source_id,
settings=settings or ProcessingSettings(),
description=description,
created_at=now,
updated_at=now,
)
else:
raise ValueError(f"Unknown target type: {target_type}")
self._targets[target_id] = target
self._save()
logger.info(f"Created picture target: {name} ({target_id}, type={target_type})")
return target
def update_target(
self,
target_id: str,
name: Optional[str] = None,
device_id: Optional[str] = None,
picture_source_id: Optional[str] = None,
settings: Optional[ProcessingSettings] = None,
description: Optional[str] = None,
) -> PictureTarget:
"""Update a picture target.
Raises:
ValueError: If target not found or validation fails
"""
if target_id not in self._targets:
raise ValueError(f"Picture target not found: {target_id}")
target = self._targets[target_id]
if name is not None:
# Check for duplicate name (exclude self)
for other in self._targets.values():
if other.id != target_id and other.name == name:
raise ValueError(f"Picture target with name '{name}' already exists")
target.name = name
if description is not None:
target.description = description
if isinstance(target, WledPictureTarget):
if device_id is not None:
target.device_id = device_id
if picture_source_id is not None:
target.picture_source_id = picture_source_id
if settings is not None:
target.settings = settings
target.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated picture target: {target_id}")
return target
def delete_target(self, target_id: str) -> None:
"""Delete a picture target.
Raises:
ValueError: If target not found
"""
if target_id not in self._targets:
raise ValueError(f"Picture target not found: {target_id}")
del self._targets[target_id]
self._save()
logger.info(f"Deleted picture target: {target_id}")
def get_targets_for_device(self, device_id: str) -> List[PictureTarget]:
"""Get all targets that reference a specific device."""
return [
t for t in self._targets.values()
if isinstance(t, WledPictureTarget) and t.device_id == device_id
]
def is_referenced_by_source(self, source_id: str) -> bool:
"""Check if any target references a picture source."""
for target in self._targets.values():
if isinstance(target, WledPictureTarget) and target.picture_source_id == source_id:
return True
return False
def count(self) -> int:
"""Get number of targets."""
return len(self._targets)