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) {
|
||||
|
||||
Reference in New Issue
Block a user