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:
2026-02-20 15:49:47 +03:00
parent c4e0257389
commit 7de3546b14
33 changed files with 2325 additions and 814 deletions

View File

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

View File

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

View 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))

View File

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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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.",
)

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ class StorageConfig(BaseSettings):
picture_sources_file: str = "data/picture_sources.json"
picture_targets_file: str = "data/picture_targets.json"
pattern_templates_file: str = "data/pattern_templates.json"
color_strip_sources_file: str = "data/color_strip_sources.json"
profiles_file: str = "data/profiles.json"

View File

@@ -1,10 +1,6 @@
"""Target processing pipeline."""
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processing_settings import (
DEFAULT_STATE_CHECK_INTERVAL,
ProcessingSettings,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
ProcessingMetrics,
@@ -13,10 +9,8 @@ from wled_controller.core.processing.target_processor import (
)
__all__ = [
"DEFAULT_STATE_CHECK_INTERVAL",
"DeviceInfo",
"ProcessingMetrics",
"ProcessingSettings",
"ProcessorManager",
"TargetContext",
"TargetProcessor",

View File

@@ -0,0 +1,321 @@
"""Color strip stream — produces LED color arrays from a source.
A ColorStripStream is the runtime counterpart of ColorStripSource.
It continuously computes and caches a fresh np.ndarray (led_count, 3) uint8
by processing frames from a LiveStream.
Multiple WledTargetProcessors may read from the same ColorStripStream instance
(shared via ColorStripStreamManager reference counting), meaning the CPU-bound
processing — border extraction, pixel mapping, color correction — runs only once
even when multiple devices share the same source configuration.
"""
import threading
import time
from abc import ABC, abstractmethod
from typing import Optional
import numpy as np
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper
from wled_controller.core.capture.screen_capture import extract_border_pixels
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def _apply_saturation(colors: np.ndarray, saturation: float) -> np.ndarray:
"""Adjust saturation via luminance mixing (Rec.601 weights).
saturation=1.0: no change
saturation=0.0: grayscale
saturation=2.0: double saturation (clipped to 0-255)
"""
gray = (
colors[:, 0].astype(np.int32) * 299
+ colors[:, 1].astype(np.int32) * 587
+ colors[:, 2].astype(np.int32) * 114
) // 1000
gray = gray[:, np.newaxis] # (N, 1) for broadcast
result = gray + saturation * (colors.astype(np.int32) - gray)
return np.clip(result, 0, 255).astype(np.uint8)
def _build_gamma_lut(gamma: float) -> np.ndarray:
"""Build a 256-entry uint8 LUT for gamma correction.
gamma=1.0: identity (no correction)
gamma<1.0: brighter midtones
gamma>1.0: darker midtones
"""
if gamma == 1.0:
return np.arange(256, dtype=np.uint8)
lut = np.array(
[min(255, int(((i / 255.0) ** (1.0 / gamma)) * 255 + 0.5)) for i in range(256)],
dtype=np.uint8,
)
return lut
class ColorStripStream(ABC):
"""Abstract base: a runtime source of LED color arrays.
Produces a continuous stream of np.ndarray (led_count, 3) uint8 values.
Consumers call get_latest_colors() (non-blocking) to read the most recent
computed frame.
"""
@property
@abstractmethod
def target_fps(self) -> int:
"""Target processing rate."""
@property
@abstractmethod
def led_count(self) -> int:
"""Number of LEDs this stream produces colors for."""
@property
def display_index(self) -> Optional[int]:
"""Display index of the underlying capture, or None."""
return None
@property
def calibration(self) -> Optional[CalibrationConfig]:
"""Calibration config, or None if not applicable."""
return None
@abstractmethod
def start(self) -> None:
"""Start producing colors."""
@abstractmethod
def stop(self) -> None:
"""Stop producing colors and release resources."""
@abstractmethod
def get_latest_colors(self) -> Optional[np.ndarray]:
"""Get the most recent LED color array (led_count, 3) uint8, or None."""
def get_last_timing(self) -> dict:
"""Return per-stage timing from the last processed frame (ms)."""
return {}
def update_source(self, source) -> None:
"""Hot-update processing parameters. No-op by default."""
class PictureColorStripStream(ColorStripStream):
"""Color strip stream backed by a LiveStream (picture source).
Runs a background thread that:
1. Reads the latest frame from the LiveStream
2. Extracts border pixels using the calibration's border_width
3. Maps border pixels to LED colors via PixelMapper
4. Applies temporal smoothing
5. Applies saturation correction
6. Applies gamma correction (LUT-based, O(1) per pixel)
7. Applies brightness scaling
8. Caches the result for lock-free consumer reads
Processing parameters can be hot-updated via update_source() without
restarting the thread (except when the underlying LiveStream changes).
"""
def __init__(self, live_stream: LiveStream, source):
"""
Args:
live_stream: Acquired LiveStream (lifecycle managed by ColorStripStreamManager)
source: PictureColorStripSource config
"""
from wled_controller.storage.color_strip_source import PictureColorStripSource
self._live_stream = live_stream
self._fps: int = source.fps
self._smoothing: float = source.smoothing
self._brightness: float = source.brightness
self._saturation: float = source.saturation
self._gamma: float = source.gamma
self._interpolation_mode: str = source.interpolation_mode
self._calibration: CalibrationConfig = source.calibration
self._pixel_mapper = PixelMapper(
self._calibration, interpolation_mode=self._interpolation_mode
)
self._led_count: int = self._calibration.get_total_leds()
self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma)
# Thread-safe color cache
self._latest_colors: Optional[np.ndarray] = None
self._colors_lock = threading.Lock()
self._previous_colors: Optional[np.ndarray] = None
self._running = False
self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {}
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
@property
def display_index(self) -> Optional[int]:
return self._live_stream.display_index
@property
def calibration(self) -> Optional[CalibrationConfig]:
return self._calibration
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._processing_loop,
name="css-picture-stream",
daemon=True,
)
self._thread.start()
logger.info(
f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})"
)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("PictureColorStripStream thread did not terminate within 5s")
self._thread = None
self._latest_colors = None
self._previous_colors = None
logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._latest_colors
def get_last_timing(self) -> dict:
return dict(self._last_timing)
def update_source(self, source) -> None:
"""Hot-update processing parameters. Thread-safe for scalar params.
PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
"""
from wled_controller.storage.color_strip_source import PictureColorStripSource
if not isinstance(source, PictureColorStripSource):
return
self._fps = source.fps
self._smoothing = source.smoothing
self._brightness = source.brightness
self._saturation = source.saturation
if source.gamma != self._gamma:
self._gamma = source.gamma
self._gamma_lut = _build_gamma_lut(source.gamma)
if (
source.interpolation_mode != self._interpolation_mode
or source.calibration != self._calibration
):
self._interpolation_mode = source.interpolation_mode
self._calibration = source.calibration
self._led_count = source.calibration.get_total_leds()
self._pixel_mapper = PixelMapper(
source.calibration, interpolation_mode=source.interpolation_mode
)
self._previous_colors = None # Reset smoothing history on calibration change
logger.info("PictureColorStripStream params updated in-place")
def _processing_loop(self) -> None:
"""Background thread: poll source, process, cache colors."""
cached_frame = None
while self._running:
loop_start = time.perf_counter()
fps = self._fps
frame_time = 1.0 / fps if fps > 0 else 1.0
try:
frame = self._live_stream.get_latest_frame()
if frame is None or frame is cached_frame:
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
continue
cached_frame = frame
t0 = time.perf_counter()
calibration = self._calibration
border_pixels = extract_border_pixels(frame, calibration.border_width)
t1 = time.perf_counter()
led_colors = self._pixel_mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
# Temporal smoothing
smoothing = self._smoothing
if (
self._previous_colors is not None
and smoothing > 0
and len(self._previous_colors) == len(led_colors)
):
alpha = int(smoothing * 256)
led_colors = (
(256 - alpha) * led_colors.astype(np.uint16)
+ alpha * self._previous_colors.astype(np.uint16)
) >> 8
led_colors = led_colors.astype(np.uint8)
t3 = time.perf_counter()
# Saturation
saturation = self._saturation
if saturation != 1.0:
led_colors = _apply_saturation(led_colors, saturation)
t4 = time.perf_counter()
# Gamma (LUT lookup — O(1) per pixel)
if self._gamma != 1.0:
led_colors = self._gamma_lut[led_colors]
t5 = time.perf_counter()
# Brightness
brightness = self._brightness
if brightness != 1.0:
led_colors = np.clip(
led_colors.astype(np.float32) * brightness, 0, 255
).astype(np.uint8)
t6 = time.perf_counter()
self._previous_colors = led_colors
with self._colors_lock:
self._latest_colors = led_colors
self._last_timing = {
"extract_ms": (t1 - t0) * 1000,
"map_leds_ms": (t2 - t1) * 1000,
"smooth_ms": (t3 - t2) * 1000,
"saturation_ms": (t4 - t3) * 1000,
"gamma_ms": (t5 - t4) * 1000,
"brightness_ms": (t6 - t5) * 1000,
"total_ms": (t6 - t0) * 1000,
}
except Exception as e:
logger.error(f"PictureColorStripStream processing error: {e}", exc_info=True)
elapsed = time.perf_counter() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
time.sleep(remaining)

View File

