Add API Input color strip source type with REST and WebSocket push
New source_type "api_input" allows external clients to push raw LED color arrays ([R,G,B] per LED) via REST POST or WebSocket. Includes configurable fallback color and timeout for automatic revert when no data is received. Stream auto-sizes LED count from the target device. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,8 @@ class CSSEditorModal extends Modal {
|
||||
audio_color: document.getElementById('css-editor-audio-color').value,
|
||||
audio_color_peak: document.getElementById('css-editor-audio-color-peak').value,
|
||||
audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
|
||||
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
|
||||
api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -66,6 +68,7 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
|
||||
document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none';
|
||||
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
||||
|
||||
if (type === 'effect') onEffectTypeChange();
|
||||
if (type === 'audio') onAudioVizChange();
|
||||
@@ -99,9 +102,9 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
// LED count — not needed for composite/mapped/audio (uses device count)
|
||||
// LED count — not needed for composite/mapped/audio/api_input (uses device count)
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
(type === 'composite' || type === 'mapped' || type === 'audio') ? 'none' : '';
|
||||
(type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : '';
|
||||
|
||||
if (type === 'audio') {
|
||||
_loadAudioSources();
|
||||
@@ -575,6 +578,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
const isComposite = source.source_type === 'composite';
|
||||
const isMapped = source.source_type === 'mapped';
|
||||
const isAudio = source.source_type === 'audio';
|
||||
const isApiInput = source.source_type === 'api_input';
|
||||
|
||||
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
|
||||
const animBadge = anim
|
||||
@@ -656,6 +660,15 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''}
|
||||
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
|
||||
`;
|
||||
} else if (isApiInput) {
|
||||
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
||||
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
|
||||
propsHtml = `
|
||||
<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')}">⏱️ ${timeoutVal}s</span>
|
||||
`;
|
||||
} else {
|
||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||
? pictureSourceMap[source.picture_source_id].name
|
||||
@@ -669,8 +682,8 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio)
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : isApiInput ? '📡' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput)
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
|
||||
: '';
|
||||
|
||||
@@ -759,6 +772,13 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
_loadCompositeState(css);
|
||||
} else if (sourceType === 'mapped') {
|
||||
_loadMappedState(css);
|
||||
} else if (sourceType === 'api_input') {
|
||||
document.getElementById('css-editor-api-input-fallback-color').value =
|
||||
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
|
||||
document.getElementById('css-editor-api-input-timeout').value = css.timeout ?? 5.0;
|
||||
document.getElementById('css-editor-api-input-timeout-val').textContent =
|
||||
parseFloat(css.timeout ?? 5.0).toFixed(1);
|
||||
_showApiInputEndpoints(css.id);
|
||||
} else {
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -844,6 +864,10 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
_loadCompositeState(null);
|
||||
_resetMappedState();
|
||||
_resetAudioState();
|
||||
document.getElementById('css-editor-api-input-fallback-color').value = '#000000';
|
||||
document.getElementById('css-editor-api-input-timeout').value = 5.0;
|
||||
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
|
||||
_showApiInputEndpoints(null);
|
||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||
document.getElementById('css-editor-gradient-preset').value = '';
|
||||
gradientInit([
|
||||
@@ -969,6 +993,14 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
payload = { name, zones };
|
||||
if (!cssId) payload.source_type = 'mapped';
|
||||
} else if (sourceType === 'api_input') {
|
||||
const fbHex = document.getElementById('css-editor-api-input-fallback-color').value;
|
||||
payload = {
|
||||
name,
|
||||
fallback_color: hexToRgbArray(fbHex),
|
||||
timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'api_input';
|
||||
} else {
|
||||
payload = {
|
||||
name,
|
||||
@@ -1013,6 +1045,25 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── API Input helpers ────────────────────────────────────────── */
|
||||
|
||||
function _showApiInputEndpoints(cssId) {
|
||||
const el = document.getElementById('css-editor-api-input-endpoints');
|
||||
const group = document.getElementById('css-editor-api-input-endpoints-group');
|
||||
if (!el || !group) return;
|
||||
if (!cssId) {
|
||||
el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`;
|
||||
return;
|
||||
}
|
||||
const base = `${window.location.origin}/api/v1`;
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsBase = `${wsProto}//${window.location.host}/api/v1`;
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:4px"><strong>REST POST:</strong><br>${base}/color-strip-sources/${cssId}/colors</div>
|
||||
<div><strong>WebSocket:</strong><br>${wsBase}/color-strip-sources/${cssId}/ws?token=<api_key></div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Clone ────────────────────────────────────────────────────── */
|
||||
|
||||
export async function cloneColorStrip(cssId) {
|
||||
|
||||
@@ -589,7 +589,7 @@
|
||||
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
||||
"color_strip.error.name_required": "Please enter a name",
|
||||
"color_strip.type": "Type:",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input.",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
|
||||
"color_strip.type.picture": "Picture Source",
|
||||
"color_strip.type.static": "Static Color",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
@@ -657,6 +657,15 @@
|
||||
"color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.",
|
||||
"color_strip.type.audio": "Audio Reactive",
|
||||
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
|
||||
"color_strip.type.api_input": "API Input",
|
||||
"color_strip.type.api_input.hint": "Receives raw LED color arrays from external clients via REST POST or WebSocket. Use this to integrate with custom software, home automation, or any system that can send HTTP requests.",
|
||||
"color_strip.api_input.fallback_color": "Fallback Color:",
|
||||
"color_strip.api_input.fallback_color.hint": "Color to display when no data has been received within the timeout period. LEDs will show this color on startup and after the connection is lost.",
|
||||
"color_strip.api_input.timeout": "Timeout (seconds):",
|
||||
"color_strip.api_input.timeout.hint": "How long to wait for new color data before reverting to the fallback color. Set to 0 to never time out.",
|
||||
"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.composite.layers": "Layers:",
|
||||
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
|
||||
"color_strip.composite.add_layer": "+ Add Layer",
|
||||
|
||||
@@ -589,7 +589,7 @@
|
||||
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
||||
"color_strip.error.name_required": "Введите название",
|
||||
"color_strip.type": "Тип:",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени.",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
@@ -657,6 +657,15 @@
|
||||
"color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.",
|
||||
"color_strip.type.audio": "Аудиореактив",
|
||||
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
|
||||
"color_strip.type.api_input": "API-ввод",
|
||||
"color_strip.type.api_input.hint": "Принимает массивы цветов LED от внешних клиентов через REST POST или WebSocket. Используйте для интеграции с собственным ПО, домашней автоматизацией или любой системой, способной отправлять HTTP-запросы.",
|
||||
"color_strip.api_input.fallback_color": "Цвет по умолчанию:",
|
||||
"color_strip.api_input.fallback_color.hint": "Цвет для отображения, когда данные не получены в течение периода ожидания. LED покажут этот цвет при запуске и после потери соединения.",
|
||||
"color_strip.api_input.timeout": "Тайм-аут (секунды):",
|
||||
"color_strip.api_input.timeout.hint": "Время ожидания новых данных о цветах перед возвратом к цвету по умолчанию. Установите 0, чтобы не использовать тайм-аут.",
|
||||
"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.composite.layers": "Слои:",
|
||||
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
||||
"color_strip.composite.add_layer": "+ Добавить слой",
|
||||
|
||||
Reference in New Issue
Block a user