Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e0bf8538c | |||
| be4c98b543 | |||
| dca2d212b1 | |||
| 53986f8d95 | |||
| a4a9f6f77f | |||
| 9fcfdb8570 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/>';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,12 +99,10 @@ 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();
|
||||
// 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] || [];
|
||||
@@ -112,9 +110,6 @@ async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
|
||||
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — charts will fill from polling
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up history for targets that are no longer running
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,14 +1401,17 @@ 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)
|
||||
// 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);
|
||||
@@ -1397,12 +1420,26 @@ function connectLedPreviewWS(targetId: any) {
|
||||
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;
|
||||
|
||||
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,10 +1498,14 @@ 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 (frame && panel) {
|
||||
if (panel.dataset.zoneMode === 'separate') {
|
||||
_renderLedStripZones(panel, frame);
|
||||
} else {
|
||||
const canvas = panel.querySelector('.led-preview-canvas');
|
||||
if (canvas) _renderLedStrip(canvas, frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectLedPreviewWS(targetId: any) {
|
||||
|
||||
@@ -203,6 +203,7 @@ export interface ColorStripSource {
|
||||
// API Input
|
||||
fallback_color?: number[];
|
||||
timeout?: number;
|
||||
interpolation?: string;
|
||||
|
||||
// Notification
|
||||
notification_effect?: string;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.",
|
||||
|
||||
@@ -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 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user