diff --git a/TODO-css-improvements.md b/TODO-css-improvements.md index e3637d5..90de97f 100644 --- a/TODO-css-improvements.md +++ b/TODO-css-improvements.md @@ -81,9 +81,9 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT ### `api_input` -- [ ] Crossfade transition when new data arrives -- [ ] Interpolation when incoming LED count differs from strip count -- [ ] Last-write-wins from any client (no multi-source blending) +- [x] ~~Crossfade transition~~ — won't do: external client owns temporal transitions; crossfading on our side would double-smooth +- [x] Interpolation when incoming LED count differs from strip count (linear/nearest/none modes) +- [x] Last-write-wins from any client — already the default behavior (push overwrites buffer) ## Architectural / Pipeline diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index f5db541..993922d 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -93,6 +93,7 @@ class ColorStripSourceCreate(BaseModel): # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)") timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0) + interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)") # notification-type fields notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep") duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds") @@ -163,6 +164,7 @@ class ColorStripSourceUpdate(BaseModel): # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0) + interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)") # notification-type fields notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep") duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds") @@ -234,6 +236,7 @@ class ColorStripSourceResponse(BaseModel): # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)") + interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)") # notification-type fields notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep") duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds") diff --git a/server/src/wled_controller/core/processing/api_input_stream.py b/server/src/wled_controller/core/processing/api_input_stream.py index 9db157a..552b2a6 100644 --- a/server/src/wled_controller/core/processing/api_input_stream.py +++ b/server/src/wled_controller/core/processing/api_input_stream.py @@ -46,6 +46,7 @@ class ApiInputColorStripStream(ColorStripStream): fallback = source.fallback_color self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._timeout = max(0.0, source.timeout if source.timeout else 5.0) + self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear" self._led_count = _DEFAULT_LED_COUNT # Build initial fallback buffer @@ -77,31 +78,59 @@ class ApiInputColorStripStream(ColorStripStream): self._colors = self._fallback_array.copy() logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs") + def _resize(self, colors: np.ndarray, target_count: int) -> np.ndarray: + """Resize colors array to target_count using the configured interpolation. + + Args: + colors: np.ndarray shape (N, 3) uint8 + target_count: desired LED count + + Returns: + np.ndarray shape (target_count, 3) uint8 + """ + n = len(colors) + if n == target_count: + return colors + + if self._interpolation == "none": + # Truncate or zero-pad (legacy behavior) + result = np.zeros((target_count, 3), dtype=np.uint8) + copy_len = min(n, target_count) + result[:copy_len] = colors[:copy_len] + return result + + if self._interpolation == "nearest": + indices = np.round(np.linspace(0, n - 1, target_count)).astype(int) + return colors[indices].copy() + + # linear (default) + src_positions = np.linspace(0, 1, n) + dst_positions = np.linspace(0, 1, target_count) + result = np.empty((target_count, 3), dtype=np.uint8) + for ch in range(3): + result[:, ch] = np.interp(dst_positions, src_positions, colors[:, ch].astype(np.float32)).astype(np.uint8) + return result + def push_colors(self, colors: np.ndarray) -> None: """Push a new frame of LED colors. - Thread-safe. Auto-grows the buffer if the incoming array is larger - than the current buffer; otherwise truncates or zero-pads. + Thread-safe. When the incoming LED count differs from the device + LED count, the data is resized according to the configured + interpolation mode (none/linear/nearest). Args: colors: np.ndarray shape (N, 3) uint8 """ with self._lock: n = len(colors) - # Auto-grow if incoming data is larger - if n > self._led_count: - self._ensure_capacity(n) if n == self._led_count: if self._colors.shape == colors.shape: np.copyto(self._colors, colors, casting='unsafe') else: self._colors = np.empty((n, 3), dtype=np.uint8) np.copyto(self._colors, colors, casting='unsafe') - elif n < self._led_count: - # Zero-pad to led_count - padded = np.zeros((self._led_count, 3), dtype=np.uint8) - padded[:n] = colors[:n] - self._colors = padded + else: + self._colors = self._resize(colors, self._led_count) self._last_push_time = time.monotonic() self._push_generation += 1 self._timed_out = False @@ -228,12 +257,13 @@ class ApiInputColorStripStream(ColorStripStream): return self._push_generation def update_source(self, source) -> None: - """Hot-update fallback_color and timeout from updated source config.""" + """Hot-update fallback_color, timeout, and interpolation from updated source config.""" from wled_controller.storage.color_strip_source import ApiInputColorStripSource if isinstance(source, ApiInputColorStripSource): fallback = source.fallback_color self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._timeout = max(0.0, source.timeout if source.timeout else 5.0) + self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear" with self._lock: self._fallback_array = self._build_fallback(self._led_count) if self._timed_out: diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index aa5cdd5..eaa1a51 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -158,14 +158,15 @@ } .dashboard-target-metrics { - display: flex; - gap: 12px; + display: grid; + grid-template-columns: auto 72px 36px; + gap: 8px; align-items: center; } .dashboard-metric { text-align: center; - min-width: 48px; + overflow: hidden; } .dashboard-metric-value { @@ -174,6 +175,7 @@ color: var(--primary-text-color); line-height: 1.2; font-family: var(--font-mono, monospace); + white-space: nowrap; } .dashboard-metric-label { @@ -187,7 +189,7 @@ display: flex; align-items: center; gap: 6px; - min-width: auto; + overflow: hidden; } .dashboard-fps-sparkline { @@ -200,7 +202,8 @@ display: flex; flex-direction: column; align-items: center; - min-width: 36px; + width: 44px; + flex-shrink: 0; line-height: 1.1; } diff --git a/server/src/wled_controller/static/js/core/api.ts b/server/src/wled_controller/static/js/core/api.ts index b875868..44e1e99 100644 --- a/server/src/wled_controller/static/js/core/api.ts +++ b/server/src/wled_controller/static/js/core/api.ts @@ -82,6 +82,27 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P throw new Error('fetchWithAuth: unreachable code — retry loop exhausted'); } +// ── Cached metrics-history fetch ──────────────────────────── +let _metricsHistoryCache: { data: any; ts: number } | null = null; +const _METRICS_CACHE_TTL = 5000; // 5 seconds + +/** Fetch metrics history with a short TTL cache to avoid duplicate requests across tabs. */ +export async function fetchMetricsHistory(): Promise { + const now = Date.now(); + if (_metricsHistoryCache && now - _metricsHistoryCache.ts < _METRICS_CACHE_TTL) { + return _metricsHistoryCache.data; + } + try { + const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); + if (!resp.ok) return null; + const data = await resp.json(); + _metricsHistoryCache = { data, ts: now }; + return data; + } catch { + return null; + } +} + export function escapeHtml(text: string) { if (!text) return ''; const div = document.createElement('div'); diff --git a/server/src/wled_controller/static/js/core/chart-utils.ts b/server/src/wled_controller/static/js/core/chart-utils.ts index 5b2a3f3..f093ad1 100644 --- a/server/src/wled_controller/static/js/core/chart-utils.ts +++ b/server/src/wled_controller/static/js/core/chart-utils.ts @@ -8,6 +8,14 @@ * Requires Chart.js to be registered globally (done by perf-charts.js). */ +const DEFAULT_MAX_SAMPLES = 120; + +/** Left-pad an array with nulls so it always has `maxSamples` entries. */ +function _padLeft(arr: number[], maxSamples: number): (number | null)[] { + const pad = maxSamples - arr.length; + return pad > 0 ? [...new Array(pad).fill(null), ...arr] : arr.slice(-maxSamples); +} + /** * Create an FPS sparkline Chart.js instance. * @@ -23,23 +31,29 @@ export function createFpsSparkline(canvasId: string, actualHistory: number[], cu const canvas = document.getElementById(canvasId); if (!canvas) return null; + const maxSamples = opts.maxSamples || DEFAULT_MAX_SAMPLES; + const paddedActual = _padLeft(actualHistory, maxSamples); + const paddedCurrent = _padLeft(currentHistory, maxSamples); + const datasets: any[] = [ { - data: [...actualHistory], + data: paddedActual, borderColor: '#2196F3', backgroundColor: 'rgba(33,150,243,0.12)', borderWidth: 1.5, tension: 0.3, fill: true, pointRadius: 0, + spanGaps: false, }, { - data: [...currentHistory], + data: paddedCurrent, borderColor: '#4CAF50', borderWidth: 1.5, tension: 0.3, fill: false, pointRadius: 0, + spanGaps: false, }, ]; @@ -47,7 +61,7 @@ export function createFpsSparkline(canvasId: string, actualHistory: number[], cu const maxHwFps = opts.maxHwFps; if (maxHwFps && maxHwFps < fpsTarget * 1.15) { datasets.push({ - data: actualHistory.map(() => maxHwFps), + data: paddedActual.map(() => maxHwFps), borderColor: 'rgba(255,152,0,0.5)', borderWidth: 1, borderDash: [4, 3], @@ -56,10 +70,12 @@ export function createFpsSparkline(canvasId: string, actualHistory: number[], cu }); } + const labels = new Array(maxSamples).fill(''); + return new Chart(canvas, { type: 'line', data: { - labels: actualHistory.map(() => ''), + labels, datasets, }, options: { diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index e16bd1b..db66aec 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -90,6 +90,7 @@ class CSSEditorModal extends Modal { audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value, api_input_timeout: (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value, + api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value, notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked, notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value, notification_duration: (document.getElementById('css-editor-notification-duration') as HTMLInputElement).value, @@ -392,6 +393,7 @@ let _audioVizIconSelect: any = null; let _gradientPresetIconSelect: any = null; let _gradientEasingIconSelect: any = null; let _candleTypeIconSelect: any = null; +let _apiInputInterpolationIconSelect: any = null; const _icon = (d: any) => `${d}`; function _ensureInterpolationIconSelect() { @@ -406,6 +408,18 @@ function _ensureInterpolationIconSelect() { _interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 }); } +function _ensureApiInputInterpolationIconSelect() { + const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null; + if (!sel) return; + const items = [ + { value: 'linear', icon: _icon(P.activity), label: t('color_strip.api_input.interpolation.linear'), desc: t('color_strip.api_input.interpolation.linear.desc') }, + { value: 'nearest', icon: _icon(P.hash), label: t('color_strip.api_input.interpolation.nearest'), desc: t('color_strip.api_input.interpolation.nearest.desc') }, + { value: 'none', icon: _icon(P.circleOff), label: t('color_strip.api_input.interpolation.none'), desc: t('color_strip.api_input.interpolation.none.desc') }, + ]; + if (_apiInputInterpolationIconSelect) { _apiInputInterpolationIconSelect.updateItems(items); return; } + _apiInputInterpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 }); +} + function _ensureEffectTypeIconSelect() { const sel = document.getElementById('css-editor-effect-type') as HTMLSelectElement | null; if (!sel) return; @@ -1042,11 +1056,13 @@ const CSS_CARD_RENDERERS: Record = { api_input: (source) => { const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]); const timeoutVal = (source.timeout ?? 5.0).toFixed(1); + const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear'; return ` ${fbColor.toUpperCase()} ${ICON_TIMER} ${timeoutVal}s + ${escapeHtml(interpLabel)} `; }, notification: (source) => { @@ -1416,12 +1432,16 @@ const _typeHandlers: Record any; reset: (... (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0; (document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = parseFloat(css.timeout ?? 5.0).toFixed(1); + (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear'; + _ensureApiInputInterpolationIconSelect(); _showApiInputEndpoints(css.id); }, reset() { (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000'; (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any; (document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0'; + (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear'; + _ensureApiInputInterpolationIconSelect(); _showApiInputEndpoints(null); }, getPayload(name) { @@ -1430,6 +1450,7 @@ const _typeHandlers: Record any; reset: (... name, fallback_color: hexToRgbArray(fbHex), timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value), + interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value, }; }, }, diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index e93de72..d53da59 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -3,7 +3,7 @@ */ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts'; @@ -99,21 +99,16 @@ function _createFpsChart(canvasId: string, actualHistory: number[], currentHisto async function _initFpsCharts(runningTargetIds: string[]): Promise { _destroyFpsCharts(); - // Seed FPS history from server ring buffer on first load - if (Object.keys(_fpsHistory).length === 0 && runningTargetIds.length > 0) { - try { - const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); - if (resp.ok) { - const data = await resp.json(); - const serverTargets = data.targets || {}; - for (const id of runningTargetIds) { - const samples = serverTargets[id] || []; - _fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null); - _fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null); - } + // Seed FPS history from server ring buffer (on first load and tab switches) + if (runningTargetIds.length > 0) { + const data = await fetchMetricsHistory(); + if (data) { + const serverTargets = data.targets || {}; + for (const id of runningTargetIds) { + const samples = serverTargets[id] || []; + _fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null); + _fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null); } - } catch { - // Silently ignore — charts will fill from polling } } @@ -162,15 +157,18 @@ function _updateRunningMetrics(enrichedRunning: any[]): void { if (chart) { const actualH = _fpsHistory[target.id] || []; const currentH = _fpsCurrentHistory[target.id] || []; - // Mutate in-place to avoid array copies + // Left-pad with nulls so all charts span full width + const pad0 = MAX_FPS_SAMPLES - actualH.length; + const pad1 = MAX_FPS_SAMPLES - currentH.length; const ds0 = chart.data.datasets[0].data; ds0.length = 0; + if (pad0 > 0) for (let i = 0; i < pad0; i++) ds0.push(null); ds0.push(...actualH); const ds1 = chart.data.datasets[1].data; ds1.length = 0; + if (pad1 > 0) for (let i = 0; i < pad1; i++) ds1.push(null); ds1.push(...currentH); - while (chart.data.labels.length < ds0.length) chart.data.labels.push(''); - chart.data.labels.length = ds0.length; + chart.data.labels.length = MAX_FPS_SAMPLES; chart.update('none'); } diff --git a/server/src/wled_controller/static/js/features/graph-editor.ts b/server/src/wled_controller/static/js/features/graph-editor.ts index 3f4d8b1..c9d39ab 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.ts +++ b/server/src/wled_controller/static/js/features/graph-editor.ts @@ -13,7 +13,7 @@ import { outputTargetsCache, patternTemplatesCache, scenePresetsCache, automationsCacheObj, csptCache, } from '../core/state.ts'; -import { fetchWithAuth } from '../core/api.ts'; +import { fetchWithAuth, fetchMetricsHistory } from '../core/api.ts'; import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { t } from '../core/i18n.ts'; @@ -2436,10 +2436,9 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML // Seed from server-side metrics history (non-blocking) try { - const histResp = await fetchWithAuth('/system/metrics-history'); + const hist = await fetchMetricsHistory(); if (_hoverNodeId !== nodeId) return; // user moved away during fetch - if (histResp.ok) { - const hist = await histResp.json(); + if (hist) { const samples = hist.targets?.[nodeId] || []; for (const s of samples) { if (s.fps != null) _hoverFpsHistory.push(s.fps); diff --git a/server/src/wled_controller/static/js/features/perf-charts.ts b/server/src/wled_controller/static/js/features/perf-charts.ts index 368cec4..2088ec1 100644 --- a/server/src/wled_controller/static/js/features/perf-charts.ts +++ b/server/src/wled_controller/static/js/features/perf-charts.ts @@ -7,7 +7,7 @@ import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); window.Chart = Chart; // expose globally for targets.js, dashboard.js -import { API_BASE, getHeaders } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { dashboardPollInterval } from '../core/state.ts'; import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'; @@ -112,9 +112,8 @@ function _createChart(canvasId: string, key: string): any { /** Seed charts from server-side metrics history. */ async function _seedFromServer(): Promise { try { - const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); - if (!resp.ok) return; - const data = await resp.json(); + const data = await fetchMetricsHistory(); + if (!data) return; const samples = data.system || []; _history.cpu = samples.map(s => s.cpu).filter(v => v != null); _history.ram = samples.map(s => s.ram_pct).filter(v => v != null); diff --git a/server/src/wled_controller/static/js/features/targets.ts b/server/src/wled_controller/static/js/features/targets.ts index e42a356..384669a 100644 --- a/server/src/wled_controller/static/js/features/targets.ts +++ b/server/src/wled_controller/static/js/features/targets.ts @@ -12,7 +12,7 @@ import { streamsCache, audioSourcesCache, syncClocksCache, colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -132,7 +132,7 @@ function _pushTargetFps(targetId: any, actual: any, current: any) { } function _createTargetFpsChart(canvasId: any, actualHistory: any, currentHistory: any, fpsTarget: any, maxHwFps: any) { - return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps }); + return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps, maxSamples: _TARGET_MAX_FPS_SAMPLES }); } function _updateTargetFpsChart(targetId: any, fpsTarget: any) { @@ -140,7 +140,6 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) { if (!chart) return; const actualH = _targetFpsHistory[targetId] || []; const currentH = _targetFpsCurrentHistory[targetId] || []; - // Mutate in-place to avoid array copies const ds0 = chart.data.datasets[0].data; ds0.length = 0; ds0.push(...actualH); @@ -832,6 +831,25 @@ export async function loadTargetsTab() { // Push FPS samples and create/update charts for running targets const allTargets = [...ledTargets, ...kcTargets]; const runningIds = new Set(); + const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id); + + // Seed FPS history from server if empty (first load / page reload) + if (runningTargetIds.length > 0 && runningTargetIds.some(id => !_targetFpsHistory[id]?.length)) { + const data = await fetchMetricsHistory(); + if (data) { + const serverTargets = data.targets || {}; + for (const id of runningTargetIds) { + if (!_targetFpsHistory[id]?.length) { + const samples = serverTargets[id] || []; + const actual = samples.map((s: any) => s.fps).filter((v: any) => v != null); + const current = samples.map((s: any) => s.fps_current).filter((v: any) => v != null); + _targetFpsHistory[id] = actual.slice(-_TARGET_MAX_FPS_SAMPLES); + _targetFpsCurrentHistory[id] = current.slice(-_TARGET_MAX_FPS_SAMPLES); + } + } + } + } + allTargets.forEach(target => { if (target.state && target.state.processing) { runningIds.add(target.id); @@ -1322,6 +1340,8 @@ function _renderLedStripZones(panel: any, rgbBytes: any) { return; } + // Separate mode: each zone gets the full source frame resampled to its LED count + // (matches backend OpenRGB separate-mode behavior) for (const canvas of zoneCanvases) { const zoneName = canvas.dataset.zoneName; const zoneSize = cache[zoneName.toLowerCase()]; @@ -1381,28 +1401,45 @@ function connectLedPreviewWS(targetId: any) { const panel = document.getElementById(`led-preview-panel-${targetId}`); - // Composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...] - if (raw.length > 4 && raw[1] === 0xFE && panel && panel.dataset.composite === '1') { + // Detect composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...] + const isCompositeWire = raw.length > 4 && raw[1] === 0xFE; + + if (isCompositeWire) { const layerCount = raw[2]; const ledCount = (raw[3] << 8) | raw[4]; const rgbSize = ledCount * 3; let offset = 5; - // Render per-layer canvases (individual layers) - const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]'); - for (let i = 0; i < layerCount; i++) { - const layerRgb = raw.subarray(offset, offset + rgbSize); - offset += rgbSize; - // layer canvases: idx 0 = "composite", idx 1..N = individual layers - const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first - if (canvas) _renderLedStrip(canvas, layerRgb); + // Render per-layer canvases if panel supports it + if (panel && panel.dataset.composite === '1') { + const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]'); + for (let i = 0; i < layerCount; i++) { + const layerRgb = raw.subarray(offset, offset + rgbSize); + offset += rgbSize; + // layer canvases: idx 0 = "composite", idx 1..N = individual layers + const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first + if (canvas) _renderLedStrip(canvas, layerRgb); + } + } else { + // Skip layer data (panel doesn't have layer canvases) + offset += layerCount * rgbSize; } // Final composite result const compositeRgb = raw.subarray(offset, offset + rgbSize); _ledPreviewLastFrame[targetId] = compositeRgb; - const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]'); - if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb); + + if (panel) { + if (panel.dataset.composite === '1') { + const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]'); + if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb); + } else if (panel.dataset.zoneMode === 'separate') { + _renderLedStripZones(panel, compositeRgb); + } else { + const canvas = panel.querySelector('.led-preview-canvas'); + if (canvas) _renderLedStrip(canvas, compositeRgb); + } + } } else { // Standard wire format: [brightness_byte] [R G B R G B ...] const frame = raw.subarray(1); @@ -1461,9 +1498,13 @@ function _restoreLedPreviewState(targetId: any) { _setPreviewButtonState(targetId, true); // Re-render cached frame onto the new canvas const frame = _ledPreviewLastFrame[targetId]; - if (frame) { - const canvas = panel?.querySelector('.led-preview-canvas'); - if (canvas) _renderLedStrip(canvas, frame); + if (frame && panel) { + if (panel.dataset.zoneMode === 'separate') { + _renderLedStripZones(panel, frame); + } else { + const canvas = panel.querySelector('.led-preview-canvas'); + if (canvas) _renderLedStrip(canvas, frame); + } } } diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 4d86563..eeed46d 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -203,6 +203,7 @@ export interface ColorStripSource { // API Input fallback_color?: number[]; timeout?: number; + interpolation?: string; // Notification notification_effect?: string; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index dd2dbdf..23b8e4a 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1061,6 +1061,14 @@ "color_strip.api_input.endpoints": "Push Endpoints:", "color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.", "color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.", + "color_strip.api_input.interpolation": "LED Interpolation:", + "color_strip.api_input.interpolation.hint": "How to resize incoming LED data when its count differs from the device's LED count. Linear gives smooth blending, Nearest preserves sharp edges, None truncates or zero-pads.", + "color_strip.api_input.interpolation.linear": "Linear", + "color_strip.api_input.interpolation.linear.desc": "Smooth blending between LEDs", + "color_strip.api_input.interpolation.nearest": "Nearest", + "color_strip.api_input.interpolation.nearest.desc": "Sharp edges, no blending", + "color_strip.api_input.interpolation.none": "None", + "color_strip.api_input.interpolation.none.desc": "Truncate or zero-pad", "color_strip.type.notification": "Notification", "color_strip.type.notification.desc": "One-shot effect on webhook trigger", "color_strip.type.notification.hint": "Fires a one-shot visual effect (flash, pulse, sweep) when triggered via a webhook. Designed for use as a composite layer over a persistent base source.", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 1085caf..1b13ee3 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1044,6 +1044,14 @@ "color_strip.api_input.endpoints": "Эндпоинты для отправки:", "color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.", "color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.", + "color_strip.api_input.interpolation": "Интерполяция LED:", + "color_strip.api_input.interpolation.hint": "Как масштабировать входящие данные LED, когда их количество отличается от количества LED на устройстве. Линейная — плавное смешивание, Ближайший — чёткие границы, Нет — обрезка или дополнение нулями.", + "color_strip.api_input.interpolation.linear": "Линейная", + "color_strip.api_input.interpolation.linear.desc": "Плавное смешивание между LED", + "color_strip.api_input.interpolation.nearest": "Ближайший", + "color_strip.api_input.interpolation.nearest.desc": "Чёткие границы, без смешивания", + "color_strip.api_input.interpolation.none": "Нет", + "color_strip.api_input.interpolation.none.desc": "Обрезка или дополнение нулями", "color_strip.type.notification": "Уведомления", "color_strip.type.notification.desc": "Разовый эффект по вебхуку", "color_strip.type.notification.hint": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 5c662e8..17848be 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1044,6 +1044,14 @@ "color_strip.api_input.endpoints": "推送端点:", "color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSON,WebSocket 接受 JSON 和原始二进制帧。", "color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。", + "color_strip.api_input.interpolation": "LED 插值:", + "color_strip.api_input.interpolation.hint": "当传入的 LED 数量与设备 LED 数量不同时如何调整大小。线性提供平滑混合,最近邻保持锐利边缘,无则截断或补零。", + "color_strip.api_input.interpolation.linear": "线性", + "color_strip.api_input.interpolation.linear.desc": "LED 之间平滑混合", + "color_strip.api_input.interpolation.nearest": "最近邻", + "color_strip.api_input.interpolation.nearest.desc": "锐利边缘,无混合", + "color_strip.api_input.interpolation.none": "无", + "color_strip.api_input.interpolation.none.desc": "截断或补零", "color_strip.type.notification": "通知", "color_strip.type.notification.desc": "通过Webhook触发的一次性效果", "color_strip.type.notification.hint": "通过 Webhook 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 04c662a..8ae3586 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -768,21 +768,27 @@ class ApiInputColorStripSource(ColorStripSource): fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B] timeout: float = 5.0 # seconds before reverting to fallback + interpolation: str = "linear" # none | linear | nearest def to_dict(self) -> dict: d = super().to_dict() d["fallback_color"] = list(self.fallback_color) d["timeout"] = self.timeout + d["interpolation"] = self.interpolation return d @classmethod def from_dict(cls, data: dict) -> "ApiInputColorStripSource": common = _parse_css_common(data) fallback_color = _validate_rgb(data.get("fallback_color"), [0, 0, 0]) + interpolation = data.get("interpolation", "linear") + if interpolation not in ("none", "linear", "nearest"): + interpolation = "linear" return cls( **common, source_type="api_input", fallback_color=fallback_color, timeout=float(data.get("timeout") or 5.0), + interpolation=interpolation, ) @classmethod @@ -790,14 +796,17 @@ class ApiInputColorStripSource(ColorStripSource): created_at: datetime, updated_at: datetime, description=None, clock_id=None, tags=None, fallback_color=None, timeout=None, + interpolation=None, **_kwargs): fb = _validate_rgb(fallback_color, [0, 0, 0]) + interp = interpolation if interpolation in ("none", "linear", "nearest") else "linear" return cls( id=id, name=name, source_type="api_input", created_at=created_at, updated_at=updated_at, description=description, clock_id=clock_id, tags=tags or [], fallback_color=fb, timeout=float(timeout) if timeout is not None else 5.0, + interpolation=interp, ) def apply_update(self, **kwargs) -> None: @@ -806,6 +815,9 @@ class ApiInputColorStripSource(ColorStripSource): self.fallback_color = fallback_color if kwargs.get("timeout") is not None: self.timeout = float(kwargs["timeout"]) + interpolation = kwargs.get("interpolation") + if interpolation in ("none", "linear", "nearest"): + self.interpolation = interpolation @dataclass diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 44edebe..9c85682 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -363,6 +363,19 @@ oninput="document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(this.value).toFixed(1)"> +
+
+ + +
+ + +
+