6 Commits

Author SHA1 Message Date
3e0bf8538c feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout
Some checks are pending
Lint & Test / test (push) Waiting to run
API Input:
- Add interpolation mode (linear/nearest/none) for LED count mismatch
  between incoming data and device LED count
- New IconSelect in editor, i18n for en/ru/zh
- Mark crossfade as won't-do (client owns temporal transitions)
- Mark last-write-wins as already implemented

LED Preview:
- Fix zone-mode preview parsing composite wire format (0xFE header
  bytes were rendered as color data, garbling multi-zone previews)
- Fix _restoreLedPreviewState to handle zone-mode panels

FPS Charts:
- Seed target card charts from server metrics-history on first load
- Add fetchMetricsHistory() with 5s TTL cache shared across
  dashboard, targets, perf-charts, and graph-editor
- Fix chart padding: pass maxSamples per caller (120 for dashboard,
  30 for target cards) instead of hardcoded 120
- Fix dashboard chart empty on tab switch (always fetch server history)
- Left-pad with nulls for consistent chart width across targets

Dashboard:
- Fix metrics row alignment (grid layout with fixed column widths)
- Fix FPS label overflow into uptime column
2026-03-26 02:06:49 +03:00
be4c98b543 fix: show template name instead of ID in filter list and card badges
All checks were successful
Lint & Test / test (push) Successful in 1m7s
Collapsed filter cards in the modal showed raw template IDs (e.g.
pp_cb72e227) instead of resolving select options to their labels.
Card filter chain badges now include the referenced template name.
2026-03-25 23:56:40 +03:00
dca2d212b1 fix: clip graph node title and subtitle to prevent overflow
Long entity names overflowed past the icon area on graph cards.
Added SVG clipPath to constrain text within the node bounds.
2026-03-25 23:56:30 +03:00
53986f8d95 fix: replace emoji with SVG icons on weather and daylight cards
Weather card used  and 🌡 emoji, daylight card used 🕐 and .
Replaced with ICON_FAST_FORWARD, ICON_THERMOMETER, and ICON_CLOCK.
Added thermometer icon path.
2026-03-25 23:56:21 +03:00
a4a9f6f77f fix: send gradient_id instead of palette in effect transient preview
All checks were successful
Lint & Test / test (push) Successful in 1m19s
The preview config was sending `palette` which defaults to "fire" on
the server, ignoring the user's selected gradient. Also removed the
dead fallback notification block and stale custom_palette check.
2026-03-25 23:43:33 +03:00
9fcfdb8570 ci: use sparse checkout for release notes in release workflow
Only fetch RELEASE_NOTES.md instead of full repo checkout, and
simplify the file detection to a direct path check.
2026-03-25 23:43:31 +03:00
24 changed files with 290 additions and 81 deletions

View File

@@ -12,8 +12,11 @@ jobs:
outputs:
release_id: ${{ steps.create.outputs.release_id }}
steps:
- name: Checkout
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
@@ -33,11 +36,9 @@ jobs:
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
# Scan for RELEASE_NOTES.md (check repo root first, then recursively)
NOTES_FILE=$(find . -maxdepth 3 -name "RELEASE_NOTES.md" -type f | head -1)
if [ -n "$NOTES_FILE" ]; then
export RELEASE_NOTES=$(cat "$NOTES_FILE")
echo "Found release notes: $NOTES_FILE"
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"

View File

