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:
2026-03-13 21:36:26 +03:00
parent 8960e7dca3
commit 153972fcd5
11 changed files with 135 additions and 70 deletions

View File

@@ -98,7 +98,7 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type, target_type=target.target_type,
device_id=target.device_id, device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id, color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id, brightness_value_source_id=target.brightness_value_source_id or "",
fps=target.fps, fps=target.fps,
keepalive_interval=target.keepalive_interval, keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval, state_check_interval=target.state_check_interval,

View File

@@ -10,8 +10,9 @@ Prerequisites (optional dependency):
import platform import platform
import sys import sys
import threading
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Set
import numpy as np import numpy as np
@@ -26,6 +27,13 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
_MAX_CAMERA_INDEX = 10 # probe indices 0..9 _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 = { _CV2_BACKENDS = {
"auto": None, "auto": None,
"dshow": 700, # cv2.CAP_DSHOW "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]] = [] cameras: List[Dict[str, Any]] = []
sequential_idx = 0 sequential_idx = 0
with _camera_lock:
active = set(_active_cv2_indices)
for i in range(max_probe): 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: if backend_id is not None:
cap = cv2.VideoCapture(i, backend_id) cap = cv2.VideoCapture(i, backend_id)
else: else:
@@ -149,6 +179,7 @@ class CameraCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]): def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config) super().__init__(display_index, config)
self._cap = None self._cap = None
self._cv2_index: Optional[int] = None
def initialize(self) -> None: def initialize(self) -> None:
if self._initialized: if self._initialized:
@@ -173,18 +204,34 @@ class CameraCaptureStream(CaptureStream):
camera = cameras[self.display_index] camera = cameras[self.display_index]
cv2_index = camera["cv2_index"] cv2_index = camera["cv2_index"]
# Open the camera # Prevent concurrent opens of the same physical camera (crashes DSHOW)
backend_id = _cv2_backend_id(backend_name) with _camera_lock:
if backend_id is not None: if cv2_index in _active_cv2_indices:
self._cap = cv2.VideoCapture(cv2_index, backend_id) raise RuntimeError(
else: f"Camera {self.display_index} (cv2 index {cv2_index}) "
self._cap = cv2.VideoCapture(cv2_index) f"is already in use by another stream"
)
_active_cv2_indices.add(cv2_index)
if not self._cap.isOpened(): try:
raise RuntimeError( # Open the camera
f"Failed to open camera {self.display_index} " backend_id = _cv2_backend_id(backend_name)
f"(cv2 index {cv2_index})" 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 # Apply optional resolution override
res_w = self.config.get("resolution_width", 0) res_w = self.config.get("resolution_width", 0)
@@ -198,6 +245,9 @@ class CameraCaptureStream(CaptureStream):
if not ret or frame is None: if not ret or frame is None:
self._cap.release() self._cap.release()
self._cap = None self._cap = None
with _camera_lock:
_active_cv2_indices.discard(cv2_index)
self._cv2_index = None
raise RuntimeError( raise RuntimeError(
f"Camera {self.display_index} opened but test read failed" f"Camera {self.display_index} opened but test read failed"
) )
@@ -234,6 +284,10 @@ class CameraCaptureStream(CaptureStream):
if self._cap is not None: if self._cap is not None:
self._cap.release() self._cap.release()
self._cap = None 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 self._initialized = False
logger.info(f"Camera capture stream cleaned up (display={self.display_index})") logger.info(f"Camera capture stream cleaned up (display={self.display_index})")

View File

@@ -13,10 +13,8 @@ class AdalightDeviceProvider(SerialDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.adalight_client import AdalightClient from wled_controller.core.devices.adalight_client import AdalightClient
return AdalightClient(
led_count = kwargs.pop("led_count", 0) url,
baud_rate = kwargs.pop("baud_rate", None) led_count=kwargs.get("led_count", 0),
kwargs.pop("use_ddp", None) # Not applicable for serial baud_rate=kwargs.get("baud_rate"),
kwargs.pop("send_latency_ms", None) )
kwargs.pop("rgbw", None)
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate)

View File

@@ -13,10 +13,8 @@ class AmbiLEDDeviceProvider(SerialDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.ambiled_client import AmbiLEDClient from wled_controller.core.devices.ambiled_client import AmbiLEDClient
return AmbiLEDClient(
led_count = kwargs.pop("led_count", 0) url,
baud_rate = kwargs.pop("baud_rate", None) led_count=kwargs.get("led_count", 0),
kwargs.pop("use_ddp", None) baud_rate=kwargs.get("baud_rate"),
kwargs.pop("send_latency_ms", None) )
kwargs.pop("rgbw", None)
return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)

View File

@@ -24,8 +24,11 @@ class MockDeviceProvider(LEDDeviceProvider):
return {"manual_led_count", "power_control", "brightness_control"} return {"manual_led_count", "power_control", "brightness_control"}
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
kwargs.pop("use_ddp", None) return MockClient(
return MockClient(url, **kwargs) 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: 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)) return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))

