From bebdfcf319df5ba984c683f4cf1320439ecbcccc Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 12 Mar 2026 19:18:36 +0300 Subject: [PATCH] Add test preview for color strip sources with LED strip and rectangle views New WebSocket endpoint streams real-time RGB frames from any CSS source. Generic sources show a horizontal LED strip canvas. Picture sources show a rectangle with per-edge canvases matching the calibration layout. Server computes exact output indices per edge (offset + reverse + CW/CCW) so the frontend renders edges in correct visual orientation. Co-Authored-By: Claude Opus 4.6 --- .../api/routes/color_strip_sources.py | 95 +++++++++++- .../src/wled_controller/static/css/modal.css | 55 +++++++ server/src/wled_controller/static/js/app.js | 2 + .../static/js/features/color-strips.js | 137 +++++++++++++++++- .../wled_controller/static/locales/en.json | 3 + .../wled_controller/static/locales/ru.json | 3 + .../wled_controller/static/locales/zh.json | 3 + .../src/wled_controller/templates/index.html | 1 + .../templates/modals/test-css-source.html | 37 +++++ 9 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 server/src/wled_controller/templates/modals/test-css-source.html diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 33d3dd8..a5ff3bd 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -1,5 +1,7 @@ -"""Color strip source routes: CRUD, calibration test, and API input push.""" +"""Color strip source routes: CRUD, calibration test, preview, and API input push.""" +import asyncio +import json as _json import secrets import numpy as np @@ -667,3 +669,94 @@ async def css_api_input_ws( logger.error(f"API input WebSocket error for source {source_id}: {e}") finally: logger.info(f"API input WebSocket disconnected for source {source_id}") + + +# ── Test / Preview WebSocket ────────────────────────────────────────── + +@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws") +async def test_color_strip_ws( + websocket: WebSocket, + source_id: str, + token: str = Query(""), + led_count: int = Query(100), +): + """WebSocket for real-time CSS source preview. Auth via ``?token=``. + + First message is JSON metadata (source_type, led_count, calibration segments). + Subsequent messages are binary RGB frames (``led_count * 3`` bytes). + """ + # Authenticate + 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 + + # Validate source exists + store: ColorStripStore = get_color_strip_store() + try: + source = store.get_source(source_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + # Acquire stream + manager: ProcessorManager = get_processor_manager() + csm = manager.color_strip_stream_manager + consumer_id = "__test__" + try: + stream = csm.acquire(source_id, consumer_id) + except Exception as e: + logger.error(f"CSS test: failed to acquire stream for {source_id}: {e}") + await websocket.close(code=4003, reason=str(e)) + return + + # Configure LED count for auto-sizing streams + if hasattr(stream, "configure"): + stream.configure(max(1, led_count)) + + await websocket.accept() + logger.info(f"CSS test WebSocket connected for {source_id}") + + try: + # Send metadata as first message + is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) + meta: dict = { + "type": "meta", + "source_type": source.source_type, + "led_count": stream.led_count, + } + if is_picture and stream.calibration: + cal = stream.calibration + total = cal.get_total_leds() + offset = cal.offset % total if total > 0 else 0 + edges = [] + for seg in cal.segments: + # Compute output indices matching PixelMapper logic + indices = list(range(seg.led_start, seg.led_start + seg.led_count)) + if seg.reverse: + indices = indices[::-1] + if offset > 0: + indices = [(idx + offset) % total for idx in indices] + edges.append({"edge": seg.edge, "indices": indices}) + meta["edges"] = edges + await websocket.send_text(_json.dumps(meta)) + + # Stream binary RGB frames at ~20 Hz + while True: + colors = stream.get_latest_colors() + if colors is not None: + await websocket.send_bytes(colors.tobytes()) + await asyncio.sleep(0.05) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"CSS test WebSocket error for {source_id}: {e}") + finally: + csm.release(source_id, consumer_id) + logger.info(f"CSS test WebSocket disconnected for {source_id}") diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 7e346ba..fa2176a 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -118,6 +118,61 @@ font-size: 0.9em; } +/* Color strip test preview */ +.css-test-strip-canvas { + display: block; + width: 100%; + height: 48px; + background: #111; + border-radius: 6px; + image-rendering: pixelated; +} + +.css-test-rect { + display: grid; + grid-template-columns: 14px 1fr 14px; + grid-template-rows: 14px auto 14px; + width: 100%; +} + +.css-test-rect-corner { + background: transparent; +} + +.css-test-rect-screen { + background: #111; + border-radius: 2px; + aspect-ratio: 16 / 9; +} + +.css-test-edge-h, +.css-test-edge-v { + image-rendering: pixelated; + display: block; + width: 100%; + height: 100%; +} + +.css-test-info { + display: flex; + gap: 16px; + padding: 8px 0 0; + font-family: monospace; + font-size: 0.85em; + color: var(--text-muted, #888); +} + +.css-test-status { + text-align: center; + padding: 8px 0; + color: var(--text-muted, #888); + font-size: 0.9em; +} + +#test-css-source-modal.css-test-wide .modal-content { + max-width: 700px; +} + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 05cad71..baf55d8 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -125,6 +125,7 @@ import { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, + testColorStrip, closeTestCssSourceModal, } from './features/color-strips.js'; // Layer 5: audio sources @@ -404,6 +405,7 @@ Object.assign(window, { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, + testColorStrip, closeTestCssSourceModal, // audio sources showAudioSourceModal, 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 20cb0f0..a640194 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -12,7 +12,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, - ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, + ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE, } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; @@ -1259,6 +1259,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { const testNotifyBtn = isNotification ? `` : ''; + const testPreviewBtn = !isNotification && !isApiInput + ? `` + : ''; return wrapCard({ dataAttr: 'data-css-id', @@ -1278,7 +1281,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { actions: ` - ${calibrationBtn}${testNotifyBtn}`, + ${calibrationBtn}${testNotifyBtn}${testPreviewBtn}`, }); } @@ -1879,4 +1882,134 @@ export async function stopCSSOverlay(cssId) { } } +/* ── Test / Preview ───────────────────────────────────────────── */ + +let _cssTestWs = null; +let _cssTestRaf = null; +let _cssTestLatestRgb = null; +let _cssTestMeta = null; + +export function testColorStrip(sourceId) { + const modal = document.getElementById('test-css-source-modal'); + if (!modal) return; + modal.style.display = 'flex'; + modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); }; + + // Reset views + document.getElementById('css-test-strip-view').style.display = 'none'; + document.getElementById('css-test-rect-view').style.display = 'none'; + document.getElementById('css-test-status').style.display = ''; + document.getElementById('css-test-status').textContent = t('color_strip.test.connecting'); + document.getElementById('css-test-led-count').textContent = ''; + _cssTestLatestRgb = null; + _cssTestMeta = null; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const apiKey = localStorage.getItem('wled_api_key') || ''; + const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=100`; + + _cssTestWs = new WebSocket(wsUrl); + _cssTestWs.binaryType = 'arraybuffer'; + + _cssTestWs.onmessage = (event) => { + if (typeof event.data === 'string') { + // JSON metadata + _cssTestMeta = JSON.parse(event.data); + const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0; + document.getElementById('css-test-strip-view').style.display = isPicture ? 'none' : ''; + document.getElementById('css-test-rect-view').style.display = isPicture ? '' : 'none'; + document.getElementById('css-test-status').style.display = 'none'; + document.getElementById('test-css-source-modal').classList.toggle('css-test-wide', isPicture); + document.getElementById('css-test-led-count').textContent = `${_cssTestMeta.led_count} LEDs`; + } else { + // Binary RGB frame + _cssTestLatestRgb = new Uint8Array(event.data); + } + }; + + _cssTestWs.onerror = () => { + document.getElementById('css-test-status').textContent = t('color_strip.test.error'); + }; + + _cssTestWs.onclose = () => { + _cssTestWs = null; + }; + + // Start render loop + _cssTestRenderLoop(); +} + +function _cssTestRenderLoop() { + _cssTestRaf = requestAnimationFrame(_cssTestRenderLoop); + if (!_cssTestLatestRgb || !_cssTestMeta) return; + + const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0; + if (isPicture) { + _cssTestRenderRect(_cssTestLatestRgb, _cssTestMeta.edges); + } else { + _cssTestRenderStrip(_cssTestLatestRgb); + } +} + +function _cssTestRenderStrip(rgbBytes) { + const canvas = document.getElementById('css-test-strip-canvas'); + if (!canvas) return; + const ledCount = rgbBytes.length / 3; + 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 _cssTestRenderRect(rgbBytes, edges) { + // edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...] + // indices are pre-computed on server: reverse + offset already applied + const edgeMap = { top: [], right: [], bottom: [], left: [] }; + for (const e of edges) { + if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices); + } + + for (const [edge, indices] of Object.entries(edgeMap)) { + const canvas = document.getElementById(`css-test-edge-${edge}`); + if (!canvas) continue; + const count = indices.length; + if (count === 0) { canvas.width = 0; continue; } + + const isH = edge === 'top' || edge === 'bottom'; + canvas.width = isH ? count : 1; + canvas.height = isH ? 1 : count; + const ctx = canvas.getContext('2d'); + const imageData = ctx.createImageData(canvas.width, canvas.height); + const px = imageData.data; + for (let i = 0; i < count; i++) { + const si = indices[i] * 3; + const di = i * 4; + px[di] = rgbBytes[si] || 0; + px[di + 1] = rgbBytes[si + 1] || 0; + px[di + 2] = rgbBytes[si + 2] || 0; + px[di + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + } +} + +export function closeTestCssSourceModal() { + if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } + if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } + _cssTestLatestRgb = null; + _cssTestMeta = null; + const modal = document.getElementById('test-css-source-modal'); + if (modal) { modal.style.display = 'none'; modal.classList.remove('css-test-wide'); } +} + /* Gradient editor moved to css-gradient-editor.js */ diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 5cb6716..804a492 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -928,6 +928,9 @@ "color_strip.notification.test.ok": "Notification sent", "color_strip.notification.test.no_streams": "No running streams for this source", "color_strip.notification.test.error": "Failed to send notification", + "color_strip.test.title": "Test Preview", + "color_strip.test.connecting": "Connecting...", + "color_strip.test.error": "Failed to connect to preview stream", "color_strip.type.daylight": "Daylight Cycle", "color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours", "color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index b6f9968..16372df 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -928,6 +928,9 @@ "color_strip.notification.test.ok": "Уведомление отправлено", "color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника", "color_strip.notification.test.error": "Не удалось отправить уведомление", + "color_strip.test.title": "Предпросмотр", + "color_strip.test.connecting": "Подключение...", + "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", "color_strip.type.daylight": "Дневной цикл", "color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа", "color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 2ae51ae..d0f1d3e 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -928,6 +928,9 @@ "color_strip.notification.test.ok": "通知已发送", "color_strip.notification.test.no_streams": "此源没有运行中的流", "color_strip.notification.test.error": "发送通知失败", + "color_strip.test.title": "预览测试", + "color_strip.test.connecting": "连接中...", + "color_strip.test.error": "无法连接到预览流", "color_strip.type.daylight": "日光循环", "color_strip.type.daylight.desc": "模拟24小时自然日光变化", "color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。", diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index d96d993..1e5fa8c 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -175,6 +175,7 @@ {% include 'modals/device-settings.html' %} {% include 'modals/target-editor.html' %} {% include 'modals/css-editor.html' %} + {% include 'modals/test-css-source.html' %} {% include 'modals/kc-editor.html' %} {% include 'modals/pattern-template.html' %} {% include 'modals/api-key.html' %} diff --git a/server/src/wled_controller/templates/modals/test-css-source.html b/server/src/wled_controller/templates/modals/test-css-source.html new file mode 100644 index 0000000..29cb388 --- /dev/null +++ b/server/src/wled_controller/templates/modals/test-css-source.html @@ -0,0 +1,37 @@ + +