From 390f71ebae1280f9cc0678a020527078b8555784 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Feb 2026 00:09:55 +0300 Subject: [PATCH] Cache idle LED clients to avoid repeated Arduino resets Adalight devices trigger a ~3s bootloader reset on every serial connection open. Add a persistent idle client cache in ProcessorManager so calibration test toggles, static color changes, and auto-restore reuse an existing connection instead of creating a new one each time. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/devices.py | 8 +- .../core/processing/processor_manager.py | 89 ++++++++++++++----- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 3e12183..1179928 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -291,7 +291,7 @@ async def delete_device( # Remove from manager try: - manager.remove_device(device_id) + await manager.remove_device(device_id) except (ValueError, RuntimeError): pass @@ -497,11 +497,7 @@ async def set_device_color( # If device is idle, apply the color immediately if color is not None and not manager.is_device_processing(device_id): try: - provider = get_provider(device.device_type) - await provider.set_color( - device.url, color, - led_count=device.led_count, baud_rate=device.baud_rate, - ) + await manager.send_static_color(device_id, color) except Exception as e: logger.warning(f"Failed to apply static color immediately: {e}") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index c7741a8..78391bd 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -68,6 +68,7 @@ class ProcessorManager: """Initialize processor manager.""" self._devices: Dict[str, DeviceState] = {} self._processors: Dict[str, TargetProcessor] = {} + self._idle_clients: Dict[str, object] = {} # device_id -> cached LEDClient self._health_monitoring_active = False self._http_client: Optional[httpx.AsyncClient] = None self._picture_source_store = picture_source_store @@ -181,7 +182,7 @@ class ProcessorManager: logger.info(f"Registered device {device_id} with {led_count} LEDs") - def remove_device(self, device_id: str): + async def remove_device(self, device_id: str): """Unregister a device.""" if device_id not in self._devices: raise ValueError(f"Device {device_id} not found") @@ -193,6 +194,7 @@ class ProcessorManager: f"Cannot remove device {device_id}: target {proc.target_id} is using it" ) + await self._close_idle_client(device_id) self._stop_device_health_check(device_id) del self._devices[device_id] logger.info(f"Unregistered device {device_id}") @@ -379,6 +381,10 @@ class ProcessorManager: f"Device {proc.device_id} is already being processed by target {other_id}" ) + # Close cached idle client — processor creates its own connection + if isinstance(proc, WledTargetProcessor): + await self._close_idle_client(proc.device_id) + await proc.start() async def stop_processing(self, target_id: str): @@ -492,6 +498,42 @@ class ProcessorManager: ds.test_mode_active = False ds.test_mode_edges = {} await self._send_clear_pixels(device_id) + await self._close_idle_client(device_id) + + async def _get_idle_client(self, device_id: str): + """Get or create a cached idle LED client for a device. + + Reuses an existing connected client to avoid repeated serial + reconnection (which triggers Arduino bootloader reset on Adalight). + """ + # Prefer a running processor's client (already connected) + active = self._find_active_led_client(device_id) + if active: + return active + + # Reuse cached idle client if still connected + cached = self._idle_clients.get(device_id) + if cached and cached.is_connected: + return cached + + # Create and cache a new client + ds = self._devices[device_id] + client = create_led_client( + ds.device_type, ds.device_url, + use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate, + ) + await client.connect() + self._idle_clients[device_id] = client + return client + + async def _close_idle_client(self, device_id: str) -> None: + """Close and remove the cached idle client for a device.""" + client = self._idle_clients.pop(device_id, None) + if client: + try: + await client.close() + except Exception as e: + logger.warning(f"Error closing idle client for {device_id}: {e}") async def _send_test_pixels(self, device_id: str) -> None: """Build and send test pixel array for active test edges.""" @@ -513,12 +555,8 @@ class ProcessorManager: pixels = pixels[-offset:] + pixels[:-offset] try: - active_client = self._find_active_led_client(device_id) - 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, baud_rate=ds.baud_rate) as client: - await client.send_pixels(pixels) + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) except Exception as e: logger.error(f"Failed to send test pixels for {device_id}: {e}") @@ -528,12 +566,8 @@ class ProcessorManager: pixels = [(0, 0, 0)] * ds.led_count try: - active_client = self._find_active_led_client(device_id) - 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, baud_rate=ds.baud_rate) as client: - await client.send_pixels(pixels) + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) except Exception as e: logger.error(f"Failed to clear pixels for {device_id}: {e}") @@ -560,6 +594,19 @@ class ProcessorManager: return proc.device_id return None + async def send_static_color(self, device_id: str, color: Tuple[int, int, int]) -> None: + """Send a solid color to a device via the cached idle client.""" + import numpy as np + ds = self._devices.get(device_id) + if not ds: + raise ValueError(f"Device {device_id} not found") + try: + client = await self._get_idle_client(device_id) + frame = np.full((ds.led_count, 3), color, dtype=np.uint8) + await client.send_pixels(frame) + except Exception as e: + logger.error(f"Failed to send static color for {device_id}: {e}") + async def _restore_device_idle_state(self, device_id: str) -> None: """Restore a device to its idle state when all targets stop. @@ -575,23 +622,15 @@ class ProcessorManager: return try: - provider = get_provider(ds.device_type) - if ds.static_color is not None: - await provider.set_color( - ds.device_url, ds.static_color, - led_count=ds.led_count, baud_rate=ds.baud_rate, - ) + await self.send_static_color(device_id, ds.static_color) logger.info( f"Auto-restore: sent static color {ds.static_color} " f"to {ds.device_type} device {device_id}" ) elif ds.device_type != "wled": # Non-WLED without static color: power off (send black frame) - await provider.set_power( - ds.device_url, False, - led_count=ds.led_count, baud_rate=ds.baud_rate, - ) + await self._send_clear_pixels(device_id) logger.info(f"Auto-restore: powered off {ds.device_type} device {device_id}") else: # WLED: stop() already called restore_device_state() via snapshot @@ -617,6 +656,10 @@ class ProcessorManager: for device_id in self._devices: await self._restore_device_idle_state(device_id) + # Close any cached idle LED clients + for did in list(self._idle_clients): + await self._close_idle_client(did) + # Safety net: release any remaining managed live streams self._live_stream_manager.release_all()