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 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ from wled_controller.api.schemas.picture_targets import (
|
|||||||
TargetMetricsResponse,
|
TargetMetricsResponse,
|
||||||
TargetProcessingState,
|
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.capture_engines import EngineRegistry
|
||||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
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=<api_key>."""
|
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
|
||||||
# Authenticate
|
# Authenticate
|
||||||
authenticated = False
|
authenticated = False
|
||||||
if token and config.auth.api_keys:
|
cfg = get_config()
|
||||||
for _label, api_key in config.auth.api_keys.items():
|
if token and cfg.auth.api_keys:
|
||||||
|
for _label, api_key in cfg.auth.api_keys.items():
|
||||||
if secrets.compare_digest(token, api_key):
|
if secrets.compare_digest(token, api_key):
|
||||||
authenticated = True
|
authenticated = True
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1241,7 +1241,10 @@ class ProcessorManager:
|
|||||||
|
|
||||||
frame_time = 1.0 / target_fps
|
frame_time = 1.0 / target_fps
|
||||||
fps_samples: List[float] = []
|
fps_samples: List[float] = []
|
||||||
|
prev_frame_time_stamp = time.time()
|
||||||
prev_capture = None # Track previous ScreenCapture for change detection
|
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
|
rectangles = state._resolved_rectangles
|
||||||
|
|
||||||
@@ -1264,7 +1267,18 @@ class ProcessorManager:
|
|||||||
|
|
||||||
# Skip processing if the frame hasn't changed
|
# Skip processing if the frame hasn't changed
|
||||||
if capture is prev_capture:
|
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
|
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)
|
await asyncio.sleep(frame_time)
|
||||||
continue
|
continue
|
||||||
prev_capture = capture
|
prev_capture = capture
|
||||||
@@ -1281,17 +1295,31 @@ class ProcessorManager:
|
|||||||
|
|
||||||
# Broadcast to WebSocket clients
|
# Broadcast to WebSocket clients
|
||||||
await self._broadcast_kc_colors(target_id, colors)
|
await self._broadcast_kc_colors(target_id, colors)
|
||||||
|
last_broadcast_time = time.time()
|
||||||
|
send_timestamps.append(last_broadcast_time)
|
||||||
|
|
||||||
# Update metrics
|
# Update metrics
|
||||||
state.metrics.frames_processed += 1
|
state.metrics.frames_processed += 1
|
||||||
state.metrics.last_update = datetime.utcnow()
|
state.metrics.last_update = datetime.utcnow()
|
||||||
|
|
||||||
loop_time = time.time() - loop_start
|
# Calculate actual FPS from frame-to-frame interval
|
||||||
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0)
|
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:
|
if len(fps_samples) > 10:
|
||||||
fps_samples.pop(0)
|
fps_samples.pop(0)
|
||||||
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
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:
|
except Exception as e:
|
||||||
state.metrics.errors_count += 1
|
state.metrics.errors_count += 1
|
||||||
state.metrics.last_error = str(e)
|
state.metrics.last_error = str(e)
|
||||||
@@ -1357,13 +1385,18 @@ class ProcessorManager:
|
|||||||
raise ValueError(f"KC target {target_id} not found")
|
raise ValueError(f"KC target {target_id} not found")
|
||||||
|
|
||||||
state = self._kc_targets[target_id]
|
state = self._kc_targets[target_id]
|
||||||
|
metrics = state.metrics
|
||||||
return {
|
return {
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": state.is_running,
|
"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,
|
"fps_target": state.settings.fps,
|
||||||
"last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None,
|
"frames_skipped": metrics.frames_skipped if state.is_running else None,
|
||||||
"errors": [state.metrics.last_error] if state.metrics.last_error else [],
|
"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:
|
def get_kc_target_metrics(self, target_id: str) -> dict:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
let apiKey = 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
|
// Toggle hint description visibility next to a label
|
||||||
function toggleHint(btn) {
|
function toggleHint(btn) {
|
||||||
@@ -78,7 +80,9 @@ function openLightbox(imageSrc, statsHtml) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeLightbox(event) {
|
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');
|
const lightbox = document.getElementById('image-lightbox');
|
||||||
lightbox.classList.remove('active');
|
lightbox.classList.remove('active');
|
||||||
const img = document.getElementById('lightbox-image');
|
const img = document.getElementById('lightbox-image');
|
||||||
@@ -89,6 +93,9 @@ function closeLightbox(event) {
|
|||||||
document.getElementById('lightbox-stats').style.display = 'none';
|
document.getElementById('lightbox-stats').style.display = 'none';
|
||||||
const spinner = lightbox.querySelector('.lightbox-spinner');
|
const spinner = lightbox.querySelector('.lightbox-spinner');
|
||||||
if (spinner) spinner.style.display = 'none';
|
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();
|
unlockBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4341,20 +4348,32 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
|||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
|
||||||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||||||
<div class="metric-label">${t('targets.metrics.actual_fps')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.current_fps')}</div>
|
||||||
|
<div class="metric-value">${state.fps_current ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||||
<div class="metric-value">${state.fps_target || 0}</div>
|
<div class="metric-value">${state.fps_target || 0}</div>
|
||||||
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
|
||||||
|
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||||
<div class="metric-label">${t('targets.metrics.frames')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.keepalive')}</div>
|
||||||
|
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">${t('device.metrics.errors')}</div>
|
||||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||||
<div class="metric-label">${t('targets.metrics.errors')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -4382,7 +4401,21 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
|||||||
|
|
||||||
// ===== KEY COLORS TEST =====
|
// ===== 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) {
|
async function testKCTarget(targetId) {
|
||||||
|
kcTestTargetId = targetId;
|
||||||
|
|
||||||
// Show lightbox immediately with a spinner
|
// Show lightbox immediately with a spinner
|
||||||
const lightbox = document.getElementById('image-lightbox');
|
const lightbox = document.getElementById('image-lightbox');
|
||||||
const lbImg = document.getElementById('lightbox-image');
|
const lbImg = document.getElementById('lightbox-image');
|
||||||
@@ -4400,19 +4433,15 @@ async function testKCTarget(targetId) {
|
|||||||
}
|
}
|
||||||
spinner.style.display = '';
|
spinner.style.display = '';
|
||||||
|
|
||||||
|
// Show auto-refresh button
|
||||||
|
const refreshBtn = document.getElementById('lightbox-auto-refresh');
|
||||||
|
if (refreshBtn) refreshBtn.style.display = '';
|
||||||
|
|
||||||
lightbox.classList.add('active');
|
lightbox.classList.add('active');
|
||||||
lockBody();
|
lockBody();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, {
|
const result = await fetchKCTest(targetId);
|
||||||
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();
|
|
||||||
displayKCTestResults(result);
|
displayKCTestResults(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
closeLightbox();
|
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) {
|
function displayKCTestResults(result) {
|
||||||
const srcImg = new window.Image();
|
const srcImg = new window.Image();
|
||||||
srcImg.onload = () => {
|
srcImg.onload = () => {
|
||||||
@@ -4794,12 +4861,15 @@ function updateKCColorSwatches(targetId, colors) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = entries.map(([name, color]) => `
|
container.innerHTML = entries.map(([name, color]) => {
|
||||||
<div class="kc-swatch">
|
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')}`;
|
||||||
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
|
return `
|
||||||
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
<div class="kc-swatch">
|
||||||
</div>
|
<div class="kc-swatch-color" style="background-color: ${hex}" title="${hex}"></div>
|
||||||
`).join('');
|
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== PATTERN TEMPLATES =====
|
// ===== PATTERN TEMPLATES =====
|
||||||
|
|||||||
@@ -867,6 +867,7 @@
|
|||||||
<!-- Image Lightbox -->
|
<!-- Image Lightbox -->
|
||||||
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
||||||
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
||||||
|
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Auto-refresh" style="display:none">▶</button>
|
||||||
<div class="lightbox-content">
|
<div class="lightbox-content">
|
||||||
<img id="lightbox-image" src="" alt="Full size preview">
|
<img id="lightbox-image" src="" alt="Full size preview">
|
||||||
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
||||||
|
|||||||
@@ -2440,6 +2440,33 @@ input:-webkit-autofill:focus {
|
|||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lightbox-refresh-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 64px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-refresh-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-refresh-btn.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.lightbox-stats {
|
.lightbox-stats {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user