diff --git a/server/pyproject.toml b/server/pyproject.toml index a26e5b1..967b716 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -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] diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index cb658ef..8169cb2 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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 diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index d4ad90f..6c89919 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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): diff --git a/server/src/wled_controller/core/adalight_client.py b/server/src/wled_controller/core/adalight_client.py new file mode 100644 index 0000000..e41f053 --- /dev/null +++ b/server/src/wled_controller/core/adalight_client.py @@ -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' + 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), + ) diff --git a/server/src/wled_controller/core/adalight_provider.py b/server/src/wled_controller/core/adalight_provider.py new file mode 100644 index 0000000..bea1de3 --- /dev/null +++ b/server/src/wled_controller/core/adalight_provider.py @@ -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 [] diff --git a/server/src/wled_controller/core/led_client.py b/server/src/wled_controller/core/led_client.py index 61eefd1..b9ba9c3 100644 --- a/server/src/wled_controller/core/led_client.py +++ b/server/src/wled_controller/core/led_client.py @@ -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() diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index b3e83a3..552e7ec 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -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}") diff --git a/server/src/wled_controller/core/wled_provider.py b/server/src/wled_controller/core/wled_provider.py index c42d772..dfd081b 100644 --- a/server/src/wled_controller/core/wled_provider.py +++ b/server/src/wled_controller/core/wled_provider.py @@ -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: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 912a632..b514d3c 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -649,7 +649,7 @@ function createDeviceCard(device) {
${device.name || device.id} - ${device.url ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}🌐` : ''} + ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}🌐` : (device.url && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${healthLabel}
@@ -659,6 +659,7 @@ function createDeviceCard(device) { ${state.device_led_type ? `🔌 ${state.device_led_type.replace(/ RGBW$/, '')}` : ''} ${state.device_rgbw ? '' : ''} + ${(device.capabilities || []).includes('brightness_control') ? `
-
+ ` : ''}
-
+
- + +
+ + +
@@ -582,7 +599,7 @@
@@ -593,21 +610,38 @@ - +
-
+
- +
- - + + +
+ + diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 147e658..1e436d3 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 2c14bb0..aee0dfa 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "ТВ в Гостиной", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index a26d23a..d0bc169 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -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;