From afb20f2dac3c79134558a75b9cdfa1cef40fce53 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 16 Feb 2026 16:35:41 +0300 Subject: [PATCH] Add configurable baud rate for Adalight with dynamic FPS hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baud rate is now a first-class device field, passed through the full stack: Device model → API schemas → routes → ProcessorManager → AdalightClient. The frontend shows a baud rate dropdown (115200–2M) for Adalight devices in both Add Device and Settings modals, with a live "Max FPS ≈ N" hint computed from LED count and baud rate. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/devices.py | 5 ++ .../wled_controller/api/schemas/devices.py | 3 + .../wled_controller/core/adalight_client.py | 40 ++++-------- .../wled_controller/core/adalight_provider.py | 3 +- .../wled_controller/core/processor_manager.py | 18 ++++-- .../src/wled_controller/core/wled_provider.py | 1 + server/src/wled_controller/main.py | 1 + server/src/wled_controller/static/app.js | 61 ++++++++++++++++++- server/src/wled_controller/static/index.html | 42 ++++++++++++- .../wled_controller/static/locales/en.json | 2 + .../wled_controller/static/locales/ru.json | 2 + server/src/wled_controller/static/style.css | 7 +++ .../wled_controller/storage/device_store.py | 13 +++- 13 files changed, 160 insertions(+), 38 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 8169cb2..e5717ae 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -49,6 +49,7 @@ def _device_to_response(device) -> DeviceResponse: device_type=device.device_type, led_count=device.led_count, enabled=device.enabled, + baud_rate=device.baud_rate, capabilities=sorted(get_device_capabilities(device.device_type)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), created_at=device.created_at, @@ -115,6 +116,7 @@ async def create_device( url=device_data.url, led_count=led_count, device_type=device_type, + baud_rate=device_data.baud_rate, ) # Register in processor manager for health monitoring @@ -124,6 +126,7 @@ async def create_device( led_count=device.led_count, calibration=device.calibration, device_type=device.device_type, + baud_rate=device.baud_rate, ) return _device_to_response(device) @@ -229,6 +232,7 @@ async def update_device( url=update_data.url, enabled=update_data.enabled, led_count=update_data.led_count, + baud_rate=update_data.baud_rate, ) # Sync connection info in processor manager @@ -237,6 +241,7 @@ async def update_device( device_id, device_url=update_data.url, led_count=update_data.led_count, + baud_rate=update_data.baud_rate, ) except ValueError: pass diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 6c89919..51bdae7 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -13,6 +13,7 @@ class DeviceCreate(BaseModel): 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)") + baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") class DeviceUpdate(BaseModel): @@ -22,6 +23,7 @@ class DeviceUpdate(BaseModel): 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)") + baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") class Calibration(BaseModel): @@ -87,6 +89,7 @@ class DeviceResponse(BaseModel): device_type: str = Field(default="wled", description="LED device type") led_count: int = Field(description="Total number of LEDs") enabled: bool = Field(description="Whether device is enabled") + baud_rate: Optional[int] = Field(None, description="Serial baud rate") capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") calibration: Optional[Calibration] = Field(None, description="Calibration configuration") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/adalight_client.py b/server/src/wled_controller/core/adalight_client.py index e41f053..10a47c1 100644 --- a/server/src/wled_controller/core/adalight_client.py +++ b/server/src/wled_controller/core/adalight_client.py @@ -1,6 +1,6 @@ """Adalight serial LED client — sends pixel data over serial using the Adalight protocol.""" -import time +import asyncio from datetime import datetime from typing import List, Optional, Tuple @@ -59,14 +59,16 @@ def _build_adalight_header(led_count: int) -> bytes: class AdalightClient(LEDClient): """LED client for Arduino Adalight serial devices.""" - def __init__(self, url: str, led_count: int = 0, **kwargs): + def __init__(self, url: str, led_count: int = 0, baud_rate: int = None, **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) + baud_rate: Override baud rate (if None, parsed from url or default 115200) """ - self._port, self._baud_rate = parse_adalight_url(url) + self._port, url_baud = parse_adalight_url(url) + self._baud_rate = baud_rate or url_baud self._led_count = led_count self._serial = None self._connected = False @@ -82,13 +84,11 @@ class AdalightClient(LEDClient): import serial try: - self._serial = serial.Serial( - port=self._port, - baudrate=self._baud_rate, - timeout=1, + self._serial = await asyncio.to_thread( + serial.Serial, port=self._port, baudrate=self._baud_rate, timeout=1 ) - # Wait for Arduino to finish bootloader reset - time.sleep(ARDUINO_RESET_DELAY) + # Wait for Arduino to finish bootloader reset (non-blocking) + await asyncio.sleep(ARDUINO_RESET_DELAY) self._connected = True logger.info( f"Adalight connected: {self._port} @ {self._baud_rate} baud " @@ -119,13 +119,13 @@ class AdalightClient(LEDClient): pixels: List[Tuple[int, int, int]], brightness: int = 255, ) -> bool: - """Send pixel data over serial using Adalight protocol.""" + """Send pixel data over serial using Adalight protocol (non-blocking).""" if not self.is_connected: return False try: frame = self._build_frame(pixels, brightness) - self._serial.write(frame) + await asyncio.to_thread(self._serial.write, frame) return True except Exception as e: logger.error(f"Adalight send_pixels error: {e}") @@ -133,22 +133,8 @@ class AdalightClient(LEDClient): @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}") + # Serial write is blocking — use async send_pixels path instead + return False def _build_frame(self, pixels: List[Tuple[int, int, int]], brightness: int) -> bytes: """Build a complete Adalight frame: header + brightness-scaled RGB data.""" diff --git a/server/src/wled_controller/core/adalight_provider.py b/server/src/wled_controller/core/adalight_provider.py index bea1de3..ad01687 100644 --- a/server/src/wled_controller/core/adalight_provider.py +++ b/server/src/wled_controller/core/adalight_provider.py @@ -30,8 +30,9 @@ class AdalightDeviceProvider(LEDDeviceProvider): from wled_controller.core.adalight_client import AdalightClient led_count = kwargs.pop("led_count", 0) + baud_rate = kwargs.pop("baud_rate", None) kwargs.pop("use_ddp", None) # Not applicable for serial - return AdalightClient(url, led_count=led_count, **kwargs) + return AdalightClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs) async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: from wled_controller.core.adalight_client import AdalightClient diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 552e7ec..114a8b8 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -132,6 +132,7 @@ class DeviceState: led_count: int calibration: CalibrationConfig device_type: str = "wled" + baud_rate: Optional[int] = None health: DeviceHealth = field(default_factory=DeviceHealth) health_task: Optional[asyncio.Task] = None # Calibration test mode (works independently of target processing) @@ -222,6 +223,7 @@ class ProcessorManager: led_count: int, calibration: Optional[CalibrationConfig] = None, device_type: str = "wled", + baud_rate: Optional[int] = None, ): """Register a device for health monitoring. @@ -231,6 +233,7 @@ class ProcessorManager: led_count: Number of LEDs calibration: Calibration config (creates default if None) device_type: LED device type (e.g. "wled") + baud_rate: Serial baud rate (for adalight devices) """ if device_id in self._devices: raise ValueError(f"Device {device_id} already registered") @@ -244,6 +247,7 @@ class ProcessorManager: led_count=led_count, calibration=calibration, device_type=device_type, + baud_rate=baud_rate, ) self._devices[device_id] = state @@ -277,7 +281,7 @@ class ProcessorManager: del self._devices[device_id] logger.info(f"Unregistered device {device_id}") - def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None): + def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None, baud_rate: Optional[int] = None): """Update device connection info.""" if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") @@ -287,6 +291,8 @@ class ProcessorManager: ds.device_url = device_url if led_count is not None: ds.led_count = led_count + if baud_rate is not None: + ds.baud_rate = baud_rate def update_calibration(self, device_id: str, calibration: CalibrationConfig): """Update calibration for a device. @@ -527,14 +533,16 @@ class ProcessorManager: # Resolve stream settings self._resolve_stream_settings(state) - # Determine device type from device state + # Determine device type and baud rate from device state device_type = "wled" + baud_rate = None if state.device_id in self._devices: device_type = self._devices[state.device_id].device_type + baud_rate = self._devices[state.device_id].baud_rate # Connect to LED device via factory try: - state.led_client = create_led_client(device_type, state.device_url, use_ddp=True, led_count=state.led_count) + state.led_client = create_led_client(device_type, state.device_url, use_ddp=True, led_count=state.led_count, baud_rate=baud_rate) await state.led_client.connect() logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)") @@ -885,7 +893,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, led_count=ds.led_count) as client: + async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate) 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 +913,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, led_count=ds.led_count) as client: + async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate) 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 dfd081b..6ca0d27 100644 --- a/server/src/wled_controller/core/wled_provider.py +++ b/server/src/wled_controller/core/wled_provider.py @@ -35,6 +35,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) + kwargs.pop("baud_rate", 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/main.py b/server/src/wled_controller/main.py index 55a52c9..0262cf3 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -155,6 +155,7 @@ async def lifespan(app: FastAPI): led_count=device.led_count, calibration=device.calibration, device_type=device.device_type, + baud_rate=device.baud_rate, ) logger.info(f"Registered device: {device.name} ({device.id})") except Exception as e: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index b514d3c..7b2175e 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -763,11 +763,27 @@ async function showSettings(deviceId) { ledCountGroup.style.display = 'none'; } + // Show baud rate field for adalight devices + const baudRateGroup = document.getElementById('settings-baud-rate-group'); + if (isAdalight) { + baudRateGroup.style.display = ''; + const baudSelect = document.getElementById('settings-baud-rate'); + if (device.baud_rate) { + baudSelect.value = String(device.baud_rate); + } else { + baudSelect.value = '115200'; + } + updateSettingsBaudFpsHint(); + } else { + baudRateGroup.style.display = 'none'; + } + // Snapshot initial values for dirty checking settingsInitialValues = { name: device.name, url: device.url, led_count: String(device.led_count || ''), + baud_rate: String(device.baud_rate || '115200'), device_type: device.device_type, capabilities: caps, state_check_interval: '30', @@ -838,12 +854,16 @@ async function saveDeviceSettings() { } try { - // Update device info (name, url, optionally led_count) + // Update device info (name, url, optionally led_count, baud_rate) 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); } + if (settingsInitialValues.device_type === 'adalight') { + const baudVal = document.getElementById('settings-baud-rate').value; + if (baudVal) body.baud_rate = parseInt(baudVal, 10); + } const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), @@ -930,12 +950,15 @@ function onDeviceTypeChanged() { const ledCountGroup = document.getElementById('device-led-count-group'); const discoverySection = document.getElementById('discovery-section'); + const baudRateGroup = document.getElementById('device-baud-rate-group'); + if (deviceType === 'adalight') { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = ''; serialSelect.setAttribute('required', ''); ledCountGroup.style.display = ''; + baudRateGroup.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) @@ -949,12 +972,14 @@ function onDeviceTypeChanged() { 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'; // Show cached results or trigger scan for WLED if (deviceType in _discoveryCache) { _renderDiscoveryList(); @@ -964,6 +989,36 @@ function onDeviceTypeChanged() { } } +function _computeMaxFps(baudRate, ledCount) { + if (!baudRate || !ledCount || ledCount < 1) return null; + const bitsPerFrame = (ledCount * 3 + 6) * 10; + return Math.floor(baudRate / bitsPerFrame); +} + +function _renderFpsHint(hintEl, baudRate, ledCount) { + const fps = _computeMaxFps(baudRate, ledCount); + if (fps !== null) { + hintEl.textContent = `Max FPS ≈ ${fps}`; + hintEl.style.display = ''; + } else { + hintEl.style.display = 'none'; + } +} + +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); + _renderFpsHint(hintEl, baudRate, ledCount); +} + +function updateSettingsBaudFpsHint() { + const hintEl = document.getElementById('settings-baud-fps-hint'); + const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10); + const ledCount = parseInt(document.getElementById('settings-led-count').value, 10); + _renderFpsHint(hintEl, baudRate, ledCount); +} + function _renderDiscoveryList() { const selectedType = document.getElementById('device-type').value; const devices = _discoveryCache[selectedType]; @@ -1226,6 +1281,10 @@ async function handleAddDevice(event) { if (ledCountInput && ledCountInput.value) { body.led_count = parseInt(ledCountInput.value, 10); } + const baudRateSelect = document.getElementById('device-baud-rate'); + if (deviceType === 'adalight' && baudRateSelect && baudRateSelect.value) { + body.baud_rate = parseInt(baudRateSelect.value, 10); + } const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); if (lastTemplateId) { body.capture_template_id = lastTemplateId; diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 2a3b98a..c0ecd5e 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -28,7 +28,7 @@ 🔑 Login @@ -260,7 +260,25 @@ - + + +
@@ -641,7 +659,25 @@
- + + + diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 1e436d3..5c33ace 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -112,6 +112,8 @@ "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.baud_rate": "Baud Rate:", + "device.baud_rate.hint": "Serial communication speed. Higher = more FPS but requires matching 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 aee0dfa..8723c1b 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -112,6 +112,8 @@ "device.serial_port.hint": "Выберите COM порт устройства Adalight", "device.serial_port.none": "Серийные порты не найдены", "device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)", + "device.baud_rate": "Скорость порта:", + "device.baud_rate.hint": "Скорость серийного соединения. Выше = больше FPS, но требует соответствия скетчу 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 d0bc169..3caa3ba 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -1160,6 +1160,13 @@ input:-webkit-autofill:focus { font-size: 0.85rem; } +.fps-hint { + display: block; + margin-top: 4px; + font-size: 0.82rem; + color: var(--info-color, #2196F3); +} + .slider-row { display: flex; align-items: center; diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index 556b3b4..80cda8f 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -32,6 +32,7 @@ class Device: led_count: int, enabled: bool = True, device_type: str = "wled", + baud_rate: Optional[int] = None, calibration: Optional[CalibrationConfig] = None, created_at: Optional[datetime] = None, updated_at: Optional[datetime] = None, @@ -42,13 +43,14 @@ class Device: self.led_count = led_count self.enabled = enabled self.device_type = device_type + self.baud_rate = baud_rate self.calibration = calibration or create_default_calibration(led_count) self.created_at = created_at or datetime.utcnow() self.updated_at = updated_at or datetime.utcnow() def to_dict(self) -> dict: """Convert device to dictionary.""" - return { + d = { "id": self.id, "name": self.name, "url": self.url, @@ -59,6 +61,9 @@ class Device: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } + if self.baud_rate is not None: + d["baud_rate"] = self.baud_rate + return d @classmethod def from_dict(cls, data: dict) -> "Device": @@ -81,6 +86,7 @@ class Device: led_count=data["led_count"], enabled=data.get("enabled", True), device_type=data.get("device_type", "wled"), + baud_rate=data.get("baud_rate"), calibration=calibration, created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), @@ -165,6 +171,7 @@ class DeviceStore: url: str, led_count: int, device_type: str = "wled", + baud_rate: Optional[int] = None, calibration: Optional[CalibrationConfig] = None, ) -> Device: """Create a new device.""" @@ -176,6 +183,7 @@ class DeviceStore: url=url, led_count=led_count, device_type=device_type, + baud_rate=baud_rate, calibration=calibration, ) @@ -200,6 +208,7 @@ class DeviceStore: url: Optional[str] = None, led_count: Optional[int] = None, enabled: Optional[bool] = None, + baud_rate: Optional[int] = None, calibration: Optional[CalibrationConfig] = None, ) -> Device: """Update device.""" @@ -216,6 +225,8 @@ class DeviceStore: device.calibration = create_default_calibration(led_count) if enabled is not None: device.enabled = enabled + if baud_rate is not None: + device.baud_rate = baud_rate if calibration is not None: if calibration.get_total_leds() != device.led_count: raise ValueError(