Add WLED auto-discovery via mDNS with zeroconf
Scan the local network for WLED devices advertising _wled._tcp.local. and present them in the Add Device modal for one-click selection. - New discovery.py: async mDNS browse + parallel /json/info enrichment - GET /api/v1/devices/discover endpoint with already_added dedup - Header scan button (magnifying glass icon) in add-device modal - Discovered devices show name, IP, LED count, version; click to fill form - en/ru locale strings for discovery UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -879,6 +879,16 @@ function showAddDevice() {
|
||||
const error = document.getElementById('add-device-error');
|
||||
form.reset();
|
||||
error.style.display = 'none';
|
||||
// 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';
|
||||
}
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||
@@ -890,6 +900,79 @@ function closeAddDeviceModal() {
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
async function scanForDevices() {
|
||||
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');
|
||||
|
||||
section.style.display = 'block';
|
||||
loading.style.display = 'flex';
|
||||
list.innerHTML = '';
|
||||
empty.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/discover?timeout=3`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.devices.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
data.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);
|
||||
});
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
console.error('Device scan failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDiscoveredDevice(device) {
|
||||
document.getElementById('device-name').value = device.name;
|
||||
document.getElementById('device-url').value = device.url;
|
||||
const typeSelect = document.getElementById('device-type');
|
||||
if (typeSelect) typeSelect.value = device.device_type;
|
||||
showToast(t('device.scan.selected'), 'info');
|
||||
}
|
||||
|
||||
async function handleAddDevice(event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
@@ -570,9 +570,22 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="devices.add">Add New Device</h2>
|
||||
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close">✕</button>
|
||||
<div class="modal-header-actions">
|
||||
<button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery">🔍</button>
|
||||
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="discovery-section" class="discovery-section" style="display: none;">
|
||||
<div id="discovery-loading" class="discovery-loading" style="display: none;">
|
||||
<span class="discovery-spinner"></span>
|
||||
</div>
|
||||
<div id="discovery-list" class="discovery-list"></div>
|
||||
<div id="discovery-empty" style="display: none;">
|
||||
<small data-i18n="device.scan.empty">No WLED devices found on the network</small>
|
||||
</div>
|
||||
<hr class="modal-divider">
|
||||
</div>
|
||||
<form id="add-device-form">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"devices.wled_webui_link": "WLED Web UI",
|
||||
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
||||
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
||||
"device.scan": "Auto Discovery",
|
||||
"device.scan.empty": "No WLED devices found on the network",
|
||||
"device.scan.error": "Network scan failed",
|
||||
"device.scan.already_added": "Already added",
|
||||
"device.scan.selected": "Device selected",
|
||||
"device.type": "Device Type:",
|
||||
"device.type.hint": "Select the type of LED controller",
|
||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"devices.wled_webui_link": "веб-интерфейс WLED",
|
||||
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
||||
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
||||
"device.scan": "Автопоиск",
|
||||
"device.scan.empty": "WLED устройства не найдены в сети",
|
||||
"device.scan.error": "Ошибка сканирования сети",
|
||||
"device.scan.already_added": "Уже добавлено",
|
||||
"device.scan.selected": "Устройство выбрано",
|
||||
"device.type": "Тип устройства:",
|
||||
"device.type.hint": "Выберите тип LED контроллера",
|
||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||
|
||||
@@ -352,6 +352,73 @@ section {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Device discovery */
|
||||
.discovery-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
.discovery-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.discovery-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.discovery-item:not(.discovery-item--added):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.discovery-item--added {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.discovery-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.discovery-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 12px 0;
|
||||
}
|
||||
.discovery-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.discovery-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.channel-indicator {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
@@ -949,6 +1016,34 @@ input:-webkit-autofill:focus {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-header-btn:hover {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
.modal-header-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user