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 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 00:09:55 +03:00
parent 6e973965b1
commit 390f71ebae
2 changed files with 68 additions and 29 deletions

View File

@@ -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}")

View File

@@ -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,11 +555,7 @@ 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:
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,11 +566,7 @@ 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:
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()