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}`;
|
||||
|
||||
Reference in New Issue
Block a user