Add OpenRGB device support for PC peripheral ambient lighting

New device type enabling control of keyboards, mice, RAM, GPU, and fans
via the OpenRGB SDK server (TCP port 6742). Includes auto-discovery,
health monitoring, state snapshot/restore, and fast synchronous pixel
send with brightness scaling. Also updates TODO.md with complexity notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 11:30:02 +03:00
parent bf2fd5ca69
commit b9ec509f56
12 changed files with 455 additions and 2 deletions

26
TODO.md
View File

@@ -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

View File

@@ -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]

View File

@@ -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()

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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');

View File

@@ -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', '');

View File

@@ -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');

View File

@@ -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",

View File

@@ -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": "ТВ в Гостиной",

View File

@@ -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": "客厅电视",

View File

@@ -32,6 +32,7 @@
<option value="ambiled">AmbiLED</option>
<option value="mqtt">MQTT</option>
<option value="ws">WebSocket</option>
<option value="openrgb">OpenRGB</option>
<option value="mock">Mock</option>
</select>
</div>