@@ -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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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<any | null> {
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');

View File

@@ -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: {

View File

@@ -138,6 +138,10 @@ export class FilterListManager {
if (filterDef && !isExpanded) {
summary = filterDef.options_schema.map(opt => {
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
if (opt.type === 'select' && Array.isArray(opt.choices)) {
const choice = opt.choices.find(c => c.value === val);
if (choice) return choice.label;
}
return val;
}).join(', ');
}

View File

@@ -336,10 +336,17 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
g.appendChild(dot);
}
// Clip path for title/subtitle text (prevent overflow past icon area)
const clipId = `clip-text-${id.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
const clipPath = svgEl('clipPath', { id: clipId });
clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height }));
g.appendChild(clipPath);
// Title (shift left edge for icon to have room)
const title = svgEl('text', {
class: 'graph-node-title',
x: 16, y: 24,
'clip-path': `url(#${clipId})`,
});
title.textContent = name;
g.appendChild(title);
@@ -349,6 +356,7 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
const sub = svgEl('text', {
class: 'graph-node-subtitle',
x: 16, y: 42,
'clip-path': `url(#${clipId})`,
});
sub.textContent = subtype.replace(/_/g, ' ');
g.appendChild(sub);

View File

@@ -82,4 +82,5 @@ export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
export const thermometer = '<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/>';
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';

View File

@@ -176,6 +176,7 @@ export const ICON_UNDO = _svg(P.undo2);
export const ICON_SCENE = _svg(P.sparkles);
export const ICON_CAPTURE = _svg(P.camera);
export const ICON_BELL = _svg(P.bellRing);
export const ICON_THERMOMETER = _svg(P.thermometer);
export const ICON_CPU = _svg(P.cpu);
export const ICON_KEYBOARD = _svg(P.keyboard);
export const ICON_MOUSE = _svg(P.mouse);

View File

@@ -38,9 +38,8 @@ function _collectPreviewConfig() {
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
if (config.palette === 'custom') { const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement)?.value?.trim(); if (cpText) { try { config.custom_palette = JSON.parse(cpText); } catch {} } }
} else if (sourceType === 'daylight') {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') {

View File

@@ -13,7 +13,7 @@ import {
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_TEST,
ICON_AUTOMATION,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
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<string, CardPropsRenderer> = {
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 `
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
</span>
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
<span class="stream-card-prop" title="${t('color_strip.api_input.interpolation')}">${escapeHtml(interpLabel)}</span>
`;
},
notification: (source) => {
@@ -1067,7 +1083,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
const useRealTime = source.use_real_time;
const speedVal = (source.speed ?? 1.0).toFixed(1);
return `
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : ' ' + speedVal + 'x'}</span>
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
${clockBadge}
`;
},
@@ -1092,8 +1108,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
: '';
return `
<span class="stream-card-prop${wsLink}" title="${t('color_strip.weather.source')}">${ICON_LINK_SOURCE} ${escapeHtml(wsName)}</span>
<span class="stream-card-prop"> ${speedVal}x</span>
<span class="stream-card-prop">🌡 ${tempInfl}</span>
<span class="stream-card-prop">${ICON_FAST_FORWARD} ${speedVal}x</span>
<span class="stream-card-prop">${ICON_THERMOMETER} ${tempInfl}</span>
${clockBadge}
`;
},
@@ -1416,12 +1432,16 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => 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<string, { load: (...args: any[]) => 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,
};
},
},

View File

@@ -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<void> {
_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');
}

View File

@@ -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);

View File

@@ -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<void> {
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);

View File

@@ -409,7 +409,14 @@ function renderPictureSourcesList(streams: any) {
const renderPPTemplateCard = (tmpl: any) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
const filterNames = tmpl.filters.map(fi => {
let label = _getFilterName(fi.filter_id);
if (fi.filter_id === 'filter_template' && fi.options?.template_id) {
const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
});
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return wrapCard({
@@ -435,7 +442,14 @@ function renderPictureSourcesList(streams: any) {
const renderCSPTCard = (tmpl: any) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
const filterNames = tmpl.filters.map(fi => {
let label = _getStripFilterName(fi.filter_id);
if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) {
const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
});
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
return wrapCard({

View File

@@ -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);
}
}
}

View File

@@ -203,6 +203,7 @@ export interface ColorStripSource {
// API Input
fallback_color?: number[];
timeout?: number;
interpolation?: string;
// Notification
notification_effect?: string;

View File

@@ -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.",

View File

@@ -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": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.",

View File

@@ -1044,6 +1044,14 @@
"color_strip.api_input.endpoints": "推送端点:",
"color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSONWebSocket 接受 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 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。",

View File

@@ -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

View File

@@ -363,6 +363,19 @@
oninput="document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-api-input-interpolation" data-i18n="color_strip.api_input.interpolation">LED Interpolation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.interpolation.hint">How to resize incoming LED data when its count differs from the device's LED count.</small>
<select id="css-editor-api-input-interpolation">
<option value="linear">Linear</option>
<option value="nearest">Nearest</option>
<option value="none">None</option>
</select>
</div>
<div class="form-group" id="css-editor-api-input-endpoints-group">
<div class="label-row">
<label data-i18n="color_strip.api_input.endpoints">Push Endpoints:</label>