Add LED skip start/end, rename standby_interval to keepalive_interval, remove migrations

LED skip: set first N and last M LEDs to black on a target. Color sources
(static, gradient, effect, color cycle) render across only the active
(non-skipped) LEDs. Processor pads with blacks before sending to device.

Rename standby_interval → keepalive_interval across all Python, API
schemas, and JS. from_dict falls back to old key for existing configs.

Remove legacy migration functions (_migrate_devices_to_targets,
_migrate_targets_to_color_strips) and legacy fields from target model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 02:15:29 +03:00
parent f9a5fb68ed
commit e32bfab888
14 changed files with 168 additions and 163 deletions

View File

@@ -95,8 +95,10 @@ def _target_to_response(target) -> PictureTargetResponse:
device_id=target.device_id, device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id, color_strip_source_id=target.color_strip_source_id,
fps=target.fps, fps=target.fps,
standby_interval=target.standby_interval, keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval, state_check_interval=target.state_check_interval,
led_skip_start=target.led_skip_start,
led_skip_end=target.led_skip_end,
description=target.description, description=target.description,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
@@ -150,8 +152,10 @@ async def create_target(
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
fps=data.fps, fps=data.fps,
standby_interval=data.standby_interval, keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
led_skip_start=data.led_skip_start,
led_skip_end=data.led_skip_end,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings, key_colors_settings=kc_settings,
description=data.description, description=data.description,
@@ -264,8 +268,10 @@ async def update_target(
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
fps=data.fps, fps=data.fps,
standby_interval=data.standby_interval, keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
led_skip_start=data.led_skip_start,
led_skip_end=data.led_skip_end,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings, key_colors_settings=kc_settings,
description=data.description, description=data.description,
@@ -276,8 +282,10 @@ async def update_target(
target.sync_with_manager( target.sync_with_manager(
manager, manager,
settings_changed=(data.fps is not None or settings_changed=(data.fps is not None or
data.standby_interval is not None or data.keepalive_interval is not None or
data.state_check_interval is not None or data.state_check_interval is not None or
data.led_skip_start is not None or
data.led_skip_end is not None or
data.key_colors_settings is not None), data.key_colors_settings is not None),
source_changed=data.color_strip_source_id is not None, source_changed=data.color_strip_source_id is not None,
device_changed=data.device_id is not None, device_changed=data.device_id is not None,

View File

@@ -54,8 +54,10 @@ class PictureTargetCreate(BaseModel):
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") 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)")
standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
led_skip_start: int = Field(default=0, ge=0, description="Number of LEDs at the start to keep black")
led_skip_end: int = Field(default=0, ge=0, description="Number of LEDs at the end to keep black")
# KC target fields # KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
@@ -70,8 +72,10 @@ class PictureTargetUpdate(BaseModel):
device_id: Optional[str] = Field(None, description="LED device ID") device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") 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)")
standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
led_skip_start: Optional[int] = Field(None, ge=0, description="Number of LEDs at the start to keep black")
led_skip_end: Optional[int] = Field(None, ge=0, description="Number of LEDs at the end to keep black")
# KC target fields # KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
@@ -88,8 +92,10 @@ class PictureTargetResponse(BaseModel):
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") 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")
standby_interval: float = Field(default=1.0, description="Keepalive interval (s)") keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
led_skip_start: int = Field(default=0, description="LEDs skipped at start")
led_skip_end: int = Field(default=0, description="LEDs skipped at end")
# KC target fields # KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")

View File

@@ -14,5 +14,5 @@ class ProcessingSettings:
brightness: float = 1.0 brightness: float = 1.0
smoothing: float = 0.3 smoothing: float = 0.3
interpolation_mode: str = "average" interpolation_mode: str = "average"
standby_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

View File

@@ -269,8 +269,10 @@ class ProcessorManager:
device_id: str, device_id: str,
color_strip_source_id: str = "", color_strip_source_id: str = "",
fps: int = 30, fps: int = 30,
standby_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
led_skip_start: int = 0,
led_skip_end: int = 0,
): ):
"""Register a WLED target processor.""" """Register a WLED target processor."""
if target_id in self._processors: if target_id in self._processors:
@@ -283,8 +285,10 @@ class ProcessorManager:
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
fps=fps, fps=fps,
standby_interval=standby_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
led_skip_start=led_skip_start,
led_skip_end=led_skip_end,
ctx=self._build_context(), ctx=self._build_context(),
) )
self._processors[target_id] = proc self._processors[target_id] = proc

