Introduce ColorStripSource as first-class entity
Extracts color processing and calibration out of WledPictureTarget into a new PictureColorStripSource entity, enabling multiple LED targets to share one capture/processing pipeline. New entities & processing: - storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models - storage/color_strip_store.py: JSON-backed CRUD store (prefix css_) - core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread) - core/processing/color_strip_stream_manager.py: ref-counted shared stream manager Modified storage/processing: - WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval - Device model: calibration field removed - WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline - ProcessorManager: wires ColorStripStreamManager into TargetContext API layer: - New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test - Removed calibration endpoints from /devices - Updated /picture-targets CRUD for new target structure Frontend: - New color-strips.js module with CSS editor modal and card rendering - Calibration modal extended with CSS mode (css-id hidden field + device picker) - targets.js: Color Strip Sources section added to LED tab; target editor/card updated - app.js: imports and window globals for CSS + showCSSCalibration - en.json / ru.json: color_strip.* and targets.section.color_strips keys added Data migration runs at startup: existing WledPictureTargets are converted to reference a new PictureColorStripSource created from their old settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router
|
||||
from .routes.picture_sources import router as picture_sources_router
|
||||
from .routes.pattern_templates import router as pattern_templates_router
|
||||
from .routes.picture_targets import router as picture_targets_router
|
||||
from .routes.color_strip_sources import router as color_strip_sources_router
|
||||
from .routes.profiles import router as profiles_router
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,6 +19,7 @@ router.include_router(templates_router)
|
||||
router.include_router(postprocessing_router)
|
||||
router.include_router(pattern_templates_router)
|
||||
router.include_router(picture_sources_router)
|
||||
router.include_router(color_strip_sources_router)
|
||||
router.include_router(picture_targets_router)
|
||||
router.include_router(profiles_router)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.profile_store import ProfileStore
|
||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||
|
||||
@@ -17,6 +18,7 @@ _pp_template_store: PostprocessingTemplateStore | None = None
|
||||
_pattern_template_store: PatternTemplateStore | None = None
|
||||
_picture_source_store: PictureSourceStore | None = None
|
||||
_picture_target_store: PictureTargetStore | None = None
|
||||
_color_strip_store: ColorStripStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
_profile_store: ProfileStore | None = None
|
||||
_profile_engine: ProfileEngine | None = None
|
||||
@@ -64,6 +66,13 @@ def get_picture_target_store() -> PictureTargetStore:
|
||||
return _picture_target_store
|
||||
|
||||
|
||||
def get_color_strip_store() -> ColorStripStore:
|
||||
"""Get color strip store dependency."""
|
||||
if _color_strip_store is None:
|
||||
raise RuntimeError("Color strip store not initialized")
|
||||
return _color_strip_store
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
@@ -93,13 +102,14 @@ def init_dependencies(
|
||||
pattern_template_store: PatternTemplateStore | None = None,
|
||||
picture_source_store: PictureSourceStore | None = None,
|
||||
picture_target_store: PictureTargetStore | None = None,
|
||||
color_strip_store: ColorStripStore | None = None,
|
||||
profile_store: ProfileStore | None = None,
|
||||
profile_engine: ProfileEngine | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||
global _profile_store, _profile_engine
|
||||
global _color_strip_store, _profile_store, _profile_engine
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
@@ -107,5 +117,6 @@ def init_dependencies(
|
||||
_pattern_template_store = pattern_template_store
|
||||
_picture_source_store = picture_source_store
|
||||
_picture_target_store = picture_target_store
|
||||
_color_strip_store = color_strip_store
|
||||
_profile_store = profile_store
|
||||
_profile_engine = profile_engine
|
||||
|
||||
258
server/src/wled_controller/api/routes/color_strip_sources.py
Normal file
258
server/src/wled_controller/api/routes/color_strip_sources.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Color strip source routes: CRUD and calibration test."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_color_strip_store,
|
||||
get_picture_target_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.color_strip_sources import (
|
||||
ColorStripSourceCreate,
|
||||
ColorStripSourceListResponse,
|
||||
ColorStripSourceResponse,
|
||||
ColorStripSourceUpdate,
|
||||
CSSCalibrationTestRequest,
|
||||
)
|
||||
from wled_controller.api.schemas.devices import (
|
||||
Calibration as CalibrationSchema,
|
||||
CalibrationTestModeResponse,
|
||||
)
|
||||
from wled_controller.core.capture.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _css_to_response(source) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
|
||||
calibration = None
|
||||
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
|
||||
|
||||
return ColorStripSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
source_type=source.source_type,
|
||||
picture_source_id=getattr(source, "picture_source_id", None),
|
||||
fps=getattr(source, "fps", None),
|
||||
brightness=getattr(source, "brightness", None),
|
||||
saturation=getattr(source, "saturation", None),
|
||||
gamma=getattr(source, "gamma", None),
|
||||
smoothing=getattr(source, "smoothing", None),
|
||||
interpolation_mode=getattr(source, "interpolation_mode", None),
|
||||
calibration=calibration,
|
||||
description=source.description,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ===== CRUD ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/color-strip-sources", response_model=ColorStripSourceListResponse, tags=["Color Strip Sources"])
|
||||
async def list_color_strip_sources(
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""List all color strip sources."""
|
||||
sources = store.get_all_sources()
|
||||
responses = [_css_to_response(s) for s in sources]
|
||||
return ColorStripSourceListResponse(sources=responses, count=len(responses))
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
|
||||
async def create_color_strip_source(
|
||||
data: ColorStripSourceCreate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Create a new color strip source."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
picture_source_id=data.picture_source_id,
|
||||
fps=data.fps,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
smoothing=data.smoothing,
|
||||
interpolation_mode=data.interpolation_mode,
|
||||
calibration=calibration,
|
||||
description=data.description,
|
||||
)
|
||||
return _css_to_response(source)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create color strip source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
|
||||
async def get_color_strip_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Get a color strip source by ID."""
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
return _css_to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
|
||||
async def update_color_strip_source(
|
||||
source_id: str,
|
||||
data: ColorStripSourceUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update a color strip source and hot-reload any running streams."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
picture_source_id=data.picture_source_id,
|
||||
fps=data.fps,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
smoothing=data.smoothing,
|
||||
interpolation_mode=data.interpolation_mode,
|
||||
calibration=calibration,
|
||||
description=data.description,
|
||||
)
|
||||
|
||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||
try:
|
||||
manager._color_strip_stream_manager.update_source(source_id, source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
|
||||
|
||||
return _css_to_response(source)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update color strip source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
|
||||
async def delete_color_strip_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||
):
|
||||
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
||||
try:
|
||||
if target_store.is_referenced_by_color_strip_source(source_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Color strip source is referenced by one or more LED targets. "
|
||||
"Delete or reassign the targets first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete color strip source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== CALIBRATION TEST =====
|
||||
|
||||
@router.put(
|
||||
"/api/v1/color-strip-sources/{source_id}/calibration/test",
|
||||
response_model=CalibrationTestModeResponse,
|
||||
tags=["Color Strip Sources"],
|
||||
)
|
||||
async def test_css_calibration(
|
||||
source_id: str,
|
||||
body: CSSCalibrationTestRequest,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripStore = Depends(get_color_strip_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Temporarily light up LED edges to verify calibration.
|
||||
|
||||
Pass a device_id and an edges dict with RGB colors.
|
||||
Send an empty edges dict to exit test mode.
|
||||
"""
|
||||
try:
|
||||
# Validate device exists in manager
|
||||
if body.device_id not in manager._devices:
|
||||
raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found")
|
||||
|
||||
# Validate edge names and colors
|
||||
valid_edges = {"top", "right", "bottom", "left"}
|
||||
for edge_name, color in body.edges.items():
|
||||
if edge_name not in valid_edges:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}"
|
||||
)
|
||||
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.",
|
||||
)
|
||||
|
||||
# Get CSS calibration to send the right pixel pattern
|
||||
calibration = None
|
||||
if body.edges:
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||
calibration = source.calibration
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await manager.set_test_mode(body.device_id, body.edges, calibration)
|
||||
|
||||
active_edges = list(body.edges.keys())
|
||||
logger.info(
|
||||
f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} "
|
||||
f"for device {body.device_id} via CSS {source_id}: {active_edges}"
|
||||
)
|
||||
|
||||
return CalibrationTestModeResponse(
|
||||
test_mode=len(active_edges) > 0,
|
||||
active_edges=active_edges,
|
||||
device_id=body.device_id,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set CSS calibration test mode: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -15,9 +15,6 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.devices import (
|
||||
Calibration as CalibrationSchema,
|
||||
CalibrationTestModeRequest,
|
||||
CalibrationTestModeResponse,
|
||||
DeviceCreate,
|
||||
DeviceListResponse,
|
||||
DeviceResponse,
|
||||
@@ -27,10 +24,6 @@ from wled_controller.api.schemas.devices import (
|
||||
DiscoverDevicesResponse,
|
||||
StaticColorUpdate,
|
||||
)
|
||||
from wled_controller.core.capture.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
@@ -54,7 +47,6 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
auto_shutdown=device.auto_shutdown,
|
||||
static_color=list(device.static_color) if device.static_color else None,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -133,7 +125,6 @@ async def create_device(
|
||||
device_id=device.id,
|
||||
device_url=device.url,
|
||||
led_count=device.led_count,
|
||||
calibration=device.calibration,
|
||||
device_type=device.device_type,
|
||||
baud_rate=device.baud_rate,
|
||||
auto_shutdown=device.auto_shutdown,
|
||||
@@ -534,105 +525,3 @@ async def set_device_color(
|
||||
|
||||
return {"color": list(color) if color else None}
|
||||
|
||||
|
||||
# ===== CALIBRATION ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
||||
async def get_calibration(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get calibration configuration 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 CalibrationSchema(**calibration_to_dict(device.calibration))
|
||||
|
||||
|
||||
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
||||
async def update_calibration(
|
||||
device_id: str,
|
||||
calibration_data: CalibrationSchema,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update calibration configuration for a device."""
|
||||
try:
|
||||
# Convert schema to CalibrationConfig
|
||||
calibration_dict = calibration_data.model_dump()
|
||||
calibration = calibration_from_dict(calibration_dict)
|
||||
|
||||
# Update in storage
|
||||
device = store.update_device(device_id, calibration=calibration)
|
||||
|
||||
# Update in manager (also updates active target's cached calibration)
|
||||
try:
|
||||
manager.update_calibration(device_id, calibration)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update calibration: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/devices/{device_id}/calibration/test",
|
||||
response_model=CalibrationTestModeResponse,
|
||||
tags=["Calibration"],
|
||||
)
|
||||
async def set_calibration_test_mode(
|
||||
device_id: str,
|
||||
body: CalibrationTestModeRequest,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Toggle calibration test mode for specific edges."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
# Validate edge names and colors
|
||||
valid_edges = {"top", "right", "bottom", "left"}
|
||||
for edge_name, color in body.edges.items():
|
||||
if edge_name not in valid_edges:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}"
|
||||
)
|
||||
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255."
|
||||
)
|
||||
|
||||
await manager.set_test_mode(device_id, body.edges)
|
||||
|
||||
active_edges = list(body.edges.keys())
|
||||
logger.info(
|
||||
f"Test mode {'activated' if active_edges else 'deactivated'} "
|
||||
f"for device {device_id}: {active_edges}"
|
||||
)
|
||||
|
||||
return CalibrationTestModeResponse(
|
||||
test_mode=len(active_edges) > 0,
|
||||
active_edges=active_edges,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set test mode: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -29,7 +29,6 @@ from wled_controller.api.schemas.picture_targets import (
|
||||
PictureTargetListResponse,
|
||||
PictureTargetResponse,
|
||||
PictureTargetUpdate,
|
||||
ProcessingSettings as ProcessingSettingsSchema,
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
)
|
||||
@@ -37,7 +36,6 @@ from wled_controller.config import get_config
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
calculate_average_color,
|
||||
calculate_dominant_color,
|
||||
@@ -61,32 +59,6 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
|
||||
"""Convert schema ProcessingSettings to core ProcessingSettings."""
|
||||
return ProcessingSettings(
|
||||
display_index=schema.display_index,
|
||||
fps=schema.fps,
|
||||
interpolation_mode=schema.interpolation_mode,
|
||||
brightness=schema.brightness,
|
||||
smoothing=schema.smoothing,
|
||||
standby_interval=schema.standby_interval,
|
||||
state_check_interval=schema.state_check_interval,
|
||||
)
|
||||
|
||||
|
||||
def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema:
|
||||
"""Convert core ProcessingSettings to schema ProcessingSettings."""
|
||||
return ProcessingSettingsSchema(
|
||||
display_index=settings.display_index,
|
||||
fps=settings.fps,
|
||||
interpolation_mode=settings.interpolation_mode,
|
||||
brightness=settings.brightness,
|
||||
smoothing=settings.smoothing,
|
||||
standby_interval=settings.standby_interval,
|
||||
state_check_interval=settings.state_check_interval,
|
||||
)
|
||||
|
||||
|
||||
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
|
||||
"""Convert core KeyColorsSettings to schema."""
|
||||
return KeyColorsSettingsSchema(
|
||||
@@ -117,8 +89,9 @@ def _target_to_response(target) -> PictureTargetResponse:
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_id,
|
||||
picture_source_id=target.picture_source_id,
|
||||
settings=_settings_to_schema(target.settings),
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
standby_interval=target.standby_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
description=target.description,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -163,8 +136,6 @@ async def create_target(
|
||||
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
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
|
||||
# Create in store
|
||||
@@ -172,8 +143,10 @@ async def create_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
standby_interval=data.standby_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
picture_source_id=data.picture_source_id,
|
||||
settings=core_settings,
|
||||
key_colors_settings=kc_settings,
|
||||
description=data.description,
|
||||
)
|
||||
@@ -237,8 +210,6 @@ async def update_target(
|
||||
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
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
|
||||
# Update in store
|
||||
@@ -246,8 +217,10 @@ async def update_target(
|
||||
target_id=target_id,
|
||||
name=data.name,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
standby_interval=data.standby_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
picture_source_id=data.picture_source_id,
|
||||
settings=core_settings,
|
||||
key_colors_settings=kc_settings,
|
||||
description=data.description,
|
||||
)
|
||||
@@ -256,8 +229,10 @@ async def update_target(
|
||||
try:
|
||||
target.sync_with_manager(
|
||||
manager,
|
||||
settings_changed=data.settings is not None or data.key_colors_settings is not None,
|
||||
source_changed=data.picture_source_id is not None,
|
||||
settings_changed=(data.standby_interval is not None or
|
||||
data.state_check_interval is not None or
|
||||
data.key_colors_settings is not None),
|
||||
source_changed=data.color_strip_source_id is not None,
|
||||
device_changed=data.device_id is not None,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -375,76 +350,6 @@ async def get_target_state(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/picture-targets/{target_id}/settings", 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, KeyColorsPictureTarget):
|
||||
return _kc_settings_to_schema(target.settings)
|
||||
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,
|
||||
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
|
||||
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
|
||||
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
|
||||
standby_interval=settings.standby_interval if 'standby_interval' in sent else existing.standby_interval,
|
||||
state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
|
||||
)
|
||||
|
||||
# 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,
|
||||
|
||||
@@ -23,12 +23,18 @@ from .devices import (
|
||||
DeviceStateResponse,
|
||||
DeviceUpdate,
|
||||
)
|
||||
from .color_strip_sources import (
|
||||
ColorStripSourceCreate,
|
||||
ColorStripSourceListResponse,
|
||||
ColorStripSourceResponse,
|
||||
ColorStripSourceUpdate,
|
||||
CSSCalibrationTestRequest,
|
||||
)
|
||||
from .picture_targets import (
|
||||
PictureTargetCreate,
|
||||
PictureTargetListResponse,
|
||||
PictureTargetResponse,
|
||||
PictureTargetUpdate,
|
||||
ProcessingSettings,
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
)
|
||||
@@ -89,11 +95,15 @@ __all__ = [
|
||||
"DeviceResponse",
|
||||
"DeviceStateResponse",
|
||||
"DeviceUpdate",
|
||||
"ColorStripSourceCreate",
|
||||
"ColorStripSourceListResponse",
|
||||
"ColorStripSourceResponse",
|
||||
"ColorStripSourceUpdate",
|
||||
"CSSCalibrationTestRequest",
|
||||
"PictureTargetCreate",
|
||||
"PictureTargetListResponse",
|
||||
"PictureTargetResponse",
|
||||
"PictureTargetUpdate",
|
||||
"ProcessingSettings",
|
||||
"TargetMetricsResponse",
|
||||
"TargetProcessingState",
|
||||
"EngineInfo",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Color strip source schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wled_controller.api.schemas.devices import Calibration
|
||||
|
||||
|
||||
class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture"] = Field(default="picture", description="Source type")
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
|
||||
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
|
||||
class ColorStripSourceUpdate(BaseModel):
|
||||
"""Request to update a color strip source."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90)
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
|
||||
class ColorStripSourceResponse(BaseModel):
|
||||
"""Color strip source response."""
|
||||
|
||||
id: str = Field(description="Source ID")
|
||||
name: str = Field(description="Source name")
|
||||
source_type: str = Field(description="Source type")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
fps: Optional[int] = Field(None, description="Target FPS")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier")
|
||||
saturation: Optional[float] = Field(None, description="Saturation")
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction")
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class ColorStripSourceListResponse(BaseModel):
|
||||
"""List of color strip sources."""
|
||||
|
||||
sources: List[ColorStripSourceResponse] = Field(description="List of color strip sources")
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class CSSCalibrationTestRequest(BaseModel):
|
||||
"""Request to run a calibration test for a color strip source on a specific device."""
|
||||
|
||||
device_id: str = Field(description="Device ID to send test pixels to")
|
||||
edges: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"top": [255, 0, 0],
|
||||
"right": [0, 255, 0],
|
||||
"bottom": [0, 100, 255],
|
||||
"left": [255, 255, 0],
|
||||
},
|
||||
description="Map of edge names to RGB colors. Empty dict = exit test mode.",
|
||||
)
|
||||
@@ -104,7 +104,6 @@ class DeviceResponse(BaseModel):
|
||||
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
|
||||
static_color: Optional[List[int]] = Field(None, description="Static idle color [R, G, B]")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
"""Picture target schemas (CRUD, processing state, settings, metrics)."""
|
||||
"""Picture target schemas (CRUD, processing state, metrics)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
standby_interval: float = Field(default=1.0, description="Seconds between keepalive sends when screen is static (0.5-5.0)", ge=0.5, le=5.0)
|
||||
state_check_interval: int = Field(
|
||||
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
||||
description="Seconds between WLED health checks"
|
||||
)
|
||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||
|
||||
|
||||
class KeyColorRectangleSchema(BaseModel):
|
||||
@@ -65,9 +50,13 @@ class PictureTargetCreate(BaseModel):
|
||||
|
||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||
# KC target fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
@@ -76,9 +65,13 @@ 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 (for wled targets)")
|
||||
# LED target fields
|
||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
||||
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||
# KC target fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
@@ -89,10 +82,14 @@ class PictureTargetResponse(BaseModel):
|
||||
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: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)")
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
standby_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||
# KC target fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
@@ -110,21 +107,18 @@ class TargetProcessingState(BaseModel):
|
||||
|
||||
target_id: str = Field(description="Target ID")
|
||||
device_id: Optional[str] = Field(None, description="Device ID")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
processing: bool = Field(description="Whether processing is active")
|
||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
||||
fps_target: int = Field(default=0, description="Target FPS")
|
||||
fps_target: Optional[int] = Field(None, description="Target FPS")
|
||||
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
||||
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
||||
timing_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)")
|
||||
timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)")
|
||||
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
|
||||
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
|
||||
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
||||
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
||||
display_index: int = Field(default=0, description="Current display index")
|
||||
display_index: Optional[int] = Field(None, description="Current display index")
|
||||
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||
@@ -147,7 +141,7 @@ class TargetMetricsResponse(BaseModel):
|
||||
device_id: Optional[str] = Field(None, 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")
|
||||
fps_target: Optional[int] = Field(None, 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")
|
||||
|
||||
Reference in New Issue
Block a user