From 053a56eed3c9dadb2df5904e21d77d11d2a4a509 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Feb 2026 17:47:40 +0300 Subject: [PATCH] Add live LED strip preview via WebSocket on target cards Stream real-time LED colors from running WLED targets to the browser via binary WebSocket (RGB bytes, throttled to ~15 fps). Toggle button on target card opens a compact canvas strip that renders each frame using ImageData. Cached last frame is re-rendered after card reconciliation to prevent flicker during auto-refresh. Co-Authored-By: Claude Opus 4.6 --- .../api/routes/picture_targets.py | 38 ++++++ .../core/processing/processor_manager.py | 9 ++ .../core/processing/target_processor.py | 8 ++ .../core/processing/wled_target_processor.py | 44 +++++++ .../src/wled_controller/static/css/cards.css | 15 +++ server/src/wled_controller/static/js/app.js | 3 +- .../wled_controller/static/js/core/state.js | 3 + .../static/js/features/targets.js | 115 ++++++++++++++++++ 8 files changed, 234 insertions(+), 1 deletion(-) diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index e6695d5..6856a3b 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -693,6 +693,44 @@ async def target_colors_ws( manager.remove_kc_ws_client(target_id, websocket) +@router.websocket("/api/v1/picture-targets/{target_id}/led-preview/ws") +async def led_preview_ws( + websocket: WebSocket, + target_id: str, + token: str = Query(""), +): + """WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=.""" + authenticated = False + cfg = get_config() + if token and cfg.auth.api_keys: + for _label, api_key in cfg.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + authenticated = True + break + + if not authenticated: + await websocket.close(code=4001, reason="Unauthorized") + return + + await websocket.accept() + + manager = get_processor_manager() + + try: + manager.add_led_preview_client(target_id, websocket) + except ValueError: + await websocket.close(code=4004, reason="Target not found") + return + + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + manager.remove_led_preview_client(target_id, websocket) + + # ===== STATE CHANGE EVENT STREAM ===== diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 0a52119..32b2bdc 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -541,6 +541,15 @@ class ProcessorManager: proc = self._get_processor(target_id) return proc.get_latest_colors() + def add_led_preview_client(self, target_id: str, ws) -> None: + proc = self._get_processor(target_id) + proc.add_led_preview_client(ws) + + def remove_led_preview_client(self, target_id: str, ws) -> None: + proc = self._processors.get(target_id) + if proc: + proc.remove_led_preview_client(ws) + # ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) ===== async def set_test_mode( diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index dc74550..85c424d 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -224,6 +224,14 @@ class TargetProcessor(ABC): """Remove a WebSocket client.""" raise NotImplementedError(f"{type(self).__name__} does not support WebSockets") + def add_led_preview_client(self, ws) -> None: + """Add a WebSocket client for live LED strip preview.""" + raise NotImplementedError(f"{type(self).__name__} does not support LED preview") + + def remove_led_preview_client(self, ws) -> None: + """Remove a LED preview WebSocket client.""" + raise NotImplementedError(f"{type(self).__name__} does not support LED preview") + def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]: """Get latest extracted colors (KC targets only).""" return {} 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 91a29ae..6ac1e5b 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -56,6 +56,9 @@ class WledTargetProcessor(TargetProcessor): self._resolved_display_index: Optional[int] = None + # LED preview WebSocket clients + self._preview_clients: list = [] + # ----- Properties ----- @property @@ -403,6 +406,38 @@ class WledTargetProcessor(TargetProcessor): def is_overlay_active(self) -> bool: return self._overlay_active + # ----- LED Preview WebSocket ----- + + def supports_websocket(self) -> bool: + return True + + def add_led_preview_client(self, ws) -> None: + self._preview_clients.append(ws) + + def remove_led_preview_client(self, ws) -> None: + if ws in self._preview_clients: + self._preview_clients.remove(ws) + + async def _broadcast_led_preview(self, colors: np.ndarray) -> None: + """Broadcast LED colors as binary RGB bytes to preview WebSocket clients.""" + if not self._preview_clients: + return + + data = colors.astype(np.uint8).tobytes() + + async def _send_safe(ws): + try: + await ws.send_bytes(data) + return True + except Exception: + return False + + results = await asyncio.gather(*[_send_safe(ws) for ws in self._preview_clients]) + + disconnected = [ws for ws, ok in zip(self._preview_clients, results) if not ok] + for ws in disconnected: + self._preview_clients.remove(ws) + # ----- Private: processing loop ----- @staticmethod @@ -426,6 +461,7 @@ class WledTargetProcessor(TargetProcessor): fps_samples: collections.deque = collections.deque(maxlen=10) send_timestamps: collections.deque = collections.deque() last_send_time = 0.0 + _last_preview_broadcast = 0.0 prev_frame_time_stamp = time.perf_counter() loop = asyncio.get_running_loop() _init_device_info = self._ctx.get_device_info(self._device_id) @@ -540,6 +576,9 @@ class WledTargetProcessor(TargetProcessor): last_send_time = now send_timestamps.append(now) self._metrics.frames_keepalive += 1 + if self._preview_clients and (now - _last_preview_broadcast) >= 0.066: + await self._broadcast_led_preview(send_colors) + _last_preview_broadcast = now self._metrics.frames_skipped += 1 while send_timestamps and send_timestamps[0] < now - 1.0: send_timestamps.popleft() @@ -570,6 +609,11 @@ class WledTargetProcessor(TargetProcessor): last_send_time = now send_timestamps.append(now) + # Broadcast to LED preview WebSocket clients (throttled to ~15 fps) + if self._preview_clients and (now - _last_preview_broadcast) >= 0.066: + await self._broadcast_led_preview(send_colors) + _last_preview_broadcast = now + self._metrics.timing_send_ms = send_ms self._metrics.frames_processed += 1 self._metrics.last_update = datetime.utcnow() diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 1941903..02f84e5 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -587,3 +587,18 @@ ul.section-tip li { grid-template-columns: 1fr; } } + +/* ── LED Preview Panel ────────────────────────────────────────── */ + +.led-preview-panel { + padding: 4px 0 0; +} + +.led-preview-canvas { + display: block; + width: 100%; + height: 16px; + border-radius: 3px; + image-rendering: pixelated; + background: #111; +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 6ad2372..ba692a7 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -87,7 +87,7 @@ import { showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, - cloneTarget, + cloneTarget, toggleLedPreview, } from './features/targets.js'; // Layer 5: color-strip sources @@ -295,6 +295,7 @@ Object.assign(window, { stopTargetOverlay, deleteTarget, cloneTarget, + toggleLedPreview, // color-strip sources showCSSEditor, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index fbdfbfa..94144cc 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -110,6 +110,9 @@ export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; } // KC WebSockets export const kcWebSockets = {}; +// LED Preview WebSockets +export const ledPreviewWebSockets = {}; + // Tutorial state export let activeTutorial = null; export function setActiveTutorial(v) { activeTutorial = v; } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index efe1aa3..3a3a234 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -7,6 +7,7 @@ import { _targetEditorDevices, set_targetEditorDevices, _deviceBrightnessCache, kcWebSockets, + ledPreviewWebSockets, _cachedValueSources, set_cachedValueSources, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; @@ -494,6 +495,15 @@ export async function loadTargetsTab() { csPatternTemplates.reconcile(patternItems); changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed, ...kcResult.added, ...kcResult.replaced, ...kcResult.removed]); + + // Re-render cached LED preview frames onto new canvas elements after reconciliation + for (const id of ledResult.replaced) { + const frame = _ledPreviewLastFrame[id]; + if (frame && ledPreviewWebSockets[id]) { + const canvas = document.getElementById(`led-preview-canvas-${id}`); + if (canvas) _renderLedStrip(canvas, frame); + } + } } else { // ── First render: build full HTML ── const ledPanel = ` @@ -545,6 +555,15 @@ export async function loadTargetsTab() { if (!processingKCIds.has(id)) disconnectKCWebSocket(id); }); + // Auto-disconnect LED preview WebSockets for targets that stopped + const processingLedIds = new Set(); + ledTargets.forEach(target => { + if (target.state && target.state.processing) processingLedIds.add(target.id); + }); + Object.keys(ledPreviewWebSockets).forEach(id => { + if (!processingLedIds.has(id)) disconnectLedPreviewWS(id); + }); + // FPS charts: only destroy charts for replaced/removed cards (or all on first render) if (changedTargetIds) { // Incremental: destroy only charts whose cards were replaced or removed @@ -703,6 +722,9 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) { ` : ''} +
+ +
${isProcessing ? ` `} + ${isProcessing ? ` + + ` : ''} @@ -828,3 +855,91 @@ export async function deleteTarget(targetId) { } }); } + + +/* ── LED Strip Preview ────────────────────────────────────────── */ + +const _ledPreviewLastFrame = {}; + +function _renderLedStrip(canvas, rgbBytes) { + const ledCount = rgbBytes.length / 3; + if (ledCount <= 0) return; + + // Set canvas resolution to match LED count (1px per LED) + canvas.width = ledCount; + canvas.height = 1; + + const ctx = canvas.getContext('2d'); + const imageData = ctx.createImageData(ledCount, 1); + const data = imageData.data; + + for (let i = 0; i < ledCount; i++) { + const si = i * 3; + const di = i * 4; + data[di] = rgbBytes[si]; + data[di + 1] = rgbBytes[si + 1]; + data[di + 2] = rgbBytes[si + 2]; + data[di + 3] = 255; + } + + ctx.putImageData(imageData, 0, 0); +} + +function connectLedPreviewWS(targetId) { + disconnectLedPreviewWS(targetId); + + const key = localStorage.getItem('wled_api_key'); + if (!key) return; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/led-preview/ws?token=${encodeURIComponent(key)}`; + + try { + const ws = new WebSocket(wsUrl); + ws.binaryType = 'arraybuffer'; + + ws.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + const frame = new Uint8Array(event.data); + _ledPreviewLastFrame[targetId] = frame; + const canvas = document.getElementById(`led-preview-canvas-${targetId}`); + if (canvas) _renderLedStrip(canvas, frame); + } + }; + + ws.onclose = () => { + delete ledPreviewWebSockets[targetId]; + }; + + ws.onerror = (error) => { + console.error(`LED preview WebSocket error for ${targetId}:`, error); + }; + + ledPreviewWebSockets[targetId] = ws; + } catch (error) { + console.error(`Failed to connect LED preview WebSocket for ${targetId}:`, error); + } +} + +function disconnectLedPreviewWS(targetId) { + const ws = ledPreviewWebSockets[targetId]; + if (ws) { + ws.close(); + delete ledPreviewWebSockets[targetId]; + } + delete _ledPreviewLastFrame[targetId]; + const panel = document.getElementById(`led-preview-panel-${targetId}`); + if (panel) panel.style.display = 'none'; +} + +export function toggleLedPreview(targetId) { + const panel = document.getElementById(`led-preview-panel-${targetId}`); + if (!panel) return; + + if (ledPreviewWebSockets[targetId]) { + disconnectLedPreviewWS(targetId); + } else { + panel.style.display = ''; + connectLedPreviewWS(targetId); + } +}