/** * Device discovery — add device modal, network/serial scanning, device type switching. */ import { _discoveryScanRunning, set_discoveryScanRunning, _discoveryCache, set_discoveryCache, } from '../core/state.js'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { _computeMaxFps, _renderFpsHint } from './devices.js'; class AddDeviceModal extends Modal { constructor() { super('add-device-modal'); } snapshotValues() { return { name: document.getElementById('device-name').value, type: document.getElementById('device-type').value, url: document.getElementById('device-url').value, serialPort: document.getElementById('device-serial-port').value, ledCount: document.getElementById('device-led-count').value, baudRate: document.getElementById('device-baud-rate').value, ledType: document.getElementById('device-led-type')?.value || 'rgb', sendLatency: document.getElementById('device-send-latency')?.value || '0', }; } } const addDeviceModal = new AddDeviceModal(); export 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'); const baudRateGroup = document.getElementById('device-baud-rate-group'); const ledTypeGroup = document.getElementById('device-led-type-group'); const sendLatencyGroup = document.getElementById('device-send-latency-group'); if (isMockDevice(deviceType)) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = 'none'; serialSelect.removeAttribute('required'); ledCountGroup.style.display = ''; baudRateGroup.style.display = 'none'; if (ledTypeGroup) ledTypeGroup.style.display = ''; if (sendLatencyGroup) sendLatencyGroup.style.display = ''; if (discoverySection) discoverySection.style.display = 'none'; } else if (isSerialDevice(deviceType)) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = ''; serialSelect.setAttribute('required', ''); ledCountGroup.style.display = ''; baudRateGroup.style.display = ''; if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; // 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); } updateBaudFpsHint(); } else { urlGroup.style.display = ''; urlInput.setAttribute('required', ''); serialGroup.style.display = 'none'; serialSelect.removeAttribute('required'); ledCountGroup.style.display = 'none'; baudRateGroup.style.display = 'none'; if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; // Show cached results or trigger scan for WLED if (deviceType in _discoveryCache) { _renderDiscoveryList(); } else { scanForDevices(); } } } export function updateBaudFpsHint() { const hintEl = document.getElementById('baud-fps-hint'); const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10); const ledCount = parseInt(document.getElementById('device-led-count').value, 10); const deviceType = document.getElementById('device-type')?.value || 'adalight'; _renderFpsHint(hintEl, baudRate, ledCount, deviceType); } function _renderDiscoveryList() { const selectedType = document.getElementById('device-type').value; const devices = _discoveryCache[selectedType]; // Serial devices: populate serial port dropdown instead of discovery list if (isSerialDevice(selectedType)) { _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 = `
${escapeHtml(device.name)} ${escapeHtml(meta.join(' \u00b7 '))}
${device.already_added ? '' + t('device.scan.already_added') + '' : ''} `; 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); }); } export function onSerialPortFocus() { // Lazy-load: trigger discovery when user opens the serial port dropdown const deviceType = document.getElementById('device-type')?.value || 'adalight'; if (!(deviceType in _discoveryCache)) { scanForDevices(deviceType); } } export function showAddDevice() { const form = document.getElementById('add-device-form'); const error = document.getElementById('add-device-error'); form.reset(); error.style.display = 'none'; set_discoveryCache({}); // Reset discovery section const section = document.getElementById('discovery-section'); if (section) { section.style.display = 'none'; document.getElementById('discovery-list').innerHTML = ''; 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; addDeviceModal.open(); onDeviceTypeChanged(); setTimeout(() => { document.getElementById('device-name').focus(); addDeviceModal.snapshot(); }, 100); } export async function closeAddDeviceModal() { await addDeviceModal.close(); } export 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; set_discoveryScanRunning(scanType); const loading = document.getElementById('discovery-loading'); const list = document.getElementById('discovery-list'); const empty = document.getElementById('discovery-empty'); const section = document.getElementById('discovery-section'); const scanBtn = document.getElementById('scan-network-btn'); if (isSerialDevice(scanType)) { // 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 = '\u23F3'; 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 fetchWithAuth(`/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`); loading.style.display = 'none'; if (scanBtn) scanBtn.disabled = false; if (!response.ok) { if (!isSerialDevice(scanType)) { empty.style.display = 'block'; empty.querySelector('small').textContent = t('device.scan.error'); } return; } const data = await response.json(); _discoveryCache[scanType] = data.devices || []; // Only render if the user is still on this type const currentType = document.getElementById('device-type')?.value; if (currentType === scanType) { _renderDiscoveryList(); } } catch (err) { if (err.isAuth) return; loading.style.display = 'none'; if (scanBtn) scanBtn.disabled = false; if (!isSerialDevice(scanType)) { empty.style.display = 'block'; empty.querySelector('small').textContent = t('device.scan.error'); } console.error('Device scan failed:', err); } finally { if (_discoveryScanRunning === scanType) { set_discoveryScanRunning(false); } } } export function selectDiscoveredDevice(device) { document.getElementById('device-name').value = device.name; const typeSelect = document.getElementById('device-type'); if (typeSelect) typeSelect.value = device.device_type; onDeviceTypeChanged(); if (isSerialDevice(device.device_type)) { document.getElementById('device-serial-port').value = device.url; } else { document.getElementById('device-url').value = device.url; } showToast(t('device.scan.selected'), 'info'); } export async function handleAddDevice(event) { event.preventDefault(); const name = document.getElementById('device-name').value.trim(); const deviceType = document.getElementById('device-type')?.value || 'wled'; const error = document.getElementById('add-device-error'); let url; if (isMockDevice(deviceType)) { const ledCount = document.getElementById('device-led-count')?.value || '60'; url = `mock://${ledCount}`; } else if (isSerialDevice(deviceType)) { url = document.getElementById('device-serial-port').value; } else { url = document.getElementById('device-url').value.trim(); } if (!name || (!isMockDevice(deviceType) && !url)) { error.textContent = 'Please fill in all fields'; error.style.display = 'block'; return; } try { 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 baudRateSelect = document.getElementById('device-baud-rate'); if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) { body.baud_rate = parseInt(baudRateSelect.value, 10); } if (isMockDevice(deviceType)) { const sendLatency = document.getElementById('device-send-latency')?.value; if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10); const ledType = document.getElementById('device-led-type')?.value; body.rgbw = ledType === 'rgbw'; } const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); if (lastTemplateId) { body.capture_template_id = lastTemplateId; } const response = await fetchWithAuth('/devices', { method: 'POST', body: JSON.stringify(body) }); if (response.ok) { const result = await response.json(); console.log('Device added successfully:', result); showToast('Device added successfully', 'success'); addDeviceModal.forceClose(); // Use window.* to avoid circular imports if (typeof window.loadDevices === 'function') await window.loadDevices(); // Auto-start device tutorial on first device add if (!localStorage.getItem('deviceTutorialSeen')) { localStorage.setItem('deviceTutorialSeen', '1'); setTimeout(() => { if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial(); }, 300); } } else { const errorData = await response.json(); console.error('Failed to add device:', errorData); error.textContent = `Failed to add device: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { if (err.isAuth) return; console.error('Failed to add device:', err); showToast('Failed to add device', 'error'); } }