272cb69247
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>
927 lines
39 KiB
JavaScript
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';
|
|
}
|