426484adf8
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.
1746 lines
83 KiB
TypeScript
1746 lines
83 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, 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 won’t 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');
|
||
}
|
||
}
|