Remove target segments, use single color strip source per target

Segments are redundant now that the "mapped" CSS type handles spatial
multiplexing internally. Each target now references one color_strip_source_id
instead of an array of segments with start/end/reverse ranges.

Backward compat: existing targets with old segments format are migrated
on load by extracting the first segment's CSS source ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 00:00:26 +03:00
parent 9efb08acb6
commit 808037775f
14 changed files with 171 additions and 513 deletions

View File

@@ -82,6 +82,10 @@ powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-
**Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends). **Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends).
## Default Config & API Key
The server configuration is in `/server/config/default_config.yaml`. The default API key for development is `development-key-change-in-production` (label: `dev`). The server runs on port **8080** by default.
## Project Structure ## Project Structure
This is a monorepo containing: This is a monorepo containing:

View File

@@ -32,7 +32,6 @@ 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
@@ -94,15 +93,7 @@ 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,
segments=[ color_strip_source_id=target.color_strip_source_id,
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,
@@ -157,7 +148,7 @@ 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,
segments=[s.model_dump() for s in data.segments] if data.segments else None, color_strip_source_id=data.color_strip_source_id,
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,
@@ -267,12 +258,11 @@ 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,
segments=segments_dicts, color_strip_source_id=data.color_strip_source_id,
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,
@@ -288,7 +278,7 @@ async def update_target(
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.key_colors_settings is not None), data.key_colors_settings is not None),
segments_changed=data.segments is not None, css_changed=data.color_strip_source_id is not None,
device_changed=data.device_id is not None, device_changed=data.device_id is not None,
) )
except ValueError: except ValueError:
@@ -756,9 +746,8 @@ 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.segments: if isinstance(target, WledPictureTarget) and target.color_strip_source_id:
# Use the first segment's CSS for calibration/overlay first_css_id = target.color_strip_source_id
first_css_id = target.segments[0].color_strip_source_id
if first_css_id: if first_css_id:
try: try:
css = color_strip_store.get_source(first_css_id) css = color_strip_store.get_source(first_css_id)

View File

@@ -1,7 +1,7 @@
"""Picture target schemas (CRUD, processing state, metrics).""" """Picture target schemas (CRUD, processing state, metrics)."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, Optional, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -45,15 +45,6 @@ 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."""
@@ -61,7 +52,7 @@ 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")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments") color_strip_source_id: str = Field(default="", description="Color strip source ID")
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)
@@ -77,7 +68,7 @@ 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")
segments: Optional[List[TargetSegmentSchema]] = Field(None, description="LED segments") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
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)
@@ -95,7 +86,7 @@ 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")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments") color_strip_source_id: str = Field(default="", description="Color strip source ID")
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)")
@@ -119,7 +110,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")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments") color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)") fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")

View File

