/** * 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, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.js'; import { devicesCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { _computeMaxFps, _renderFpsHint } from './devices.js'; import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY } 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: JSON.stringify(_getCheckedZones('device-zone-list')), zoneMode: _getZoneMode(), dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet', dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0', dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1', }; } } const addDeviceModal = new AddDeviceModal(); /* ── Icon-grid type selector ──────────────────────────────────── */ const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'usbhid', 'spi', 'chroma', 'gamesense', '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 }); } /* ── Icon-grid DMX protocol selector ─────────────────────────── */ function _buildDmxProtocolItems() { return [ { value: 'artnet', icon: ICON_RADIO, label: 'Art-Net', desc: t('device.dmx_protocol.artnet.desc') }, { value: 'sacn', icon: ICON_GLOBE, label: 'sACN (E1.31)', desc: t('device.dmx_protocol.sacn.desc') }, ]; } const _dmxProtocolIconSelects = {}; export function ensureDmxProtocolIconSelect(selectId) { const sel = document.getElementById(selectId); if (!sel) return; if (_dmxProtocolIconSelects[selectId]) { _dmxProtocolIconSelects[selectId].updateItems(_buildDmxProtocolItems()); return; } _dmxProtocolIconSelects[selectId] = new IconSelect({ target: sel, items: _buildDmxProtocolItems(), columns: 2, }); } export function destroyDmxProtocolIconSelect(selectId) { if (_dmxProtocolIconSelects[selectId]) { _dmxProtocolIconSelects[selectId].destroy(); delete _dmxProtocolIconSelects[selectId]; } } /* ── Icon-grid SPI LED chipset selector ──────────────────────── */ function _buildSpiLedTypeItems() { return [ { value: 'WS2812B', icon: ICON_CPU, label: 'WS2812B', desc: t('device.spi.led_type.ws2812b.desc') }, { value: 'WS2812', icon: ICON_CPU, label: 'WS2812', desc: t('device.spi.led_type.ws2812.desc') }, { value: 'WS2811', icon: ICON_CPU, label: 'WS2811', desc: t('device.spi.led_type.ws2811.desc') }, { value: 'SK6812', icon: ICON_CPU, label: 'SK6812 (RGB)', desc: t('device.spi.led_type.sk6812.desc') }, { value: 'SK6812_RGBW', icon: ICON_CPU, label: 'SK6812 (RGBW)', desc: t('device.spi.led_type.sk6812_rgbw.desc') }, ]; } const _spiLedTypeIconSelects = {}; export function ensureSpiLedTypeIconSelect(selectId) { const sel = document.getElementById(selectId); if (!sel) return; if (_spiLedTypeIconSelects[selectId]) { _spiLedTypeIconSelects[selectId].updateItems(_buildSpiLedTypeItems()); return; } _spiLedTypeIconSelects[selectId] = new IconSelect({ target: sel, items: _buildSpiLedTypeItems(), columns: 3, }); } export function destroySpiLedTypeIconSelect(selectId) { if (_spiLedTypeIconSelects[selectId]) { _spiLedTypeIconSelects[selectId].destroy(); delete _spiLedTypeIconSelects[selectId]; } } /* ── Icon-grid GameSense peripheral type selector ────────────── */ function _buildGameSenseDeviceTypeItems() { return [ { value: 'keyboard', icon: ICON_KEYBOARD, label: t('device.gamesense.peripheral.keyboard'), desc: t('device.gamesense.peripheral.keyboard.desc') }, { value: 'mouse', icon: ICON_MOUSE, label: t('device.gamesense.peripheral.mouse'), desc: t('device.gamesense.peripheral.mouse.desc') }, { value: 'headset', icon: ICON_HEADPHONES, label: t('device.gamesense.peripheral.headset'), desc: t('device.gamesense.peripheral.headset.desc') }, { value: 'mousepad', icon: ICON_PLUG, label: t('device.gamesense.peripheral.mousepad'), desc: t('device.gamesense.peripheral.mousepad.desc') }, { value: 'indicator', icon: ICON_ACTIVITY, label: t('device.gamesense.peripheral.indicator'), desc: t('device.gamesense.peripheral.indicator.desc') }, ]; } const _gameSenseDeviceTypeIconSelects = {}; export function ensureGameSenseDeviceTypeIconSelect(selectId) { const sel = document.getElementById(selectId); if (!sel) return; if (_gameSenseDeviceTypeIconSelects[selectId]) { _gameSenseDeviceTypeIconSelects[selectId].updateItems(_buildGameSenseDeviceTypeItems()); return; } _gameSenseDeviceTypeIconSelects[selectId] = new IconSelect({ target: sel, items: _buildGameSenseDeviceTypeItems(), columns: 3, }); } export function destroyGameSenseDeviceTypeIconSelect(selectId) { if (_gameSenseDeviceTypeIconSelects[selectId]) { _gameSenseDeviceTypeIconSelects[selectId].destroy(); delete _gameSenseDeviceTypeIconSelects[selectId]; } } 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'); const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group'); const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group'); const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group'); // 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'; // Hide DMX fields by default if (dmxProtocolGroup) dmxProtocolGroup.style.display = 'none'; if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = 'none'; if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = 'none'; // Hide new device type fields by default _showEspnowFields(false); _showHueFields(false); _showSpiFields(false); _showChromaFields(false); _showGameSenseFields(false); 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 (isDmxDevice(deviceType)) { // DMX: show URL (IP address), LED count, DMX-specific fields; hide serial/baud/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'; // Show DMX-specific fields if (dmxProtocolGroup) dmxProtocolGroup.style.display = ''; if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = ''; if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = ''; ensureDmxProtocolIconSelect('device-dmx-protocol'); // Relabel URL field as IP Address if (urlLabel) urlLabel.textContent = t('device.dmx.url'); if (urlHint) urlHint.textContent = t('device.dmx.url.hint'); urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50'; } 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 if (isEspnowDevice(deviceType)) { // ESP-NOW: serial port for gateway, LED count, baud rate, + ESP-NOW fields 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 (discoverySection) discoverySection.style.display = 'none'; if (scanBtn) scanBtn.style.display = 'none'; // Show ESP-NOW specific fields _showEspnowFields(true); // Populate serial ports 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 if (isHueDevice(deviceType)) { // Hue: show URL (bridge IP), LED count, + Hue auth fields 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 (scanBtn) scanBtn.style.display = ''; _showHueFields(true); if (urlLabel) urlLabel.textContent = t('device.hue.url') || 'Bridge IP'; if (urlHint) urlHint.textContent = t('device.hue.url.hint') || 'IP address of your Hue bridge'; urlInput.placeholder = 'hue://192.168.1.2'; if (deviceType in _discoveryCache) { _renderDiscoveryList(); } else { scanForDevices(); } } else if (isUsbhidDevice(deviceType)) { // USB HID: show URL (VID:PID), LED count 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 (scanBtn) scanBtn.style.display = ''; if (urlLabel) urlLabel.textContent = t('device.usbhid.url') || 'VID:PID'; if (urlHint) urlHint.textContent = t('device.usbhid.url.hint') || 'USB Vendor:Product ID in hex'; urlInput.placeholder = 'hid://1532:0084'; if (deviceType in _discoveryCache) { _renderDiscoveryList(); } else { scanForDevices(); } } else if (isSpiDevice(deviceType)) { // SPI Direct: show URL (gpio/spidev), LED count, + SPI fields 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 (scanBtn) scanBtn.style.display = ''; _showSpiFields(true); ensureSpiLedTypeIconSelect('device-spi-led-type'); if (urlLabel) urlLabel.textContent = t('device.spi.url') || 'GPIO/SPI Path'; if (urlHint) urlHint.textContent = t('device.spi.url.hint') || 'GPIO pin or SPI device path'; urlInput.placeholder = 'spi://gpio:18'; if (deviceType in _discoveryCache) { _renderDiscoveryList(); } else { scanForDevices(); } } else if (isChromaDevice(deviceType)) { // Razer Chroma: auto URL, LED count, + peripheral type selector 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 (scanBtn) scanBtn.style.display = ''; _showChromaFields(true); if (deviceType in _discoveryCache) { _renderDiscoveryList(); } else { scanForDevices(); } } else if (isGameSenseDevice(deviceType)) { // SteelSeries GameSense: auto URL, LED count, + device type selector 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 (scanBtn) scanBtn.style.display = ''; _showGameSenseFields(true); ensureGameSenseDeviceTypeIconSelect('device-gamesense-device-type'); 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(); } } // Re-snapshot after type change so switching types alone doesn't mark as dirty addDeviceModal.snapshot(); } 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 = `