diff --git a/server/src/wled_controller/api/schemas/picture_sources.py b/server/src/wled_controller/api/schemas/picture_sources.py index 9e301d7..367953c 100644 --- a/server/src/wled_controller/api/schemas/picture_sources.py +++ b/server/src/wled_controller/api/schemas/picture_sources.py @@ -13,7 +13,7 @@ class PictureSourceCreate(BaseModel): stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type") display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") - target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) + target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90) source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") @@ -26,7 +26,7 @@ class PictureSourceUpdate(BaseModel): name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") - target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) + target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90) source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 2b19c6f..22f16ec 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -53,7 +53,7 @@ class PictureTargetCreate(BaseModel): # LED target fields 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=10, le=90, description="Target send FPS (10-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) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) # KC target fields @@ -69,7 +69,7 @@ class PictureTargetUpdate(BaseModel): # LED target fields 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=10, le=90, description="Target send FPS (10-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) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) # KC target fields diff --git a/server/src/wled_controller/core/devices/adalight_client.py b/server/src/wled_controller/core/devices/adalight_client.py index d781679..63b0883 100644 --- a/server/src/wled_controller/core/devices/adalight_client.py +++ b/server/src/wled_controller/core/devices/adalight_client.py @@ -105,9 +105,22 @@ class AdalightClient(LEDClient): try: black = np.zeros((self._led_count, 3), dtype=np.uint8) frame = self._build_frame(black, brightness=255) + logger.info( + f"Adalight sending black frame: {self._port} " + f"({self._led_count} LEDs, {len(frame)} bytes)" + ) await asyncio.to_thread(self._serial.write, frame) + await asyncio.to_thread(self._serial.flush) + logger.info(f"Adalight black frame sent and flushed: {self._port}") except Exception as e: - logger.debug(f"Failed to send black frame on close: {e}") + logger.warning(f"Failed to send black frame on close: {e}") + else: + logger.warning( + f"Adalight close skipped black frame: port={self._port} " + f"connected={self._connected} serial={self._serial is not None} " + f"is_open={self._serial.is_open if self._serial else 'N/A'} " + f"led_count={self._led_count}" + ) self._connected = False if self._serial and self._serial.is_open: try: diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index c8d9b1a..aa804bf 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -219,7 +219,7 @@ class PictureColorStripStream(ColorStripStream): def set_capture_fps(self, fps: int) -> None: """Update the internal capture rate. Thread-safe (read atomically by the loop).""" - fps = max(10, min(90, fps)) + fps = max(1, min(90, fps)) if fps != self._fps: self._fps = fps self._interp_duration = 1.0 / fps diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 9f86eac..7337c99 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -527,8 +527,6 @@ class ProcessorManager: ds.test_mode_edges = {} ds.test_calibration = None await self._send_clear_pixels(device_id) - # Keep idle client open — serial reconnect causes device reset. - # start_processing() closes it before connecting its own client. async def _get_idle_client(self, device_id: str): """Get or create a cached idle LED client for a device. @@ -590,22 +588,42 @@ class ProcessorManager: if offset > 0: pixels = pixels[-offset:] + pixels[:-offset] - try: - client = await self._get_idle_client(device_id) - await client.send_pixels(pixels) - except Exception as e: - logger.error(f"Failed to send test pixels for {device_id}: {e}") + await self._send_pixels_to_device(device_id, pixels) async def _send_clear_pixels(self, device_id: str) -> None: """Send all-black pixels to clear LED output.""" ds = self._devices[device_id] pixels = [(0, 0, 0)] * ds.led_count + await self._send_pixels_to_device(device_id, pixels) + def _is_serial_device(self, device_id: str) -> bool: + """Check if a device uses a serial (COM) connection.""" + ds = self._devices.get(device_id) + return ds is not None and ds.device_type not in ("wled",) + + async def _send_pixels_to_device(self, device_id: str, pixels) -> None: + """Send pixels to a device. + + Serial devices: temporary connection (open, send, close). + WLED devices: cached idle client. + """ + ds = self._devices[device_id] try: - client = await self._get_idle_client(device_id) - await client.send_pixels(pixels) + if self._is_serial_device(device_id): + client = create_led_client( + ds.device_type, ds.device_url, + led_count=ds.led_count, baud_rate=ds.baud_rate, + ) + try: + await client.connect() + await client.send_pixels(pixels) + finally: + await client.close() + else: + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) except Exception as e: - logger.error(f"Failed to clear pixels for {device_id}: {e}") + logger.error(f"Failed to send pixels to {device_id}: {e}") def _find_active_led_client(self, device_id: str): """Find an active LED client for a device (from a running processor).""" @@ -644,7 +662,7 @@ class ProcessorManager: """Restore a device to its idle state when all targets stop. - For WLED: do nothing — stop() already restored the snapshot. - - For other devices: power off (send black frame). + - For serial: do nothing — AdalightClient.close() already sent black frame. """ ds = self._devices.get(device_id) if not ds or not ds.auto_shutdown: @@ -653,15 +671,10 @@ class ProcessorManager: if self.is_device_processing(device_id): return - try: - if ds.device_type != "wled": - await self._send_clear_pixels(device_id) - logger.info(f"Auto-restore: powered off {ds.device_type} device {device_id}") - else: - # WLED: stop() already called restore_device_state() via snapshot - logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot") - except Exception as e: - logger.error(f"Auto-restore failed for device {device_id}: {e}") + if ds.device_type == "wled": + logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot") + else: + logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)") # ===== LIFECYCLE ===== @@ -678,18 +691,11 @@ class ProcessorManager: logger.error(f"Error stopping target {target_id}: {e}") # Restore idle state for devices that have auto-restore enabled + # (serial devices already dark from processor close; WLED restored by snapshot) for device_id in self._devices: await self._restore_device_idle_state(device_id) - # Power off serial LED devices before closing connections - for device_id, ds in self._devices.items(): - if ds.device_type != "wled": - try: - await self._send_clear_pixels(device_id) - except Exception as e: - logger.error(f"Failed to power off {device_id} on shutdown: {e}") - - # Close any cached idle LED clients + # Close any cached idle LED clients (WLED only; serial has no cached clients) for did in list(self._idle_clients): await self._close_idle_client(did) 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 a40751b..5bc124c 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -165,6 +165,9 @@ class WledTargetProcessor(TargetProcessor): except asyncio.CancelledError: pass self._task = None + # Allow any in-flight thread pool serial write to complete before + # close() sends the black frame (to_thread keeps running after cancel) + await asyncio.sleep(0.05) # Restore device state if self._led_client and self._device_state_before: diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 0b238ce..385134d 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -203,6 +203,14 @@ font-size: 0.85rem; } +.field-desc { + display: block; + margin: 4px 0 0 0; + color: #888; + font-size: 0.82rem; + font-style: italic; +} + .fps-hint { display: block; margin-top: 4px; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 9977368..0e6a282 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -89,7 +89,7 @@ import { // Layer 5: color-strip sources import { showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, - onCSSTypeChange, colorCycleAddColor, colorCycleRemoveColor, + onCSSTypeChange, onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor, } from './features/color-strips.js'; // Layer 5: calibration @@ -274,6 +274,7 @@ Object.assign(window, { saveCSSEditor, deleteColorStrip, onCSSTypeChange, + onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor, diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 72cd38f..5bd2b69 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -27,7 +27,6 @@ class CSSEditorModal extends Modal { frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, led_count: document.getElementById('css-editor-led-count').value, gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', - animation_enabled: document.getElementById('css-editor-animation-enabled').checked, animation_type: document.getElementById('css-editor-animation-type').value, animation_speed: document.getElementById('css-editor-animation-speed').value, cycle_speed: document.getElementById('css-editor-cycle-speed').value, @@ -50,9 +49,10 @@ export function onCSSTypeChange() { // Animation section — shown for static/gradient only (color_cycle is always animating) const animSection = document.getElementById('css-editor-animation-section'); const animTypeSelect = document.getElementById('css-editor-animation-type'); + const noneOpt = `${t('color_strip.animation.type.none')}`; if (type === 'static') { animSection.style.display = ''; - animTypeSelect.innerHTML = + animTypeSelect.innerHTML = noneOpt + `${t('color_strip.animation.type.breathing')}` + `${t('color_strip.animation.type.strobe')}` + `${t('color_strip.animation.type.sparkle')}` + @@ -61,7 +61,7 @@ export function onCSSTypeChange() { `${t('color_strip.animation.type.rainbow_fade')}`; } else if (type === 'gradient') { animSection.style.display = ''; - animTypeSelect.innerHTML = + animTypeSelect.innerHTML = noneOpt + `${t('color_strip.animation.type.breathing')}` + `${t('color_strip.animation.type.gradient_shift')}` + `${t('color_strip.animation.type.wave')}` + @@ -73,6 +73,7 @@ export function onCSSTypeChange() { } else { animSection.style.display = 'none'; } + _syncAnimationSpeedState(); if (type === 'gradient') { requestAnimationFrame(() => gradientRenderAll()); @@ -80,22 +81,41 @@ export function onCSSTypeChange() { } function _getAnimationPayload() { + const type = document.getElementById('css-editor-animation-type').value; return { - enabled: document.getElementById('css-editor-animation-enabled').checked, - type: document.getElementById('css-editor-animation-type').value, + enabled: type !== 'none', + type: type !== 'none' ? type : 'breathing', speed: parseFloat(document.getElementById('css-editor-animation-speed').value), }; } function _loadAnimationState(anim) { - document.getElementById('css-editor-animation-enabled').checked = !!(anim && anim.enabled); const speedEl = document.getElementById('css-editor-animation-speed'); speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0; document.getElementById('css-editor-animation-speed-val').textContent = parseFloat(speedEl.value).toFixed(1); // Set type after onCSSTypeChange() has populated the dropdown - if (anim && anim.type) { + if (anim && anim.enabled && anim.type) { document.getElementById('css-editor-animation-type').value = anim.type; + } else { + document.getElementById('css-editor-animation-type').value = 'none'; + } + _syncAnimationSpeedState(); +} + +export function onAnimationTypeChange() { + _syncAnimationSpeedState(); +} + +function _syncAnimationSpeedState() { + const type = document.getElementById('css-editor-animation-type').value; + const isNone = type === 'none'; + document.getElementById('css-editor-animation-speed').disabled = isNone; + const descEl = document.getElementById('css-editor-animation-type-desc'); + if (descEl) { + const desc = t('color_strip.animation.type.' + type + '.desc') || ''; + descEl.textContent = desc; + descEl.style.display = desc ? '' : 'none'; } } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 4dabca8..314daea 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -258,7 +258,7 @@ "streams.capture_template": "Engine Template:", "streams.capture_template.hint": "Engine template defining how the screen is captured", "streams.target_fps": "Target FPS:", - "streams.target_fps.hint": "Target frames per second for capture (10-90)", + "streams.target_fps.hint": "Target frames per second for capture (1-90)", "streams.source": "Source:", "streams.source.hint": "The source to apply processing filters to", "streams.pp_template": "Filter Template:", @@ -361,7 +361,7 @@ "targets.source.hint": "Which picture source to capture and process", "targets.source.none": "-- No source assigned --", "targets.fps": "Target FPS:", - "targets.fps.hint": "Target frames per second for capture and LED updates (10-90)", + "targets.fps.hint": "Target frames per second for capture and LED updates (1-90)", "targets.border_width": "Border Width (px):", "targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", "targets.interpolation": "Interpolation Mode:", @@ -589,19 +589,26 @@ "color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.", "color_strip.gradient.min_stops": "Gradient must have at least 2 stops", "color_strip.animation": "Animation", - "color_strip.animation.enabled": "Enable Animation:", - "color_strip.animation.enabled.hint": "Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.", "color_strip.animation.type": "Effect:", - "color_strip.animation.type.hint": "The animation effect to apply. Breathing, Strobe, Sparkle, Pulse, Candle, and Rainbow Fade work for both static and gradient sources; Gradient Shift and Wave are gradient-only.", + "color_strip.animation.type.hint": "Animation effect to apply.", + "color_strip.animation.type.none": "None (no animation effect)", "color_strip.animation.type.breathing": "Breathing", + "color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out", "color_strip.animation.type.color_cycle": "Color Cycle", "color_strip.animation.type.gradient_shift": "Gradient Shift", + "color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip", "color_strip.animation.type.wave": "Wave", + "color_strip.animation.type.wave.desc": "Sinusoidal brightness wave moving along the strip", "color_strip.animation.type.strobe": "Strobe", + "color_strip.animation.type.strobe.desc": "Rapid on/off flashing", "color_strip.animation.type.sparkle": "Sparkle", + "color_strip.animation.type.sparkle.desc": "Random LEDs flash briefly", "color_strip.animation.type.pulse": "Pulse", + "color_strip.animation.type.pulse.desc": "Sharp brightness pulse with quick fade", "color_strip.animation.type.candle": "Candle", + "color_strip.animation.type.candle.desc": "Warm flickering candle-like glow", "color_strip.animation.type.rainbow_fade": "Rainbow Fade", + "color_strip.animation.type.rainbow_fade.desc": "Cycles through the entire hue spectrum", "color_strip.animation.speed": "Speed:", "color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", "color_strip.color_cycle.colors": "Colors:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 333bdd5..2ac9623 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -258,7 +258,7 @@ "streams.capture_template": "Шаблон Движка:", "streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана", "streams.target_fps": "Целевой FPS:", - "streams.target_fps.hint": "Целевое количество кадров в секунду (10-90)", + "streams.target_fps.hint": "Целевое количество кадров в секунду (1-90)", "streams.source": "Источник:", "streams.source.hint": "Источник, к которому применяются фильтры обработки", "streams.pp_template": "Шаблон Фильтра:", @@ -361,7 +361,7 @@ "targets.source.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.none": "-- Источник не назначен --", "targets.fps": "Целевой FPS:", - "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (10-90)", + "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)", "targets.border_width": "Ширина границы (px):", "targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", "targets.interpolation": "Режим интерполяции:", @@ -589,19 +589,26 @@ "color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.", "color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок", "color_strip.animation": "Анимация", - "color_strip.animation.enabled": "Включить анимацию:", - "color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.", "color_strip.animation.type": "Эффект:", - "color_strip.animation.type.hint": "Эффект анимации. Дыхание, стробоскоп, искры, пульс, свеча и радужный перелив работают для статического цвета и градиента; сдвиг градиента и волна — только для градиентов.", + "color_strip.animation.type.hint": "Эффект анимации.", + "color_strip.animation.type.none": "Нет (без эффекта анимации)", "color_strip.animation.type.breathing": "Дыхание", + "color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости", "color_strip.animation.type.color_cycle": "Смена цвета", "color_strip.animation.type.gradient_shift": "Сдвиг градиента", + "color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты", "color_strip.animation.type.wave": "Волна", + "color_strip.animation.type.wave.desc": "Синусоидальная волна яркости вдоль ленты", "color_strip.animation.type.strobe": "Стробоскоп", + "color_strip.animation.type.strobe.desc": "Быстрое мигание вкл/выкл", "color_strip.animation.type.sparkle": "Искры", + "color_strip.animation.type.sparkle.desc": "Случайные светодиоды кратковременно вспыхивают", "color_strip.animation.type.pulse": "Пульс", + "color_strip.animation.type.pulse.desc": "Резкая вспышка яркости с быстрым затуханием", "color_strip.animation.type.candle": "Свеча", + "color_strip.animation.type.candle.desc": "Тёплое мерцание, как у свечи", "color_strip.animation.type.rainbow_fade": "Радужный перелив", + "color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков", "color_strip.animation.speed": "Скорость:", "color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.", "color_strip.color_cycle.colors": "Цвета:", diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index d963ed2..cdcc7bf 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -19,7 +19,7 @@ class WledPictureTarget(PictureTarget): device_id: str = "" color_strip_source_id: str = "" - fps: int = 30 # target send FPS (10-90) + fps: int = 30 # target send FPS (1-90) standby_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/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 7377c10..70b72e9 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -193,26 +193,16 @@ Animation - - - Enable Animation: - ? - - Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect. - - - - - Effect: ? - The animation effect to apply. Available effects depend on source type. - + The animation effect to apply. Available effects depend on source type. Select None to disable animation. + + diff --git a/server/src/wled_controller/templates/modals/stream.html b/server/src/wled_controller/templates/modals/stream.html index b00a43a..6878827 100644 --- a/server/src/wled_controller/templates/modals/stream.html +++ b/server/src/wled_controller/templates/modals/stream.html @@ -41,9 +41,9 @@ Target FPS: ? - Target frames per second for capture (10-90) + Target frames per second for capture (1-90) - + 30 diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index f4b611f..b98614d 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -40,9 +40,9 @@ ? - How many frames per second to send to the device (10-90). Higher values give smoother animations but use more bandwidth. + How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth. - + fps