View File

@@ -41,16 +41,20 @@ class WledTargetProcessor(TargetProcessor):
device_id: str, device_id: str,
color_strip_source_id: str, color_strip_source_id: str,
fps: int, fps: int,
standby_interval: float, keepalive_interval: float,
state_check_interval: int, state_check_interval: int,
ctx: TargetContext, led_skip_start: int = 0,
led_skip_end: int = 0,
ctx: TargetContext = None,
): ):
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
self._device_id = device_id self._device_id = device_id
self._color_strip_source_id = color_strip_source_id self._color_strip_source_id = color_strip_source_id
self._target_fps = fps if fps > 0 else 30 self._target_fps = fps if fps > 0 else 30
self._standby_interval = standby_interval self._keepalive_interval = keepalive_interval
self._state_check_interval = state_check_interval self._state_check_interval = state_check_interval
self._led_skip_start = max(0, led_skip_start)
self._led_skip_end = max(0, led_skip_end)
# Runtime state (populated on start) # Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None self._led_client: Optional[LEDClient] = None
@@ -126,7 +130,8 @@ class WledTargetProcessor(TargetProcessor):
) )
from wled_controller.core.processing.effect_stream import EffectColorStripStream from wled_controller.core.processing.effect_stream import EffectColorStripStream
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream, EffectColorStripStream)) and device_info.led_count > 0: if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream, EffectColorStripStream)) and device_info.led_count > 0:
stream.configure(device_info.led_count) effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end
stream.configure(max(1, effective_leds))
# Notify stream manager of our target FPS so it can adjust capture rate # Notify stream manager of our target FPS so it can adjust capture rate
css_manager.notify_target_fps( css_manager.notify_target_fps(
@@ -209,10 +214,14 @@ class WledTargetProcessor(TargetProcessor):
css_manager.notify_target_fps( css_manager.notify_target_fps(
self._color_strip_source_id, self._target_id, self._target_fps self._color_strip_source_id, self._target_id, self._target_fps
) )
if "standby_interval" in settings: if "keepalive_interval" in settings:
self._standby_interval = settings["standby_interval"] self._keepalive_interval = settings["keepalive_interval"]
if "state_check_interval" in settings: if "state_check_interval" in settings:
self._state_check_interval = settings["state_check_interval"] self._state_check_interval = settings["state_check_interval"]
if "led_skip_start" in settings:
self._led_skip_start = max(0, settings["led_skip_start"])
if "led_skip_end" in settings:
self._led_skip_end = max(0, settings["led_skip_end"])
logger.info(f"Updated settings for target {self._target_id}") logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None: def update_device(self, device_id: str) -> None:
@@ -293,6 +302,8 @@ class WledTargetProcessor(TargetProcessor):
"display_index": self._resolved_display_index, "display_index": self._resolved_display_index,
"overlay_active": self._overlay_active, "overlay_active": self._overlay_active,
"needs_keepalive": self._needs_keepalive, "needs_keepalive": self._needs_keepalive,
"led_skip_start": self._led_skip_start,
"led_skip_end": self._led_skip_end,
"last_update": metrics.last_update, "last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "errors": [metrics.last_error] if metrics.last_error else [],
} }
@@ -404,10 +415,24 @@ class WledTargetProcessor(TargetProcessor):
]) ])
return result return result
def _apply_led_skip(self, colors: np.ndarray) -> np.ndarray:
"""Pad color array with black at start/end for skipped LEDs."""
s, e = self._led_skip_start, self._led_skip_end
if s <= 0 and e <= 0:
return colors
channels = colors.shape[1] if colors.ndim == 2 else 3
parts = []
if s > 0:
parts.append(np.zeros((s, channels), dtype=np.uint8))
parts.append(colors)
if e > 0:
parts.append(np.zeros((e, channels), dtype=np.uint8))
return np.vstack(parts)
async def _processing_loop(self) -> None: async def _processing_loop(self) -> None:
"""Main processing loop — poll ColorStripStream → apply brightness → send.""" """Main processing loop — poll ColorStripStream → apply brightness → send."""
stream = self._color_strip_stream stream = self._color_strip_stream
standby_interval = self._standby_interval keepalive_interval = self._keepalive_interval
fps_samples: collections.deque = collections.deque(maxlen=10) fps_samples: collections.deque = collections.deque(maxlen=10)
send_timestamps: collections.deque = collections.deque() send_timestamps: collections.deque = collections.deque()
@@ -415,6 +440,7 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = 0.0 last_send_time = 0.0
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
effective_leds = max(1, (device_info.led_count if device_info else 0) - self._led_skip_start - self._led_skip_end)
# Short re-poll interval when the animation thread hasn't produced a new # Short re-poll interval when the animation thread hasn't produced a new
# frame yet. The animation thread and this loop both target the same FPS # frame yet. The animation thread and this loop both target the same FPS
# but are unsynchronised; without a short re-poll the loop can miss a # but are unsynchronised; without a short re-poll the loop can miss a
@@ -471,12 +497,13 @@ class WledTargetProcessor(TargetProcessor):
if colors is prev_colors: if colors is prev_colors:
# Same frame — send keepalive if interval elapsed (only for devices that need it) # Same frame — send keepalive if interval elapsed (only for devices that need it)
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= standby_interval: if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= keepalive_interval:
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
kc = prev_colors kc = prev_colors
if device_info and device_info.led_count > 0: if device_info and device_info.led_count > 0:
kc = self._fit_to_device(kc, device_info.led_count) kc = self._fit_to_device(kc, effective_leds)
kc = self._apply_led_skip(kc)
send_colors = self._apply_brightness(kc, device_info) send_colors = self._apply_brightness(kc, device_info)
if self._led_client.supports_fast_send: if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
@@ -496,9 +523,10 @@ class WledTargetProcessor(TargetProcessor):
prev_colors = colors prev_colors = colors
# Fit to this device's LED count (stream may be shared) # Fit to effective LED count (excluding skipped) then pad with blacks
if device_info and device_info.led_count > 0: if device_info and device_info.led_count > 0:
colors = self._fit_to_device(colors, device_info.led_count) colors = self._fit_to_device(colors, effective_leds)
colors = self._apply_led_skip(colors)
# Apply device software brightness # Apply device software brightness
send_colors = self._apply_brightness(colors, device_info) send_colors = self._apply_brightness(colors, device_info)

