Animation None option, FPS min 1, serial COM lifecycle fixes
- Replace animation Enable checkbox with None option in effect selector; show effect description tooltip; disable speed slider when None selected - Allow target FPS range 1-90 (was 10-90) across UI and backend validation - Scope serial COM connections to target lifetime (no idle caching); use temporary connections for power-off/test mode - Fix serial black frame on stop: flush after write, delay after task cancel to prevent race with in-flight thread pool write Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -219,7 +219,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
|
||||
def set_capture_fps(self, fps: int) -> None:
|
||||
"""Update the internal capture rate. Thread-safe (read atomically by the loop)."""
|
||||
fps = max(10, min(90, fps))
|
||||
fps = max(1, min(90, fps))
|
||||
if fps != self._fps:
|
||||
self._fps = fps
|
||||
self._interp_duration = 1.0 / fps
|
||||
|
||||
@@ -527,8 +527,6 @@ class ProcessorManager:
|
||||
ds.test_mode_edges = {}
|
||||
ds.test_calibration = None
|
||||
await self._send_clear_pixels(device_id)
|
||||
# Keep idle client open — serial reconnect causes device reset.
|
||||
# start_processing() closes it before connecting its own client.
|
||||
|
||||
async def _get_idle_client(self, device_id: str):
|
||||
"""Get or create a cached idle LED client for a device.
|
||||
@@ -590,22 +588,42 @@ class ProcessorManager:
|
||||
if offset > 0:
|
||||
pixels = pixels[-offset:] + pixels[:-offset]
|
||||
|
||||
try:
|
||||
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}")
|
||||
await self._send_pixels_to_device(device_id, pixels)
|
||||
|
||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
||||
"""Send all-black pixels to clear LED output."""
|
||||
ds = self._devices[device_id]
|
||||
pixels = [(0, 0, 0)] * ds.led_count
|
||||
await self._send_pixels_to_device(device_id, pixels)
|
||||
|
||||
def _is_serial_device(self, device_id: str) -> bool:
|
||||
"""Check if a device uses a serial (COM) connection."""
|
||||
ds = self._devices.get(device_id)
|
||||
return ds is not None and ds.device_type not in ("wled",)
|
||||
|
||||
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
||||
"""Send pixels to a device.
|
||||
|
||||
Serial devices: temporary connection (open, send, close).
|
||||
WLED devices: cached idle client.
|
||||
"""
|
||||
ds = self._devices[device_id]
|
||||
try:
|
||||
client = await self._get_idle_client(device_id)
|
||||
await client.send_pixels(pixels)
|
||||
if self._is_serial_device(device_id):
|
||||
client = create_led_client(
|
||||
ds.device_type, ds.device_url,
|
||||
led_count=ds.led_count, baud_rate=ds.baud_rate,
|
||||
)
|
||||
try:
|
||||
await client.connect()
|
||||
await client.send_pixels(pixels)
|
||||
finally:
|
||||
await client.close()
|
||||
else:
|
||||
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}")
|
||||
logger.error(f"Failed to send pixels to {device_id}: {e}")
|
||||
|
||||
def _find_active_led_client(self, device_id: str):
|
||||
"""Find an active LED client for a device (from a running processor)."""
|
||||
@@ -644,7 +662,7 @@ class ProcessorManager:
|
||||
"""Restore a device to its idle state when all targets stop.
|
||||
|
||||
- For WLED: do nothing — stop() already restored the snapshot.
|
||||
- For other devices: power off (send black frame).
|
||||
- For serial: do nothing — AdalightClient.close() already sent black frame.
|
||||
"""
|
||||
ds = self._devices.get(device_id)
|
||||
if not ds or not ds.auto_shutdown:
|
||||
@@ -653,15 +671,10 @@ class ProcessorManager:
|
||||
if self.is_device_processing(device_id):
|
||||
return
|
||||
|
||||
try:
|
||||
if ds.device_type != "wled":
|
||||
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
|
||||
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-restore failed for device {device_id}: {e}")
|
||||
if ds.device_type == "wled":
|
||||
logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot")
|
||||
else:
|
||||
logger.info(f"Auto-restore: {ds.device_type} device {device_id} dark (closed by processor)")
|
||||
|
||||
# ===== LIFECYCLE =====
|
||||
|
||||
@@ -678,18 +691,11 @@ class ProcessorManager:
|
||||
logger.error(f"Error stopping target {target_id}: {e}")
|
||||
|
||||
# Restore idle state for devices that have auto-restore enabled
|
||||
# (serial devices already dark from processor close; WLED restored by snapshot)
|
||||
for device_id in self._devices:
|
||||
await self._restore_device_idle_state(device_id)
|
||||
|
||||
# Power off serial LED devices before closing connections
|
||||
for device_id, ds in self._devices.items():
|
||||
if ds.device_type != "wled":
|
||||
try:
|
||||
await self._send_clear_pixels(device_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to power off {device_id} on shutdown: {e}")
|
||||
|
||||
# Close any cached idle LED clients
|
||||
# Close any cached idle LED clients (WLED only; serial has no cached clients)
|
||||
for did in list(self._idle_clients):
|
||||
await self._close_idle_client(did)
|
||||
|
||||
|
||||
@@ -165,6 +165,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
# Allow any in-flight thread pool serial write to complete before
|
||||
# close() sends the black frame (to_thread keeps running after cancel)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Restore device state
|
||||
if self._led_client and self._device_state_before:
|
||||
|
||||
Reference in New Issue
Block a user