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

@@ -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()