Introduce ColorStripSource as first-class entity
Extracts color processing and calibration out of WledPictureTarget into a new PictureColorStripSource entity, enabling multiple LED targets to share one capture/processing pipeline. New entities & processing: - storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models - storage/color_strip_store.py: JSON-backed CRUD store (prefix css_) - core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread) - core/processing/color_strip_stream_manager.py: ref-counted shared stream manager Modified storage/processing: - WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval - Device model: calibration field removed - WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline - ProcessorManager: wires ColorStripStreamManager into TargetContext API layer: - New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test - Removed calibration endpoints from /devices - Updated /picture-targets CRUD for new target structure Frontend: - New color-strips.js module with CSS editor modal and card rendering - Calibration modal extended with CSS mode (css-id hidden field + device picker) - targets.js: Color Strip Sources section added to LED tab; target editor/card updated - app.js: imports and window globals for CSS + showCSSCalibration - en.json / ru.json: color_strip.* and targets.section.color_strips keys added Data migration runs at startup: existing WledPictureTargets are converted to reference a new PictureColorStripSource created from their old settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router
|
|||||||
from .routes.picture_sources import router as picture_sources_router
|
from .routes.picture_sources import router as picture_sources_router
|
||||||
from .routes.pattern_templates import router as pattern_templates_router
|
from .routes.pattern_templates import router as pattern_templates_router
|
||||||
from .routes.picture_targets import router as picture_targets_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
|
from .routes.profiles import router as profiles_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -18,6 +19,7 @@ router.include_router(templates_router)
|
|||||||
router.include_router(postprocessing_router)
|
router.include_router(postprocessing_router)
|
||||||
router.include_router(pattern_templates_router)
|
router.include_router(pattern_templates_router)
|
||||||
router.include_router(picture_sources_router)
|
router.include_router(picture_sources_router)
|
||||||
|
router.include_router(color_strip_sources_router)
|
||||||
router.include_router(picture_targets_router)
|
router.include_router(picture_targets_router)
|
||||||
router.include_router(profiles_router)
|
router.include_router(profiles_router)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
|
|||||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
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.storage.profile_store import ProfileStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
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
|
_pattern_template_store: PatternTemplateStore | None = None
|
||||||
_picture_source_store: PictureSourceStore | None = None
|
_picture_source_store: PictureSourceStore | None = None
|
||||||
_picture_target_store: PictureTargetStore | None = None
|
_picture_target_store: PictureTargetStore | None = None
|
||||||
|
_color_strip_store: ColorStripStore | None = None
|
||||||
_processor_manager: ProcessorManager | None = None
|
_processor_manager: ProcessorManager | None = None
|
||||||
_profile_store: ProfileStore | None = None
|
_profile_store: ProfileStore | None = None
|
||||||
_profile_engine: ProfileEngine | None = None
|
_profile_engine: ProfileEngine | None = None
|
||||||
@@ -64,6 +66,13 @@ def get_picture_target_store() -> PictureTargetStore:
|
|||||||
return _picture_target_store
|
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:
|
def get_processor_manager() -> ProcessorManager:
|
||||||
"""Get processor manager dependency."""
|
"""Get processor manager dependency."""
|
||||||
if _processor_manager is None:
|
if _processor_manager is None:
|
||||||
@@ -93,13 +102,14 @@ def init_dependencies(
|
|||||||
pattern_template_store: PatternTemplateStore | None = None,
|
pattern_template_store: PatternTemplateStore | None = None,
|
||||||
picture_source_store: PictureSourceStore | None = None,
|
picture_source_store: PictureSourceStore | None = None,
|
||||||
picture_target_store: PictureTargetStore | None = None,
|
picture_target_store: PictureTargetStore | None = None,
|
||||||
|
color_strip_store: ColorStripStore | None = None,
|
||||||
profile_store: ProfileStore | None = None,
|
profile_store: ProfileStore | None = None,
|
||||||
profile_engine: ProfileEngine | None = None,
|
profile_engine: ProfileEngine | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
global _device_store, _template_store, _processor_manager
|
global _device_store, _template_store, _processor_manager
|
||||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
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
|
_device_store = device_store
|
||||||
_template_store = template_store
|
_template_store = template_store
|
||||||
_processor_manager = processor_manager
|
_processor_manager = processor_manager
|
||||||
@@ -107,5 +117,6 @@ def init_dependencies(
|
|||||||
_pattern_template_store = pattern_template_store
|
_pattern_template_store = pattern_template_store
|
||||||
_picture_source_store = picture_source_store
|
_picture_source_store = picture_source_store
|
||||||
_picture_target_store = picture_target_store
|
_picture_target_store = picture_target_store
|
||||||
|
_color_strip_store = color_strip_store
|
||||||
_profile_store = profile_store
|
_profile_store = profile_store
|
||||||
_profile_engine = profile_engine
|
_profile_engine = profile_engine
|
||||||
|
|||||||
258
server/src/wled_controller/api/routes/color_strip_sources.py
Normal file
258
server/src/wled_controller/api/routes/color_strip_sources.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""Color strip source routes: CRUD and calibration test."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_color_strip_store,
|
||||||
|
get_picture_target_store,
|
||||||
|
get_processor_manager,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.color_strip_sources import (
|
||||||
|
ColorStripSourceCreate,
|
||||||
|
ColorStripSourceListResponse,
|
||||||
|
ColorStripSourceResponse,
|
||||||
|
ColorStripSourceUpdate,
|
||||||
|
CSSCalibrationTestRequest,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.devices import (
|
||||||
|
Calibration as CalibrationSchema,
|
||||||
|
CalibrationTestModeResponse,
|
||||||
|
)
|
||||||
|
from wled_controller.core.capture.calibration import (
|
||||||
|
calibration_from_dict,
|
||||||
|
calibration_to_dict,
|
||||||
|
)
|
||||||
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
|
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||||
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||||
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _css_to_response(source) -> ColorStripSourceResponse:
|
||||||
|
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
|
||||||
|
calibration = None
|
||||||
|
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||||
|
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
|
||||||
|
|
||||||
|
return ColorStripSourceResponse(
|
||||||
|
id=source.id,
|
||||||
|
name=source.name,
|
||||||
|
source_type=source.source_type,
|
||||||
|
picture_source_id=getattr(source, "picture_source_id", None),
|
||||||
|
fps=getattr(source, "fps", None),
|
||||||
|
brightness=getattr(source, "brightness", None),
|
||||||
|
saturation=getattr(source, "saturation", None),
|
||||||
|
gamma=getattr(source, "gamma", None),
|
||||||
|
smoothing=getattr(source, "smoothing", None),
|
||||||
|
interpolation_mode=getattr(source, "interpolation_mode", None),
|
||||||
|
calibration=calibration,
|
||||||
|
description=source.description,
|
||||||
|
created_at=source.created_at,
|
||||||
|
updated_at=source.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD ENDPOINTS =====
|
||||||
|
|
||||||
|
@router.get("/api/v1/color-strip-sources", response_model=ColorStripSourceListResponse, tags=["Color Strip Sources"])
|
||||||
|
async def list_color_strip_sources(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
):
|
||||||
|
"""List all color strip sources."""
|
||||||
|
sources = store.get_all_sources()
|
||||||
|
responses = [_css_to_response(s) for s in sources]
|
||||||
|
return ColorStripSourceListResponse(sources=responses, count=len(responses))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
|
||||||
|
async def create_color_strip_source(
|
||||||
|
data: ColorStripSourceCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
):
|
||||||
|
"""Create a new color strip source."""
|
||||||
|
try:
|
||||||
|
calibration = None
|
||||||
|
if data.calibration is not None:
|
||||||
|
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||||
|
|
||||||
|
source = store.create_source(
|
||||||
|
name=data.name,
|
||||||
|
source_type=data.source_type,
|
||||||
|
picture_source_id=data.picture_source_id,
|
||||||
|
fps=data.fps,
|
||||||
|
brightness=data.brightness,
|
||||||
|
saturation=data.saturation,
|
||||||
|
gamma=data.gamma,
|
||||||
|
smoothing=data.smoothing,
|
||||||
|
interpolation_mode=data.interpolation_mode,
|
||||||
|
calibration=calibration,
|
||||||
|
description=data.description,
|
||||||
|
)
|
||||||
|
return _css_to_response(source)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create color strip source: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
|
||||||
|
async def get_color_strip_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
):
|
||||||
|
"""Get a color strip source by ID."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
return _css_to_response(source)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
|
||||||
|
async def update_color_strip_source(
|
||||||
|
source_id: str,
|
||||||
|
data: ColorStripSourceUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Update a color strip source and hot-reload any running streams."""
|
||||||
|
try:
|
||||||
|
calibration = None
|
||||||
|
if data.calibration is not None:
|
||||||
|
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||||
|
|
||||||
|
source = store.update_source(
|
||||||
|
source_id=source_id,
|
||||||
|
name=data.name,
|
||||||
|
picture_source_id=data.picture_source_id,
|
||||||
|
fps=data.fps,
|
||||||
|
brightness=data.brightness,
|
||||||
|
saturation=data.saturation,
|
||||||
|
gamma=data.gamma,
|
||||||
|
smoothing=data.smoothing,
|
||||||
|
interpolation_mode=data.interpolation_mode,
|
||||||
|
calibration=calibration,
|
||||||
|
description=data.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||||
|
try:
|
||||||
|
manager._color_strip_stream_manager.update_source(source_id, source)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
|
||||||
|
|
||||||
|
return _css_to_response(source)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update color strip source: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
|
||||||
|
async def delete_color_strip_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
):
|
||||||
|
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
||||||
|
try:
|
||||||
|
if target_store.is_referenced_by_color_strip_source(source_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Color strip source is referenced by one or more LED targets. "
|
||||||
|
"Delete or reassign the targets first.",
|
||||||
|
)
|
||||||
|
store.delete_source(source_id)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete color strip source: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CALIBRATION TEST =====
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/color-strip-sources/{source_id}/calibration/test",
|
||||||
|
response_model=CalibrationTestModeResponse,
|
||||||
|
tags=["Color Strip Sources"],
|
||||||
|
)
|
||||||
|
async def test_css_calibration(
|
||||||
|
source_id: str,
|
||||||
|
body: CSSCalibrationTestRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Temporarily light up LED edges to verify calibration.
|
||||||
|
|
||||||
|
Pass a device_id and an edges dict with RGB colors.
|
||||||
|
Send an empty edges dict to exit test mode.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate device exists in manager
|
||||||
|
if body.device_id not in manager._devices:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found")
|
||||||
|
|
||||||
|
# Validate edge names and colors
|
||||||
|
valid_edges = {"top", "right", "bottom", "left"}
|
||||||
|
for edge_name, color in body.edges.items():
|
||||||
|
if edge_name not in valid_edges:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}"
|
||||||
|
)
|
||||||
|
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get CSS calibration to send the right pixel pattern
|
||||||
|
calibration = None
|
||||||
|
if body.edges:
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||||
|
calibration = source.calibration
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
await manager.set_test_mode(body.device_id, body.edges, calibration)
|
||||||
|
|
||||||
|
active_edges = list(body.edges.keys())
|
||||||
|
logger.info(
|
||||||
|
f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} "
|
||||||
|
f"for device {body.device_id} via CSS {source_id}: {active_edges}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CalibrationTestModeResponse(
|
||||||
|
test_mode=len(active_edges) > 0,
|
||||||
|
active_edges=active_edges,
|
||||||
|
device_id=body.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set CSS calibration test mode: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -15,9 +15,6 @@ from wled_controller.api.dependencies import (
|
|||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.devices import (
|
from wled_controller.api.schemas.devices import (
|
||||||
Calibration as CalibrationSchema,
|
|
||||||
CalibrationTestModeRequest,
|
|
||||||
CalibrationTestModeResponse,
|
|
||||||
DeviceCreate,
|
DeviceCreate,
|
||||||
DeviceListResponse,
|
DeviceListResponse,
|
||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
@@ -27,10 +24,6 @@ from wled_controller.api.schemas.devices import (
|
|||||||
DiscoverDevicesResponse,
|
DiscoverDevicesResponse,
|
||||||
StaticColorUpdate,
|
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.core.processing.processor_manager import ProcessorManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
@@ -54,7 +47,6 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
static_color=list(device.static_color) if device.static_color else None,
|
static_color=list(device.static_color) if device.static_color else None,
|
||||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -133,7 +125,6 @@ async def create_device(
|
|||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
device_url=device.url,
|
device_url=device.url,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
calibration=device.calibration,
|
|
||||||
device_type=device.device_type,
|
device_type=device.device_type,
|
||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
@@ -534,105 +525,3 @@ async def set_device_color(
|
|||||||
|
|
||||||
return {"color": list(color) if color else None}
|
return {"color": list(color) if color else None}
|
||||||
|
|
||||||
|
|
||||||
# ===== CALIBRATION ENDPOINTS =====
|
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
|
||||||
async def get_calibration(
|
|
||||||
device_id: str,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
):
|
|
||||||
"""Get calibration configuration for a device."""
|
|
||||||
device = store.get_device(device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
|
||||||
|
|
||||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
|
||||||
async def update_calibration(
|
|
||||||
device_id: str,
|
|
||||||
calibration_data: CalibrationSchema,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Update calibration configuration for a device."""
|
|
||||||
try:
|
|
||||||
# Convert schema to CalibrationConfig
|
|
||||||
calibration_dict = calibration_data.model_dump()
|
|
||||||
calibration = calibration_from_dict(calibration_dict)
|
|
||||||
|
|
||||||
# Update in storage
|
|
||||||
device = store.update_device(device_id, calibration=calibration)
|
|
||||||
|
|
||||||
# Update in manager (also updates active target's cached calibration)
|
|
||||||
try:
|
|
||||||
manager.update_calibration(device_id, calibration)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return CalibrationSchema(**calibration_to_dict(device.calibration))
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update calibration: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/api/v1/devices/{device_id}/calibration/test",
|
|
||||||
response_model=CalibrationTestModeResponse,
|
|
||||||
tags=["Calibration"],
|
|
||||||
)
|
|
||||||
async def set_calibration_test_mode(
|
|
||||||
device_id: str,
|
|
||||||
body: CalibrationTestModeRequest,
|
|
||||||
_auth: AuthRequired,
|
|
||||||
store: DeviceStore = Depends(get_device_store),
|
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
|
||||||
):
|
|
||||||
"""Toggle calibration test mode for specific edges."""
|
|
||||||
try:
|
|
||||||
device = store.get_device(device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
|
||||||
|
|
||||||
# Validate edge names and colors
|
|
||||||
valid_edges = {"top", "right", "bottom", "left"}
|
|
||||||
for edge_name, color in body.edges.items():
|
|
||||||
if edge_name not in valid_edges:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}"
|
|
||||||
)
|
|
||||||
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255."
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager.set_test_mode(device_id, body.edges)
|
|
||||||
|
|
||||||
active_edges = list(body.edges.keys())
|
|
||||||
logger.info(
|
|
||||||
f"Test mode {'activated' if active_edges else 'deactivated'} "
|
|
||||||
f"for device {device_id}: {active_edges}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return CalibrationTestModeResponse(
|
|
||||||
test_mode=len(active_edges) > 0,
|
|
||||||
active_edges=active_edges,
|
|
||||||
device_id=device_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to set test mode: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from wled_controller.api.schemas.picture_targets import (
|
|||||||
PictureTargetListResponse,
|
PictureTargetListResponse,
|
||||||
PictureTargetResponse,
|
PictureTargetResponse,
|
||||||
PictureTargetUpdate,
|
PictureTargetUpdate,
|
||||||
ProcessingSettings as ProcessingSettingsSchema,
|
|
||||||
TargetMetricsResponse,
|
TargetMetricsResponse,
|
||||||
TargetProcessingState,
|
TargetProcessingState,
|
||||||
)
|
)
|
||||||
@@ -37,7 +36,6 @@ from wled_controller.config import get_config
|
|||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
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 (
|
from wled_controller.core.capture.screen_capture import (
|
||||||
calculate_average_color,
|
calculate_average_color,
|
||||||
calculate_dominant_color,
|
calculate_dominant_color,
|
||||||
@@ -61,32 +59,6 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter()
|
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:
|
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
|
||||||
"""Convert core KeyColorsSettings to schema."""
|
"""Convert core KeyColorsSettings to schema."""
|
||||||
return KeyColorsSettingsSchema(
|
return KeyColorsSettingsSchema(
|
||||||
@@ -117,8 +89,9 @@ def _target_to_response(target) -> PictureTargetResponse:
|
|||||||
name=target.name,
|
name=target.name,
|
||||||
target_type=target.target_type,
|
target_type=target.target_type,
|
||||||
device_id=target.device_id,
|
device_id=target.device_id,
|
||||||
picture_source_id=target.picture_source_id,
|
color_strip_source_id=target.color_strip_source_id,
|
||||||
settings=_settings_to_schema(target.settings),
|
standby_interval=target.standby_interval,
|
||||||
|
state_check_interval=target.state_check_interval,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
updated_at=target.updated_at,
|
updated_at=target.updated_at,
|
||||||
@@ -163,8 +136,6 @@ async def create_target(
|
|||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
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
|
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||||
|
|
||||||
# Create in store
|
# Create in store
|
||||||
@@ -172,8 +143,10 @@ async def create_target(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
target_type=data.target_type,
|
target_type=data.target_type,
|
||||||
device_id=data.device_id,
|
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,
|
picture_source_id=data.picture_source_id,
|
||||||
settings=core_settings,
|
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
@@ -237,8 +210,6 @@ async def update_target(
|
|||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
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
|
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||||
|
|
||||||
# Update in store
|
# Update in store
|
||||||
@@ -246,8 +217,10 @@ async def update_target(
|
|||||||
target_id=target_id,
|
target_id=target_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
device_id=data.device_id,
|
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,
|
picture_source_id=data.picture_source_id,
|
||||||
settings=core_settings,
|
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
@@ -256,8 +229,10 @@ async def update_target(
|
|||||||
try:
|
try:
|
||||||
target.sync_with_manager(
|
target.sync_with_manager(
|
||||||
manager,
|
manager,
|
||||||
settings_changed=data.settings is not None or data.key_colors_settings is not None,
|
settings_changed=(data.standby_interval is not None or
|
||||||
source_changed=data.picture_source_id is not None,
|
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,
|
device_changed=data.device_id is not None,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -375,76 +350,6 @@ async def get_target_state(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@router.get("/api/v1/picture-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
|
||||||
async def get_target_metrics(
|
async def get_target_metrics(
|
||||||
target_id: str,
|
target_id: str,
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ from .devices import (
|
|||||||
DeviceStateResponse,
|
DeviceStateResponse,
|
||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
)
|
)
|
||||||
|
from .color_strip_sources import (
|
||||||
|
ColorStripSourceCreate,
|
||||||
|
ColorStripSourceListResponse,
|
||||||
|
ColorStripSourceResponse,
|
||||||
|
ColorStripSourceUpdate,
|
||||||
|
CSSCalibrationTestRequest,
|
||||||
|
)
|
||||||
from .picture_targets import (
|
from .picture_targets import (
|
||||||
PictureTargetCreate,
|
PictureTargetCreate,
|
||||||
PictureTargetListResponse,
|
PictureTargetListResponse,
|
||||||
PictureTargetResponse,
|
PictureTargetResponse,
|
||||||
PictureTargetUpdate,
|
PictureTargetUpdate,
|
||||||
ProcessingSettings,
|
|
||||||
TargetMetricsResponse,
|
TargetMetricsResponse,
|
||||||
TargetProcessingState,
|
TargetProcessingState,
|
||||||
)
|
)
|
||||||
@@ -89,11 +95,15 @@ __all__ = [
|
|||||||
"DeviceResponse",
|
"DeviceResponse",
|
||||||
"DeviceStateResponse",
|
"DeviceStateResponse",
|
||||||
"DeviceUpdate",
|
"DeviceUpdate",
|
||||||
|
"ColorStripSourceCreate",
|
||||||
|
"ColorStripSourceListResponse",
|
||||||
|
"ColorStripSourceResponse",
|
||||||
|
"ColorStripSourceUpdate",
|
||||||
|
"CSSCalibrationTestRequest",
|
||||||
"PictureTargetCreate",
|
"PictureTargetCreate",
|
||||||
"PictureTargetListResponse",
|
"PictureTargetListResponse",
|
||||||
"PictureTargetResponse",
|
"PictureTargetResponse",
|
||||||
"PictureTargetUpdate",
|
"PictureTargetUpdate",
|
||||||
"ProcessingSettings",
|
|
||||||
"TargetMetricsResponse",
|
"TargetMetricsResponse",
|
||||||
"TargetProcessingState",
|
"TargetProcessingState",
|
||||||
"EngineInfo",
|
"EngineInfo",
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Color strip source schemas (CRUD)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from wled_controller.api.schemas.devices import Calibration
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceCreate(BaseModel):
|
||||||
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
|
source_type: Literal["picture"] = Field(default="picture", description="Source type")
|
||||||
|
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||||
|
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
|
||||||
|
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||||
|
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
|
||||||
|
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
|
||||||
|
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||||
|
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||||
|
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceUpdate(BaseModel):
|
||||||
|
"""Request to update a color strip source."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||||
|
fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90)
|
||||||
|
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||||
|
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
|
||||||
|
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
|
||||||
|
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceResponse(BaseModel):
|
||||||
|
"""Color strip source response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Source ID")
|
||||||
|
name: str = Field(description="Source name")
|
||||||
|
source_type: str = Field(description="Source type")
|
||||||
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||||
|
fps: Optional[int] = Field(None, description="Target FPS")
|
||||||
|
brightness: Optional[float] = Field(None, description="Brightness multiplier")
|
||||||
|
saturation: Optional[float] = Field(None, description="Saturation")
|
||||||
|
gamma: Optional[float] = Field(None, description="Gamma correction")
|
||||||
|
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||||
|
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||||
|
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||||
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class ColorStripSourceListResponse(BaseModel):
|
||||||
|
"""List of color strip sources."""
|
||||||
|
|
||||||
|
sources: List[ColorStripSourceResponse] = Field(description="List of color strip sources")
|
||||||
|
count: int = Field(description="Number of sources")
|
||||||
|
|
||||||
|
|
||||||
|
class CSSCalibrationTestRequest(BaseModel):
|
||||||
|
"""Request to run a calibration test for a color strip source on a specific device."""
|
||||||
|
|
||||||
|
device_id: str = Field(description="Device ID to send test pixels to")
|
||||||
|
edges: dict = Field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"top": [255, 0, 0],
|
||||||
|
"right": [0, 255, 0],
|
||||||
|
"bottom": [0, 100, 255],
|
||||||
|
"left": [255, 255, 0],
|
||||||
|
},
|
||||||
|
description="Map of edge names to RGB colors. Empty dict = exit test mode.",
|
||||||
|
)
|
||||||
@@ -104,7 +104,6 @@ class DeviceResponse(BaseModel):
|
|||||||
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
|
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]")
|
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")
|
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")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
"""Picture target schemas (CRUD, processing state, settings, metrics)."""
|
"""Picture target schemas (CRUD, processing state, metrics)."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Literal, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KeyColorRectangleSchema(BaseModel):
|
class KeyColorRectangleSchema(BaseModel):
|
||||||
@@ -65,9 +50,13 @@ class PictureTargetCreate(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||||
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
||||||
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
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)")
|
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)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
|
||||||
@@ -76,9 +65,13 @@ class PictureTargetUpdate(BaseModel):
|
|||||||
"""Request to update a picture target."""
|
"""Request to update a picture target."""
|
||||||
|
|
||||||
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
||||||
device_id: Optional[str] = Field(None, description="WLED device ID")
|
# LED target fields
|
||||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
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)")
|
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)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
|
||||||
@@ -89,10 +82,14 @@ class PictureTargetResponse(BaseModel):
|
|||||||
id: str = Field(description="Target ID")
|
id: str = Field(description="Target ID")
|
||||||
name: str = Field(description="Target name")
|
name: str = Field(description="Target name")
|
||||||
target_type: str = Field(description="Target type")
|
target_type: str = Field(description="Target type")
|
||||||
device_id: str = Field(default="", description="WLED device ID")
|
# LED target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)")
|
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")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
@@ -110,21 +107,18 @@ class TargetProcessingState(BaseModel):
|
|||||||
|
|
||||||
target_id: str = Field(description="Target ID")
|
target_id: str = Field(description="Target ID")
|
||||||
device_id: Optional[str] = Field(None, description="Device 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")
|
processing: bool = Field(description="Whether processing is active")
|
||||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||||
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
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_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
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")
|
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_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_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)")
|
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")
|
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
||||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
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")
|
device_id: Optional[str] = Field(None, description="Device ID")
|
||||||
processing: bool = Field(description="Whether processing is active")
|
processing: bool = Field(description="Whether processing is active")
|
||||||
fps_actual: Optional[float] = Field(None, description="Actual FPS")
|
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")
|
uptime_seconds: float = Field(description="Processing uptime in seconds")
|
||||||
frames_processed: int = Field(description="Total frames processed")
|
frames_processed: int = Field(description="Total frames processed")
|
||||||
errors_count: int = Field(description="Total error count")
|
errors_count: int = Field(description="Total error count")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class StorageConfig(BaseSettings):
|
|||||||
picture_sources_file: str = "data/picture_sources.json"
|
picture_sources_file: str = "data/picture_sources.json"
|
||||||
picture_targets_file: str = "data/picture_targets.json"
|
picture_targets_file: str = "data/picture_targets.json"
|
||||||
pattern_templates_file: str = "data/pattern_templates.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"
|
profiles_file: str = "data/profiles.json"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Target processing pipeline."""
|
"""Target processing pipeline."""
|
||||||
|
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
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 (
|
from wled_controller.core.processing.target_processor import (
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
ProcessingMetrics,
|
ProcessingMetrics,
|
||||||
@@ -13,10 +9,8 @@ from wled_controller.core.processing.target_processor import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_STATE_CHECK_INTERVAL",
|
|
||||||
"DeviceInfo",
|
"DeviceInfo",
|
||||||
"ProcessingMetrics",
|
"ProcessingMetrics",
|
||||||
"ProcessingSettings",
|
|
||||||
"ProcessorManager",
|
"ProcessorManager",
|
||||||
"TargetContext",
|
"TargetContext",
|
||||||
"TargetProcessor",
|
"TargetProcessor",
|
||||||
|
|||||||
321
server/src/wled_controller/core/processing/color_strip_stream.py
Normal file
321
server/src/wled_controller/core/processing/color_strip_stream.py
Normal 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)
|
||||||
@@ -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()
|
||||||
|
]
|
||||||
@@ -93,7 +93,7 @@ class KCTargetProcessor(TargetProcessor):
|
|||||||
settings, # KeyColorsSettings
|
settings, # KeyColorsSettings
|
||||||
ctx: TargetContext,
|
ctx: TargetContext,
|
||||||
):
|
):
|
||||||
super().__init__(target_id, picture_source_id, ctx)
|
super().__init__(target_id, ctx, picture_source_id)
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wled_controller.core.capture.calibration import (
|
from wled_controller.core.capture.calibration import CalibrationConfig
|
||||||
CalibrationConfig,
|
|
||||||
create_default_calibration,
|
|
||||||
)
|
|
||||||
from wled_controller.core.devices.led_client import (
|
from wled_controller.core.devices.led_client import (
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
check_device_health,
|
check_device_health,
|
||||||
@@ -17,11 +14,8 @@ from wled_controller.core.devices.led_client import (
|
|||||||
get_provider,
|
get_provider,
|
||||||
)
|
)
|
||||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
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.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 (
|
from wled_controller.core.processing.target_processor import (
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
TargetContext,
|
TargetContext,
|
||||||
@@ -33,15 +27,16 @@ from wled_controller.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeviceState:
|
class DeviceState:
|
||||||
"""State for a registered LED device (health monitoring + calibration)."""
|
"""State for a registered LED device (health monitoring)."""
|
||||||
|
|
||||||
device_id: str
|
device_id: str
|
||||||
device_url: str
|
device_url: str
|
||||||
led_count: int
|
led_count: int
|
||||||
calibration: CalibrationConfig
|
|
||||||
device_type: str = "wled"
|
device_type: str = "wled"
|
||||||
baud_rate: Optional[int] = None
|
baud_rate: Optional[int] = None
|
||||||
health: DeviceHealth = field(default_factory=DeviceHealth)
|
health: DeviceHealth = field(default_factory=DeviceHealth)
|
||||||
@@ -55,6 +50,8 @@ class DeviceState:
|
|||||||
# Calibration test mode (works independently of target processing)
|
# Calibration test mode (works independently of target processing)
|
||||||
test_mode_active: bool = False
|
test_mode_active: bool = False
|
||||||
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
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)
|
# Tracked power state for serial devices (no hardware query)
|
||||||
power_on: bool = True
|
power_on: bool = True
|
||||||
|
|
||||||
@@ -62,11 +59,11 @@ class DeviceState:
|
|||||||
class ProcessorManager:
|
class ProcessorManager:
|
||||||
"""Manages devices and delegates target processing to TargetProcessor instances.
|
"""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.
|
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."""
|
"""Initialize processor manager."""
|
||||||
self._devices: Dict[str, DeviceState] = {}
|
self._devices: Dict[str, DeviceState] = {}
|
||||||
self._processors: Dict[str, TargetProcessor] = {}
|
self._processors: Dict[str, TargetProcessor] = {}
|
||||||
@@ -78,9 +75,14 @@ class ProcessorManager:
|
|||||||
self._pp_template_store = pp_template_store
|
self._pp_template_store = pp_template_store
|
||||||
self._pattern_template_store = pattern_template_store
|
self._pattern_template_store = pattern_template_store
|
||||||
self._device_store = device_store
|
self._device_store = device_store
|
||||||
|
self._color_strip_store = color_strip_store
|
||||||
self._live_stream_manager = LiveStreamManager(
|
self._live_stream_manager = LiveStreamManager(
|
||||||
picture_source_store, capture_template_store, pp_template_store
|
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._overlay_manager = OverlayManager()
|
||||||
self._event_queues: List[asyncio.Queue] = []
|
self._event_queues: List[asyncio.Queue] = []
|
||||||
logger.info("Processor manager initialized")
|
logger.info("Processor manager initialized")
|
||||||
@@ -97,6 +99,7 @@ class ProcessorManager:
|
|||||||
pp_template_store=self._pp_template_store,
|
pp_template_store=self._pp_template_store,
|
||||||
pattern_template_store=self._pattern_template_store,
|
pattern_template_store=self._pattern_template_store,
|
||||||
device_store=self._device_store,
|
device_store=self._device_store,
|
||||||
|
color_strip_stream_manager=self._color_strip_stream_manager,
|
||||||
fire_event=self._fire_event,
|
fire_event=self._fire_event,
|
||||||
get_device_info=self._get_device_info,
|
get_device_info=self._get_device_info,
|
||||||
)
|
)
|
||||||
@@ -110,7 +113,6 @@ class ProcessorManager:
|
|||||||
device_id=ds.device_id,
|
device_id=ds.device_id,
|
||||||
device_url=ds.device_url,
|
device_url=ds.device_url,
|
||||||
led_count=ds.led_count,
|
led_count=ds.led_count,
|
||||||
calibration=ds.calibration,
|
|
||||||
device_type=ds.device_type,
|
device_type=ds.device_type,
|
||||||
baud_rate=ds.baud_rate,
|
baud_rate=ds.baud_rate,
|
||||||
software_brightness=ds.software_brightness,
|
software_brightness=ds.software_brightness,
|
||||||
@@ -144,14 +146,13 @@ class ProcessorManager:
|
|||||||
self._http_client = httpx.AsyncClient(timeout=5)
|
self._http_client = httpx.AsyncClient(timeout=5)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|
||||||
# ===== DEVICE MANAGEMENT (health monitoring + calibration) =====
|
# ===== DEVICE MANAGEMENT (health monitoring) =====
|
||||||
|
|
||||||
def add_device(
|
def add_device(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
device_url: str,
|
device_url: str,
|
||||||
led_count: int,
|
led_count: int,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
|
||||||
device_type: str = "wled",
|
device_type: str = "wled",
|
||||||
baud_rate: Optional[int] = None,
|
baud_rate: Optional[int] = None,
|
||||||
software_brightness: int = 255,
|
software_brightness: int = 255,
|
||||||
@@ -162,14 +163,10 @@ class ProcessorManager:
|
|||||||
if device_id in self._devices:
|
if device_id in self._devices:
|
||||||
raise ValueError(f"Device {device_id} already registered")
|
raise ValueError(f"Device {device_id} already registered")
|
||||||
|
|
||||||
if calibration is None:
|
|
||||||
calibration = create_default_calibration(led_count)
|
|
||||||
|
|
||||||
state = DeviceState(
|
state = DeviceState(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
device_url=device_url,
|
device_url=device_url,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
calibration=calibration,
|
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
baud_rate=baud_rate,
|
baud_rate=baud_rate,
|
||||||
software_brightness=software_brightness,
|
software_brightness=software_brightness,
|
||||||
@@ -214,34 +211,8 @@ class ProcessorManager:
|
|||||||
if baud_rate is not None:
|
if baud_rate is not None:
|
||||||
ds.baud_rate = baud_rate
|
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:
|
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:
|
if device_id not in self._devices:
|
||||||
raise ValueError(f"Device {device_id} not found")
|
raise ValueError(f"Device {device_id} not found")
|
||||||
return self._devices[device_id]
|
return self._devices[device_id]
|
||||||
@@ -298,8 +269,9 @@ class ProcessorManager:
|
|||||||
self,
|
self,
|
||||||
target_id: str,
|
target_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
color_strip_source_id: str = "",
|
||||||
picture_source_id: str = "",
|
standby_interval: float = 1.0,
|
||||||
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
):
|
):
|
||||||
"""Register a WLED target processor."""
|
"""Register a WLED target processor."""
|
||||||
if target_id in self._processors:
|
if target_id in self._processors:
|
||||||
@@ -310,8 +282,9 @@ class ProcessorManager:
|
|||||||
proc = WledTargetProcessor(
|
proc = WledTargetProcessor(
|
||||||
target_id=target_id,
|
target_id=target_id,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
settings=settings or ProcessingSettings(),
|
color_strip_source_id=color_strip_source_id,
|
||||||
picture_source_id=picture_source_id,
|
standby_interval=standby_interval,
|
||||||
|
state_check_interval=state_check_interval,
|
||||||
ctx=self._build_context(),
|
ctx=self._build_context(),
|
||||||
)
|
)
|
||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
@@ -357,6 +330,11 @@ class ProcessorManager:
|
|||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
proc.update_source(picture_source_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):
|
def update_target_device(self, target_id: str, device_id: str):
|
||||||
"""Update the device for a target."""
|
"""Update the device for a target."""
|
||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
@@ -499,10 +477,19 @@ class ProcessorManager:
|
|||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
return proc.get_latest_colors()
|
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:
|
async def set_test_mode(
|
||||||
"""Set or clear calibration test mode for a device."""
|
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:
|
if device_id not in self._devices:
|
||||||
raise ValueError(f"Device {device_id} not found")
|
raise ValueError(f"Device {device_id} not found")
|
||||||
|
|
||||||
@@ -513,10 +500,13 @@ class ProcessorManager:
|
|||||||
ds.test_mode_edges = {
|
ds.test_mode_edges = {
|
||||||
edge: tuple(color) for edge, color in edges.items()
|
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)
|
await self._send_test_pixels(device_id)
|
||||||
else:
|
else:
|
||||||
ds.test_mode_active = False
|
ds.test_mode_active = False
|
||||||
ds.test_mode_edges = {}
|
ds.test_mode_edges = {}
|
||||||
|
ds.test_calibration = None
|
||||||
await self._send_clear_pixels(device_id)
|
await self._send_clear_pixels(device_id)
|
||||||
await self._close_idle_client(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:
|
async def _send_test_pixels(self, device_id: str) -> None:
|
||||||
"""Build and send test pixel array for active test edges."""
|
"""Build and send test pixel array for active test edges."""
|
||||||
ds = self._devices[device_id]
|
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
|
pixels = [(0, 0, 0)] * ds.led_count
|
||||||
|
|
||||||
for edge_name, color in ds.test_mode_edges.items():
|
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:
|
if seg.edge == edge_name:
|
||||||
for i in range(seg.led_start, seg.led_start + seg.led_count):
|
for i in range(seg.led_start, seg.led_start + seg.led_count):
|
||||||
if i < ds.led_count:
|
if i < ds.led_count:
|
||||||
@@ -569,8 +565,8 @@ class ProcessorManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
|
# Apply offset rotation (same as Phase 2 in PixelMapper.map_border_to_leds)
|
||||||
total_leds = ds.calibration.get_total_leds()
|
total_leds = ds.test_calibration.get_total_leds()
|
||||||
offset = ds.calibration.offset % total_leds if total_leds > 0 else 0
|
offset = ds.test_calibration.offset % total_leds if total_leds > 0 else 0
|
||||||
if offset > 0:
|
if offset > 0:
|
||||||
pixels = pixels[-offset:] + pixels[:-offset]
|
pixels = pixels[-offset:] + pixels[:-offset]
|
||||||
|
|
||||||
@@ -701,6 +697,9 @@ class ProcessorManager:
|
|||||||
for did in list(self._idle_clients):
|
for did in list(self._idle_clients):
|
||||||
await self._close_idle_client(did)
|
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
|
# Safety net: release any remaining managed live streams
|
||||||
self._live_stream_manager.release_all()
|
self._live_stream_manager.release_all()
|
||||||
|
|
||||||
@@ -780,7 +779,7 @@ class ProcessorManager:
|
|||||||
state.device_type, state.device_url, client, state.health,
|
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
|
reported = state.health.device_led_count
|
||||||
if reported and reported != state.led_count and self._device_store:
|
if reported and reported != state.led_count and self._device_store:
|
||||||
old_count = state.led_count
|
old_count = state.led_count
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
|
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import httpx
|
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||||
from wled_controller.core.capture.calibration import CalibrationConfig
|
|
||||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||||
from wled_controller.core.capture.screen_overlay import OverlayManager
|
from wled_controller.core.capture.screen_overlay import OverlayManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
@@ -65,7 +64,6 @@ class DeviceInfo:
|
|||||||
device_id: str
|
device_id: str
|
||||||
device_url: str
|
device_url: str
|
||||||
led_count: int
|
led_count: int
|
||||||
calibration: "CalibrationConfig"
|
|
||||||
device_type: str = "wled"
|
device_type: str = "wled"
|
||||||
baud_rate: Optional[int] = None
|
baud_rate: Optional[int] = None
|
||||||
software_brightness: int = 255
|
software_brightness: int = 255
|
||||||
@@ -86,6 +84,7 @@ class TargetContext:
|
|||||||
pp_template_store: Optional["PostprocessingTemplateStore"] = None
|
pp_template_store: Optional["PostprocessingTemplateStore"] = None
|
||||||
pattern_template_store: Optional["PatternTemplateStore"] = None
|
pattern_template_store: Optional["PatternTemplateStore"] = None
|
||||||
device_store: Optional["DeviceStore"] = None
|
device_store: Optional["DeviceStore"] = None
|
||||||
|
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
|
||||||
fire_event: Callable[[dict], None] = lambda e: None
|
fire_event: Callable[[dict], None] = lambda e: None
|
||||||
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
||||||
|
|
||||||
@@ -100,7 +99,7 @@ class TargetProcessor(ABC):
|
|||||||
Lifecycle: register → start → (running loop) → stop → unregister
|
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._target_id = target_id
|
||||||
self._picture_source_id = picture_source_id
|
self._picture_source_id = picture_source_id
|
||||||
self._ctx = ctx
|
self._ctx = ctx
|
||||||
@@ -161,8 +160,8 @@ class TargetProcessor(ABC):
|
|||||||
"""Update device association. Raises for targets without devices."""
|
"""Update device association. Raises for targets without devices."""
|
||||||
raise ValueError(f"Target {self._target_id} does not support device assignment")
|
raise ValueError(f"Target {self._target_id} does not support device assignment")
|
||||||
|
|
||||||
def update_calibration(self, calibration) -> None:
|
def update_color_strip_source(self, color_strip_source_id: str) -> None:
|
||||||
"""Update calibration. No-op for targets without devices."""
|
"""Update color strip source. No-op for targets that don't use CSS."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ----- Device / display info (overridden by device-aware subclasses) -----
|
# ----- Device / display info (overridden by device-aware subclasses) -----
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import concurrent.futures
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
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.devices.led_client import LEDClient, create_led_client
|
||||||
from wled_controller.core.processing.live_stream import LiveStream
|
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||||
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.processing.target_processor import (
|
from wled_controller.core.processing.target_processor import (
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
ProcessingMetrics,
|
ProcessingMetrics,
|
||||||
@@ -27,92 +20,44 @@ from wled_controller.core.processing.target_processor import (
|
|||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from wled_controller.core.capture_engines.base import ScreenCapture
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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
|
# WledTargetProcessor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class WledTargetProcessor(TargetProcessor):
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
target_id: str,
|
target_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
settings: ProcessingSettings,
|
color_strip_source_id: str,
|
||||||
picture_source_id: str,
|
standby_interval: float,
|
||||||
|
state_check_interval: int,
|
||||||
ctx: TargetContext,
|
ctx: TargetContext,
|
||||||
):
|
):
|
||||||
super().__init__(target_id, picture_source_id, ctx)
|
super().__init__(target_id, ctx)
|
||||||
self._device_id = device_id
|
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)
|
# Runtime state (populated on start)
|
||||||
self._led_client: Optional[LEDClient] = None
|
self._led_client: Optional[LEDClient] = None
|
||||||
self._pixel_mapper: Optional[PixelMapper] = None
|
self._color_strip_stream = None
|
||||||
self._live_stream: Optional[LiveStream] = None
|
|
||||||
self._previous_colors: Optional[np.ndarray] = None
|
|
||||||
self._device_state_before: Optional[dict] = None
|
self._device_state_before: Optional[dict] = None
|
||||||
self._overlay_active = False
|
self._overlay_active = False
|
||||||
|
|
||||||
# Resolved stream metadata
|
# Resolved stream metadata (set once stream is acquired)
|
||||||
self._resolved_display_index: Optional[int] = None
|
self._resolved_display_index: Optional[int] = None
|
||||||
self._resolved_target_fps: 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 -----
|
# ----- Properties -----
|
||||||
|
|
||||||
@@ -120,10 +65,6 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
def device_id(self) -> str:
|
def device_id(self) -> str:
|
||||||
return self._device_id
|
return self._device_id
|
||||||
|
|
||||||
@property
|
|
||||||
def settings(self) -> ProcessingSettings:
|
|
||||||
return self._settings
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def led_client(self) -> Optional[LEDClient]:
|
def led_client(self) -> Optional[LEDClient]:
|
||||||
return self._led_client
|
return self._led_client
|
||||||
@@ -139,9 +80,6 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if device_info is None:
|
if device_info is None:
|
||||||
raise ValueError(f"Device {self._device_id} not registered")
|
raise ValueError(f"Device {self._device_id} not registered")
|
||||||
|
|
||||||
# Resolve stream settings
|
|
||||||
self._resolve_stream_settings()
|
|
||||||
|
|
||||||
# Connect to LED device
|
# Connect to LED device
|
||||||
try:
|
try:
|
||||||
self._led_client = create_led_client(
|
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"Target {self._target_id} connected to {device_info.device_type} "
|
||||||
f"device ({device_info.led_count} LEDs)"
|
f"device ({device_info.led_count} LEDs)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Snapshot device state before streaming
|
|
||||||
self._device_state_before = await self._led_client.snapshot_device_state()
|
self._device_state_before = await self._led_client.snapshot_device_state()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to LED device for target {self._target_id}: {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}")
|
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:
|
try:
|
||||||
live_stream = await asyncio.to_thread(
|
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id)
|
||||||
self._ctx.live_stream_manager.acquire, self._picture_source_id
|
self._color_strip_stream = stream
|
||||||
)
|
self._resolved_display_index = stream.display_index
|
||||||
self._live_stream = live_stream
|
self._resolved_target_fps = stream.target_fps
|
||||||
if live_stream.display_index is not None:
|
|
||||||
self._resolved_display_index = live_stream.display_index
|
|
||||||
self._resolved_target_fps = live_stream.target_fps
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Acquired live stream for target {self._target_id} "
|
f"Acquired color strip stream for target {self._target_id} "
|
||||||
f"(picture_source={self._picture_source_id})"
|
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "
|
||||||
|
f"fps={self._resolved_target_fps})"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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:
|
if self._led_client:
|
||||||
await self._led_client.close()
|
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
|
# Reset metrics and start loop
|
||||||
calibration = device_info.calibration
|
|
||||||
self._pixel_mapper = PixelMapper(
|
|
||||||
calibration,
|
|
||||||
interpolation_mode=self._settings.interpolation_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset metrics
|
|
||||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||||
self._previous_colors = None
|
|
||||||
|
|
||||||
# Start processing task
|
|
||||||
self._task = asyncio.create_task(self._processing_loop())
|
self._task = asyncio.create_task(self._processing_loop())
|
||||||
self._is_running = True
|
self._is_running = True
|
||||||
|
|
||||||
@@ -224,80 +160,84 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
await self._led_client.close()
|
await self._led_client.close()
|
||||||
self._led_client = None
|
self._led_client = None
|
||||||
|
|
||||||
# Release live stream
|
# Release color strip stream
|
||||||
if self._live_stream:
|
if self._color_strip_stream is not None:
|
||||||
try:
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
self._ctx.live_stream_manager.release(self._picture_source_id)
|
if css_manager and self._color_strip_source_id:
|
||||||
except Exception as e:
|
try:
|
||||||
logger.warning(f"Error releasing live stream: {e}")
|
await asyncio.to_thread(css_manager.release, self._color_strip_source_id)
|
||||||
self._live_stream = None
|
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}")
|
logger.info(f"Stopped processing for target {self._target_id}")
|
||||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
||||||
|
|
||||||
# ----- Settings -----
|
# ----- Settings -----
|
||||||
|
|
||||||
def update_settings(self, settings: ProcessingSettings) -> None:
|
def update_settings(self, settings: dict) -> None:
|
||||||
self._settings = settings
|
"""Update target-specific timing settings."""
|
||||||
# Recreate pixel mapper if interpolation mode changed
|
if isinstance(settings, dict):
|
||||||
if self._pixel_mapper:
|
if "standby_interval" in settings:
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
self._standby_interval = settings["standby_interval"]
|
||||||
if device_info:
|
if "state_check_interval" in settings:
|
||||||
self._pixel_mapper = PixelMapper(
|
self._state_check_interval = settings["state_check_interval"]
|
||||||
device_info.calibration,
|
|
||||||
interpolation_mode=settings.interpolation_mode,
|
|
||||||
)
|
|
||||||
logger.info(f"Updated settings for target {self._target_id}")
|
logger.info(f"Updated settings for target {self._target_id}")
|
||||||
|
|
||||||
def update_device(self, device_id: str) -> None:
|
def update_device(self, device_id: str) -> None:
|
||||||
"""Update the device this target streams to."""
|
"""Update the device this target streams to."""
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
|
|
||||||
def update_calibration(self, calibration: CalibrationConfig) -> None:
|
def update_color_strip_source(self, color_strip_source_id: str) -> None:
|
||||||
"""Update the cached calibration + rebuild pixel mapper."""
|
"""Hot-swap the color strip source for a running target."""
|
||||||
if self._pixel_mapper:
|
if not self._is_running or self._color_strip_source_id == color_strip_source_id:
|
||||||
self._pixel_mapper = PixelMapper(
|
self._color_strip_source_id = color_strip_source_id
|
||||||
calibration,
|
return
|
||||||
interpolation_mode=self._settings.interpolation_mode,
|
|
||||||
)
|
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]:
|
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:
|
if self._resolved_display_index is not None:
|
||||||
return self._resolved_display_index
|
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 -----
|
# ----- State / Metrics -----
|
||||||
|
|
||||||
def get_state(self) -> dict:
|
def get_state(self) -> dict:
|
||||||
metrics = self._metrics
|
metrics = self._metrics
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"target_id": self._target_id,
|
"target_id": self._target_id,
|
||||||
"device_id": self._device_id,
|
"device_id": self._device_id,
|
||||||
|
"color_strip_source_id": self._color_strip_source_id,
|
||||||
"processing": self._is_running,
|
"processing": self._is_running,
|
||||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||||
"fps_potential": metrics.fps_potential 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_skipped": metrics.frames_skipped if self._is_running else None,
|
||||||
"frames_keepalive": metrics.frames_keepalive 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,
|
"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_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,
|
||||||
"display_index": self._resolved_display_index if self._resolved_display_index is not None else self._settings.display_index,
|
|
||||||
"overlay_active": self._overlay_active,
|
"overlay_active": self._overlay_active,
|
||||||
"last_update": metrics.last_update,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||||
@@ -305,6 +245,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
def get_metrics(self) -> dict:
|
def get_metrics(self) -> dict:
|
||||||
metrics = self._metrics
|
metrics = self._metrics
|
||||||
|
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
|
||||||
uptime_seconds = 0.0
|
uptime_seconds = 0.0
|
||||||
if metrics.start_time and self._is_running:
|
if metrics.start_time and self._is_running:
|
||||||
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
|
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
|
||||||
@@ -314,7 +255,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
"device_id": self._device_id,
|
"device_id": self._device_id,
|
||||||
"processing": self._is_running,
|
"processing": self._is_running,
|
||||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||||
"fps_target": self._settings.fps,
|
"fps_target": fps_target,
|
||||||
"uptime_seconds": uptime_seconds,
|
"uptime_seconds": uptime_seconds,
|
||||||
"frames_processed": metrics.frames_processed,
|
"frames_processed": metrics.frames_processed,
|
||||||
"errors_count": metrics.errors_count,
|
"errors_count": metrics.errors_count,
|
||||||
@@ -331,11 +272,21 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if self._overlay_active:
|
if self._overlay_active:
|
||||||
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
||||||
|
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
# Calibration comes from the active color strip stream
|
||||||
if device_info is None:
|
if self._color_strip_stream is None:
|
||||||
raise ValueError(f"Device {self._device_id} not found")
|
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()
|
displays = get_available_displays()
|
||||||
if display_index >= len(displays):
|
if display_index >= len(displays):
|
||||||
raise ValueError(f"Invalid display index {display_index}")
|
raise ValueError(f"Invalid display index {display_index}")
|
||||||
@@ -344,7 +295,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._ctx.overlay_manager.start_overlay,
|
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
|
self._overlay_active = True
|
||||||
@@ -366,106 +317,65 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
def is_overlay_active(self) -> bool:
|
def is_overlay_active(self) -> bool:
|
||||||
return self._overlay_active
|
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 -----
|
# ----- Private: processing loop -----
|
||||||
|
|
||||||
async def _processing_loop(self) -> None:
|
@staticmethod
|
||||||
"""Main processing loop — capture → extract → map → smooth → send."""
|
def _apply_brightness(colors: np.ndarray, device_info: Optional[DeviceInfo]) -> np.ndarray:
|
||||||
settings = self._settings
|
"""Apply device software_brightness if < 255."""
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
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
|
async def _processing_loop(self) -> None:
|
||||||
smoothing = settings.smoothing
|
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
||||||
border_width = device_info.calibration.border_width if device_info else 10
|
stream = self._color_strip_stream
|
||||||
led_brightness = settings.brightness
|
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(
|
logger.info(
|
||||||
f"Processing loop started for target {self._target_id} "
|
f"Processing loop started for target {self._target_id} "
|
||||||
f"(display={self._resolved_display_index}, fps={target_fps})"
|
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:
|
try:
|
||||||
while self._is_running:
|
while self._is_running:
|
||||||
now = loop_start = time.time()
|
loop_start = now = time.time()
|
||||||
|
|
||||||
# Re-fetch device info for runtime changes (test mode, brightness)
|
# Re-fetch device info for runtime changes (test mode, brightness)
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
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:
|
if device_info and device_info.test_mode_active:
|
||||||
await asyncio.sleep(frame_time)
|
await asyncio.sleep(frame_time)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
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:
|
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)
|
await asyncio.sleep(frame_time)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip processing + send if the frame hasn't changed
|
if colors is prev_colors:
|
||||||
if capture is prev_capture:
|
# Same frame — send keepalive if interval elapsed
|
||||||
if self._previous_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
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:
|
if not self._is_running or self._led_client is None:
|
||||||
break
|
break
|
||||||
brightness_value = int(led_brightness * 255)
|
send_colors = self._apply_brightness(prev_colors, device_info)
|
||||||
if device_info and device_info.software_brightness < 255:
|
|
||||||
brightness_value = brightness_value * device_info.software_brightness // 255
|
|
||||||
if self._led_client.supports_fast_send:
|
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:
|
else:
|
||||||
await self._led_client.send_pixels(self._previous_colors, brightness=brightness_value)
|
await self._led_client.send_pixels(send_colors)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
@@ -476,22 +386,13 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._metrics.fps_current = len(send_timestamps)
|
self._metrics.fps_current = len(send_timestamps)
|
||||||
await asyncio.sleep(frame_time)
|
await asyncio.sleep(frame_time)
|
||||||
continue
|
continue
|
||||||
prev_capture = capture
|
|
||||||
|
|
||||||
# Compute brightness before thread dispatch
|
prev_colors = colors
|
||||||
brightness_value = int(led_brightness * 255)
|
|
||||||
if device_info and device_info.software_brightness < 255:
|
|
||||||
brightness_value = brightness_value * device_info.software_brightness // 255
|
|
||||||
|
|
||||||
# CPU-bound work in dedicated thread-pool executor
|
# Apply device software brightness
|
||||||
raw_colors, send_colors, frame_timing = await loop.run_in_executor(
|
send_colors = self._apply_brightness(colors, device_info)
|
||||||
_frame_executor,
|
|
||||||
_process_frame, capture, border_width,
|
|
||||||
self._pixel_mapper, self._previous_colors, smoothing,
|
|
||||||
brightness_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send to LED device (brightness already applied in thread)
|
# Send to LED device
|
||||||
if not self._is_running or self._led_client is None:
|
if not self._is_running or self._led_client is None:
|
||||||
break
|
break
|
||||||
t_send_start = time.perf_counter()
|
t_send_start = time.perf_counter()
|
||||||
@@ -500,45 +401,30 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
else:
|
else:
|
||||||
await self._led_client.send_pixels(send_colors)
|
await self._led_client.send_pixels(send_colors)
|
||||||
send_ms = (time.perf_counter() - t_send_start) * 1000
|
send_ms = (time.perf_counter() - t_send_start) * 1000
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
|
|
||||||
# Per-stage timing (rolling average over last 10 frames)
|
self._metrics.timing_send_ms = send_ms
|
||||||
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.frames_processed += 1
|
self._metrics.frames_processed += 1
|
||||||
|
self._metrics.last_update = datetime.utcnow()
|
||||||
|
|
||||||
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
|
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Frame {self._metrics.frames_processed} for {self._target_id} "
|
f"Frame {self._metrics.frames_processed} for {self._target_id} "
|
||||||
f"({len(send_colors)} LEDs, bri={brightness_value}) — "
|
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
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
|
interval = now - prev_frame_time_stamp
|
||||||
prev_frame_time_stamp = now
|
prev_frame_time_stamp = now
|
||||||
fps_samples.append(1.0 / interval if interval > 0 else 0)
|
fps_samples.append(1.0 / interval if interval > 0 else 0)
|
||||||
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
||||||
|
|
||||||
# Potential FPS
|
|
||||||
processing_time = now - loop_start
|
processing_time = now - loop_start
|
||||||
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
|
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:
|
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||||
send_timestamps.popleft()
|
send_timestamps.popleft()
|
||||||
self._metrics.fps_current = len(send_timestamps)
|
self._metrics.fps_current = len(send_timestamps)
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ from wled_controller.api import router
|
|||||||
from wled_controller.api.dependencies import init_dependencies
|
from wled_controller.api.dependencies import init_dependencies
|
||||||
from wled_controller.config import get_config
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
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 import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
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.storage.profile_store import ProfileStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
from wled_controller.utils import setup_logging, get_logger
|
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_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
||||||
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
||||||
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_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)
|
profile_store = ProfileStore(config.storage.profiles_file)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
@@ -49,6 +50,7 @@ processor_manager = ProcessorManager(
|
|||||||
pp_template_store=pp_template_store,
|
pp_template_store=pp_template_store,
|
||||||
pattern_template_store=pattern_template_store,
|
pattern_template_store=pattern_template_store,
|
||||||
device_store=device_store,
|
device_store=device_store,
|
||||||
|
color_strip_store=color_strip_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,22 +71,10 @@ def _migrate_devices_to_targets():
|
|||||||
migrated = 0
|
migrated = 0
|
||||||
for device_id, device_data in devices_raw.items():
|
for device_id, device_data in devices_raw.items():
|
||||||
legacy_source_id = device_data.get("picture_source_id", "")
|
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
|
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)
|
device_name = device_data.get("name", device_id)
|
||||||
target_name = f"{device_name} Target"
|
target_name = f"{device_name} Target"
|
||||||
|
|
||||||
@@ -93,8 +83,6 @@ def _migrate_devices_to_targets():
|
|||||||
name=target_name,
|
name=target_name,
|
||||||
target_type="wled",
|
target_type="wled",
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
picture_source_id=legacy_source_id,
|
|
||||||
settings=settings,
|
|
||||||
description=f"Auto-migrated from device {device_name}",
|
description=f"Auto-migrated from device {device_name}",
|
||||||
)
|
)
|
||||||
migrated += 1
|
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")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan manager.
|
"""Application lifespan manager.
|
||||||
@@ -137,6 +184,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
_migrate_devices_to_targets()
|
_migrate_devices_to_targets()
|
||||||
|
_migrate_targets_to_color_strips()
|
||||||
|
|
||||||
# Create profile engine (needs processor_manager)
|
# Create profile engine (needs processor_manager)
|
||||||
profile_engine = ProfileEngine(profile_store, processor_manager)
|
profile_engine = ProfileEngine(profile_store, processor_manager)
|
||||||
@@ -148,6 +196,7 @@ async def lifespan(app: FastAPI):
|
|||||||
pattern_template_store=pattern_template_store,
|
pattern_template_store=pattern_template_store,
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
picture_target_store=picture_target_store,
|
picture_target_store=picture_target_store,
|
||||||
|
color_strip_store=color_strip_store,
|
||||||
profile_store=profile_store,
|
profile_store=profile_store,
|
||||||
profile_engine=profile_engine,
|
profile_engine=profile_engine,
|
||||||
)
|
)
|
||||||
@@ -160,7 +209,6 @@ async def lifespan(app: FastAPI):
|
|||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
device_url=device.url,
|
device_url=device.url,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
calibration=device.calibration,
|
|
||||||
device_type=device.device_type,
|
device_type=device.device_type,
|
||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
software_brightness=device.software_brightness,
|
software_brightness=device.software_brightness,
|
||||||
|
|||||||
@@ -87,11 +87,17 @@ import {
|
|||||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||||
} from './features/targets.js';
|
} from './features/targets.js';
|
||||||
|
|
||||||
|
// Layer 5: color-strip sources
|
||||||
|
import {
|
||||||
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
|
} from './features/color-strips.js';
|
||||||
|
|
||||||
// Layer 5: calibration
|
// Layer 5: calibration
|
||||||
import {
|
import {
|
||||||
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
|
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
|
||||||
updateOffsetSkipLock, updateCalibrationPreview,
|
updateOffsetSkipLock, updateCalibrationPreview,
|
||||||
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
||||||
|
showCSSCalibration,
|
||||||
} from './features/calibration.js';
|
} from './features/calibration.js';
|
||||||
|
|
||||||
// Layer 6: tabs
|
// Layer 6: tabs
|
||||||
@@ -262,6 +268,13 @@ Object.assign(window, {
|
|||||||
stopTargetOverlay,
|
stopTargetOverlay,
|
||||||
deleteTarget,
|
deleteTarget,
|
||||||
|
|
||||||
|
// color-strip sources
|
||||||
|
showCSSEditor,
|
||||||
|
closeCSSEditorModal,
|
||||||
|
forceCSSEditorClose,
|
||||||
|
saveCSSEditor,
|
||||||
|
deleteColorStrip,
|
||||||
|
|
||||||
// calibration
|
// calibration
|
||||||
showCalibration,
|
showCalibration,
|
||||||
closeCalibrationModal,
|
closeCalibrationModal,
|
||||||
@@ -273,6 +286,7 @@ Object.assign(window, {
|
|||||||
toggleEdgeInputs,
|
toggleEdgeInputs,
|
||||||
toggleDirection,
|
toggleDirection,
|
||||||
toggleTestEdge,
|
toggleTestEdge,
|
||||||
|
showCSSCalibration,
|
||||||
|
|
||||||
// tabs
|
// tabs
|
||||||
switchTab,
|
switchTab,
|
||||||
|
|||||||
@@ -35,8 +35,15 @@ class CalibrationModal extends Modal {
|
|||||||
|
|
||||||
onForceClose() {
|
onForceClose() {
|
||||||
closeTutorial();
|
closeTutorial();
|
||||||
const deviceId = this.$('calibration-device-id').value;
|
if (_isCSS()) {
|
||||||
if (deviceId) clearTestMode(deviceId);
|
_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();
|
if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect();
|
||||||
const error = this.$('calibration-error');
|
const error = this.$('calibration-error');
|
||||||
if (error) error.style.display = 'none';
|
if (error) error.style.display = 'none';
|
||||||
@@ -48,6 +55,33 @@ const calibModal = new CalibrationModal();
|
|||||||
let _dragRaf = null;
|
let _dragRaf = null;
|
||||||
let _previewRaf = 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) ────────────────────── */
|
/* ── Public API (exported names unchanged) ────────────────────── */
|
||||||
|
|
||||||
export async function showCalibration(deviceId) {
|
export async function showCalibration(deviceId) {
|
||||||
@@ -148,6 +182,92 @@ export async function closeCalibrationModal() {
|
|||||||
calibModal.close();
|
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() {
|
export function updateOffsetSkipLock() {
|
||||||
const offsetEl = document.getElementById('cal-offset');
|
const offsetEl = document.getElementById('cal-offset');
|
||||||
const skipStartEl = document.getElementById('cal-skip-start');
|
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-bottom-leds').value || 0) +
|
||||||
parseInt(document.getElementById('cal-left-leds').value || 0);
|
parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||||
const totalEl = document.querySelector('.preview-screen-total');
|
const totalEl = document.querySelector('.preview-screen-total');
|
||||||
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
const inCSS = _isCSS();
|
||||||
const mismatch = total !== deviceCount;
|
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;
|
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
|
||||||
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
|
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
|
||||||
|
|
||||||
@@ -186,7 +307,8 @@ export function updateCalibrationPreview() {
|
|||||||
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
||||||
|
|
||||||
const deviceId = document.getElementById('calibration-device-id').value;
|
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 => {
|
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||||||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${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;
|
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||||||
if (edgeLeds === 0) return;
|
if (edgeLeds === 0) return;
|
||||||
|
|
||||||
const deviceId = document.getElementById('calibration-device-id').value;
|
|
||||||
const error = document.getElementById('calibration-error');
|
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]) calibrationTestState[deviceId] = new Set();
|
||||||
|
|
||||||
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
|
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
|
||||||
@@ -658,11 +813,16 @@ async function clearTestMode(deviceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCalibration() {
|
export async function saveCalibration() {
|
||||||
|
const cssMode = _isCSS();
|
||||||
const deviceId = document.getElementById('calibration-device-id').value;
|
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');
|
const error = document.getElementById('calibration-error');
|
||||||
|
|
||||||
await clearTestMode(deviceId);
|
if (cssMode) {
|
||||||
|
await _clearCSSTestMode();
|
||||||
|
} else {
|
||||||
|
await clearTestMode(deviceId);
|
||||||
|
}
|
||||||
updateCalibrationPreview();
|
updateCalibrationPreview();
|
||||||
|
|
||||||
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
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 leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||||
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||||||
|
|
||||||
if (total !== deviceLedCount) {
|
if (!cssMode) {
|
||||||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
|
||||||
error.style.display = 'block';
|
if (total !== deviceLedCount) {
|
||||||
return;
|
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||||||
|
error.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPosition = document.getElementById('cal-start-position').value;
|
const startPosition = document.getElementById('cal-start-position').value;
|
||||||
@@ -695,14 +858,26 @@ export async function saveCalibration() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
let response;
|
||||||
method: 'PUT',
|
if (cssMode) {
|
||||||
body: JSON.stringify(calibration)
|
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) {
|
if (response.ok) {
|
||||||
showToast('Calibration saved', 'success');
|
showToast('Calibration saved', 'success');
|
||||||
calibModal.forceClose();
|
calibModal.forceClose();
|
||||||
window.loadDevices();
|
if (cssMode) {
|
||||||
|
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||||
|
} else {
|
||||||
|
window.loadDevices();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
error.textContent = `Failed to save: ${errorData.detail}`;
|
error.textContent = `Failed to save: ${errorData.detail}`;
|
||||||
|
|||||||
218
server/src/wled_controller/static/js/features/color-strips.js
Normal file
218
server/src/wled_controller/static/js/features/color-strips.js
Normal 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')}">✕</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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { showToast, showConfirm } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
|
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
|
||||||
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||||
|
import { createColorStripCard } from './color-strips.js';
|
||||||
|
|
||||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||||
// (pattern-templates.js calls window.loadTargetsTab)
|
// (pattern-templates.js calls window.loadTargetsTab)
|
||||||
@@ -30,10 +31,7 @@ class TargetEditorModal extends Modal {
|
|||||||
return {
|
return {
|
||||||
name: document.getElementById('target-editor-name').value,
|
name: document.getElementById('target-editor-name').value,
|
||||||
device: document.getElementById('target-editor-device').value,
|
device: document.getElementById('target-editor-device').value,
|
||||||
source: document.getElementById('target-editor-source').value,
|
css: document.getElementById('target-editor-css').value,
|
||||||
fps: document.getElementById('target-editor-fps').value,
|
|
||||||
interpolation: document.getElementById('target-editor-interpolation').value,
|
|
||||||
smoothing: document.getElementById('target-editor-smoothing').value,
|
|
||||||
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -47,11 +45,11 @@ function _autoGenerateTargetName() {
|
|||||||
if (_targetNameManuallyEdited) return;
|
if (_targetNameManuallyEdited) return;
|
||||||
if (document.getElementById('target-editor-id').value) return;
|
if (document.getElementById('target-editor-id').value) return;
|
||||||
const deviceSelect = document.getElementById('target-editor-device');
|
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 deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
|
||||||
if (!deviceName || !sourceName) return;
|
if (!deviceName || !cssName) return;
|
||||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`;
|
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _updateStandbyVisibility() {
|
function _updateStandbyVisibility() {
|
||||||
@@ -64,14 +62,14 @@ function _updateStandbyVisibility() {
|
|||||||
|
|
||||||
export async function showTargetEditor(targetId = null) {
|
export async function showTargetEditor(targetId = null) {
|
||||||
try {
|
try {
|
||||||
// Load devices and sources for dropdowns
|
// Load devices and CSS sources for dropdowns
|
||||||
const [devicesResp, sourcesResp] = await Promise.all([
|
const [devicesResp, cssResp] = await Promise.all([
|
||||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||||
fetchWithAuth('/picture-sources'),
|
fetchWithAuth('/color-strip-sources'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
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);
|
set_targetEditorDevices(devices);
|
||||||
|
|
||||||
// Populate device select
|
// Populate device select
|
||||||
@@ -87,16 +85,15 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
deviceSelect.appendChild(opt);
|
deviceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate source select
|
// Populate color strip source select
|
||||||
const sourceSelect = document.getElementById('target-editor-source');
|
const cssSelect = document.getElementById('target-editor-css');
|
||||||
sourceSelect.innerHTML = '';
|
cssSelect.innerHTML = '';
|
||||||
sources.forEach(s => {
|
cssSources.forEach(s => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = s.id;
|
opt.value = s.id;
|
||||||
opt.dataset.name = s.name;
|
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 = `🎞️ ${s.name}`;
|
||||||
opt.textContent = `${typeIcon} ${s.name}`;
|
cssSelect.appendChild(opt);
|
||||||
sourceSelect.appendChild(opt);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
@@ -108,24 +105,14 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
document.getElementById('target-editor-id').value = target.id;
|
document.getElementById('target-editor-id').value = target.id;
|
||||||
document.getElementById('target-editor-name').value = target.name;
|
document.getElementById('target-editor-name').value = target.name;
|
||||||
deviceSelect.value = target.device_id || '';
|
deviceSelect.value = target.device_id || '';
|
||||||
sourceSelect.value = target.picture_source_id || '';
|
cssSelect.value = target.color_strip_source_id || '';
|
||||||
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
|
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
|
||||||
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
|
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
|
||||||
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;
|
|
||||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||||
} else {
|
} else {
|
||||||
// Creating new target — first option is selected by default
|
// Creating new target — first option is selected by default
|
||||||
document.getElementById('target-editor-id').value = '';
|
document.getElementById('target-editor-id').value = '';
|
||||||
document.getElementById('target-editor-name').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 = 1.0;
|
||||||
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
|
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
|
||||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||||
@@ -135,7 +122,7 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
_targetNameManuallyEdited = !!targetId;
|
_targetNameManuallyEdited = !!targetId;
|
||||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||||
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
|
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
|
||||||
sourceSelect.onchange = () => _autoGenerateTargetName();
|
cssSelect.onchange = () => _autoGenerateTargetName();
|
||||||
if (!targetId) _autoGenerateTargetName();
|
if (!targetId) _autoGenerateTargetName();
|
||||||
|
|
||||||
// Show/hide standby interval based on selected device capabilities
|
// 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 targetId = document.getElementById('target-editor-id').value;
|
||||||
const name = document.getElementById('target-editor-name').value.trim();
|
const name = document.getElementById('target-editor-name').value.trim();
|
||||||
const deviceId = document.getElementById('target-editor-device').value;
|
const deviceId = document.getElementById('target-editor-device').value;
|
||||||
const sourceId = document.getElementById('target-editor-source').value;
|
const cssId = document.getElementById('target-editor-css').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 standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -182,13 +166,8 @@ export async function saveTargetEditor() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
picture_source_id: sourceId,
|
color_strip_source_id: cssId,
|
||||||
settings: {
|
standby_interval: standbyInterval,
|
||||||
fps: fps,
|
|
||||||
interpolation_mode: interpolation,
|
|
||||||
smoothing: smoothing,
|
|
||||||
standby_interval: standbyInterval,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -243,10 +222,11 @@ export async function loadTargetsTab() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch devices, targets, sources, and pattern templates in parallel
|
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
|
||||||
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
|
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
|
||||||
fetchWithAuth('/devices'),
|
fetchWithAuth('/devices'),
|
||||||
fetchWithAuth('/picture-targets'),
|
fetchWithAuth('/picture-targets'),
|
||||||
|
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||||
fetchWithAuth('/picture-sources').catch(() => null),
|
fetchWithAuth('/picture-sources').catch(() => null),
|
||||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -257,10 +237,16 @@ export async function loadTargetsTab() {
|
|||||||
const targetsData = await targetsResp.json();
|
const targetsData = await targetsResp.json();
|
||||||
const targets = targetsData.targets || [];
|
const targets = targetsData.targets || [];
|
||||||
|
|
||||||
let sourceMap = {};
|
let colorStripSourceMap = {};
|
||||||
if (sourcesResp && sourcesResp.ok) {
|
if (cssResp && cssResp.ok) {
|
||||||
const srcData = await sourcesResp.json();
|
const cssData = await cssResp.json();
|
||||||
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
(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 = [];
|
let patternTemplates = [];
|
||||||
@@ -320,7 +306,7 @@ export async function loadTargetsTab() {
|
|||||||
if (activeSubTab === 'wled') activeSubTab = 'led';
|
if (activeSubTab === 'wled') activeSubTab = 'led';
|
||||||
|
|
||||||
const subTabs = [
|
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 },
|
{ 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
|
// Use window.createPatternTemplateCard to avoid circular import
|
||||||
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
||||||
|
|
||||||
// LED panel: devices section + targets section
|
// LED panel: devices section + color strip sources section + targets section
|
||||||
const ledPanel = `
|
const ledPanel = `
|
||||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
||||||
<div class="subtab-section">
|
<div class="subtab-section">
|
||||||
@@ -343,10 +329,19 @@ export async function loadTargetsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="subtab-section">
|
||||||
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
||||||
<div class="devices-grid">
|
<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="template-card add-template-card" onclick="showTargetEditor()">
|
||||||
<div class="add-template-icon">+</div>
|
<div class="add-template-icon">+</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,7 +355,7 @@ export async function loadTargetsTab() {
|
|||||||
<div class="subtab-section">
|
<div class="subtab-section">
|
||||||
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
||||||
<div class="devices-grid">
|
<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="template-card add-template-card" onclick="showKCEditor()">
|
||||||
<div class="add-template-icon">+</div>
|
<div class="add-template-icon">+</div>
|
||||||
</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 state = target.state || {};
|
||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
const settings = target.settings || {};
|
|
||||||
|
|
||||||
const isProcessing = state.processing || false;
|
const isProcessing = state.processing || false;
|
||||||
|
|
||||||
const device = deviceMap[target.device_id];
|
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 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)
|
// Health info from target state (forwarded from device)
|
||||||
const devOnline = state.device_online || false;
|
const devOnline = state.device_online || false;
|
||||||
@@ -455,8 +449,7 @@ export function createTargetCard(target, deviceMap, sourceMap) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stream-card-props">
|
<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.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.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
|
||||||
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
|
|||||||
@@ -343,10 +343,11 @@
|
|||||||
"streams.validate_image.valid": "Image accessible",
|
"streams.validate_image.valid": "Image accessible",
|
||||||
"streams.validate_image.invalid": "Image not accessible",
|
"streams.validate_image.invalid": "Image not accessible",
|
||||||
"targets.title": "⚡ Targets",
|
"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.wled": "LED",
|
||||||
"targets.subtab.led": "LED",
|
"targets.subtab.led": "LED",
|
||||||
"targets.section.devices": "💡 Devices",
|
"targets.section.devices": "💡 Devices",
|
||||||
|
"targets.section.color_strips": "🎞️ Color Strip Sources",
|
||||||
"targets.section.targets": "⚡ Targets",
|
"targets.section.targets": "⚡ Targets",
|
||||||
"targets.add": "Add Target",
|
"targets.add": "Add Target",
|
||||||
"targets.edit": "Edit Target",
|
"targets.edit": "Edit Target",
|
||||||
@@ -358,6 +359,8 @@
|
|||||||
"targets.device": "Device:",
|
"targets.device": "Device:",
|
||||||
"targets.device.hint": "Select the LED device to send data to",
|
"targets.device.hint": "Select the LED device to send data to",
|
||||||
"targets.device.none": "-- Select a device --",
|
"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": "Source:",
|
||||||
"targets.source.hint": "Which picture source to capture and process",
|
"targets.source.hint": "Which picture source to capture and process",
|
||||||
"targets.source.none": "-- No source assigned --",
|
"targets.source.none": "-- No source assigned --",
|
||||||
@@ -380,6 +383,7 @@
|
|||||||
"targets.delete.confirm": "Are you sure you want to delete this target?",
|
"targets.delete.confirm": "Are you sure you want to delete this target?",
|
||||||
"targets.error.load": "Failed to load targets",
|
"targets.error.load": "Failed to load targets",
|
||||||
"targets.error.required": "Please fill in all required fields",
|
"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.error.delete": "Failed to delete target",
|
||||||
"targets.button.start": "Start",
|
"targets.button.start": "Start",
|
||||||
"targets.button.stop": "Stop",
|
"targets.button.stop": "Stop",
|
||||||
@@ -531,5 +535,36 @@
|
|||||||
"aria.cancel": "Cancel",
|
"aria.cancel": "Cancel",
|
||||||
"aria.previous": "Previous",
|
"aria.previous": "Previous",
|
||||||
"aria.next": "Next",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,10 +343,11 @@
|
|||||||
"streams.validate_image.valid": "Изображение доступно",
|
"streams.validate_image.valid": "Изображение доступно",
|
||||||
"streams.validate_image.invalid": "Изображение недоступно",
|
"streams.validate_image.invalid": "Изображение недоступно",
|
||||||
"targets.title": "⚡ Цели",
|
"targets.title": "⚡ Цели",
|
||||||
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
|
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
|
||||||
"targets.subtab.wled": "LED",
|
"targets.subtab.wled": "LED",
|
||||||
"targets.subtab.led": "LED",
|
"targets.subtab.led": "LED",
|
||||||
"targets.section.devices": "💡 Устройства",
|
"targets.section.devices": "💡 Устройства",
|
||||||
|
"targets.section.color_strips": "🎞️ Источники цветовых полос",
|
||||||
"targets.section.targets": "⚡ Цели",
|
"targets.section.targets": "⚡ Цели",
|
||||||
"targets.add": "Добавить Цель",
|
"targets.add": "Добавить Цель",
|
||||||
"targets.edit": "Редактировать Цель",
|
"targets.edit": "Редактировать Цель",
|
||||||
@@ -358,6 +359,8 @@
|
|||||||
"targets.device": "Устройство:",
|
"targets.device": "Устройство:",
|
||||||
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
||||||
"targets.device.none": "-- Выберите устройство --",
|
"targets.device.none": "-- Выберите устройство --",
|
||||||
|
"targets.color_strip_source": "Источник цветовой полосы:",
|
||||||
|
"targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов",
|
||||||
"targets.source": "Источник:",
|
"targets.source": "Источник:",
|
||||||
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
||||||
"targets.source.none": "-- Источник не назначен --",
|
"targets.source.none": "-- Источник не назначен --",
|
||||||
@@ -380,6 +383,7 @@
|
|||||||
"targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?",
|
"targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?",
|
||||||
"targets.error.load": "Не удалось загрузить цели",
|
"targets.error.load": "Не удалось загрузить цели",
|
||||||
"targets.error.required": "Пожалуйста, заполните все обязательные поля",
|
"targets.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||||
|
"targets.error.name_required": "Введите название цели",
|
||||||
"targets.error.delete": "Не удалось удалить цель",
|
"targets.error.delete": "Не удалось удалить цель",
|
||||||
"targets.button.start": "Запустить",
|
"targets.button.start": "Запустить",
|
||||||
"targets.button.stop": "Остановить",
|
"targets.button.stop": "Остановить",
|
||||||
@@ -531,5 +535,36 @@
|
|||||||
"aria.cancel": "Отмена",
|
"aria.cancel": "Отмена",
|
||||||
"aria.previous": "Назад",
|
"aria.previous": "Назад",
|
||||||
"aria.next": "Вперёд",
|
"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": "Введите название"
|
||||||
}
|
}
|
||||||
|
|||||||
130
server/src/wled_controller/storage/color_strip_source.py
Normal file
130
server/src/wled_controller/storage/color_strip_source.py
Normal 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.0–2.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
|
||||||
224
server/src/wled_controller/storage/color_strip_store.py
Normal file
224
server/src/wled_controller/storage/color_strip_store.py
Normal 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
|
||||||
@@ -6,12 +6,6 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
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
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -20,8 +14,9 @@ logger = get_logger(__name__)
|
|||||||
class Device:
|
class Device:
|
||||||
"""Represents a WLED device configuration.
|
"""Represents a WLED device configuration.
|
||||||
|
|
||||||
A device is a holder of connection state and calibration options.
|
A device holds connection state and output settings.
|
||||||
Processing settings and picture source assignments live on PictureTargets.
|
Calibration, processing settings, and picture source assignments
|
||||||
|
now live on ColorStripSource and WledPictureTarget respectively.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -36,7 +31,6 @@ class Device:
|
|||||||
software_brightness: int = 255,
|
software_brightness: int = 255,
|
||||||
auto_shutdown: bool = False,
|
auto_shutdown: bool = False,
|
||||||
static_color: Optional[Tuple[int, int, int]] = None,
|
static_color: Optional[Tuple[int, int, int]] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
|
||||||
created_at: Optional[datetime] = None,
|
created_at: Optional[datetime] = None,
|
||||||
updated_at: Optional[datetime] = None,
|
updated_at: Optional[datetime] = None,
|
||||||
):
|
):
|
||||||
@@ -50,9 +44,10 @@ class Device:
|
|||||||
self.software_brightness = software_brightness
|
self.software_brightness = software_brightness
|
||||||
self.auto_shutdown = auto_shutdown
|
self.auto_shutdown = auto_shutdown
|
||||||
self.static_color = static_color
|
self.static_color = static_color
|
||||||
self.calibration = calibration or create_default_calibration(led_count)
|
|
||||||
self.created_at = created_at or datetime.utcnow()
|
self.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_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:
|
def to_dict(self) -> dict:
|
||||||
"""Convert device to dictionary."""
|
"""Convert device to dictionary."""
|
||||||
@@ -63,7 +58,6 @@ class Device:
|
|||||||
"led_count": self.led_count,
|
"led_count": self.led_count,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"device_type": self.device_type,
|
"device_type": self.device_type,
|
||||||
"calibration": calibration_to_dict(self.calibration),
|
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -81,20 +75,13 @@ class Device:
|
|||||||
def from_dict(cls, data: dict) -> "Device":
|
def from_dict(cls, data: dict) -> "Device":
|
||||||
"""Create device from dictionary.
|
"""Create device from dictionary.
|
||||||
|
|
||||||
Backward-compatible: ignores legacy 'settings' and 'picture_source_id'
|
Backward-compatible: reads legacy 'calibration' field and stores it
|
||||||
fields that have been migrated to PictureTarget.
|
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_raw = data.get("static_color")
|
||||||
static_color = tuple(static_color_raw) if static_color_raw else None
|
static_color = tuple(static_color_raw) if static_color_raw else None
|
||||||
|
|
||||||
return cls(
|
device = cls(
|
||||||
device_id=data["id"],
|
device_id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
url=data["url"],
|
url=data["url"],
|
||||||
@@ -105,11 +92,21 @@ class Device:
|
|||||||
software_brightness=data.get("software_brightness", 255),
|
software_brightness=data.get("software_brightness", 255),
|
||||||
auto_shutdown=data.get("auto_shutdown", False),
|
auto_shutdown=data.get("auto_shutdown", False),
|
||||||
static_color=static_color,
|
static_color=static_color,
|
||||||
calibration=calibration,
|
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_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:
|
class DeviceStore:
|
||||||
"""Persistent storage for WLED devices."""
|
"""Persistent storage for WLED devices."""
|
||||||
@@ -129,7 +126,7 @@ class DeviceStore:
|
|||||||
def load(self):
|
def load(self):
|
||||||
"""Load devices from storage file."""
|
"""Load devices from storage file."""
|
||||||
if not self.storage_file.exists():
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -190,7 +187,6 @@ class DeviceStore:
|
|||||||
led_count: int,
|
led_count: int,
|
||||||
device_type: str = "wled",
|
device_type: str = "wled",
|
||||||
baud_rate: Optional[int] = None,
|
baud_rate: Optional[int] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
|
||||||
auto_shutdown: bool = False,
|
auto_shutdown: bool = False,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Create a new device."""
|
"""Create a new device."""
|
||||||
@@ -203,7 +199,6 @@ class DeviceStore:
|
|||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
baud_rate=baud_rate,
|
baud_rate=baud_rate,
|
||||||
calibration=calibration,
|
|
||||||
auto_shutdown=auto_shutdown,
|
auto_shutdown=auto_shutdown,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,7 +224,6 @@ class DeviceStore:
|
|||||||
led_count: Optional[int] = None,
|
led_count: Optional[int] = None,
|
||||||
enabled: Optional[bool] = None,
|
enabled: Optional[bool] = None,
|
||||||
baud_rate: Optional[int] = None,
|
baud_rate: Optional[int] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
|
||||||
auto_shutdown: Optional[bool] = None,
|
auto_shutdown: Optional[bool] = None,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Update device."""
|
"""Update device."""
|
||||||
@@ -249,13 +243,6 @@ class DeviceStore:
|
|||||||
device.baud_rate = baud_rate
|
device.baud_rate = baud_rate
|
||||||
if auto_shutdown is not None:
|
if auto_shutdown is not None:
|
||||||
device.auto_shutdown = auto_shutdown
|
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()
|
device.updated_at = datetime.utcnow()
|
||||||
self.save()
|
self.save()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
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.picture_target import PictureTarget
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||||
from wled_controller.storage.key_colors_picture_target import (
|
from wled_controller.storage.key_colors_picture_target import (
|
||||||
@@ -17,6 +16,8 @@ from wled_controller.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
|
||||||
|
|
||||||
|
|
||||||
class PictureTargetStore:
|
class PictureTargetStore:
|
||||||
"""Persistent storage for picture targets."""
|
"""Persistent storage for picture targets."""
|
||||||
@@ -100,19 +101,24 @@ class PictureTargetStore:
|
|||||||
name: str,
|
name: str,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
picture_source_id: str = "",
|
color_strip_source_id: str = "",
|
||||||
settings: Optional[ProcessingSettings] = None,
|
standby_interval: float = 1.0,
|
||||||
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
|
# Legacy params — accepted but ignored for backward compat
|
||||||
|
picture_source_id: str = "",
|
||||||
|
settings=None,
|
||||||
) -> PictureTarget:
|
) -> PictureTarget:
|
||||||
"""Create a new picture target.
|
"""Create a new picture target.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Target name
|
name: Target name
|
||||||
target_type: Target type ("wled", "key_colors")
|
target_type: Target type ("led", "wled", "key_colors")
|
||||||
device_id: WLED device ID (for wled targets)
|
device_id: WLED device ID (for led targets)
|
||||||
picture_source_id: Picture source ID
|
color_strip_source_id: Color strip source ID (for led targets)
|
||||||
settings: Processing settings (for wled 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)
|
key_colors_settings: Key colors settings (for key_colors targets)
|
||||||
description: Optional description
|
description: Optional description
|
||||||
|
|
||||||
@@ -139,8 +145,9 @@ class PictureTargetStore:
|
|||||||
name=name,
|
name=name,
|
||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
picture_source_id=picture_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
settings=settings or ProcessingSettings(),
|
standby_interval=standby_interval,
|
||||||
|
state_check_interval=state_check_interval,
|
||||||
description=description,
|
description=description,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -170,10 +177,14 @@ class PictureTargetStore:
|
|||||||
target_id: str,
|
target_id: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
device_id: Optional[str] = None,
|
device_id: Optional[str] = None,
|
||||||
picture_source_id: Optional[str] = None,
|
color_strip_source_id: Optional[str] = None,
|
||||||
settings: Optional[ProcessingSettings] = None,
|
standby_interval: Optional[float] = None,
|
||||||
|
state_check_interval: Optional[int] = None,
|
||||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
|
# Legacy params — accepted but ignored
|
||||||
|
picture_source_id: Optional[str] = None,
|
||||||
|
settings=None,
|
||||||
) -> PictureTarget:
|
) -> PictureTarget:
|
||||||
"""Update a picture target.
|
"""Update a picture target.
|
||||||
|
|
||||||
@@ -194,8 +205,9 @@ class PictureTargetStore:
|
|||||||
target.update_fields(
|
target.update_fields(
|
||||||
name=name,
|
name=name,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
picture_source_id=picture_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
settings=settings,
|
standby_interval=standby_interval,
|
||||||
|
state_check_interval=state_check_interval,
|
||||||
key_colors_settings=key_colors_settings,
|
key_colors_settings=key_colors_settings,
|
||||||
description=description,
|
description=description,
|
||||||
)
|
)
|
||||||
@@ -228,9 +240,16 @@ class PictureTargetStore:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def is_referenced_by_source(self, source_id: str) -> bool:
|
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():
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -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 dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
|
||||||
from wled_controller.storage.picture_target import PictureTarget
|
from wled_controller.storage.picture_target import PictureTarget
|
||||||
|
|
||||||
|
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WledPictureTarget(PictureTarget):
|
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 = ""
|
device_id: str = ""
|
||||||
picture_source_id: str = ""
|
color_strip_source_id: str = ""
|
||||||
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
|
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:
|
def register_with_manager(self, manager) -> None:
|
||||||
"""Register this WLED target with the processor manager."""
|
"""Register this WLED target with the processor manager."""
|
||||||
@@ -22,74 +33,72 @@ class WledPictureTarget(PictureTarget):
|
|||||||
manager.add_target(
|
manager.add_target(
|
||||||
target_id=self.id,
|
target_id=self.id,
|
||||||
device_id=self.device_id,
|
device_id=self.device_id,
|
||||||
settings=self.settings,
|
color_strip_source_id=self.color_strip_source_id,
|
||||||
picture_source_id=self.picture_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:
|
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
|
||||||
"""Push changed fields to the processor manager."""
|
"""Push changed fields to the processor manager."""
|
||||||
if settings_changed:
|
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:
|
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:
|
if device_changed:
|
||||||
manager.update_target_device(self.id, self.device_id)
|
manager.update_target_device(self.id, self.device_id)
|
||||||
|
|
||||||
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
|
||||||
settings=None, key_colors_settings=None, description=None) -> 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."""
|
"""Apply mutable field updates for WLED targets."""
|
||||||
super().update_fields(name=name, description=description)
|
super().update_fields(name=name, description=description)
|
||||||
if device_id is not None:
|
if device_id is not None:
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
if picture_source_id is not None:
|
if color_strip_source_id is not None:
|
||||||
self.picture_source_id = picture_source_id
|
self.color_strip_source_id = color_strip_source_id
|
||||||
if settings is not None:
|
if standby_interval is not None:
|
||||||
self.settings = settings
|
self.standby_interval = standby_interval
|
||||||
|
if state_check_interval is not None:
|
||||||
|
self.state_check_interval = state_check_interval
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_picture_source(self) -> bool:
|
def has_picture_source(self) -> bool:
|
||||||
return True
|
return bool(self.color_strip_source_id)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary."""
|
"""Convert to dictionary."""
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["device_id"] = self.device_id
|
d["device_id"] = self.device_id
|
||||||
d["picture_source_id"] = self.picture_source_id
|
d["color_strip_source_id"] = self.color_strip_source_id
|
||||||
d["settings"] = {
|
d["standby_interval"] = self.standby_interval
|
||||||
"display_index": self.settings.display_index,
|
d["state_check_interval"] = self.state_check_interval
|
||||||
"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,
|
|
||||||
}
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
||||||
"""Create from dictionary."""
|
"""Create from dictionary. Reads legacy picture_source_id/settings for migration."""
|
||||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
obj = cls(
|
||||||
|
|
||||||
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(
|
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=data.get("device_id", ""),
|
device_id=data.get("device_id", ""),
|
||||||
picture_source_id=data.get("picture_source_id", ""),
|
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||||
settings=settings,
|
standby_interval=data.get("standby_interval", 1.0),
|
||||||
|
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_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
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
{% include 'modals/calibration.html' %}
|
{% include 'modals/calibration.html' %}
|
||||||
{% include 'modals/device-settings.html' %}
|
{% include 'modals/device-settings.html' %}
|
||||||
{% include 'modals/target-editor.html' %}
|
{% include 'modals/target-editor.html' %}
|
||||||
|
{% include 'modals/css-editor.html' %}
|
||||||
{% include 'modals/kc-editor.html' %}
|
{% include 'modals/kc-editor.html' %}
|
||||||
{% include 'modals/pattern-template.html' %}
|
{% include 'modals/pattern-template.html' %}
|
||||||
{% include 'modals/api-key.html' %}
|
{% include 'modals/api-key.html' %}
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" id="calibration-device-id">
|
<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 -->
|
<!-- Interactive Preview with integrated LED inputs and test toggles -->
|
||||||
<div style="margin-bottom: 12px; padding: 0 24px;">
|
<div style="margin-bottom: 12px; padding: 0 24px;">
|
||||||
<div class="calibration-preview">
|
<div class="calibration-preview">
|
||||||
|
|||||||
110
server/src/wled_controller/templates/modals/css-editor.html
Normal file
110
server/src/wled_controller/templates/modals/css-editor.html
Normal 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">✕</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, <1=brighter midtones, >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">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveCSSEditor()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -25,48 +25,11 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<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>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.source.hint">Select a source that defines what to capture</small>
|
<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-source"></select>
|
<select id="target-editor-css"></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">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-standby-group">
|
<div class="form-group" id="target-editor-standby-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user