/** * 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, isMqttDevice, isWsDevice, isOpenrgbDevice, 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'; import { getDeviceTypeIcon } from '../core/icons.js'; import { IconSelect } from '../core/icon-select.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', zones: _getCheckedZones('device-zone-list'), zoneMode: _getZoneMode(), }; } } const addDeviceModal = new AddDeviceModal(); /* ── Icon-grid type selector ──────────────────────────────────── */ const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'mock']; function _buildDeviceTypeItems() { return DEVICE_TYPE_KEYS.map(key => ({ value: key, icon: getDeviceTypeIcon(key), label: t(`device.type.${key}`), desc: t(`device.type.${key}.desc`), })); } let _deviceTypeIconSelect = null; function _ensureDeviceTypeIconSelect() { const sel = document.getElementById('device-type'); if (!sel) return; if (_deviceTypeIconSelect) { _deviceTypeIconSelect.updateItems(_buildDeviceTypeItems()); return; } _deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 }); } export function onDeviceTypeChanged() { const deviceType = document.getElementById('device-type').value; if (_deviceTypeIconSelect) _deviceTypeIconSelect.setValue(deviceType); 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'); // URL label / hint / placeholder — adapt per device type const urlLabel = document.getElementById('device-url-label'); const urlHint = document.getElementById('device-url-hint'); const zoneGroup = document.getElementById('device-zone-group'); const scanBtn = document.getElementById('scan-network-btn'); // Hide zone group + mode group by default (shown only for openrgb) if (zoneGroup) zoneGroup.style.display = 'none'; const zoneModeGroup = document.getElementById('device-zone-mode-group'); if (zoneModeGroup) zoneModeGroup.style.display = 'none'; if (isMqttDevice(deviceType)) { // MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery urlGroup.style.display = ''; urlInput.setAttribute('required', ''); serialGroup.style.display = 'none'; serialSelect.removeAttribute('required'); ledCountGroup.style.display = ''; baudRateGroup.style.display = 'none'; if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; if (discoverySection) discoverySection.style.display = 'none'; if (scanBtn) scanBtn.style.display = 'none'; // Relabel URL field as "Topic" if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; } else 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'; if (scanBtn) scanBtn.style.display = 'none'; } else if (isWsDevice(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 = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; if (discoverySection) discoverySection.style.display = 'none'; if (scanBtn) scanBtn.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'; if (scanBtn) scanBtn.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 if (isOpenrgbDevice(deviceType)) { 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'; if (scanBtn) scanBtn.style.display = ''; if (zoneGroup) zoneGroup.style.display = ''; if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); urlInput.placeholder = 'openrgb://localhost:6742/0'; if (deviceType in _discoveryCache) { _renderDiscoveryList(); } else { scanForDevices(); } } 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'; if (scanBtn) scanBtn.style.display = ''; // Restore default URL label/hint/placeholder if (urlLabel) urlLabel.textContent = t('device.url'); if (urlHint) urlHint.textContent = t('device.url.hint'); urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100'; // 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; _ensureDeviceTypeIconSelect(); 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; } // Fetch zones for OpenRGB devices if (isOpenrgbDevice(device.device_type)) { _fetchOpenrgbZones(device.url, 'device-zone-list'); } 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)) { url = 'mock://'; } else if (isWsDevice(deviceType)) { url = 'ws://'; } else if (isSerialDevice(deviceType)) { url = document.getElementById('device-serial-port').value; } else { url = document.getElementById('device-url').value.trim(); } // MQTT: ensure mqtt:// prefix if (isMqttDevice(deviceType) && url && !url.startsWith('mqtt://')) { url = 'mqtt://' + url; } // OpenRGB: append selected zones to URL const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : []; if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) { url = _appendZonesToUrl(url, checkedZones); } if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) { error.textContent = t('device_discovery.error.fill_all_fields'); error.style.display = 'block'; return; } const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); 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'; } if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) { body.zone_mode = _getZoneMode(); } 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(t('device_discovery.added'), 'success'); addDeviceModal.forceClose(); if (typeof window.loadDevices === 'function') await window.loadDevices(); 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 = t('device_discovery.error.add_failed'); error.style.display = 'block'; } } catch (err) { if (err.isAuth) return; console.error('Failed to add device:', err); showToast(t('device_discovery.error.add_failed'), 'error'); } } // ===== OpenRGB zone helpers ===== /** * Fetch zones for an OpenRGB device URL and render checkboxes in the given container. * @param {string} baseUrl - Base OpenRGB URL (e.g. openrgb://localhost:6742/0) * @param {string} containerId - ID of the zone checkbox list container * @param {string[]} [preChecked=[]] - Zone names to pre-check */ export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = []) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = `${t('device.openrgb.zone.loading')}`; try { const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); if (!resp.ok) { const err = await resp.json().catch(() => ({})); container.innerHTML = `${err.detail || t('device.openrgb.zone.error')}`; return; } const data = await resp.json(); _renderZoneCheckboxes(container, data.zones, preChecked); } catch (err) { if (err.isAuth) return; container.innerHTML = `${t('device.openrgb.zone.error')}`; } } function _renderZoneCheckboxes(container, zones, preChecked = []) { container.innerHTML = ''; container._zonesData = zones; const preSet = new Set(preChecked.map(n => n.toLowerCase())); zones.forEach(zone => { const label = document.createElement('label'); label.className = 'zone-checkbox-item'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = zone.name; if (preSet.has(zone.name.toLowerCase())) cb.checked = true; cb.addEventListener('change', () => _updateZoneModeVisibility(container.id)); const nameSpan = document.createElement('span'); nameSpan.textContent = zone.name; const countSpan = document.createElement('span'); countSpan.className = 'zone-led-count'; countSpan.textContent = `${zone.led_count} LEDs`; label.appendChild(cb); label.appendChild(nameSpan); label.appendChild(countSpan); container.appendChild(label); }); _updateZoneModeVisibility(container.id); } export function _getCheckedZones(containerId) { const container = document.getElementById(containerId); if (!container) return []; return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) .map(cb => cb.value); } /** * Split an OpenRGB URL into base URL (without zones) and zone names. * E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" → { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] } */ export function _splitOpenrgbZone(url) { if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] }; const stripped = url.slice('openrgb://'.length); const parts = stripped.split('/'); // parts: [host:port, device_index, ...zone_str] if (parts.length >= 3) { const zoneStr = parts.slice(2).join('/'); const zones = zoneStr.split('+').map(z => z.trim()).filter(Boolean); const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1]; return { baseUrl, zones }; } return { baseUrl: url, zones: [] }; } function _appendZonesToUrl(baseUrl, zones) { // Strip any existing zone suffix const { baseUrl: clean } = _splitOpenrgbZone(baseUrl); return clean + '/' + zones.join('+'); } /** Show/hide zone mode toggle based on how many zones are checked. */ export function _updateZoneModeVisibility(containerId) { const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group' : containerId === 'settings-zone-list' ? 'settings-zone-mode-group' : null; if (!modeGroupId) return; const modeGroup = document.getElementById(modeGroupId); if (!modeGroup) return; const checkedCount = _getCheckedZones(containerId).length; modeGroup.style.display = checkedCount >= 2 ? '' : 'none'; } /** Get the selected zone mode radio value ('combined' or 'separate'). */ export function _getZoneMode(radioName = 'device-zone-mode') { const radio = document.querySelector(`input[name="${radioName}"]:checked`); return radio ? radio.value : 'combined'; }