Files
ledgrab/server/src/ledgrab/static/js/features/device-discovery.ts
T
alexei.dolgolyov 426484adf8 feat(devices): Nanoleaf OpenAPI target type + first pair-flow user
Adds support for Nanoleaf controllers (Light Panels / Canvas / Shapes /
Lines / Elements) via the documented HTTP REST API on port 16021.
First concrete consumer of the pair-UX scaffold from commit 2f31680 --
the abstraction is no longer speculative.

Backend:
- NanoleafClient is a single-pixel HTTP adapter: averages the strip to
  one RGB triple, converts to Nanoleaf's HSB scale (H 0-360 / S 0-100 /
  B 0-100), and PUTs to /api/v1/<token>/state with duration:0 so
  transitions are instant for ambilight. Brightness is clamped to >=1
  because Nanoleaf rejects brightness=0.
- pair_nanoleaf(host) implements the two-step handshake: POST
  /api/v1/new during the 30-second pairing window the controller opens
  after the user holds the power button for 5 s.
    200 -> {auth_token: "..."}
    403 -> raises PairingNotReady ("Hold the power button...")
    other / transport error -> RuntimeError wrapping the cause
- NanoleafDeviceProvider.pair_device returns {nanoleaf_token: ...}
  forwarded by POST /api/v1/devices/pair to the frontend for inclusion
  in the subsequent create payload.
- mDNS discovery via _nanoleafapi._tcp (and the v1 variant); failures
  yield [] rather than raising.
- Health check probes /api/v1 without a token (401/403 still proves
  the host is alive).
- NanoleafConfig has nanoleaf_token + nanoleaf_min_interval_ms
  (default 100 ms = ~10 Hz; HTTP overhead caps practical max ~20 Hz).
- Auth token encrypted at rest via _enc/_dec, matching Hue / BLE-Govee.
- 42 unit tests cover URL parsing, RGB->HSB conversion, pairing
  handshake (200 / 403 / 500 / missing-token / transport-error),
  state mutations, brightness clamp, set_power / set_brightness /
  set_color, connection lifecycle, provider validate / pair /
  discover / capabilities, and Device.to_config round-trip including
  the encrypted-token roundtrip via to_dict + from_dict.

Frontend:
- 'nanoleaf' in DEVICE_TYPE_KEYS (next to 'govee'), HEXAGON icon
  (deliberate departure from the smart-bulb lightbulb family --
  Nanoleaf is panels, not bulbs, and the brand identity is hexagonal).
- isNanoleafDevice predicate + per-type field show/hide.
- Pair flow integration: when the device type is Nanoleaf, the add-
  device modal retitles its submit button to "Pair Device" and
  intercepts the submit. handleAddDevice awaits
  runPairingFlow({deviceType: 'nanoleaf', url}), merges result.fields
  ({nanoleaf_token}) into the create body, then POSTs. On
  PairingCancelled the user stays on the modal silently.
- Settings modal exposes the rate-limit field and a read-only
  "Paired" indicator reusing the pair-modal success badge. The token
  itself is never rendered to the DOM and never sent on update --
  re-pairing requires delete + re-add.
- Per-type pairing instructions in en/ru/zh
  (device.nanoleaf.pair.instructions) that the scaffold's i18n lookup
  resolves automatically.
- Bundle: +6.4 KiB (pairing-flow.ts was tree-shaken before this
  commit; now both it and the Nanoleaf branches are baked in).

The pair-UX scaffold is now proven, not speculative. Tuya and Twinkly
can follow the same shape when their phases arrive.
2026-05-16 03:59:38 +03:00

