Introduce ColorStripSource as first-class entity
Extracts color processing and calibration out of WledPictureTarget into a new PictureColorStripSource entity, enabling multiple LED targets to share one capture/processing pipeline. New entities & processing: - storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models - storage/color_strip_store.py: JSON-backed CRUD store (prefix css_) - core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread) - core/processing/color_strip_stream_manager.py: ref-counted shared stream manager Modified storage/processing: - WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval - Device model: calibration field removed - WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline - ProcessorManager: wires ColorStripStreamManager into TargetContext API layer: - New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test - Removed calibration endpoints from /devices - Updated /picture-targets CRUD for new target structure Frontend: - New color-strips.js module with CSS editor modal and card rendering - Calibration modal extended with CSS mode (css-id hidden field + device picker) - targets.js: Color Strip Sources section added to LED tab; target editor/card updated - app.js: imports and window globals for CSS + showCSSCalibration - en.json / ru.json: color_strip.* and targets.section.color_strips keys added Data migration runs at startup: existing WledPictureTargets are converted to reference a new PictureColorStripSource created from their old settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,8 +35,15 @@ class CalibrationModal extends Modal {
|
||||
|
||||
onForceClose() {
|
||||
closeTutorial();
|
||||
const deviceId = this.$('calibration-device-id').value;
|
||||
if (deviceId) clearTestMode(deviceId);
|
||||
if (_isCSS()) {
|
||||
_clearCSSTestMode();
|
||||
document.getElementById('calibration-css-id').value = '';
|
||||
const testGroup = document.getElementById('calibration-css-test-group');
|
||||
if (testGroup) testGroup.style.display = 'none';
|
||||
} else {
|
||||
const deviceId = this.$('calibration-device-id').value;
|
||||
if (deviceId) clearTestMode(deviceId);
|
||||
}
|
||||
if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect();
|
||||
const error = this.$('calibration-error');
|
||||
if (error) error.style.display = 'none';
|
||||
@@ -48,6 +55,33 @@ const calibModal = new CalibrationModal();
|
||||
let _dragRaf = null;
|
||||
let _previewRaf = null;
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function _isCSS() {
|
||||
return !!(document.getElementById('calibration-css-id')?.value);
|
||||
}
|
||||
|
||||
function _cssStateKey() {
|
||||
return `css_${document.getElementById('calibration-css-id').value}`;
|
||||
}
|
||||
|
||||
async function _clearCSSTestMode() {
|
||||
const cssId = document.getElementById('calibration-css-id')?.value;
|
||||
const stateKey = _cssStateKey();
|
||||
if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return;
|
||||
calibrationTestState[stateKey] = new Set();
|
||||
const testDeviceId = document.getElementById('calibration-test-device')?.value;
|
||||
if (!testDeviceId) return;
|
||||
try {
|
||||
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to clear CSS test mode:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Public API (exported names unchanged) ────────────────────── */
|
||||
|
||||
export async function showCalibration(deviceId) {
|
||||
@@ -148,6 +182,92 @@ export async function closeCalibrationModal() {
|
||||
calibModal.close();
|
||||
}
|
||||
|
||||
/* ── CSS Calibration support ──────────────────────────────────── */
|
||||
|
||||
export async function showCSSCalibration(cssId) {
|
||||
try {
|
||||
const [cssResp, devicesResp] = await Promise.all([
|
||||
fetchWithAuth(`/color-strip-sources/${cssId}`),
|
||||
fetchWithAuth('/devices'),
|
||||
]);
|
||||
|
||||
if (!cssResp.ok) { showToast('Failed to load color strip source', 'error'); return; }
|
||||
const source = await cssResp.json();
|
||||
const calibration = source.calibration || {};
|
||||
|
||||
// Set CSS mode — clear device-id, set css-id
|
||||
document.getElementById('calibration-device-id').value = '';
|
||||
document.getElementById('calibration-css-id').value = cssId;
|
||||
|
||||
// Populate device picker for edge test
|
||||
const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : [];
|
||||
const testDeviceSelect = document.getElementById('calibration-test-device');
|
||||
testDeviceSelect.innerHTML = '';
|
||||
devices.forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.id;
|
||||
opt.textContent = d.name;
|
||||
testDeviceSelect.appendChild(opt);
|
||||
});
|
||||
const testGroup = document.getElementById('calibration-css-test-group');
|
||||
testGroup.style.display = devices.length ? '' : 'none';
|
||||
|
||||
// Populate calibration fields
|
||||
const preview = document.querySelector('.calibration-preview');
|
||||
preview.style.aspectRatio = '';
|
||||
document.getElementById('cal-device-led-count-inline').textContent = '—';
|
||||
|
||||
document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left';
|
||||
document.getElementById('cal-layout').value = calibration.layout || 'clockwise';
|
||||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||||
|
||||
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
||||
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
||||
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
||||
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
||||
|
||||
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
||||
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
||||
updateOffsetSkipLock();
|
||||
|
||||
document.getElementById('cal-border-width').value = calibration.border_width || 10;
|
||||
|
||||
window.edgeSpans = {
|
||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
|
||||
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
|
||||
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
|
||||
};
|
||||
|
||||
calibrationTestState[_cssStateKey()] = new Set();
|
||||
|
||||
updateCalibrationPreview();
|
||||
|
||||
calibModal.snapshot();
|
||||
calibModal.open();
|
||||
|
||||
initSpanDrag();
|
||||
requestAnimationFrame(() => renderCalibrationCanvas());
|
||||
|
||||
if (!window._calibrationResizeObserver) {
|
||||
window._calibrationResizeObserver = new ResizeObserver(() => {
|
||||
if (window._calibrationResizeRaf) return;
|
||||
window._calibrationResizeRaf = requestAnimationFrame(() => {
|
||||
window._calibrationResizeRaf = null;
|
||||
updateSpanBars();
|
||||
renderCalibrationCanvas();
|
||||
});
|
||||
});
|
||||
}
|
||||
window._calibrationResizeObserver.observe(preview);
|
||||
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load CSS calibration:', error);
|
||||
showToast('Failed to load calibration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function updateOffsetSkipLock() {
|
||||
const offsetEl = document.getElementById('cal-offset');
|
||||
const skipStartEl = document.getElementById('cal-skip-start');
|
||||
@@ -165,8 +285,9 @@ export function updateCalibrationPreview() {
|
||||
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
|
||||
parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
const totalEl = document.querySelector('.preview-screen-total');
|
||||
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
||||
const mismatch = total !== deviceCount;
|
||||
const inCSS = _isCSS();
|
||||
const deviceCount = inCSS ? null : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
||||
const mismatch = !inCSS && total !== deviceCount;
|
||||
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
|
||||
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
|
||||
|
||||
@@ -186,7 +307,8 @@ export function updateCalibrationPreview() {
|
||||
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
||||
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const activeEdges = calibrationTestState[deviceId] || new Set();
|
||||
const stateKey = _isCSS() ? _cssStateKey() : deviceId;
|
||||
const activeEdges = calibrationTestState[stateKey] || new Set();
|
||||
|
||||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
||||
@@ -612,9 +734,42 @@ export async function toggleTestEdge(edge) {
|
||||
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||||
if (edgeLeds === 0) return;
|
||||
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const error = document.getElementById('calibration-error');
|
||||
|
||||
if (_isCSS()) {
|
||||
const cssId = document.getElementById('calibration-css-id').value;
|
||||
const testDeviceId = document.getElementById('calibration-test-device')?.value;
|
||||
if (!testDeviceId) return;
|
||||
|
||||
const stateKey = _cssStateKey();
|
||||
if (!calibrationTestState[stateKey]) calibrationTestState[stateKey] = new Set();
|
||||
if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge);
|
||||
else calibrationTestState[stateKey].add(edge);
|
||||
|
||||
const edges = {};
|
||||
calibrationTestState[stateKey].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; });
|
||||
updateCalibrationPreview();
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ device_id: testDeviceId, edges }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Test failed: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle CSS test edge:', err);
|
||||
error.textContent = 'Failed to toggle test edge';
|
||||
error.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set();
|
||||
|
||||
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
|
||||
@@ -658,11 +813,16 @@ async function clearTestMode(deviceId) {
|
||||
}
|
||||
|
||||
export async function saveCalibration() {
|
||||
const cssMode = _isCSS();
|
||||
const deviceId = document.getElementById('calibration-device-id').value;
|
||||
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
|
||||
const cssId = document.getElementById('calibration-css-id').value;
|
||||
const error = document.getElementById('calibration-error');
|
||||
|
||||
await clearTestMode(deviceId);
|
||||
if (cssMode) {
|
||||
await _clearCSSTestMode();
|
||||
} else {
|
||||
await clearTestMode(deviceId);
|
||||
}
|
||||
updateCalibrationPreview();
|
||||
|
||||
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
||||
@@ -671,10 +831,13 @@ export async function saveCalibration() {
|
||||
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
||||
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||||
|
||||
if (total !== deviceLedCount) {
|
||||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
if (!cssMode) {
|
||||
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
|
||||
if (total !== deviceLedCount) {
|
||||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const startPosition = document.getElementById('cal-start-position').value;
|
||||
@@ -695,14 +858,26 @@ export async function saveCalibration() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(calibration)
|
||||
});
|
||||
let response;
|
||||
if (cssMode) {
|
||||
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ calibration }),
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(calibration),
|
||||
});
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast('Calibration saved', 'success');
|
||||
calibModal.forceClose();
|
||||
window.loadDevices();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
} else {
|
||||
window.loadDevices();
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Failed to save: ${errorData.detail}`;
|
||||
|
||||
218
server/src/wled_controller/static/js/features/color-strips.js
Normal file
218
server/src/wled_controller/static/js/features/color-strips.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Color Strip Sources — CRUD, card rendering, calibration bridge.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
|
||||
class CSSEditorModal extends Modal {
|
||||
constructor() {
|
||||
super('css-editor-modal');
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('css-editor-name').value,
|
||||
picture_source: document.getElementById('css-editor-picture-source').value,
|
||||
fps: document.getElementById('css-editor-fps').value,
|
||||
interpolation: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: document.getElementById('css-editor-smoothing').value,
|
||||
brightness: document.getElementById('css-editor-brightness').value,
|
||||
saturation: document.getElementById('css-editor-saturation').value,
|
||||
gamma: document.getElementById('css-editor-gamma').value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cssEditorModal = new CSSEditorModal();
|
||||
|
||||
/* ── Card ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function createColorStripCard(source, pictureSourceMap) {
|
||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||
? pictureSourceMap[source.picture_source_id].name
|
||||
: source.picture_source_id || '—';
|
||||
const cal = source.calibration || {};
|
||||
const ledCount = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
|
||||
|
||||
return `
|
||||
<div class="card" data-css-id="${source.id}">
|
||||
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
🎞️ ${escapeHtml(source.name)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
|
||||
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Editor open/close ────────────────────────────────────────── */
|
||||
|
||||
export async function showCSSEditor(cssId = null) {
|
||||
try {
|
||||
const sourcesResp = await fetchWithAuth('/picture-sources');
|
||||
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
|
||||
|
||||
const sourceSelect = document.getElementById('css-editor-picture-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.dataset.name = s.name;
|
||||
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (cssId) {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load color strip source');
|
||||
const css = await resp.json();
|
||||
|
||||
document.getElementById('css-editor-id').value = css.id;
|
||||
document.getElementById('css-editor-name').value = css.name;
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
const fps = css.fps ?? 30;
|
||||
document.getElementById('css-editor-fps').value = fps;
|
||||
document.getElementById('css-editor-fps-value').textContent = fps;
|
||||
|
||||
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
|
||||
|
||||
const smoothing = css.smoothing ?? 0.3;
|
||||
document.getElementById('css-editor-smoothing').value = smoothing;
|
||||
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
|
||||
|
||||
const brightness = css.brightness ?? 1.0;
|
||||
document.getElementById('css-editor-brightness').value = brightness;
|
||||
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
|
||||
|
||||
const saturation = css.saturation ?? 1.0;
|
||||
document.getElementById('css-editor-saturation').value = saturation;
|
||||
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2);
|
||||
|
||||
const gamma = css.gamma ?? 1.0;
|
||||
document.getElementById('css-editor-gamma').value = gamma;
|
||||
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
|
||||
|
||||
document.getElementById('css-editor-title').textContent = t('color_strip.edit');
|
||||
} else {
|
||||
document.getElementById('css-editor-id').value = '';
|
||||
document.getElementById('css-editor-name').value = '';
|
||||
document.getElementById('css-editor-fps').value = 30;
|
||||
document.getElementById('css-editor-fps-value').textContent = '30';
|
||||
document.getElementById('css-editor-interpolation').value = 'average';
|
||||
document.getElementById('css-editor-smoothing').value = 0.3;
|
||||
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
|
||||
document.getElementById('css-editor-brightness').value = 1.0;
|
||||
document.getElementById('css-editor-brightness-value').textContent = '1.00';
|
||||
document.getElementById('css-editor-saturation').value = 1.0;
|
||||
document.getElementById('css-editor-saturation-value').textContent = '1.00';
|
||||
document.getElementById('css-editor-gamma').value = 1.0;
|
||||
document.getElementById('css-editor-gamma-value').textContent = '1.00';
|
||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||
}
|
||||
|
||||
document.getElementById('css-editor-error').style.display = 'none';
|
||||
cssEditorModal.snapshot();
|
||||
cssEditorModal.open();
|
||||
setTimeout(() => document.getElementById('css-editor-name').focus(), 100);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to open CSS editor:', error);
|
||||
showToast('Failed to open color strip editor', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeCSSEditorModal() { cssEditorModal.close(); }
|
||||
export function forceCSSEditorClose() { cssEditorModal.forceClose(); }
|
||||
export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
|
||||
|
||||
/* ── Save ─────────────────────────────────────────────────────── */
|
||||
|
||||
export async function saveCSSEditor() {
|
||||
const cssId = document.getElementById('css-editor-id').value;
|
||||
const name = document.getElementById('css-editor-name').value.trim();
|
||||
|
||||
if (!name) {
|
||||
cssEditorModal.showError(t('color_strip.error.name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
picture_source_id: document.getElementById('css-editor-picture-source').value,
|
||||
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
|
||||
interpolation_mode: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
|
||||
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
||||
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
|
||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cssId) {
|
||||
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.source_type = 'picture';
|
||||
response = await fetchWithAuth('/color-strip-sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
}
|
||||
|
||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||
cssEditorModal.forceClose();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Error saving CSS:', error);
|
||||
cssEditorModal.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Delete ───────────────────────────────────────────────────── */
|
||||
|
||||
export async function deleteColorStrip(cssId) {
|
||||
const confirmed = await showConfirm(t('color_strip.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('color_strip.deleted'), 'success');
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
const msg = err.detail || 'Failed to delete';
|
||||
const isReferenced = response.status === 409;
|
||||
showToast(isReferenced ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast('Failed to delete color strip source', 'error');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
|
||||
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||
import { createColorStripCard } from './color-strips.js';
|
||||
|
||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
@@ -30,10 +31,7 @@ class TargetEditorModal extends Modal {
|
||||
return {
|
||||
name: document.getElementById('target-editor-name').value,
|
||||
device: document.getElementById('target-editor-device').value,
|
||||
source: document.getElementById('target-editor-source').value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
interpolation: document.getElementById('target-editor-interpolation').value,
|
||||
smoothing: document.getElementById('target-editor-smoothing').value,
|
||||
css: document.getElementById('target-editor-css').value,
|
||||
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
||||
};
|
||||
}
|
||||
@@ -47,11 +45,11 @@ function _autoGenerateTargetName() {
|
||||
if (_targetNameManuallyEdited) return;
|
||||
if (document.getElementById('target-editor-id').value) return;
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const sourceSelect = document.getElementById('target-editor-source');
|
||||
const cssSelect = document.getElementById('target-editor-css');
|
||||
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!deviceName || !sourceName) return;
|
||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`;
|
||||
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
|
||||
if (!deviceName || !cssName) return;
|
||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
||||
}
|
||||
|
||||
function _updateStandbyVisibility() {
|
||||
@@ -64,14 +62,14 @@ function _updateStandbyVisibility() {
|
||||
|
||||
export async function showTargetEditor(targetId = null) {
|
||||
try {
|
||||
// Load devices and sources for dropdowns
|
||||
const [devicesResp, sourcesResp] = await Promise.all([
|
||||
// Load devices and CSS sources for dropdowns
|
||||
const [devicesResp, cssResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
fetchWithAuth('/color-strip-sources'),
|
||||
]);
|
||||
|
||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
|
||||
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
||||
set_targetEditorDevices(devices);
|
||||
|
||||
// Populate device select
|
||||
@@ -87,16 +85,15 @@ export async function showTargetEditor(targetId = null) {
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('target-editor-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
// Populate color strip source select
|
||||
const cssSelect = document.getElementById('target-editor-css');
|
||||
cssSelect.innerHTML = '';
|
||||
cssSources.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.dataset.name = s.name;
|
||||
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
opt.textContent = `🎞️ ${s.name}`;
|
||||
cssSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (targetId) {
|
||||
@@ -108,24 +105,14 @@ export async function showTargetEditor(targetId = null) {
|
||||
document.getElementById('target-editor-id').value = target.id;
|
||||
document.getElementById('target-editor-name').value = target.name;
|
||||
deviceSelect.value = target.device_id || '';
|
||||
sourceSelect.value = target.picture_source_id || '';
|
||||
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
|
||||
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
|
||||
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
|
||||
document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
|
||||
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
|
||||
cssSelect.value = target.color_strip_source_id || '';
|
||||
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
|
||||
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
|
||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||
} else {
|
||||
// Creating new target — first option is selected by default
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = '';
|
||||
document.getElementById('target-editor-fps').value = 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||
document.getElementById('target-editor-interpolation').value = 'average';
|
||||
document.getElementById('target-editor-smoothing').value = 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
|
||||
document.getElementById('target-editor-standby-interval').value = 1.0;
|
||||
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
@@ -135,7 +122,7 @@ export async function showTargetEditor(targetId = null) {
|
||||
_targetNameManuallyEdited = !!targetId;
|
||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
|
||||
sourceSelect.onchange = () => _autoGenerateTargetName();
|
||||
cssSelect.onchange = () => _autoGenerateTargetName();
|
||||
if (!targetId) _autoGenerateTargetName();
|
||||
|
||||
// Show/hide standby interval based on selected device capabilities
|
||||
@@ -168,10 +155,7 @@ export async function saveTargetEditor() {
|
||||
const targetId = document.getElementById('target-editor-id').value;
|
||||
const name = document.getElementById('target-editor-name').value.trim();
|
||||
const deviceId = document.getElementById('target-editor-device').value;
|
||||
const sourceId = document.getElementById('target-editor-source').value;
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
const interpolation = document.getElementById('target-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
|
||||
const cssId = document.getElementById('target-editor-css').value;
|
||||
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
||||
|
||||
if (!name) {
|
||||
@@ -182,13 +166,8 @@ export async function saveTargetEditor() {
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
picture_source_id: sourceId,
|
||||
settings: {
|
||||
fps: fps,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing: smoothing,
|
||||
standby_interval: standbyInterval,
|
||||
},
|
||||
color_strip_source_id: cssId,
|
||||
standby_interval: standbyInterval,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -243,10 +222,11 @@ export async function loadTargetsTab() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
// Fetch devices, targets, sources, and pattern templates in parallel
|
||||
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
|
||||
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
|
||||
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
|
||||
fetchWithAuth('/devices'),
|
||||
fetchWithAuth('/picture-targets'),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
]);
|
||||
@@ -257,10 +237,16 @@ export async function loadTargetsTab() {
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
|
||||
let sourceMap = {};
|
||||
if (sourcesResp && sourcesResp.ok) {
|
||||
const srcData = await sourcesResp.json();
|
||||
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
||||
let colorStripSourceMap = {};
|
||||
if (cssResp && cssResp.ok) {
|
||||
const cssData = await cssResp.json();
|
||||
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
|
||||
}
|
||||
|
||||
let pictureSourceMap = {};
|
||||
if (psResp && psResp.ok) {
|
||||
const psData = await psResp.json();
|
||||
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
|
||||
}
|
||||
|
||||
let patternTemplates = [];
|
||||
@@ -320,7 +306,7 @@ export async function loadTargetsTab() {
|
||||
if (activeSubTab === 'wled') activeSubTab = 'led';
|
||||
|
||||
const subTabs = [
|
||||
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
|
||||
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
|
||||
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
||||
];
|
||||
|
||||
@@ -331,7 +317,7 @@ export async function loadTargetsTab() {
|
||||
// Use window.createPatternTemplateCard to avoid circular import
|
||||
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
||||
|
||||
// LED panel: devices section + targets section
|
||||
// LED panel: devices section + color strip sources section + targets section
|
||||
const ledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
||||
<div class="subtab-section">
|
||||
@@ -343,10 +329,19 @@ export async function loadTargetsTab() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
|
||||
<div class="devices-grid">
|
||||
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showCSSEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
||||
<div class="devices-grid">
|
||||
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
@@ -360,7 +355,7 @@ export async function loadTargetsTab() {
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
||||
<div class="devices-grid">
|
||||
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
|
||||
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showKCEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
@@ -422,17 +417,16 @@ export async function loadTargetsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTargetCard(target, deviceMap, sourceMap) {
|
||||
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
const settings = target.settings || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
|
||||
const device = deviceMap[target.device_id];
|
||||
const source = sourceMap[target.picture_source_id];
|
||||
const css = colorStripSourceMap[target.color_strip_source_id];
|
||||
const deviceName = device ? device.name : (target.device_id || 'No device');
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
|
||||
|
||||
// Health info from target state (forwarded from device)
|
||||
const devOnline = state.device_online || false;
|
||||
@@ -455,8 +449,7 @@ export function createTargetCard(target, deviceMap, sourceMap) {
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${settings.fps || 30}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
${isProcessing ? `
|
||||
|
||||
Reference in New Issue
Block a user