Add LED device abstraction layer for multi-controller support
Introduce abstract LEDClient base class with factory pattern so new LED controller types can plug in alongside WLED. ProcessorManager is now fully type-agnostic — all device-specific logic (health checks, state snapshot/restore, fast send) lives behind the LEDClient interface. - New led_client.py: LEDClient ABC, DeviceHealth, factory functions - WLEDClient inherits LEDClient, encapsulates WLED health checks and state management - device_type field on Device storage model (defaults to "wled") - Rename target_type "wled" → "led" with backward-compat migration - Frontend: "WLED" tab → "LED", device type badge, type selector in add-device modal, device type shown in target device dropdown - All wled_* API fields renamed to device_* for generic naming Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -614,33 +614,33 @@ async function loadDevices() {
|
||||
function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
|
||||
// WLED device health indicator
|
||||
const wledOnline = state.wled_online || false;
|
||||
const wledLatency = state.wled_latency_ms;
|
||||
const wledName = state.wled_name;
|
||||
const wledVersion = state.wled_version;
|
||||
const wledLastChecked = state.wled_last_checked;
|
||||
// Device health indicator
|
||||
const devOnline = state.device_online || false;
|
||||
const devLatency = state.device_latency_ms;
|
||||
const devName = state.device_name;
|
||||
const devVersion = state.device_version;
|
||||
const devLastChecked = state.device_last_checked;
|
||||
|
||||
let healthClass, healthTitle, healthLabel;
|
||||
if (wledLastChecked === null || wledLastChecked === undefined) {
|
||||
if (devLastChecked === null || devLastChecked === undefined) {
|
||||
healthClass = 'health-unknown';
|
||||
healthTitle = t('device.health.checking');
|
||||
healthLabel = '';
|
||||
} else if (wledOnline) {
|
||||
} else if (devOnline) {
|
||||
healthClass = 'health-online';
|
||||
healthTitle = `${t('device.health.online')}`;
|
||||
if (wledName) healthTitle += ` - ${wledName}`;
|
||||
if (wledVersion) healthTitle += ` v${wledVersion}`;
|
||||
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
|
||||
if (devName) healthTitle += ` - ${devName}`;
|
||||
if (devVersion) healthTitle += ` v${devVersion}`;
|
||||
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
|
||||
healthLabel = '';
|
||||
} else {
|
||||
healthClass = 'health-offline';
|
||||
healthTitle = t('device.health.offline');
|
||||
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
|
||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||||
}
|
||||
|
||||
const ledCount = state.wled_led_count || device.led_count;
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
|
||||
return `
|
||||
<div class="card" data-device-id="${device.id}">
|
||||
@@ -654,9 +654,10 @@ function createDeviceCard(device) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
||||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
||||
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.wled_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
</div>
|
||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
@@ -903,7 +904,8 @@ async function handleAddDevice(event) {
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { name, url };
|
||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||
const body = { name, url, device_type: deviceType };
|
||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||
if (lastTemplateId) {
|
||||
body.capture_template_id = lastTemplateId;
|
||||
@@ -1060,6 +1062,10 @@ async function showCalibration(deviceId) {
|
||||
// Set skip LEDs
|
||||
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
||||
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
||||
updateOffsetSkipLock();
|
||||
|
||||
// Set border width
|
||||
document.getElementById('cal-border-width').value = calibration.border_width || 10;
|
||||
|
||||
// Initialize edge spans
|
||||
window.edgeSpans = {
|
||||
@@ -1081,6 +1087,7 @@ async function showCalibration(deviceId) {
|
||||
spans: JSON.stringify(window.edgeSpans),
|
||||
skip_start: String(calibration.skip_leds_start || 0),
|
||||
skip_end: String(calibration.skip_leds_end || 0),
|
||||
border_width: String(calibration.border_width || 10),
|
||||
};
|
||||
|
||||
// Initialize test mode state for this device
|
||||
@@ -1131,7 +1138,8 @@ function isCalibrationDirty() {
|
||||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
|
||||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
|
||||
document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
|
||||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end
|
||||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ||
|
||||
document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1160,6 +1168,17 @@ async function closeCalibrationModal() {
|
||||
forceCloseCalibrationModal();
|
||||
}
|
||||
|
||||
function updateOffsetSkipLock() {
|
||||
const offsetEl = document.getElementById('cal-offset');
|
||||
const skipStartEl = document.getElementById('cal-skip-start');
|
||||
const skipEndEl = document.getElementById('cal-skip-end');
|
||||
const hasOffset = parseInt(offsetEl.value || 0) > 0;
|
||||
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
|
||||
skipStartEl.disabled = hasOffset;
|
||||
skipEndEl.disabled = hasOffset;
|
||||
offsetEl.disabled = hasSkip;
|
||||
}
|
||||
|
||||
function updateCalibrationPreview() {
|
||||
// Calculate total from edge inputs
|
||||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||||
@@ -1774,6 +1793,7 @@ async function saveCalibration() {
|
||||
span_left_end: spans.left?.end ?? 1,
|
||||
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
|
||||
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
|
||||
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1935,11 +1955,13 @@ const calibrationTutorialSteps = [
|
||||
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
|
||||
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
|
||||
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
|
||||
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
|
||||
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
|
||||
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
|
||||
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
|
||||
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds', position: 'top' }
|
||||
{ selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
|
||||
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
|
||||
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
|
||||
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
||||
];
|
||||
|
||||
const deviceTutorialSteps = [
|
||||
@@ -3820,13 +3842,14 @@ async function showTargetEditor(targetId = null) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.id;
|
||||
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
|
||||
opt.textContent = `${d.name}${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||
const devType = (d.device_type || 'wled').toUpperCase();
|
||||
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('target-editor-source');
|
||||
sourceSelect.innerHTML = '<option value="">-- No source --</option>';
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
@@ -3847,7 +3870,6 @@ async function showTargetEditor(targetId = null) {
|
||||
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-border-width').value = target.settings?.border_width ?? 10;
|
||||
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;
|
||||
@@ -3862,7 +3884,6 @@ async function showTargetEditor(targetId = null) {
|
||||
sourceSelect.value = '';
|
||||
document.getElementById('target-editor-fps').value = 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||
document.getElementById('target-editor-border-width').value = 10;
|
||||
document.getElementById('target-editor-interpolation').value = 'average';
|
||||
document.getElementById('target-editor-smoothing').value = 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
|
||||
@@ -3876,7 +3897,6 @@ async function showTargetEditor(targetId = null) {
|
||||
device: deviceSelect.value,
|
||||
source: sourceSelect.value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
border_width: document.getElementById('target-editor-border-width').value,
|
||||
interpolation: document.getElementById('target-editor-interpolation').value,
|
||||
smoothing: document.getElementById('target-editor-smoothing').value,
|
||||
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
||||
@@ -3901,7 +3921,6 @@ function isTargetEditorDirty() {
|
||||
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
|
||||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
|
||||
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
|
||||
document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width ||
|
||||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
|
||||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
|
||||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
|
||||
@@ -3929,7 +3948,6 @@ async function saveTargetEditor() {
|
||||
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 borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10;
|
||||
const interpolation = document.getElementById('target-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
|
||||
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
||||
@@ -3947,7 +3965,6 @@ async function saveTargetEditor() {
|
||||
picture_source_id: sourceId,
|
||||
settings: {
|
||||
fps: fps,
|
||||
border_width: borderWidth,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing: smoothing,
|
||||
standby_interval: standbyInterval,
|
||||
@@ -3963,7 +3980,7 @@ async function saveTargetEditor() {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'wled';
|
||||
payload.target_type = 'led';
|
||||
response = await fetch(`${API_BASE}/picture-targets`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
@@ -4083,14 +4100,16 @@ async function loadTargetsTab() {
|
||||
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
||||
|
||||
// Group by type
|
||||
const wledDevices = devicesWithState;
|
||||
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
|
||||
const ledDevices = devicesWithState;
|
||||
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
||||
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
||||
|
||||
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
|
||||
// Backward compat: map stored "wled" sub-tab to "led"
|
||||
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
||||
if (activeSubTab === 'wled') activeSubTab = 'led';
|
||||
|
||||
const subTabs = [
|
||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
||||
{ key: 'led', icon: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
|
||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
||||
];
|
||||
|
||||
@@ -4098,13 +4117,13 @@ async function loadTargetsTab() {
|
||||
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
||||
).join('')}</div>`;
|
||||
|
||||
// WLED panel: devices section + targets section
|
||||
const wledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled">
|
||||
// LED panel: devices 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">
|
||||
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
|
||||
<div class="devices-grid">
|
||||
${wledDevices.map(device => createDeviceCard(device)).join('')}
|
||||
${ledDevices.map(device => createDeviceCard(device)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddDevice()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
@@ -4113,7 +4132,7 @@ async function loadTargetsTab() {
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
||||
<div class="devices-grid">
|
||||
${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>
|
||||
@@ -4144,9 +4163,9 @@ async function loadTargetsTab() {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + wledPanel + kcPanel;
|
||||
container.innerHTML = tabBar + ledPanel + kcPanel;
|
||||
|
||||
// Attach event listeners and fetch WLED brightness for device cards
|
||||
// Attach event listeners and fetch brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
@@ -4186,12 +4205,12 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||
|
||||
// Health info from target state (forwarded from device)
|
||||
const wledOnline = state.wled_online || false;
|
||||
const devOnline = state.device_online || false;
|
||||
let healthClass = 'health-unknown';
|
||||
let healthTitle = '';
|
||||
if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) {
|
||||
healthClass = wledOnline ? 'health-online' : 'health-offline';
|
||||
healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline');
|
||||
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
|
||||
healthClass = devOnline ? 'health-online' : 'health-offline';
|
||||
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
|
||||
}
|
||||
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user