@@ -0,0 +1,192 @@
"""Shared color strip stream management with reference counting.
ColorStripStreamManager creates PictureColorStripStream instances from
ColorStripSource configs and shares them across multiple consumers (LED targets).
When multiple targets reference the same ColorStripSource, they share a single
stream instance — processing runs once, not once per target.
Reference counting ensures streams are cleaned up when the last consumer
releases them.
"""
from dataclasses import dataclass
from typing import Dict, Optional
from wled_controller.core.processing.color_strip_stream import (
ColorStripStream,
PictureColorStripStream,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class _ColorStripEntry:
"""Internal tracking entry for a managed color strip stream."""
stream: ColorStripStream
ref_count: int
# ID of the picture source whose LiveStream we acquired (for release)
picture_source_id: str
class ColorStripStreamManager:
"""Manages shared ColorStripStream instances with reference counting.
Multiple LED targets using the same ColorStripSource share a single
ColorStripStream. Streams are created on first acquire and cleaned up
when the last consumer releases.
"""
def __init__(self, color_strip_store, live_stream_manager):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
live_stream_manager: LiveStreamManager for acquiring picture streams
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
self._streams: Dict[str, _ColorStripEntry] = {}
def acquire(self, css_id: str) -> ColorStripStream:
"""Get or create a ColorStripStream for the given ColorStripSource.
If a stream already exists for this css_id, increments the reference
count and returns the existing instance.
Otherwise, loads the ColorStripSource config, acquires the underlying
LiveStream, creates a new ColorStripStream, starts it, and stores it
with ref_count=1.
Args:
css_id: ID of the ColorStripSource config
Returns:
ColorStripStream instance (shared if already running)
Raises:
ValueError: If ColorStripSource not found or type unsupported
RuntimeError: If stream creation/start fails
"""
if css_id in self._streams:
entry = self._streams[css_id]
entry.ref_count += 1
logger.info(
f"Reusing color strip stream for source {css_id} "
f"(ref_count={entry.ref_count})"
)
return entry.stream
from wled_controller.storage.color_strip_source import PictureColorStripSource
source = self._color_strip_store.get_source(css_id)
if not isinstance(source, PictureColorStripSource):
raise ValueError(
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
)
if not source.picture_source_id:
raise ValueError(
f"Color strip source {css_id} has no picture_source_id assigned"
)
# Acquire the underlying live stream (ref-counted)
live_stream = self._live_stream_manager.acquire(source.picture_source_id)
try:
css_stream = PictureColorStripStream(live_stream, source)
css_stream.start()
except Exception as e:
self._live_stream_manager.release(source.picture_source_id)
raise RuntimeError(
f"Failed to start color strip stream for source {css_id}: {e}"
) from e
self._streams[css_id] = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_id=source.picture_source_id,
)
logger.info(f"Created color strip stream for source {css_id}")
return css_stream
def release(self, css_id: str) -> None:
"""Release a reference to a ColorStripStream.
Decrements the reference count. When it reaches 0, stops the stream,
releases the underlying LiveStream, and removes from registry.
Args:
css_id: ID of the ColorStripSource to release
"""
entry = self._streams.get(css_id)
if not entry:
logger.warning(f"Attempted to release unknown color strip stream: {css_id}")
return
entry.ref_count -= 1
logger.debug(f"Released color strip stream {css_id} (ref_count={entry.ref_count})")
if entry.ref_count <= 0:
try:
entry.stream.stop()
except Exception as e:
logger.error(f"Error stopping color strip stream {css_id}: {e}")
picture_source_id = entry.picture_source_id
del self._streams[css_id]
logger.info(f"Removed color strip stream for source {css_id}")
# Release the underlying live stream
self._live_stream_manager.release(picture_source_id)
def update_source(self, css_id: str, new_source) -> None:
"""Hot-update processing params on a running stream.
If the picture_source_id changed, only updates in-place params;
the underlying LiveStream is not swapped (requires target restart
to take full effect).
Args:
css_id: ID of the ColorStripSource
new_source: Updated ColorStripSource config
"""
entry = self._streams.get(css_id)
if not entry:
return # Stream not running; config will be used on next acquire
entry.stream.update_source(new_source)
# Track picture_source_id change for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource
if isinstance(new_source, PictureColorStripSource):
if new_source.picture_source_id != entry.picture_source_id:
logger.info(
f"CSS {css_id}: picture_source_id changed — "
f"restart target to use new source"
)
logger.info(f"Updated running color strip stream {css_id}")
def release_all(self) -> None:
"""Stop and remove all managed color strip streams. Called on shutdown."""
css_ids = list(self._streams.keys())
for css_id in css_ids:
entry = self._streams.get(css_id)
if entry:
try:
entry.stream.stop()
except Exception as e:
logger.error(f"Error stopping color strip stream {css_id}: {e}")
self._streams.clear()
logger.info("Released all managed color strip streams")
def get_active_stream_ids(self) -> list:
"""Get list of active stream IDs with ref counts (for diagnostics)."""
return [
{"id": sid, "ref_count": entry.ref_count}
for sid, entry in self._streams.items()
]

View File

@@ -93,7 +93,7 @@ class KCTargetProcessor(TargetProcessor):
settings, # KeyColorsSettings
ctx: TargetContext,
):
super().__init__(target_id, picture_source_id, ctx)
super().__init__(target_id, ctx, picture_source_id)
self._settings = settings
# Runtime state

View File

@@ -6,10 +6,7 @@ from typing import Dict, List, Optional, Tuple
import httpx
from wled_controller.core.capture.calibration import (
CalibrationConfig,
create_default_calibration,
)
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.devices.led_client import (
DeviceHealth,
check_device_health,
@@ -17,11 +14,8 @@ from wled_controller.core.devices.led_client import (
get_provider,
)
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.core.processing.processing_settings import (
DEFAULT_STATE_CHECK_INTERVAL,
ProcessingSettings,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
TargetContext,
@@ -33,15 +27,16 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
@dataclass
class DeviceState:
"""State for a registered LED device (health monitoring + calibration)."""
"""State for a registered LED device (health monitoring)."""
device_id: str
device_url: str
led_count: int
calibration: CalibrationConfig
device_type: str = "wled"
baud_rate: Optional[int] = None
health: DeviceHealth = field(default_factory=DeviceHealth)
@@ -55,6 +50,8 @@ class DeviceState:
# Calibration test mode (works independently of target processing)
test_mode_active: bool = False
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
# Calibration used for the current test (from the CSS being tested)
test_calibration: Optional[CalibrationConfig] = None
# Tracked power state for serial devices (no hardware query)
power_on: bool = True
@@ -62,11 +59,11 @@ class DeviceState:
class ProcessorManager:
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring and calibration.
Devices are registered for health monitoring.
Targets are registered for processing via polymorphic TargetProcessor subclasses.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None):
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None):
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
@@ -78,9 +75,14 @@ class ProcessorManager:
self._pp_template_store = pp_template_store
self._pattern_template_store = pattern_template_store
self._device_store = device_store
self._color_strip_store = color_strip_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
self._color_strip_stream_manager = ColorStripStreamManager(
color_strip_store=color_strip_store,
live_stream_manager=self._live_stream_manager,
)
self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = []
logger.info("Processor manager initialized")
@@ -97,6 +99,7 @@ class ProcessorManager:
pp_template_store=self._pp_template_store,
pattern_template_store=self._pattern_template_store,
device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager,
fire_event=self._fire_event,
get_device_info=self._get_device_info,
)
@@ -110,7 +113,6 @@ class ProcessorManager:
device_id=ds.device_id,
device_url=ds.device_url,
led_count=ds.led_count,
calibration=ds.calibration,
device_type=ds.device_type,
baud_rate=ds.baud_rate,
software_brightness=ds.software_brightness,
@@ -144,14 +146,13 @@ class ProcessorManager:
self._http_client = httpx.AsyncClient(timeout=5)
return self._http_client
# ===== DEVICE MANAGEMENT (health monitoring + calibration) =====
# ===== DEVICE MANAGEMENT (health monitoring) =====
def add_device(
self,
device_id: str,
device_url: str,
led_count: int,
calibration: Optional[CalibrationConfig] = None,
device_type: str = "wled",
baud_rate: Optional[int] = None,
software_brightness: int = 255,
@@ -162,14 +163,10 @@ class ProcessorManager:
if device_id in self._devices:
raise ValueError(f"Device {device_id} already registered")
if calibration is None:
calibration = create_default_calibration(led_count)
state = DeviceState(
device_id=device_id,
device_url=device_url,
led_count=led_count,
calibration=calibration,
device_type=device_type,
baud_rate=baud_rate,
software_brightness=software_brightness,
@@ -214,34 +211,8 @@ class ProcessorManager:
if baud_rate is not None:
ds.baud_rate = baud_rate
def update_calibration(self, device_id: str, calibration: CalibrationConfig):
"""Update calibration for a device.
Also propagates to any target processor using this device.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
calibration.validate()
ds = self._devices[device_id]
if calibration.get_total_leds() != ds.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({ds.led_count})"
)
ds.calibration = calibration
# Propagate to active processors using this device
for proc in self._processors.values():
if proc.device_id == device_id:
proc.update_calibration(calibration)
logger.info(f"Updated calibration for device {device_id}")
def get_device_state(self, device_id: str) -> DeviceState:
"""Get device state (for health/calibration info)."""
"""Get device state (for health info)."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
return self._devices[device_id]
@@ -298,8 +269,9 @@ class ProcessorManager:
self,
target_id: str,
device_id: str,
settings: Optional[ProcessingSettings] = None,
picture_source_id: str = "",
color_strip_source_id: str = "",
standby_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
):
"""Register a WLED target processor."""
if target_id in self._processors:
@@ -310,8 +282,9 @@ class ProcessorManager:
proc = WledTargetProcessor(
target_id=target_id,
device_id=device_id,
settings=settings or ProcessingSettings(),
picture_source_id=picture_source_id,
color_strip_source_id=color_strip_source_id,
standby_interval=standby_interval,
state_check_interval=state_check_interval,
ctx=self._build_context(),
)
self._processors[target_id] = proc
@@ -357,6 +330,11 @@ class ProcessorManager:
proc = self._get_processor(target_id)
proc.update_source(picture_source_id)
def update_target_color_strip_source(self, target_id: str, color_strip_source_id: str):
"""Update the color strip source for a WLED target."""
proc = self._get_processor(target_id)
proc.update_color_strip_source(color_strip_source_id)
def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a target."""
proc = self._get_processor(target_id)
@@ -499,10 +477,19 @@ class ProcessorManager:
proc = self._get_processor(target_id)
return proc.get_latest_colors()
# ===== CALIBRATION TEST MODE (on device) =====
# ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) =====
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
"""Set or clear calibration test mode for a device."""
async def set_test_mode(
self,
device_id: str,
edges: Dict[str, List[int]],
calibration: Optional[CalibrationConfig] = None,
) -> None:
"""Set or clear calibration test mode for a device.
When setting test mode, pass the calibration from the CSS being tested.
When clearing (edges={}), calibration is not needed.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
@@ -513,10 +500,13 @@ class ProcessorManager:
ds.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
if calibration is not None:
ds.test_calibration = calibration
await self._send_test_pixels(device_id)
else:
ds.test_mode_active = False
ds.test_mode_edges = {}
ds.test_calibration = None
await self._send_clear_pixels(device_id)
await self._close_idle_client(device_id)
@@ -558,10 +548,16 @@ class ProcessorManager:
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
ds = self._devices[device_id]
# Require calibration to know which LEDs map to which edges
if ds.test_calibration is None:
logger.debug(f"No calibration for test mode on {device_id}, skipping LED test")
return
pixels = [(0, 0, 0)] * ds.led_count
for edge_name, color in ds.test_mode_edges.items():
for seg in ds.calibration.segments:
for seg in ds.test_calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < ds.led_count:
@@ -569,8 +565,8 @@ class ProcessorManager:
break
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
total_leds = ds.calibration.get_total_leds()
offset = ds.calibration.offset % total_leds if total_leds > 0 else 0
total_leds = ds.test_calibration.get_total_leds()
offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0
if offset > 0:
pixels = pixels[-offset:] + pixels[:-offset]
@@ -701,6 +697,9 @@ class ProcessorManager:
for did in list(self._idle_clients):
await self._close_idle_client(did)
# Safety net: release all color strip streams
self._color_strip_stream_manager.release_all()
# Safety net: release any remaining managed live streams
self._live_stream_manager.release_all()
@@ -780,7 +779,7 @@ class ProcessorManager:
state.device_type, state.device_url, client, state.health,
)
# Auto-sync LED count (preserve existing calibration)
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count

