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:
26
TODO.md
26
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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
255
server/src/wled_controller/core/devices/openrgb_client.py
Normal file
255
server/src/wled_controller/core/devices/openrgb_client.py
Normal 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),
|
||||
)
|
||||
132
server/src/wled_controller/core/devices/openrgb_provider.py
Normal file
132
server/src/wled_controller/core/devices/openrgb_provider.py
Normal 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)
|
||||
@@ -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');
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ТВ в Гостиной",
|
||||
|
||||
@@ -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": "客厅电视",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user