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:
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
This is a monorepo containing:
|
||||
|
||||
@@ -32,7 +32,6 @@ from wled_controller.api.schemas.picture_targets import (
|
||||
PictureTargetUpdate,
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
TargetSegmentSchema,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
@@ -94,15 +93,7 @@ def _target_to_response(target) -> PictureTargetResponse:
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_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
|
||||
],
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
fps=target.fps,
|
||||
keepalive_interval=target.keepalive_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
@@ -157,7 +148,7 @@ async def create_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
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,
|
||||
keepalive_interval=data.keepalive_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)
|
||||
|
||||
# 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_id=target_id,
|
||||
name=data.name,
|
||||
device_id=data.device_id,
|
||||
segments=segments_dicts,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
@@ -288,7 +278,7 @@ async def update_target(
|
||||
data.keepalive_interval is not None or
|
||||
data.state_check_interval is not None or
|
||||
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,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -756,9 +746,8 @@ async def start_target_overlay(
|
||||
# can start even when processing is not currently running.
|
||||
calibration = None
|
||||
display_info = None
|
||||
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 isinstance(target, WledPictureTarget) and target.color_strip_source_id:
|
||||
first_css_id = target.color_strip_source_id
|
||||
if first_css_id:
|
||||
try:
|
||||
css = color_strip_store.get_source(first_css_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Picture target schemas (CRUD, processing state, metrics)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -45,15 +45,6 @@ class KeyColorsResponse(BaseModel):
|
||||
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):
|
||||
"""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)")
|
||||
# LED target fields
|
||||
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)")
|
||||
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)
|
||||
@@ -77,7 +68,7 @@ class PictureTargetUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
||||
# LED target fields
|
||||
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)")
|
||||
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)
|
||||
@@ -95,7 +86,7 @@ class PictureTargetResponse(BaseModel):
|
||||
target_type: str = Field(description="Target type")
|
||||
# LED target fields
|
||||
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")
|
||||
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)")
|
||||
@@ -119,7 +110,7 @@ class TargetProcessingState(BaseModel):
|
||||
|
||||
target_id: str = Field(description="Target 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")
|
||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
||||
|
||||
@@ -272,7 +272,7 @@ class ProcessorManager:
|
||||
self,
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
segments: Optional[list] = None,
|
||||
color_strip_source_id: str = "",
|
||||
fps: int = 30,
|
||||
keepalive_interval: float = 1.0,
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||
@@ -286,7 +286,7 @@ class ProcessorManager:
|
||||
proc = WledTargetProcessor(
|
||||
target_id=target_id,
|
||||
device_id=device_id,
|
||||
segments=segments,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
@@ -335,10 +335,10 @@ class ProcessorManager:
|
||||
proc = self._get_processor(target_id)
|
||||
proc.update_source(picture_source_id)
|
||||
|
||||
def update_target_segments(self, target_id: str, segments: list):
|
||||
"""Update the segments for a WLED target."""
|
||||
def update_target_css(self, target_id: str, color_strip_source_id: str):
|
||||
"""Update the color strip source for a WLED target."""
|
||||
proc = self._get_processor(target_id)
|
||||
proc.update_segments(segments)
|
||||
proc.update_css_source(color_strip_source_id)
|
||||
|
||||
def update_target_device(self, target_id: str, device_id: str):
|
||||
"""Update the device for a target."""
|
||||
|
||||
@@ -160,8 +160,8 @@ class TargetProcessor(ABC):
|
||||
"""Update device association. Raises for targets without devices."""
|
||||
raise ValueError(f"Target {self._target_id} does not support device assignment")
|
||||
|
||||
def update_segments(self, segments: list) -> None:
|
||||
"""Update segments. No-op for targets that don't use segments."""
|
||||
def update_css_source(self, color_strip_source_id: str) -> None:
|
||||
"""Update the color strip source. No-op for targets that don't use CSS."""
|
||||
pass
|
||||
|
||||
# ----- Device / display info (overridden by device-aware subclasses) -----
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import collections
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -24,47 +24,14 @@ from wled_controller.utils.timer import high_resolution_timer
|
||||
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):
|
||||
"""Streams LED colors from one or more ColorStripStreams to a WLED/LED device.
|
||||
|
||||
Each segment maps a CSS source to a pixel range on the device.
|
||||
Gaps between segments stay black.
|
||||
"""
|
||||
"""Streams LED colors from a single ColorStripStream to a WLED/LED device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
segments: Optional[List[dict]] = None,
|
||||
color_strip_source_id: str = "",
|
||||
fps: int = 30,
|
||||
keepalive_interval: float = 1.0,
|
||||
state_check_interval: int = 30,
|
||||
@@ -75,12 +42,11 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._target_fps = fps if fps > 0 else 30
|
||||
self._keepalive_interval = keepalive_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)
|
||||
self._led_client: Optional[LEDClient] = None
|
||||
# List of (resolved_seg_dict, stream) tuples — read by the loop
|
||||
self._segment_streams: List[Tuple[dict, object]] = []
|
||||
self._css_stream: Optional[object] = None # active stream reference
|
||||
self._device_state_before: Optional[dict] = None
|
||||
self._overlay_active = False
|
||||
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}")
|
||||
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
|
||||
if css_manager is None:
|
||||
await self._led_client.close()
|
||||
self._led_client = None
|
||||
raise RuntimeError("Color strip stream manager not available in context")
|
||||
|
||||
if not self._segments:
|
||||
if not self._css_id:
|
||||
await self._led_client.close()
|
||||
self._led_client = None
|
||||
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]] = []
|
||||
raise RuntimeError(f"Target {self._target_id} has no color strip source configured")
|
||||
|
||||
try:
|
||||
for seg in resolved:
|
||||
if not seg["css_id"]:
|
||||
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))
|
||||
stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
|
||||
if hasattr(stream, "configure") and device_info.led_count > 0:
|
||||
stream.configure(device_info.led_count)
|
||||
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
|
||||
|
||||
# Resolve display index from first stream that has one
|
||||
self._resolved_display_index = None
|
||||
for _, s in segment_streams:
|
||||
di = getattr(s, "display_index", None)
|
||||
if di is not None:
|
||||
self._resolved_display_index = di
|
||||
break
|
||||
self._resolved_display_index = getattr(stream, "display_index", None)
|
||||
self._css_stream = stream
|
||||
|
||||
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(
|
||||
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:
|
||||
# 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:
|
||||
await self._led_client.close()
|
||||
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
|
||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
@@ -213,16 +157,15 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await self._led_client.close()
|
||||
self._led_client = None
|
||||
|
||||
# Release all segment streams
|
||||
# Release CSS stream
|
||||
css_manager = self._ctx.color_strip_stream_manager
|
||||
if css_manager:
|
||||
for seg, stream in self._segment_streams:
|
||||
try:
|
||||
css_manager.remove_target_fps(seg["css_id"], self._target_id)
|
||||
await asyncio.to_thread(css_manager.release, seg["css_id"], self._target_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing segment stream {seg['css_id']} for {self._target_id}: {e}")
|
||||
self._segment_streams = []
|
||||
if css_manager and self._css_stream is not None:
|
||||
try:
|
||||
css_manager.remove_target_fps(self._css_id, self._target_id)
|
||||
await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}")
|
||||
self._css_stream = None
|
||||
|
||||
logger.info(f"Stopped processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
||||
@@ -235,9 +178,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
if "fps" in settings:
|
||||
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
|
||||
css_manager = self._ctx.color_strip_stream_manager
|
||||
if css_manager and self._is_running:
|
||||
for seg, _ in self._segment_streams:
|
||||
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
|
||||
if css_manager and self._is_running and self._css_id:
|
||||
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
|
||||
if "keepalive_interval" in settings:
|
||||
self._keepalive_interval = settings["keepalive_interval"]
|
||||
if "state_check_interval" in settings:
|
||||
@@ -248,9 +190,10 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"""Update the device this target streams to."""
|
||||
self._device_id = device_id
|
||||
|
||||
def update_segments(self, new_segments: List[dict]) -> None:
|
||||
"""Hot-swap all segments for a running target."""
|
||||
self._segments = list(new_segments)
|
||||
def update_css_source(self, new_css_id: str) -> None:
|
||||
"""Hot-swap the color strip source for a running target."""
|
||||
old_css_id = self._css_id
|
||||
self._css_id = new_css_id
|
||||
|
||||
if not self._is_running:
|
||||
return
|
||||
@@ -262,42 +205,35 @@ class WledTargetProcessor(TargetProcessor):
|
||||
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:
|
||||
# Release old stream
|
||||
if self._css_stream is not None and old_css_id:
|
||||
try:
|
||||
css_manager.remove_target_fps(seg["css_id"], self._target_id)
|
||||
css_manager.release(seg["css_id"], self._target_id)
|
||||
css_manager.remove_target_fps(old_css_id, self._target_id)
|
||||
css_manager.release(old_css_id, self._target_id)
|
||||
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
|
||||
resolved = _resolve_segments(new_segments, device_leds)
|
||||
new_stream_list: List[Tuple[dict, object]] = []
|
||||
for seg in resolved:
|
||||
if not seg["css_id"]:
|
||||
continue
|
||||
# Acquire new stream
|
||||
new_stream = None
|
||||
if new_css_id:
|
||||
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))
|
||||
new_stream = css_manager.acquire(new_css_id, self._target_id)
|
||||
if hasattr(new_stream, "configure") and device_leds > 0:
|
||||
new_stream.configure(device_leds)
|
||||
css_manager.notify_target_fps(new_css_id, self._target_id, self._target_fps)
|
||||
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
|
||||
self._segment_streams = new_stream_list
|
||||
logger.info(f"Hot-swapped segments for {self._target_id}: {len(new_stream_list)} segment(s)")
|
||||
# Atomic swap — the processing loop detects via identity check
|
||||
self._css_stream = new_stream
|
||||
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
|
||||
|
||||
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:
|
||||
return self._resolved_display_index
|
||||
for _, stream in self._segment_streams:
|
||||
di = getattr(stream, "display_index", None)
|
||||
if di is not None:
|
||||
return di
|
||||
if self._css_stream is not None:
|
||||
return getattr(self._css_stream, "display_index", None)
|
||||
return None
|
||||
|
||||
# ----- State / Metrics -----
|
||||
@@ -307,8 +243,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
fps_target = self._target_fps
|
||||
|
||||
css_timing: dict = {}
|
||||
if self._is_running and self._segment_streams:
|
||||
css_timing = self._segment_streams[0][1].get_last_timing()
|
||||
if self._is_running and self._css_stream is not None:
|
||||
css_timing = self._css_stream.get_last_timing()
|
||||
|
||||
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
|
||||
@@ -321,19 +257,10 @@ class WledTargetProcessor(TargetProcessor):
|
||||
else:
|
||||
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 {
|
||||
"target_id": self._target_id,
|
||||
"device_id": self._device_id,
|
||||
"segments": segments_info,
|
||||
"color_strip_source_id": self._css_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||
@@ -383,25 +310,19 @@ class WledTargetProcessor(TargetProcessor):
|
||||
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
||||
|
||||
if calibration is None or display_info is None:
|
||||
# Find calibration from the first picture stream
|
||||
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:
|
||||
stream = self._css_stream
|
||||
if stream is None or not (hasattr(stream, "calibration") and stream.calibration):
|
||||
raise ValueError(
|
||||
f"Cannot start overlay for {self._target_id}: no stream with calibration"
|
||||
)
|
||||
|
||||
if calibration is None:
|
||||
calibration = stream_with_cal.calibration
|
||||
calibration = stream.calibration
|
||||
|
||||
if display_info is None:
|
||||
display_index = self._resolved_display_index
|
||||
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:
|
||||
raise ValueError(f"Invalid display index {display_index} for overlay")
|
||||
displays = get_available_displays()
|
||||
@@ -435,13 +356,6 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
# ----- 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
|
||||
def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray:
|
||||
"""Resample colors to match the target LED count."""
|
||||
@@ -457,7 +371,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
return result
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
_total_leds = _init_device_info.led_count if _init_device_info else 0
|
||||
|
||||
# Device-sized output buffer (persistent between frames; gaps stay black)
|
||||
device_buf = np.zeros((_total_leds, 3), dtype=np.uint8)
|
||||
|
||||
# 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)
|
||||
# Stream reference — re-read each tick to detect hot-swaps
|
||||
stream = self._css_stream
|
||||
prev_frame_ref = None
|
||||
has_any_frame = False
|
||||
|
||||
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
|
||||
@@ -510,7 +420,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
logger.info(
|
||||
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()
|
||||
@@ -523,13 +433,12 @@ class WledTargetProcessor(TargetProcessor):
|
||||
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)
|
||||
# Detect hot-swapped CSS stream
|
||||
cur_stream = self._css_stream
|
||||
if cur_stream is not stream:
|
||||
stream = cur_stream
|
||||
prev_frame_ref = None
|
||||
has_any_frame = False
|
||||
device_buf[:] = 0
|
||||
|
||||
_diag_device_info_age += 1
|
||||
if _diag_device_info is None or _diag_device_info_age >= 30:
|
||||
@@ -542,34 +451,28 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
if not segment_streams:
|
||||
if stream is None:
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Poll all segment streams
|
||||
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
|
||||
# Poll the CSS stream
|
||||
frame = stream.get_latest_colors()
|
||||
|
||||
if all_none:
|
||||
if frame is None:
|
||||
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)
|
||||
continue
|
||||
|
||||
if not any_new:
|
||||
# All streams returned same frame — keepalive or skip
|
||||
if frame is prev_frame_ref:
|
||||
# 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:
|
||||
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:
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
@@ -582,31 +485,17 @@ class WledTargetProcessor(TargetProcessor):
|
||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||
send_timestamps.popleft()
|
||||
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
|
||||
await asyncio.sleep(repoll)
|
||||
continue
|
||||
|
||||
prev_frame_ref = frame
|
||||
has_any_frame = True
|
||||
|
||||
# Compose new frame from all segments
|
||||
device_buf[:] = 0
|
||||
for i, (seg, stream) in enumerate(segment_streams):
|
||||
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)
|
||||
# Fit to device LED count and apply brightness
|
||||
device_colors = self._fit_to_device(frame, _total_leds)
|
||||
send_colors = _cached_brightness(device_colors, device_info)
|
||||
|
||||
# Send to LED device
|
||||
if not self._is_running or self._led_client is None:
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
import {
|
||||
loadTargetsTab, loadTargets, switchTargetSubTab,
|
||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||
addTargetSegment, removeTargetSegment, syncSegmentsMappedMode,
|
||||
startTargetProcessing, stopTargetProcessing,
|
||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||
cloneTarget,
|
||||
@@ -283,9 +282,6 @@ Object.assign(window, {
|
||||
closeTargetEditorModal,
|
||||
forceCloseTargetEditorModal,
|
||||
saveTargetEditor,
|
||||
addTargetSegment,
|
||||
removeTargetSegment,
|
||||
syncSegmentsMappedMode,
|
||||
startTargetProcessing,
|
||||
stopTargetProcessing,
|
||||
startTargetOverlay,
|
||||
|
||||
@@ -447,14 +447,11 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
|
||||
if (device) {
|
||||
subtitleParts.push((device.device_type || '').toUpperCase());
|
||||
}
|
||||
const segments = target.segments || [];
|
||||
if (segments.length > 0) {
|
||||
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`);
|
||||
const cssId = target.color_strip_source_id || '';
|
||||
if (cssId) {
|
||||
const css = cssSourceMap[cssId];
|
||||
if (css) {
|
||||
subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,73 +74,9 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Segment editor state ---
|
||||
// --- Editor state ---
|
||||
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 {
|
||||
constructor() {
|
||||
super('target-editor-modal');
|
||||
@@ -150,7 +86,7 @@ class TargetEditorModal extends Modal {
|
||||
return {
|
||||
name: document.getElementById('target-editor-name').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,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
};
|
||||
@@ -166,9 +102,8 @@ function _autoGenerateTargetName() {
|
||||
if (document.getElementById('target-editor-id').value) return;
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
// Use first segment's CSS name
|
||||
const firstCssSelect = document.querySelector('.segment-css-select');
|
||||
const cssName = firstCssSelect?.selectedOptions[0]?.dataset?.name || '';
|
||||
const cssSelect = document.getElementById('target-editor-css-source');
|
||||
const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!deviceName || !cssName) return;
|
||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
||||
}
|
||||
@@ -210,54 +145,11 @@ function _updateKeepaliveVisibility() {
|
||||
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>`
|
||||
function _populateCssDropdown(selectedId = '') {
|
||||
const select = document.getElementById('target-editor-css-source');
|
||||
select.innerHTML = _editorCssSources.map(s =>
|
||||
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${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(); 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) {
|
||||
@@ -286,10 +178,6 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Clear segment list
|
||||
const segmentList = document.getElementById('target-editor-segment-list');
|
||||
segmentList.innerHTML = '';
|
||||
|
||||
if (targetId) {
|
||||
// Editing existing target
|
||||
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-title').textContent = t('targets.edit');
|
||||
|
||||
// Populate segments
|
||||
const segments = target.segments || [];
|
||||
if (segments.length === 0) {
|
||||
addTargetSegment();
|
||||
} else {
|
||||
segments.forEach(seg => addTargetSegment(seg));
|
||||
}
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
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-title').textContent = t('targets.add');
|
||||
|
||||
const segments = cloneData.segments || [];
|
||||
if (segments.length === 0) {
|
||||
addTargetSegment();
|
||||
} else {
|
||||
segments.forEach(seg => addTargetSegment(seg));
|
||||
}
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
} else {
|
||||
// Creating new target — start with one empty segment
|
||||
// Creating new target
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = '';
|
||||
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').textContent = '1.0';
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
addTargetSegment();
|
||||
}
|
||||
|
||||
syncSegmentsMappedMode();
|
||||
_populateCssDropdown('');
|
||||
}
|
||||
|
||||
// Auto-name generation
|
||||
_targetNameManuallyEdited = !!(targetId || cloneData);
|
||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||
window._targetAutoName = _autoGenerateTargetName;
|
||||
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
||||
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
|
||||
if (!targetId && !cloneData) _autoGenerateTargetName();
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 colorStripSourceId = document.getElementById('target-editor-css-source').value;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
segments,
|
||||
color_strip_source_id: colorStripSourceId,
|
||||
fps,
|
||||
keepalive_interval: standbyInterval,
|
||||
};
|
||||
@@ -693,17 +555,10 @@ 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(', ');
|
||||
function _cssSourceName(cssId, colorStripSourceMap) {
|
||||
if (!cssId) return t('targets.no_css');
|
||||
const css = colorStripSourceMap[cssId];
|
||||
return css ? escapeHtml(css.name) : escapeHtml(cssId);
|
||||
}
|
||||
|
||||
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
@@ -715,13 +570,12 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
const device = deviceMap[target.device_id];
|
||||
const deviceName = device ? device.name : (target.device_id || 'No device');
|
||||
|
||||
const segments = target.segments || [];
|
||||
const segSummary = _segmentsSummary(segments, colorStripSourceMap);
|
||||
const cssId = target.color_strip_source_id || '';
|
||||
const cssSummary = _cssSourceName(cssId, 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';
|
||||
// Determine if overlay is available (picture-based CSS)
|
||||
const css = cssId ? colorStripSourceMap[cssId] : null;
|
||||
const overlayAvailable = !css || css.source_type === 'picture';
|
||||
|
||||
// Health info from target state (forwarded from device)
|
||||
const devOnline = state.device_online || false;
|
||||
@@ -745,7 +599,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${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 class="card-content">
|
||||
${isProcessing ? `
|
||||
|
||||
@@ -365,15 +365,9 @@
|
||||
"targets.device": "Device:",
|
||||
"targets.device.hint": "Select the LED device to send data to",
|
||||
"targets.device.none": "-- Select a device --",
|
||||
"targets.segments": "Segments:",
|
||||
"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.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.color_strip_source.hint": "Select the color strip source that provides LED colors for this target",
|
||||
"targets.no_css": "No source",
|
||||
"targets.source": "Source:",
|
||||
"targets.source.hint": "Which picture source to capture and process",
|
||||
"targets.source.none": "-- No source assigned --",
|
||||
|
||||
@@ -365,15 +365,9 @@
|
||||
"targets.device": "Устройство:",
|
||||
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
||||
"targets.device.none": "-- Выберите устройство --",
|
||||
"targets.segments": "Сегменты:",
|
||||
"targets.color_strip_source": "Источник цветовой полосы:",
|
||||
"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.color_strip_source.hint": "Выберите источник цветовой полосы, который предоставляет цвета LED для этой цели",
|
||||
"targets.no_css": "Нет источника",
|
||||
"targets.source": "Источник:",
|
||||
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
||||
"targets.source.none": "-- Источник не назначен --",
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
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 (
|
||||
KeyColorsSettings,
|
||||
KeyColorsPictureTarget,
|
||||
@@ -101,7 +101,7 @@ class PictureTargetStore:
|
||||
name: str,
|
||||
target_type: str,
|
||||
device_id: str = "",
|
||||
segments: Optional[List[dict]] = None,
|
||||
color_strip_source_id: str = "",
|
||||
fps: int = 30,
|
||||
keepalive_interval: float = 1.0,
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||
@@ -126,14 +126,12 @@ class PictureTargetStore:
|
||||
now = datetime.utcnow()
|
||||
|
||||
if target_type == "led":
|
||||
seg_list = [TargetSegment.from_dict(s) for s in segments] if segments else []
|
||||
|
||||
target: PictureTarget = WledPictureTarget(
|
||||
id=target_id,
|
||||
name=name,
|
||||
target_type="led",
|
||||
device_id=device_id,
|
||||
segments=seg_list,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
@@ -166,7 +164,7 @@ class PictureTargetStore:
|
||||
target_id: str,
|
||||
name: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
segments: Optional[List[dict]] = None,
|
||||
color_strip_source_id: Optional[str] = None,
|
||||
fps: Optional[int] = None,
|
||||
keepalive_interval: Optional[float] = None,
|
||||
state_check_interval: Optional[int] = None,
|
||||
@@ -192,7 +190,7 @@ class PictureTargetStore:
|
||||
target.update_fields(
|
||||
name=name,
|
||||
device_id=device_id,
|
||||
segments=segments,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
@@ -239,7 +237,7 @@ class PictureTargetStore:
|
||||
return [
|
||||
target.name for target in self._targets.values()
|
||||
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:
|
||||
|
||||
@@ -1,56 +1,20 @@
|
||||
"""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 typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from wled_controller.storage.picture_target import PictureTarget
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
"""LED picture target — pairs an LED device with a ColorStripSource."""
|
||||
|
||||
device_id: str = ""
|
||||
segments: List[TargetSegment] = field(default_factory=list)
|
||||
color_strip_source_id: str = ""
|
||||
fps: int = 30 # target send FPS (1-90)
|
||||
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||
@@ -61,14 +25,14 @@ class WledPictureTarget(PictureTarget):
|
||||
manager.add_target(
|
||||
target_id=self.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,
|
||||
keepalive_interval=self.keepalive_interval,
|
||||
state_check_interval=self.state_check_interval,
|
||||
)
|
||||
|
||||
def sync_with_manager(self, manager, *, settings_changed: bool,
|
||||
segments_changed: bool = False,
|
||||
css_changed: bool = False,
|
||||
device_changed: bool = False) -> None:
|
||||
"""Push changed fields to the processor manager."""
|
||||
if settings_changed:
|
||||
@@ -77,23 +41,20 @@ class WledPictureTarget(PictureTarget):
|
||||
"keepalive_interval": self.keepalive_interval,
|
||||
"state_check_interval": self.state_check_interval,
|
||||
})
|
||||
if segments_changed:
|
||||
manager.update_target_segments(self.id, [s.to_dict() for s in self.segments])
|
||||
if css_changed:
|
||||
manager.update_target_css(self.id, self.color_strip_source_id)
|
||||
if device_changed:
|
||||
manager.update_target_device(self.id, self.device_id)
|
||||
|
||||
def update_fields(self, *, name=None, device_id=None, 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,
|
||||
description=None, **_kwargs) -> None:
|
||||
"""Apply mutable field updates for WLED targets."""
|
||||
super().update_fields(name=name, description=description)
|
||||
if device_id is not None:
|
||||
self.device_id = device_id
|
||||
if segments is not None:
|
||||
self.segments = [
|
||||
TargetSegment.from_dict(s) if isinstance(s, dict) else s
|
||||
for s in segments
|
||||
]
|
||||
if color_strip_source_id is not None:
|
||||
self.color_strip_source_id = color_strip_source_id
|
||||
if fps is not None:
|
||||
self.fps = fps
|
||||
if keepalive_interval is not None:
|
||||
@@ -103,13 +64,13 @@ class WledPictureTarget(PictureTarget):
|
||||
|
||||
@property
|
||||
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:
|
||||
"""Convert to dictionary."""
|
||||
d = super().to_dict()
|
||||
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["keepalive_interval"] = self.keepalive_interval
|
||||
d["state_check_interval"] = self.state_check_interval
|
||||
@@ -118,30 +79,22 @@ class WledPictureTarget(PictureTarget):
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
||||
"""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 = []
|
||||
# New format: direct color_strip_source_id
|
||||
if "color_strip_source_id" in data:
|
||||
css_id = data["color_strip_source_id"]
|
||||
# Old format: segments array — take first segment's css_id
|
||||
elif "segments" in data:
|
||||
segs = data["segments"]
|
||||
css_id = segs[0].get("color_strip_source_id", "") if segs else ""
|
||||
else:
|
||||
segments = []
|
||||
css_id = ""
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
target_type="led",
|
||||
device_id=data.get("device_id", ""),
|
||||
segments=segments,
|
||||
color_strip_source_id=css_id,
|
||||
fps=data.get("fps", 30),
|
||||
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||
|
||||
@@ -24,14 +24,13 @@
|
||||
<small id="target-editor-device-info" class="device-led-info" style="display:none"></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-segments-group">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<select id="target-editor-css-source"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-fps-group">
|
||||
|
||||
Reference in New Issue
Block a user