Fix device provider kwargs, camera crash guard, target API, and graph color picker
- Refactor all device providers to use explicit kwargs.get() instead of fragile pop-then-passthrough (fixes WLED target start failing with unexpected dmx_protocol kwarg) - Add process-wide camera index registry to prevent concurrent opens of the same physical camera which crashes the DSHOW backend on Windows - Fix OutputTargetResponse validation error when brightness_value_source_id is None (coerce to empty string in response and from_dict) - Replace native <input type="color"> in graph editor with the custom color picker popover used throughout the app, positioned via getScreenCTM() inside an absolute overlay on .graph-container Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,9 @@ Prerequisites (optional dependency):
|
||||
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -26,6 +27,13 @@ from wled_controller.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||
|
||||
# Process-wide registry of cv2 camera indices currently held open.
|
||||
# Prevents _enumerate_cameras from probing an in-use camera (which can
|
||||
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
|
||||
# from opening the same physical camera concurrently.
|
||||
_active_cv2_indices: Set[int] = set()
|
||||
_camera_lock = threading.Lock()
|
||||
_CV2_BACKENDS = {
|
||||
"auto": None,
|
||||
"dshow": 700, # cv2.CAP_DSHOW
|
||||
@@ -103,7 +111,29 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
||||
cameras: List[Dict[str, Any]] = []
|
||||
sequential_idx = 0
|
||||
|
||||
with _camera_lock:
|
||||
active = set(_active_cv2_indices)
|
||||
|
||||
for i in range(max_probe):
|
||||
if i in active:
|
||||
# Camera already held open — use cached metadata if available,
|
||||
# otherwise add a placeholder so display_index mapping stays stable.
|
||||
if _camera_cache is not None:
|
||||
prev = [c for c in _camera_cache if c["cv2_index"] == i]
|
||||
if prev:
|
||||
cameras.append(prev[0])
|
||||
sequential_idx += 1
|
||||
continue
|
||||
cameras.append({
|
||||
"cv2_index": i,
|
||||
"name": friendly_names.get(sequential_idx, f"Camera {sequential_idx}"),
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"fps": 30.0,
|
||||
})
|
||||
sequential_idx += 1
|
||||
continue
|
||||
|
||||
if backend_id is not None:
|
||||
cap = cv2.VideoCapture(i, backend_id)
|
||||
else:
|
||||
@@ -149,6 +179,7 @@ class CameraCaptureStream(CaptureStream):
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._cap = None
|
||||
self._cv2_index: Optional[int] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
@@ -173,18 +204,34 @@ class CameraCaptureStream(CaptureStream):
|
||||
camera = cameras[self.display_index]
|
||||
cv2_index = camera["cv2_index"]
|
||||
|
||||
# Open the camera
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
if backend_id is not None:
|
||||
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
else:
|
||||
self._cap = cv2.VideoCapture(cv2_index)
|
||||
# Prevent concurrent opens of the same physical camera (crashes DSHOW)
|
||||
with _camera_lock:
|
||||
if cv2_index in _active_cv2_indices:
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} (cv2 index {cv2_index}) "
|
||||
f"is already in use by another stream"
|
||||
)
|
||||
_active_cv2_indices.add(cv2_index)
|
||||
|
||||
if not self._cap.isOpened():
|
||||
raise RuntimeError(
|
||||
f"Failed to open camera {self.display_index} "
|
||||
f"(cv2 index {cv2_index})"
|
||||
)
|
||||
try:
|
||||
# Open the camera
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
if backend_id is not None:
|
||||
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
else:
|
||||
self._cap = cv2.VideoCapture(cv2_index)
|
||||
|
||||
if not self._cap.isOpened():
|
||||
raise RuntimeError(
|
||||
f"Failed to open camera {self.display_index} "
|
||||
f"(cv2 index {cv2_index})"
|
||||
)
|
||||
except Exception:
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(cv2_index)
|
||||
raise
|
||||
|
||||
self._cv2_index = cv2_index
|
||||
|
||||
# Apply optional resolution override
|
||||
res_w = self.config.get("resolution_width", 0)
|
||||
@@ -198,6 +245,9 @@ class CameraCaptureStream(CaptureStream):
|
||||
if not ret or frame is None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(cv2_index)
|
||||
self._cv2_index = None
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} opened but test read failed"
|
||||
)
|
||||
@@ -234,6 +284,10 @@ class CameraCaptureStream(CaptureStream):
|
||||
if self._cap is not None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
if self._cv2_index is not None:
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(self._cv2_index)
|
||||
self._cv2_index = None
|
||||
self._initialized = False
|
||||
logger.info(f"Camera capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
|
||||
@@ -13,10 +13,8 @@ class AdalightDeviceProvider(SerialDeviceProvider):
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.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
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||
return AdalightClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
baud_rate=kwargs.get("baud_rate"),
|
||||
)
|
||||
|
||||
@@ -13,10 +13,8 @@ class AmbiLEDDeviceProvider(SerialDeviceProvider):
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
|
||||
|
||||
led_count = kwargs.pop("led_count", 0)
|
||||
baud_rate = kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("use_ddp", None)
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||
return AmbiLEDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
baud_rate=kwargs.get("baud_rate"),
|
||||
)
|
||||
|
||||
@@ -24,8 +24,11 @@ class MockDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count", "power_control", "brightness_control"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
kwargs.pop("use_ddp", None)
|
||||
return MockClient(url, **kwargs)
|
||||
return MockClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
send_latency_ms=kwargs.get("send_latency_ms", 0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
|
||||
|
||||
@@ -31,7 +31,10 @@ class MQTTDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return MQTTLEDClient(url, **kwargs)
|
||||
return MQTTLEDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
|
||||
@@ -30,12 +30,10 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
||||
return {"health_check", "auto_restore", "static_color"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
zone_mode = kwargs.pop("zone_mode", "combined")
|
||||
kwargs.pop("led_count", None)
|
||||
kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
return OpenRGBLEDClient(url, zone_mode=zone_mode, **kwargs)
|
||||
return OpenRGBLEDClient(
|
||||
url,
|
||||
zone_mode=kwargs.get("zone_mode", "combined"),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await OpenRGBLEDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
@@ -53,12 +53,10 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
kwargs.pop("led_count", None)
|
||||
kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("send_latency_ms", None)
|
||||
kwargs.pop("rgbw", None)
|
||||
kwargs.pop("zone_mode", None)
|
||||
return WLEDClient(url, **kwargs)
|
||||
return WLEDClient(
|
||||
url,
|
||||
use_ddp=kwargs.get("use_ddp", False),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
|
||||
@@ -27,7 +27,10 @@ class WSDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return WSLEDClient(url, **kwargs)
|
||||
return WSLEDClient(
|
||||
url,
|
||||
led_count=kwargs.get("led_count", 0),
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
|
||||
Reference in New Issue
Block a user