Add multi-segment LED targets, replace single color strip source + skip fields
Each target now has a segments list where each segment maps a color strip source to a pixel range (start/end) on the device with optional reverse. This enables composing multiple visualizations on a single LED strip. Old targets auto-migrate from the single source format on load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ from wled_controller.api.schemas.picture_targets import (
|
|||||||
PictureTargetUpdate,
|
PictureTargetUpdate,
|
||||||
TargetMetricsResponse,
|
TargetMetricsResponse,
|
||||||
TargetProcessingState,
|
TargetProcessingState,
|
||||||
|
TargetSegmentSchema,
|
||||||
)
|
)
|
||||||
from wled_controller.config import get_config
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
@@ -93,12 +94,18 @@ 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,
|
||||||
color_strip_source_id=target.color_strip_source_id,
|
segments=[
|
||||||
|
TargetSegmentSchema(
|
||||||
|
color_strip_source_id=s.color_strip_source_id,
|
||||||
|
start=s.start,
|
||||||
|
end=s.end,
|
||||||
|
reverse=s.reverse,
|
||||||
|
)
|
||||||
|
for s in target.segments
|
||||||
|
],
|
||||||
fps=target.fps,
|
fps=target.fps,
|
||||||
keepalive_interval=target.keepalive_interval,
|
keepalive_interval=target.keepalive_interval,
|
||||||
state_check_interval=target.state_check_interval,
|
state_check_interval=target.state_check_interval,
|
||||||
led_skip_start=target.led_skip_start,
|
|
||||||
led_skip_end=target.led_skip_end,
|
|
||||||
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,
|
||||||
@@ -150,12 +157,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,
|
segments=[s.model_dump() for s in data.segments] if data.segments else None,
|
||||||
fps=data.fps,
|
fps=data.fps,
|
||||||
keepalive_interval=data.keepalive_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
led_skip_start=data.led_skip_start,
|
|
||||||
led_skip_end=data.led_skip_end,
|
|
||||||
picture_source_id=data.picture_source_id,
|
picture_source_id=data.picture_source_id,
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -262,17 +267,15 @@ async def update_target(
|
|||||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
|
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
|
||||||
|
|
||||||
# Update in store
|
# Update in store
|
||||||
|
segments_dicts = [s.model_dump() for s in data.segments] if data.segments is not None else None
|
||||||
target = target_store.update_target(
|
target = target_store.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,
|
segments=segments_dicts,
|
||||||
fps=data.fps,
|
fps=data.fps,
|
||||||
keepalive_interval=data.keepalive_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
led_skip_start=data.led_skip_start,
|
|
||||||
led_skip_end=data.led_skip_end,
|
|
||||||
picture_source_id=data.picture_source_id,
|
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
@@ -284,10 +287,8 @@ async def update_target(
|
|||||||
settings_changed=(data.fps is not None or
|
settings_changed=(data.fps is not None or
|
||||||
data.keepalive_interval is not None or
|
data.keepalive_interval is not None or
|
||||||
data.state_check_interval is not None or
|
data.state_check_interval is not None or
|
||||||
data.led_skip_start is not None or
|
|
||||||
data.led_skip_end is not None or
|
|
||||||
data.key_colors_settings is not None),
|
data.key_colors_settings is not None),
|
||||||
source_changed=data.color_strip_source_id is not None,
|
segments_changed=data.segments is not None,
|
||||||
device_changed=data.device_id is not None,
|
device_changed=data.device_id is not None,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -755,9 +756,12 @@ async def start_target_overlay(
|
|||||||
# can start even when processing is not currently running.
|
# can start even when processing is not currently running.
|
||||||
calibration = None
|
calibration = None
|
||||||
display_info = None
|
display_info = None
|
||||||
if isinstance(target, WledPictureTarget) and target.color_strip_source_id:
|
if isinstance(target, WledPictureTarget) and target.segments:
|
||||||
|
# Use the first segment's CSS for calibration/overlay
|
||||||
|
first_css_id = target.segments[0].color_strip_source_id
|
||||||
|
if first_css_id:
|
||||||
try:
|
try:
|
||||||
css = color_strip_store.get_source(target.color_strip_source_id)
|
css = color_strip_store.get_source(first_css_id)
|
||||||
if isinstance(css, PictureColorStripSource) and css.calibration:
|
if isinstance(css, PictureColorStripSource) and css.calibration:
|
||||||
calibration = css.calibration
|
calibration = css.calibration
|
||||||
# Resolve the display this CSS is capturing
|
# Resolve the display this CSS is capturing
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ class KeyColorsResponse(BaseModel):
|
|||||||
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSegmentSchema(BaseModel):
|
||||||
|
"""A segment mapping a color strip source to a pixel range on the device."""
|
||||||
|
|
||||||
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
|
start: int = Field(default=0, ge=0, description="Start pixel (inclusive)")
|
||||||
|
end: int = Field(default=0, ge=0, description="End pixel (exclusive, 0 = auto-fit)")
|
||||||
|
reverse: bool = Field(default=False, description="Reverse pixel order within segment")
|
||||||
|
|
||||||
|
|
||||||
class PictureTargetCreate(BaseModel):
|
class PictureTargetCreate(BaseModel):
|
||||||
"""Request to create a picture target."""
|
"""Request to create a picture target."""
|
||||||
|
|
||||||
@@ -52,12 +61,10 @@ class PictureTargetCreate(BaseModel):
|
|||||||
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
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
|
||||||
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
keepalive_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)
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||||
led_skip_start: int = Field(default=0, ge=0, description="Number of LEDs at the start to keep black")
|
|
||||||
led_skip_end: int = Field(default=0, ge=0, description="Number of LEDs at the end to keep black")
|
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
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)")
|
||||||
@@ -70,12 +77,10 @@ class PictureTargetUpdate(BaseModel):
|
|||||||
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)
|
||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
segments: Optional[List[TargetSegmentSchema]] = Field(None, description="LED segments")
|
||||||
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
keepalive_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)
|
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||||
led_skip_start: Optional[int] = Field(None, ge=0, description="Number of LEDs at the start to keep black")
|
|
||||||
led_skip_end: Optional[int] = Field(None, ge=0, description="Number of LEDs at the end to keep black")
|
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
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)")
|
||||||
@@ -90,12 +95,10 @@ class PictureTargetResponse(BaseModel):
|
|||||||
target_type: str = Field(description="Target type")
|
target_type: str = Field(description="Target type")
|
||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
|
||||||
fps: Optional[int] = Field(None, description="Target send FPS")
|
fps: Optional[int] = Field(None, description="Target send FPS")
|
||||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
keepalive_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)")
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||||
led_skip_start: int = Field(default=0, description="LEDs skipped at start")
|
|
||||||
led_skip_end: int = Field(default=0, description="LEDs skipped at end")
|
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
||||||
@@ -116,7 +119,7 @@ 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")
|
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
|
||||||
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)")
|
||||||
|
|||||||
@@ -270,12 +270,10 @@ class ProcessorManager:
|
|||||||
self,
|
self,
|
||||||
target_id: str,
|
target_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
color_strip_source_id: str = "",
|
segments: Optional[list] = None,
|
||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
keepalive_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
led_skip_start: int = 0,
|
|
||||||
led_skip_end: int = 0,
|
|
||||||
):
|
):
|
||||||
"""Register a WLED target processor."""
|
"""Register a WLED target processor."""
|
||||||
if target_id in self._processors:
|
if target_id in self._processors:
|
||||||
@@ -286,12 +284,10 @@ class ProcessorManager:
|
|||||||
proc = WledTargetProcessor(
|
proc = WledTargetProcessor(
|
||||||
target_id=target_id,
|
target_id=target_id,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
segments=segments,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
led_skip_start=led_skip_start,
|
|
||||||
led_skip_end=led_skip_end,
|
|
||||||
ctx=self._build_context(),
|
ctx=self._build_context(),
|
||||||
)
|
)
|
||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
@@ -337,10 +333,10 @@ 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):
|
def update_target_segments(self, target_id: str, segments: list):
|
||||||
"""Update the color strip source for a WLED target."""
|
"""Update the segments for a WLED target."""
|
||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
proc.update_color_strip_source(color_strip_source_id)
|
proc.update_segments(segments)
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
@@ -160,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_color_strip_source(self, color_strip_source_id: str) -> None:
|
def update_segments(self, segments: list) -> None:
|
||||||
"""Update color strip source. No-op for targets that don't use CSS."""
|
"""Update segments. No-op for targets that don't use segments."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ----- Device / display info (overridden by device-aware subclasses) -----
|
# ----- Device / display info (overridden by device-aware subclasses) -----
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP."""
|
"""WLED/LED target processor — gets colors from ColorStripStreams, sends via DDP."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
import collections
|
import collections
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -24,46 +24,67 @@ from wled_controller.utils.timer import high_resolution_timer
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolved segment info used inside the processing loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_segments(segments: List[dict], device_led_count: int) -> List[dict]:
|
||||||
|
"""Resolve auto-fit segments based on device LED count.
|
||||||
|
|
||||||
|
A single segment with ``end == 0`` auto-fits to the full device.
|
||||||
|
Multiple segments with ``end == 0`` are left as-is (invalid but we
|
||||||
|
clamp gracefully).
|
||||||
|
"""
|
||||||
|
resolved = []
|
||||||
|
for seg in segments:
|
||||||
|
css_id = seg.get("color_strip_source_id", "")
|
||||||
|
start = max(0, seg.get("start", 0))
|
||||||
|
end = seg.get("end", 0)
|
||||||
|
reverse = seg.get("reverse", False)
|
||||||
|
if end <= 0:
|
||||||
|
end = device_led_count
|
||||||
|
end = min(end, device_led_count)
|
||||||
|
start = min(start, end)
|
||||||
|
resolved.append({"css_id": css_id, "start": start, "end": end, "reverse": reverse})
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# WledTargetProcessor
|
# WledTargetProcessor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class WledTargetProcessor(TargetProcessor):
|
class WledTargetProcessor(TargetProcessor):
|
||||||
"""Streams LED colors from a ColorStripStream to a WLED/LED device.
|
"""Streams LED colors from one or more ColorStripStreams to a WLED/LED device.
|
||||||
|
|
||||||
The ColorStripStream handles all capture and color processing.
|
Each segment maps a CSS source to a pixel range on the device.
|
||||||
This processor only applies device software_brightness and sends pixels.
|
Gaps between segments stay black.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
target_id: str,
|
target_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
color_strip_source_id: str,
|
segments: Optional[List[dict]] = None,
|
||||||
fps: int,
|
fps: int = 30,
|
||||||
keepalive_interval: float,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int,
|
state_check_interval: int = 30,
|
||||||
led_skip_start: int = 0,
|
|
||||||
led_skip_end: int = 0,
|
|
||||||
ctx: TargetContext = None,
|
ctx: TargetContext = None,
|
||||||
):
|
):
|
||||||
super().__init__(target_id, ctx)
|
super().__init__(target_id, ctx)
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
self._color_strip_source_id = color_strip_source_id
|
|
||||||
self._target_fps = fps if fps > 0 else 30
|
self._target_fps = fps if fps > 0 else 30
|
||||||
self._keepalive_interval = keepalive_interval
|
self._keepalive_interval = keepalive_interval
|
||||||
self._state_check_interval = state_check_interval
|
self._state_check_interval = state_check_interval
|
||||||
self._led_skip_start = max(0, led_skip_start)
|
self._segments = list(segments) if segments else []
|
||||||
self._led_skip_end = max(0, led_skip_end)
|
|
||||||
|
|
||||||
# Runtime state (populated on start)
|
# Runtime state (populated on start)
|
||||||
self._led_client: Optional[LEDClient] = None
|
self._led_client: Optional[LEDClient] = None
|
||||||
self._color_strip_stream = None
|
# List of (resolved_seg_dict, stream) tuples — read by the loop
|
||||||
|
self._segment_streams: List[Tuple[dict, object]] = []
|
||||||
self._device_state_before: Optional[dict] = None
|
self._device_state_before: Optional[dict] = None
|
||||||
self._overlay_active = False
|
self._overlay_active = False
|
||||||
self._needs_keepalive = True # resolved at start from device capabilities
|
self._needs_keepalive = True
|
||||||
|
|
||||||
# Resolved stream metadata (set once stream is acquired)
|
|
||||||
self._resolved_display_index: Optional[int] = None
|
self._resolved_display_index: Optional[int] = None
|
||||||
|
|
||||||
# ----- Properties -----
|
# ----- Properties -----
|
||||||
@@ -105,44 +126,57 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
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 color strip stream
|
# Acquire color strip streams for each segment
|
||||||
css_manager = self._ctx.color_strip_stream_manager
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
if css_manager is None:
|
if css_manager is None:
|
||||||
await self._led_client.close()
|
await self._led_client.close()
|
||||||
self._led_client = None
|
self._led_client = None
|
||||||
raise RuntimeError("Color strip stream manager not available in context")
|
raise RuntimeError("Color strip stream manager not available in context")
|
||||||
|
|
||||||
if not self._color_strip_source_id:
|
if not self._segments:
|
||||||
await self._led_client.close()
|
await self._led_client.close()
|
||||||
self._led_client = None
|
self._led_client = None
|
||||||
raise RuntimeError(f"Target {self._target_id} has no color strip source assigned")
|
raise RuntimeError(f"Target {self._target_id} has no segments configured")
|
||||||
|
|
||||||
|
resolved = _resolve_segments(self._segments, device_info.led_count)
|
||||||
|
segment_streams: List[Tuple[dict, object]] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id, self._target_id)
|
for seg in resolved:
|
||||||
self._color_strip_stream = stream
|
if not seg["css_id"]:
|
||||||
self._resolved_display_index = stream.display_index
|
continue
|
||||||
|
stream = await asyncio.to_thread(css_manager.acquire, seg["css_id"], self._target_id)
|
||||||
|
seg_len = seg["end"] - seg["start"]
|
||||||
|
if hasattr(stream, "configure") and seg_len > 0:
|
||||||
|
stream.configure(seg_len)
|
||||||
|
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
|
||||||
|
segment_streams.append((seg, stream))
|
||||||
|
|
||||||
# For auto-sized non-picture streams (led_count == 0), size to device LED count
|
# Resolve display index from first stream that has one
|
||||||
if hasattr(stream, "configure") and device_info.led_count > 0:
|
self._resolved_display_index = None
|
||||||
effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end
|
for _, s in segment_streams:
|
||||||
stream.configure(max(1, effective_leds))
|
di = getattr(s, "display_index", None)
|
||||||
|
if di is not None:
|
||||||
|
self._resolved_display_index = di
|
||||||
|
break
|
||||||
|
|
||||||
# Notify stream manager of our target FPS so it can adjust capture rate
|
self._segment_streams = segment_streams
|
||||||
css_manager.notify_target_fps(
|
|
||||||
self._color_strip_source_id, self._target_id, self._target_fps
|
|
||||||
)
|
|
||||||
|
|
||||||
|
seg_desc = ", ".join(f"{s['css_id']}[{s['start']}:{s['end']}]" for s in resolved if s["css_id"])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Acquired color strip stream for target {self._target_id} "
|
f"Acquired {len(segment_streams)} segment stream(s) for target {self._target_id}: {seg_desc}"
|
||||||
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "
|
|
||||||
f"fps={self._target_fps})"
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}")
|
# Release any streams we already acquired
|
||||||
|
for seg, stream in segment_streams:
|
||||||
|
try:
|
||||||
|
css_manager.release(seg["css_id"], self._target_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if self._led_client:
|
if self._led_client:
|
||||||
await self._led_client.close()
|
await self._led_client.close()
|
||||||
self._led_client = None
|
self._led_client = None
|
||||||
raise RuntimeError(f"Failed to acquire color strip stream: {e}")
|
raise RuntimeError(f"Failed to acquire segment streams: {e}")
|
||||||
|
|
||||||
# Reset metrics and start loop
|
# Reset metrics and start loop
|
||||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||||
@@ -167,8 +201,6 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
# Allow any in-flight thread pool serial write to complete before
|
|
||||||
# close() sends the black frame (to_thread keeps running after cancel)
|
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# Restore device state
|
# Restore device state
|
||||||
@@ -181,16 +213,16 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
await self._led_client.close()
|
await self._led_client.close()
|
||||||
self._led_client = None
|
self._led_client = None
|
||||||
|
|
||||||
# Release color strip stream
|
# Release all segment streams
|
||||||
if self._color_strip_stream is not None:
|
|
||||||
css_manager = self._ctx.color_strip_stream_manager
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
if css_manager and self._color_strip_source_id:
|
if css_manager:
|
||||||
|
for seg, stream in self._segment_streams:
|
||||||
try:
|
try:
|
||||||
css_manager.remove_target_fps(self._color_strip_source_id, self._target_id)
|
css_manager.remove_target_fps(seg["css_id"], self._target_id)
|
||||||
await asyncio.to_thread(css_manager.release, self._color_strip_source_id, self._target_id)
|
await asyncio.to_thread(css_manager.release, seg["css_id"], self._target_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}")
|
logger.warning(f"Error releasing segment stream {seg['css_id']} for {self._target_id}: {e}")
|
||||||
self._color_strip_stream = None
|
self._segment_streams = []
|
||||||
|
|
||||||
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})
|
||||||
@@ -202,56 +234,70 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if isinstance(settings, dict):
|
if isinstance(settings, dict):
|
||||||
if "fps" in settings:
|
if "fps" in settings:
|
||||||
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
|
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
|
||||||
# Notify stream manager so capture rate adjusts to max of all consumers
|
|
||||||
css_manager = self._ctx.color_strip_stream_manager
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
if css_manager and self._color_strip_source_id and self._is_running:
|
if css_manager and self._is_running:
|
||||||
css_manager.notify_target_fps(
|
for seg, _ in self._segment_streams:
|
||||||
self._color_strip_source_id, self._target_id, self._target_fps
|
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
|
||||||
)
|
|
||||||
if "keepalive_interval" in settings:
|
if "keepalive_interval" in settings:
|
||||||
self._keepalive_interval = settings["keepalive_interval"]
|
self._keepalive_interval = settings["keepalive_interval"]
|
||||||
if "state_check_interval" in settings:
|
if "state_check_interval" in settings:
|
||||||
self._state_check_interval = settings["state_check_interval"]
|
self._state_check_interval = settings["state_check_interval"]
|
||||||
if "led_skip_start" in settings:
|
|
||||||
self._led_skip_start = max(0, settings["led_skip_start"])
|
|
||||||
if "led_skip_end" in settings:
|
|
||||||
self._led_skip_end = max(0, settings["led_skip_end"])
|
|
||||||
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_color_strip_source(self, color_strip_source_id: str) -> None:
|
def update_segments(self, new_segments: List[dict]) -> None:
|
||||||
"""Hot-swap the color strip source for a running target."""
|
"""Hot-swap all segments for a running target."""
|
||||||
if not self._is_running or self._color_strip_source_id == color_strip_source_id:
|
self._segments = list(new_segments)
|
||||||
self._color_strip_source_id = color_strip_source_id
|
|
||||||
|
if not self._is_running:
|
||||||
return
|
return
|
||||||
|
|
||||||
css_manager = self._ctx.color_strip_stream_manager
|
css_manager = self._ctx.color_strip_stream_manager
|
||||||
if css_manager is None:
|
if css_manager is None:
|
||||||
self._color_strip_source_id = color_strip_source_id
|
|
||||||
return
|
return
|
||||||
|
|
||||||
old_id = self._color_strip_source_id
|
device_info = self._ctx.get_device_info(self._device_id)
|
||||||
|
device_leds = device_info.led_count if device_info else 0
|
||||||
|
|
||||||
|
# Release old streams
|
||||||
|
for seg, stream in self._segment_streams:
|
||||||
try:
|
try:
|
||||||
new_stream = css_manager.acquire(color_strip_source_id, self._target_id)
|
css_manager.remove_target_fps(seg["css_id"], self._target_id)
|
||||||
css_manager.remove_target_fps(old_id, self._target_id)
|
css_manager.release(seg["css_id"], self._target_id)
|
||||||
css_manager.release(old_id, self._target_id)
|
|
||||||
self._color_strip_stream = new_stream
|
|
||||||
self._resolved_display_index = new_stream.display_index
|
|
||||||
self._color_strip_source_id = color_strip_source_id
|
|
||||||
css_manager.notify_target_fps(color_strip_source_id, self._target_id, self._target_fps)
|
|
||||||
logger.info(f"Swapped color strip source for {self._target_id}: {old_id} → {color_strip_source_id}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to swap color strip source for {self._target_id}: {e}")
|
logger.warning(f"Error releasing segment {seg['css_id']}: {e}")
|
||||||
|
|
||||||
|
# Acquire new streams
|
||||||
|
resolved = _resolve_segments(new_segments, device_leds)
|
||||||
|
new_stream_list: List[Tuple[dict, object]] = []
|
||||||
|
for seg in resolved:
|
||||||
|
if not seg["css_id"]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stream = css_manager.acquire(seg["css_id"], self._target_id)
|
||||||
|
seg_len = seg["end"] - seg["start"]
|
||||||
|
if hasattr(stream, "configure") and seg_len > 0:
|
||||||
|
stream.configure(seg_len)
|
||||||
|
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
|
||||||
|
new_stream_list.append((seg, stream))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to acquire segment {seg['css_id']}: {e}")
|
||||||
|
|
||||||
|
# Atomic swap — the processing loop re-reads this reference each tick
|
||||||
|
self._segment_streams = new_stream_list
|
||||||
|
logger.info(f"Hot-swapped segments for {self._target_id}: {len(new_stream_list)} segment(s)")
|
||||||
|
|
||||||
def get_display_index(self) -> Optional[int]:
|
def get_display_index(self) -> Optional[int]:
|
||||||
"""Display index being captured, from the active stream."""
|
"""Display index being captured, from the first 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
|
||||||
if self._color_strip_stream is not None:
|
for _, stream in self._segment_streams:
|
||||||
return self._color_strip_stream.display_index
|
di = getattr(stream, "display_index", None)
|
||||||
|
if di is not None:
|
||||||
|
return di
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ----- State / Metrics -----
|
# ----- State / Metrics -----
|
||||||
@@ -260,10 +306,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
metrics = self._metrics
|
metrics = self._metrics
|
||||||
fps_target = self._target_fps
|
fps_target = self._target_fps
|
||||||
|
|
||||||
# Pull per-stage timing from the CSS stream (runs in a background thread)
|
|
||||||
css_timing: dict = {}
|
css_timing: dict = {}
|
||||||
if self._is_running and self._color_strip_stream is not None:
|
if self._is_running and self._segment_streams:
|
||||||
css_timing = self._color_strip_stream.get_last_timing()
|
css_timing = self._segment_streams[0][1].get_last_timing()
|
||||||
|
|
||||||
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
|
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
|
||||||
extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
|
extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
|
||||||
@@ -272,15 +317,23 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
if css_timing:
|
if css_timing:
|
||||||
total_ms = round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1)
|
total_ms = round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1)
|
||||||
elif self._is_running and send_ms is not None:
|
elif self._is_running and send_ms is not None:
|
||||||
# Non-picture sources have no CSS pipeline timing — total = send only
|
|
||||||
total_ms = send_ms
|
total_ms = send_ms
|
||||||
else:
|
else:
|
||||||
total_ms = None
|
total_ms = None
|
||||||
|
|
||||||
|
# Serialize segments for the dashboard
|
||||||
|
segments_info = [
|
||||||
|
{"color_strip_source_id": seg["css_id"], "start": seg["start"],
|
||||||
|
"end": seg["end"], "reverse": seg.get("reverse", False)}
|
||||||
|
for seg, _ in self._segment_streams
|
||||||
|
] if self._segment_streams else [
|
||||||
|
s for s in self._segments
|
||||||
|
]
|
||||||
|
|
||||||
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,
|
"segments": segments_info,
|
||||||
"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,
|
||||||
@@ -296,8 +349,6 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
"display_index": self._resolved_display_index,
|
"display_index": self._resolved_display_index,
|
||||||
"overlay_active": self._overlay_active,
|
"overlay_active": self._overlay_active,
|
||||||
"needs_keepalive": self._needs_keepalive,
|
"needs_keepalive": self._needs_keepalive,
|
||||||
"led_skip_start": self._led_skip_start,
|
|
||||||
"led_skip_end": self._led_skip_end,
|
|
||||||
"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 [],
|
||||||
}
|
}
|
||||||
@@ -332,28 +383,30 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
||||||
|
|
||||||
if calibration is None or display_info is None:
|
if calibration is None or display_info is None:
|
||||||
# Calibration comes from the active color strip stream
|
# Find calibration from the first picture stream
|
||||||
if self._color_strip_stream is None:
|
stream_with_cal = None
|
||||||
|
for _, s in self._segment_streams:
|
||||||
|
if hasattr(s, "calibration") and s.calibration:
|
||||||
|
stream_with_cal = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if stream_with_cal is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Cannot start overlay for {self._target_id}: no color strip stream active "
|
f"Cannot start overlay for {self._target_id}: no stream with calibration"
|
||||||
f"and no calibration provided."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if calibration is None:
|
if calibration is None:
|
||||||
calibration = self._color_strip_stream.calibration
|
calibration = stream_with_cal.calibration
|
||||||
|
|
||||||
if display_info is None:
|
if display_info is None:
|
||||||
display_index = self._resolved_display_index
|
display_index = self._resolved_display_index
|
||||||
if display_index is None:
|
if display_index is None:
|
||||||
display_index = self._color_strip_stream.display_index
|
display_index = getattr(stream_with_cal, "display_index", None)
|
||||||
|
|
||||||
if display_index is None or display_index < 0:
|
if display_index is None or display_index < 0:
|
||||||
raise ValueError(f"Invalid display index {display_index} for overlay")
|
raise ValueError(f"Invalid display index {display_index} for overlay")
|
||||||
|
|
||||||
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}")
|
||||||
|
|
||||||
display_info = displays[display_index]
|
display_info = displays[display_index]
|
||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -391,16 +444,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray:
|
def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray:
|
||||||
"""Resample colors to match the target device LED count.
|
"""Resample colors to match the target LED count."""
|
||||||
|
|
||||||
Uses linear interpolation so gradients look correct regardless of
|
|
||||||
source/target LED count mismatch (shared streams may be sized to a
|
|
||||||
different consumer's LED count).
|
|
||||||
"""
|
|
||||||
n = len(colors)
|
n = len(colors)
|
||||||
if n == device_led_count or device_led_count <= 0:
|
if n == device_led_count or device_led_count <= 0:
|
||||||
return colors
|
return colors
|
||||||
# Linear interpolation — preserves gradient appearance at any size
|
|
||||||
src_x = np.linspace(0, 1, n)
|
src_x = np.linspace(0, 1, n)
|
||||||
dst_x = np.linspace(0, 1, device_led_count)
|
dst_x = np.linspace(0, 1, device_led_count)
|
||||||
result = np.column_stack([
|
result = np.column_stack([
|
||||||
@@ -409,62 +456,26 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
])
|
])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_led_skip(colors: np.ndarray, buf: Optional[np.ndarray], skip_start: int) -> np.ndarray:
|
|
||||||
"""Copy effective colors into pre-allocated buffer with black padding.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
colors: Effective LED colors (skip-excluded)
|
|
||||||
buf: Pre-allocated (device_led_count, 3) buffer with black edges,
|
|
||||||
or None when no skip is configured.
|
|
||||||
skip_start: Number of black LEDs at the start (write offset)
|
|
||||||
"""
|
|
||||||
if buf is None:
|
|
||||||
return colors
|
|
||||||
buf[skip_start:skip_start + len(colors)] = colors
|
|
||||||
return buf
|
|
||||||
|
|
||||||
async def _processing_loop(self) -> None:
|
async def _processing_loop(self) -> None:
|
||||||
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
"""Main processing loop — poll segment streams → compose → brightness → send."""
|
||||||
stream = self._color_strip_stream
|
|
||||||
keepalive_interval = self._keepalive_interval
|
keepalive_interval = self._keepalive_interval
|
||||||
|
|
||||||
fps_samples: collections.deque = collections.deque(maxlen=10)
|
fps_samples: collections.deque = collections.deque(maxlen=10)
|
||||||
send_timestamps: collections.deque = collections.deque()
|
send_timestamps: collections.deque = collections.deque()
|
||||||
prev_colors = None
|
|
||||||
last_send_time = 0.0
|
last_send_time = 0.0
|
||||||
prev_frame_time_stamp = time.perf_counter()
|
prev_frame_time_stamp = time.perf_counter()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
_init_device_info = self._ctx.get_device_info(self._device_id)
|
_init_device_info = self._ctx.get_device_info(self._device_id)
|
||||||
_total_leds = _init_device_info.led_count if _init_device_info else 0
|
_total_leds = _init_device_info.led_count if _init_device_info else 0
|
||||||
effective_leds = max(1, _total_leds - self._led_skip_start - self._led_skip_end)
|
|
||||||
|
|
||||||
# Pre-allocate skip buffer (reused every frame — edges stay black)
|
# Device-sized output buffer (persistent between frames; gaps stay black)
|
||||||
if (self._led_skip_start > 0 or self._led_skip_end > 0) and _total_leds > 0:
|
device_buf = np.zeros((_total_leds, 3), dtype=np.uint8)
|
||||||
_skip_buf: Optional[np.ndarray] = np.zeros((_total_leds, 3), dtype=np.uint8)
|
|
||||||
else:
|
|
||||||
_skip_buf = None
|
|
||||||
|
|
||||||
# Pre-allocate resampling cache (linspace + result reused while sizes unchanged)
|
# Segment stream references — re-read each tick to detect hot-swaps
|
||||||
_fit_key = (0, 0)
|
segment_streams = self._segment_streams
|
||||||
_fit_src_x = _fit_dst_x = _fit_result = None
|
# Per-stream identity tracking for "same frame" detection
|
||||||
|
prev_refs: list = [None] * len(segment_streams)
|
||||||
def _cached_fit(colors_in):
|
has_any_frame = False
|
||||||
"""Resample colors to effective_leds using cached linspace arrays."""
|
|
||||||
nonlocal _fit_key, _fit_src_x, _fit_dst_x, _fit_result
|
|
||||||
n_src = len(colors_in)
|
|
||||||
if n_src == effective_leds or effective_leds <= 0:
|
|
||||||
return colors_in
|
|
||||||
if (n_src, effective_leds) != _fit_key:
|
|
||||||
_fit_key = (n_src, effective_leds)
|
|
||||||
_fit_src_x = np.linspace(0, 1, n_src)
|
|
||||||
_fit_dst_x = np.linspace(0, 1, effective_leds)
|
|
||||||
_fit_result = np.empty((effective_leds, 3), dtype=np.uint8)
|
|
||||||
for _ch in range(3):
|
|
||||||
np.copyto(_fit_result[:, _ch],
|
|
||||||
np.interp(_fit_dst_x, _fit_src_x, colors_in[:, _ch]),
|
|
||||||
casting='unsafe')
|
|
||||||
return _fit_result
|
|
||||||
|
|
||||||
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
|
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
|
||||||
_bright_u16: Optional[np.ndarray] = None
|
_bright_u16: Optional[np.ndarray] = None
|
||||||
@@ -472,7 +483,6 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
_bright_n = 0
|
_bright_n = 0
|
||||||
|
|
||||||
def _cached_brightness(colors_in, dev_info):
|
def _cached_brightness(colors_in, dev_info):
|
||||||
"""Apply software brightness using pre-allocated uint16 scratch."""
|
|
||||||
nonlocal _bright_n, _bright_u16, _bright_out
|
nonlocal _bright_n, _bright_u16, _bright_out
|
||||||
if not dev_info or dev_info.software_brightness >= 255:
|
if not dev_info or dev_info.software_brightness >= 255:
|
||||||
return colors_in
|
return colors_in
|
||||||
@@ -487,24 +497,20 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
np.copyto(_bright_out, _bright_u16, casting='unsafe')
|
np.copyto(_bright_out, _bright_u16, casting='unsafe')
|
||||||
return _bright_out
|
return _bright_out
|
||||||
|
|
||||||
# Short re-poll interval when the animation thread hasn't produced a new
|
|
||||||
# frame yet. The animation thread and this loop both target the same FPS
|
|
||||||
# but are unsynchronised; without a short re-poll the loop can miss a
|
|
||||||
# frame and wait a full frame_time, periodically halving the send rate.
|
|
||||||
SKIP_REPOLL = 0.005 # 5 ms
|
SKIP_REPOLL = 0.005 # 5 ms
|
||||||
|
|
||||||
# --- Timing diagnostics ---
|
# --- Timing diagnostics ---
|
||||||
_diag_interval = 5.0 # report every 5 seconds
|
_diag_interval = 5.0
|
||||||
_diag_next_report = time.perf_counter() + _diag_interval
|
_diag_next_report = time.perf_counter() + _diag_interval
|
||||||
_diag_sleep_jitters: list = [] # (requested_ms, actual_ms)
|
_diag_sleep_jitters: list = []
|
||||||
_diag_slow_iters: list = [] # (iter_ms, phase)
|
_diag_slow_iters: list = []
|
||||||
_diag_iter_times: list = [] # total iter durations in ms
|
_diag_iter_times: list = []
|
||||||
_diag_device_info: Optional[DeviceInfo] = None
|
_diag_device_info: Optional[DeviceInfo] = None
|
||||||
_diag_device_info_age = 0 # iterations since last refresh
|
_diag_device_info_age = 0
|
||||||
|
|
||||||
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={self._target_fps})"
|
f"({len(segment_streams)} segments, {_total_leds} LEDs, fps={self._target_fps})"
|
||||||
)
|
)
|
||||||
|
|
||||||
next_frame_time = time.perf_counter()
|
next_frame_time = time.perf_counter()
|
||||||
@@ -513,14 +519,18 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
with high_resolution_timer():
|
with high_resolution_timer():
|
||||||
while self._is_running:
|
while self._is_running:
|
||||||
loop_start = now = time.perf_counter()
|
loop_start = now = time.perf_counter()
|
||||||
# Re-read target_fps each tick so hot-updates take effect immediately
|
|
||||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
target_fps = self._target_fps if self._target_fps > 0 else 30
|
||||||
frame_time = 1.0 / target_fps
|
frame_time = 1.0 / target_fps
|
||||||
|
keepalive_interval = self._keepalive_interval
|
||||||
|
|
||||||
|
# Detect hot-swapped segments
|
||||||
|
cur_streams = self._segment_streams
|
||||||
|
if cur_streams is not segment_streams:
|
||||||
|
segment_streams = cur_streams
|
||||||
|
prev_refs = [None] * len(segment_streams)
|
||||||
|
has_any_frame = False
|
||||||
|
device_buf[:] = 0
|
||||||
|
|
||||||
# Re-fetch device info every ~30 iterations instead of every
|
|
||||||
# iteration (it's just a dict lookup but creates a new
|
|
||||||
# namedtuple each time, and we poll at ~200 iter/sec with
|
|
||||||
# SKIP_REPOLL).
|
|
||||||
_diag_device_info_age += 1
|
_diag_device_info_age += 1
|
||||||
if _diag_device_info is None or _diag_device_info_age >= 30:
|
if _diag_device_info is None or _diag_device_info_age >= 30:
|
||||||
_diag_device_info = self._ctx.get_device_info(self._device_id)
|
_diag_device_info = self._ctx.get_device_info(self._device_id)
|
||||||
@@ -532,25 +542,34 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
await asyncio.sleep(frame_time)
|
await asyncio.sleep(frame_time)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
if not segment_streams:
|
||||||
colors = stream.get_latest_colors()
|
|
||||||
|
|
||||||
if colors is None:
|
|
||||||
if self._metrics.frames_processed == 0:
|
|
||||||
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
|
||||||
|
|
||||||
if colors is prev_colors:
|
try:
|
||||||
# Same frame — send keepalive if interval elapsed (only for devices that need it)
|
# Poll all segment streams
|
||||||
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= keepalive_interval:
|
any_new = False
|
||||||
|
all_none = True
|
||||||
|
for i, (seg, stream) in enumerate(segment_streams):
|
||||||
|
frame = stream.get_latest_colors()
|
||||||
|
if frame is not prev_refs[i]:
|
||||||
|
any_new = True
|
||||||
|
prev_refs[i] = frame
|
||||||
|
if frame is not None:
|
||||||
|
all_none = False
|
||||||
|
|
||||||
|
if all_none:
|
||||||
|
if self._metrics.frames_processed == 0:
|
||||||
|
logger.info(f"No data from any segment stream for {self._target_id}")
|
||||||
|
await asyncio.sleep(frame_time)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not any_new:
|
||||||
|
# All streams returned same frame — keepalive or skip
|
||||||
|
if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_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
|
||||||
kc = prev_colors
|
send_colors = _cached_brightness(device_buf, device_info)
|
||||||
if device_info and device_info.led_count > 0:
|
|
||||||
kc = _cached_fit(kc)
|
|
||||||
kc = self._apply_led_skip(kc, _skip_buf, self._led_skip_start)
|
|
||||||
send_colors = _cached_brightness(kc, device_info)
|
|
||||||
if self._led_client.supports_fast_send:
|
if self._led_client.supports_fast_send:
|
||||||
self._led_client.send_pixels_fast(send_colors)
|
self._led_client.send_pixels_fast(send_colors)
|
||||||
else:
|
else:
|
||||||
@@ -563,19 +582,31 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
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)
|
||||||
repoll = SKIP_REPOLL if stream.is_animated else frame_time
|
is_animated = any(s.is_animated for _, s in segment_streams)
|
||||||
|
repoll = SKIP_REPOLL if is_animated else frame_time
|
||||||
await asyncio.sleep(repoll)
|
await asyncio.sleep(repoll)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
prev_colors = colors
|
has_any_frame = True
|
||||||
|
|
||||||
# Fit to effective LED count (excluding skipped) then pad with blacks
|
# Compose new frame from all segments
|
||||||
if device_info and device_info.led_count > 0:
|
device_buf[:] = 0
|
||||||
colors = _cached_fit(colors)
|
for i, (seg, stream) in enumerate(segment_streams):
|
||||||
colors = self._apply_led_skip(colors, _skip_buf, self._led_skip_start)
|
frame = prev_refs[i]
|
||||||
|
if frame is None:
|
||||||
|
continue
|
||||||
|
seg_start, seg_end = seg["start"], seg["end"]
|
||||||
|
seg_len = seg_end - seg_start
|
||||||
|
if seg_len <= 0:
|
||||||
|
continue
|
||||||
|
if len(frame) != seg_len:
|
||||||
|
frame = self._fit_to_device(frame, seg_len)
|
||||||
|
if seg.get("reverse"):
|
||||||
|
frame = frame[::-1]
|
||||||
|
device_buf[seg_start:seg_end] = frame
|
||||||
|
|
||||||
# Apply device software brightness
|
# Apply device software brightness
|
||||||
send_colors = _cached_brightness(colors, device_info)
|
send_colors = _cached_brightness(device_buf, device_info)
|
||||||
|
|
||||||
# Send to LED device
|
# 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:
|
||||||
@@ -601,7 +632,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
|
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
# FPS tracking (skip first sample — interval from loop init is near-zero)
|
# FPS tracking
|
||||||
interval = now - prev_frame_time_stamp
|
interval = now - prev_frame_time_stamp
|
||||||
prev_frame_time_stamp = now
|
prev_frame_time_stamp = now
|
||||||
if self._metrics.frames_processed > 1:
|
if self._metrics.frames_processed > 1:
|
||||||
@@ -620,9 +651,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._metrics.last_error = str(e)
|
self._metrics.last_error = str(e)
|
||||||
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
|
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
# Drift-compensating throttle: sleep until the absolute
|
# Drift-compensating throttle
|
||||||
# next_frame_time so overshoots in one frame are recovered
|
|
||||||
# in the next, keeping average FPS on target.
|
|
||||||
next_frame_time += frame_time
|
next_frame_time += frame_time
|
||||||
sleep_time = next_frame_time - time.perf_counter()
|
sleep_time = next_frame_time - time.perf_counter()
|
||||||
if sleep_time > 0:
|
if sleep_time > 0:
|
||||||
@@ -633,17 +662,16 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
requested_sleep = sleep_time * 1000
|
requested_sleep = sleep_time * 1000
|
||||||
jitter = actual_sleep - requested_sleep
|
jitter = actual_sleep - requested_sleep
|
||||||
_diag_sleep_jitters.append((requested_sleep, actual_sleep))
|
_diag_sleep_jitters.append((requested_sleep, actual_sleep))
|
||||||
if jitter > 10.0: # >10ms overshoot
|
if jitter > 10.0:
|
||||||
_diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter"))
|
_diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter"))
|
||||||
elif sleep_time < -frame_time:
|
elif sleep_time < -frame_time:
|
||||||
# Too far behind — reset to avoid burst catch-up
|
|
||||||
next_frame_time = time.perf_counter()
|
next_frame_time = time.perf_counter()
|
||||||
|
|
||||||
# Track total iteration time
|
# Track total iteration time
|
||||||
iter_end = time.perf_counter()
|
iter_end = time.perf_counter()
|
||||||
iter_ms = (iter_end - loop_start) * 1000
|
iter_ms = (iter_end - loop_start) * 1000
|
||||||
_diag_iter_times.append(iter_ms)
|
_diag_iter_times.append(iter_ms)
|
||||||
if iter_ms > frame_time * 1500: # > 1.5x frame time in ms
|
if iter_ms > frame_time * 1500:
|
||||||
if "sleep_jitter" not in [s[1] for s in _diag_slow_iters[-1:]]:
|
if "sleep_jitter" not in [s[1] for s in _diag_slow_iters[-1:]]:
|
||||||
_diag_slow_iters.append((iter_ms, "slow_iter"))
|
_diag_slow_iters.append((iter_ms, "slow_iter"))
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,92 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Segment rows in target editor */
|
||||||
|
.segment-row {
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: var(--card-bg, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-row-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-index-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-inline {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-text {
|
||||||
|
color: var(--danger-color, #f44336);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-text:hover {
|
||||||
|
color: #ff6659;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-row-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-row-fields select {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-range-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-range-fields label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-range-fields input[type="number"] {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-reverse-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-reverse-label input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.fps-hint {
|
.fps-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
loadTargetsTab, loadTargets, switchTargetSubTab,
|
loadTargetsTab, loadTargets, switchTargetSubTab,
|
||||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||||
|
addTargetSegment, removeTargetSegment,
|
||||||
startTargetProcessing, stopTargetProcessing,
|
startTargetProcessing, stopTargetProcessing,
|
||||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||||
} from './features/targets.js';
|
} from './features/targets.js';
|
||||||
@@ -265,6 +266,8 @@ Object.assign(window, {
|
|||||||
closeTargetEditorModal,
|
closeTargetEditorModal,
|
||||||
forceCloseTargetEditorModal,
|
forceCloseTargetEditorModal,
|
||||||
saveTargetEditor,
|
saveTargetEditor,
|
||||||
|
addTargetSegment,
|
||||||
|
removeTargetSegment,
|
||||||
startTargetProcessing,
|
startTargetProcessing,
|
||||||
stopTargetProcessing,
|
stopTargetProcessing,
|
||||||
startTargetOverlay,
|
startTargetOverlay,
|
||||||
|
|||||||
@@ -447,9 +447,15 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
|
|||||||
if (device) {
|
if (device) {
|
||||||
subtitleParts.push((device.device_type || '').toUpperCase());
|
subtitleParts.push((device.device_type || '').toUpperCase());
|
||||||
}
|
}
|
||||||
const cssSource = target.color_strip_source_id ? cssSourceMap[target.color_strip_source_id] : null;
|
const segments = target.segments || [];
|
||||||
if (cssSource) {
|
if (segments.length > 0) {
|
||||||
subtitleParts.push(t(`color_strip.type.${cssSource.source_type}`) || cssSource.source_type);
|
const firstCss = cssSourceMap[segments[0].color_strip_source_id];
|
||||||
|
if (firstCss) {
|
||||||
|
subtitleParts.push(t(`color_strip.type.${firstCss.source_type}`) || firstCss.source_type);
|
||||||
|
}
|
||||||
|
if (segments.length > 1) {
|
||||||
|
subtitleParts.push(`${segments.length} seg`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Segment editor state ---
|
||||||
|
let _editorCssSources = []; // populated when editor opens
|
||||||
|
|
||||||
|
function _serializeSegments() {
|
||||||
|
const rows = document.querySelectorAll('.segment-row');
|
||||||
|
const segments = [];
|
||||||
|
rows.forEach(row => {
|
||||||
|
segments.push({
|
||||||
|
css: row.querySelector('.segment-css-select').value,
|
||||||
|
start: row.querySelector('.segment-start').value,
|
||||||
|
end: row.querySelector('.segment-end').value,
|
||||||
|
reverse: row.querySelector('.segment-reverse').checked,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return JSON.stringify(segments);
|
||||||
|
}
|
||||||
|
|
||||||
class TargetEditorModal extends Modal {
|
class TargetEditorModal extends Modal {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('target-editor-modal');
|
super('target-editor-modal');
|
||||||
@@ -83,11 +100,9 @@ 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,
|
||||||
css: document.getElementById('target-editor-css').value,
|
segments: _serializeSegments(),
|
||||||
fps: document.getElementById('target-editor-fps').value,
|
fps: document.getElementById('target-editor-fps').value,
|
||||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||||
led_skip_start: document.getElementById('target-editor-skip-start').value,
|
|
||||||
led_skip_end: document.getElementById('target-editor-skip-end').value,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,9 +115,10 @@ 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 cssSelect = document.getElementById('target-editor-css');
|
|
||||||
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||||
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
|
// Use first segment's CSS name
|
||||||
|
const firstCssSelect = document.querySelector('.segment-css-select');
|
||||||
|
const cssName = firstCssSelect?.selectedOptions[0]?.dataset?.name || '';
|
||||||
if (!deviceName || !cssName) return;
|
if (!deviceName || !cssName) return;
|
||||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
||||||
}
|
}
|
||||||
@@ -132,6 +148,56 @@ function _updateKeepaliveVisibility() {
|
|||||||
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
|
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addTargetSegment(segment = null) {
|
||||||
|
const list = document.getElementById('target-editor-segment-list');
|
||||||
|
const index = list.querySelectorAll('.segment-row').length;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'segment-row';
|
||||||
|
row.innerHTML = _renderSegmentRowInner(index, segment);
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTargetSegment(btn) {
|
||||||
|
const row = btn.closest('.segment-row');
|
||||||
|
row.remove();
|
||||||
|
// Re-index labels
|
||||||
|
document.querySelectorAll('.segment-row').forEach((r, i) => {
|
||||||
|
const label = r.querySelector('.segment-index-label');
|
||||||
|
if (label) label.textContent = `#${i + 1}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSegmentRowInner(index, segment) {
|
||||||
|
const cssId = segment?.color_strip_source_id || '';
|
||||||
|
const start = segment?.start ?? 0;
|
||||||
|
const end = segment?.end ?? 0;
|
||||||
|
const reverse = segment?.reverse || false;
|
||||||
|
|
||||||
|
const options = _editorCssSources.map(s =>
|
||||||
|
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === cssId ? 'selected' : ''}>\uD83C\uDFAC ${escapeHtml(s.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="segment-row-header">
|
||||||
|
<span class="segment-index-label">#${index + 1}</span>
|
||||||
|
<button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="segment-row-fields">
|
||||||
|
<select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName()">${options}</select>
|
||||||
|
<div class="segment-range-fields">
|
||||||
|
<label>${t('targets.segment.start')}</label>
|
||||||
|
<input type="number" class="segment-start" min="0" value="${start}" placeholder="0">
|
||||||
|
<label>${t('targets.segment.end')}</label>
|
||||||
|
<input type="number" class="segment-end" min="0" value="${end}" placeholder="0 = auto">
|
||||||
|
</div>
|
||||||
|
<label class="segment-reverse-label">
|
||||||
|
<input type="checkbox" class="segment-reverse" ${reverse ? 'checked' : ''}>
|
||||||
|
<span>${t('targets.segment.reverse')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function showTargetEditor(targetId = null) {
|
export async function showTargetEditor(targetId = null) {
|
||||||
try {
|
try {
|
||||||
// Load devices and CSS sources for dropdowns
|
// Load devices and CSS sources for dropdowns
|
||||||
@@ -143,6 +209,7 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||||
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
||||||
set_targetEditorDevices(devices);
|
set_targetEditorDevices(devices);
|
||||||
|
_editorCssSources = cssSources;
|
||||||
|
|
||||||
// Populate device select
|
// Populate device select
|
||||||
const deviceSelect = document.getElementById('target-editor-device');
|
const deviceSelect = document.getElementById('target-editor-device');
|
||||||
@@ -157,16 +224,9 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
deviceSelect.appendChild(opt);
|
deviceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate color strip source select
|
// Clear segment list
|
||||||
const cssSelect = document.getElementById('target-editor-css');
|
const segmentList = document.getElementById('target-editor-segment-list');
|
||||||
cssSelect.innerHTML = '';
|
segmentList.innerHTML = '';
|
||||||
cssSources.forEach(s => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = s.id;
|
|
||||||
opt.dataset.name = s.name;
|
|
||||||
opt.textContent = `🎞️ ${s.name}`;
|
|
||||||
cssSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
// Editing existing target
|
// Editing existing target
|
||||||
@@ -177,33 +237,37 @@ 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 || '';
|
||||||
cssSelect.value = target.color_strip_source_id || '';
|
|
||||||
const fps = target.fps ?? 30;
|
const fps = target.fps ?? 30;
|
||||||
document.getElementById('target-editor-fps').value = fps;
|
document.getElementById('target-editor-fps').value = fps;
|
||||||
document.getElementById('target-editor-fps-value').textContent = fps;
|
document.getElementById('target-editor-fps-value').textContent = fps;
|
||||||
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
|
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
|
||||||
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
|
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
|
||||||
document.getElementById('target-editor-skip-start').value = target.led_skip_start ?? 0;
|
|
||||||
document.getElementById('target-editor-skip-end').value = target.led_skip_end ?? 0;
|
|
||||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||||
|
|
||||||
|
// Populate segments
|
||||||
|
const segments = target.segments || [];
|
||||||
|
if (segments.length === 0) {
|
||||||
|
addTargetSegment();
|
||||||
} else {
|
} else {
|
||||||
// Creating new target — first option is selected by default
|
segments.forEach(seg => addTargetSegment(seg));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Creating new target — start with one empty segment
|
||||||
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 = 30;
|
||||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||||
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
||||||
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
||||||
document.getElementById('target-editor-skip-start').value = 0;
|
|
||||||
document.getElementById('target-editor-skip-end').value = 0;
|
|
||||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||||
|
addTargetSegment();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-name generation
|
// Auto-name generation
|
||||||
_targetNameManuallyEdited = !!targetId;
|
_targetNameManuallyEdited = !!targetId;
|
||||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||||
|
window._targetAutoName = _autoGenerateTargetName;
|
||||||
deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _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
|
||||||
@@ -237,10 +301,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 cssId = document.getElementById('target-editor-css').value;
|
|
||||||
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
|
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
|
||||||
const ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0;
|
|
||||||
const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0;
|
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
targetEditorModal.showError(t('targets.error.name_required'));
|
targetEditorModal.showError(t('targets.error.name_required'));
|
||||||
@@ -249,14 +310,28 @@ export async function saveTargetEditor() {
|
|||||||
|
|
||||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||||
|
|
||||||
|
// Collect segments from DOM
|
||||||
|
const segmentRows = document.querySelectorAll('.segment-row');
|
||||||
|
const segments = [];
|
||||||
|
for (const row of segmentRows) {
|
||||||
|
const cssId = row.querySelector('.segment-css-select').value;
|
||||||
|
const start = parseInt(row.querySelector('.segment-start').value) || 0;
|
||||||
|
const end = parseInt(row.querySelector('.segment-end').value) || 0;
|
||||||
|
const reverse = row.querySelector('.segment-reverse').checked;
|
||||||
|
segments.push({
|
||||||
|
color_strip_source_id: cssId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
reverse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
color_strip_source_id: cssId,
|
segments,
|
||||||
fps,
|
fps,
|
||||||
keepalive_interval: standbyInterval,
|
keepalive_interval: standbyInterval,
|
||||||
led_skip_start: ledSkipStart,
|
|
||||||
led_skip_end: ledSkipEnd,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -535,6 +610,19 @@ export async function loadTargetsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _segmentsSummary(segments, colorStripSourceMap) {
|
||||||
|
if (!segments || segments.length === 0) return t('targets.no_segments');
|
||||||
|
return segments.map(seg => {
|
||||||
|
const css = colorStripSourceMap[seg.color_strip_source_id];
|
||||||
|
const name = css ? css.name : (seg.color_strip_source_id || '?');
|
||||||
|
let range = '';
|
||||||
|
if (seg.start || seg.end) {
|
||||||
|
range = ` [${seg.start}-${seg.end || '\u221e'}]`;
|
||||||
|
}
|
||||||
|
return escapeHtml(name) + range;
|
||||||
|
}).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||||
const state = target.state || {};
|
const state = target.state || {};
|
||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
@@ -542,9 +630,15 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|||||||
const isProcessing = state.processing || false;
|
const isProcessing = state.processing || false;
|
||||||
|
|
||||||
const device = deviceMap[target.device_id];
|
const device = deviceMap[target.device_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 cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
|
|
||||||
|
const segments = target.segments || [];
|
||||||
|
const segSummary = _segmentsSummary(segments, colorStripSourceMap);
|
||||||
|
|
||||||
|
// Determine if overlay is available (first segment has a picture-based CSS)
|
||||||
|
const firstCssId = segments.length > 0 ? segments[0].color_strip_source_id : '';
|
||||||
|
const firstCss = firstCssId ? colorStripSourceMap[firstCssId] : null;
|
||||||
|
const overlayAvailable = !firstCss || firstCss.source_type === 'picture';
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -568,7 +662,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|||||||
<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')}">⚡ ${target.fps || 30} fps</span>
|
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30} fps</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.segments')}">🎞️ ${segSummary}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
@@ -631,7 +725,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|||||||
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
|
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
${(!css || css.source_type === 'picture') ? (state.overlay_active ? `
|
${overlayAvailable ? (state.overlay_active ? `
|
||||||
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
|
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
|
||||||
👁️
|
👁️
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -356,8 +356,14 @@
|
|||||||
"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.segments": "Segments:",
|
||||||
"targets.color_strip_source.hint": "Color strip source that captures and processes screen pixels into LED colors",
|
"targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.",
|
||||||
|
"targets.segments.add": "+ Add Segment",
|
||||||
|
"targets.segment.start": "Start:",
|
||||||
|
"targets.segment.end": "End:",
|
||||||
|
"targets.segment.reverse": "Reverse",
|
||||||
|
"targets.segment.remove": "Remove segment",
|
||||||
|
"targets.no_segments": "No segments",
|
||||||
"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 --",
|
||||||
@@ -373,10 +379,6 @@
|
|||||||
"targets.interpolation.dominant": "Dominant",
|
"targets.interpolation.dominant": "Dominant",
|
||||||
"targets.smoothing": "Smoothing:",
|
"targets.smoothing": "Smoothing:",
|
||||||
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||||
"targets.led_skip": "LED Skip:",
|
|
||||||
"targets.led_skip.hint": "Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.",
|
|
||||||
"targets.led_skip_start": "Start:",
|
|
||||||
"targets.led_skip_end": "End:",
|
|
||||||
"targets.keepalive_interval": "Keep Alive Interval:",
|
"targets.keepalive_interval": "Keep Alive Interval:",
|
||||||
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
|
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
|
||||||
"targets.created": "Target created successfully",
|
"targets.created": "Target created successfully",
|
||||||
|
|||||||
@@ -356,8 +356,14 @@
|
|||||||
"targets.device": "Устройство:",
|
"targets.device": "Устройство:",
|
||||||
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
||||||
"targets.device.none": "-- Выберите устройство --",
|
"targets.device.none": "-- Выберите устройство --",
|
||||||
"targets.color_strip_source": "Источник цветовой полосы:",
|
"targets.segments": "Сегменты:",
|
||||||
"targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов",
|
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
|
||||||
|
"targets.segments.add": "+ Добавить сегмент",
|
||||||
|
"targets.segment.start": "Начало:",
|
||||||
|
"targets.segment.end": "Конец:",
|
||||||
|
"targets.segment.reverse": "Реверс",
|
||||||
|
"targets.segment.remove": "Удалить сегмент",
|
||||||
|
"targets.no_segments": "Нет сегментов",
|
||||||
"targets.source": "Источник:",
|
"targets.source": "Источник:",
|
||||||
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
||||||
"targets.source.none": "-- Источник не назначен --",
|
"targets.source.none": "-- Источник не назначен --",
|
||||||
@@ -373,10 +379,6 @@
|
|||||||
"targets.interpolation.dominant": "Доминантный",
|
"targets.interpolation.dominant": "Доминантный",
|
||||||
"targets.smoothing": "Сглаживание:",
|
"targets.smoothing": "Сглаживание:",
|
||||||
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||||
"targets.led_skip": "Пропуск LED:",
|
|
||||||
"targets.led_skip.hint": "Количество светодиодов в начале и конце ленты, которые остаются чёрными. Источники цвета будут рендериться только на активных (непропущенных) LED.",
|
|
||||||
"targets.led_skip_start": "Начало:",
|
|
||||||
"targets.led_skip_end": "Конец:",
|
|
||||||
"targets.keepalive_interval": "Интервал поддержания связи:",
|
"targets.keepalive_interval": "Интервал поддержания связи:",
|
||||||
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
|
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
|
||||||
"targets.created": "Цель успешно создана",
|
"targets.created": "Цель успешно создана",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
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 TargetSegment, WledPictureTarget
|
||||||
from wled_controller.storage.key_colors_picture_target import (
|
from wled_controller.storage.key_colors_picture_target import (
|
||||||
KeyColorsSettings,
|
KeyColorsSettings,
|
||||||
KeyColorsPictureTarget,
|
KeyColorsPictureTarget,
|
||||||
@@ -101,38 +101,21 @@ class PictureTargetStore:
|
|||||||
name: str,
|
name: str,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
color_strip_source_id: str = "",
|
segments: Optional[List[dict]] = None,
|
||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
keepalive_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
led_skip_start: int = 0,
|
|
||||||
led_skip_end: int = 0,
|
|
||||||
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 = "",
|
picture_source_id: str = "",
|
||||||
settings=None,
|
|
||||||
) -> PictureTarget:
|
) -> PictureTarget:
|
||||||
"""Create a new picture target.
|
"""Create a new picture target.
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Target name
|
|
||||||
target_type: Target type ("led", "wled", "key_colors")
|
|
||||||
device_id: WLED device ID (for led targets)
|
|
||||||
color_strip_source_id: Color strip source ID (for led targets)
|
|
||||||
keepalive_interval: Keepalive interval in seconds (for led targets)
|
|
||||||
state_check_interval: State check interval in seconds (for led targets)
|
|
||||||
key_colors_settings: Key colors settings (for key_colors targets)
|
|
||||||
description: Optional description
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If validation fails
|
ValueError: If validation fails
|
||||||
"""
|
"""
|
||||||
if target_type not in ("led", "wled", "key_colors"):
|
if target_type not in ("led", "key_colors"):
|
||||||
raise ValueError(f"Invalid target type: {target_type}")
|
raise ValueError(f"Invalid target type: {target_type}")
|
||||||
# Normalize legacy "wled" to "led"
|
|
||||||
if target_type == "wled":
|
|
||||||
target_type = "led"
|
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
for target in self._targets.values():
|
for target in self._targets.values():
|
||||||
@@ -143,17 +126,17 @@ class PictureTargetStore:
|
|||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
if target_type == "led":
|
if target_type == "led":
|
||||||
|
seg_list = [TargetSegment.from_dict(s) for s in segments] if segments else []
|
||||||
|
|
||||||
target: PictureTarget = WledPictureTarget(
|
target: PictureTarget = WledPictureTarget(
|
||||||
id=target_id,
|
id=target_id,
|
||||||
name=name,
|
name=name,
|
||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
segments=seg_list,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
led_skip_start=led_skip_start,
|
|
||||||
led_skip_end=led_skip_end,
|
|
||||||
description=description,
|
description=description,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -183,17 +166,12 @@ 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,
|
||||||
color_strip_source_id: Optional[str] = None,
|
segments: Optional[List[dict]] = None,
|
||||||
fps: Optional[int] = None,
|
fps: Optional[int] = None,
|
||||||
keepalive_interval: Optional[float] = None,
|
keepalive_interval: Optional[float] = None,
|
||||||
state_check_interval: Optional[int] = None,
|
state_check_interval: Optional[int] = None,
|
||||||
led_skip_start: Optional[int] = None,
|
|
||||||
led_skip_end: 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.
|
||||||
|
|
||||||
@@ -214,12 +192,10 @@ class PictureTargetStore:
|
|||||||
target.update_fields(
|
target.update_fields(
|
||||||
name=name,
|
name=name,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
segments=segments,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
keepalive_interval=keepalive_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
led_skip_start=led_skip_start,
|
|
||||||
led_skip_end=led_skip_end,
|
|
||||||
key_colors_settings=key_colors_settings,
|
key_colors_settings=key_colors_settings,
|
||||||
description=description,
|
description=description,
|
||||||
)
|
)
|
||||||
@@ -262,7 +238,8 @@ class PictureTargetStore:
|
|||||||
"""Return names of LED targets that reference a color strip source."""
|
"""Return names of LED targets that reference a color strip source."""
|
||||||
return [
|
return [
|
||||||
target.name for target in self._targets.values()
|
target.name for target in self._targets.values()
|
||||||
if isinstance(target, WledPictureTarget) and target.color_strip_source_id == css_id
|
if isinstance(target, WledPictureTarget)
|
||||||
|
and any(seg.color_strip_source_id == css_id for seg in target.segments)
|
||||||
]
|
]
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""LED picture target — sends a color strip source to an LED device."""
|
"""LED picture target — sends color strip sources to an LED device."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from wled_controller.storage.picture_target import PictureTarget
|
from wled_controller.storage.picture_target import PictureTarget
|
||||||
|
|
||||||
@@ -9,20 +10,50 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WledPictureTarget(PictureTarget):
|
class TargetSegment:
|
||||||
"""LED picture target — pairs an LED device with a ColorStripSource.
|
"""Maps a color strip source to a pixel range on the LED device.
|
||||||
|
|
||||||
The ColorStripSource produces LED colors (calibration, color correction,
|
``start`` is inclusive, ``end`` is exclusive. When a target has a single
|
||||||
smoothing). The target controls device-specific settings including send FPS.
|
segment with ``end == 0`` the range auto-fits to the full device LED count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
color_strip_source_id: str = ""
|
||||||
|
start: int = 0
|
||||||
|
end: int = 0
|
||||||
|
reverse: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"color_strip_source_id": self.color_strip_source_id,
|
||||||
|
"start": self.start,
|
||||||
|
"end": self.end,
|
||||||
|
"reverse": self.reverse,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict) -> "TargetSegment":
|
||||||
|
return TargetSegment(
|
||||||
|
color_strip_source_id=d.get("color_strip_source_id", ""),
|
||||||
|
start=d.get("start", 0),
|
||||||
|
end=d.get("end", 0),
|
||||||
|
reverse=d.get("reverse", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WledPictureTarget(PictureTarget):
|
||||||
|
"""LED picture target — pairs an LED device with one or more ColorStripSources.
|
||||||
|
|
||||||
|
Each segment maps a ColorStripSource to a pixel range on the device.
|
||||||
|
Gaps between segments stay black. A single segment with ``end == 0``
|
||||||
|
auto-fits to the full device LED count.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
color_strip_source_id: str = ""
|
segments: List[TargetSegment] = field(default_factory=list)
|
||||||
fps: int = 30 # target send FPS (1-90)
|
fps: int = 30 # target send FPS (1-90)
|
||||||
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
led_skip_start: int = 0 # first N LEDs forced to black
|
|
||||||
led_skip_end: int = 0 # last M LEDs forced to black
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -30,80 +61,90 @@ 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,
|
||||||
color_strip_source_id=self.color_strip_source_id,
|
segments=[s.to_dict() for s in self.segments],
|
||||||
fps=self.fps,
|
fps=self.fps,
|
||||||
keepalive_interval=self.keepalive_interval,
|
keepalive_interval=self.keepalive_interval,
|
||||||
state_check_interval=self.state_check_interval,
|
state_check_interval=self.state_check_interval,
|
||||||
led_skip_start=self.led_skip_start,
|
|
||||||
led_skip_end=self.led_skip_end,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
|
segments_changed: bool = False,
|
||||||
|
device_changed: bool = False) -> 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, {
|
manager.update_target_settings(self.id, {
|
||||||
"fps": self.fps,
|
"fps": self.fps,
|
||||||
"keepalive_interval": self.keepalive_interval,
|
"keepalive_interval": self.keepalive_interval,
|
||||||
"state_check_interval": self.state_check_interval,
|
"state_check_interval": self.state_check_interval,
|
||||||
"led_skip_start": self.led_skip_start,
|
|
||||||
"led_skip_end": self.led_skip_end,
|
|
||||||
})
|
})
|
||||||
if source_changed:
|
if segments_changed:
|
||||||
manager.update_target_color_strip_source(self.id, self.color_strip_source_id)
|
manager.update_target_segments(self.id, [s.to_dict() for s in self.segments])
|
||||||
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, color_strip_source_id=None,
|
def update_fields(self, *, name=None, device_id=None, segments=None,
|
||||||
fps=None, keepalive_interval=None, state_check_interval=None,
|
fps=None, keepalive_interval=None, state_check_interval=None,
|
||||||
led_skip_start=None, led_skip_end=None,
|
|
||||||
description=None, **_kwargs) -> None:
|
description=None, **_kwargs) -> 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 color_strip_source_id is not None:
|
if segments is not None:
|
||||||
self.color_strip_source_id = color_strip_source_id
|
self.segments = [
|
||||||
|
TargetSegment.from_dict(s) if isinstance(s, dict) else s
|
||||||
|
for s in segments
|
||||||
|
]
|
||||||
if fps is not None:
|
if fps is not None:
|
||||||
self.fps = fps
|
self.fps = fps
|
||||||
if keepalive_interval is not None:
|
if keepalive_interval is not None:
|
||||||
self.keepalive_interval = keepalive_interval
|
self.keepalive_interval = keepalive_interval
|
||||||
if state_check_interval is not None:
|
if state_check_interval is not None:
|
||||||
self.state_check_interval = state_check_interval
|
self.state_check_interval = state_check_interval
|
||||||
if led_skip_start is not None:
|
|
||||||
self.led_skip_start = led_skip_start
|
|
||||||
if led_skip_end is not None:
|
|
||||||
self.led_skip_end = led_skip_end
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_picture_source(self) -> bool:
|
def has_picture_source(self) -> bool:
|
||||||
return bool(self.color_strip_source_id)
|
return any(s.color_strip_source_id for s in self.segments)
|
||||||
|
|
||||||
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["color_strip_source_id"] = self.color_strip_source_id
|
d["segments"] = [s.to_dict() for s in self.segments]
|
||||||
d["fps"] = self.fps
|
d["fps"] = self.fps
|
||||||
d["keepalive_interval"] = self.keepalive_interval
|
d["keepalive_interval"] = self.keepalive_interval
|
||||||
d["state_check_interval"] = self.state_check_interval
|
d["state_check_interval"] = self.state_check_interval
|
||||||
d["led_skip_start"] = self.led_skip_start
|
|
||||||
d["led_skip_end"] = self.led_skip_end
|
|
||||||
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 with backward compatibility."""
|
||||||
|
# Migrate old single-source format to segments
|
||||||
|
if "segments" in data:
|
||||||
|
segments = [TargetSegment.from_dict(s) for s in data["segments"]]
|
||||||
|
elif "color_strip_source_id" in data:
|
||||||
|
css_id = data.get("color_strip_source_id", "")
|
||||||
|
skip_start = data.get("led_skip_start", 0)
|
||||||
|
skip_end = data.get("led_skip_end", 0)
|
||||||
|
if css_id:
|
||||||
|
segments = [TargetSegment(
|
||||||
|
color_strip_source_id=css_id,
|
||||||
|
start=skip_start,
|
||||||
|
end=0, # auto-fit; skip_end handled by processor
|
||||||
|
)]
|
||||||
|
else:
|
||||||
|
segments = []
|
||||||
|
else:
|
||||||
|
segments = []
|
||||||
|
|
||||||
return cls(
|
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", ""),
|
||||||
color_strip_source_id=data.get("color_strip_source_id", ""),
|
segments=segments,
|
||||||
fps=data.get("fps", 30),
|
fps=data.get("fps", 30),
|
||||||
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
||||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||||
led_skip_start=data.get("led_skip_start", 0),
|
|
||||||
led_skip_end=data.get("led_skip_end", 0),
|
|
||||||
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())),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- Target Editor Modal (name, device, color strip source, standby) -->
|
<!-- Target Editor Modal (name, device, segments, settings) -->
|
||||||
<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">
|
||||||
@@ -23,13 +23,14 @@
|
|||||||
<select id="target-editor-device"></select>
|
<select id="target-editor-device"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" id="target-editor-segments-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-css" data-i18n="targets.color_strip_source">Color Strip Source:</label>
|
<label data-i18n="targets.segments">Segments:</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.color_strip_source.hint">Color strip source that captures and processes screen pixels into LED colors</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.segments.hint">Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.</small>
|
||||||
<select id="target-editor-css"></select>
|
<div id="target-editor-segment-list"></div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="addTargetSegment()" data-i18n="targets.segments.add">+ Add Segment</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-fps-group">
|
<div class="form-group" id="target-editor-fps-group">
|
||||||
@@ -48,24 +49,6 @@
|
|||||||
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-skip-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label data-i18n="targets.led_skip">LED Skip:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.led_skip.hint">Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.</small>
|
|
||||||
<div class="inline-fields">
|
|
||||||
<div class="inline-field">
|
|
||||||
<label for="target-editor-skip-start" data-i18n="targets.led_skip_start">Start:</label>
|
|
||||||
<input type="number" id="target-editor-skip-start" min="0" value="0">
|
|
||||||
</div>
|
|
||||||
<div class="inline-field">
|
|
||||||
<label for="target-editor-skip-end" data-i18n="targets.led_skip_end">End:</label>
|
|
||||||
<input type="number" id="target-editor-skip-end" min="0" value="0">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-keepalive-group">
|
<div class="form-group" id="target-editor-keepalive-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-keepalive-interval">
|
<label for="target-editor-keepalive-interval">
|
||||||
|
|||||||
Reference in New Issue
Block a user