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