1746 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, 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 { runPairingFlow, PairingCancelled } from './pairing-flow.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES, ICON_PALETTE } from '../core/icons.ts';
import { EntitySelect, EntityPalette } 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',
ddpPort: (document.getElementById('device-ddp-port') as HTMLInputElement)?.value || '0',
ddpDestinationId: (document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1',
ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1',
opcChannel: (document.getElementById('device-opc-channel') as HTMLInputElement)?.value || '0',
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '',
yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500',
wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50',
goveeMinInterval: (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value || '50',
nanoleafMinInterval: (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value || '100',
groupChildren: JSON.stringify(_getGroupChildIds('device')),
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
};
}
}
const addDeviceModal = new AddDeviceModal();
/* ── Icon-grid type selector ──────────────────────────────────── */
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'opc', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'govee', 'nanoleaf', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', '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 DDP color-order selector ──────────────────────── */
function _buildDdpColorOrderItems() {
return [
{ value: '1', icon: ICON_PALETTE, label: 'RGB', desc: t('device.ddp.color_order.rgb.desc') },
{ value: '0', icon: ICON_PALETTE, label: 'GRB', desc: t('device.ddp.color_order.grb.desc') },
{ value: '2', icon: ICON_PALETTE, label: 'BRG', desc: t('device.ddp.color_order.brg.desc') },
{ value: '3', icon: ICON_PALETTE, label: 'RBG', desc: t('device.ddp.color_order.rbg.desc') },
{ value: '4', icon: ICON_PALETTE, label: 'BGR', desc: t('device.ddp.color_order.bgr.desc') },
{ value: '5', icon: ICON_PALETTE, label: 'GBR', desc: t('device.ddp.color_order.gbr.desc') },
];
}
const _ddpColorOrderIconSelects: Record<string, any> = {};
export function ensureDdpColorOrderIconSelect(selectId: any) {
const sel = document.getElementById(selectId);
if (!sel) return;
if (_ddpColorOrderIconSelects[selectId]) {
_ddpColorOrderIconSelects[selectId].updateItems(_buildDdpColorOrderItems());
return;
}
_ddpColorOrderIconSelects[selectId] = new IconSelect({
target: sel,
items: _buildDdpColorOrderItems(),
columns: 3,
} as any);
}
export function destroyDdpColorOrderIconSelect(selectId: any) {
if (_ddpColorOrderIconSelects[selectId]) {
_ddpColorOrderIconSelects[selectId].destroy();
delete _ddpColorOrderIconSelects[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;
const ddpPortGroup = document.getElementById('device-ddp-port-group') as HTMLElement;
const ddpDestinationIdGroup = document.getElementById('device-ddp-destination-id-group') as HTMLElement;
const ddpColorOrderGroup = document.getElementById('device-ddp-color-order-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 DDP fields by default
if (ddpPortGroup) ddpPortGroup.style.display = 'none';
if (ddpDestinationIdGroup) ddpDestinationIdGroup.style.display = 'none';
if (ddpColorOrderGroup) ddpColorOrderGroup.style.display = 'none';
// Hide new device type fields by default
_showEspnowFields(false);
_showHueFields(false);
_showYeelightFields(false);
_showWizFields(false);
_showLifxFields(false);
_showGoveeFields(false);
_showNanoleafFields(false);
_showBleFields(false);
_showSpiFields(false);
_showChromaFields(false);
_showGameSenseFields(false);
_showGroupFields(false);
_showOpcFields(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 (isDdpDevice(deviceType)) {
// DDP: show URL (IP address), LED count, DDP-specific fields; hide
// serial/baud/discovery (no native discovery — user enters IP manually).
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 DDP-specific fields
if (ddpPortGroup) ddpPortGroup.style.display = '';
if (ddpDestinationIdGroup) ddpDestinationIdGroup.style.display = '';
if (ddpColorOrderGroup) ddpColorOrderGroup.style.display = '';
ensureDdpColorOrderIconSelect('device-ddp-color-order');
// Relabel URL field as IP Address
if (urlLabel) urlLabel.textContent = t('device.ddp.url');
if (urlHint) urlHint.textContent = t('device.ddp.url.hint');
urlInput.placeholder = t('device.ddp.url.placeholder') || '192.168.1.50';
} else if (isOpcDevice(deviceType)) {
// OPC: TCP-based multi-pixel open protocol (Fadecandy, xLights,
// hobbyist drivers). No native discovery — user enters IP manually.
// Single optional channel field (0 = broadcast).
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';
_showOpcFields(true);
// Relabel URL field as IP Address
if (urlLabel) urlLabel.textContent = t('device.opc.url');
if (urlHint) urlHint.textContent = t('device.opc.url.hint');
urlInput.placeholder = t('device.opc.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 (isYeelightDevice(deviceType)) {
// Yeelight: show URL (LAN IP), LED count (controls source mapping;
// the bulb itself averages to one color), rate-limit ms. SSDP
// discovery is supported — same scan button as WLED/Hue.
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 = '';
_showYeelightFields(true);
if (urlLabel) urlLabel.textContent = t('device.yeelight.url') || 'IP Address:';
if (urlHint) urlHint.textContent = t('device.yeelight.url.hint') || 'LAN IP of the Yeelight bulb. TCP port 55443 is fixed in the protocol.';
urlInput.placeholder = t('device.yeelight.url.placeholder') || '192.168.1.50';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isWizDevice(deviceType)) {
// WiZ: UDP fire-and-forget on port 38899. Show URL (LAN IP), LED
// count (controls source mapping; the bulb averages to one color),
// rate-limit ms. Discovery uses UDP broadcast — same scan flow.
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 = '';
_showWizFields(true);
if (urlLabel) urlLabel.textContent = t('device.wiz.url') || 'IP Address:';
if (urlHint) urlHint.textContent = t('device.wiz.url.hint') || 'LAN IP of the WiZ bulb. UDP port 38899 is the protocol default.';
urlInput.placeholder = t('device.wiz.url.placeholder') || '192.168.1.50';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isLifxDevice(deviceType)) {
// LIFX: binary UDP on port 56700. Show URL (LAN IP), LED count
// (controls source mapping; LIFX is single-pixel — HSBK averaged
// from the strip), rate-limit ms. Discovery uses UDP broadcast.
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 = '';
_showLifxFields(true);
if (urlLabel) urlLabel.textContent = t('device.lifx.url') || 'IP Address:';
if (urlHint) urlHint.textContent = t('device.lifx.url.hint') || 'LAN IP of the LIFX bulb. UDP port 56700 is the protocol default.';
urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isGoveeDevice(deviceType)) {
// Govee: 2023+ LAN API over UDP fire-and-forget on port 4003.
// Discovery uses multicast UDP 239.255.255.250:4001 — same scan
// flow as the rest of the LAN-bulb family. Each device requires
// "LAN Control" toggled ON inside the Govee Home app
// (Device → ⚙ → LAN Control); the hint copy mentions this since
// it's the #1 source of "why isn't my Govee responding?" issues.
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 = '';
_showGoveeFields(true);
if (urlLabel) urlLabel.textContent = t('device.govee.url') || 'IP Address:';
if (urlHint) urlHint.textContent = t('device.govee.url.hint') || 'LAN IP of the Govee device. Enable LAN Control in the Govee Home app first (Device → ⚙ → LAN Control), or the bulb wont respond.';
urlInput.placeholder = t('device.govee.url.placeholder') || '192.168.1.50';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isNanoleafDevice(deviceType)) {
// Nanoleaf: HTTP REST over fixed port 16021. The controller requires
// a one-time pairing handshake — the user holds the power button for
// 5 seconds until the LEDs flash, then the backend POSTs to /new and
// gets back an auth token. We delegate the handshake to runPairingFlow
// (see handleAddDevice below); discovery uses mDNS (_nanoleafapi._tcp).
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 = '';
_showNanoleafFields(true);
if (urlLabel) urlLabel.textContent = t('device.nanoleaf.url') || 'IP Address:';
if (urlHint) urlHint.textContent = t('device.nanoleaf.url.hint') || 'LAN IP of the Nanoleaf controller. HTTP port 16021 is fixed in the protocol.';
urlInput.placeholder = t('device.nanoleaf.url.placeholder') || '192.168.1.50';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isBleDevice(deviceType)) {
// BLE: show URL (ble://<address>), LED count, protocol family picker,
// and a Govee-only AES key field that toggles with the family selection.
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 = '';
_showBleFields(true);
_ensureBleFamilyIconSelect();
if (urlLabel) urlLabel.textContent = t('device.ble.url') || 'BLE Address';
if (urlHint) urlHint.textContent = t('device.ble.url.hint') || 'MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://';
urlInput.placeholder = 'ble://AA:BB:CC:DD:EE:FF';
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 if (isGroupDevice(deviceType)) {
// Group: hide URL/serial, show child device picker + mode + LED count (for independent)
urlGroup.style.display = 'none';
urlInput.removeAttribute('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 (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
_showGroupFields(true);
ensureGroupModeIconSelect('device-group-mode-select');
_updateGroupLedCountVisibility();
// Clear children list for fresh start
const childrenList = document.getElementById('device-group-children-list');
if (childrenList) childrenList.innerHTML = '';
} 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 BLE fields
if (isBleDevice(presetType)) {
const bleFamilyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (bleFamilyEl && cloneData.ble_family) {
bleFamilyEl.value = cloneData.ble_family;
const iconSelect = _bleFamilyIconSelects['device-ble-family'];
if (iconSelect) iconSelect.setValue(cloneData.ble_family);
}
const goveeKeyEl = document.getElementById('device-ble-govee-key') as HTMLInputElement;
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key;
_updateBleGoveeKeyVisibility();
}
// 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 DDP fields
if (isDdpDevice(presetType)) {
const ddpPort = document.getElementById('device-ddp-port') as HTMLInputElement;
if (ddpPort && cloneData.ddp_port != null) ddpPort.value = cloneData.ddp_port;
const ddpDest = document.getElementById('device-ddp-destination-id') as HTMLInputElement;
if (ddpDest && cloneData.ddp_destination_id != null) ddpDest.value = cloneData.ddp_destination_id;
const ddpColorOrder = document.getElementById('device-ddp-color-order') as HTMLSelectElement;
if (ddpColorOrder && cloneData.ddp_color_order != null) {
ddpColorOrder.value = String(cloneData.ddp_color_order);
const iconSelect = _ddpColorOrderIconSelects['device-ddp-color-order'];
if (iconSelect) iconSelect.setValue(String(cloneData.ddp_color_order));
}
}
// Prefill OPC fields
if (isOpcDevice(presetType)) {
const opcChannel = document.getElementById('device-opc-channel') as HTMLInputElement;
if (opcChannel && cloneData.opc_channel != null) opcChannel.value = String(cloneData.opc_channel);
}
// Prefill Yeelight fields
if (isYeelightDevice(presetType)) {
const ymi = document.getElementById('device-yeelight-min-interval') as HTMLInputElement;
if (ymi && cloneData.yeelight_min_interval_ms != null) {
ymi.value = String(cloneData.yeelight_min_interval_ms);
}
}
// Prefill WiZ fields
if (isWizDevice(presetType)) {
const wmi = document.getElementById('device-wiz-min-interval') as HTMLInputElement;
if (wmi && cloneData.wiz_min_interval_ms != null) {
wmi.value = String(cloneData.wiz_min_interval_ms);
}
}
// Prefill LIFX fields
if (isLifxDevice(presetType)) {
const lmi = document.getElementById('device-lifx-min-interval') as HTMLInputElement;
if (lmi && cloneData.lifx_min_interval_ms != null) {
lmi.value = String(cloneData.lifx_min_interval_ms);
}
}
// Prefill Nanoleaf fields (clone only carries the rate limit — the
// token is not exposed in /devices responses, so a cloned device
// must re-pair to get its own auth token).
if (isNanoleafDevice(presetType)) {
const nmi = document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement;
if (nmi && cloneData.nanoleaf_min_interval_ms != null) {
nmi.value = String(cloneData.nanoleaf_min_interval_ms);
}
}
// Prefill Govee fields
if (isGoveeDevice(presetType)) {
const gmi = document.getElementById('device-govee-min-interval') as HTMLInputElement;
if (gmi && cloneData.govee_min_interval_ms != null) {
gmi.value = String(cloneData.govee_min_interval_ms);
}
}
// 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 scanTimeout = scanType === 'ble' ? 8 : 3;
const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&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');
}
// Auto-fill the BLE protocol family detected during discovery so the
// user doesn't silently get the default (sp110e) against a different
// controller. Wrong family → writes go to a non-existent GATT
// characteristic and the strip stays dark.
if (isBleDevice(device.device_type) && device.ble_family) {
const familyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (familyEl) familyEl.value = device.ble_family;
const iconSelect = _bleFamilyIconSelects['device-ble-family'];
if (iconSelect) iconSelect.setValue(device.ble_family);
_updateBleGoveeKeyVisibility();
}
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 (isGroupDevice(deviceType)) {
url = 'group://virtual';
} else 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;
}
// Nanoleaf: ensure nanoleaf:// prefix so the URL the pair flow + the
// create endpoint see is identical and provider-resolvable.
if (isNanoleafDevice(deviceType) && url && !url.startsWith('nanoleaf://')) {
url = 'nanoleaf://' + 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) && !isGroupDevice(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 (isDdpDevice(deviceType)) {
body.ddp_port = parseInt((document.getElementById('device-ddp-port') as HTMLInputElement)?.value || '0', 10);
body.ddp_destination_id = parseInt((document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1', 10);
body.ddp_color_order = parseInt((document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', 10);
}
if (isOpcDevice(deviceType)) {
const raw = (document.getElementById('device-opc-channel') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '0', 10);
body.opc_channel = Number.isFinite(parsed) ? parsed : 0;
}
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 (isYeelightDevice(deviceType)) {
const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '500', 10);
body.yeelight_min_interval_ms = Number.isFinite(parsed) ? parsed : 500;
}
if (isWizDevice(deviceType)) {
const raw = (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '50', 10);
body.wiz_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
}
if (isLifxDevice(deviceType)) {
const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
}
if (isGoveeDevice(deviceType)) {
const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '50', 10);
body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
}
if (isNanoleafDevice(deviceType)) {
const raw = (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100;
}
if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
if (goveeKey) body.ble_govee_key = goveeKey;
}
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';
}
if (isGroupDevice(deviceType)) {
body.group_device_ids = _getGroupChildIds('device');
body.group_mode = (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence';
if (body.group_device_ids.length === 0) {
error.textContent = t('device.group.error.no_children');
error.style.display = 'block';
return;
}
}
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;
// Nanoleaf — and any future driver that advertises `requires_pairing` —
// needs an out-of-band handshake before the device row can be created.
// We open the shared pair modal (features/pairing-flow.ts), wait for
// the backend to negotiate a token with the controller, then merge the
// returned fields into the create body. A soft cancel (user closed the
// pair modal) bails out silently — the add-device modal is still open
// for them to retry or pick a different type.
if (isNanoleafDevice(deviceType)) {
try {
const pairResult = await runPairingFlow({ deviceType: 'nanoleaf', url });
Object.assign(body, pairResult.fields);
} catch (pairErr: unknown) {
if (pairErr instanceof PairingCancelled) return;
const msg = pairErr instanceof Error ? pairErr.message : t('pairing.failed_prefix');
showToast(msg, 'error');
return;
}
}
const response = await fetchWithAuth('/devices', {
method: 'POST',
body: JSON.stringify(body)
});
if (response.ok) {
const result = await response.json();
// result is logged by the API layer; no console.log here.
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 _showGroupFields(show: boolean, prefix = 'device') {
const ids = [`${prefix}-group-children-group`, `${prefix}-group-mode-group`];
ids.forEach(id => {
const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none';
});
}
/* ── Icon-grid group mode selector ────────────────────────────── */
function _buildGroupModeItems() {
return [
{ value: 'sequence', icon: ICON_GIT_MERGE, label: t('device.group.mode.sequence'), desc: t('device.group.mode.sequence.desc') },
{ value: 'independent', icon: ICON_COPY, label: t('device.group.mode.independent'), desc: t('device.group.mode.independent.desc') },
];
}
const _groupModeIconSelects: Record<string, any> = {};
export function ensureGroupModeIconSelect(selectId: string) {
const sel = document.getElementById(selectId);
if (!sel) return;
if (_groupModeIconSelects[selectId]) {
_groupModeIconSelects[selectId].updateItems(_buildGroupModeItems());
return;
}
_groupModeIconSelects[selectId] = new IconSelect({
target: sel as HTMLSelectElement,
items: _buildGroupModeItems(),
columns: 2,
onChange: () => _updateGroupLedCountVisibility(selectId.startsWith('settings-') ? 'settings' : 'device'),
} as any);
}
export function destroyGroupModeIconSelect(selectId: string) {
if (_groupModeIconSelects[selectId]) {
_groupModeIconSelects[selectId].destroy();
delete _groupModeIconSelects[selectId];
}
}
function _updateGroupLedCountVisibility(prefix = 'device') {
const sel = document.getElementById(`${prefix}-group-mode-select`) as HTMLSelectElement;
const mode = sel ? sel.value : 'sequence';
const ledCountGroup = document.getElementById(`${prefix}-led-count-group`) as HTMLElement;
if (ledCountGroup) ledCountGroup.style.display = mode === 'independent' ? '' : 'none';
}
function _renderGroupChildrenList(prefix = 'device') {
const list = document.getElementById(`${prefix}-group-children-list`) as HTMLElement;
if (!list) return;
const items = list.querySelectorAll('.group-child-row');
const count = items.length;
items.forEach((row, idx) => {
const label = row.querySelector('.group-child-index') as HTMLElement;
if (label) label.textContent = `#${idx + 1}`;
const upBtn = row.querySelector('.group-child-up') as HTMLButtonElement;
const downBtn = row.querySelector('.group-child-down') as HTMLButtonElement;
if (upBtn) upBtn.disabled = idx === 0;
if (downBtn) downBtn.disabled = idx === count - 1;
});
}
function _moveGroupChild(row: HTMLElement, direction: 'up' | 'down') {
const list = row.parentElement;
if (!list) return;
if (direction === 'up' && row.previousElementSibling) {
list.insertBefore(row, row.previousElementSibling);
} else if (direction === 'down' && row.nextElementSibling) {
list.insertBefore(row.nextElementSibling, row);
}
const prefix = list.id.startsWith('settings-') ? 'settings' : 'device';
_renderGroupChildrenList(prefix);
}
function _getDeviceItems() {
return (devicesCache.data || []).map((d: any) => ({
value: d.id,
label: d.name,
icon: getDeviceTypeIcon(d.device_type),
desc: `${d.led_count} LEDs`,
}));
}
function _updateGroupChildDisplay(row: HTMLElement, deviceId: string) {
const iconEl = row.querySelector('.group-child-icon') as HTMLElement;
const nameEl = row.querySelector('.group-child-name') as HTMLElement;
const metaEl = row.querySelector('.group-child-meta') as HTMLElement;
const device = (devicesCache.data || []).find((d: any) => d.id === deviceId);
if (device) {
iconEl.innerHTML = getDeviceTypeIcon(device.device_type);
nameEl.textContent = device.name;
metaEl.textContent = `${device.led_count} LEDs`;
row.classList.remove('group-child-empty');
row.dataset.deviceId = device.id;
} else {
iconEl.innerHTML = ICON_PLUS;
nameEl.textContent = t('device.group.select_device');
metaEl.textContent = '';
row.classList.add('group-child-empty');
row.dataset.deviceId = '';
}
}
async function _pickDeviceForRow(row: HTMLElement, prefix: string) {
const currentId = row.dataset.deviceId || '';
const picked = await EntityPalette.pick({
items: _getDeviceItems(),
current: currentId,
placeholder: t('device.group.select_device'),
});
if (picked != null) {
_updateGroupChildDisplay(row, picked as string);
}
}
function _addGroupChildRow(prefix = 'device', selectedId = '') {
const list = document.getElementById(`${prefix}-group-children-list`) as HTMLElement;
if (!list) return;
const row = document.createElement('div');
row.className = 'group-child-row' + (selectedId ? '' : ' group-child-empty');
row.dataset.deviceId = selectedId;
row.innerHTML =
`<span class="group-child-index">#${list.children.length + 1}</span>` +
`<div class="group-child-device" title="${t('device.group.select_device')}">` +
`<span class="group-child-icon">${ICON_PLUS}</span>` +
`<span class="group-child-name">${t('device.group.select_device')}</span>` +
`<span class="group-child-meta"></span>` +
`</div>` +
`<span class="group-child-actions">` +
`<button type="button" class="btn btn-sm btn-icon group-child-up" title="${t('device.group.move_up')}">${ICON_CHEVRON_UP}</button>` +
`<button type="button" class="btn btn-sm btn-icon group-child-down" title="${t('device.group.move_down')}">${ICON_CHEVRON_DOWN}</button>` +
`<button type="button" class="btn btn-sm btn-icon btn-danger group-child-remove" title="${t('common.remove')}">${ICON_TRASH}</button>` +
`</span>`;
if (selectedId) _updateGroupChildDisplay(row, selectedId);
row.querySelector('.group-child-device')!.addEventListener('click', () => _pickDeviceForRow(row, prefix));
row.querySelector('.group-child-up')!.addEventListener('click', () => _moveGroupChild(row, 'up'));
row.querySelector('.group-child-down')!.addEventListener('click', () => _moveGroupChild(row, 'down'));
row.querySelector('.group-child-remove')!.addEventListener('click', () => {
row.remove();
_renderGroupChildrenList(prefix);
});
list.appendChild(row);
_renderGroupChildrenList(prefix);
}
function _getGroupChildIds(prefix = 'device'): string[] {
const list = document.getElementById(`${prefix}-group-children-list`) as HTMLElement;
if (!list) return [];
const rows = list.querySelectorAll('.group-child-row') as NodeListOf<HTMLElement>;
return Array.from(rows).map(r => r.dataset.deviceId || '').filter(v => v !== '');
}
export function addGroupChild() {
_addGroupChildRow('device');
}
export function addGroupChildSettings() {
_addGroupChildRow('settings');
}
export function addGroupChildSettingsWithId(deviceId: string) {
_addGroupChildRow('settings', deviceId);
}
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 _showYeelightFields(show: boolean) {
const el = document.getElementById('device-yeelight-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
}
function _showOpcFields(show: boolean) {
const el = document.getElementById('device-opc-channel-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
}
function _showWizFields(show: boolean) {
const el = document.getElementById('device-wiz-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
}
function _showLifxFields(show: boolean) {
const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
}
function _showGoveeFields(show: boolean) {
const el = document.getElementById('device-govee-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
}
/**
* Toggle Nanoleaf-specific rows AND swap the footer submit button into
* "Pair Device" mode. Nanoleaf is the first driver that requires an
* out-of-band handshake (hold power button → backend POSTs /new for a
* token), so the submit button's tooltip changes to signal that the
* next click runs pairing rather than just creating the row. The actual
* pair flow is invoked from handleAddDevice when device_type is 'nanoleaf'.
*/
function _showNanoleafFields(show: boolean) {
const el = document.getElementById('device-nanoleaf-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
const submitBtn = document.getElementById('add-device-submit-btn') as HTMLButtonElement | null;
if (submitBtn) {
if (show) {
const label = t('device.nanoleaf.pair_button') || 'Pair Device';
submitBtn.title = label;
submitBtn.setAttribute('aria-label', label);
submitBtn.setAttribute('data-pair-mode', 'nanoleaf');
} else {
const label = t('aria.save') || 'Add Device';
submitBtn.title = label;
submitBtn.setAttribute('aria-label', label);
submitBtn.removeAttribute('data-pair-mode');
}
}
}
// Tracks whether the BLE fields are currently shown — avoids reading
// style.display strings in _updateBleGoveeKeyVisibility.
let _bleFieldsVisible = false;
function _showBleFields(show: boolean) {
_bleFieldsVisible = show;
const familyGroup = document.getElementById('device-ble-family-group') as HTMLElement;
if (familyGroup) familyGroup.style.display = show ? '' : 'none';
if (!show) _destroyBleFamilyIconSelect();
_updateBleGoveeKeyVisibility();
}
function _updateBleGoveeKeyVisibility() {
const family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value;
const goveeGroup = document.getElementById('device-ble-govee-key-group') as HTMLElement;
if (goveeGroup) goveeGroup.style.display = _bleFieldsVisible && family === 'govee' ? '' : 'none';
}
function _buildBleFamilyItems() {
return [
{ value: 'sp110e', icon: ICON_CPU, label: 'SP110E / SP108E', desc: t('device.ble.family.sp110e.desc') },
{ value: 'triones', icon: ICON_BLUETOOTH, label: 'Triones / HappyLighting / LEDnet', desc: t('device.ble.family.triones.desc') },
{ value: 'zengge', icon: ICON_LIGHTBULB, label: 'Zengge / iLightsIn', desc: t('device.ble.family.zengge.desc') },
{ value: 'govee', icon: ICON_BLUETOOTH, label: 'Govee (experimental)', desc: t('device.ble.family.govee.desc') },
];
}
// Parameterized by select element ID so both the add-device modal
// (``device-ble-family``) and the settings modal (``settings-ble-family``)
// get their own IconSelect instance — same pattern as DMX/SPI/etc.
const _bleFamilyIconSelects: Record<string, any> = {};
export function destroyBleFamilyIconSelect(selectId: string) {
if (_bleFamilyIconSelects[selectId]) {
_bleFamilyIconSelects[selectId].destroy();
delete _bleFamilyIconSelects[selectId];
}
}
export function ensureBleFamilyIconSelect(selectId: string, onChange?: () => void): any {
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
if (!sel) return null;
if (_bleFamilyIconSelects[selectId]) {
_bleFamilyIconSelects[selectId].updateItems(_buildBleFamilyItems());
return _bleFamilyIconSelects[selectId];
}
_bleFamilyIconSelects[selectId] = new IconSelect({
target: sel,
items: _buildBleFamilyItems(),
columns: 2,
} as any);
if (onChange) {
sel.addEventListener('change', onChange);
}
return _bleFamilyIconSelects[selectId];
}
// Thin wrappers used by the add-device modal.
function _destroyBleFamilyIconSelect() {
destroyBleFamilyIconSelect('device-ble-family');
}
function _ensureBleFamilyIconSelect() {
ensureBleFamilyIconSelect('device-ble-family', _updateBleGoveeKeyVisibility);
_updateBleGoveeKeyVisibility();
}
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');
}
}