View File

@@ -17,8 +17,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
if TYPE_CHECKING:
import httpx
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.storage import DeviceStore
@@ -65,7 +64,6 @@ class DeviceInfo:
device_id: str
device_url: str
led_count: int
calibration: "CalibrationConfig"
device_type: str = "wled"
baud_rate: Optional[int] = None
software_brightness: int = 255
@@ -86,6 +84,7 @@ class TargetContext:
pp_template_store: Optional["PostprocessingTemplateStore"] = None
pattern_template_store: Optional["PatternTemplateStore"] = None
device_store: Optional["DeviceStore"] = None
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
@@ -100,7 +99,7 @@ class TargetProcessor(ABC):
Lifecycle: register → start → (running loop) → stop → unregister
"""
def __init__(self, target_id: str, picture_source_id: str, ctx: TargetContext):
def __init__(self, target_id: str, ctx: TargetContext, picture_source_id: str = ""):
self._target_id = target_id
self._picture_source_id = picture_source_id
self._ctx = ctx
@@ -161,8 +160,8 @@ class TargetProcessor(ABC):
"""Update device association. Raises for targets without devices."""
raise ValueError(f"Target {self._target_id} does not support device assignment")
def update_calibration(self, calibration) -> None:
"""Update calibration. No-op for targets without devices."""
def update_color_strip_source(self, color_strip_source_id: str) -> None:
"""Update color strip source. No-op for targets that don't use CSS."""
pass
# ----- Device / display info (overridden by device-aware subclasses) -----

View File

@@ -1,24 +1,17 @@
"""WLED/LED target processor — captures screen, maps to LEDs, sends via DDP."""
"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP."""
from __future__ import annotations
import asyncio
import collections
import concurrent.futures
import time
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from typing import Optional
import numpy as np
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper
from wled_controller.core.devices.led_client import LEDClient, create_led_client
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.core.capture.screen_capture import (
extract_border_pixels,
get_available_displays,
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.target_processor import (
DeviceInfo,
ProcessingMetrics,
@@ -27,92 +20,44 @@ from wled_controller.core.processing.target_processor import (
)
from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.capture_engines.base import ScreenCapture
logger = get_logger(__name__)
_frame_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="frame-proc",
)
# ---------------------------------------------------------------------------
# CPU-bound frame processing (runs in dedicated thread-pool executor)
# ---------------------------------------------------------------------------
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing, brightness):
"""All CPU-bound work for one WLED frame.
Returns (raw_colors, send_colors, timing_ms).
raw_colors: unscaled array for smoothing history.
send_colors: brightness-scaled array ready for DDP send.
timing_ms: dict with per-stage timing in milliseconds.
"""
t0 = time.perf_counter()
border_pixels = extract_border_pixels(capture, border_width)
t1 = time.perf_counter()
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
# Inline numpy smoothing — avoids list↔numpy round-trip
if previous_colors is not None and smoothing > 0 and len(previous_colors) == len(led_colors):
alpha = int(smoothing * 256)
led_colors = (
(256 - alpha) * led_colors.astype(np.uint16)
+ alpha * previous_colors.astype(np.uint16)
) >> 8
led_colors = led_colors.astype(np.uint8)
t3 = time.perf_counter()
# Apply brightness scaling in thread pool (avoids extra array copies on event loop)
if brightness < 255:
send_colors = (led_colors.astype(np.uint16) * brightness >> 8).astype(np.uint8)
else:
send_colors = led_colors
t4 = time.perf_counter()
timing_ms = {
"extract": (t1 - t0) * 1000,
"map_leds": (t2 - t1) * 1000,
"smooth": (t3 - t2) * 1000,
"total": (t4 - t0) * 1000,
}
return led_colors, send_colors, timing_ms
# ---------------------------------------------------------------------------
# WledTargetProcessor
# ---------------------------------------------------------------------------
class WledTargetProcessor(TargetProcessor):
"""Processes screen capture frames and streams LED colors to a WLED/LED device."""
"""Streams LED colors from a ColorStripStream to a WLED/LED device.
The ColorStripStream handles all capture and color processing.
This processor only applies device software_brightness and sends pixels.
"""
def __init__(
self,
target_id: str,
device_id: str,
settings: ProcessingSettings,
picture_source_id: str,
color_strip_source_id: str,
standby_interval: float,
state_check_interval: int,
ctx: TargetContext,
):
super().__init__(target_id, picture_source_id, ctx)
super().__init__(target_id, ctx)
self._device_id = device_id
self._settings = settings
self._color_strip_source_id = color_strip_source_id
self._standby_interval = standby_interval
self._state_check_interval = state_check_interval
# Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None
self._pixel_mapper: Optional[PixelMapper] = None
self._live_stream: Optional[LiveStream] = None
self._previous_colors: Optional[np.ndarray] = None
self._color_strip_stream = None
self._device_state_before: Optional[dict] = None
self._overlay_active = False
# Resolved stream metadata
# Resolved stream metadata (set once stream is acquired)
self._resolved_display_index: Optional[int] = None
self._resolved_target_fps: Optional[int] = None
self._resolved_engine_type: Optional[str] = None
self._resolved_engine_config: Optional[dict] = None
# ----- Properties -----
@@ -120,10 +65,6 @@ class WledTargetProcessor(TargetProcessor):
def device_id(self) -> str:
return self._device_id
@property
def settings(self) -> ProcessingSettings:
return self._settings
@property
def led_client(self) -> Optional[LEDClient]:
return self._led_client
@@ -139,9 +80,6 @@ class WledTargetProcessor(TargetProcessor):
if device_info is None:
raise ValueError(f"Device {self._device_id} not registered")
# Resolve stream settings
self._resolve_stream_settings()
# Connect to LED device
try:
self._led_client = create_led_client(
@@ -154,44 +92,42 @@ class WledTargetProcessor(TargetProcessor):
f"Target {self._target_id} connected to {device_info.device_type} "
f"device ({device_info.led_count} LEDs)"
)
# Snapshot device state before streaming
self._device_state_before = await self._led_client.snapshot_device_state()
except Exception as e:
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}")
# Acquire live stream
# Acquire color strip stream
css_manager = self._ctx.color_strip_stream_manager
if css_manager is None:
await self._led_client.close()
self._led_client = None
raise RuntimeError("Color strip stream manager not available in context")
if not self._color_strip_source_id:
await self._led_client.close()
self._led_client = None
raise RuntimeError(f"Target {self._target_id} has no color strip source assigned")
try:
live_stream = await asyncio.to_thread(
self._ctx.live_stream_manager.acquire, self._picture_source_id
)
self._live_stream = live_stream
if live_stream.display_index is not None:
self._resolved_display_index = live_stream.display_index
self._resolved_target_fps = live_stream.target_fps
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id)
self._color_strip_stream = stream
self._resolved_display_index = stream.display_index
self._resolved_target_fps = stream.target_fps
logger.info(
f"Acquired live stream for target {self._target_id} "
f"(picture_source={self._picture_source_id})"
f"Acquired color strip stream for target {self._target_id} "
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "
f"fps={self._resolved_target_fps})"
)
except Exception as e:
logger.error(f"Failed to initialize live stream for target {self._target_id}: {e}")
logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}")
if self._led_client:
await self._led_client.close()
raise RuntimeError(f"Failed to initialize live stream: {e}")
self._led_client = None
raise RuntimeError(f"Failed to acquire color strip stream: {e}")
# Initialize pixel mapper from current device calibration
calibration = device_info.calibration
self._pixel_mapper = PixelMapper(
calibration,
interpolation_mode=self._settings.interpolation_mode,
)
# Reset metrics
# Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._previous_colors = None
# Start processing task
self._task = asyncio.create_task(self._processing_loop())
self._is_running = True
@@ -224,80 +160,84 @@ class WledTargetProcessor(TargetProcessor):
await self._led_client.close()
self._led_client = None
# Release live stream
if self._live_stream:
try:
self._ctx.live_stream_manager.release(self._picture_source_id)
except Exception as e:
logger.warning(f"Error releasing live stream: {e}")
self._live_stream = None
# Release color strip stream
if self._color_strip_stream is not None:
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._color_strip_source_id:
try:
await asyncio.to_thread(css_manager.release, self._color_strip_source_id)
except Exception as e:
logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}")
self._color_strip_stream = None
logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
# ----- Settings -----
def update_settings(self, settings: ProcessingSettings) -> None:
self._settings = settings
# Recreate pixel mapper if interpolation mode changed
if self._pixel_mapper:
device_info = self._ctx.get_device_info(self._device_id)
if device_info:
self._pixel_mapper = PixelMapper(
device_info.calibration,
interpolation_mode=settings.interpolation_mode,
)
def update_settings(self, settings: dict) -> None:
"""Update target-specific timing settings."""
if isinstance(settings, dict):
if "standby_interval" in settings:
self._standby_interval = settings["standby_interval"]
if "state_check_interval" in settings:
self._state_check_interval = settings["state_check_interval"]
logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None:
"""Update the device this target streams to."""
self._device_id = device_id
def update_calibration(self, calibration: CalibrationConfig) -> None:
"""Update the cached calibration + rebuild pixel mapper."""
if self._pixel_mapper:
self._pixel_mapper = PixelMapper(
calibration,
interpolation_mode=self._settings.interpolation_mode,
)
def update_color_strip_source(self, color_strip_source_id: str) -> None:
"""Hot-swap the color strip source for a running target."""
if not self._is_running or self._color_strip_source_id == color_strip_source_id:
self._color_strip_source_id = color_strip_source_id
return
css_manager = self._ctx.color_strip_stream_manager
if css_manager is None:
self._color_strip_source_id = color_strip_source_id
return
old_id = self._color_strip_source_id
try:
new_stream = css_manager.acquire(color_strip_source_id)
css_manager.release(old_id)
self._color_strip_stream = new_stream
self._resolved_display_index = new_stream.display_index
self._resolved_target_fps = new_stream.target_fps
self._color_strip_source_id = color_strip_source_id
logger.info(f"Swapped color strip source for {self._target_id}: {old_id}{color_strip_source_id}")
except Exception as e:
logger.error(f"Failed to swap color strip source for {self._target_id}: {e}")
def get_display_index(self) -> Optional[int]:
"""Display index being captured."""
"""Display index being captured, from the active stream."""
if self._resolved_display_index is not None:
return self._resolved_display_index
return self._settings.display_index
if self._color_strip_stream is not None:
return self._color_strip_stream.display_index
return None
# ----- State / Metrics -----
def get_state(self) -> dict:
metrics = self._metrics
device_info = self._ctx.get_device_info(self._device_id)
# Include device health info
health_info = {}
if device_info:
# Get full health from the device state (delegate via manager callback)
from wled_controller.core.devices.led_client import DeviceHealth
# We access health through the manager's get_device_health_dict
# For now, return empty — will be populated by manager wrapper
pass
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
return {
"target_id": self._target_id,
"device_id": self._device_id,
"color_strip_source_id": self._color_strip_source_id,
"processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": self._settings.fps,
"fps_target": fps_target,
"frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None,
"timing_extract_ms": round(metrics.timing_extract_ms, 1) if self._is_running else None,
"timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if self._is_running else None,
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None,
"timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None,
"timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None,
"display_index": self._resolved_display_index if self._resolved_display_index is not None else self._settings.display_index,
"display_index": self._resolved_display_index,
"overlay_active": self._overlay_active,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
@@ -305,6 +245,7 @@ class WledTargetProcessor(TargetProcessor):
def get_metrics(self) -> dict:
metrics = self._metrics
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
uptime_seconds = 0.0
if metrics.start_time and self._is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
@@ -314,7 +255,7 @@ class WledTargetProcessor(TargetProcessor):
"device_id": self._device_id,
"processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_target": self._settings.fps,
"fps_target": fps_target,
"uptime_seconds": uptime_seconds,
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
@@ -331,11 +272,21 @@ class WledTargetProcessor(TargetProcessor):
if self._overlay_active:
raise RuntimeError(f"Overlay already active for {self._target_id}")
device_info = self._ctx.get_device_info(self._device_id)
if device_info is None:
raise ValueError(f"Device {self._device_id} not found")
# Calibration comes from the active color strip stream
if self._color_strip_stream is None:
raise ValueError(
f"Cannot start overlay for {self._target_id}: no color strip stream active. "
f"Start processing first."
)
calibration = self._color_strip_stream.calibration
display_index = self._resolved_display_index
if display_index is None:
display_index = self._color_strip_stream.display_index
if display_index is None or display_index < 0:
raise ValueError(f"Invalid display index {display_index} for overlay")
display_index = self._resolved_display_index or self._settings.display_index
displays = get_available_displays()
if display_index >= len(displays):
raise ValueError(f"Invalid display index {display_index}")
@@ -344,7 +295,7 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.to_thread(
self._ctx.overlay_manager.start_overlay,
self._target_id, display_info, device_info.calibration, target_name,
self._target_id, display_info, calibration, target_name,
)
self._overlay_active = True
@@ -366,106 +317,65 @@ class WledTargetProcessor(TargetProcessor):
def is_overlay_active(self) -> bool:
return self._overlay_active
# ----- Private: stream settings resolution -----
def _resolve_stream_settings(self) -> None:
"""Resolve picture source chain to populate resolved_* metadata fields."""
if not self._picture_source_id or not self._ctx.picture_source_store:
raise ValueError(f"Target {self._target_id} has no picture source assigned")
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
chain = self._ctx.picture_source_store.resolve_stream_chain(self._picture_source_id)
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
self._resolved_display_index = -1
self._resolved_target_fps = 1
self._resolved_engine_type = None
self._resolved_engine_config = None
elif isinstance(raw_stream, ScreenCapturePictureSource):
self._resolved_display_index = raw_stream.display_index
self._resolved_target_fps = raw_stream.target_fps
if raw_stream.capture_template_id and self._ctx.capture_template_store:
try:
tpl = self._ctx.capture_template_store.get_template(raw_stream.capture_template_id)
self._resolved_engine_type = tpl.engine_type
self._resolved_engine_config = tpl.engine_config
except ValueError:
logger.warning(
f"Capture template {raw_stream.capture_template_id} not found, "
f"using MSS fallback"
)
self._resolved_engine_type = "mss"
self._resolved_engine_config = {}
logger.info(
f"Resolved stream metadata for target {self._target_id}: "
f"display={self._resolved_display_index}, fps={self._resolved_target_fps}, "
f"engine={self._resolved_engine_type}"
)
# ----- Private: processing loop -----
async def _processing_loop(self) -> None:
"""Main processing loop — capture → extract → map → smooth → send."""
settings = self._settings
device_info = self._ctx.get_device_info(self._device_id)
@staticmethod
def _apply_brightness(colors: np.ndarray, device_info: Optional[DeviceInfo]) -> np.ndarray:
"""Apply device software_brightness if < 255."""
if device_info and device_info.software_brightness < 255:
return (colors.astype(np.uint16) * device_info.software_brightness >> 8).astype(np.uint8)
return colors
target_fps = settings.fps
smoothing = settings.smoothing
border_width = device_info.calibration.border_width if device_info else 10
led_brightness = settings.brightness
async def _processing_loop(self) -> None:
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
stream = self._color_strip_stream
target_fps = self._resolved_target_fps or 30
frame_time = 1.0 / target_fps
standby_interval = self._standby_interval
fps_samples: collections.deque = collections.deque(maxlen=10)
send_timestamps: collections.deque = collections.deque()
prev_colors = None
last_send_time = 0.0
prev_frame_time_stamp = time.time()
loop = asyncio.get_running_loop()
logger.info(
f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={target_fps})"
)
frame_time = 1.0 / target_fps
standby_interval = settings.standby_interval
fps_samples: collections.deque = collections.deque(maxlen=10)
timing_samples: collections.deque = collections.deque(maxlen=10)
prev_frame_time_stamp = time.time()
prev_capture = None
last_send_time = 0.0
send_timestamps: collections.deque = collections.deque()
loop = asyncio.get_running_loop()
try:
while self._is_running:
now = loop_start = time.time()
loop_start = now = time.time()
# Re-fetch device info for runtime changes (test mode, brightness)
device_info = self._ctx.get_device_info(self._device_id)
# Skip capture/send while in calibration test mode
# Skip send while in calibration test mode
if device_info and device_info.test_mode_active:
await asyncio.sleep(frame_time)
continue
try:
capture = self._live_stream.get_latest_frame()
colors = stream.get_latest_colors()
if capture is None:
if colors is None:
if self._metrics.frames_processed == 0:
logger.info(f"Capture returned None for target {self._target_id} (no new frame yet)")
logger.info(f"Stream returned None for target {self._target_id} (no data yet)")
await asyncio.sleep(frame_time)
continue
# Skip processing + send if the frame hasn't changed
if capture is prev_capture:
if self._previous_colors is not None and (loop_start - last_send_time) >= standby_interval:
if colors is prev_colors:
# Same frame — send keepalive if interval elapsed
if prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
if not self._is_running or self._led_client is None:
break
brightness_value = int(led_brightness * 255)
if device_info and device_info.software_brightness < 255:
brightness_value = brightness_value * device_info.software_brightness // 255
send_colors = self._apply_brightness(prev_colors, device_info)
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(self._previous_colors, brightness=brightness_value)
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(self._previous_colors, brightness=brightness_value)
await self._led_client.send_pixels(send_colors)
now = time.time()
last_send_time = now
send_timestamps.append(now)
@@ -476,22 +386,13 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(frame_time)
continue
prev_capture = capture
# Compute brightness before thread dispatch
brightness_value = int(led_brightness * 255)
if device_info and device_info.software_brightness < 255:
brightness_value = brightness_value * device_info.software_brightness // 255
prev_colors = colors
# CPU-bound work in dedicated thread-pool executor
raw_colors, send_colors, frame_timing = await loop.run_in_executor(
_frame_executor,
_process_frame, capture, border_width,
self._pixel_mapper, self._previous_colors, smoothing,
brightness_value,
)
# Apply device software brightness
send_colors = self._apply_brightness(colors, device_info)
# Send to LED device (brightness already applied in thread)
# Send to LED device
if not self._is_running or self._led_client is None:
break
t_send_start = time.perf_counter()
@@ -500,45 +401,30 @@ class WledTargetProcessor(TargetProcessor):
else:
await self._led_client.send_pixels(send_colors)
send_ms = (time.perf_counter() - t_send_start) * 1000
now = time.time()
last_send_time = now
send_timestamps.append(now)
# Per-stage timing (rolling average over last 10 frames)
frame_timing["send"] = send_ms
timing_samples.append(frame_timing)
n = len(timing_samples)
self._metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n
self._metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n
self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
self._metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n
self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms
# Update metrics
self._metrics.timing_send_ms = send_ms
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.utcnow()
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
logger.info(
f"Frame {self._metrics.frames_processed} for {self._target_id} "
f"({len(send_colors)} LEDs, bri={brightness_value}) — "
f"extract={frame_timing['extract']:.1f}ms "
f"map={frame_timing['map_leds']:.1f}ms "
f"smooth={frame_timing['smooth']:.1f}ms "
f"send={send_ms:.1f}ms"
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
)
self._metrics.last_update = datetime.utcnow()
self._previous_colors = raw_colors
# Calculate actual FPS (reuse cached 'now' from send timestamp)
# FPS tracking
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
fps_samples.append(1.0 / interval if interval > 0 else 0)
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
# Potential FPS
processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
# fps_current: count sends in last 1 second
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)

