From 398f090ecacaffc8445371f749a7e25e67247478 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Feb 2026 15:57:07 +0300 Subject: [PATCH] Port WLED optimizations to KC loop: fix FPS metrics, add keepalive and auto-refresh test - Fix KC fps_actual to use frame-to-frame timestamps (was inflated by measuring before sleep) - Add fps_potential, fps_current, frames_skipped, frames_keepalive metrics to KC loop - Add keepalive broadcast for static frames so WS clients stay in sync - Expose all KC metrics in get_kc_target_state() and update UI card to show 7 metrics - Add auto-refresh play/pause button to KC test lightbox (polls every ~1s) - Fix WebSocket color swatches computing hex from r,g,b when hex field is absent - Fix WebSocket auth crash by using get_config() instead of module-level config variable - Fix lightbox closing when clicking auto-refresh button (event bubbling) Co-Authored-By: Claude Opus 4.6 --- .../api/routes/picture_targets.py | 7 +- .../wled_controller/core/processor_manager.py | 43 ++++++- server/src/wled_controller/static/app.js | 110 ++++++++++++++---- server/src/wled_controller/static/index.html | 1 + server/src/wled_controller/static/style.css | 27 +++++ 5 files changed, 160 insertions(+), 28 deletions(-) diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index c2a3fe3..9584d07 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -33,7 +33,7 @@ from wled_controller.api.schemas.picture_targets import ( TargetMetricsResponse, TargetProcessingState, ) -from wled_controller.config import config +from wled_controller.config import get_config from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings @@ -763,8 +763,9 @@ async def target_colors_ws( """WebSocket for real-time key color updates. Auth via ?token=.""" # Authenticate authenticated = False - if token and config.auth.api_keys: - for _label, api_key in config.auth.api_keys.items(): + 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 diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 41d4124..3e4d644 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -1241,7 +1241,10 @@ class ProcessorManager: frame_time = 1.0 / target_fps fps_samples: List[float] = [] + prev_frame_time_stamp = time.time() prev_capture = None # Track previous ScreenCapture for change detection + last_broadcast_time = 0.0 # Timestamp of last WS broadcast (for keepalive) + send_timestamps: collections.deque = collections.deque() # for fps_current rectangles = state._resolved_rectangles @@ -1264,7 +1267,18 @@ class ProcessorManager: # Skip processing if the frame hasn't changed if capture is prev_capture: + # Keepalive: re-broadcast last colors so WS clients stay in sync + if state.latest_colors and (loop_start - last_broadcast_time) >= 1.0: + await self._broadcast_kc_colors(target_id, state.latest_colors) + last_broadcast_time = time.time() + send_timestamps.append(last_broadcast_time) + state.metrics.frames_keepalive += 1 state.metrics.frames_skipped += 1 + # Update fps_current even on skip + now_ts = time.time() + while send_timestamps and send_timestamps[0] < now_ts - 1.0: + send_timestamps.popleft() + state.metrics.fps_current = len(send_timestamps) await asyncio.sleep(frame_time) continue prev_capture = capture @@ -1281,17 +1295,31 @@ class ProcessorManager: # Broadcast to WebSocket clients await self._broadcast_kc_colors(target_id, colors) + last_broadcast_time = time.time() + send_timestamps.append(last_broadcast_time) # Update metrics state.metrics.frames_processed += 1 state.metrics.last_update = datetime.utcnow() - loop_time = time.time() - loop_start - fps_samples.append(1.0 / loop_time if loop_time > 0 else 0) + # Calculate actual FPS from frame-to-frame interval + now = time.time() + interval = now - prev_frame_time_stamp + prev_frame_time_stamp = now + fps_samples.append(1.0 / interval if interval > 0 else 0) if len(fps_samples) > 10: fps_samples.pop(0) state.metrics.fps_actual = sum(fps_samples) / len(fps_samples) + # Potential FPS = how fast the pipeline could run without throttle + processing_time = now - loop_start + state.metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 + + # Update fps_current: count sends in last 1 second + while send_timestamps and send_timestamps[0] < now - 1.0: + send_timestamps.popleft() + state.metrics.fps_current = len(send_timestamps) + except Exception as e: state.metrics.errors_count += 1 state.metrics.last_error = str(e) @@ -1357,13 +1385,18 @@ class ProcessorManager: raise ValueError(f"KC target {target_id} not found") state = self._kc_targets[target_id] + metrics = state.metrics return { "target_id": target_id, "processing": state.is_running, - "fps_actual": round(state.metrics.fps_actual, 1) if state.is_running else None, + "fps_actual": round(metrics.fps_actual, 1) if state.is_running else None, + "fps_potential": metrics.fps_potential if state.is_running else None, "fps_target": state.settings.fps, - "last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None, - "errors": [state.metrics.last_error] if state.metrics.last_error else [], + "frames_skipped": metrics.frames_skipped if state.is_running else None, + "frames_keepalive": metrics.frames_keepalive if state.is_running else None, + "fps_current": metrics.fps_current if state.is_running else None, + "last_update": metrics.last_update, + "errors": [metrics.last_error] if metrics.last_error else [], } def get_kc_target_metrics(self, target_id: str) -> dict: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 7e4442c..3104ba8 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -1,6 +1,8 @@ const API_BASE = '/api/v1'; let refreshInterval = null; let apiKey = null; +let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh +let kcTestTargetId = null; // currently testing KC target // Toggle hint description visibility next to a label function toggleHint(btn) { @@ -78,7 +80,9 @@ function openLightbox(imageSrc, statsHtml) { } function closeLightbox(event) { - if (event && event.target && event.target.closest('.lightbox-content')) return; + if (event && event.target && (event.target.closest('.lightbox-content') || event.target.closest('.lightbox-refresh-btn'))) return; + // Stop KC test auto-refresh if running + stopKCTestAutoRefresh(); const lightbox = document.getElementById('image-lightbox'); lightbox.classList.remove('active'); const img = document.getElementById('lightbox-image'); @@ -89,6 +93,9 @@ function closeLightbox(event) { document.getElementById('lightbox-stats').style.display = 'none'; const spinner = lightbox.querySelector('.lightbox-spinner'); if (spinner) spinner.style.display = 'none'; + // Hide auto-refresh button + const refreshBtn = document.getElementById('lightbox-auto-refresh'); + if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); } unlockBody(); } @@ -4341,20 +4348,32 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) { ${isProcessing ? `
+
${t('device.metrics.actual_fps')}
${state.fps_actual?.toFixed(1) || '0.0'}
-
${t('targets.metrics.actual_fps')}
+
${t('device.metrics.current_fps')}
+
${state.fps_current ?? '-'}
+
+
+
${t('device.metrics.target_fps')}
${state.fps_target || 0}
-
${t('targets.metrics.target_fps')}
+
${t('device.metrics.potential_fps')}
+
${state.fps_potential?.toFixed(0) || '-'}
+
+
+
${t('device.metrics.frames')}
${metrics.frames_processed || 0}
-
${t('targets.metrics.frames')}
+
${t('device.metrics.keepalive')}
+
${state.frames_keepalive ?? '-'}
+
+
+
${t('device.metrics.errors')}
${metrics.errors_count || 0}
-
${t('targets.metrics.errors')}
` : ''} @@ -4382,7 +4401,21 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) { // ===== KEY COLORS TEST ===== +async function fetchKCTest(targetId) { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, { + method: 'POST', + headers: getHeaders(), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || response.statusText); + } + return response.json(); +} + async function testKCTarget(targetId) { + kcTestTargetId = targetId; + // Show lightbox immediately with a spinner const lightbox = document.getElementById('image-lightbox'); const lbImg = document.getElementById('lightbox-image'); @@ -4400,19 +4433,15 @@ async function testKCTarget(targetId) { } spinner.style.display = ''; + // Show auto-refresh button + const refreshBtn = document.getElementById('lightbox-auto-refresh'); + if (refreshBtn) refreshBtn.style.display = ''; + lightbox.classList.add('active'); lockBody(); try { - const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, { - method: 'POST', - headers: getHeaders(), - }); - if (!response.ok) { - const err = await response.json().catch(() => ({})); - throw new Error(err.detail || response.statusText); - } - const result = await response.json(); + const result = await fetchKCTest(targetId); displayKCTestResults(result); } catch (e) { closeLightbox(); @@ -4420,6 +4449,44 @@ async function testKCTarget(targetId) { } } +function toggleKCTestAutoRefresh() { + if (kcTestAutoRefresh) { + stopKCTestAutoRefresh(); + } else { + kcTestAutoRefresh = setInterval(async () => { + if (!kcTestTargetId) return; + try { + const result = await fetchKCTest(kcTestTargetId); + displayKCTestResults(result); + } catch (e) { + stopKCTestAutoRefresh(); + } + }, 1000); + updateAutoRefreshButton(true); + } +} + +function stopKCTestAutoRefresh() { + if (kcTestAutoRefresh) { + clearInterval(kcTestAutoRefresh); + kcTestAutoRefresh = null; + } + kcTestTargetId = null; + updateAutoRefreshButton(false); +} + +function updateAutoRefreshButton(active) { + const btn = document.getElementById('lightbox-auto-refresh'); + if (!btn) return; + if (active) { + btn.classList.add('active'); + btn.innerHTML = '⏸'; // pause symbol + } else { + btn.classList.remove('active'); + btn.innerHTML = '▶'; // play symbol + } +} + function displayKCTestResults(result) { const srcImg = new window.Image(); srcImg.onload = () => { @@ -4794,12 +4861,15 @@ function updateKCColorSwatches(targetId, colors) { return; } - container.innerHTML = entries.map(([name, color]) => ` -
-
- ${escapeHtml(name)} -
- `).join(''); + container.innerHTML = entries.map(([name, color]) => { + const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`; + return ` +
+
+ ${escapeHtml(name)} +
+ `; + }).join(''); } // ===== PATTERN TEMPLATES ===== diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 6370324..2cd2dbc 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -867,6 +867,7 @@