feat: color value source test visualization
Lint & Test / test (push) Successful in 1m6s

WS now sends color RGB data for color-type streams. Test modal renders
a color history swatch strip below the chart, colors the chart line and
fill area with the current output color, and shows rgb() in the stats.
Works for static_color, animated_color, and adaptive_time_color sources.
This commit is contained in:
2026-03-30 03:12:57 +03:00
parent 0a8737157c
commit f6c25cd15f
4 changed files with 95 additions and 3 deletions
@@ -47,6 +47,7 @@ from wled_controller.storage.value_source import (
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -381,10 +382,21 @@ async def test_value_source_ws(
await websocket.accept()
logger.info(f"Value source test WebSocket connected for {source_id}")
# Detect if this stream produces colors
_is_color_stream = (
hasattr(stream, "get_color") and type(stream).get_color is not ValueStream.get_color
)
try:
while True:
value = stream.get_value()
msg: dict = {"value": round(value, 4)}
if _is_color_stream:
try:
r, g, b = stream.get_color()
msg["color"] = [int(r), int(g), int(b)]
except NotImplementedError:
pass
if hasattr(stream, "get_raw_value"):
raw = stream.get_raw_value()
if raw is not None:
@@ -82,6 +82,17 @@
border-radius: 6px;
}
.vs-test-color-swatch {
margin-top: 8px;
}
.vs-test-color-canvas {
display: block;
width: 100%;
height: 32px;
border-radius: 6px;
}
.vs-test-stats {
display: flex;
gap: 20px;
@@ -760,6 +760,8 @@ let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
let _testVsRawLatest: number | null = null;
let _testVsRawRange: [number, number] | null = null;
let _testVsColorLatest: [number, number, number] | null = null;
let _testVsColorHistory: [number, number, number][] = [];
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
@@ -777,11 +779,17 @@ export function testValueSource(sourceId: any) {
_testVsMaxObserved = -Infinity;
_testVsRawLatest = null;
_testVsRawRange = null;
_testVsColorLatest = null;
_testVsColorHistory = [];
// Hide color swatch until color data arrives
const swatchEl = document.getElementById('vs-test-color-swatch');
if (swatchEl) swatchEl.style.display = 'none';
const currentEl = document.getElementById('vs-test-current');
const minEl = document.getElementById('vs-test-min');
const maxEl = document.getElementById('vs-test-max');
if (currentEl) currentEl.textContent = '---';
if (currentEl) { currentEl.textContent = '---'; currentEl.style.color = ''; }
if (minEl) minEl.textContent = '---';
if (maxEl) maxEl.textContent = '---';
@@ -816,6 +824,13 @@ export function testValueSource(sourceId: any) {
_testVsRawLatest = data.raw_value;
if (data.raw_range) _testVsRawRange = data.raw_range;
}
if (data.color) {
_testVsColorLatest = data.color;
_testVsColorHistory.push(data.color);
if (_testVsColorHistory.length > VS_HISTORY_SIZE) {
_testVsColorHistory.shift();
}
}
} catch (e) {
console.error('Value source test WS parse error:', e);
return;
@@ -869,6 +884,7 @@ function _sizeVsCanvas(canvas: HTMLCanvasElement) {
function _renderVsTestLoop() {
_renderVsChart();
_renderVsColorSwatch();
if (testVsModal.isOpen) {
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
}
@@ -923,7 +939,9 @@ function _renderVsChart() {
}
ctx.lineTo((startOffset + history.length - 1) * stepX, h);
ctx.closePath();
ctx.fillStyle = 'rgba(76, 175, 80, 0.15)';
ctx.fillStyle = _testVsColorLatest
? `rgba(${_testVsColorLatest[0]},${_testVsColorLatest[1]},${_testVsColorLatest[2]},0.15)`
: 'rgba(76, 175, 80, 0.15)';
ctx.fill();
// Draw the line
@@ -934,7 +952,10 @@ function _renderVsChart() {
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#4caf50';
const lineColor = _testVsColorLatest
? `rgb(${_testVsColorLatest[0]},${_testVsColorLatest[1]},${_testVsColorLatest[2]})`
: '#4caf50';
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.stroke();
@@ -993,6 +1014,51 @@ function _fmtRaw(v: number): string {
return Number.isInteger(v) ? String(v) : v.toFixed(1);
}
function _renderVsColorSwatch() {
const swatchEl = document.getElementById('vs-test-color-swatch');
const canvas = document.getElementById('vs-test-color-canvas') as HTMLCanvasElement | null;
if (!canvas || !swatchEl) return;
if (_testVsColorHistory.length === 0) {
swatchEl.style.display = 'none';
return;
}
swatchEl.style.display = '';
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement!.getBoundingClientRect();
const w = rect.width;
const h = 32;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d')!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const colors = _testVsColorHistory;
const stepX = w / VS_HISTORY_SIZE;
const barW = Math.max(stepX, 1);
const startOffset = VS_HISTORY_SIZE - colors.length;
for (let i = 0; i < colors.length; i++) {
const [r, g, b] = colors[i];
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect((startOffset + i) * stepX, 0, barW + 0.5, h);
}
// Update current color stat display
if (_testVsColorLatest) {
const [r, g, b] = _testVsColorLatest;
const curEl = document.getElementById('vs-test-current');
if (curEl) {
curEl.textContent = `rgb(${r}, ${g}, ${b})`;
curEl.style.color = `rgb(${r},${g},${b})`;
}
}
}
// ── Card rendering (used by streams.js) ───────────────────────
export function createValueSourceCard(src: ValueSource) {
@@ -7,6 +7,9 @@
</div>
<div class="modal-body">
<canvas id="vs-test-canvas" class="vs-test-canvas"></canvas>
<div id="vs-test-color-swatch" class="vs-test-color-swatch" style="display:none">
<canvas id="vs-test-color-canvas" class="vs-test-color-canvas"></canvas>
</div>
<div class="vs-test-stats">
<span class="vs-test-stat vs-test-stat-current">
<span class="vs-test-stat-label" data-i18n="value_source.test.current">Current</span>