Add Adalight serial LED device support with per-type discovery and capability-based UI

Implements the second device provider (Adalight) for Arduino-based serial LED controllers,
validating the LEDDeviceProvider abstraction. Adds serial port auto-discovery, per-type
discovery caching with lazy-load, capability-driven UI (brightness control, manual LED count,
standby), and serial port combobox in both Add Device and General Settings modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 15:55:42 +03:00
parent 242718a9a9
commit 1612c04c90
13 changed files with 678 additions and 76 deletions

View File

@@ -38,6 +38,7 @@ dependencies = [
"python-multipart>=0.0.12",
"wmi>=1.5.1; sys_platform == 'win32'",
"zeroconf>=0.131.0",
"pyserial>=3.5",
]
[project.optional-dependencies]

View File

@@ -83,7 +83,12 @@ async def create_device(
try:
result = await provider.validate_device(device_url)
led_count = result["led_count"]
led_count = result.get("led_count") or device_data.led_count
if not led_count or led_count < 1:
raise HTTPException(
status_code=422,
detail="LED count is required for this device type.",
)
except httpx.ConnectError:
raise HTTPException(
status_code=422,
@@ -146,18 +151,28 @@ async def discover_devices(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
timeout: float = 3.0,
device_type: str | None = None,
):
"""Scan the local network for LED devices via all registered providers."""
"""Scan for LED devices. Optionally filter by device_type (e.g. wled, adalight)."""
import asyncio
import time
start = time.time()
capped_timeout = min(timeout, 10.0)
# Discover from all providers in parallel
providers = get_all_providers()
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
all_results = await asyncio.gather(*discover_tasks)
discovered = [d for batch in all_results for d in batch]
if device_type:
# Discover from a single provider
try:
provider = get_provider(device_type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
discovered = await provider.discover(timeout=capped_timeout)
else:
# Discover from all providers in parallel
providers = get_all_providers()
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
all_results = await asyncio.gather(*discover_tasks)
discovered = [d for batch in all_results for d in batch]
elapsed_ms = (time.time() - start) * 1000
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
@@ -213,6 +228,7 @@ async def update_device(
name=update_data.name,
url=update_data.url,
enabled=update_data.enabled,
led_count=update_data.led_count,
)
# Sync connection info in processor manager
@@ -220,7 +236,7 @@ async def update_device(
manager.update_device_info(
device_id,
device_url=update_data.url,
led_count=None,
led_count=update_data.led_count,
)
except ValueError:
pass

View File

@@ -10,16 +10,18 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: str = Field(description="Device URL (e.g., http://192.168.1.100)")
device_type: str = Field(default="wled", description="LED device type (e.g., wled)")
url: str = Field(description="Device URL (e.g., http://192.168.1.100 or COM3)")
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
class DeviceUpdate(BaseModel):
"""Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="WLED device URL")
url: Optional[str] = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
class Calibration(BaseModel):

View File

@@ -0,0 +1,204 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import time
from datetime import datetime
from typing import List, Optional, Tuple
import numpy as np
from wled_controller.core.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
DEFAULT_BAUD_RATE = 115200
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
def parse_adalight_url(url: str) -> Tuple[str, int]:
"""Parse an Adalight URL into (port, baud_rate).
Formats:
"COM3" -> ("COM3", 115200)
"COM3:230400" -> ("COM3", 230400)
"/dev/ttyUSB0" -> ("/dev/ttyUSB0", 115200)
"""
url = url.strip()
if ":" in url and not url.startswith("/"):
# Windows COM port with baud: "COM3:230400"
parts = url.rsplit(":", 1)
try:
baud = int(parts[1])
return parts[0], baud
except ValueError:
pass
elif ":" in url and url.startswith("/"):
# Unix path with baud: "/dev/ttyUSB0:230400"
parts = url.rsplit(":", 1)
try:
baud = int(parts[1])
return parts[0], baud
except ValueError:
pass
return url, DEFAULT_BAUD_RATE
def _build_adalight_header(led_count: int) -> bytes:
"""Build the 6-byte Adalight protocol header.
Format: 'A' 'd' 'a' <count_hi> <count_lo> <checksum>
where count = led_count - 1 (zero-indexed).
"""
count = led_count - 1
hi = (count >> 8) & 0xFF
lo = count & 0xFF
checksum = hi ^ lo ^ 0x55
return bytes([ord("A"), ord("d"), ord("a"), hi, lo, checksum])
class AdalightClient(LEDClient):
"""LED client for Arduino Adalight serial devices."""
def __init__(self, url: str, led_count: int = 0, **kwargs):
"""Initialize Adalight client.
Args:
url: Serial port string, e.g. "COM3" or "COM3:230400"
led_count: Number of LEDs on the strip (required for Adalight header)
"""
self._port, self._baud_rate = parse_adalight_url(url)
self._led_count = led_count
self._serial = None
self._connected = False
# Pre-compute Adalight header if led_count is known
self._header = _build_adalight_header(led_count) if led_count > 0 else b""
# Pre-allocate numpy buffer for brightness scaling
self._pixel_buf = None
async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset."""
import serial
try:
self._serial = serial.Serial(
port=self._port,
baudrate=self._baud_rate,
timeout=1,
)
# Wait for Arduino to finish bootloader reset
time.sleep(ARDUINO_RESET_DELAY)
self._connected = True
logger.info(
f"Adalight connected: {self._port} @ {self._baud_rate} baud "
f"({self._led_count} LEDs)"
)
return True
except Exception as e:
logger.error(f"Failed to open serial port {self._port}: {e}")
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
async def close(self) -> None:
"""Close the serial port."""
self._connected = False
if self._serial and self._serial.is_open:
try:
self._serial.close()
except Exception as e:
logger.warning(f"Error closing serial port: {e}")
self._serial = None
logger.info(f"Adalight disconnected: {self._port}")
@property
def is_connected(self) -> bool:
return self._connected and self._serial is not None and self._serial.is_open
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
) -> bool:
"""Send pixel data over serial using Adalight protocol."""
if not self.is_connected:
return False
try:
frame = self._build_frame(pixels, brightness)
self._serial.write(frame)
return True
except Exception as e:
logger.error(f"Adalight send_pixels error: {e}")
return False
@property
def supports_fast_send(self) -> bool:
return True
def send_pixels_fast(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
) -> None:
"""Synchronous fire-and-forget serial send."""
if not self.is_connected:
return
try:
frame = self._build_frame(pixels, brightness)
self._serial.write(frame)
except Exception as e:
logger.error(f"Adalight send_pixels_fast error: {e}")
def _build_frame(self, pixels: List[Tuple[int, int, int]], brightness: int) -> bytes:
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check if the serial port exists without opening it.
Enumerates COM ports to avoid exclusive-access conflicts on Windows.
"""
port, _baud = parse_adalight_url(url)
try:
import serial.tools.list_ports
available_ports = [p.device for p in serial.tools.list_ports.comports()]
port_upper = port.upper()
found = any(p.upper() == port_upper for p in available_ports)
if found:
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
device_name=prev_health.device_name if prev_health else None,
device_version=None,
device_led_count=prev_health.device_led_count if prev_health else None,
)
else:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
error=f"Serial port {port} not found",
)
except Exception as e:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
error=str(e),
)

View File

@@ -0,0 +1,93 @@
"""Adalight device provider — serial LED controller support."""
from typing import List
from wled_controller.core.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AdalightDeviceProvider(LEDDeviceProvider):
"""Provider for Adalight serial LED controllers."""
@property
def device_type(self) -> str:
return "adalight"
@property
def capabilities(self) -> set:
# No hardware brightness control, no standby required
# manual_led_count: user must specify LED count (can't auto-detect)
return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.adalight_client import AdalightClient
led_count = kwargs.pop("led_count", 0)
kwargs.pop("use_ddp", None) # Not applicable for serial
return AdalightClient(url, led_count=led_count, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
from wled_controller.core.adalight_client import AdalightClient
return await AdalightClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate that the serial port exists.
Returns:
Empty dict — Adalight devices don't report LED count,
so it must be provided by the user.
"""
from wled_controller.core.adalight_client import parse_adalight_url
port, _baud = parse_adalight_url(url)
try:
import serial.tools.list_ports
available_ports = [p.device for p in serial.tools.list_ports.comports()]
port_upper = port.upper()
if not any(p.upper() == port_upper for p in available_ports):
raise ValueError(
f"Serial port {port} not found. "
f"Available ports: {', '.join(available_ports) or 'none'}"
)
except ValueError:
raise
except Exception as e:
raise ValueError(f"Failed to enumerate serial ports: {e}")
logger.info(f"Adalight device validated: port {port}")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover serial ports that could be Adalight devices."""
try:
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
results = []
for port_info in ports:
results.append(
DiscoveredDevice(
name=port_info.description or port_info.device,
url=port_info.device,
device_type="adalight",
ip=port_info.device,
mac="",
led_count=None,
version=None,
)
)
logger.info(f"Serial port scan found {len(results)} port(s)")
return results
except Exception as e:
logger.error(f"Serial port discovery failed: {e}")
return []

View File

@@ -258,5 +258,8 @@ def _register_builtin_providers():
from wled_controller.core.wled_provider import WLEDDeviceProvider
register_provider(WLEDDeviceProvider())
from wled_controller.core.adalight_provider import AdalightDeviceProvider
register_provider(AdalightDeviceProvider())
_register_builtin_providers()

View File

@@ -534,7 +534,7 @@ class ProcessorManager:
# Connect to LED device via factory
try:
state.led_client = create_led_client(device_type, state.device_url, use_ddp=True)
state.led_client = create_led_client(device_type, state.device_url, use_ddp=True, led_count=state.led_count)
await state.led_client.connect()
logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)")
@@ -885,7 +885,7 @@ class ProcessorManager:
if active_client:
await active_client.send_pixels(pixels)
else:
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count) as client:
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send test pixels for {device_id}: {e}")
@@ -905,7 +905,7 @@ class ProcessorManager:
if active_client:
await active_client.send_pixels(pixels)
else:
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count) as client:
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to clear pixels for {device_id}: {e}")

View File

@@ -34,6 +34,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.wled_client import WLEDClient
kwargs.pop("led_count", None)
return WLEDClient(url, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:

View File

@@ -649,7 +649,7 @@ function createDeviceCard(device) {
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${device.url ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : ''}
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
@@ -659,6 +659,7 @@ function createDeviceCard(device) {
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
@@ -666,7 +667,7 @@ function createDeviceCard(device) {
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>
</div>` : ''}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
@@ -729,17 +730,46 @@ async function showSettings(deviceId) {
}
const device = await deviceResponse.json();
const isAdalight = device.device_type === 'adalight';
// Populate fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
document.getElementById('settings-health-interval').value = 30;
// Toggle URL vs serial port field
const urlGroup = document.getElementById('settings-url-group');
const serialGroup = document.getElementById('settings-serial-port-group');
if (isAdalight) {
urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required');
serialGroup.style.display = '';
// Populate serial port dropdown via discovery
_populateSettingsSerialPorts(device.url);
} else {
urlGroup.style.display = '';
document.getElementById('settings-device-url').setAttribute('required', '');
document.getElementById('settings-device-url').value = device.url;
serialGroup.style.display = 'none';
}
// Show LED count field for devices with manual_led_count capability
const caps = device.capabilities || [];
const ledCountGroup = document.getElementById('settings-led-count-group');
if (caps.includes('manual_led_count')) {
ledCountGroup.style.display = '';
document.getElementById('settings-led-count').value = device.led_count || '';
} else {
ledCountGroup.style.display = 'none';
}
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
led_count: String(device.led_count || ''),
device_type: device.device_type,
capabilities: caps,
state_check_interval: '30',
};
@@ -759,11 +789,21 @@ async function showSettings(deviceId) {
}
}
function _getSettingsUrl() {
if (settingsInitialValues.device_type === 'adalight') {
return document.getElementById('settings-serial-port').value;
}
return document.getElementById('settings-device-url').value.trim();
}
function isSettingsDirty() {
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
_getSettingsUrl() !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
ledCountDirty
);
}
@@ -787,7 +827,7 @@ async function closeDeviceSettingsModal() {
async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const url = _getSettingsUrl();
const error = document.getElementById('settings-error');
// Validation
@@ -798,11 +838,16 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url)
// Update device info (name, url, optionally led_count)
const body = { name, url };
const ledCountInput = document.getElementById('settings-led-count');
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10);
}
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify(body)
});
if (deviceResponse.status === 401) {
@@ -874,6 +919,174 @@ async function fetchDeviceBrightness(deviceId) {
// Add device modal
let _discoveryScanRunning = false;
let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
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');
if (deviceType === 'adalight') {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = '';
serialSelect.setAttribute('required', '');
ledCountGroup.style.display = '';
// 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);
}
} else {
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = 'none';
// Show cached results or trigger scan for WLED
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
}
}
function _renderDiscoveryList() {
const selectedType = document.getElementById('device-type').value;
const devices = _discoveryCache[selectedType];
// Adalight: populate serial port dropdown instead of discovery list
if (selectedType === 'adalight') {
_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);
});
}
function onSerialPortFocus() {
// Lazy-load: trigger discovery when user opens the serial port dropdown
if (!('adalight' in _discoveryCache)) {
scanForDevices('adalight');
}
}
async function _populateSettingsSerialPorts(currentUrl) {
const select = document.getElementById('settings-serial-port');
select.innerHTML = '';
// Show loading placeholder
const loadingOpt = document.createElement('option');
loadingOpt.value = currentUrl;
loadingOpt.textContent = currentUrl + ' ⏳';
select.appendChild(loadingOpt);
try {
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, {
headers: getHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
const devices = data.devices || [];
select.innerHTML = '';
// Always include current port even if not discovered
let currentFound = false;
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.url;
opt.textContent = device.name;
if (device.url === currentUrl) currentFound = true;
select.appendChild(opt);
});
if (!currentFound) {
const opt = document.createElement('option');
opt.value = currentUrl;
opt.textContent = currentUrl;
select.insertBefore(opt, select.firstChild);
}
select.value = currentUrl;
} catch (err) {
console.error('Failed to discover serial ports:', err);
// Keep the current URL as fallback
}
}
function showAddDevice() {
const modal = document.getElementById('add-device-modal');
@@ -881,6 +1094,7 @@ function showAddDevice() {
const error = document.getElementById('add-device-error');
form.reset();
error.style.display = 'none';
_discoveryCache = {};
// Reset discovery section
const section = document.getElementById('discovery-section');
if (section) {
@@ -889,13 +1103,14 @@ function showAddDevice() {
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;
modal.style.display = 'flex';
lockBody();
onDeviceTypeChanged();
setTimeout(() => document.getElementById('device-name').focus(), 100);
// Auto-start discovery on open
scanForDevices();
}
function closeAddDeviceModal() {
@@ -904,9 +1119,12 @@ function closeAddDeviceModal() {
unlockBody();
}
async function scanForDevices() {
if (_discoveryScanRunning) return;
_discoveryScanRunning = true;
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;
_discoveryScanRunning = scanType;
const loading = document.getElementById('discovery-loading');
const list = document.getElementById('discovery-list');
@@ -914,14 +1132,26 @@ async function scanForDevices() {
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 (scanType === 'adalight') {
// 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 = '⏳';
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 fetch(`${API_BASE}/devices/discover?timeout=3`, {
const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, {
headers: getHeaders()
});
@@ -931,54 +1161,46 @@ async function scanForDevices() {
if (scanBtn) scanBtn.disabled = false;
if (!response.ok) {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
if (scanType !== 'adalight') {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
return;
}
const data = await response.json();
_discoveryCache[scanType] = data.devices || [];
if (data.devices.length === 0) {
empty.style.display = 'block';
return;
// Only render if the user is still on this type
const currentType = document.getElementById('device-type')?.value;
if (currentType === scanType) {
_renderDiscoveryList();
}
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');
if (scanType !== 'adalight') {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
console.error('Device scan failed:', err);
} finally {
_discoveryScanRunning = false;
if (_discoveryScanRunning === scanType) {
_discoveryScanRunning = false;
}
}
}
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;
onDeviceTypeChanged();
if (device.device_type === 'adalight') {
document.getElementById('device-serial-port').value = device.url;
} else {
document.getElementById('device-url').value = device.url;
}
showToast(t('device.scan.selected'), 'info');
}
@@ -986,7 +1208,10 @@ async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value.trim();
const url = document.getElementById('device-url').value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled';
const url = deviceType === 'adalight'
? document.getElementById('device-serial-port').value
: document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error');
if (!name || !url) {
@@ -996,8 +1221,11 @@ async function handleAddDevice(event) {
}
try {
const deviceType = document.getElementById('device-type')?.value || 'wled';
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 lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
@@ -4274,7 +4502,9 @@ async function loadTargetsTab() {
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
fetchDeviceBrightness(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
fetchDeviceBrightness(device.id);
}
});
// Manage KC WebSockets: connect for processing, disconnect for stopped

View File

@@ -237,13 +237,30 @@
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<div class="form-group" id="settings-url-group">
<div class="label-row">
<label for="settings-device-url" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="settings-serial-port-group" style="display: none;">
<div class="label-row">
<label for="settings-serial-port" data-i18n="device.serial_port">Serial Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
<select id="settings-serial-port"></select>
</div>
<div class="form-group" id="settings-led-count-group" style="display: none;">
<div class="label-row">
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<input type="number" id="settings-led-count" min="1" max="10000">
</div>
<div class="form-group">
@@ -582,7 +599,7 @@
</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>
<small data-i18n="device.scan.empty">No devices found</small>
</div>
<hr class="modal-divider">
</div>
@@ -593,21 +610,38 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
<select id="device-type">
<select id="device-type" onchange="onDeviceTypeChanged()">
<option value="wled">WLED</option>
<option value="adalight">Adalight</option>
</select>
</div>
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<div class="form-group" id="device-url-group">
<div class="label-row">
<label for="device-url" data-i18n="device.url">URL:</label>
<label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="device-serial-port-group" style="display: none;">
<div class="label-row">
<label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
<select id="device-serial-port" onfocus="onSerialPortFocus()"></select>
</div>
<div class="form-group" id="device-led-count-group" style="display: none;">
<div class="label-row">
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<input type="number" id="device-led-count" min="1" max="10000" placeholder="60">
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>

View File

@@ -102,12 +102,16 @@
"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.empty": "No devices found",
"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.serial_port": "Serial Port:",
"device.serial_port.hint": "Select the COM port of the Adalight device",
"device.serial_port.none": "No serial ports found",
"device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:",
"device.name.placeholder": "Living Room TV",

View File

@@ -102,12 +102,16 @@
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
"device.scan": "Автопоиск",
"device.scan.empty": "WLED устройства не найдены в сети",
"device.scan.empty": "Устройства не найдены",
"device.scan.error": "Ошибка сканирования сети",
"device.scan.already_added": "Уже добавлено",
"device.scan.selected": "Устройство выбрано",
"device.type": "Тип устройства:",
"device.type.hint": "Выберите тип LED контроллера",
"device.serial_port": "Серийный порт:",
"device.serial_port.hint": "Выберите COM порт устройства Adalight",
"device.serial_port.none": "Серийные порты не найдены",
"device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной",

View File

@@ -397,6 +397,16 @@ section {
background: var(--border-color);
white-space: nowrap;
}
.discovery-type-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: var(--primary-color);
color: #fff;
font-weight: 600;
vertical-align: middle;
margin-right: 2px;
}
.modal-divider {
border: none;
border-top: 1px solid var(--border-color);
@@ -1244,7 +1254,7 @@ input:-webkit-autofill:focus {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
margin: 40px auto 20px;
margin: 40px auto 40px;
background: var(--card-bg);
border: 2px solid var(--border-color);
border-radius: 8px;