997ff2fd70
- Rename all 54 .js files to .ts, update esbuild entry point - Add tsconfig.json, TypeScript devDependency, typecheck script - Create types.ts with 25+ interfaces matching backend Pydantic schemas (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.) - Make DataCache generic (DataCache<T>) with typed state instances - Type all state variables in state.ts with proper entity types - Type all create*Card functions with proper entity interfaces - Type all function parameters and return types across all 54 files - Type core component constructors (CardSection, IconSelect, EntitySelect, FilterList, TagInput, TreeNav, Modal) with exported option interfaces - Add comprehensive global.d.ts for window function declarations - Type fetchWithAuth with FetchAuthOpts interface - Remove all (window as any) casts in favor of global.d.ts declarations - Zero tsc errors, esbuild bundle unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1045 lines
47 KiB
TypeScript
1045 lines
47 KiB
TypeScript
/**
|
|
* Device discovery — add device modal, network/serial scanning, device type switching.
|
|
*/
|
|
|
|
import {
|
|
_discoveryScanRunning, set_discoveryScanRunning,
|
|
_discoveryCache, set_discoveryCache,
|
|
csptCache,
|
|
} from '../core/state.ts';
|
|
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.ts';
|
|
import { devicesCache } from '../core/state.ts';
|
|
import { t } from '../core/i18n.ts';
|
|
import { showToast, desktopFocus } from '../core/ui.ts';
|
|
import { Modal } from '../core/modal.ts';
|
|
import { _computeMaxFps, _renderFpsHint } from './devices.ts';
|
|
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.ts';
|
|
import { EntitySelect } from '../core/entity-palette.ts';
|
|
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
|
|
|
class AddDeviceModal extends Modal {
|
|
constructor() { super('add-device-modal'); }
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: (document.getElementById('device-name') as HTMLInputElement).value,
|
|
type: (document.getElementById('device-type') as HTMLSelectElement).value,
|
|
url: (document.getElementById('device-url') as HTMLInputElement).value,
|
|
serialPort: (document.getElementById('device-serial-port') as HTMLSelectElement).value,
|
|
ledCount: (document.getElementById('device-led-count') as HTMLInputElement).value,
|
|
baudRate: (document.getElementById('device-baud-rate') as HTMLSelectElement).value,
|
|
ledType: (document.getElementById('device-led-type') as HTMLSelectElement)?.value || 'rgb',
|
|
sendLatency: (document.getElementById('device-send-latency') as HTMLInputElement)?.value || '0',
|
|
zones: JSON.stringify(_getCheckedZones('device-zone-list')),
|
|
zoneMode: _getZoneMode(),
|
|
csptId: (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value || '',
|
|
dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet',
|
|
dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0',
|
|
dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.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: any = null;
|
|
let _csptEntitySelect: any = 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 } as any);
|
|
}
|
|
|
|
function _ensureCsptEntitySelect() {
|
|
const sel = document.getElementById('device-css-processing-template');
|
|
if (!sel) return;
|
|
const templates = csptCache.data || [];
|
|
// Populate native <select> options
|
|
sel.innerHTML = `<option value="">${t('common.none_no_cspt')}</option>` +
|
|
templates.map((tp: any) => `<option value="${tp.id}">${tp.name}</option>`).join('');
|
|
if (_csptEntitySelect) _csptEntitySelect.destroy();
|
|
if (templates.length > 0) {
|
|
_csptEntitySelect = new EntitySelect({
|
|
target: sel,
|
|
getItems: () => (csptCache.data || []).map((tp: any) => ({
|
|
value: tp.id,
|
|
label: tp.name,
|
|
icon: ICON_TEMPLATE,
|
|
desc: '',
|
|
})),
|
|
placeholder: t('palette.search'),
|
|
allowNone: true,
|
|
noneLabel: t('common.none_no_cspt'),
|
|
} as any);
|
|
}
|
|
}
|
|
|
|
/* ── 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: Record<string, any> = {};
|
|
|
|
export function ensureDmxProtocolIconSelect(selectId: any) {
|
|
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,
|
|
} as any);
|
|
}
|
|
|
|
export function destroyDmxProtocolIconSelect(selectId: any) {
|
|
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: Record<string, any> = {};
|
|
|
|
export function ensureSpiLedTypeIconSelect(selectId: any) {
|
|
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,
|
|
} as any);
|
|
}
|
|
|
|
export function destroySpiLedTypeIconSelect(selectId: any) {
|
|
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: Record<string, any> = {};
|
|
|
|
export function ensureGameSenseDeviceTypeIconSelect(selectId: any) {
|
|
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,
|
|
} as any);
|
|
}
|
|
|
|
export function destroyGameSenseDeviceTypeIconSelect(selectId: any) {
|
|
if (_gameSenseDeviceTypeIconSelects[selectId]) {
|
|
_gameSenseDeviceTypeIconSelects[selectId].destroy();
|
|
delete _gameSenseDeviceTypeIconSelects[selectId];
|
|
}
|
|
}
|
|
|
|
export function onDeviceTypeChanged() {
|
|
const deviceType = (document.getElementById('device-type') as HTMLSelectElement).value;
|
|
if (_deviceTypeIconSelect) _deviceTypeIconSelect.setValue(deviceType);
|
|
const urlGroup = document.getElementById('device-url-group') as HTMLElement;
|
|
const urlInput = document.getElementById('device-url') as HTMLInputElement;
|
|
const serialGroup = document.getElementById('device-serial-port-group') as HTMLElement;
|
|
const serialSelect = document.getElementById('device-serial-port') as HTMLSelectElement;
|
|
const ledCountGroup = document.getElementById('device-led-count-group') as HTMLElement;
|
|
const discoverySection = document.getElementById('discovery-section') as HTMLElement;
|
|
const baudRateGroup = document.getElementById('device-baud-rate-group') as HTMLElement;
|
|
const ledTypeGroup = document.getElementById('device-led-type-group') as HTMLElement;
|
|
const sendLatencyGroup = document.getElementById('device-send-latency-group') as HTMLElement;
|
|
|
|
// URL label / hint / placeholder — adapt per device type
|
|
const urlLabel = document.getElementById('device-url-label') as HTMLElement;
|
|
const urlHint = document.getElementById('device-url-hint') as HTMLElement;
|
|
|
|
const zoneGroup = document.getElementById('device-zone-group') as HTMLElement;
|
|
const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
|
|
const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group') as HTMLElement;
|
|
const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group') as HTMLElement;
|
|
const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group') as HTMLElement;
|
|
|
|
// 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') as HTMLElement;
|
|
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;
|
|
opt.selected = 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;
|
|
opt.selected = 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') as HTMLElement;
|
|
const baudRate = parseInt((document.getElementById('device-baud-rate') as HTMLSelectElement).value, 10);
|
|
const ledCount = parseInt((document.getElementById('device-led-count') as HTMLInputElement).value, 10);
|
|
const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'adalight';
|
|
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
|
|
}
|
|
|
|
function _renderDiscoveryList() {
|
|
const selectedType = (document.getElementById('device-type') as HTMLSelectElement).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') as HTMLElement;
|
|
const empty = document.getElementById('discovery-empty') as HTMLElement;
|
|
const section = document.getElementById('discovery-section') as HTMLElement;
|
|
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: any) => {
|
|
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: any) {
|
|
const select = document.getElementById('device-serial-port') as HTMLSelectElement;
|
|
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;
|
|
}
|
|
|
|
// Default placeholder
|
|
const placeholder = document.createElement('option');
|
|
placeholder.value = '';
|
|
placeholder.textContent = t('device.serial_port.select') || 'Select a port...';
|
|
placeholder.disabled = true;
|
|
placeholder.selected = true;
|
|
select.appendChild(placeholder);
|
|
|
|
devices.forEach((device: any) => {
|
|
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') as HTMLSelectElement)?.value || 'adalight';
|
|
if (!(deviceType in _discoveryCache)) {
|
|
scanForDevices(deviceType);
|
|
}
|
|
}
|
|
|
|
export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
|
// When no type specified: show type picker first
|
|
if (!presetType) {
|
|
showTypePicker({
|
|
title: t('device.select_type'),
|
|
items: _buildDeviceTypeItems(),
|
|
onPick: (type: any) => showAddDevice(type),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const form = document.getElementById('add-device-form') as HTMLFormElement;
|
|
const error = document.getElementById('add-device-error') as HTMLElement;
|
|
form.reset();
|
|
error.style.display = 'none';
|
|
set_discoveryCache({});
|
|
// Reset discovery section
|
|
const section = document.getElementById('discovery-section') as HTMLElement;
|
|
if (section) {
|
|
section.style.display = 'none';
|
|
(document.getElementById('discovery-list') as HTMLElement).innerHTML = '';
|
|
(document.getElementById('discovery-empty') as HTMLElement).style.display = 'none';
|
|
(document.getElementById('discovery-loading') as HTMLElement).style.display = 'none';
|
|
}
|
|
// Reset serial port dropdown
|
|
(document.getElementById('device-serial-port') as HTMLSelectElement).innerHTML = '';
|
|
const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
|
|
if (scanBtn) scanBtn.disabled = false;
|
|
_ensureDeviceTypeIconSelect();
|
|
// Populate CSPT template selector
|
|
csptCache.fetch().then(() => _ensureCsptEntitySelect());
|
|
|
|
// Pre-select type and hide the type selector (already chosen)
|
|
(document.getElementById('device-type') as HTMLSelectElement).value = presetType;
|
|
(document.getElementById('device-type-group') as HTMLElement).style.display = 'none';
|
|
const typeIcon = getDeviceTypeIcon(presetType);
|
|
const typeName = t(`device.type.${presetType}`);
|
|
(document.getElementById('add-device-modal-title') as HTMLElement).innerHTML = `${typeIcon} ${t('devices.add')}: ${typeName}`;
|
|
|
|
addDeviceModal.open();
|
|
onDeviceTypeChanged();
|
|
|
|
// Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields)
|
|
if (cloneData) {
|
|
(document.getElementById('device-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
|
// Clear URL — devices must have unique addresses, user must enter a new one
|
|
const urlInput = document.getElementById('device-url') as HTMLInputElement;
|
|
if (urlInput) urlInput.value = '';
|
|
// Prefill LED count
|
|
const ledCountInput = document.getElementById('device-led-count') as HTMLInputElement;
|
|
if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count;
|
|
// Prefill baud rate for serial devices
|
|
if (isSerialDevice(presetType)) {
|
|
const baudSelect = document.getElementById('device-baud-rate') as HTMLSelectElement;
|
|
if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate);
|
|
}
|
|
// Prefill mock device fields
|
|
if (isMockDevice(presetType)) {
|
|
const ledTypeEl = document.getElementById('device-led-type') as HTMLSelectElement;
|
|
if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb';
|
|
const sendLatencyEl = document.getElementById('device-send-latency') as HTMLInputElement;
|
|
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
|
|
}
|
|
// Prefill DMX fields
|
|
if (isDmxDevice(presetType)) {
|
|
const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
|
|
if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol;
|
|
const dmxUniverse = document.getElementById('device-dmx-start-universe') as HTMLInputElement;
|
|
if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe;
|
|
const dmxChannel = document.getElementById('device-dmx-start-channel') as HTMLInputElement;
|
|
if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel;
|
|
}
|
|
// Prefill CSPT template selector (after fetch completes)
|
|
if (cloneData.default_css_processing_template_id) {
|
|
csptCache.fetch().then(() => {
|
|
_ensureCsptEntitySelect();
|
|
const csptEl = document.getElementById('device-css-processing-template') as HTMLSelectElement;
|
|
if (csptEl) csptEl.value = cloneData.default_css_processing_template_id;
|
|
});
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
desktopFocus(document.getElementById('device-name'));
|
|
addDeviceModal.snapshot();
|
|
}, 100);
|
|
}
|
|
|
|
export async function closeAddDeviceModal() {
|
|
await addDeviceModal.close();
|
|
}
|
|
|
|
export async function scanForDevices(forceType?: any) {
|
|
const scanType = forceType || (document.getElementById('device-type') as HTMLSelectElement)?.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') as HTMLElement;
|
|
const list = document.getElementById('discovery-list') as HTMLElement;
|
|
const empty = document.getElementById('discovery-empty') as HTMLElement;
|
|
const section = document.getElementById('discovery-section') as HTMLElement;
|
|
const scanBtn = document.getElementById('scan-network-btn') as HTMLButtonElement;
|
|
|
|
if (isSerialDevice(scanType)) {
|
|
// Show loading in the serial port dropdown
|
|
const select = document.getElementById('device-serial-port') as HTMLSelectElement;
|
|
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') as HTMLElement).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') as HTMLSelectElement)?.value;
|
|
if (currentType === scanType) {
|
|
_renderDiscoveryList();
|
|
}
|
|
} catch (err: any) {
|
|
if (err.isAuth) return;
|
|
loading.style.display = 'none';
|
|
if (scanBtn) scanBtn.disabled = false;
|
|
if (!isSerialDevice(scanType)) {
|
|
empty.style.display = 'block';
|
|
(empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
|
|
}
|
|
console.error('Device scan failed:', err);
|
|
} finally {
|
|
if (_discoveryScanRunning === scanType) {
|
|
set_discoveryScanRunning(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function selectDiscoveredDevice(device: any) {
|
|
(document.getElementById('device-name') as HTMLInputElement).value = device.name;
|
|
const typeSelect = document.getElementById('device-type') as HTMLSelectElement;
|
|
if (typeSelect) typeSelect.value = device.device_type;
|
|
onDeviceTypeChanged();
|
|
if (isSerialDevice(device.device_type)) {
|
|
(document.getElementById('device-serial-port') as HTMLSelectElement).value = device.url;
|
|
} else {
|
|
(document.getElementById('device-url') as HTMLInputElement).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: any) {
|
|
event.preventDefault();
|
|
|
|
const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
|
|
const deviceType = (document.getElementById('device-type') as HTMLSelectElement)?.value || 'wled';
|
|
const error = document.getElementById('add-device-error') as HTMLElement;
|
|
|
|
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') as HTMLSelectElement).value;
|
|
} else if (isChromaDevice(deviceType)) {
|
|
const chromaType = (document.getElementById('device-chroma-device-type') as HTMLSelectElement)?.value || 'chromalink';
|
|
url = `chroma://${chromaType}`;
|
|
} else if (isGameSenseDevice(deviceType)) {
|
|
url = 'gamesense://auto';
|
|
} else {
|
|
url = (document.getElementById('device-url') as HTMLInputElement).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: any = { name, url, device_type: deviceType };
|
|
const ledCountInput = document.getElementById('device-led-count') as HTMLInputElement;
|
|
if (ledCountInput && ledCountInput.value) {
|
|
body.led_count = parseInt(ledCountInput.value, 10);
|
|
}
|
|
const baudRateSelect = document.getElementById('device-baud-rate') as HTMLSelectElement;
|
|
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
|
|
body.baud_rate = parseInt(baudRateSelect.value, 10);
|
|
}
|
|
if (isMockDevice(deviceType)) {
|
|
const sendLatency = (document.getElementById('device-send-latency') as HTMLInputElement)?.value;
|
|
if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10);
|
|
const ledType = (document.getElementById('device-led-type') as HTMLSelectElement)?.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') as HTMLSelectElement)?.value || 'artnet';
|
|
body.dmx_start_universe = parseInt((document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0', 10);
|
|
body.dmx_start_channel = parseInt((document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1', 10);
|
|
}
|
|
if (isEspnowDevice(deviceType)) {
|
|
body.espnow_peer_mac = (document.getElementById('device-espnow-peer-mac') as HTMLInputElement)?.value || '';
|
|
body.espnow_channel = parseInt((document.getElementById('device-espnow-channel') as HTMLInputElement)?.value || '1', 10);
|
|
body.baud_rate = parseInt((document.getElementById('device-baud-rate') as HTMLSelectElement)?.value || '921600', 10);
|
|
}
|
|
if (isHueDevice(deviceType)) {
|
|
body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
|
|
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
|
|
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
|
|
}
|
|
if (isSpiDevice(deviceType)) {
|
|
body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
|
|
body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
|
|
}
|
|
if (isChromaDevice(deviceType)) {
|
|
body.chroma_device_type = (document.getElementById('device-chroma-device-type') as HTMLSelectElement)?.value || 'chromalink';
|
|
}
|
|
if (isGameSenseDevice(deviceType)) {
|
|
body.gamesense_device_type = (document.getElementById('device-gamesense-device-type') as HTMLSelectElement)?.value || 'keyboard';
|
|
}
|
|
const csptId = (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value;
|
|
if (csptId) body.default_css_processing_template_id = csptId;
|
|
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);
|
|
const detail = errorData.detail || errorData.message || '';
|
|
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
|
error.textContent = detailStr || t('device_discovery.error.add_failed');
|
|
error.style.display = 'block';
|
|
}
|
|
} catch (err: any) {
|
|
if (err.isAuth) return;
|
|
console.error('Failed to add device:', err);
|
|
showToast(err.message || 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: any, containerId: any, preChecked: any = []) {
|
|
const container = document.getElementById(containerId) as HTMLElement;
|
|
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: any) {
|
|
if (err.isAuth) return;
|
|
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
|
|
}
|
|
}
|
|
|
|
function _renderZoneCheckboxes(container: any, zones: any, preChecked: any = []) {
|
|
container.innerHTML = '';
|
|
container._zonesData = zones;
|
|
const preSet = new Set(preChecked.map((n: any) => n.toLowerCase()));
|
|
|
|
zones.forEach((zone: any) => {
|
|
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: any) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return [];
|
|
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
|
.map(cb => (cb as HTMLInputElement).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: any) {
|
|
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: any) => z.trim()).filter(Boolean);
|
|
const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1];
|
|
return { baseUrl, zones };
|
|
}
|
|
return { baseUrl: url, zones: [] };
|
|
}
|
|
|
|
function _appendZonesToUrl(baseUrl: any, zones: any) {
|
|
// 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: any) {
|
|
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) as HTMLElement;
|
|
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`) as HTMLInputElement;
|
|
return radio ? radio.value : 'combined';
|
|
}
|
|
|
|
/* ── New device type field visibility helpers ──────────────────── */
|
|
|
|
function _showEspnowFields(show: boolean) {
|
|
const ids = ['device-espnow-peer-mac-group', 'device-espnow-channel-group'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id) as HTMLElement;
|
|
if (el) el.style.display = show ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function _showHueFields(show: boolean) {
|
|
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id) as HTMLElement;
|
|
if (el) el.style.display = show ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function _showSpiFields(show: boolean) {
|
|
const ids = ['device-spi-speed-group', 'device-spi-led-type-group'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id) as HTMLElement;
|
|
if (el) el.style.display = show ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function _showChromaFields(show: boolean) {
|
|
const el = document.getElementById('device-chroma-device-type-group') as HTMLElement;
|
|
if (el) el.style.display = show ? '' : 'none';
|
|
}
|
|
|
|
function _showGameSenseFields(show: boolean) {
|
|
const el = document.getElementById('device-gamesense-device-type-group') as HTMLElement;
|
|
if (el) el.style.display = show ? '' : 'none';
|
|
}
|
|
|
|
/* ── Clone device ──────────────────────────────────────────────── */
|
|
|
|
export async function cloneDevice(deviceId: any) {
|
|
try {
|
|
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
|
if (!resp.ok) throw new Error('Failed to load device');
|
|
const device = await resp.json();
|
|
showAddDevice(device.device_type || 'wled', device);
|
|
} catch (error: any) {
|
|
if (error.isAuth) return;
|
|
console.error('Failed to clone device:', error);
|
|
showToast(t('device.error.clone_failed'), 'error');
|
|
}
|
|
}
|