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 <noreply@anthropic.com>
This commit is contained in:
@@ -693,6 +693,44 @@ async def target_colors_ws(
|
|||||||
manager.remove_kc_ws_client(target_id, websocket)
|
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=<api_key>."""
|
||||||
|
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 =====
|
# ===== STATE CHANGE EVENT STREAM =====
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,15 @@ class ProcessorManager:
|
|||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
return proc.get_latest_colors()
|
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) =====
|
# ===== CALIBRATION TEST MODE (on device, driven by CSS calibration) =====
|
||||||
|
|
||||||
async def set_test_mode(
|
async def set_test_mode(
|
||||||
|
|||||||
@@ -224,6 +224,14 @@ class TargetProcessor(ABC):
|
|||||||
"""Remove a WebSocket client."""
|
"""Remove a WebSocket client."""
|
||||||
raise NotImplementedError(f"{type(self).__name__} does not support WebSockets")
|
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]]:
|
def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]:
|
||||||
"""Get latest extracted colors (KC targets only)."""
|
"""Get latest extracted colors (KC targets only)."""
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
self._resolved_display_index: Optional[int] = None
|
self._resolved_display_index: Optional[int] = None
|
||||||
|
|
||||||
|
# LED preview WebSocket clients
|
||||||
|
self._preview_clients: list = []
|
||||||
|
|
||||||
# ----- Properties -----
|
# ----- Properties -----
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -403,6 +406,38 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
def is_overlay_active(self) -> bool:
|
def is_overlay_active(self) -> bool:
|
||||||
return self._overlay_active
|
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 -----
|
# ----- Private: processing loop -----
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -426,6 +461,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
fps_samples: collections.deque = collections.deque(maxlen=10)
|
fps_samples: collections.deque = collections.deque(maxlen=10)
|
||||||
send_timestamps: collections.deque = collections.deque()
|
send_timestamps: collections.deque = collections.deque()
|
||||||
last_send_time = 0.0
|
last_send_time = 0.0
|
||||||
|
_last_preview_broadcast = 0.0
|
||||||
prev_frame_time_stamp = time.perf_counter()
|
prev_frame_time_stamp = time.perf_counter()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
_init_device_info = self._ctx.get_device_info(self._device_id)
|
_init_device_info = self._ctx.get_device_info(self._device_id)
|
||||||
@@ -540,6 +576,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
self._metrics.frames_keepalive += 1
|
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
|
self._metrics.frames_skipped += 1
|
||||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||||
send_timestamps.popleft()
|
send_timestamps.popleft()
|
||||||
@@ -570,6 +609,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(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.timing_send_ms = send_ms
|
||||||
self._metrics.frames_processed += 1
|
self._metrics.frames_processed += 1
|
||||||
self._metrics.last_update = datetime.utcnow()
|
self._metrics.last_update = datetime.utcnow()
|
||||||
|
|||||||
@@ -587,3 +587,18 @@ ul.section-tip li {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ import {
|
|||||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||||
startTargetProcessing, stopTargetProcessing,
|
startTargetProcessing, stopTargetProcessing,
|
||||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||||
cloneTarget,
|
cloneTarget, toggleLedPreview,
|
||||||
} from './features/targets.js';
|
} from './features/targets.js';
|
||||||
|
|
||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
@@ -295,6 +295,7 @@ Object.assign(window, {
|
|||||||
stopTargetOverlay,
|
stopTargetOverlay,
|
||||||
deleteTarget,
|
deleteTarget,
|
||||||
cloneTarget,
|
cloneTarget,
|
||||||
|
toggleLedPreview,
|
||||||
|
|
||||||
// color-strip sources
|
// color-strip sources
|
||||||
showCSSEditor,
|
showCSSEditor,
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; }
|
|||||||
// KC WebSockets
|
// KC WebSockets
|
||||||
export const kcWebSockets = {};
|
export const kcWebSockets = {};
|
||||||
|
|
||||||
|
// LED Preview WebSockets
|
||||||
|
export const ledPreviewWebSockets = {};
|
||||||
|
|
||||||
// Tutorial state
|
// Tutorial state
|
||||||
export let activeTutorial = null;
|
export let activeTutorial = null;
|
||||||
export function setActiveTutorial(v) { activeTutorial = v; }
|
export function setActiveTutorial(v) { activeTutorial = v; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
_targetEditorDevices, set_targetEditorDevices,
|
_targetEditorDevices, set_targetEditorDevices,
|
||||||
_deviceBrightnessCache,
|
_deviceBrightnessCache,
|
||||||
kcWebSockets,
|
kcWebSockets,
|
||||||
|
ledPreviewWebSockets,
|
||||||
_cachedValueSources, set_cachedValueSources,
|
_cachedValueSources, set_cachedValueSources,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
@@ -494,6 +495,15 @@ export async function loadTargetsTab() {
|
|||||||
csPatternTemplates.reconcile(patternItems);
|
csPatternTemplates.reconcile(patternItems);
|
||||||
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
||||||
...kcResult.added, ...kcResult.replaced, ...kcResult.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 {
|
} else {
|
||||||
// ── First render: build full HTML ──
|
// ── First render: build full HTML ──
|
||||||
const ledPanel = `
|
const ledPanel = `
|
||||||
@@ -545,6 +555,15 @@ export async function loadTargetsTab() {
|
|||||||
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
|
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)
|
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
|
||||||
if (changedTargetIds) {
|
if (changedTargetIds) {
|
||||||
// Incremental: destroy only charts whose cards were replaced or removed
|
// Incremental: destroy only charts whose cards were replaced or removed
|
||||||
@@ -703,6 +722,9 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="led-preview-panel-${target.id}" class="led-preview-panel" style="display:${ledPreviewWebSockets[target.id] ? '' : 'none'}">
|
||||||
|
<canvas id="led-preview-canvas-${target.id}" class="led-preview-canvas"></canvas>
|
||||||
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
||||||
@@ -713,6 +735,11 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|||||||
▶️
|
▶️
|
||||||
</button>
|
</button>
|
||||||
`}
|
`}
|
||||||
|
${isProcessing ? `
|
||||||
|
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
||||||
|
📊
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
|
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
|
||||||
📋
|
📋
|
||||||
</button>
|
</button>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user