View File

@@ -54,105 +54,6 @@ processor_manager = ProcessorManager(
) )
def _migrate_devices_to_targets():
"""One-time migration: create picture targets from legacy device settings.
If the target store is empty and any device has legacy picture_source_id
or settings in raw JSON, migrate them to WledPictureTargets.
"""
if picture_target_store.count() > 0:
return # Already have targets, skip migration
raw = device_store.load_raw()
devices_raw = raw.get("devices", {})
if not devices_raw:
return
migrated = 0
for device_id, device_data in devices_raw.items():
legacy_source_id = device_data.get("picture_source_id", "")
if not legacy_source_id:
continue
device_name = device_data.get("name", device_id)
target_name = f"{device_name} Target"
try:
target = picture_target_store.create_target(
name=target_name,
target_type="wled",
device_id=device_id,
description=f"Auto-migrated from device {device_name}",
)
migrated += 1
logger.info(f"Migrated device {device_id} -> target {target.id}")
except Exception as e:
logger.error(f"Failed to migrate device {device_id} to target: {e}")
if migrated > 0:
logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings")
def _migrate_targets_to_color_strips():
"""One-time migration: create ColorStripSources from legacy WledPictureTarget data.
For each WledPictureTarget that has a legacy _legacy_picture_source_id (from old JSON)
but no color_strip_source_id, create a ColorStripSource and link it.
"""
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.core.capture.calibration import create_default_calibration
migrated = 0
for target in picture_target_store.get_all_targets():
if not isinstance(target, WledPictureTarget):
continue
if target.color_strip_source_id:
continue # already migrated
if not target._legacy_picture_source_id:
continue # no legacy source to migrate
legacy_settings = target._legacy_settings or {}
# Try to get calibration from device (old location)
device = device_store.get_device(target.device_id) if target.device_id else None
calibration = getattr(device, "_legacy_calibration", None) if device else None
if calibration is None:
calibration = create_default_calibration(0)
css_name = f"{target.name} Strip"
# Ensure unique name
existing_names = {s.name for s in color_strip_store.get_all_sources()}
if css_name in existing_names:
css_name = f"{target.name} Strip (migrated)"
try:
css = color_strip_store.create_source(
name=css_name,
source_type="picture",
picture_source_id=target._legacy_picture_source_id,
fps=legacy_settings.get("fps", 30),
brightness=legacy_settings.get("brightness", 1.0),
smoothing=legacy_settings.get("smoothing", 0.3),
interpolation_mode=legacy_settings.get("interpolation_mode", "average"),
calibration=calibration,
)
# Update target to reference the new CSS
target.color_strip_source_id = css.id
target.standby_interval = legacy_settings.get("standby_interval", 1.0)
target.state_check_interval = legacy_settings.get("state_check_interval", 30)
picture_target_store._save()
migrated += 1
logger.info(f"Migrated target {target.id} -> CSS {css.id} ({css_name})")
except Exception as e:
logger.error(f"Failed to migrate target {target.id} to CSS: {e}")
if migrated > 0:
logger.info(f"CSS migration complete: created {migrated} color strip source(s) from legacy targets")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan manager. """Application lifespan manager.
@@ -182,10 +83,6 @@ async def lifespan(app: FastAPI):
logger.info(f"Authorized clients: {client_labels}") logger.info(f"Authorized clients: {client_labels}")
logger.info("All API requests require valid Bearer token authentication") logger.info("All API requests require valid Bearer token authentication")
# Run migrations
_migrate_devices_to_targets()
_migrate_targets_to_color_strips()
# Create profile engine (needs processor_manager) # Create profile engine (needs processor_manager)
profile_engine = ProfileEngine(profile_store, processor_manager) profile_engine = ProfileEngine(profile_store, processor_manager)

View File

@@ -211,6 +211,26 @@
font-style: italic; font-style: italic;
} }
.inline-fields {
display: flex;
gap: 12px;
}
.inline-field {
flex: 1;
}
.inline-field label {
display: block;
margin-bottom: 4px;
font-size: 0.85rem;
color: #aaa;
}
.inline-field input[type="number"] {
width: 100%;
}
.fps-hint { .fps-hint {
display: block; display: block;
margin-top: 4px; margin-top: 4px;

View File

@@ -85,7 +85,9 @@ class TargetEditorModal extends Modal {
device: document.getElementById('target-editor-device').value, device: document.getElementById('target-editor-device').value,
css: document.getElementById('target-editor-css').value, css: document.getElementById('target-editor-css').value,
fps: document.getElementById('target-editor-fps').value, fps: document.getElementById('target-editor-fps').value,
standby_interval: document.getElementById('target-editor-keepalive-interval').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
led_skip_start: document.getElementById('target-editor-skip-start').value,
led_skip_end: document.getElementById('target-editor-skip-end').value,
}; };
} }
} }
@@ -179,8 +181,10 @@ export async function showTargetEditor(targetId = null) {
const fps = target.fps ?? 30; const fps = target.fps ?? 30;
document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = target.standby_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = target.standby_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-skip-start').value = target.led_skip_start ?? 0;
document.getElementById('target-editor-skip-end').value = target.led_skip_end ?? 0;
document.getElementById('target-editor-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').textContent = t('targets.edit');
} else { } else {
// Creating new target — first option is selected by default // Creating new target — first option is selected by default
@@ -190,6 +194,8 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-keepalive-interval').value = 1.0; document.getElementById('target-editor-keepalive-interval').value = 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0'; document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
document.getElementById('target-editor-skip-start').value = 0;
document.getElementById('target-editor-skip-end').value = 0;
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
} }
@@ -233,6 +239,8 @@ export async function saveTargetEditor() {
const deviceId = document.getElementById('target-editor-device').value; const deviceId = document.getElementById('target-editor-device').value;
const cssId = document.getElementById('target-editor-css').value; const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value); const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
const ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0;
const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0;
if (!name) { if (!name) {
targetEditorModal.showError(t('targets.error.name_required')); targetEditorModal.showError(t('targets.error.name_required'));
@@ -246,7 +254,9 @@ export async function saveTargetEditor() {
device_id: deviceId, device_id: deviceId,
color_strip_source_id: cssId, color_strip_source_id: cssId,
fps, fps,
standby_interval: standbyInterval, keepalive_interval: standbyInterval,
led_skip_start: ledSkipStart,
led_skip_end: ledSkipEnd,
}; };
try { try {

View File

@@ -373,6 +373,10 @@
"targets.interpolation.dominant": "Dominant", "targets.interpolation.dominant": "Dominant",
"targets.smoothing": "Smoothing:", "targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"targets.led_skip": "LED Skip:",
"targets.led_skip.hint": "Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.",
"targets.led_skip_start": "Start:",
"targets.led_skip_end": "End:",
"targets.keepalive_interval": "Keep Alive Interval:", "targets.keepalive_interval": "Keep Alive Interval:",
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)", "targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
"targets.created": "Target created successfully", "targets.created": "Target created successfully",

View File

@@ -373,6 +373,10 @@
"targets.interpolation.dominant": "Доминантный", "targets.interpolation.dominant": "Доминантный",
"targets.smoothing": "Сглаживание:", "targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.led_skip": "Пропуск LED:",
"targets.led_skip.hint": "Количество светодиодов в начале и конце ленты, которые остаются чёрными. Источники цвета будут рендериться только на активных (непропущенных) LED.",
"targets.led_skip_start": "Начало:",
"targets.led_skip_end": "Конец:",
"targets.keepalive_interval": "Интервал поддержания связи:", "targets.keepalive_interval": "Интервал поддержания связи:",
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)", "targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
"targets.created": "Цель успешно создана", "targets.created": "Цель успешно создана",

View File

@@ -91,9 +91,7 @@ class KeyColorsPictureTarget(PictureTarget):
def update_fields(self, *, name=None, device_id=None, picture_source_id=None, def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None, settings=None, key_colors_settings=None, description=None,
# WledPictureTarget-specific params — accepted but ignored: **_kwargs) -> None:
color_strip_source_id=None, standby_interval=None,
state_check_interval=None) -> None:
"""Apply mutable field updates for KC targets.""" """Apply mutable field updates for KC targets."""
super().update_fields(name=name, description=description) super().update_fields(name=name, description=description)
if picture_source_id is not None: if picture_source_id is not None:

View File

@@ -103,8 +103,10 @@ class PictureTargetStore:
device_id: str = "", device_id: str = "",
color_strip_source_id: str = "", color_strip_source_id: str = "",
fps: int = 30, fps: int = 30,
standby_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
led_skip_start: int = 0,
led_skip_end: int = 0,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
# Legacy params — accepted but ignored for backward compat # Legacy params — accepted but ignored for backward compat
@@ -118,7 +120,7 @@ class PictureTargetStore:
target_type: Target type ("led", "wled", "key_colors") target_type: Target type ("led", "wled", "key_colors")
device_id: WLED device ID (for led targets) device_id: WLED device ID (for led targets)
color_strip_source_id: Color strip source ID (for led targets) color_strip_source_id: Color strip source ID (for led targets)
standby_interval: Keepalive interval in seconds (for led targets) keepalive_interval: Keepalive interval in seconds (for led targets)
state_check_interval: State check interval in seconds (for led targets) state_check_interval: State check interval in seconds (for led targets)
key_colors_settings: Key colors settings (for key_colors targets) key_colors_settings: Key colors settings (for key_colors targets)
description: Optional description description: Optional description
@@ -148,8 +150,10 @@ class PictureTargetStore:
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
fps=fps, fps=fps,
standby_interval=standby_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
led_skip_start=led_skip_start,
led_skip_end=led_skip_end,
description=description, description=description,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -181,8 +185,10 @@ class PictureTargetStore:
device_id: Optional[str] = None, device_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None, color_strip_source_id: Optional[str] = None,
fps: Optional[int] = None, fps: Optional[int] = None,
standby_interval: Optional[float] = None, keepalive_interval: Optional[float] = None,
state_check_interval: Optional[int] = None, state_check_interval: Optional[int] = None,
led_skip_start: Optional[int] = None,
led_skip_end: Optional[int] = None,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
# Legacy params — accepted but ignored # Legacy params — accepted but ignored
@@ -210,8 +216,10 @@ class PictureTargetStore:
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
fps=fps, fps=fps,
standby_interval=standby_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
led_skip_start=led_skip_start,
led_skip_end=led_skip_end,
key_colors_settings=key_colors_settings, key_colors_settings=key_colors_settings,
description=description, description=description,
) )

View File

@@ -1,8 +1,7 @@
"""LED picture target — sends a color strip source to an LED device.""" """LED picture target — sends a color strip source to an LED device."""
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional
from wled_controller.storage.picture_target import PictureTarget from wled_controller.storage.picture_target import PictureTarget
@@ -20,12 +19,10 @@ class WledPictureTarget(PictureTarget):
device_id: str = "" device_id: str = ""
color_strip_source_id: str = "" color_strip_source_id: str = ""
fps: int = 30 # target send FPS (1-90) fps: int = 30 # target send FPS (1-90)
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
led_skip_start: int = 0 # first N LEDs forced to black
# Legacy fields — populated from old JSON data during migration; not written back led_skip_end: int = 0 # last M LEDs forced to black
_legacy_picture_source_id: str = field(default="", repr=False, compare=False)
_legacy_settings: Optional[dict] = field(default=None, repr=False, compare=False)
def register_with_manager(self, manager) -> None: def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager.""" """Register this WLED target with the processor manager."""
@@ -35,8 +32,10 @@ class WledPictureTarget(PictureTarget):
device_id=self.device_id, device_id=self.device_id,
color_strip_source_id=self.color_strip_source_id, color_strip_source_id=self.color_strip_source_id,
fps=self.fps, fps=self.fps,
standby_interval=self.standby_interval, keepalive_interval=self.keepalive_interval,
state_check_interval=self.state_check_interval, state_check_interval=self.state_check_interval,
led_skip_start=self.led_skip_start,
led_skip_end=self.led_skip_end,
) )
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None: def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
@@ -44,8 +43,10 @@ class WledPictureTarget(PictureTarget):
if settings_changed: if settings_changed:
manager.update_target_settings(self.id, { manager.update_target_settings(self.id, {
"fps": self.fps, "fps": self.fps,
"standby_interval": self.standby_interval, "keepalive_interval": self.keepalive_interval,
"state_check_interval": self.state_check_interval, "state_check_interval": self.state_check_interval,
"led_skip_start": self.led_skip_start,
"led_skip_end": self.led_skip_end,
}) })
if source_changed: if source_changed:
manager.update_target_color_strip_source(self.id, self.color_strip_source_id) manager.update_target_color_strip_source(self.id, self.color_strip_source_id)
@@ -53,10 +54,9 @@ class WledPictureTarget(PictureTarget):
manager.update_target_device(self.id, self.device_id) manager.update_target_device(self.id, self.device_id)
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
fps=None, standby_interval=None, state_check_interval=None, fps=None, keepalive_interval=None, state_check_interval=None,
# Legacy params accepted but ignored to keep base class compat: led_skip_start=None, led_skip_end=None,
picture_source_id=None, settings=None, description=None, **_kwargs) -> None:
key_colors_settings=None, description=None) -> None:
"""Apply mutable field updates for WLED targets.""" """Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description) super().update_fields(name=name, description=description)
if device_id is not None: if device_id is not None:
@@ -65,10 +65,14 @@ class WledPictureTarget(PictureTarget):
self.color_strip_source_id = color_strip_source_id self.color_strip_source_id = color_strip_source_id
if fps is not None: if fps is not None:
self.fps = fps self.fps = fps
if standby_interval is not None: if keepalive_interval is not None:
self.standby_interval = standby_interval self.keepalive_interval = keepalive_interval
if state_check_interval is not None: if state_check_interval is not None:
self.state_check_interval = state_check_interval self.state_check_interval = state_check_interval
if led_skip_start is not None:
self.led_skip_start = led_skip_start
if led_skip_end is not None:
self.led_skip_end = led_skip_end
@property @property
def has_picture_source(self) -> bool: def has_picture_source(self) -> bool:
@@ -80,31 +84,27 @@ class WledPictureTarget(PictureTarget):
d["device_id"] = self.device_id d["device_id"] = self.device_id
d["color_strip_source_id"] = self.color_strip_source_id d["color_strip_source_id"] = self.color_strip_source_id
d["fps"] = self.fps d["fps"] = self.fps
d["standby_interval"] = self.standby_interval d["keepalive_interval"] = self.keepalive_interval
d["state_check_interval"] = self.state_check_interval d["state_check_interval"] = self.state_check_interval
d["led_skip_start"] = self.led_skip_start
d["led_skip_end"] = self.led_skip_end
return d return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget": def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary. Reads legacy picture_source_id/settings for migration.""" """Create from dictionary."""
obj = cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
target_type="led", target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""),
fps=data.get("fps", 30), fps=data.get("fps", 30),
standby_interval=data.get("standby_interval", 1.0), keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
led_skip_start=data.get("led_skip_start", 0),
led_skip_end=data.get("led_skip_end", 0),
description=data.get("description"), description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
) )
# Preserve legacy fields for migration — never written back by to_dict()
obj._legacy_picture_source_id = data.get("picture_source_id", "")
settings_data = data.get("settings", {})
if settings_data:
obj._legacy_settings = settings_data
return obj

View File

@@ -48,6 +48,24 @@
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small> <small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="target-editor-skip-group">
<div class="label-row">
<label data-i18n="targets.led_skip">LED Skip:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.led_skip.hint">Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.</small>
<div class="inline-fields">
<div class="inline-field">
<label for="target-editor-skip-start" data-i18n="targets.led_skip_start">Start:</label>
<input type="number" id="target-editor-skip-start" min="0" value="0">
</div>
<div class="inline-field">
<label for="target-editor-skip-end" data-i18n="targets.led_skip_end">End:</label>
<input type="number" id="target-editor-skip-end" min="0" value="0">
</div>
</div>
</div>
<div class="form-group" id="target-editor-keepalive-group"> <div class="form-group" id="target-editor-keepalive-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-keepalive-interval"> <label for="target-editor-keepalive-interval">