diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 5808461..312b9d2 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -95,8 +95,10 @@ def _target_to_response(target) -> PictureTargetResponse: device_id=target.device_id, color_strip_source_id=target.color_strip_source_id, fps=target.fps, - standby_interval=target.standby_interval, + keepalive_interval=target.keepalive_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, created_at=target.created_at, updated_at=target.updated_at, @@ -150,8 +152,10 @@ async def create_target( device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, fps=data.fps, - standby_interval=data.standby_interval, + keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, + led_skip_start=data.led_skip_start, + led_skip_end=data.led_skip_end, picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, @@ -264,8 +268,10 @@ async def update_target( device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, fps=data.fps, - standby_interval=data.standby_interval, + keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, + led_skip_start=data.led_skip_start, + led_skip_end=data.led_skip_end, picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, @@ -276,8 +282,10 @@ async def update_target( target.sync_with_manager( manager, 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.led_skip_start is not None or + data.led_skip_end is not None or data.key_colors_settings is not None), source_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 22f16ec..cf6aa4d 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -54,8 +54,10 @@ class PictureTargetCreate(BaseModel): device_id: str = Field(default="", description="LED device 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)") - 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) + 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 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)") @@ -70,8 +72,10 @@ class PictureTargetUpdate(BaseModel): device_id: Optional[str] = Field(None, description="LED device 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)") - 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) + 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 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)") @@ -88,8 +92,10 @@ class PictureTargetResponse(BaseModel): device_id: str = Field(default="", description="LED device ID") color_strip_source_id: str = Field(default="", description="Color strip source ID") 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)") + 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 picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") diff --git a/server/src/wled_controller/core/processing/processing_settings.py b/server/src/wled_controller/core/processing/processing_settings.py index b9ebfce..9a3e98e 100644 --- a/server/src/wled_controller/core/processing/processing_settings.py +++ b/server/src/wled_controller/core/processing/processing_settings.py @@ -14,5 +14,5 @@ class ProcessingSettings: brightness: float = 1.0 smoothing: float = 0.3 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 diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 0f4c492..bfcf97a 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -269,8 +269,10 @@ class ProcessorManager: device_id: str, color_strip_source_id: str = "", fps: int = 30, - standby_interval: float = 1.0, + keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, + led_skip_start: int = 0, + led_skip_end: int = 0, ): """Register a WLED target processor.""" if target_id in self._processors: @@ -283,8 +285,10 @@ class ProcessorManager: device_id=device_id, color_strip_source_id=color_strip_source_id, fps=fps, - standby_interval=standby_interval, + keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, + led_skip_start=led_skip_start, + led_skip_end=led_skip_end, ctx=self._build_context(), ) self._processors[target_id] = proc diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index ad193af..1687190 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -41,16 +41,20 @@ class WledTargetProcessor(TargetProcessor): device_id: str, color_strip_source_id: str, fps: int, - standby_interval: float, + keepalive_interval: float, state_check_interval: int, - ctx: TargetContext, + led_skip_start: int = 0, + led_skip_end: int = 0, + ctx: TargetContext = None, ): super().__init__(target_id, ctx) self._device_id = device_id self._color_strip_source_id = color_strip_source_id 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._led_skip_start = max(0, led_skip_start) + self._led_skip_end = max(0, led_skip_end) # Runtime state (populated on start) self._led_client: Optional[LEDClient] = None @@ -126,7 +130,8 @@ class WledTargetProcessor(TargetProcessor): ) from wled_controller.core.processing.effect_stream import EffectColorStripStream 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 css_manager.notify_target_fps( @@ -209,10 +214,14 @@ class WledTargetProcessor(TargetProcessor): css_manager.notify_target_fps( self._color_strip_source_id, self._target_id, self._target_fps ) - if "standby_interval" in settings: - self._standby_interval = settings["standby_interval"] + if "keepalive_interval" in settings: + self._keepalive_interval = settings["keepalive_interval"] if "state_check_interval" in settings: 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}") def update_device(self, device_id: str) -> None: @@ -293,6 +302,8 @@ class WledTargetProcessor(TargetProcessor): "display_index": self._resolved_display_index, "overlay_active": self._overlay_active, "needs_keepalive": self._needs_keepalive, + "led_skip_start": self._led_skip_start, + "led_skip_end": self._led_skip_end, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], } @@ -404,10 +415,24 @@ class WledTargetProcessor(TargetProcessor): ]) 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: """Main processing loop — poll ColorStripStream → apply brightness → send.""" stream = self._color_strip_stream - standby_interval = self._standby_interval + keepalive_interval = self._keepalive_interval fps_samples: collections.deque = collections.deque(maxlen=10) send_timestamps: collections.deque = collections.deque() @@ -415,6 +440,7 @@ class WledTargetProcessor(TargetProcessor): last_send_time = 0.0 prev_frame_time_stamp = time.perf_counter() 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 # 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 @@ -471,12 +497,13 @@ class WledTargetProcessor(TargetProcessor): if colors is prev_colors: # 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: break kc = prev_colors 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) if self._led_client.supports_fast_send: self._led_client.send_pixels_fast(send_colors) @@ -496,9 +523,10 @@ class WledTargetProcessor(TargetProcessor): 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: - 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 send_colors = self._apply_brightness(colors, device_info) diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 1f3333d..06c9ae8 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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 async def lifespan(app: FastAPI): """Application lifespan manager. @@ -182,10 +83,6 @@ async def lifespan(app: FastAPI): logger.info(f"Authorized clients: {client_labels}") 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) profile_engine = ProfileEngine(profile_store, processor_manager) diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 4734d1a..a316d22 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -211,6 +211,26 @@ 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 { display: block; margin-top: 4px; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 77bdee3..e0ecad2 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -85,7 +85,9 @@ class TargetEditorModal extends Modal { device: document.getElementById('target-editor-device').value, css: document.getElementById('target-editor-css').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; document.getElementById('target-editor-fps').value = 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').textContent = 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.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'); } else { // 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-keepalive-interval').value = 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'); } @@ -233,6 +239,8 @@ export async function saveTargetEditor() { const deviceId = document.getElementById('target-editor-device').value; const cssId = document.getElementById('target-editor-css').value; const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value); + const ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0; + const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0; if (!name) { targetEditorModal.showError(t('targets.error.name_required')); @@ -246,7 +254,9 @@ export async function saveTargetEditor() { device_id: deviceId, color_strip_source_id: cssId, fps, - standby_interval: standbyInterval, + keepalive_interval: standbyInterval, + led_skip_start: ledSkipStart, + led_skip_end: ledSkipEnd, }; try { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 9c7bb04..ce9d657 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -373,6 +373,10 @@ "targets.interpolation.dominant": "Dominant", "targets.smoothing": "Smoothing:", "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.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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 97f6827..5b1a203 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -373,6 +373,10 @@ "targets.interpolation.dominant": "Доминантный", "targets.smoothing": "Сглаживание:", "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.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)", "targets.created": "Цель успешно создана", diff --git a/server/src/wled_controller/storage/key_colors_picture_target.py b/server/src/wled_controller/storage/key_colors_picture_target.py index bd82ea8..dc42b34 100644 --- a/server/src/wled_controller/storage/key_colors_picture_target.py +++ b/server/src/wled_controller/storage/key_colors_picture_target.py @@ -91,9 +91,7 @@ class KeyColorsPictureTarget(PictureTarget): def update_fields(self, *, name=None, device_id=None, picture_source_id=None, settings=None, key_colors_settings=None, description=None, - # WledPictureTarget-specific params — accepted but ignored: - color_strip_source_id=None, standby_interval=None, - state_check_interval=None) -> None: + **_kwargs) -> None: """Apply mutable field updates for KC targets.""" super().update_fields(name=name, description=description) if picture_source_id is not None: diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index d323a34..11b7a4a 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -103,8 +103,10 @@ class PictureTargetStore: device_id: str = "", color_strip_source_id: str = "", fps: int = 30, - standby_interval: float = 1.0, + keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, + led_skip_start: int = 0, + led_skip_end: int = 0, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, # Legacy params — accepted but ignored for backward compat @@ -118,7 +120,7 @@ class PictureTargetStore: target_type: Target type ("led", "wled", "key_colors") device_id: WLED device ID (for led targets) color_strip_source_id: Color strip source ID (for led targets) - 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) key_colors_settings: Key colors settings (for key_colors targets) description: Optional description @@ -148,8 +150,10 @@ class PictureTargetStore: device_id=device_id, color_strip_source_id=color_strip_source_id, fps=fps, - standby_interval=standby_interval, + keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, + led_skip_start=led_skip_start, + led_skip_end=led_skip_end, description=description, created_at=now, updated_at=now, @@ -181,8 +185,10 @@ class PictureTargetStore: device_id: Optional[str] = None, color_strip_source_id: Optional[str] = None, fps: Optional[int] = None, - standby_interval: Optional[float] = None, + keepalive_interval: Optional[float] = 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, description: Optional[str] = None, # Legacy params — accepted but ignored @@ -210,8 +216,10 @@ class PictureTargetStore: device_id=device_id, color_strip_source_id=color_strip_source_id, fps=fps, - standby_interval=standby_interval, + keepalive_interval=keepalive_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, description=description, ) diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index cdcc7bf..de86988 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -1,8 +1,7 @@ """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 typing import Optional from wled_controller.storage.picture_target import PictureTarget @@ -20,12 +19,10 @@ class WledPictureTarget(PictureTarget): device_id: str = "" color_strip_source_id: str = "" 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 - - # Legacy fields — populated from old JSON data during migration; not written back - _legacy_picture_source_id: str = field(default="", repr=False, compare=False) - _legacy_settings: Optional[dict] = field(default=None, repr=False, compare=False) + led_skip_start: int = 0 # first N LEDs forced to black + led_skip_end: int = 0 # last M LEDs forced to black def register_with_manager(self, manager) -> None: """Register this WLED target with the processor manager.""" @@ -35,8 +32,10 @@ class WledPictureTarget(PictureTarget): device_id=self.device_id, color_strip_source_id=self.color_strip_source_id, fps=self.fps, - standby_interval=self.standby_interval, + keepalive_interval=self.keepalive_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: @@ -44,8 +43,10 @@ class WledPictureTarget(PictureTarget): if settings_changed: manager.update_target_settings(self.id, { "fps": self.fps, - "standby_interval": self.standby_interval, + "keepalive_interval": self.keepalive_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: 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) def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, - fps=None, standby_interval=None, state_check_interval=None, - # Legacy params accepted but ignored to keep base class compat: - picture_source_id=None, settings=None, - key_colors_settings=None, description=None) -> None: + fps=None, keepalive_interval=None, state_check_interval=None, + led_skip_start=None, led_skip_end=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: @@ -65,10 +65,14 @@ class WledPictureTarget(PictureTarget): self.color_strip_source_id = color_strip_source_id if fps is not None: self.fps = fps - if standby_interval is not None: - self.standby_interval = standby_interval + if keepalive_interval is not None: + self.keepalive_interval = keepalive_interval if state_check_interval is not None: 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 def has_picture_source(self) -> bool: @@ -80,31 +84,27 @@ class WledPictureTarget(PictureTarget): d["device_id"] = self.device_id d["color_strip_source_id"] = self.color_strip_source_id 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["led_skip_start"] = self.led_skip_start + d["led_skip_end"] = self.led_skip_end return d @classmethod def from_dict(cls, data: dict) -> "WledPictureTarget": - """Create from dictionary. Reads legacy picture_source_id/settings for migration.""" - obj = cls( + """Create from dictionary.""" + return cls( id=data["id"], name=data["name"], target_type="led", device_id=data.get("device_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""), 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), + led_skip_start=data.get("led_skip_start", 0), + led_skip_end=data.get("led_skip_end", 0), description=data.get("description"), created_at=datetime.fromisoformat(data.get("created_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 diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index 440f54a..528df13 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -48,6 +48,24 @@ +