View File

@@ -16,13 +16,13 @@ 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.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processing_settings import 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.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
from wled_controller.utils import setup_logging, get_logger
@@ -41,6 +41,7 @@ pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_te
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
profile_store = ProfileStore(config.storage.profiles_file)
processor_manager = ProcessorManager(
@@ -49,6 +50,7 @@ processor_manager = ProcessorManager(
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
)
@@ -69,22 +71,10 @@ def _migrate_devices_to_targets():
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:
if not legacy_source_id:
continue
# Build ProcessingSettings from legacy data
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
settings = ProcessingSettings(
display_index=legacy_settings.get("display_index", 0),
fps=legacy_settings.get("fps", 30),
brightness=legacy_settings.get("brightness", 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"
@@ -93,8 +83,6 @@ def _migrate_devices_to_targets():
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
@@ -106,6 +94,65 @@ def _migrate_devices_to_targets():
logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings")
def _migrate_targets_to_color_strips():
"""One-time migration: create ColorStripSources from legacy WledPictureTarget data.
For each WledPictureTarget that has a legacy _legacy_picture_source_id (from old JSON)
but no color_strip_source_id, create a ColorStripSource and link it.
"""
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.core.capture.calibration import create_default_calibration
migrated = 0
for target in picture_target_store.get_all_targets():
if not isinstance(target, WledPictureTarget):
continue
if target.color_strip_source_id:
continue # already migrated
if not target._legacy_picture_source_id:
continue # no legacy source to migrate
legacy_settings = target._legacy_settings or {}
# Try to get calibration from device (old location)
device = device_store.get_device(target.device_id) if target.device_id else None
calibration = getattr(device, "_legacy_calibration", None) if device else None
if calibration is None:
calibration = create_default_calibration(0)
css_name = f"{target.name} Strip"
# Ensure unique name
existing_names = {s.name for s in color_strip_store.get_all_sources()}
if css_name in existing_names:
css_name = f"{target.name} Strip (migrated)"
try:
css = color_strip_store.create_source(
name=css_name,
source_type="picture",
picture_source_id=target._legacy_picture_source_id,
fps=legacy_settings.get("fps", 30),
brightness=legacy_settings.get("brightness", 1.0),
smoothing=legacy_settings.get("smoothing", 0.3),
interpolation_mode=legacy_settings.get("interpolation_mode", "average"),
calibration=calibration,
)
# Update target to reference the new CSS
target.color_strip_source_id = css.id
target.standby_interval = legacy_settings.get("standby_interval", 1.0)
target.state_check_interval = legacy_settings.get("state_check_interval", 30)
picture_target_store._save()
migrated += 1
logger.info(f"Migrated target {target.id} -> CSS {css.id} ({css_name})")
except Exception as e:
logger.error(f"Failed to migrate target {target.id} to CSS: {e}")
if migrated > 0:
logger.info(f"CSS migration complete: created {migrated} color strip source(s) from legacy targets")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager.
@@ -137,6 +184,7 @@ async def lifespan(app: FastAPI):
# Run migrations
_migrate_devices_to_targets()
_migrate_targets_to_color_strips()
# Create profile engine (needs processor_manager)
profile_engine = ProfileEngine(profile_store, processor_manager)
@@ -148,6 +196,7 @@ async def lifespan(app: FastAPI):
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,
)
@@ -160,7 +209,6 @@ async def lifespan(app: FastAPI):
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,
software_brightness=device.software_brightness,

View File

@@ -87,11 +87,17 @@ import {
startTargetOverlay, stopTargetOverlay, deleteTarget,
} from './features/targets.js';
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
} from './features/color-strips.js';
// Layer 5: calibration
import {
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration,
} from './features/calibration.js';
// Layer 6: tabs
@@ -262,6 +268,13 @@ Object.assign(window, {
stopTargetOverlay,
deleteTarget,
// color-strip sources
showCSSEditor,
closeCSSEditorModal,
forceCSSEditorClose,
saveCSSEditor,
deleteColorStrip,
// calibration
showCalibration,
closeCalibrationModal,
@@ -273,6 +286,7 @@ Object.assign(window, {
toggleEdgeInputs,
toggleDirection,
toggleTestEdge,
showCSSCalibration,
// tabs
switchTab,

View File

@@ -35,8 +35,15 @@ class CalibrationModal extends Modal {
onForceClose() {
closeTutorial();
const deviceId = this.$('calibration-device-id').value;
if (deviceId) clearTestMode(deviceId);
if (_isCSS()) {
_clearCSSTestMode();
document.getElementById('calibration-css-id').value = '';
const testGroup = document.getElementById('calibration-css-test-group');
if (testGroup) testGroup.style.display = 'none';
} else {
const deviceId = this.$('calibration-device-id').value;
if (deviceId) clearTestMode(deviceId);
}
if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect();
const error = this.$('calibration-error');
if (error) error.style.display = 'none';
@@ -48,6 +55,33 @@ const calibModal = new CalibrationModal();
let _dragRaf = null;
let _previewRaf = null;
/* ── Helpers ──────────────────────────────────────────────────── */
function _isCSS() {
return !!(document.getElementById('calibration-css-id')?.value);
}
function _cssStateKey() {
return `css_${document.getElementById('calibration-css-id').value}`;
}
async function _clearCSSTestMode() {
const cssId = document.getElementById('calibration-css-id')?.value;
const stateKey = _cssStateKey();
if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return;
calibrationTestState[stateKey] = new Set();
const testDeviceId = document.getElementById('calibration-test-device')?.value;
if (!testDeviceId) return;
try {
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
});
} catch (err) {
console.error('Failed to clear CSS test mode:', err);
}
}
/* ── Public API (exported names unchanged) ────────────────────── */
export async function showCalibration(deviceId) {
@@ -148,6 +182,92 @@ export async function closeCalibrationModal() {
calibModal.close();
}
/* ── CSS Calibration support ──────────────────────────────────── */
export async function showCSSCalibration(cssId) {
try {
const [cssResp, devicesResp] = await Promise.all([
fetchWithAuth(`/color-strip-sources/${cssId}`),
fetchWithAuth('/devices'),
]);
if (!cssResp.ok) { showToast('Failed to load color strip source', 'error'); return; }
const source = await cssResp.json();
const calibration = source.calibration || {};
// Set CSS mode — clear device-id, set css-id
document.getElementById('calibration-device-id').value = '';
document.getElementById('calibration-css-id').value = cssId;
// Populate device picker for edge test
const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : [];
const testDeviceSelect = document.getElementById('calibration-test-device');
testDeviceSelect.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
testDeviceSelect.appendChild(opt);
});
const testGroup = document.getElementById('calibration-css-test-group');
testGroup.style.display = devices.length ? '' : 'none';
// Populate calibration fields
const preview = document.querySelector('.calibration-preview');
preview.style.aspectRatio = '';
document.getElementById('cal-device-led-count-inline').textContent = '—';
document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left';
document.getElementById('cal-layout').value = calibration.layout || 'clockwise';
document.getElementById('cal-offset').value = calibration.offset || 0;
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
updateOffsetSkipLock();
document.getElementById('cal-border-width').value = calibration.border_width || 10;
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
};
calibrationTestState[_cssStateKey()] = new Set();
updateCalibrationPreview();
calibModal.snapshot();
calibModal.open();
initSpanDrag();
requestAnimationFrame(() => renderCalibrationCanvas());
if (!window._calibrationResizeObserver) {
window._calibrationResizeObserver = new ResizeObserver(() => {
if (window._calibrationResizeRaf) return;
window._calibrationResizeRaf = requestAnimationFrame(() => {
window._calibrationResizeRaf = null;
updateSpanBars();
renderCalibrationCanvas();
});
});
}
window._calibrationResizeObserver.observe(preview);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load CSS calibration:', error);
showToast('Failed to load calibration', 'error');
}
}
export function updateOffsetSkipLock() {
const offsetEl = document.getElementById('cal-offset');
const skipStartEl = document.getElementById('cal-skip-start');
@@ -165,8 +285,9 @@ export function updateCalibrationPreview() {
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
const totalEl = document.querySelector('.preview-screen-total');
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
const mismatch = total !== deviceCount;
const inCSS = _isCSS();
const deviceCount = inCSS ? null : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
const mismatch = !inCSS && total !== deviceCount;
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
@@ -186,7 +307,8 @@ export function updateCalibrationPreview() {
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const deviceId = document.getElementById('calibration-device-id').value;
const activeEdges = calibrationTestState[deviceId] || new Set();
const stateKey = _isCSS() ? _cssStateKey() : deviceId;
const activeEdges = calibrationTestState[stateKey] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => {
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
@@ -612,9 +734,42 @@ export async function toggleTestEdge(edge) {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
if (edgeLeds === 0) return;
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
if (_isCSS()) {
const cssId = document.getElementById('calibration-css-id').value;
const testDeviceId = document.getElementById('calibration-test-device')?.value;
if (!testDeviceId) return;
const stateKey = _cssStateKey();
if (!calibrationTestState[stateKey]) calibrationTestState[stateKey] = new Set();
if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge);
else calibrationTestState[stateKey].add(edge);
const edges = {};
calibrationTestState[stateKey].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; });
updateCalibrationPreview();
try {
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges }),
});
if (!response.ok) {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err);
error.textContent = 'Failed to toggle test edge';
error.style.display = 'block';
}
return;
}
const deviceId = document.getElementById('calibration-device-id').value;
if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set();
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
@@ -658,11 +813,16 @@ async function clearTestMode(deviceId) {
}
export async function saveCalibration() {
const cssMode = _isCSS();
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
const cssId = document.getElementById('calibration-css-id').value;
const error = document.getElementById('calibration-error');
await clearTestMode(deviceId);
if (cssMode) {
await _clearCSSTestMode();
} else {
await clearTestMode(deviceId);
}
updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
@@ -671,10 +831,13 @@ export async function saveCalibration() {
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
const total = topLeds + rightLeds + bottomLeds + leftLeds;
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
if (!cssMode) {
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
}
}
const startPosition = document.getElementById('cal-start-position').value;
@@ -695,14 +858,26 @@ export async function saveCalibration() {
};
try {
const response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
method: 'PUT',
body: JSON.stringify(calibration)
});
let response;
if (cssMode) {
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify({ calibration }),
});
} else {
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
method: 'PUT',
body: JSON.stringify(calibration),
});
}
if (response.ok) {
showToast('Calibration saved', 'success');
calibModal.forceClose();
window.loadDevices();
if (cssMode) {
if (window.loadTargetsTab) window.loadTargetsTab();
} else {
window.loadDevices();
}
} else {
const errorData = await response.json();
error.textContent = `Failed to save: ${errorData.detail}`;

View File

@@ -0,0 +1,218 @@
/**
* Color Strip Sources — CRUD, card rendering, calibration bridge.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
class CSSEditorModal extends Modal {
constructor() {
super('css-editor-modal');
}
snapshotValues() {
return {
name: document.getElementById('css-editor-name').value,
picture_source: document.getElementById('css-editor-picture-source').value,
fps: document.getElementById('css-editor-fps').value,
interpolation: document.getElementById('css-editor-interpolation').value,
smoothing: document.getElementById('css-editor-smoothing').value,
brightness: document.getElementById('css-editor-brightness').value,
saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value,
};
}
}
const cssEditorModal = new CSSEditorModal();
/* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
: source.picture_source_id || '—';
const cal = source.calibration || {};
const ledCount = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
return `
<div class="card" data-css-id="${source.id}">
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
🎞️ ${escapeHtml(source.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>
</div>
</div>
`;
}
/* ── Editor open/close ────────────────────────────────────────── */
export async function showCSSEditor(cssId = null) {
try {
const sourcesResp = await fetchWithAuth('/picture-sources');
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
const sourceSelect = document.getElementById('css-editor-picture-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (cssId) {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
if (!resp.ok) throw new Error('Failed to load color strip source');
const css = await resp.json();
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
sourceSelect.value = css.picture_source_id || '';
const fps = css.fps ?? 30;
document.getElementById('css-editor-fps').value = fps;
document.getElementById('css-editor-fps-value').textContent = fps;
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
const brightness = css.brightness ?? 1.0;
document.getElementById('css-editor-brightness').value = brightness;
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
const saturation = css.saturation ?? 1.0;
document.getElementById('css-editor-saturation').value = saturation;
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2);
const gamma = css.gamma ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
document.getElementById('css-editor-title').textContent = t('color_strip.edit');
} else {
document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-fps').value = 30;
document.getElementById('css-editor-fps-value').textContent = '30';
document.getElementById('css-editor-interpolation').value = 'average';
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
document.getElementById('css-editor-brightness').value = 1.0;
document.getElementById('css-editor-brightness-value').textContent = '1.00';
document.getElementById('css-editor-saturation').value = 1.0;
document.getElementById('css-editor-saturation-value').textContent = '1.00';
document.getElementById('css-editor-gamma').value = 1.0;
document.getElementById('css-editor-gamma-value').textContent = '1.00';
document.getElementById('css-editor-title').textContent = t('color_strip.add');
}
document.getElementById('css-editor-error').style.display = 'none';
cssEditorModal.snapshot();
cssEditorModal.open();
setTimeout(() => document.getElementById('css-editor-name').focus(), 100);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to open CSS editor:', error);
showToast('Failed to open color strip editor', 'error');
}
}
export function closeCSSEditorModal() { cssEditorModal.close(); }
export function forceCSSEditorClose() { cssEditorModal.forceClose(); }
export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
/* ── Save ─────────────────────────────────────────────────────── */
export async function saveCSSEditor() {
const cssId = document.getElementById('css-editor-id').value;
const name = document.getElementById('css-editor-name').value.trim();
if (!name) {
cssEditorModal.showError(t('color_strip.error.name_required'));
return;
}
const payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
};
try {
let response;
if (cssId) {
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.source_type = 'picture';
response = await fetchWithAuth('/color-strip-sources', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
cssEditorModal.forceClose();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
console.error('Error saving CSS:', error);
cssEditorModal.showError(error.message);
}
}
/* ── Delete ───────────────────────────────────────────────────── */
export async function deleteColorStrip(cssId) {
const confirmed = await showConfirm(t('color_strip.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('color_strip.deleted'), 'success');
if (window.loadTargetsTab) await window.loadTargetsTab();
} else {
const err = await response.json();
const msg = err.detail || 'Failed to delete';
const isReferenced = response.status === 409;
showToast(isReferenced ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to delete color strip source', 'error');
}
}

View File

@@ -14,6 +14,7 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
@@ -30,10 +31,7 @@ class TargetEditorModal extends Modal {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
source: document.getElementById('target-editor-source').value,
fps: document.getElementById('target-editor-fps').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value,
css: document.getElementById('target-editor-css').value,
standby_interval: document.getElementById('target-editor-standby-interval').value,
};
}
@@ -47,11 +45,11 @@ function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const sourceSelect = document.getElementById('target-editor-source');
const cssSelect = document.getElementById('target-editor-css');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !sourceName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`;
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
function _updateStandbyVisibility() {
@@ -64,14 +62,14 @@ function _updateStandbyVisibility() {
export async function showTargetEditor(targetId = null) {
try {
// Load devices and sources for dropdowns
const [devicesResp, sourcesResp] = await Promise.all([
// Load devices and CSS sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/color-strip-sources'),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices);
// Populate device select
@@ -87,16 +85,15 @@ export async function showTargetEditor(targetId = null) {
deviceSelect.appendChild(opt);
});
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
// Populate color strip source select
const cssSelect = document.getElementById('target-editor-css');
cssSelect.innerHTML = '';
cssSources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
opt.textContent = `🎞️ ${s.name}`;
cssSelect.appendChild(opt);
});
if (targetId) {
@@ -108,24 +105,14 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
sourceSelect.value = target.picture_source_id || '';
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
cssSelect.value = target.color_strip_source_id || '';
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
} else {
// Creating new target — first option is selected by default
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
document.getElementById('target-editor-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
@@ -135,7 +122,7 @@ export async function showTargetEditor(targetId = null) {
_targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
sourceSelect.onchange = () => _autoGenerateTargetName();
cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities
@@ -168,10 +155,7 @@ export async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value;
const sourceId = document.getElementById('target-editor-source').value;
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
if (!name) {
@@ -182,13 +166,8 @@ export async function saveTargetEditor() {
const payload = {
name,
device_id: deviceId,
picture_source_id: sourceId,
settings: {
fps: fps,
interpolation_mode: interpolation,
smoothing: smoothing,
standby_interval: standbyInterval,
},
color_strip_source_id: cssId,
standby_interval: standbyInterval,
};
try {
@@ -243,10 +222,11 @@ export async function loadTargetsTab() {
if (!container) return;
try {
// Fetch devices, targets, sources, and pattern templates in parallel
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
@@ -257,10 +237,16 @@ export async function loadTargetsTab() {
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let sourceMap = {};
if (sourcesResp && sourcesResp.ok) {
const srcData = await sourcesResp.json();
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
let colorStripSourceMap = {};
if (cssResp && cssResp.ok) {
const cssData = await cssResp.json();
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
}
let pictureSourceMap = {};
if (psResp && psResp.ok) {
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
let patternTemplates = [];
@@ -320,7 +306,7 @@ export async function loadTargetsTab() {
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
@@ -331,7 +317,7 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + targets section
// LED panel: devices section + color strip sources section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
@@ -343,10 +329,19 @@ export async function loadTargetsTab() {
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
<div class="devices-grid">
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showCSSEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
@@ -360,7 +355,7 @@ export async function loadTargetsTab() {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
@@ -422,17 +417,16 @@ export async function loadTargetsTab() {
}
}
export function createTargetCard(target, deviceMap, sourceMap) {
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const settings = target.settings || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const source = sourceMap[target.picture_source_id];
const css = colorStripSourceMap[target.color_strip_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
@@ -455,8 +449,7 @@ export function createTargetCard(target, deviceMap, sourceMap) {
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}"> ${settings.fps || 30}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
</div>
<div class="card-content">
${isProcessing ? `

View File

@@ -343,10 +343,11 @@
"streams.validate_image.valid": "Image 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.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
"targets.section.devices": "💡 Devices",
"targets.section.color_strips": "🎞️ Color Strip Sources",
"targets.section.targets": "⚡ Targets",
"targets.add": "Add Target",
"targets.edit": "Edit Target",
@@ -358,6 +359,8 @@
"targets.device": "Device:",
"targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --",
"targets.color_strip_source": "Color Strip Source:",
"targets.color_strip_source.hint": "Color strip source that captures and processes screen pixels into LED colors",
"targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --",
@@ -380,6 +383,7 @@
"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.name_required": "Please enter a target name",
"targets.error.delete": "Failed to delete target",
"targets.button.start": "Start",
"targets.button.stop": "Stop",
@@ -531,5 +535,36 @@
"aria.cancel": "Cancel",
"aria.previous": "Previous",
"aria.next": "Next",
"aria.hint": "Show hint"
"aria.hint": "Show hint",
"color_strip.add": "🎞️ Add Color Strip Source",
"color_strip.edit": "🎞️ Edit Color Strip Source",
"color_strip.name": "Name:",
"color_strip.name.placeholder": "Wall Strip",
"color_strip.picture_source": "Picture Source:",
"color_strip.picture_source.hint": "Which screen capture source to use as input for LED color calculation",
"color_strip.fps": "Target FPS:",
"color_strip.fps.hint": "Target frames per second for LED color updates (10-90)",
"color_strip.interpolation": "Color Mode:",
"color_strip.interpolation.hint": "How to calculate LED color from sampled border pixels",
"color_strip.interpolation.average": "Average",
"color_strip.interpolation.median": "Median",
"color_strip.interpolation.dominant": "Dominant",
"color_strip.smoothing": "Smoothing:",
"color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"color_strip.brightness": "Brightness:",
"color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.",
"color_strip.saturation": "Saturation:",
"color_strip.saturation.hint": "Color saturation (0=grayscale, 1=unchanged, 2=double saturation)",
"color_strip.gamma": "Gamma:",
"color_strip.gamma.hint": "Gamma correction (1=none, <1=brighter midtones, >1=darker midtones)",
"color_strip.test_device": "Test on Device:",
"color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles",
"color_strip.leds": "LED count",
"color_strip.created": "Color strip source created",
"color_strip.updated": "Color strip source updated",
"color_strip.deleted": "Color strip source deleted",
"color_strip.delete.confirm": "Are you sure you want to delete this color strip source?",
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
"color_strip.error.name_required": "Please enter a name"
}

View File

@@ -343,10 +343,11 @@
"streams.validate_image.valid": "Изображение доступно",
"streams.validate_image.invalid": "Изображение недоступно",
"targets.title": "⚡ Цели",
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
"targets.section.devices": "💡 Устройства",
"targets.section.color_strips": "🎞️ Источники цветовых полос",
"targets.section.targets": "⚡ Цели",
"targets.add": "Добавить Цель",
"targets.edit": "Редактировать Цель",
@@ -358,6 +359,8 @@
"targets.device": "Устройство:",
"targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --",
"targets.color_strip_source": "Источник цветовой полосы:",
"targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов",
"targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --",
@@ -380,6 +383,7 @@
"targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?",
"targets.error.load": "Не удалось загрузить цели",
"targets.error.required": "Пожалуйста, заполните все обязательные поля",
"targets.error.name_required": "Введите название цели",
"targets.error.delete": "Не удалось удалить цель",
"targets.button.start": "Запустить",
"targets.button.stop": "Остановить",
@@ -531,5 +535,36 @@
"aria.cancel": "Отмена",
"aria.previous": "Назад",
"aria.next": "Вперёд",
"aria.hint": "Показать подсказку"
"aria.hint": "Показать подсказку",
"color_strip.add": "🎞️ Добавить источник цветовой полосы",
"color_strip.edit": "🎞️ Редактировать источник цветовой полосы",
"color_strip.name": "Название:",
"color_strip.name.placeholder": "Настенная полоса",
"color_strip.picture_source": "Источник изображения:",
"color_strip.picture_source.hint": "Источник захвата экрана для расчёта цветов светодиодов",
"color_strip.fps": "Целевой FPS:",
"color_strip.fps.hint": "Целевая частота кадров для обновления цветов светодиодов (10-90)",
"color_strip.interpolation": "Режим цвета:",
"color_strip.interpolation.hint": "Как вычислять цвет светодиода по пикселям рамки",
"color_strip.interpolation.average": "Среднее",
"color_strip.interpolation.median": "Медиана",
"color_strip.interpolation.dominant": "Доминирующий",
"color_strip.smoothing": "Сглаживание:",
"color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.",
"color_strip.brightness": "Яркость:",
"color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.",
"color_strip.saturation": "Насыщенность:",
"color_strip.saturation.hint": "Насыщенность цвета (0=оттенки серого, 1=без изменений, 2=двойная насыщенность)",
"color_strip.gamma": "Гамма:",
"color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, <1=ярче средние тона, >1=темнее средние тона)",
"color_strip.test_device": "Тестировать на устройстве:",
"color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку",
"color_strip.leds": "Количество светодиодов",
"color_strip.created": "Источник цветовой полосы создан",
"color_strip.updated": "Источник цветовой полосы обновлён",
"color_strip.deleted": "Источник цветовой полосы удалён",
"color_strip.delete.confirm": "Удалить этот источник цветовой полосы?",
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название"
}