View File

@@ -31,7 +31,10 @@ class MQTTDeviceProvider(LEDDeviceProvider):
return {"manual_led_count"} return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient: 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( async def check_health(
self, url: str, http_client, prev_health=None, self, url: str, http_client, prev_health=None,

View File

@@ -30,12 +30,10 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
return {"health_check", "auto_restore", "static_color"} return {"health_check", "auto_restore", "static_color"}
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
zone_mode = kwargs.pop("zone_mode", "combined") return OpenRGBLEDClient(
kwargs.pop("led_count", None) url,
kwargs.pop("baud_rate", None) zone_mode=kwargs.get("zone_mode", "combined"),
kwargs.pop("send_latency_ms", None) )
kwargs.pop("rgbw", None)
return OpenRGBLEDClient(url, zone_mode=zone_mode, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await OpenRGBLEDClient.check_health(url, http_client, prev_health) return await OpenRGBLEDClient.check_health(url, http_client, prev_health)

View File

@@ -53,12 +53,10 @@ class WLEDDeviceProvider(LEDDeviceProvider):
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.wled_client import WLEDClient from wled_controller.core.devices.wled_client import WLEDClient
kwargs.pop("led_count", None) return WLEDClient(
kwargs.pop("baud_rate", None) url,
kwargs.pop("send_latency_ms", None) use_ddp=kwargs.get("use_ddp", False),
kwargs.pop("rgbw", None) )
kwargs.pop("zone_mode", None)
return WLEDClient(url, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
from wled_controller.core.devices.wled_client import WLEDClient from wled_controller.core.devices.wled_client import WLEDClient

View File

@@ -27,7 +27,10 @@ class WSDeviceProvider(LEDDeviceProvider):
return {"manual_led_count"} return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient: 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( async def check_health(
self, url: str, http_client, prev_health=None, self, url: str, http_client, prev_health=None,

View File

@@ -4,6 +4,7 @@
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js'; import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js';
import { EDGE_COLORS } from './graph-edges.js'; import { EDGE_COLORS } from './graph-edges.js';
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.js';
import * as P from './icon-paths.js'; import * as P from './icon-paths.js';
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -143,34 +144,43 @@ function renderNode(node, callbacks) {
barHit.style.cursor = 'pointer'; barHit.style.cursor = 'pointer';
barHit.addEventListener('click', (e) => { barHit.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
// Create temporary color input positioned near the click const svg = barHit.ownerSVGElement;
const input = document.createElement('input'); const container = svg?.closest('.graph-container');
input.type = 'color'; if (!svg || !container) return;
input.value = color;
input.style.position = 'fixed'; // Remove any previous graph color picker overlay
input.style.left = e.clientX + 'px'; container.querySelector('.graph-cp-overlay')?.remove();
input.style.top = e.clientY + 'px'; closeAllColorPickers();
input.style.width = '0';
input.style.height = '0'; // Compute position relative to container
input.style.padding = '0'; const ctm = barHit.getScreenCTM();
input.style.border = 'none'; const cr = container.getBoundingClientRect();
input.style.opacity = '0'; const px = (ctm ? ctm.e : e.clientX) - cr.left;
input.style.pointerEvents = 'none'; const py = (ctm ? ctm.f : e.clientY) - cr.top;
document.body.appendChild(input);
input.addEventListener('input', () => { // Create an HTML overlay with the custom color picker
const c = input.value; const pickerId = `graph-node-${id}`;
bar.setAttribute('fill', c); const overlay = document.createElement('div');
barCover.setAttribute('fill', c); overlay.className = 'graph-cp-overlay';
_saveNodeColor(id, c); overlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`;
overlay.innerHTML = createColorPicker({
id: pickerId,
currentColor: color,
anchor: 'left',
}); });
input.addEventListener('change', () => { container.appendChild(overlay);
input.remove();
// Register callback to update the bar color
registerColorPicker(pickerId, (hex) => {
color = hex;
bar.setAttribute('fill', hex);
barCover.setAttribute('fill', hex);
_saveNodeColor(id, hex);
overlay.remove();
}); });
// Fallback remove if user cancels
input.addEventListener('blur', () => { // Open the popover immediately
setTimeout(() => input.remove(), 200); window._cpToggle(pickerId);
});
input.click();
}); });
g.appendChild(barHit); g.appendChild(barHit);

View File

@@ -114,7 +114,7 @@ class WledOutputTarget(OutputTarget):
target_type="led", target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id", ""), brightness_value_source_id=data.get("brightness_value_source_id") or "",
fps=data.get("fps", 30), fps=data.get("fps", 30),
keepalive_interval=data.get("keepalive_interval", 1.0), keepalive_interval=data.get("keepalive_interval", 1.0),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),