diff --git a/TODO.md b/TODO.md index 6c240dc..a1826a6 100644 --- a/TODO.md +++ b/TODO.md @@ -20,9 +20,7 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [ ] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity - Complexity: low — mechanical rename across routes, schemas, store, frontend fetch calls; no logic changes, but many files touched (~20+), needs care with stored JSON migration - Impact: low-medium — improves API clarity for future integrations (OpenRGB, Art-Net) -- [ ] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets - - Complexity: medium — new device type + client (OpenRGB SDK uses a TCP socket protocol); new target processor subclass; device discovery via OpenRGB server - - Impact: high — extends ambient lighting beyond WLED to entire PC ecosystem +- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets - [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers - Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI - Impact: medium — opens stage/theatrical use case, niche but differentiating @@ -53,12 +51,13 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices - Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect - Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case -- [ ] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting - - Complexity: medium — OpenCV `VideoCapture` is straightforward; needs new capture source type + calibration for camera field of view; FPS/resolution config - - Impact: low-medium — room-reactive lighting is novel but limited practical appeal +- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting ## UX +- [ ] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag + - Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration + - Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming") - [ ] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest - Complexity: medium-large — responsive CSS overhaul for all tabs; service worker for offline caching; manifest.json; touch-friendly controls (larger tap targets, swipe gestures) - Impact: high — phone control is a top user request; current desktop-first layout is unusable on mobile diff --git a/server/pyproject.toml b/server/pyproject.toml index 1d19f3f..82fdbad 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -57,6 +57,9 @@ dev = [ "black>=24.0.0", "ruff>=0.6.0", ] +camera = [ + "opencv-python-headless>=4.8.0", +] # High-performance screen capture engines (Windows only) perf = [ "dxcam>=0.0.5; sys_platform == 'win32'", diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index 160191e..fd51010 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -228,6 +228,7 @@ async def list_engines(_auth: AuthRequired): name=engine_type.upper(), default_config=engine_class.get_default_config(), available=(engine_type in available_set), + has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False), ) ) diff --git a/server/src/wled_controller/api/schemas/templates.py b/server/src/wled_controller/api/schemas/templates.py index 2777598..c0d04ca 100644 --- a/server/src/wled_controller/api/schemas/templates.py +++ b/server/src/wled_controller/api/schemas/templates.py @@ -50,6 +50,7 @@ class EngineInfo(BaseModel): name: str = Field(description="Human-readable engine name") default_config: Dict = Field(description="Default configuration for this engine") available: bool = Field(description="Whether engine is available on this system") + has_own_displays: bool = Field(default=False, description="Engine has its own device list (not desktop monitors)") class EngineListResponse(BaseModel): diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py index 03cea13..a914332 100644 --- a/server/src/wled_controller/core/capture_engines/__init__.py +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -12,6 +12,7 @@ from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine, DXcam from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream +from wled_controller.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream # Auto-register available engines EngineRegistry.register(MSSEngine) @@ -19,6 +20,7 @@ EngineRegistry.register(DXcamEngine) EngineRegistry.register(BetterCamEngine) EngineRegistry.register(WGCEngine) EngineRegistry.register(ScrcpyEngine) +EngineRegistry.register(CameraEngine) __all__ = [ "CaptureEngine", @@ -36,4 +38,6 @@ __all__ = [ "WGCCaptureStream", "ScrcpyEngine", "ScrcpyCaptureStream", + "CameraEngine", + "CameraCaptureStream", ] diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py index 7a2934d..7f634dc 100644 --- a/server/src/wled_controller/core/capture_engines/base.py +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -103,6 +103,7 @@ class CaptureEngine(ABC): ENGINE_TYPE: str = "base" ENGINE_PRIORITY: int = 0 + HAS_OWN_DISPLAYS: bool = False # True for engines with non-desktop device lists @classmethod @abstractmethod diff --git a/server/src/wled_controller/core/capture_engines/camera_engine.py b/server/src/wled_controller/core/capture_engines/camera_engine.py new file mode 100644 index 0000000..56582ef --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/camera_engine.py @@ -0,0 +1,276 @@ +"""Camera/webcam capture engine using OpenCV. + +Captures frames from USB/integrated webcams via ``cv2.VideoCapture``. +Each camera is exposed as a "display" through the standard +``CaptureEngine`` / ``CaptureStream`` architecture. + +Prerequisites (optional dependency): + pip install opencv-python-headless>=4.8.0 +""" + +import platform +import sys +from typing import Any, Dict, List, Optional + +import numpy as np + +from wled_controller.core.capture_engines.base import ( + CaptureEngine, + CaptureStream, + DisplayInfo, + ScreenCapture, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +_MAX_CAMERA_INDEX = 10 # probe indices 0..9 +_CV2_BACKENDS = { + "auto": None, + "dshow": 700, # cv2.CAP_DSHOW + "msmf": 1400, # cv2.CAP_MSMF + "v4l2": 200, # cv2.CAP_V4L2 +} + + +def _get_default_backend(): + """Return the best default backend for the current platform.""" + if sys.platform == "win32": + return "dshow" # faster open than MSMF on Windows + return "auto" + + +def _cv2_backend_id(backend_name: str) -> Optional[int]: + """Convert a backend name string to cv2 API preference constant.""" + return _CV2_BACKENDS.get(backend_name) + + +def _get_camera_friendly_names() -> Dict[int, str]: + """Get friendly names for cameras from OS. + + On Windows, queries WMI for PnP camera devices. + Returns a dict mapping sequential index → friendly name. + """ + if platform.system() != "Windows": + return {} + + try: + import wmi + c = wmi.WMI() + cameras = c.query( + "SELECT Name FROM Win32_PnPEntity WHERE PNPClass = 'Camera'" + ) + return {i: cam.Name for i, cam in enumerate(cameras)} + except Exception as e: + logger.debug(f"WMI camera enumeration failed: {e}") + return {} + + +def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]: + """Probe camera indices and return metadata for each available camera. + + Returns a list of dicts: {cv2_index, name, width, height, fps}. + """ + try: + import cv2 + except ImportError: + return [] + + backend_id = _cv2_backend_id(backend_name) + friendly_names = _get_camera_friendly_names() + + cameras: List[Dict[str, Any]] = [] + sequential_idx = 0 + + for i in range(_MAX_CAMERA_INDEX): + if backend_id is not None: + cap = cv2.VideoCapture(i, backend_id) + else: + cap = cv2.VideoCapture(i) + + if not cap.isOpened(): + cap.release() + continue + + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + + name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}") + cap.release() + + cameras.append({ + "cv2_index": i, + "name": name, + "width": width, + "height": height, + "fps": fps, + }) + sequential_idx += 1 + + return cameras + + +# --------------------------------------------------------------------------- +# CaptureStream +# --------------------------------------------------------------------------- + +class CameraCaptureStream(CaptureStream): + """OpenCV-based webcam capture stream. + + Synchronous capture like MSSEngine — ``capture_frame()`` grabs + one frame per call. OpenCV's internal buffering keeps latency low. + """ + + def __init__(self, display_index: int, config: Dict[str, Any]): + super().__init__(display_index, config) + self._cap = None + + def initialize(self) -> None: + if self._initialized: + return + + import cv2 + + backend_name = self.config.get("camera_backend", _get_default_backend()) + + # Enumerate to resolve display_index → actual cv2 camera index + cameras = _enumerate_cameras(backend_name) + if not cameras: + raise RuntimeError( + "No cameras found. Ensure a webcam is connected." + ) + if self.display_index >= len(cameras): + raise RuntimeError( + f"Camera index {self.display_index} out of range " + f"(found {len(cameras)} camera(s))" + ) + + 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) + + if not self._cap.isOpened(): + raise RuntimeError( + f"Failed to open camera {self.display_index} " + f"(cv2 index {cv2_index})" + ) + + # Apply optional resolution override + res_w = self.config.get("resolution_width", 0) + res_h = self.config.get("resolution_height", 0) + if res_w > 0 and res_h > 0: + self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w) + self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h) + + # Test read + ret, frame = self._cap.read() + if not ret or frame is None: + self._cap.release() + self._cap = None + raise RuntimeError( + f"Camera {self.display_index} opened but test read failed" + ) + + h, w = frame.shape[:2] + self._initialized = True + logger.info( + f"Camera capture stream initialized " + f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})" + ) + + def capture_frame(self) -> Optional[ScreenCapture]: + if not self._initialized: + self.initialize() + + import cv2 + + ret, frame = self._cap.read() + if not ret or frame is None: + return None + + # OpenCV captures BGR; convert to RGB + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + h, w = rgb.shape[:2] + + return ScreenCapture( + image=rgb, + width=w, + height=h, + display_index=self.display_index, + ) + + def cleanup(self) -> None: + if self._cap is not None: + self._cap.release() + self._cap = None + self._initialized = False + logger.info(f"Camera capture stream cleaned up (display={self.display_index})") + + +# --------------------------------------------------------------------------- +# CaptureEngine +# --------------------------------------------------------------------------- + +class CameraEngine(CaptureEngine): + """Camera/webcam capture engine using OpenCV. + + Captures frames from USB or integrated webcams. + Each connected camera appears as a selectable "display". + + Prerequisites: + pip install opencv-python-headless>=4.8.0 + """ + + ENGINE_TYPE = "camera" + ENGINE_PRIORITY = 0 + HAS_OWN_DISPLAYS = True + + @classmethod + def is_available(cls) -> bool: + try: + import cv2 # noqa: F401 + return True + except ImportError: + return False + + @classmethod + def get_default_config(cls) -> Dict[str, Any]: + return { + "camera_backend": _get_default_backend(), + "resolution_width": 0, + "resolution_height": 0, + } + + @classmethod + def get_available_displays(cls) -> List[DisplayInfo]: + backend = _get_default_backend() + cameras = _enumerate_cameras(backend) + + displays = [] + for idx, cam in enumerate(cameras): + displays.append(DisplayInfo( + index=idx, + name=cam["name"], + width=cam["width"], + height=cam["height"], + x=idx * 500, + y=0, + is_primary=(idx == 0), + refresh_rate=int(cam["fps"]), + )) + + logger.debug(f"Camera engine detected {len(displays)} camera(s)") + return displays + + @classmethod + def create_stream( + cls, display_index: int, config: Dict[str, Any] + ) -> CameraCaptureStream: + return CameraCaptureStream(display_index, config) diff --git a/server/src/wled_controller/core/capture_engines/scrcpy_engine.py b/server/src/wled_controller/core/capture_engines/scrcpy_engine.py index 2b7f46b..3ba0d98 100644 --- a/server/src/wled_controller/core/capture_engines/scrcpy_engine.py +++ b/server/src/wled_controller/core/capture_engines/scrcpy_engine.py @@ -304,6 +304,7 @@ class ScrcpyEngine(CaptureEngine): ENGINE_TYPE = "scrcpy" ENGINE_PRIORITY = 5 + HAS_OWN_DISPLAYS = True @classmethod def is_available(cls) -> bool: diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 8b4509a..f132f76 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -723,6 +723,36 @@ background: rgba(76, 175, 80, 0.12) !important; } +/* ── Device picker list (cameras, scrcpy) ─────────────────────── */ + +.device-picker-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.device-picker-item { + position: relative; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border: 2px solid var(--border-color); + border-radius: 8px; + background: linear-gradient(135deg, rgba(128, 128, 128, 0.08), rgba(128, 128, 128, 0.03)); + cursor: pointer; +} + +.device-picker-item .layout-index-label { + position: static; + flex-shrink: 0; +} + +.device-picker-item .layout-display-label { + text-align: left; + padding: 0; +} + /* ── Gradient editor ────────────────────────────────────────────── */ .effect-palette-preview { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index e6d9e68..d43d285 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -73,7 +73,7 @@ export function createDeviceCard(device) { healthClass = 'health-offline'; healthTitle = t('device.health.offline'); if (state.device_error) healthTitle += `: ${state.device_error}`; - healthLabel = `${t('device.health.offline')}`; + healthLabel = ''; } const ledCount = state.device_led_count || device.led_count; diff --git a/server/src/wled_controller/static/js/features/displays.js b/server/src/wled_controller/static/js/features/displays.js index 6268d69..4fbcada 100644 --- a/server/src/wled_controller/static/js/features/displays.js +++ b/server/src/wled_controller/static/js/features/displays.js @@ -6,6 +6,7 @@ import { _cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex, set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache, + availableEngines, } from '../core/state.js'; import { t } from '../core/i18n.js'; import { fetchWithAuth } from '../core/api.js'; @@ -14,6 +15,10 @@ import { showToast } from '../core/ui.js'; /** Currently active engine type for the picker (null = desktop monitors). */ let _pickerEngineType = null; +/** Check if an engine type has its own device list (for inline onclick use). */ +window._engineHasOwnDisplays = (engineType) => + !!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays); + export function openDisplayPicker(callback, selectedIndex, engineType = null) { set_displayPickerCallback(callback); set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null); @@ -57,8 +62,10 @@ async function _fetchAndRenderEngineDisplays(engineType) { if (displays.length > 0) { renderDisplayPickerLayout(displays, engineType); - } else { + } else if (engineType === 'scrcpy') { _renderEmptyAndroidPicker(canvas); + } else { + canvas.innerHTML = `