/**
* 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';
}