diff --git a/TODO.md b/TODO.md index d4822ed..6c240dc 100644 --- a/TODO.md +++ b/TODO.md @@ -7,32 +7,58 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [x] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content - [x] `P1` **Color temperature filter** — Already covered by existing Color Correction filter (2000-10000K) - [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color + - Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes + - Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips - [x] `P2` **Palette quantization** — Force output to match a user-defined palette (preset or custom hex) - [x] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually - [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut + - Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing + - Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently ## Output Targets - [ ] `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 - [ ] `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 ## Automation & Integration - [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration + - Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop + - Impact: high — key integration point for home automation users without Home Assistant - [ ] `P2` **WebSocket event bus** — Broadcast all state changes over a single WS channel + - Complexity: low-medium — ProcessorManager already emits events; add a WS endpoint that fans out JSON events to connected clients + - Impact: medium — enables real-time dashboards, mobile apps, and third-party integrations - [ ] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter) + - Complexity: large — OS-level notification listener (platform-specific: Win32 `WinToast`/`pystray`, macOS `pyobjc`); needs a new "effect source" type that triggers color pulses + - Impact: low-medium — fun but niche; platform-dependent maintenance burden ## Multi-Display - [ ] `P2` **Investigate multimonitor support** — Research and plan support for multi-monitor setups + - Complexity: research — audit DXGI/MSS capture engine's display enumeration; test with 2+ monitors; identify gaps in calibration UI (per-display config) + - Impact: high — many users have multi-monitor setups; prerequisite for multi-display unification - [ ] `P3` **Multi-display unification** — Treat 2-3 monitors as single virtual display for seamless ambilight + - Complexity: large — virtual display abstraction stitching multiple captures; edge-matching calibration between monitors; significant UI changes + - Impact: high — flagship feature for multi-monitor users, but depends on investigation results ## Capture Engines - [ ] `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 ## UX - [ ] `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 9a497c7..1d19f3f 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "PyAudioWPatch>=0.2.12; sys_platform == 'win32'", "sounddevice>=0.5", "aiomqtt>=2.0.0", + "openrgb-python>=0.2.15", ] [project.optional-dependencies] diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index 9de35bd..10cf290 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -288,5 +288,8 @@ def _register_builtin_providers(): from wled_controller.core.devices.ws_provider import WSDeviceProvider register_provider(WSDeviceProvider()) + from wled_controller.core.devices.openrgb_provider import OpenRGBDeviceProvider + register_provider(OpenRGBDeviceProvider()) + _register_builtin_providers() diff --git a/server/src/wled_controller/core/devices/openrgb_client.py b/server/src/wled_controller/core/devices/openrgb_client.py new file mode 100644 index 0000000..3f1f00f --- /dev/null +++ b/server/src/wled_controller/core/devices/openrgb_client.py @@ -0,0 +1,255 @@ +"""OpenRGB LED client — controls RGB peripherals via the OpenRGB SDK server.""" + +import asyncio +import socket +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple, Union + +import numpy as np + +from wled_controller.core.devices.led_client import DeviceHealth, LEDClient +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +def parse_openrgb_url(url: str) -> Tuple[str, int, int]: + """Parse an openrgb:// URL into (host, port, device_index). + + Format: openrgb://host:port/device_index + Defaults: host=localhost, port=6742, device_index=0 + """ + if url.startswith("openrgb://"): + url = url[len("openrgb://"):] + else: + return ("localhost", 6742, 0) + + # Split path from host:port + if "/" in url: + host_port, index_str = url.split("/", 1) + try: + device_index = int(index_str) + except ValueError: + device_index = 0 + else: + host_port = url + device_index = 0 + + # Split host and port + if ":" in host_port: + host, port_str = host_port.rsplit(":", 1) + try: + port = int(port_str) + except ValueError: + port = 6742 + else: + host = host_port if host_port else "localhost" + port = 6742 + + return (host, port, device_index) + + +class OpenRGBLEDClient(LEDClient): + """Controls an OpenRGB device via the openrgb-python library. + + The OpenRGB SDK server (TCP port 6742) exposes PC peripherals such as + keyboards, mice, RAM, GPU, and fans for live RGB control. + + Uses asyncio.to_thread() for blocking library calls in connect/close, + and direct synchronous calls for send_pixels_fast (runs in processing thread). + """ + + def __init__(self, url: str, **kwargs): + self._url = url + host, port, device_index = parse_openrgb_url(url) + self._host = host + self._port = port + self._device_index = device_index + self._client: Any = None # openrgb.OpenRGBClient + self._device: Any = None # openrgb.Device + self._connected = False + self._device_name: Optional[str] = None + self._device_led_count: Optional[int] = None + + async def connect(self) -> bool: + """Connect to OpenRGB server and access the target device.""" + try: + self._client, self._device = await asyncio.to_thread(self._connect_sync) + self._connected = True + self._device_name = self._device.name + self._device_led_count = len(self._device.leds) + logger.info( + f"Connected to OpenRGB device '{self._device_name}' " + f"({self._device_led_count} LEDs) at {self._host}:{self._port}/{self._device_index}" + ) + return True + except Exception as e: + self._connected = False + raise ConnectionError(f"Failed to connect to OpenRGB: {e}") from e + + def _connect_sync(self) -> Tuple[Any, Any]: + """Synchronous connect — runs in thread pool.""" + from openrgb import OpenRGBClient + from openrgb.utils import DeviceType + + client = OpenRGBClient(self._host, self._port, name="WLED Controller") + devices = client.devices + if self._device_index >= len(devices): + client.disconnect() + raise ValueError( + f"Device index {self._device_index} out of range " + f"(server has {len(devices)} device(s))" + ) + device = devices[self._device_index] + # Set direct mode for live control (try direct first, fall back) + try: + device.set_mode("direct") + except Exception: + try: + # Some devices use different mode names + for mode in device.modes: + if mode.name.lower() in ("direct", "static", "custom"): + device.set_mode(mode.id) + break + except Exception as e: + logger.warning(f"Could not set direct mode on '{device.name}': {e}") + return client, device + + async def close(self) -> None: + """Disconnect from the OpenRGB server.""" + if self._client is not None: + try: + await asyncio.to_thread(self._client.disconnect) + except Exception as e: + logger.debug(f"Error disconnecting OpenRGB: {e}") + finally: + self._client = None + self._device = None + self._connected = False + + @property + def is_connected(self) -> bool: + return self._connected and self._client is not None + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + """Send pixel colors to the OpenRGB device (async wrapper).""" + if not self.is_connected: + return False + try: + await asyncio.to_thread(self.send_pixels_fast, pixels, brightness) + return True + except Exception as e: + logger.error(f"OpenRGB send_pixels failed: {e}") + self._connected = False + return False + + @property + def supports_fast_send(self) -> bool: + return True + + def send_pixels_fast( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> None: + """Synchronous fire-and-forget send for the processing hot loop. + + Converts numpy (N,3) array to List[RGBColor] and calls + device.set_colors(colors, fast=True) to skip the re-fetch round trip. + """ + if not self.is_connected or self._device is None: + return + + try: + from openrgb.utils import RGBColor + + if isinstance(pixels, np.ndarray): + pixel_array = pixels + else: + pixel_array = np.array(pixels, dtype=np.uint8) + + # Apply brightness scaling + if brightness < 255: + pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8) + + # Truncate or pad to match device LED count + n_device = len(self._device.leds) + n_pixels = len(pixel_array) + if n_pixels > n_device: + pixel_array = pixel_array[:n_device] + + colors = [RGBColor(int(r), int(g), int(b)) for r, g, b in pixel_array] + + # Pad with black if fewer pixels than device LEDs + if len(colors) < n_device: + colors.extend([RGBColor(0, 0, 0)] * (n_device - len(colors))) + + self._device.set_colors(colors, fast=True) + except Exception as e: + logger.error(f"OpenRGB send_pixels_fast failed: {e}") + self._connected = False + + async def snapshot_device_state(self) -> Optional[dict]: + """Save the active mode index before streaming.""" + if self._device is None: + return None + try: + return {"active_mode": self._device.active_mode} + except Exception as e: + logger.warning(f"Could not snapshot OpenRGB device state: {e}") + return None + + async def restore_device_state(self, state: Optional[dict]) -> None: + """Restore the original mode after streaming stops.""" + if not state or self._device is None: + return + try: + mode_id = state.get("active_mode") + if mode_id is not None: + await asyncio.to_thread(self._device.set_mode, mode_id) + logger.info(f"Restored OpenRGB device mode to {mode_id}") + except Exception as e: + logger.warning(f"Could not restore OpenRGB device state: {e}") + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health: Optional[DeviceHealth] = None, + ) -> DeviceHealth: + """Check OpenRGB server reachability via raw TCP socket connect. + + Uses a lightweight socket probe instead of full library init to avoid + re-downloading all device data every health check cycle (~30s). + """ + host, port, device_index = parse_openrgb_url(url) + start = asyncio.get_event_loop().time() + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3.0) + await asyncio.to_thread(sock.connect, (host, port)) + sock.close() + latency = (asyncio.get_event_loop().time() - start) * 1000 + + # Preserve cached device metadata from previous health check + device_name = prev_health.device_name if prev_health else None + device_led_count = prev_health.device_led_count if prev_health else None + + return DeviceHealth( + online=True, + latency_ms=latency, + last_checked=datetime.utcnow(), + device_name=device_name, + device_led_count=device_led_count, + ) + except Exception as e: + return DeviceHealth( + online=False, + last_checked=datetime.utcnow(), + error=str(e), + ) diff --git a/server/src/wled_controller/core/devices/openrgb_provider.py b/server/src/wled_controller/core/devices/openrgb_provider.py new file mode 100644 index 0000000..b02b37b --- /dev/null +++ b/server/src/wled_controller/core/devices/openrgb_provider.py @@ -0,0 +1,132 @@ +"""OpenRGB device provider — factory, validation, health checks, discovery.""" + +import asyncio +from typing import List, Optional, Tuple + +from wled_controller.core.devices.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, +) +from wled_controller.core.devices.openrgb_client import ( + OpenRGBLEDClient, + parse_openrgb_url, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class OpenRGBDeviceProvider(LEDDeviceProvider): + """Provider for OpenRGB-controlled PC peripherals.""" + + @property + def device_type(self) -> str: + return "openrgb" + + @property + def capabilities(self) -> set: + return {"health_check", "auto_restore", "static_color"} + + def create_client(self, url: str, **kwargs) -> LEDClient: + kwargs.pop("led_count", None) + kwargs.pop("baud_rate", None) + kwargs.pop("send_latency_ms", None) + kwargs.pop("rgbw", None) + return OpenRGBLEDClient(url, **kwargs) + + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + return await OpenRGBLEDClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + """Validate an OpenRGB device URL by connecting and reading device info. + + Returns: + dict with 'led_count' key. + + Raises: + Exception on validation failure. + """ + host, port, device_index = parse_openrgb_url(url) + + def _validate_sync(): + from openrgb import OpenRGBClient + + client = OpenRGBClient(host, port, name="WLED Controller (validate)") + try: + devices = client.devices + if device_index >= len(devices): + raise ValueError( + f"Device index {device_index} out of range " + f"(server has {len(devices)} device(s))" + ) + device = devices[device_index] + led_count = len(device.leds) + logger.info( + f"OpenRGB device validated: '{device.name}' ({led_count} LEDs)" + ) + return {"led_count": led_count} + finally: + client.disconnect() + + return await asyncio.to_thread(_validate_sync) + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + """Discover OpenRGB devices on localhost.""" + + def _discover_sync(): + from openrgb import OpenRGBClient + + results = [] + try: + client = OpenRGBClient("localhost", 6742, name="WLED Controller (discover)") + except Exception as e: + logger.debug(f"OpenRGB discovery failed (server not running?): {e}") + return results + + try: + for idx, device in enumerate(client.devices): + results.append( + DiscoveredDevice( + name=f"{device.name}", + url=f"openrgb://localhost:6742/{idx}", + device_type="openrgb", + ip="localhost", + mac="", + led_count=len(device.leds), + version=None, + ) + ) + except Exception as e: + logger.warning(f"OpenRGB discovery error enumerating devices: {e}") + finally: + try: + client.disconnect() + except Exception: + pass + + return results + + return await asyncio.to_thread(_discover_sync) + + async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: + """Set all LEDs on the OpenRGB device to a solid color.""" + host, port, device_index = parse_openrgb_url(url) + + def _set_color_sync(): + from openrgb import OpenRGBClient + from openrgb.utils import RGBColor + + client = OpenRGBClient(host, port, name="WLED Controller (color)") + try: + devices = client.devices + if device_index >= len(devices): + raise ValueError(f"Device index {device_index} out of range") + device = devices[device_index] + color_obj = RGBColor(color[0], color[1], color[2]) + device.set_color(color_obj) + finally: + client.disconnect() + + await asyncio.to_thread(_set_color_sync) diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 53c17ba..8968fd6 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -86,6 +86,10 @@ export function isWsDevice(type) { return type === 'ws'; } +export function isOpenrgbDevice(type) { + return type === 'openrgb'; +} + export function handle401Error() { if (!apiKey) return; // Already handled or no session localStorage.removeItem('wled_api_key'); diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index 03ec2ce..d0f5351 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -6,7 +6,7 @@ import { _discoveryScanRunning, set_discoveryScanRunning, _discoveryCache, set_discoveryCache, } from '../core/state.js'; -import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, escapeHtml } from '../core/api.js'; +import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -111,6 +111,24 @@ export function onDeviceTypeChanged() { serialSelect.appendChild(opt); } updateBaudFpsHint(); + } else if (isOpenrgbDevice(deviceType)) { + urlGroup.style.display = ''; + urlInput.setAttribute('required', ''); + serialGroup.style.display = 'none'; + serialSelect.removeAttribute('required'); + ledCountGroup.style.display = 'none'; + baudRateGroup.style.display = 'none'; + if (ledTypeGroup) ledTypeGroup.style.display = 'none'; + if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; + if (scanBtn) scanBtn.style.display = ''; + if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); + if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); + urlInput.placeholder = 'openrgb://localhost:6742/0'; + if (deviceType in _discoveryCache) { + _renderDiscoveryList(); + } else { + scanForDevices(); + } } else { urlGroup.style.display = ''; urlInput.setAttribute('required', ''); diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 0cbb6c7..e6d9e68 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -5,7 +5,7 @@ import { _deviceBrightnessCache, updateDeviceBrightness, } from '../core/state.js'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice } from '../core/api.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -203,6 +203,10 @@ export async function showSettings(deviceId) { if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; + } else if (isOpenrgbDevice(device.device_type)) { + if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); + if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); + urlInput.placeholder = 'openrgb://localhost:6742/0'; } else { if (urlLabel) urlLabel.textContent = t('device.url'); if (urlHint) urlHint.textContent = t('settings.url.hint'); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a0c82c6..2e94620 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -134,6 +134,9 @@ "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room", "device.ws_url": "Connection URL:", "device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data", + "device.openrgb.url": "OpenRGB URL:", + "device.openrgb.url.hint": "OpenRGB server address (e.g. openrgb://localhost:6742/0)", + "device.type.openrgb": "OpenRGB", "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", "device.name": "Device Name:", "device.name.placeholder": "Living Room TV", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index eefeb1b..9772950 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -134,6 +134,9 @@ "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная", "device.ws_url": "URL подключения:", "device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных", + "device.openrgb.url": "OpenRGB URL:", + "device.openrgb.url.hint": "Адрес сервера OpenRGB (напр. openrgb://localhost:6742/0)", + "device.type.openrgb": "OpenRGB", "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", "device.name": "Имя Устройства:", "device.name.placeholder": "ТВ в Гостиной", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 5048c4e..272663c 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -134,6 +134,9 @@ "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅", "device.ws_url": "连接 URL:", "device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL", + "device.openrgb.url": "OpenRGB URL:", + "device.openrgb.url.hint": "OpenRGB 服务器地址(例如 openrgb://localhost:6742/0)", + "device.type.openrgb": "OpenRGB", "device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)", "device.name": "设备名称:", "device.name.placeholder": "客厅电视", diff --git a/server/src/wled_controller/templates/modals/add-device.html b/server/src/wled_controller/templates/modals/add-device.html index 9ddc731..621238c 100644 --- a/server/src/wled_controller/templates/modals/add-device.html +++ b/server/src/wled_controller/templates/modals/add-device.html @@ -32,6 +32,7 @@ +