fa81d6a608
- New WS device type: broadcaster singleton + LEDClient that sends binary
frames to connected WebSocket clients during processing
- FastAPI WS endpoint at /api/v1/devices/{device_id}/ws with token auth
- Frontend: add/edit WS devices, connection URL with copy button in settings
- Add health_check and auto_restore capabilities to WLED and Serial providers;
hide health interval and auto-restore toggle for devices without them
- Skip health check loop for virtual devices (Mock, MQTT, WS) — set always-online
- Copy buttons and labels for API CSS push endpoints (REST POST / WebSocket)
- Hide mock:// and ws:// URLs in target device dropdown
- Hide filter textbox when card section is collapsed (cs-collapsed CSS class)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
423 lines
17 KiB
JavaScript
423 lines
17 KiB
JavaScript
/**
|
|
* Device discovery — add device modal, network/serial scanning, device type switching.
|
|
*/
|
|
|
|
import {
|
|
_discoveryScanRunning, set_discoveryScanRunning,
|
|
_discoveryCache, set_discoveryCache,
|
|
} from '../core/state.js';
|
|
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, escapeHtml } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { _computeMaxFps, _renderFpsHint } from './devices.js';
|
|
|
|
class AddDeviceModal extends Modal {
|
|
constructor() { super('add-device-modal'); }
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: document.getElementById('device-name').value,
|
|
type: document.getElementById('device-type').value,
|
|
url: document.getElementById('device-url').value,
|
|
serialPort: document.getElementById('device-serial-port').value,
|
|
ledCount: document.getElementById('device-led-count').value,
|
|
baudRate: document.getElementById('device-baud-rate').value,
|
|
ledType: document.getElementById('device-led-type')?.value || 'rgb',
|
|
sendLatency: document.getElementById('device-send-latency')?.value || '0',
|
|
};
|
|
}
|
|
}
|
|
|
|
const addDeviceModal = new AddDeviceModal();
|
|
|
|
export function onDeviceTypeChanged() {
|
|
const deviceType = document.getElementById('device-type').value;
|
|
const urlGroup = document.getElementById('device-url-group');
|
|
const urlInput = document.getElementById('device-url');
|
|
const serialGroup = document.getElementById('device-serial-port-group');
|
|
const serialSelect = document.getElementById('device-serial-port');
|
|
const ledCountGroup = document.getElementById('device-led-count-group');
|
|
const discoverySection = document.getElementById('discovery-section');
|
|
const baudRateGroup = document.getElementById('device-baud-rate-group');
|
|
const ledTypeGroup = document.getElementById('device-led-type-group');
|
|
const sendLatencyGroup = document.getElementById('device-send-latency-group');
|
|
|
|
// URL label / hint / placeholder — adapt per device type
|
|
const urlLabel = document.getElementById('device-url-label');
|
|
const urlHint = document.getElementById('device-url-hint');
|
|
|
|
const scanBtn = document.getElementById('scan-network-btn');
|
|
|
|
if (isMqttDevice(deviceType)) {
|
|
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
|
|
urlGroup.style.display = '';
|
|
urlInput.setAttribute('required', '');
|
|
serialGroup.style.display = 'none';
|
|
serialSelect.removeAttribute('required');
|
|
ledCountGroup.style.display = '';
|
|
baudRateGroup.style.display = 'none';
|
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
|
if (discoverySection) discoverySection.style.display = 'none';
|
|
if (scanBtn) scanBtn.style.display = 'none';
|
|
// Relabel URL field as "Topic"
|
|
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
|
|
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
|
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
|
} else if (isMockDevice(deviceType)) {
|
|
urlGroup.style.display = 'none';
|
|
urlInput.removeAttribute('required');
|
|
serialGroup.style.display = 'none';
|
|
serialSelect.removeAttribute('required');
|
|
ledCountGroup.style.display = '';
|
|
baudRateGroup.style.display = 'none';
|
|
if (ledTypeGroup) ledTypeGroup.style.display = '';
|
|
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
|
|
if (discoverySection) discoverySection.style.display = 'none';
|
|
if (scanBtn) scanBtn.style.display = 'none';
|
|
} else if (isWsDevice(deviceType)) {
|
|
urlGroup.style.display = 'none';
|
|
urlInput.removeAttribute('required');
|
|
serialGroup.style.display = 'none';
|
|
serialSelect.removeAttribute('required');
|
|
ledCountGroup.style.display = '';
|
|
baudRateGroup.style.display = 'none';
|
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
|
if (discoverySection) discoverySection.style.display = 'none';
|
|
if (scanBtn) scanBtn.style.display = 'none';
|
|
} else if (isSerialDevice(deviceType)) {
|
|
urlGroup.style.display = 'none';
|
|
urlInput.removeAttribute('required');
|
|
serialGroup.style.display = '';
|
|
serialSelect.setAttribute('required', '');
|
|
ledCountGroup.style.display = '';
|
|
baudRateGroup.style.display = '';
|
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
|
if (scanBtn) scanBtn.style.display = 'none';
|
|
// Hide discovery list — serial port dropdown replaces it
|
|
if (discoverySection) discoverySection.style.display = 'none';
|
|
// Populate from cache or show placeholder (lazy-load on focus)
|
|
if (deviceType in _discoveryCache) {
|
|
_populateSerialPortDropdown(_discoveryCache[deviceType]);
|
|
} else {
|
|
serialSelect.innerHTML = '';
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...';
|
|
opt.disabled = true;
|
|
serialSelect.appendChild(opt);
|
|
}
|
|
updateBaudFpsHint();
|
|
} else {
|
|
urlGroup.style.display = '';
|
|
urlInput.setAttribute('required', '');
|
|
serialGroup.style.display = 'none';
|
|
serialSelect.removeAttribute('required');
|
|
ledCountGroup.style.display = 'none';
|
|
baudRateGroup.style.display = 'none';
|
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
|
if (scanBtn) scanBtn.style.display = '';
|
|
// Restore default URL label/hint/placeholder
|
|
if (urlLabel) urlLabel.textContent = t('device.url');
|
|
if (urlHint) urlHint.textContent = t('device.url.hint');
|
|
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
|
|
// Show cached results or trigger scan for WLED
|
|
if (deviceType in _discoveryCache) {
|
|
_renderDiscoveryList();
|
|
} else {
|
|
scanForDevices();
|
|
}
|
|
}
|
|
}
|
|
|
|
export function updateBaudFpsHint() {
|
|
const hintEl = document.getElementById('baud-fps-hint');
|
|
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
|
|
const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
|
|
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
|
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
|
|
}
|
|
|
|
function _renderDiscoveryList() {
|
|
const selectedType = document.getElementById('device-type').value;
|
|
const devices = _discoveryCache[selectedType];
|
|
|
|
// Serial devices: populate serial port dropdown instead of discovery list
|
|
if (isSerialDevice(selectedType)) {
|
|
_populateSerialPortDropdown(devices || []);
|
|
return;
|
|
}
|
|
|
|
// WLED and others: render discovery list cards
|
|
const list = document.getElementById('discovery-list');
|
|
const empty = document.getElementById('discovery-empty');
|
|
const section = document.getElementById('discovery-section');
|
|
if (!list || !section) return;
|
|
|
|
list.innerHTML = '';
|
|
|
|
if (!devices) {
|
|
section.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
section.style.display = 'block';
|
|
|
|
if (devices.length === 0) {
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
devices.forEach(device => {
|
|
const card = document.createElement('div');
|
|
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
|
const meta = [device.ip];
|
|
if (device.led_count) meta.push(device.led_count + ' LEDs');
|
|
if (device.version) meta.push('v' + device.version);
|
|
card.innerHTML = `
|
|
<div class="discovery-item-info">
|
|
<strong>${escapeHtml(device.name)}</strong>
|
|
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
|
|
</div>
|
|
${device.already_added
|
|
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
|
|
: ''}
|
|
`;
|
|
if (!device.already_added) {
|
|
card.onclick = () => selectDiscoveredDevice(device);
|
|
}
|
|
list.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function _populateSerialPortDropdown(devices) {
|
|
const select = document.getElementById('device-serial-port');
|
|
select.innerHTML = '';
|
|
|
|
if (devices.length === 0) {
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = t('device.serial_port.none') || 'No serial ports found';
|
|
opt.disabled = true;
|
|
select.appendChild(opt);
|
|
return;
|
|
}
|
|
|
|
devices.forEach(device => {
|
|
const opt = document.createElement('option');
|
|
opt.value = device.url;
|
|
opt.textContent = device.name;
|
|
if (device.already_added) {
|
|
opt.textContent += ' (' + t('device.scan.already_added') + ')';
|
|
}
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
export function onSerialPortFocus() {
|
|
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
|
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
|
if (!(deviceType in _discoveryCache)) {
|
|
scanForDevices(deviceType);
|
|
}
|
|
}
|
|
|
|
export function showAddDevice() {
|
|
const form = document.getElementById('add-device-form');
|
|
const error = document.getElementById('add-device-error');
|
|
form.reset();
|
|
error.style.display = 'none';
|
|
set_discoveryCache({});
|
|
// Reset discovery section
|
|
const section = document.getElementById('discovery-section');
|
|
if (section) {
|
|
section.style.display = 'none';
|
|
document.getElementById('discovery-list').innerHTML = '';
|
|
document.getElementById('discovery-empty').style.display = 'none';
|
|
document.getElementById('discovery-loading').style.display = 'none';
|
|
}
|
|
// Reset serial port dropdown
|
|
document.getElementById('device-serial-port').innerHTML = '';
|
|
const scanBtn = document.getElementById('scan-network-btn');
|
|
if (scanBtn) scanBtn.disabled = false;
|
|
addDeviceModal.open();
|
|
onDeviceTypeChanged();
|
|
setTimeout(() => {
|
|
document.getElementById('device-name').focus();
|
|
addDeviceModal.snapshot();
|
|
}, 100);
|
|
}
|
|
|
|
export async function closeAddDeviceModal() {
|
|
await addDeviceModal.close();
|
|
}
|
|
|
|
export async function scanForDevices(forceType) {
|
|
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
|
|
|
|
// Per-type guard: prevent duplicate scans for the same type
|
|
if (_discoveryScanRunning === scanType) return;
|
|
set_discoveryScanRunning(scanType);
|
|
|
|
const loading = document.getElementById('discovery-loading');
|
|
const list = document.getElementById('discovery-list');
|
|
const empty = document.getElementById('discovery-empty');
|
|
const section = document.getElementById('discovery-section');
|
|
const scanBtn = document.getElementById('scan-network-btn');
|
|
|
|
if (isSerialDevice(scanType)) {
|
|
// Show loading in the serial port dropdown
|
|
const select = document.getElementById('device-serial-port');
|
|
select.innerHTML = '';
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = '\u23F3';
|
|
opt.disabled = true;
|
|
select.appendChild(opt);
|
|
} else {
|
|
// Show the discovery section with loading spinner
|
|
section.style.display = 'block';
|
|
loading.style.display = 'flex';
|
|
list.innerHTML = '';
|
|
empty.style.display = 'none';
|
|
}
|
|
if (scanBtn) scanBtn.disabled = true;
|
|
|
|
try {
|
|
const response = await fetchWithAuth(`/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`);
|
|
|
|
loading.style.display = 'none';
|
|
if (scanBtn) scanBtn.disabled = false;
|
|
|
|
if (!response.ok) {
|
|
if (!isSerialDevice(scanType)) {
|
|
empty.style.display = 'block';
|
|
empty.querySelector('small').textContent = t('device.scan.error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
_discoveryCache[scanType] = data.devices || [];
|
|
|
|
// Only render if the user is still on this type
|
|
const currentType = document.getElementById('device-type')?.value;
|
|
if (currentType === scanType) {
|
|
_renderDiscoveryList();
|
|
}
|
|
} catch (err) {
|
|
if (err.isAuth) return;
|
|
loading.style.display = 'none';
|
|
if (scanBtn) scanBtn.disabled = false;
|
|
if (!isSerialDevice(scanType)) {
|
|
empty.style.display = 'block';
|
|
empty.querySelector('small').textContent = t('device.scan.error');
|
|
}
|
|
console.error('Device scan failed:', err);
|
|
} finally {
|
|
if (_discoveryScanRunning === scanType) {
|
|
set_discoveryScanRunning(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function selectDiscoveredDevice(device) {
|
|
document.getElementById('device-name').value = device.name;
|
|
const typeSelect = document.getElementById('device-type');
|
|
if (typeSelect) typeSelect.value = device.device_type;
|
|
onDeviceTypeChanged();
|
|
if (isSerialDevice(device.device_type)) {
|
|
document.getElementById('device-serial-port').value = device.url;
|
|
} else {
|
|
document.getElementById('device-url').value = device.url;
|
|
}
|
|
showToast(t('device.scan.selected'), 'info');
|
|
}
|
|
|
|
export async function handleAddDevice(event) {
|
|
event.preventDefault();
|
|
|
|
const name = document.getElementById('device-name').value.trim();
|
|
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
|
const error = document.getElementById('add-device-error');
|
|
|
|
let url;
|
|
if (isMockDevice(deviceType)) {
|
|
url = 'mock://';
|
|
} else if (isWsDevice(deviceType)) {
|
|
url = 'ws://';
|
|
} else if (isSerialDevice(deviceType)) {
|
|
url = document.getElementById('device-serial-port').value;
|
|
} else {
|
|
url = document.getElementById('device-url').value.trim();
|
|
}
|
|
|
|
// MQTT: ensure mqtt:// prefix
|
|
if (isMqttDevice(deviceType) && url && !url.startsWith('mqtt://')) {
|
|
url = 'mqtt://' + url;
|
|
}
|
|
|
|
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
|
|
error.textContent = t('device_discovery.error.fill_all_fields');
|
|
error.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const body = { name, url, device_type: deviceType };
|
|
const ledCountInput = document.getElementById('device-led-count');
|
|
if (ledCountInput && ledCountInput.value) {
|
|
body.led_count = parseInt(ledCountInput.value, 10);
|
|
}
|
|
const baudRateSelect = document.getElementById('device-baud-rate');
|
|
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
|
|
body.baud_rate = parseInt(baudRateSelect.value, 10);
|
|
}
|
|
if (isMockDevice(deviceType)) {
|
|
const sendLatency = document.getElementById('device-send-latency')?.value;
|
|
if (sendLatency) body.send_latency_ms = parseInt(sendLatency, 10);
|
|
const ledType = document.getElementById('device-led-type')?.value;
|
|
body.rgbw = ledType === 'rgbw';
|
|
}
|
|
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
|
if (lastTemplateId) {
|
|
body.capture_template_id = lastTemplateId;
|
|
}
|
|
|
|
const response = await fetchWithAuth('/devices', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('Device added successfully:', result);
|
|
showToast(t('device_discovery.added'), 'success');
|
|
addDeviceModal.forceClose();
|
|
// Use window.* to avoid circular imports
|
|
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
|
// Auto-start device tutorial on first device add
|
|
if (!localStorage.getItem('deviceTutorialSeen')) {
|
|
localStorage.setItem('deviceTutorialSeen', '1');
|
|
setTimeout(() => {
|
|
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
|
|
}, 300);
|
|
}
|
|
} else {
|
|
const errorData = await response.json();
|
|
console.error('Failed to add device:', errorData);
|
|
error.textContent = t('device_discovery.error.add_failed');
|
|
error.style.display = 'block';
|
|
}
|
|
} catch (err) {
|
|
if (err.isAuth) return;
|
|
console.error('Failed to add device:', err);
|
|
showToast(t('device_discovery.error.add_failed'), 'error');
|
|
}
|
|
}
|