View File

@@ -0,0 +1,130 @@
"""Color strip source data model with inheritance-based source types.
A ColorStripSource produces a stream of LED color arrays (np.ndarray shape (N, 3))
from some input, encapsulating everything needed to drive a physical LED strip:
calibration, color correction, smoothing, and FPS.
Current types:
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
Future types (not yet implemented):
StaticColorStripSource — constant solid colors
GradientColorStripSource — animated gradient
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from wled_controller.core.capture.calibration import (
CalibrationConfig,
calibration_from_dict,
calibration_to_dict,
create_default_calibration,
)
@dataclass
class ColorStripSource:
"""Base class for color strip source configurations."""
id: str
name: str
source_type: str # "picture" | future types
created_at: datetime
updated_at: datetime
description: Optional[str] = None
def to_dict(self) -> dict:
"""Convert source to dictionary. Subclasses extend this."""
return {
"id": self.id,
"name": self.name,
"source_type": self.source_type,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
# Subclass fields default to None for forward compat
"picture_source_id": None,
"fps": None,
"brightness": None,
"saturation": None,
"gamma": None,
"smoothing": None,
"interpolation_mode": None,
"calibration": None,
}
@staticmethod
def from_dict(data: dict) -> "ColorStripSource":
"""Factory: dispatch to the correct subclass based on source_type."""
source_type: str = data.get("source_type", "picture") or "picture"
sid: str = data["id"]
name: str = data["name"]
description: str | None = data.get("description")
raw_created = data.get("created_at")
created_at: datetime = (
datetime.fromisoformat(raw_created)
if isinstance(raw_created, str)
else raw_created if isinstance(raw_created, datetime)
else datetime.utcnow()
)
raw_updated = data.get("updated_at")
updated_at: datetime = (
datetime.fromisoformat(raw_updated)
if isinstance(raw_updated, str)
else raw_updated if isinstance(raw_updated, datetime)
else datetime.utcnow()
)
calibration_data = data.get("calibration")
calibration = (
calibration_from_dict(calibration_data)
if calibration_data
else create_default_calibration(0)
)
# Only "picture" type for now; extend with elif branches for future types
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
created_at=created_at, updated_at=updated_at, description=description,
picture_source_id=data.get("picture_source_id") or "",
fps=data.get("fps") or 30,
brightness=data["brightness"] if data.get("brightness") is not None else 1.0,
saturation=data["saturation"] if data.get("saturation") is not None else 1.0,
gamma=data["gamma"] if data.get("gamma") is not None else 1.0,
smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3,
interpolation_mode=data.get("interpolation_mode") or "average",
calibration=calibration,
)
@dataclass
class PictureColorStripSource(ColorStripSource):
"""Color strip source driven by a PictureSource (screen capture / static image).
Contains everything required to produce LED color arrays from a picture stream:
calibration (LED positions), color correction, smoothing, FPS target.
"""
picture_source_id: str = ""
fps: int = 30
brightness: float = 1.0 # color correction multiplier (0.02.0; 1.0 = unchanged)
saturation: float = 1.0 # 1.0 = unchanged, 0.0 = grayscale, 2.0 = double saturation
gamma: float = 1.0 # 1.0 = no correction; <1 = brighter, >1 = darker mids
smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full)
interpolation_mode: str = "average" # "average" | "median" | "dominant"
calibration: CalibrationConfig = field(default_factory=lambda: create_default_calibration(0))
def to_dict(self) -> dict:
d = super().to_dict()
d["picture_source_id"] = self.picture_source_id
d["fps"] = self.fps
d["brightness"] = self.brightness
d["saturation"] = self.saturation
d["gamma"] = self.gamma
d["smoothing"] = self.smoothing
d["interpolation_mode"] = self.interpolation_mode
d["calibration"] = calibration_to_dict(self.calibration)
return d

View File

@@ -0,0 +1,224 @@
"""Color strip source 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.capture.calibration import calibration_to_dict
from wled_controller.storage.color_strip_source import (
ColorStripSource,
PictureColorStripSource,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ColorStripStore:
"""Persistent storage for color strip sources."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._sources: Dict[str, ColorStripSource] = {}
self._load()
def _load(self) -> None:
if not self.file_path.exists():
logger.info("Color strip store file not found — starting empty")
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
sources_data = data.get("color_strip_sources", {})
loaded = 0
for source_id, source_dict in sources_data.items():
try:
source = ColorStripSource.from_dict(source_dict)
self._sources[source_id] = source
loaded += 1
except Exception as e:
logger.error(f"Failed to load color strip source {source_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} color strip sources from storage")
except Exception as e:
logger.error(f"Failed to load color strip sources from {self.file_path}: {e}")
raise
logger.info(f"Color strip store initialized with {len(self._sources)} sources")
def _save(self) -> None:
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
sources_dict = {
sid: source.to_dict()
for sid, source in self._sources.items()
}
data = {
"version": "1.0.0",
"color_strip_sources": sources_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 color strip sources to {self.file_path}: {e}")
raise
def get_all_sources(self) -> List[ColorStripSource]:
return list(self._sources.values())
def get_source(self, source_id: str) -> ColorStripSource:
"""Get a color strip source by ID.
Raises:
ValueError: If source not found
"""
if source_id not in self._sources:
raise ValueError(f"Color strip source not found: {source_id}")
return self._sources[source_id]
def create_source(
self,
name: str,
source_type: str = "picture",
picture_source_id: str = "",
fps: int = 30,
brightness: float = 1.0,
saturation: float = 1.0,
gamma: float = 1.0,
smoothing: float = 0.3,
interpolation_mode: str = "average",
calibration=None,
description: Optional[str] = None,
) -> ColorStripSource:
"""Create a new color strip source.
Raises:
ValueError: If validation fails
"""
from wled_controller.core.capture.calibration import create_default_calibration
if not name or not name.strip():
raise ValueError("Name is required")
for source in self._sources.values():
if source.name == name:
raise ValueError(f"Color strip source with name '{name}' already exists")
if calibration is None:
calibration = create_default_calibration(0)
source_id = f"css_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
source = PictureColorStripSource(
id=source_id,
name=name,
source_type=source_type,
created_at=now,
updated_at=now,
description=description,
picture_source_id=picture_source_id,
fps=fps,
brightness=brightness,
saturation=saturation,
gamma=gamma,
smoothing=smoothing,
interpolation_mode=interpolation_mode,
calibration=calibration,
)
self._sources[source_id] = source
self._save()
logger.info(f"Created color strip source: {name} ({source_id}, type={source_type})")
return source
def update_source(
self,
source_id: str,
name: Optional[str] = None,
picture_source_id: Optional[str] = None,
fps: Optional[int] = None,
brightness: Optional[float] = None,
saturation: Optional[float] = None,
gamma: Optional[float] = None,
smoothing: Optional[float] = None,
interpolation_mode: Optional[str] = None,
calibration=None,
description: Optional[str] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
Raises:
ValueError: If source not found
"""
if source_id not in self._sources:
raise ValueError(f"Color strip source not found: {source_id}")
source = self._sources[source_id]
if name is not None:
for other in self._sources.values():
if other.id != source_id and other.name == name:
raise ValueError(f"Color strip source with name '{name}' already exists")
source.name = name
if description is not None:
source.description = description
if isinstance(source, PictureColorStripSource):
if picture_source_id is not None:
source.picture_source_id = picture_source_id
if fps is not None:
source.fps = fps
if brightness is not None:
source.brightness = brightness
if saturation is not None:
source.saturation = saturation
if gamma is not None:
source.gamma = gamma
if smoothing is not None:
source.smoothing = smoothing
if interpolation_mode is not None:
source.interpolation_mode = interpolation_mode
if calibration is not None:
source.calibration = calibration
source.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated color strip source: {source_id}")
return source
def delete_source(self, source_id: str) -> None:
"""Delete a color strip source.
Raises:
ValueError: If source not found
"""
if source_id not in self._sources:
raise ValueError(f"Color strip source not found: {source_id}")
del self._sources[source_id]
self._save()
logger.info(f"Deleted color strip source: {source_id}")
def is_referenced_by_target(self, source_id: str, target_store) -> bool:
"""Check if this source is referenced by any picture target."""
from wled_controller.storage.wled_picture_target import WledPictureTarget
for target in target_store.get_all_targets():
if isinstance(target, WledPictureTarget) and target.color_strip_source_id == source_id:
return True
return False

View File

@@ -6,12 +6,6 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from wled_controller.core.capture.calibration import (
CalibrationConfig,
calibration_from_dict,
calibration_to_dict,
create_default_calibration,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -20,8 +14,9 @@ logger = get_logger(__name__)
class Device:
"""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.
A device holds connection state and output settings.
Calibration, processing settings, and picture source assignments
now live on ColorStripSource and WledPictureTarget respectively.
"""
def __init__(
@@ -36,7 +31,6 @@ class Device:
software_brightness: int = 255,
auto_shutdown: bool = False,
static_color: Optional[Tuple[int, int, int]] = None,
calibration: Optional[CalibrationConfig] = None,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -50,9 +44,10 @@ class Device:
self.software_brightness = software_brightness
self.auto_shutdown = auto_shutdown
self.static_color = static_color
self.calibration = calibration or create_default_calibration(led_count)
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
# Preserved from old JSON for migration — not written back
self._legacy_calibration = None
def to_dict(self) -> dict:
"""Convert device to dictionary."""
@@ -63,7 +58,6 @@ class Device:
"led_count": self.led_count,
"enabled": self.enabled,
"device_type": self.device_type,
"calibration": calibration_to_dict(self.calibration),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -81,20 +75,13 @@ class Device:
def from_dict(cls, data: dict) -> "Device":
"""Create device from dictionary.
Backward-compatible: ignores legacy 'settings' and 'picture_source_id'
fields that have been migrated to PictureTarget.
Backward-compatible: reads legacy 'calibration' field and stores it
in _legacy_calibration for migration use only.
"""
calibration_data = data.get("calibration")
calibration = (
calibration_from_dict(calibration_data)
if calibration_data
else create_default_calibration(data["led_count"])
)
static_color_raw = data.get("static_color")
static_color = tuple(static_color_raw) if static_color_raw else None
return cls(
device = cls(
device_id=data["id"],
name=data["name"],
url=data["url"],
@@ -105,11 +92,21 @@ class Device:
software_brightness=data.get("software_brightness", 255),
auto_shutdown=data.get("auto_shutdown", False),
static_color=static_color,
calibration=calibration,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
# Preserve old calibration for migration (never written back by to_dict)
calibration_data = data.get("calibration")
if calibration_data:
try:
from wled_controller.core.capture.calibration import calibration_from_dict
device._legacy_calibration = calibration_from_dict(calibration_data)
except Exception:
pass
return device
class DeviceStore:
"""Persistent storage for WLED devices."""
@@ -129,7 +126,7 @@ class DeviceStore:
def load(self):
"""Load devices from storage file."""
if not self.storage_file.exists():
logger.info(f"Storage file does not exist, starting with empty store")
logger.info("Storage file does not exist, starting with empty store")
return
try:
@@ -190,7 +187,6 @@ class DeviceStore:
led_count: int,
device_type: str = "wled",
baud_rate: Optional[int] = None,
calibration: Optional[CalibrationConfig] = None,
auto_shutdown: bool = False,
) -> Device:
"""Create a new device."""
@@ -203,7 +199,6 @@ class DeviceStore:
led_count=led_count,
device_type=device_type,
baud_rate=baud_rate,
calibration=calibration,
auto_shutdown=auto_shutdown,
)
@@ -229,7 +224,6 @@ class DeviceStore:
led_count: Optional[int] = None,
enabled: Optional[bool] = None,
baud_rate: Optional[int] = None,
calibration: Optional[CalibrationConfig] = None,
auto_shutdown: Optional[bool] = None,
) -> Device:
"""Update device."""
@@ -249,13 +243,6 @@ class DeviceStore:
device.baud_rate = baud_rate
if auto_shutdown is not None:
device.auto_shutdown = auto_shutdown
if calibration is not None:
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
device.updated_at = datetime.utcnow()
self.save()

View File

@@ -6,7 +6,6 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
@@ -17,6 +16,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
class PictureTargetStore:
"""Persistent storage for picture targets."""
@@ -100,19 +101,24 @@ class PictureTargetStore:
name: str,
target_type: str,
device_id: str = "",
picture_source_id: str = "",
settings: Optional[ProcessingSettings] = None,
color_strip_source_id: str = "",
standby_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
# Legacy params — accepted but ignored for backward compat
picture_source_id: str = "",
settings=None,
) -> PictureTarget:
"""Create a new picture target.
Args:
name: Target name
target_type: Target type ("wled", "key_colors")
device_id: WLED device ID (for wled targets)
picture_source_id: Picture source ID
settings: Processing settings (for wled targets)
target_type: Target type ("led", "wled", "key_colors")
device_id: WLED device ID (for led targets)
color_strip_source_id: Color strip source ID (for led targets)
standby_interval: Keepalive interval in seconds (for led targets)
state_check_interval: State check interval in seconds (for led targets)
key_colors_settings: Key colors settings (for key_colors targets)
description: Optional description
@@ -139,8 +145,9 @@ class PictureTargetStore:
name=name,
target_type="led",
device_id=device_id,
picture_source_id=picture_source_id,
settings=settings or ProcessingSettings(),
color_strip_source_id=color_strip_source_id,
standby_interval=standby_interval,
state_check_interval=state_check_interval,
description=description,
created_at=now,
updated_at=now,
@@ -170,10 +177,14 @@ class PictureTargetStore:
target_id: str,
name: Optional[str] = None,
device_id: Optional[str] = None,
picture_source_id: Optional[str] = None,
settings: Optional[ProcessingSettings] = None,
color_strip_source_id: Optional[str] = None,
standby_interval: Optional[float] = None,
state_check_interval: Optional[int] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
# Legacy params — accepted but ignored
picture_source_id: Optional[str] = None,
settings=None,
) -> PictureTarget:
"""Update a picture target.
@@ -194,8 +205,9 @@ class PictureTargetStore:
target.update_fields(
name=name,
device_id=device_id,
picture_source_id=picture_source_id,
settings=settings,
color_strip_source_id=color_strip_source_id,
standby_interval=standby_interval,
state_check_interval=state_check_interval,
key_colors_settings=key_colors_settings,
description=description,
)
@@ -228,9 +240,16 @@ class PictureTargetStore:
]
def is_referenced_by_source(self, source_id: str) -> bool:
"""Check if any target references a picture source."""
"""Check if any KC target directly references a picture source."""
for target in self._targets.values():
if target.has_picture_source and target.picture_source_id == source_id:
if isinstance(target, KeyColorsPictureTarget) and target.picture_source_id == source_id:
return True
return False
def is_referenced_by_color_strip_source(self, css_id: str) -> bool:
"""Check if any WLED target references a color strip source."""
for target in self._targets.values():
if isinstance(target, WledPictureTarget) and target.color_strip_source_id == css_id:
return True
return False

