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