Add Adalight serial LED device support with per-type discovery and capability-based UI
Implements the second device provider (Adalight) for Arduino-based serial LED controllers, validating the LEDDeviceProvider abstraction. Adds serial port auto-discovery, per-type discovery caching with lazy-load, capability-driven UI (brightness control, manual LED count, standby), and serial port combobox in both Add Device and General Settings modals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -649,7 +649,7 @@ function createDeviceCard(device) {
|
||||
<div class="card-title">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
${device.name || device.id}
|
||||
${device.url ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : ''}
|
||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||
${healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,6 +659,7 @@ function createDeviceCard(device) {
|
||||
${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>
|
||||
${(device.capabilities || []).includes('brightness_control') ? `
|
||||
<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"
|
||||
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
|
||||
@@ -666,7 +667,7 @@ function createDeviceCard(device) {
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
⚙️
|
||||
@@ -729,17 +730,46 @@ async function showSettings(deviceId) {
|
||||
}
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const isAdalight = device.device_type === 'adalight';
|
||||
|
||||
// Populate fields
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
document.getElementById('settings-health-interval').value = 30;
|
||||
|
||||
// Toggle URL vs serial port field
|
||||
const urlGroup = document.getElementById('settings-url-group');
|
||||
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||
if (isAdalight) {
|
||||
urlGroup.style.display = 'none';
|
||||
document.getElementById('settings-device-url').removeAttribute('required');
|
||||
serialGroup.style.display = '';
|
||||
// Populate serial port dropdown via discovery
|
||||
_populateSettingsSerialPorts(device.url);
|
||||
} else {
|
||||
urlGroup.style.display = '';
|
||||
document.getElementById('settings-device-url').setAttribute('required', '');
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
serialGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show LED count field for devices with manual_led_count capability
|
||||
const caps = device.capabilities || [];
|
||||
const ledCountGroup = document.getElementById('settings-led-count-group');
|
||||
if (caps.includes('manual_led_count')) {
|
||||
ledCountGroup.style.display = '';
|
||||
document.getElementById('settings-led-count').value = device.led_count || '';
|
||||
} else {
|
||||
ledCountGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
// Snapshot initial values for dirty checking
|
||||
settingsInitialValues = {
|
||||
name: device.name,
|
||||
url: device.url,
|
||||
led_count: String(device.led_count || ''),
|
||||
device_type: device.device_type,
|
||||
capabilities: caps,
|
||||
state_check_interval: '30',
|
||||
};
|
||||
|
||||
@@ -759,11 +789,21 @@ async function showSettings(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
function _getSettingsUrl() {
|
||||
if (settingsInitialValues.device_type === 'adalight') {
|
||||
return document.getElementById('settings-serial-port').value;
|
||||
}
|
||||
return document.getElementById('settings-device-url').value.trim();
|
||||
}
|
||||
|
||||
function isSettingsDirty() {
|
||||
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
|
||||
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
|
||||
return (
|
||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||||
_getSettingsUrl() !== settingsInitialValues.url ||
|
||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
||||
ledCountDirty
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,7 +827,7 @@ async function closeDeviceSettingsModal() {
|
||||
async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
const name = document.getElementById('settings-device-name').value.trim();
|
||||
const url = document.getElementById('settings-device-url').value.trim();
|
||||
const url = _getSettingsUrl();
|
||||
const error = document.getElementById('settings-error');
|
||||
|
||||
// Validation
|
||||
@@ -798,11 +838,16 @@ async function saveDeviceSettings() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Update device info (name, url)
|
||||
// Update device info (name, url, optionally led_count)
|
||||
const body = { name, url };
|
||||
const ledCountInput = document.getElementById('settings-led-count');
|
||||
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
}
|
||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name, url })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (deviceResponse.status === 401) {
|
||||
@@ -874,6 +919,174 @@ async function fetchDeviceBrightness(deviceId) {
|
||||
|
||||
// Add device modal
|
||||
let _discoveryScanRunning = false;
|
||||
let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
|
||||
|
||||
function onDeviceTypeChanged() {
|
||||
const deviceType = document.getElementById('device-type').value;
|
||||
const urlGroup = document.getElementById('device-url-group');
|
||||
const urlInput = document.getElementById('device-url');
|
||||
const serialGroup = document.getElementById('device-serial-port-group');
|
||||
const serialSelect = document.getElementById('device-serial-port');
|
||||
const ledCountGroup = document.getElementById('device-led-count-group');
|
||||
const discoverySection = document.getElementById('discovery-section');
|
||||
|
||||
if (deviceType === 'adalight') {
|
||||
urlGroup.style.display = 'none';
|
||||
urlInput.removeAttribute('required');
|
||||
serialGroup.style.display = '';
|
||||
serialSelect.setAttribute('required', '');
|
||||
ledCountGroup.style.display = '';
|
||||
// Hide discovery list — serial port dropdown replaces it
|
||||
if (discoverySection) discoverySection.style.display = 'none';
|
||||
// Populate from cache or show placeholder (lazy-load on focus)
|
||||
if (deviceType in _discoveryCache) {
|
||||
_populateSerialPortDropdown(_discoveryCache[deviceType]);
|
||||
} else {
|
||||
serialSelect.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...';
|
||||
opt.disabled = true;
|
||||
serialSelect.appendChild(opt);
|
||||
}
|
||||
} else {
|
||||
urlGroup.style.display = '';
|
||||
urlInput.setAttribute('required', '');
|
||||
serialGroup.style.display = 'none';
|
||||
serialSelect.removeAttribute('required');
|
||||
ledCountGroup.style.display = 'none';
|
||||
// Show cached results or trigger scan for WLED
|
||||
if (deviceType in _discoveryCache) {
|
||||
_renderDiscoveryList();
|
||||
} else {
|
||||
scanForDevices();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDiscoveryList() {
|
||||
const selectedType = document.getElementById('device-type').value;
|
||||
const devices = _discoveryCache[selectedType];
|
||||
|
||||
// Adalight: populate serial port dropdown instead of discovery list
|
||||
if (selectedType === 'adalight') {
|
||||
_populateSerialPortDropdown(devices || []);
|
||||
return;
|
||||
}
|
||||
|
||||
// WLED and others: render discovery list cards
|
||||
const list = document.getElementById('discovery-list');
|
||||
const empty = document.getElementById('discovery-empty');
|
||||
const section = document.getElementById('discovery-section');
|
||||
if (!list || !section) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!devices) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
|
||||
if (devices.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
devices.forEach(device => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
||||
const meta = [device.ip];
|
||||
if (device.led_count) meta.push(device.led_count + ' LEDs');
|
||||
if (device.version) meta.push('v' + device.version);
|
||||
card.innerHTML = `
|
||||
<div class="discovery-item-info">
|
||||
<strong>${escapeHtml(device.name)}</strong>
|
||||
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
|
||||
</div>
|
||||
${device.already_added
|
||||
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
|
||||
: ''}
|
||||
`;
|
||||
if (!device.already_added) {
|
||||
card.onclick = () => selectDiscoveredDevice(device);
|
||||
}
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function _populateSerialPortDropdown(devices) {
|
||||
const select = document.getElementById('device-serial-port');
|
||||
select.innerHTML = '';
|
||||
|
||||
if (devices.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = t('device.serial_port.none') || 'No serial ports found';
|
||||
opt.disabled = true;
|
||||
select.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach(device => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.url;
|
||||
opt.textContent = device.name;
|
||||
if (device.already_added) {
|
||||
opt.textContent += ' (' + t('device.scan.already_added') + ')';
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function onSerialPortFocus() {
|
||||
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
||||
if (!('adalight' in _discoveryCache)) {
|
||||
scanForDevices('adalight');
|
||||
}
|
||||
}
|
||||
|
||||
async function _populateSettingsSerialPorts(currentUrl) {
|
||||
const select = document.getElementById('settings-serial-port');
|
||||
select.innerHTML = '';
|
||||
// Show loading placeholder
|
||||
const loadingOpt = document.createElement('option');
|
||||
loadingOpt.value = currentUrl;
|
||||
loadingOpt.textContent = currentUrl + ' ⏳';
|
||||
select.appendChild(loadingOpt);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const devices = data.devices || [];
|
||||
|
||||
select.innerHTML = '';
|
||||
// Always include current port even if not discovered
|
||||
let currentFound = false;
|
||||
devices.forEach(device => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.url;
|
||||
opt.textContent = device.name;
|
||||
if (device.url === currentUrl) currentFound = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (!currentFound) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = currentUrl;
|
||||
opt.textContent = currentUrl;
|
||||
select.insertBefore(opt, select.firstChild);
|
||||
}
|
||||
select.value = currentUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to discover serial ports:', err);
|
||||
// Keep the current URL as fallback
|
||||
}
|
||||
}
|
||||
|
||||
function showAddDevice() {
|
||||
const modal = document.getElementById('add-device-modal');
|
||||
@@ -881,6 +1094,7 @@ function showAddDevice() {
|
||||
const error = document.getElementById('add-device-error');
|
||||
form.reset();
|
||||
error.style.display = 'none';
|
||||
_discoveryCache = {};
|
||||
// Reset discovery section
|
||||
const section = document.getElementById('discovery-section');
|
||||
if (section) {
|
||||
@@ -889,13 +1103,14 @@ function showAddDevice() {
|
||||
document.getElementById('discovery-empty').style.display = 'none';
|
||||
document.getElementById('discovery-loading').style.display = 'none';
|
||||
}
|
||||
// Reset serial port dropdown
|
||||
document.getElementById('device-serial-port').innerHTML = '';
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
onDeviceTypeChanged();
|
||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||
// Auto-start discovery on open
|
||||
scanForDevices();
|
||||
}
|
||||
|
||||
function closeAddDeviceModal() {
|
||||
@@ -904,9 +1119,12 @@ function closeAddDeviceModal() {
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
async function scanForDevices() {
|
||||
if (_discoveryScanRunning) return;
|
||||
_discoveryScanRunning = true;
|
||||
async function scanForDevices(forceType) {
|
||||
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
|
||||
|
||||
// Per-type guard: prevent duplicate scans for the same type
|
||||
if (_discoveryScanRunning === scanType) return;
|
||||
_discoveryScanRunning = scanType;
|
||||
|
||||
const loading = document.getElementById('discovery-loading');
|
||||
const list = document.getElementById('discovery-list');
|
||||
@@ -914,14 +1132,26 @@ async function scanForDevices() {
|
||||
const section = document.getElementById('discovery-section');
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
|
||||
section.style.display = 'block';
|
||||
loading.style.display = 'flex';
|
||||
list.innerHTML = '';
|
||||
empty.style.display = 'none';
|
||||
if (scanType === 'adalight') {
|
||||
// Show loading in the serial port dropdown
|
||||
const select = document.getElementById('device-serial-port');
|
||||
select.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = '⏳';
|
||||
opt.disabled = true;
|
||||
select.appendChild(opt);
|
||||
} else {
|
||||
// Show the discovery section with loading spinner
|
||||
section.style.display = 'block';
|
||||
loading.style.display = 'flex';
|
||||
list.innerHTML = '';
|
||||
empty.style.display = 'none';
|
||||
}
|
||||
if (scanBtn) scanBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/discover?timeout=3`, {
|
||||
const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
@@ -931,54 +1161,46 @@ async function scanForDevices() {
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
if (scanType !== 'adalight') {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
_discoveryCache[scanType] = data.devices || [];
|
||||
|
||||
if (data.devices.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
// Only render if the user is still on this type
|
||||
const currentType = document.getElementById('device-type')?.value;
|
||||
if (currentType === scanType) {
|
||||
_renderDiscoveryList();
|
||||
}
|
||||
|
||||
data.devices.forEach(device => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
||||
const meta = [device.ip];
|
||||
if (device.led_count) meta.push(device.led_count + ' LEDs');
|
||||
if (device.version) meta.push('v' + device.version);
|
||||
card.innerHTML = `
|
||||
<div class="discovery-item-info">
|
||||
<strong>${escapeHtml(device.name)}</strong>
|
||||
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
|
||||
</div>
|
||||
${device.already_added
|
||||
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
|
||||
: ''}
|
||||
`;
|
||||
if (!device.already_added) {
|
||||
card.onclick = () => selectDiscoveredDevice(device);
|
||||
}
|
||||
list.appendChild(card);
|
||||
});
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
if (scanType !== 'adalight') {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
}
|
||||
console.error('Device scan failed:', err);
|
||||
} finally {
|
||||
_discoveryScanRunning = false;
|
||||
if (_discoveryScanRunning === scanType) {
|
||||
_discoveryScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectDiscoveredDevice(device) {
|
||||
document.getElementById('device-name').value = device.name;
|
||||
document.getElementById('device-url').value = device.url;
|
||||
const typeSelect = document.getElementById('device-type');
|
||||
if (typeSelect) typeSelect.value = device.device_type;
|
||||
onDeviceTypeChanged();
|
||||
if (device.device_type === 'adalight') {
|
||||
document.getElementById('device-serial-port').value = device.url;
|
||||
} else {
|
||||
document.getElementById('device-url').value = device.url;
|
||||
}
|
||||
showToast(t('device.scan.selected'), 'info');
|
||||
}
|
||||
|
||||
@@ -986,7 +1208,10 @@ async function handleAddDevice(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('device-name').value.trim();
|
||||
const url = document.getElementById('device-url').value.trim();
|
||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||
const url = deviceType === 'adalight'
|
||||
? document.getElementById('device-serial-port').value
|
||||
: document.getElementById('device-url').value.trim();
|
||||
const error = document.getElementById('add-device-error');
|
||||
|
||||
if (!name || !url) {
|
||||
@@ -996,8 +1221,11 @@ async function handleAddDevice(event) {
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||
const body = { name, url, device_type: deviceType };
|
||||
const ledCountInput = document.getElementById('device-led-count');
|
||||
if (ledCountInput && ledCountInput.value) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
}
|
||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||
if (lastTemplateId) {
|
||||
body.capture_template_id = lastTemplateId;
|
||||
@@ -4274,7 +4502,9 @@ async function loadTargetsTab() {
|
||||
// Attach event listeners and fetch brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
if ((device.capabilities || []).includes('brightness_control')) {
|
||||
fetchDeviceBrightness(device.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Manage KC WebSockets: connect for processing, disconnect for stopped
|
||||
|
||||
Reference in New Issue
Block a user