View File

@@ -1,20 +1,31 @@
"""LED picture target — streams a picture source to an LED device."""
"""LED picture target — sends a color strip source to an LED device."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
@dataclass
class WledPictureTarget(PictureTarget):
"""LED picture target — streams a picture source to an LED device."""
"""LED picture target — pairs an LED device with a ColorStripSource.
The ColorStripSource encapsulates everything needed to produce LED colors
(calibration, color correction, smoothing, fps). The LED target itself only
holds device-specific timing/keepalive settings.
"""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
color_strip_source_id: str = ""
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
# Legacy fields — populated from old JSON data during migration; not written back
_legacy_picture_source_id: str = field(default="", repr=False, compare=False)
_legacy_settings: Optional[dict] = field(default=None, repr=False, compare=False)
def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager."""
@@ -22,74 +33,72 @@ class WledPictureTarget(PictureTarget):
manager.add_target(
target_id=self.id,
device_id=self.device_id,
settings=self.settings,
picture_source_id=self.picture_source_id,
color_strip_source_id=self.color_strip_source_id,
standby_interval=self.standby_interval,
state_check_interval=self.state_check_interval,
)
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
"""Push changed fields to the processor manager."""
if settings_changed:
manager.update_target_settings(self.id, self.settings)
manager.update_target_settings(self.id, {
"standby_interval": self.standby_interval,
"state_check_interval": self.state_check_interval,
})
if source_changed:
manager.update_target_source(self.id, self.picture_source_id)
manager.update_target_color_strip_source(self.id, self.color_strip_source_id)
if device_changed:
manager.update_target_device(self.id, self.device_id)
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None) -> None:
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
standby_interval=None, state_check_interval=None,
# Legacy params accepted but ignored to keep base class compat:
picture_source_id=None, settings=None,
key_colors_settings=None, description=None) -> None:
"""Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description)
if device_id is not None:
self.device_id = device_id
if picture_source_id is not None:
self.picture_source_id = picture_source_id
if settings is not None:
self.settings = settings
if color_strip_source_id is not None:
self.color_strip_source_id = color_strip_source_id
if standby_interval is not None:
self.standby_interval = standby_interval
if state_check_interval is not None:
self.state_check_interval = state_check_interval
@property
def has_picture_source(self) -> bool:
return True
return bool(self.color_strip_source_id)
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,
"brightness": self.settings.brightness,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"standby_interval": self.settings.standby_interval,
"state_check_interval": self.settings.state_check_interval,
}
d["color_strip_source_id"] = self.color_strip_source_id
d["standby_interval"] = self.standby_interval
d["state_check_interval"] = self.state_check_interval
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processing.processing_settings 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),
brightness=settings_data.get("brightness", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
standby_interval=settings_data.get("standby_interval", 1.0),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
"""Create from dictionary. Reads legacy picture_source_id/settings for migration."""
obj = cls(
id=data["id"],
name=data["name"],
target_type="led",
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
color_strip_source_id=data.get("color_strip_source_id", ""),
standby_interval=data.get("standby_interval", 1.0),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
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())),
)
# Preserve legacy fields for migration — never written back by to_dict()
obj._legacy_picture_source_id = data.get("picture_source_id", "")
settings_data = data.get("settings", {})
if settings_data:
obj._legacy_settings = settings_data
return obj

View File

@@ -104,6 +104,7 @@
{% include 'modals/calibration.html' %}
{% include 'modals/device-settings.html' %}
{% include 'modals/target-editor.html' %}
{% include 'modals/css-editor.html' %}
{% include 'modals/kc-editor.html' %}
{% include 'modals/pattern-template.html' %}
{% include 'modals/api-key.html' %}

View File

@@ -8,6 +8,16 @@
</div>
<div class="modal-body">
<input type="hidden" id="calibration-device-id">
<input type="hidden" id="calibration-css-id">
<!-- Device picker shown in CSS calibration mode for edge testing -->
<div id="calibration-css-test-group" class="form-group" style="display:none; margin-bottom: 12px; padding: 0 4px;">
<div class="label-row">
<label for="calibration-test-device" data-i18n="color_strip.test_device">Test on Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.test_device.hint">Select a device to send test pixels to when clicking edge toggles</small>
<select id="calibration-test-device"></select>
</div>
<!-- Interactive Preview with integrated LED inputs and test toggles -->
<div style="margin-bottom: 12px; padding: 0 24px;">
<div class="calibration-preview">

View File

@@ -0,0 +1,110 @@
<!-- Color Strip Source Editor Modal -->
<div id="css-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="css-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="css-editor-title" data-i18n="color_strip.add">🎞️ Add Color Strip Source</h2>
<button class="modal-close-btn" onclick="closeCSSEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="css-editor-form">
<input type="hidden" id="css-editor-id">
<div class="form-group">
<label for="css-editor-name" data-i18n="color_strip.name">Name:</label>
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.picture_source.hint">Which screen capture source to use as input for LED color calculation</small>
<select id="css-editor-picture-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-fps">
<span data-i18n="color_strip.fps">Target FPS:</span>
<span id="css-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.fps.hint">Target frames per second for LED color updates (10-90)</small>
<div class="slider-row">
<input type="range" id="css-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('css-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.interpolation.hint">How to calculate LED color from sampled border pixels</small>
<select id="css-editor-interpolation">
<option value="average" data-i18n="color_strip.interpolation.average">Average</option>
<option value="median" data-i18n="color_strip.interpolation.median">Median</option>
<option value="dominant" data-i18n="color_strip.interpolation.dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-smoothing">
<span data-i18n="color_strip.smoothing">Smoothing:</span>
<span id="css-editor-smoothing-value">0.30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-brightness">
<span data-i18n="color_strip.brightness">Brightness:</span>
<span id="css-editor-brightness-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.brightness.hint">Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.</small>
<input type="range" id="css-editor-brightness" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-brightness-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-saturation">
<span data-i18n="color_strip.saturation">Saturation:</span>
<span id="css-editor-saturation-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.saturation.hint">Color saturation (0=grayscale, 1=unchanged, 2=double saturation)</small>
<input type="range" id="css-editor-saturation" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-saturation-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-gamma">
<span data-i18n="color_strip.gamma">Gamma:</span>
<span id="css-editor-gamma-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gamma.hint">Gamma correction (1=none, &lt;1=brighter midtones, &gt;1=darker midtones)</small>
<input type="range" id="css-editor-gamma" min="0.1" max="3.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-gamma-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div id="css-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCSSEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCSSEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<!-- Target Editor Modal (name, device, source, settings) -->
<!-- Target Editor Modal (name, device, color strip source, standby) -->
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -25,48 +25,11 @@
<div class="form-group">
<div class="label-row">
<label for="target-editor-source" data-i18n="targets.source">Picture Source:</label>
<label for="target-editor-css" data-i18n="targets.color_strip_source">Color Strip Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<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-fps" data-i18n="targets.fps">Target FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">Target frames per second for capture and LED updates (10-90)</small>
<div class="slider-row">
<input type="range" id="target-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
<span id="target-editor-fps-value" class="slider-value">30</span>
</div>
</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="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="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">
<small class="input-hint" style="display:none" data-i18n="targets.color_strip_source.hint">Color strip source that captures and processes screen pixels into LED colors</small>
<select id="target-editor-css"></select>
</div>
<div class="form-group" id="target-editor-standby-group">