Files
ledgrab/server/src/wled_controller/static/js/features/device-discovery.js
T
alexei.dolgolyov 272cb69247 Add 6 new device providers, IconSelect grids, and UI fixes
New device providers: ESP-NOW, Philips Hue, USB HID, SPI Direct,
Razer Chroma SDK, and SteelSeries GameSense — each with client,
provider, full backend registration, schemas, routes, and frontend
support including discovery, form fields, and i18n.

Add IconSelect grids for SPI LED chipset selector and GameSense
peripheral type selector with new Lucide icons (cpu, keyboard,
mouse, headphones).

Replace emoji graph overlay buttons (eye, bell) with proper SVG
path icons for consistent cross-platform rendering.

Fix connection overlay causing horizontal scroll by adding
overflow: hidden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:32:28 +03:00

927 lines
39 KiB
JavaScript

/**
* 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 = `
<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);
});
}
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(() => {
desktopFocus(document.getElementById('device-name'));
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) || isEspnowDevice(deviceType)) {
url = document.getElementById('device-serial-port').value;
} else if (isChromaDevice(deviceType)) {
const chromaType = document.getElementById('device-chroma-device-type')?.value || 'chromalink';
url = `chroma://${chromaType}`;
} else if (isGameSenseDevice(deviceType)) {
url = 'gamesense://auto';
} 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 (isDmxDevice(deviceType)) {
body.dmx_protocol = document.getElementById('device-dmx-protocol')?.value || 'artnet';
body.dmx_start_universe = parseInt(document.getElementById('device-dmx-start-universe')?.value || '0', 10);
body.dmx_start_channel = parseInt(document.getElementById('device-dmx-start-channel')?.value || '1', 10);
}
if (isEspnowDevice(deviceType)) {
body.espnow_peer_mac = document.getElementById('device-espnow-peer-mac')?.value || '';
body.espnow_channel = parseInt(document.getElementById('device-espnow-channel')?.value || '1', 10);
body.baud_rate = parseInt(document.getElementById('device-baud-rate')?.value || '921600', 10);
}
if (isHueDevice(deviceType)) {
body.hue_username = document.getElementById('device-hue-username')?.value || '';
body.hue_client_key = document.getElementById('device-hue-client-key')?.value || '';
body.hue_entertainment_group_id = document.getElementById('device-hue-group-id')?.value || '';
}
if (isSpiDevice(deviceType)) {
body.spi_speed_hz = parseInt(document.getElementById('device-spi-speed')?.value || '800000', 10);
body.spi_led_type = document.getElementById('device-spi-led-type')?.value || 'WS2812B';
}
if (isChromaDevice(deviceType)) {
body.chroma_device_type = document.getElementById('device-chroma-device-type')?.value || 'chromalink';
}
if (isGameSenseDevice(deviceType)) {
body.gamesense_device_type = document.getElementById('device-gamesense-device-type')?.value || 'keyboard';
}
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');
devicesCache.invalidate();
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 = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
return;
}
const data = await resp.json();
_renderZoneCheckboxes(container, data.zones, preChecked);
} catch (err) {
if (err.isAuth) return;
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
}
}
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';
}
/* ── New device type field visibility helpers ──────────────────── */
function _showEspnowFields(show) {
const ids = ['device-espnow-peer-mac-group', 'device-espnow-channel-group'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
});
}
function _showHueFields(show) {
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
});
}
function _showSpiFields(show) {
const ids = ['device-spi-speed-group', 'device-spi-led-type-group'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
});
}
function _showChromaFields(show) {
const el = document.getElementById('device-chroma-device-type-group');
if (el) el.style.display = show ? '' : 'none';
}
function _showGameSenseFields(show) {
const el = document.getElementById('device-gamesense-device-type-group');
if (el) el.style.display = show ? '' : 'none';
}