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:
2026-02-20 15:49:47 +03:00
parent c4e0257389
commit 7de3546b14
33 changed files with 2325 additions and 814 deletions

View File

@@ -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}`;