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 @@ Number of LEDs on the strip (must match your Arduino sketch) - + + +