@@ -272,7 +272,7 @@ class ProcessorManager:
self, self,
target_id: str, target_id: str,
device_id: str, device_id: str,
segments: Optional[list] = None, color_strip_source_id: str = "",
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,
@@ -286,7 +286,7 @@ class ProcessorManager:
proc = WledTargetProcessor( proc = WledTargetProcessor(
target_id=target_id, target_id=target_id,
device_id=device_id, device_id=device_id,
segments=segments, color_strip_source_id=color_strip_source_id,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
@@ -335,10 +335,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_segments(self, target_id: str, segments: list): def update_target_css(self, target_id: str, color_strip_source_id: str):
"""Update the segments for a WLED target.""" """Update the color strip source for a WLED target."""
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
proc.update_segments(segments) proc.update_css_source(color_strip_source_id)
def update_target_device(self, target_id: str, device_id: str): def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a target.""" """Update the device for a target."""

View File

@@ -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_segments(self, segments: list) -> None: def update_css_source(self, color_strip_source_id: str) -> None:
"""Update segments. No-op for targets that don't use segments.""" """Update the color strip source. No-op for targets that don't use CSS."""
pass pass
# ----- Device / display info (overridden by device-aware subclasses) ----- # ----- Device / display info (overridden by device-aware subclasses) -----

View File

@@ -1,4 +1,4 @@
"""WLED/LED target processor — gets colors from ColorStripStreams, sends via DDP.""" """WLED/LED target processor — gets colors from a ColorStripStream, 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 List, Optional, Tuple from typing import Optional
import numpy as np import numpy as np
@@ -24,47 +24,14 @@ 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
# ---------------------------------------------------------------------------
class WledTargetProcessor(TargetProcessor): class WledTargetProcessor(TargetProcessor):
"""Streams LED colors from one or more ColorStripStreams to a WLED/LED device. """Streams LED colors from a single ColorStripStream to a WLED/LED device."""
Each segment maps a CSS source to a pixel range on the device.
Gaps between segments stay black.
"""
def __init__( def __init__(
self, self,
target_id: str, target_id: str,
device_id: str, device_id: str,
segments: Optional[List[dict]] = None, color_strip_source_id: str = "",
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = 30, state_check_interval: int = 30,
@@ -75,12 +42,11 @@ class WledTargetProcessor(TargetProcessor):
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._segments = list(segments) if segments else [] self._css_id = color_strip_source_id
# Runtime state (populated on start) # Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None self._led_client: Optional[LEDClient] = None
# List of (resolved_seg_dict, stream) tuples — read by the loop self._css_stream: Optional[object] = None # active stream reference
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 self._needs_keepalive = True
@@ -126,57 +92,35 @@ 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 streams for each segment # Acquire color strip stream
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._segments: if not self._css_id:
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 segments configured") raise RuntimeError(f"Target {self._target_id} has no color strip source configured")
resolved = _resolve_segments(self._segments, device_info.led_count)
segment_streams: List[Tuple[dict, object]] = []
try: try:
for seg in resolved: stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
if not seg["css_id"]: if hasattr(stream, "configure") and device_info.led_count > 0:
continue stream.configure(device_info.led_count)
stream = await asyncio.to_thread(css_manager.acquire, seg["css_id"], self._target_id) css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
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))
# Resolve display index from first stream that has one self._resolved_display_index = getattr(stream, "display_index", None)
self._resolved_display_index = None self._css_stream = stream
for _, s in segment_streams:
di = getattr(s, "display_index", None)
if di is not None:
self._resolved_display_index = di
break
self._segment_streams = segment_streams
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 {len(segment_streams)} segment stream(s) for target {self._target_id}: {seg_desc}" f"Acquired CSS stream '{self._css_id}' for target {self._target_id}"
) )
except Exception as e: except Exception as 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 segment streams: {e}") raise RuntimeError(f"Failed to acquire CSS stream: {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())
@@ -213,16 +157,15 @@ class WledTargetProcessor(TargetProcessor):
await self._led_client.close() await self._led_client.close()
self._led_client = None self._led_client = None
# Release all segment streams # Release CSS stream
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager: if css_manager and self._css_stream is not None:
for seg, stream in self._segment_streams: try:
try: css_manager.remove_target_fps(self._css_id, self._target_id)
css_manager.remove_target_fps(seg["css_id"], self._target_id) await asyncio.to_thread(css_manager.release, self._css_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 CSS stream {self._css_id} for {self._target_id}: {e}")
logger.warning(f"Error releasing segment stream {seg['css_id']} for {self._target_id}: {e}") self._css_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})
@@ -235,9 +178,8 @@ class WledTargetProcessor(TargetProcessor):
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
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._is_running: if css_manager and self._is_running and self._css_id:
for seg, _ in self._segment_streams: css_manager.notify_target_fps(self._css_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:
@@ -248,9 +190,10 @@ class WledTargetProcessor(TargetProcessor):
"""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_segments(self, new_segments: List[dict]) -> None: def update_css_source(self, new_css_id: str) -> None:
"""Hot-swap all segments for a running target.""" """Hot-swap the color strip source for a running target."""
self._segments = list(new_segments) old_css_id = self._css_id
self._css_id = new_css_id
if not self._is_running: if not self._is_running:
return return
@@ -262,42 +205,35 @@ class WledTargetProcessor(TargetProcessor):
device_info = self._ctx.get_device_info(self._device_id) device_info = self._ctx.get_device_info(self._device_id)
device_leds = device_info.led_count if device_info else 0 device_leds = device_info.led_count if device_info else 0
# Release old streams # Release old stream
for seg, stream in self._segment_streams: if self._css_stream is not None and old_css_id:
try: try:
css_manager.remove_target_fps(seg["css_id"], self._target_id) css_manager.remove_target_fps(old_css_id, self._target_id)
css_manager.release(seg["css_id"], self._target_id) css_manager.release(old_css_id, self._target_id)
except Exception as e: except Exception as e:
logger.warning(f"Error releasing segment {seg['css_id']}: {e}") logger.warning(f"Error releasing old CSS {old_css_id}: {e}")
# Acquire new streams # Acquire new stream
resolved = _resolve_segments(new_segments, device_leds) new_stream = None
new_stream_list: List[Tuple[dict, object]] = [] if new_css_id:
for seg in resolved:
if not seg["css_id"]:
continue
try: try:
stream = css_manager.acquire(seg["css_id"], self._target_id) new_stream = css_manager.acquire(new_css_id, self._target_id)
seg_len = seg["end"] - seg["start"] if hasattr(new_stream, "configure") and device_leds > 0:
if hasattr(stream, "configure") and seg_len > 0: new_stream.configure(device_leds)
stream.configure(seg_len) css_manager.notify_target_fps(new_css_id, self._target_id, self._target_fps)
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
new_stream_list.append((seg, stream))
except Exception as e: except Exception as e:
logger.error(f"Failed to acquire segment {seg['css_id']}: {e}") logger.error(f"Failed to acquire new CSS {new_css_id}: {e}")
# Atomic swap — the processing loop re-reads this reference each tick # Atomic swap — the processing loop detects via identity check
self._segment_streams = new_stream_list self._css_stream = new_stream
logger.info(f"Hot-swapped segments for {self._target_id}: {len(new_stream_list)} segment(s)") logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
def get_display_index(self) -> Optional[int]: def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the first active stream.""" """Display index being captured, from the active stream."""
if self._resolved_display_index is not None: if self._resolved_display_index is not None:
return self._resolved_display_index return self._resolved_display_index
for _, stream in self._segment_streams: if self._css_stream is not None:
di = getattr(stream, "display_index", None) return getattr(self._css_stream, "display_index", None)
if di is not None:
return di
return None return None
# ----- State / Metrics ----- # ----- State / Metrics -----
@@ -307,8 +243,8 @@ class WledTargetProcessor(TargetProcessor):
fps_target = self._target_fps fps_target = self._target_fps
css_timing: dict = {} css_timing: dict = {}
if self._is_running and self._segment_streams: if self._is_running and self._css_stream is not None:
css_timing = self._segment_streams[0][1].get_last_timing() css_timing = self._css_stream.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
@@ -321,19 +257,10 @@ class WledTargetProcessor(TargetProcessor):
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,
"segments": segments_info, "color_strip_source_id": self._css_id,
"processing": self._is_running, "processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None, "fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None,
@@ -383,25 +310,19 @@ 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:
# Find calibration from the first picture stream stream = self._css_stream
stream_with_cal = None if stream is None or not (hasattr(stream, "calibration") and stream.calibration):
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 stream with calibration" f"Cannot start overlay for {self._target_id}: no stream with calibration"
) )
if calibration is None: if calibration is None:
calibration = stream_with_cal.calibration calibration = stream.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 = getattr(stream_with_cal, "display_index", None) display_index = getattr(stream, "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()
@@ -435,13 +356,6 @@ class WledTargetProcessor(TargetProcessor):
# ----- Private: processing loop ----- # ----- Private: processing loop -----
@staticmethod
def _apply_brightness(colors: np.ndarray, device_info: Optional[DeviceInfo]) -> np.ndarray:
"""Apply device software_brightness if < 255."""
if device_info and device_info.software_brightness < 255:
return (colors.astype(np.uint16) * device_info.software_brightness >> 8).astype(np.uint8)
return colors
@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 LED count.""" """Resample colors to match the target LED count."""
@@ -457,7 +371,7 @@ class WledTargetProcessor(TargetProcessor):
return result return result
async def _processing_loop(self) -> None: async def _processing_loop(self) -> None:
"""Main processing loop — poll segment streams → compose → brightness send.""" """Main processing loop — poll CSS stream -> brightness -> send."""
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)
@@ -468,13 +382,9 @@ class WledTargetProcessor(TargetProcessor):
_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
# Device-sized output buffer (persistent between frames; gaps stay black) # Stream reference — re-read each tick to detect hot-swaps
device_buf = np.zeros((_total_leds, 3), dtype=np.uint8) stream = self._css_stream
prev_frame_ref = None
# Segment stream references — re-read each tick to detect hot-swaps
segment_streams = self._segment_streams
# Per-stream identity tracking for "same frame" detection
prev_refs: list = [None] * len(segment_streams)
has_any_frame = False has_any_frame = False
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output) # Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
@@ -510,7 +420,7 @@ class WledTargetProcessor(TargetProcessor):
logger.info( logger.info(
f"Processing loop started for target {self._target_id} " f"Processing loop started for target {self._target_id} "
f"({len(segment_streams)} segments, {_total_leds} LEDs, fps={self._target_fps})" f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps})"
) )
next_frame_time = time.perf_counter() next_frame_time = time.perf_counter()
@@ -523,13 +433,12 @@ class WledTargetProcessor(TargetProcessor):
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
keepalive_interval = self._keepalive_interval keepalive_interval = self._keepalive_interval
# Detect hot-swapped segments # Detect hot-swapped CSS stream
cur_streams = self._segment_streams cur_stream = self._css_stream
if cur_streams is not segment_streams: if cur_stream is not stream:
segment_streams = cur_streams stream = cur_stream
prev_refs = [None] * len(segment_streams) prev_frame_ref = None
has_any_frame = False has_any_frame = False
device_buf[:] = 0
_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:
@@ -542,34 +451,28 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue continue
if not segment_streams: if stream is None:
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue continue
try: try:
# Poll all segment streams # Poll the CSS stream
any_new = False frame = stream.get_latest_colors()
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 frame is None:
if self._metrics.frames_processed == 0: if self._metrics.frames_processed == 0:
logger.info(f"No data from any segment stream for {self._target_id}") logger.info(f"No data from CSS stream for {self._target_id}")
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue continue
if not any_new: if frame is prev_frame_ref:
# All streams returned same frame — keepalive or skip # Same frame — keepalive or skip
if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_interval: 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
send_colors = _cached_brightness(device_buf, device_info) send_colors = _cached_brightness(
self._fit_to_device(prev_frame_ref, _total_leds), 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:
@@ -582,31 +485,17 @@ 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)
is_animated = any(s.is_animated for _, s in segment_streams) is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll) await asyncio.sleep(repoll)
continue continue
prev_frame_ref = frame
has_any_frame = True has_any_frame = True
# Compose new frame from all segments # Fit to device LED count and apply brightness
device_buf[:] = 0 device_colors = self._fit_to_device(frame, _total_leds)
for i, (seg, stream) in enumerate(segment_streams): send_colors = _cached_brightness(device_colors, device_info)
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
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:

View File

@@ -85,7 +85,6 @@ import {
import { import {
loadTargetsTab, loadTargets, switchTargetSubTab, loadTargetsTab, loadTargets, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
addTargetSegment, removeTargetSegment, syncSegmentsMappedMode,
startTargetProcessing, stopTargetProcessing, startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, cloneTarget,
@@ -283,9 +282,6 @@ Object.assign(window, {
closeTargetEditorModal, closeTargetEditorModal,
forceCloseTargetEditorModal, forceCloseTargetEditorModal,
saveTargetEditor, saveTargetEditor,
addTargetSegment,
removeTargetSegment,
syncSegmentsMappedMode,
startTargetProcessing, startTargetProcessing,
stopTargetProcessing, stopTargetProcessing,
startTargetOverlay, startTargetOverlay,

View File

@@ -447,14 +447,11 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
if (device) { if (device) {
subtitleParts.push((device.device_type || '').toUpperCase()); subtitleParts.push((device.device_type || '').toUpperCase());
} }
const segments = target.segments || []; const cssId = target.color_strip_source_id || '';
if (segments.length > 0) { if (cssId) {
const firstCss = cssSourceMap[segments[0].color_strip_source_id]; const css = cssSourceMap[cssId];
if (firstCss) { if (css) {
subtitleParts.push(t(`color_strip.type.${firstCss.source_type}`) || firstCss.source_type); subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
}
if (segments.length > 1) {
subtitleParts.push(`${segments.length} seg`);
} }
} }
} }

View File

@@ -74,73 +74,9 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
}); });
} }
// --- Segment editor state --- // --- Editor state ---
let _editorCssSources = []; // populated when editor opens let _editorCssSources = []; // populated when editor opens
/**
* When the selected CSS source is a mapped type, collapse the segment UI
* to a single source dropdown — range fields, reverse, header, and "Add Segment"
* are hidden because the mapped CSS already defines spatial zones internally.
*/
export function syncSegmentsMappedMode() {
const list = document.getElementById('target-editor-segment-list');
if (!list) return;
const rows = list.querySelectorAll('.segment-row');
if (rows.length === 0) return;
const firstSelect = rows[0].querySelector('.segment-css-select');
const selectedId = firstSelect ? firstSelect.value : '';
const selectedSource = _editorCssSources.find(s => s.id === selectedId);
const isMapped = selectedSource && selectedSource.source_type === 'mapped';
// Remove extra segments when switching to mapped
if (isMapped && rows.length > 1) {
for (let i = rows.length - 1; i >= 1; i--) rows[i].remove();
}
// Toggle visibility of range/reverse/header within the first row
const firstRow = list.querySelector('.segment-row');
if (firstRow) {
const header = firstRow.querySelector('.segment-row-header');
const rangeFields = firstRow.querySelector('.segment-range-fields');
const reverseLabel = firstRow.querySelector('.segment-reverse-label');
if (header) header.style.display = isMapped ? 'none' : '';
if (rangeFields) rangeFields.style.display = isMapped ? 'none' : '';
if (reverseLabel) reverseLabel.style.display = isMapped ? 'none' : '';
}
// Hide/show "Add Segment" button
const addBtn = document.querySelector('#target-editor-segments-group > .btn-sm');
if (addBtn) addBtn.style.display = isMapped ? 'none' : '';
// Swap label: "Segments:" ↔ "Color Strip Source:"
const group = document.getElementById('target-editor-segments-group');
if (group) {
const label = group.querySelector('.label-row label');
const hintToggle = group.querySelector('.hint-toggle');
const hint = group.querySelector('.input-hint');
if (label) label.textContent = isMapped
? t('targets.color_strip_source')
: t('targets.segments');
if (hintToggle) hintToggle.style.display = isMapped ? 'none' : '';
if (hint) hint.style.display = 'none'; // collapse hint on switch
}
}
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');
@@ -150,7 +86,7 @@ class TargetEditorModal extends Modal {
return { return {
name: document.getElementById('target-editor-name').value, name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value, device: document.getElementById('target-editor-device').value,
segments: _serializeSegments(), css_source: document.getElementById('target-editor-css-source').value,
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,
}; };
@@ -166,9 +102,8 @@ function _autoGenerateTargetName() {
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 deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
// Use first segment's CSS name const cssSelect = document.getElementById('target-editor-css-source');
const firstCssSelect = document.querySelector('.segment-css-select'); const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || '';
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}`;
} }
@@ -210,54 +145,11 @@ function _updateKeepaliveVisibility() {
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none'; keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
} }
export function addTargetSegment(segment = null) { function _populateCssDropdown(selectedId = '') {
const list = document.getElementById('target-editor-segment-list'); const select = document.getElementById('target-editor-css-source');
const index = list.querySelectorAll('.segment-row').length; select.innerHTML = _editorCssSources.map(s =>
const row = document.createElement('div'); `<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
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(''); ).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')}">&times;</button>
</div>
<div class="segment-row-fields">
<select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName(); syncSegmentsMappedMode()">${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, cloneData = null) { export async function showTargetEditor(targetId = null, cloneData = null) {
@@ -286,10 +178,6 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
deviceSelect.appendChild(opt); deviceSelect.appendChild(opt);
}); });
// Clear segment list
const segmentList = document.getElementById('target-editor-segment-list');
segmentList.innerHTML = '';
if (targetId) { if (targetId) {
// Editing existing target // Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
@@ -306,13 +194,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
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-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').textContent = t('targets.edit');
// Populate segments _populateCssDropdown(target.color_strip_source_id || '');
const segments = target.segments || [];
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
} else if (cloneData) { } else if (cloneData) {
// Cloning — create mode but pre-filled from clone data // Cloning — create mode but pre-filled from clone data
document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-id').value = '';
@@ -325,14 +207,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
const segments = cloneData.segments || []; _populateCssDropdown(cloneData.color_strip_source_id || '');
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
} else { } else {
// Creating new target — start with one empty segment // Creating new target
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;
@@ -340,16 +217,16 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
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-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
addTargetSegment();
}
syncSegmentsMappedMode(); _populateCssDropdown('');
}
// Auto-name generation // Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData); _targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
window._targetAutoName = _autoGenerateTargetName; window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
if (!targetId && !cloneData) _autoGenerateTargetName(); if (!targetId && !cloneData) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities // Show/hide standby interval based on selected device capabilities
@@ -392,27 +269,12 @@ export async function saveTargetEditor() {
} }
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
// 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,
segments, color_strip_source_id: colorStripSourceId,
fps, fps,
keepalive_interval: standbyInterval, keepalive_interval: standbyInterval,
}; };
@@ -693,17 +555,10 @@ export async function loadTargetsTab() {
} }
} }
function _segmentsSummary(segments, colorStripSourceMap) { function _cssSourceName(cssId, colorStripSourceMap) {
if (!segments || segments.length === 0) return t('targets.no_segments'); if (!cssId) return t('targets.no_css');
return segments.map(seg => { const css = colorStripSourceMap[cssId];
const css = colorStripSourceMap[seg.color_strip_source_id]; return css ? escapeHtml(css.name) : escapeHtml(cssId);
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) {
@@ -715,13 +570,12 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const device = deviceMap[target.device_id]; const device = deviceMap[target.device_id];
const deviceName = device ? device.name : (target.device_id || 'No device'); const deviceName = device ? device.name : (target.device_id || 'No device');
const segments = target.segments || []; const cssId = target.color_strip_source_id || '';
const segSummary = _segmentsSummary(segments, colorStripSourceMap); const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
// Determine if overlay is available (first segment has a picture-based CSS) // Determine if overlay is available (picture-based CSS)
const firstCssId = segments.length > 0 ? segments[0].color_strip_source_id : ''; const css = cssId ? colorStripSourceMap[cssId] : null;
const firstCss = firstCssId ? colorStripSourceMap[firstCssId] : null; const overlayAvailable = !css || css.source_type === 'picture';
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;
@@ -745,7 +599,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.segments')}">🎞️ ${segSummary}</span> <span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${cssSummary}</span>
</div> </div>
<div class="card-content"> <div class="card-content">
${isProcessing ? ` ${isProcessing ? `

View File

@@ -365,15 +365,9 @@
"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.segments": "Segments:",
"targets.color_strip_source": "Color Strip Source:", "targets.color_strip_source": "Color Strip Source:",
"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.color_strip_source.hint": "Select the color strip source that provides LED colors for this target",
"targets.segments.add": "+ Add Segment", "targets.no_css": "No source",
"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 --",

View File

@@ -365,15 +365,9 @@
"targets.device": "Устройство:", "targets.device": "Устройство:",
"targets.device.hint": "Выберите LED устройство для передачи данных", "targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --", "targets.device.none": "-- Выберите устройство --",
"targets.segments": "Сегменты:",
"targets.color_strip_source": "Источник цветовой полосы:", "targets.color_strip_source": "Источник цветовой полосы:",
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.", "targets.color_strip_source.hint": "Выберите источник цветовой полосы, который предоставляет цвета LED для этой цели",
"targets.segments.add": "+ Добавить сегмент", "targets.no_css": "Нет источника",
"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": "-- Источник не назначен --",

View File

@@ -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 TargetSegment, WledPictureTarget from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import ( from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings, KeyColorsSettings,
KeyColorsPictureTarget, KeyColorsPictureTarget,
@@ -101,7 +101,7 @@ class PictureTargetStore:
name: str, name: str,
target_type: str, target_type: str,
device_id: str = "", device_id: str = "",
segments: Optional[List[dict]] = None, color_strip_source_id: str = "",
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,
@@ -126,14 +126,12 @@ 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,
segments=seg_list, color_strip_source_id=color_strip_source_id,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
@@ -166,7 +164,7 @@ 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,
segments: Optional[List[dict]] = None, color_strip_source_id: Optional[str] = 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,
@@ -192,7 +190,7 @@ class PictureTargetStore:
target.update_fields( target.update_fields(
name=name, name=name,
device_id=device_id, device_id=device_id,
segments=segments, color_strip_source_id=color_strip_source_id,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
@@ -239,7 +237,7 @@ class PictureTargetStore:
return [ return [
target.name for target in self._targets.values() target.name for target in self._targets.values()
if isinstance(target, WledPictureTarget) if isinstance(target, WledPictureTarget)
and any(seg.color_strip_source_id == css_id for seg in target.segments) and target.color_strip_source_id == css_id
] ]
def count(self) -> int: def count(self) -> int:

View File

@@ -1,56 +1,20 @@
"""LED picture target — sends color strip sources to an LED device.""" """LED picture target — sends color strip sources to an LED device."""
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Optional
from wled_controller.storage.picture_target import PictureTarget from wled_controller.storage.picture_target import PictureTarget
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
@dataclass
class TargetSegment:
"""Maps a color strip source to a pixel range on the LED device.
``start`` is inclusive, ``end`` is exclusive. When a target has a single
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 @dataclass
class WledPictureTarget(PictureTarget): class WledPictureTarget(PictureTarget):
"""LED picture target — pairs an LED device with one or more ColorStripSources. """LED picture target — pairs an LED device with a ColorStripSource."""
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 = ""
segments: List[TargetSegment] = field(default_factory=list) color_strip_source_id: str = ""
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
@@ -61,14 +25,14 @@ 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,
segments=[s.to_dict() for s in self.segments], color_strip_source_id=self.color_strip_source_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,
) )
def sync_with_manager(self, manager, *, settings_changed: bool, def sync_with_manager(self, manager, *, settings_changed: bool,
segments_changed: bool = False, css_changed: bool = False,
device_changed: bool = False) -> None: 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:
@@ -77,23 +41,20 @@ class WledPictureTarget(PictureTarget):
"keepalive_interval": self.keepalive_interval, "keepalive_interval": self.keepalive_interval,
"state_check_interval": self.state_check_interval, "state_check_interval": self.state_check_interval,
}) })
if segments_changed: if css_changed:
manager.update_target_segments(self.id, [s.to_dict() for s in self.segments]) manager.update_target_css(self.id, self.color_strip_source_id)
if device_changed: if device_changed:
manager.update_target_device(self.id, self.device_id) manager.update_target_device(self.id, self.device_id)
def update_fields(self, *, name=None, device_id=None, segments=None, def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
fps=None, keepalive_interval=None, state_check_interval=None, fps=None, keepalive_interval=None, state_check_interval=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 segments is not None: if color_strip_source_id is not None:
self.segments = [ self.color_strip_source_id = color_strip_source_id
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:
@@ -103,13 +64,13 @@ class WledPictureTarget(PictureTarget):
@property @property
def has_picture_source(self) -> bool: def has_picture_source(self) -> bool:
return any(s.color_strip_source_id for s in self.segments) return bool(self.color_strip_source_id)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary.""" """Convert to dictionary."""
d = super().to_dict() d = super().to_dict()
d["device_id"] = self.device_id d["device_id"] = self.device_id
d["segments"] = [s.to_dict() for s in self.segments] d["color_strip_source_id"] = self.color_strip_source_id
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
@@ -118,30 +79,22 @@ class WledPictureTarget(PictureTarget):
@classmethod @classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget": def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary with backward compatibility.""" """Create from dictionary with backward compatibility."""
# Migrate old single-source format to segments # New format: direct color_strip_source_id
if "segments" in data: if "color_strip_source_id" in data:
segments = [TargetSegment.from_dict(s) for s in data["segments"]] css_id = data["color_strip_source_id"]
elif "color_strip_source_id" in data: # Old format: segments array — take first segment's css_id
css_id = data.get("color_strip_source_id", "") elif "segments" in data:
skip_start = data.get("led_skip_start", 0) segs = data["segments"]
skip_end = data.get("led_skip_end", 0) css_id = segs[0].get("color_strip_source_id", "") if segs else ""
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: else:
segments = [] css_id = ""
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", ""),
segments=segments, color_strip_source_id=css_id,
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),

View File

@@ -24,14 +24,13 @@
<small id="target-editor-device-info" class="device-led-info" style="display:none"></small> <small id="target-editor-device-info" class="device-led-info" style="display:none"></small>
</div> </div>
<div class="form-group" id="target-editor-segments-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="targets.segments">Segments:</label> <label for="target-editor-css-source" data-i18n="targets.color_strip_source">Color Strip Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="targets.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> <small class="input-hint" style="display:none" data-i18n="targets.color_strip_source.hint">Select the color strip source that provides LED colors for this target</small>
<div id="target-editor-segment-list"></div> <select id="target-editor-css-source"></select>
<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">