feat(devices): Open Pixel Control (OPC) target type

Adds support for Open Pixel Control receivers (Fadecandy boards,
xLights/Falcon endpoints, OPC bridges, art-installation controllers,
hobbyist LED driver software). OPC is a tiny TCP protocol on port
7890 with a 4-byte header [channel][cmd][len_hi][len_lo] + RGB body.

Backend:
- OPCClient opens one persistent TCP connection and streams frames as
  header+body byte pairs. Channel 0 broadcasts to every output on the
  OPC server; channels 1-255 address a specific channel on multi-output
  servers (Fadecandy with multiple Open Pixel chains).
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
  The fast path skips the async drain so the OS write-buffer flushes
  on its own schedule -- exactly what ambilight streaming wants.
- Brightness applies client-side before the frame is sent (OPC has no
  reply channel for hardware-side brightness).
- Health check opens a TCP connection and closes it.
- OPCConfig joins the typed config union; storage gains an opc_channel
  field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, header construction, send_pixels
  emitting header+body in order, brightness application, list and
  flat-array input shapes, drain behavior, connection lifecycle,
  provider validate/discover/capabilities, Device.to_config round-trip.

Frontend:
- 'opc' in DEVICE_TYPE_KEYS (next to 'ddp'), paper-plane icon -- same
  as DDP since both are open pixel-streaming protocols.
- isOpcDevice predicate + per-type field show/hide.
- Optional channel number input (default 0 = broadcast) with hint copy
  explaining the channel semantics.
- Locale strings in en/ru/zh.

No native discovery (OPC has no discovery protocol); users supply
the receiver IP manually.
This commit is contained in:
2026-05-16 03:02:41 +03:00
parent 887131d4af
commit 31c6c3abb2
19 changed files with 790 additions and 6 deletions
+4 -1
View File
@@ -738,7 +738,10 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f
### Phase 5 — Open pixel protocols (cheap completionism) ### Phase 5 — Open pixel protocols (cheap completionism)
- [ ] OPC (Open Pixel Control) - [x] **OPC (Open Pixel Control)** — TCP, port 7890, 4-byte header
`[channel][cmd][len_hi][len_lo]` + RGB body. Channel 0 broadcasts.
Single-pixel-strip protocol, no discovery, no pairing. 36 unit
tests. Fadecandy + xLights + hobbyist receivers reachable.
- [ ] TPM2.net - [ ] TPM2.net
### Phase 6 — PC gaming RGB completion ### Phase 6 — PC gaming RGB completion
+3
View File
@@ -70,6 +70,7 @@ def _device_to_response(device) -> DeviceResponse:
wiz_min_interval_ms=device.wiz_min_interval_ms, wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_min_interval_ms, lifx_min_interval_ms=device.lifx_min_interval_ms,
govee_min_interval_ms=device.govee_min_interval_ms, govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel,
spi_speed_hz=device.spi_speed_hz, spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type, spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type, chroma_device_type=device.chroma_device_type,
@@ -245,6 +246,7 @@ async def create_device(
if device_data.govee_min_interval_ms is not None if device_data.govee_min_interval_ms is not None
else 50 else 50
), ),
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0),
spi_speed_hz=device_data.spi_speed_hz or 800000, spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B", spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink", chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -511,6 +513,7 @@ async def update_device(
wiz_min_interval_ms=update_data.wiz_min_interval_ms, wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_min_interval_ms, lifx_min_interval_ms=update_data.lifx_min_interval_ms,
govee_min_interval_ms=update_data.govee_min_interval_ms, govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel,
spi_speed_hz=update_data.spi_speed_hz, spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type, spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type, chroma_device_type=update_data.chroma_device_type,
+11
View File
@@ -91,6 +91,13 @@ class DeviceCreate(BaseModel):
le=10000, le=10000,
description="Govee client-side rate limit between commands in ms (default 50)", description="Govee client-side rate limit between commands in ms (default 50)",
) )
# OPC fields
opc_channel: Optional[int] = Field(
None,
ge=0,
le=255,
description="OPC channel (0 = broadcast to all channels on the server)",
)
# SPI Direct fields # SPI Direct fields
spi_speed_hz: Optional[int] = Field( spi_speed_hz: Optional[int] = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz" None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -191,6 +198,9 @@ class DeviceUpdate(BaseModel):
govee_min_interval_ms: Optional[int] = Field( govee_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms" None, ge=0, le=10000, description="Govee client-side rate limit in ms"
) )
opc_channel: Optional[int] = Field(
None, ge=0, le=255, description="OPC channel (0 = broadcast)"
)
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed") spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type") spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type") chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
@@ -366,6 +376,7 @@ class DeviceResponse(BaseModel):
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms") wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms") lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms") govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz") spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type") spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
@@ -124,6 +124,18 @@ class GoveeConfig(BaseDeviceConfig):
govee_min_interval_ms: int = 50 govee_min_interval_ms: int = 50
@dataclass(frozen=True)
class OPCConfig(BaseDeviceConfig):
"""Open Pixel Control receiver (Fadecandy, OPC bridges, hobbyist drivers).
``opc_channel`` of 0 broadcasts to every channel on the OPC server;
1-255 addresses a specific output on multi-channel servers.
"""
device_type: Literal["opc"] = "opc"
opc_channel: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
class SPIConfig(BaseDeviceConfig): class SPIConfig(BaseDeviceConfig):
device_type: Literal["spi"] = "spi" device_type: Literal["spi"] = "spi"
@@ -198,6 +210,7 @@ DeviceConfig = Union[
WiZConfig, WiZConfig,
LIFXConfig, LIFXConfig,
GoveeConfig, GoveeConfig,
OPCConfig,
AdalightConfig, AdalightConfig,
AmbiLEDConfig, AmbiLEDConfig,
DMXConfig, DMXConfig,
@@ -354,6 +354,10 @@ def _register_builtin_providers():
register_provider(GoveeDeviceProvider()) register_provider(GoveeDeviceProvider())
from ledgrab.core.devices.opc_provider import OPCDeviceProvider
register_provider(OPCDeviceProvider())
# BLE support is optional — only register the provider if the ``bleak`` # BLE support is optional — only register the provider if the ``bleak``
# extra is installed. Importing the provider itself is safe (it doesn't # extra is installed. Importing the provider itself is safe (it doesn't
# import bleak at module load), but we still want a clean skip on # import bleak at module load), but we still want a clean skip on
@@ -0,0 +1,229 @@
"""Open Pixel Control (OPC) LED client.
OPC is a tiny TCP-based protocol used by Fadecandy boards, OpenRGB-OPC
bridges, art-installation controllers, and a variety of hobbyist
LED-driver software. Each packet is a 4-byte header followed by a body:
[channel:1][command:1][length_hi:1][length_lo:1][body…]
For pixel data we use ``command=0`` (set 8-bit pixel colors) with an
RGB body. ``channel=0`` broadcasts to every channel on the server;
channels 1-255 address a specific output. The connection is
persistent — open once and stream frames forever.
URL scheme: ``opc://<host>[:port]`` or bare ``<host>[:port]``.
Default port 7890.
Reference: https://github.com/zestyping/openpixelcontrol
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.utils import get_logger
logger = get_logger(__name__)
OPC_PORT = 7890
OPC_CMD_SET_PIXELS = 0
def parse_opc_url(url: str) -> Tuple[str, int]:
"""Pull ``(host, port)`` from ``opc://host[:port]`` or bare ``host[:port]``."""
if not url:
raise ValueError("OPC URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
port = parsed.port or OPC_PORT
else:
parsed = urlparse(f"opc://{raw}")
host = parsed.hostname or ""
port = parsed.port or OPC_PORT
if not host:
raise ValueError(f"OPC URL has no host: {url!r}")
return host, port
def _build_set_pixels_header(channel: int, body_len: int) -> bytes:
"""Pack the 4-byte OPC header for a SET_PIXELS frame."""
return bytes(
[
channel & 0xFF,
OPC_CMD_SET_PIXELS,
(body_len >> 8) & 0xFF,
body_len & 0xFF,
]
)
class OPCClient(LEDClient):
"""LEDClient for an Open Pixel Control receiver."""
def __init__(
self,
url: str,
led_count: int = 0,
*,
channel: int = 0,
connect_timeout_s: float = 3.0,
):
host, port = parse_opc_url(url)
self._host = host
self._port = port
self._led_count = led_count
self._channel = channel & 0xFF
self._connect_timeout_s = connect_timeout_s
self._writer: Optional[asyncio.StreamWriter] = None
self._reader: Optional[asyncio.StreamReader] = None
self._connected = False
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def channel(self) -> int:
return self._channel
@property
def is_connected(self) -> bool:
return self._connected and self._writer is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def connect(self) -> bool:
if self._connected and self._writer is not None:
return True
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self._host, self._port),
timeout=self._connect_timeout_s,
)
except (OSError, asyncio.TimeoutError) as exc:
raise RuntimeError(f"Failed to connect to OPC at {self._host}: {exc}") from exc
self._connected = True
logger.info(
"OPCClient connected to %s:%d (channel=%d)",
self._host,
self._port,
self._channel,
)
return True
async def close(self) -> None:
if self._writer is not None:
try:
self._writer.close()
await self._writer.wait_closed()
except (OSError, asyncio.CancelledError):
pass
self._writer = None
self._reader = None
self._connected = False
@staticmethod
def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
if brightness >= 255:
return pixels
if brightness <= 0:
return np.zeros_like(pixels)
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
if isinstance(pixels, np.ndarray):
arr = pixels
else:
arr = np.asarray(pixels, dtype=np.uint8)
if arr.dtype != np.uint8:
arr = arr.astype(np.uint8)
if arr.ndim == 1 and arr.shape[0] % 3 == 0:
arr = arr.reshape(-1, 3)
if not arr.flags["C_CONTIGUOUS"]:
arr = np.ascontiguousarray(arr)
return arr
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected:
raise RuntimeError("OPCClient not connected")
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
body = arr.tobytes()
header = _build_set_pixels_header(self._channel, len(body))
assert self._writer is not None
self._writer.write(header)
self._writer.write(body)
await self._writer.drain()
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
"""Synchronous hot-path write. Drain runs implicitly when the OS buffer
flushes — for the ambilight loop, dropping the await is the point."""
if not self.is_connected or self._writer is None:
raise RuntimeError("OPCClient not connected")
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
body = arr.tobytes()
header = _build_set_pixels_header(self._channel, len(body))
self._writer.write(header)
self._writer.write(body)
@property
def supports_fast_send(self) -> bool:
return True
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Open a TCP connection and close it. OPC has no protocol-level
ping; reachable TCP is the strongest signal we get."""
now = datetime.now(timezone.utc)
try:
host, port = parse_opc_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
start = loop.time()
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=2.0,
)
except (OSError, asyncio.TimeoutError) as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"OPC unreachable at {host}:{port}: {exc}",
)
latency_ms = (loop.time() - start) * 1000.0
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
del reader
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
@@ -0,0 +1,61 @@
"""Open Pixel Control device provider — Fadecandy and OPC-compatible receivers."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.core.devices.opc_client import OPCClient, parse_opc_url
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import OPCConfig
logger = get_logger(__name__)
class OPCDeviceProvider(LEDDeviceProvider):
"""Provider for Open Pixel Control receivers (Fadecandy, OPC bridges, etc.).
OPC has no native discovery protocol — users supply an IP. The channel
field (default 0 = broadcast to all OPC channels) routes pixel data to
a specific output on multi-channel servers.
"""
@property
def device_type(self) -> str:
return "opc"
@property
def capabilities(self) -> set:
# OPC has no reply channel; no power / brightness query.
# Software brightness still applies client-side before the frame is sent.
return {"manual_led_count", "health_check"}
def create_client(self, config: "OPCConfig", *, deps: ProviderDeps) -> LEDClient:
return OPCClient(
config.device_url,
led_count=config.led_count,
channel=config.opc_channel,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await OPCClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
try:
host, port = parse_opc_url(url)
except ValueError as exc:
raise ValueError(f"Invalid OPC URL: {exc}") from exc
logger.info("OPC device URL validated: host=%s port=%d", host, port)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""OPC has no discovery protocol — returns empty list."""
return []
+4
View File
@@ -151,6 +151,10 @@ export function isDdpDevice(type: string) {
return type === 'ddp'; return type === 'ddp';
} }
export function isOpcDevice(type: string) {
return type === 'opc';
}
export function isEspnowDevice(type: string) { export function isEspnowDevice(type: string) {
return type === 'espnow'; return type === 'espnow';
} }
+1 -1
View File
@@ -47,7 +47,7 @@ const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.sl
const _deviceTypeIcons = { const _deviceTypeIcons = {
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb), wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette), mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench), dmx: _svg(P.radio), ddp: _svg(P.send), opc: _svg(P.send), mock: _svg(P.wrench),
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), govee: _svg(P.lightbulb), espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), govee: _svg(P.lightbulb),
usbhid: _svg(P.usb), usbhid: _svg(P.usb),
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
@@ -7,7 +7,7 @@ import {
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
csptCache, csptCache,
} from '../core/state.ts'; } from '../core/state.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { devicesCache } from '../core/state.ts'; import { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.ts'; import { showToast, desktopFocus } from '../core/ui.ts';
@@ -39,6 +39,7 @@ class AddDeviceModal extends Modal {
ddpPort: (document.getElementById('device-ddp-port') as HTMLInputElement)?.value || '0', ddpPort: (document.getElementById('device-ddp-port') as HTMLInputElement)?.value || '0',
ddpDestinationId: (document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1', ddpDestinationId: (document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1',
ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1',
opcChannel: (document.getElementById('device-opc-channel') as HTMLInputElement)?.value || '0',
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '', bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '', bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '',
yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500', yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500',
@@ -55,7 +56,7 @@ const addDeviceModal = new AddDeviceModal();
/* ── Icon-grid type selector ──────────────────────────────────── */ /* ── Icon-grid type selector ──────────────────────────────────── */
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'govee', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'opc', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'govee', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
function _buildDeviceTypeItems() { function _buildDeviceTypeItems() {
return DEVICE_TYPE_KEYS.map(key => ({ return DEVICE_TYPE_KEYS.map(key => ({
@@ -291,6 +292,7 @@ export function onDeviceTypeChanged() {
_showChromaFields(false); _showChromaFields(false);
_showGameSenseFields(false); _showGameSenseFields(false);
_showGroupFields(false); _showGroupFields(false);
_showOpcFields(false);
if (isMqttDevice(deviceType)) { if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery // MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
@@ -398,6 +400,25 @@ export function onDeviceTypeChanged() {
if (urlLabel) urlLabel.textContent = t('device.ddp.url'); if (urlLabel) urlLabel.textContent = t('device.ddp.url');
if (urlHint) urlHint.textContent = t('device.ddp.url.hint'); if (urlHint) urlHint.textContent = t('device.ddp.url.hint');
urlInput.placeholder = t('device.ddp.url.placeholder') || '192.168.1.50'; urlInput.placeholder = t('device.ddp.url.placeholder') || '192.168.1.50';
} else if (isOpcDevice(deviceType)) {
// OPC: TCP-based multi-pixel open protocol (Fadecandy, xLights,
// hobbyist drivers). No native discovery — user enters IP manually.
// Single optional channel field (0 = broadcast).
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = '';
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
_showOpcFields(true);
// Relabel URL field as IP Address
if (urlLabel) urlLabel.textContent = t('device.opc.url');
if (urlHint) urlHint.textContent = t('device.opc.url.hint');
urlInput.placeholder = t('device.opc.url.placeholder') || '192.168.1.50';
} else if (isOpenrgbDevice(deviceType)) { } else if (isOpenrgbDevice(deviceType)) {
urlGroup.style.display = ''; urlGroup.style.display = '';
urlInput.setAttribute('required', ''); urlInput.setAttribute('required', '');
@@ -893,6 +914,11 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (iconSelect) iconSelect.setValue(String(cloneData.ddp_color_order)); if (iconSelect) iconSelect.setValue(String(cloneData.ddp_color_order));
} }
} }
// Prefill OPC fields
if (isOpcDevice(presetType)) {
const opcChannel = document.getElementById('device-opc-channel') as HTMLInputElement;
if (opcChannel && cloneData.opc_channel != null) opcChannel.value = String(cloneData.opc_channel);
}
// Prefill Yeelight fields // Prefill Yeelight fields
if (isYeelightDevice(presetType)) { if (isYeelightDevice(presetType)) {
const ymi = document.getElementById('device-yeelight-min-interval') as HTMLInputElement; const ymi = document.getElementById('device-yeelight-min-interval') as HTMLInputElement;
@@ -1112,6 +1138,11 @@ export async function handleAddDevice(event: any) {
body.ddp_destination_id = parseInt((document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1', 10); body.ddp_destination_id = parseInt((document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1', 10);
body.ddp_color_order = parseInt((document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', 10); body.ddp_color_order = parseInt((document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', 10);
} }
if (isOpcDevice(deviceType)) {
const raw = (document.getElementById('device-opc-channel') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '0', 10);
body.opc_channel = Number.isFinite(parsed) ? parsed : 0;
}
if (isEspnowDevice(deviceType)) { if (isEspnowDevice(deviceType)) {
body.espnow_peer_mac = (document.getElementById('device-espnow-peer-mac') as HTMLInputElement)?.value || ''; body.espnow_peer_mac = (document.getElementById('device-espnow-peer-mac') as HTMLInputElement)?.value || '';
body.espnow_channel = parseInt((document.getElementById('device-espnow-channel') as HTMLInputElement)?.value || '1', 10); body.espnow_channel = parseInt((document.getElementById('device-espnow-channel') as HTMLInputElement)?.value || '1', 10);
@@ -1500,6 +1531,11 @@ function _showYeelightFields(show: boolean) {
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
} }
function _showOpcFields(show: boolean) {
const el = document.getElementById('device-opc-channel-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
}
function _showWizFields(show: boolean) { function _showWizFields(show: boolean) {
const el = document.getElementById('device-wiz-min-interval-group') as HTMLElement | null; const el = document.getElementById('device-wiz-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
@@ -6,7 +6,7 @@ import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache,
} from '../core/state.ts'; } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
import { devicesCache } from '../core/state.ts'; import { devicesCache } from '../core/state.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts'; import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
@@ -93,6 +93,7 @@ class DeviceSettingsModal extends Modal {
dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet', dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet',
dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0',
dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1',
opcChannel: (document.getElementById('settings-opc-channel') as HTMLInputElement | null)?.value || '0',
bleFamily: (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || '', bleFamily: (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || '',
bleGoveeKey: (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value || '', bleGoveeKey: (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value || '',
yeelightMinInterval: (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value || '500', yeelightMinInterval: (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value || '500',
@@ -630,6 +631,23 @@ export async function showSettings(deviceId: any) {
if (ddpColorOrderGroup) (ddpColorOrderGroup as HTMLElement).style.display = 'none'; if (ddpColorOrderGroup) (ddpColorOrderGroup as HTMLElement).style.display = 'none';
} }
// OPC-specific fields — single optional channel input (0 = broadcast
// to every channel on the server, 1-255 = specific output). No
// discovery; user enters IP manually. Same URL-relabel pattern as DDP.
const opcChannelGroup = document.getElementById('settings-opc-channel-group');
if (isOpcDevice(device.device_type)) {
if (opcChannelGroup) (opcChannelGroup as HTMLElement).style.display = '';
(document.getElementById('settings-opc-channel') as HTMLInputElement).value = String(device.opc_channel ?? 0);
// Relabel URL field as IP Address
const urlLabelOpc = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHintOpc = urlGroup.querySelector('.input-hint') as HTMLElement | null;
if (urlLabelOpc) urlLabelOpc.textContent = t('device.opc.url');
if (urlHintOpc) urlHintOpc.textContent = t('device.opc.url.hint');
urlInput.placeholder = t('device.opc.url.placeholder') || '192.168.1.50';
} else {
if (opcChannelGroup) (opcChannelGroup as HTMLElement).style.display = 'none';
}
// Yeelight-specific fields — exposed in the settings modal so the // Yeelight-specific fields — exposed in the settings modal so the
// user can tune the per-bulb client-side rate gate without recreating // user can tune the per-bulb client-side rate gate without recreating
// the device. The bulb runs a ~1 cmd/sec cap on the wire; values // the device. The bulb runs a ~1 cmd/sec cap on the wire; values
@@ -841,6 +859,11 @@ export async function saveDeviceSettings() {
body.ddp_destination_id = parseInt((document.getElementById('settings-ddp-destination-id') as HTMLInputElement | null)?.value || '1', 10); body.ddp_destination_id = parseInt((document.getElementById('settings-ddp-destination-id') as HTMLInputElement | null)?.value || '1', 10);
body.ddp_color_order = parseInt((document.getElementById('settings-ddp-color-order') as HTMLSelectElement | null)?.value || '1', 10); body.ddp_color_order = parseInt((document.getElementById('settings-ddp-color-order') as HTMLSelectElement | null)?.value || '1', 10);
} }
if (isOpcDevice(settingsModal.deviceType)) {
const raw = (document.getElementById('settings-opc-channel') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '0', 10);
body.opc_channel = Number.isFinite(parsed) ? parsed : 0;
}
if (isYeelightDevice(settingsModal.deviceType)) { if (isYeelightDevice(settingsModal.deviceType)) {
const raw = (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value; const raw = (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '500', 10); const parsed = parseInt(raw || '500', 10);
+2 -1
View File
@@ -47,7 +47,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string {
export type DeviceType = export type DeviceType =
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws' | 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
| 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee' | 'openrgb' | 'dmx' | 'ddp' | 'opc' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee'
| 'ble' | 'usbhid' | 'spi' | 'ble' | 'usbhid' | 'spi'
| 'chroma' | 'gamesense' | 'group'; | 'chroma' | 'gamesense' | 'group';
@@ -71,6 +71,7 @@ export interface Device {
ddp_port: number; ddp_port: number;
ddp_destination_id: number; ddp_destination_id: number;
ddp_color_order: number; ddp_color_order: number;
opc_channel: number;
espnow_peer_mac: string; espnow_peer_mac: string;
espnow_channel: number; espnow_channel: number;
hue_username: string; hue_username: string;
@@ -191,6 +191,8 @@
"device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting", "device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting",
"device.type.ddp": "DDP", "device.type.ddp": "DDP",
"device.type.ddp.desc": "Direct UDP pixel push (Pixelblaze, ESPixelStick, Falcon)", "device.type.ddp.desc": "Direct UDP pixel push (Pixelblaze, ESPixelStick, Falcon)",
"device.type.opc": "OPC",
"device.type.opc.desc": "Open Pixel Control (Fadecandy, xLights, hobbyist drivers)",
"device.type.mock": "Mock", "device.type.mock": "Mock",
"device.type.mock.desc": "Virtual device for testing", "device.type.mock.desc": "Virtual device for testing",
"device.type.espnow": "ESP-NOW", "device.type.espnow": "ESP-NOW",
@@ -328,6 +330,11 @@
"device.ddp.color_order.rbg.desc": "RBG byte order", "device.ddp.color_order.rbg.desc": "RBG byte order",
"device.ddp.color_order.bgr.desc": "BGR byte order", "device.ddp.color_order.bgr.desc": "BGR byte order",
"device.ddp.color_order.gbr.desc": "GBR byte order", "device.ddp.color_order.gbr.desc": "GBR byte order",
"device.opc.url": "IP Address:",
"device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.",
"device.opc.url.placeholder": "192.168.1.50",
"device.opc_channel": "Channel:",
"device.opc_channel.hint": "OPC channel (0 = broadcast to every channel on the server, 1-255 = specific output).",
"device.serial_port": "Serial Port:", "device.serial_port": "Serial Port:",
"device.serial_port.hint": "Select the COM port of the Adalight device", "device.serial_port.hint": "Select the COM port of the Adalight device",
"device.serial_port.none": "No serial ports found", "device.serial_port.none": "No serial ports found",
@@ -246,6 +246,8 @@
"device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение", "device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение",
"device.type.ddp": "DDP", "device.type.ddp": "DDP",
"device.type.ddp.desc": "Прямая отправка пикселей по UDP (Pixelblaze, ESPixelStick, Falcon)", "device.type.ddp.desc": "Прямая отправка пикселей по UDP (Pixelblaze, ESPixelStick, Falcon)",
"device.type.opc": "OPC",
"device.type.opc.desc": "Open Pixel Control (Fadecandy, xLights, любительские драйверы)",
"device.type.mock": "Mock", "device.type.mock": "Mock",
"device.type.mock.desc": "Виртуальное устройство для тестов", "device.type.mock.desc": "Виртуальное устройство для тестов",
"device.type.espnow": "ESP-NOW", "device.type.espnow": "ESP-NOW",
@@ -381,6 +383,11 @@
"device.ddp.color_order.rbg.desc": "RBG byte order", "device.ddp.color_order.rbg.desc": "RBG byte order",
"device.ddp.color_order.bgr.desc": "BGR byte order", "device.ddp.color_order.bgr.desc": "BGR byte order",
"device.ddp.color_order.gbr.desc": "GBR byte order", "device.ddp.color_order.gbr.desc": "GBR byte order",
"device.opc.url": "IP-адрес:",
"device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.",
"device.opc.url.placeholder": "192.168.1.50",
"device.opc_channel": "Канал:",
"device.opc_channel.hint": "Канал OPC (0 — широковещательная отправка на все каналы сервера, 1-255 — конкретный выход).",
"device.serial_port": "Серийный порт:", "device.serial_port": "Серийный порт:",
"device.serial_port.hint": "Выберите COM порт устройства Adalight", "device.serial_port.hint": "Выберите COM порт устройства Adalight",
"device.serial_port.none": "Серийные порты не найдены", "device.serial_port.none": "Серийные порты не найдены",
@@ -244,6 +244,8 @@
"device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光", "device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光",
"device.type.ddp": "DDP", "device.type.ddp": "DDP",
"device.type.ddp.desc": "直接UDP像素推送 (Pixelblaze、ESPixelStick、Falcon)", "device.type.ddp.desc": "直接UDP像素推送 (Pixelblaze、ESPixelStick、Falcon)",
"device.type.opc": "OPC",
"device.type.opc.desc": "Open Pixel Control (Fadecandy、xLights、爱好者驱动)",
"device.type.mock": "Mock", "device.type.mock": "Mock",
"device.type.mock.desc": "用于测试的虚拟设备", "device.type.mock.desc": "用于测试的虚拟设备",
"device.type.espnow": "ESP-NOW", "device.type.espnow": "ESP-NOW",
@@ -379,6 +381,11 @@
"device.ddp.color_order.rbg.desc": "RBG byte order", "device.ddp.color_order.rbg.desc": "RBG byte order",
"device.ddp.color_order.bgr.desc": "BGR byte order", "device.ddp.color_order.bgr.desc": "BGR byte order",
"device.ddp.color_order.gbr.desc": "GBR byte order", "device.ddp.color_order.gbr.desc": "GBR byte order",
"device.opc.url": "IP 地址:",
"device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。",
"device.opc.url.placeholder": "192.168.1.50",
"device.opc_channel": "通道:",
"device.opc_channel.hint": "OPC 通道(0 = 广播到服务器所有通道,1-255 = 特定输出)。",
"device.serial_port": "串口:", "device.serial_port": "串口:",
"device.serial_port.hint": "选择 Adalight 设备的 COM 端口", "device.serial_port.hint": "选择 Adalight 设备的 COM 端口",
"device.serial_port.none": "未找到串口", "device.serial_port.none": "未找到串口",
@@ -70,6 +70,8 @@ class Device:
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
# Govee fields # Govee fields
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
# OPC fields
opc_channel: int = 0,
# SPI Direct fields # SPI Direct fields
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
@@ -121,6 +123,7 @@ class Device:
self.wiz_min_interval_ms = wiz_min_interval_ms self.wiz_min_interval_ms = wiz_min_interval_ms
self.lifx_min_interval_ms = lifx_min_interval_ms self.lifx_min_interval_ms = lifx_min_interval_ms
self.govee_min_interval_ms = govee_min_interval_ms self.govee_min_interval_ms = govee_min_interval_ms
self.opc_channel = opc_channel
self.spi_speed_hz = spi_speed_hz self.spi_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type self.chroma_device_type = chroma_device_type
@@ -161,6 +164,7 @@ class Device:
SPIConfig, SPIConfig,
GoveeConfig, GoveeConfig,
LIFXConfig, LIFXConfig,
OPCConfig,
USBHIDConfig, USBHIDConfig,
WiZConfig, WiZConfig,
WLEDConfig, WLEDConfig,
@@ -231,6 +235,11 @@ class Device:
**base, **base,
govee_min_interval_ms=self.govee_min_interval_ms, govee_min_interval_ms=self.govee_min_interval_ms,
) )
if dt == "opc":
return OPCConfig(
**base,
opc_channel=self.opc_channel,
)
if dt == "spi": if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type) return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
if dt == "chroma": if dt == "chroma":
@@ -317,6 +326,8 @@ class Device:
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms d["lifx_min_interval_ms"] = self.lifx_min_interval_ms
if self.govee_min_interval_ms != 50: if self.govee_min_interval_ms != 50:
d["govee_min_interval_ms"] = self.govee_min_interval_ms d["govee_min_interval_ms"] = self.govee_min_interval_ms
if self.opc_channel:
d["opc_channel"] = self.opc_channel
if self.spi_speed_hz != 800000: if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B": if self.spi_led_type != "WS2812B":
@@ -376,6 +387,7 @@ class Device:
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50), wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50), lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
govee_min_interval_ms=data.get("govee_min_interval_ms", 50), govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
opc_channel=data.get("opc_channel", 0),
spi_speed_hz=data.get("spi_speed_hz", 800000), spi_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"), spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"), chroma_device_type=data.get("chroma_device_type", "chromalink"),
@@ -427,6 +439,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"wiz_min_interval_ms", "wiz_min_interval_ms",
"lifx_min_interval_ms", "lifx_min_interval_ms",
"govee_min_interval_ms", "govee_min_interval_ms",
"opc_channel",
"spi_speed_hz", "spi_speed_hz",
"spi_led_type", "spi_led_type",
"chroma_device_type", "chroma_device_type",
@@ -531,6 +544,7 @@ class DeviceStore(BaseSqliteStore[Device]):
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
opc_channel: int = 0,
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink", chroma_device_type: str = "chromalink",
@@ -578,6 +592,7 @@ class DeviceStore(BaseSqliteStore[Device]):
wiz_min_interval_ms=wiz_min_interval_ms, wiz_min_interval_ms=wiz_min_interval_ms,
lifx_min_interval_ms=lifx_min_interval_ms, lifx_min_interval_ms=lifx_min_interval_ms,
govee_min_interval_ms=govee_min_interval_ms, govee_min_interval_ms=govee_min_interval_ms,
opc_channel=opc_channel,
spi_speed_hz=spi_speed_hz, spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type, spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type, chroma_device_type=chroma_device_type,
@@ -44,6 +44,7 @@
<option value="openrgb">OpenRGB</option> <option value="openrgb">OpenRGB</option>
<option value="dmx">DMX</option> <option value="dmx">DMX</option>
<option value="ddp">DDP</option> <option value="ddp">DDP</option>
<option value="opc">OPC</option>
<option value="espnow">ESP-NOW</option> <option value="espnow">ESP-NOW</option>
<option value="hue">Philips Hue</option> <option value="hue">Philips Hue</option>
<option value="yeelight">Yeelight</option> <option value="yeelight">Yeelight</option>
@@ -219,6 +220,15 @@
<option value="5">GBR</option> <option value="5">GBR</option>
</select> </select>
</div> </div>
<!-- OPC fields -->
<div class="form-group" id="device-opc-channel-group" style="display: none;">
<div class="label-row">
<label for="device-opc-channel" data-i18n="device.opc_channel">Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.opc_channel.hint">OPC channel (0 = broadcast to every channel on the server, 1-255 = specific output).</small>
<input type="number" id="device-opc-channel" min="0" max="255" step="1" value="0">
</div>
<!-- Yeelight fields --> <!-- Yeelight fields -->
<div class="form-group" id="device-yeelight-min-interval-group" style="display: none;"> <div class="form-group" id="device-yeelight-min-interval-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -250,6 +250,15 @@
</select> </select>
</div> </div>
<div class="form-group" id="settings-opc-channel-group" style="display: none;">
<div class="label-row">
<label for="settings-opc-channel" data-i18n="device.opc_channel">Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.opc_channel.hint">OPC channel (0 = broadcast to every channel on the server, 1-255 = specific output).</small>
<input type="number" id="settings-opc-channel" min="0" max="255" step="1" value="0">
</div>
<div class="form-group" id="settings-yeelight-min-interval-group" style="display: none;"> <div class="form-group" id="settings-yeelight-min-interval-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-yeelight-min-interval" data-i18n="device.yeelight_min_interval">Min Update Interval:</label> <label for="settings-yeelight-min-interval" data-i18n="device.yeelight_min_interval">Min Update Interval:</label>
+341
View File
@@ -0,0 +1,341 @@
"""Tests for the Open Pixel Control LED client + provider."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import numpy as np
import pytest
from ledgrab.core.devices.device_config import OPCConfig
from ledgrab.core.devices.led_client import ProviderDeps
from ledgrab.core.devices.opc_client import (
OPC_CMD_SET_PIXELS,
OPC_PORT,
OPCClient,
_build_set_pixels_header,
parse_opc_url,
)
from ledgrab.core.devices.opc_provider import OPCDeviceProvider
# ============================================================================
# URL parsing
# ============================================================================
@pytest.mark.parametrize(
"url,expected",
[
("opc://192.168.1.50", ("192.168.1.50", OPC_PORT)),
("opc://192.168.1.50:7890", ("192.168.1.50", 7890)),
("opc://192.168.1.50:9000", ("192.168.1.50", 9000)),
("192.168.1.50", ("192.168.1.50", OPC_PORT)),
("192.168.1.50:7890", ("192.168.1.50", 7890)),
("fadecandy.local", ("fadecandy.local", OPC_PORT)),
],
)
def test_parse_opc_url(url, expected):
assert parse_opc_url(url) == expected
@pytest.mark.parametrize("url", ["", " ", "opc://", "://192.168.1.1"])
def test_parse_opc_url_rejects_empty(url):
with pytest.raises(ValueError):
parse_opc_url(url)
# ============================================================================
# Header construction
# ============================================================================
def test_build_set_pixels_header_layout():
"""4-byte header: [channel, command, length_hi, length_lo]"""
header = _build_set_pixels_header(channel=1, body_len=300)
assert len(header) == 4
assert header[0] == 1 # channel
assert header[1] == OPC_CMD_SET_PIXELS # command
assert header[2] == 1 # length_hi (300 >> 8)
assert header[3] == 44 # length_lo (300 & 0xFF)
def test_build_set_pixels_header_broadcast_channel():
header = _build_set_pixels_header(channel=0, body_len=3)
assert header[0] == 0
def test_build_set_pixels_header_clamps_channel_to_byte():
header = _build_set_pixels_header(channel=300, body_len=3)
assert header[0] == 300 & 0xFF # 44
def test_build_set_pixels_header_encodes_large_body_length():
header = _build_set_pixels_header(channel=0, body_len=0xFFFF)
assert header[2] == 0xFF
assert header[3] == 0xFF
# ============================================================================
# OPCClient (mocked transport)
# ============================================================================
def _make_connected_client(channel: int = 0) -> OPCClient:
client = OPCClient("opc://127.0.0.1", led_count=10, channel=channel)
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.close = MagicMock()
writer.wait_closed = AsyncMock()
client._writer = writer
client._reader = MagicMock()
client._connected = True
return client
def _sent_bytes(client: OPCClient) -> bytes:
"""Concatenate every write made to the mock writer into one byte string."""
return b"".join(call.args[0] for call in client._writer.write.call_args_list)
@pytest.mark.asyncio
async def test_send_pixels_emits_header_then_body():
client = _make_connected_client(channel=1)
pixels = np.array([[10, 20, 30], [40, 50, 60]], dtype=np.uint8)
await client.send_pixels(pixels)
data = _sent_bytes(client)
assert len(data) == 4 + 6 # header + 2 pixels * 3 bytes
assert data[0] == 1 # channel
assert data[1] == OPC_CMD_SET_PIXELS
assert data[2:4] == bytes([0, 6]) # body length 6
assert data[4:] == bytes([10, 20, 30, 40, 50, 60])
@pytest.mark.asyncio
async def test_send_pixels_applies_brightness():
client = _make_connected_client()
pixels = np.array([[200, 100, 50]], dtype=np.uint8)
await client.send_pixels(pixels, brightness=128)
data = _sent_bytes(client)
body = data[4:]
# Each channel scales as int(x * 128 / 255)
expected = bytes([int(200 * 128 / 255), int(100 * 128 / 255), int(50 * 128 / 255)])
assert body == expected
@pytest.mark.asyncio
async def test_send_pixels_brightness_zero_blacks_out():
client = _make_connected_client()
pixels = np.array([[200, 100, 50], [255, 255, 255]], dtype=np.uint8)
await client.send_pixels(pixels, brightness=0)
body = _sent_bytes(client)[4:]
assert body == bytes(6) # all zeros
@pytest.mark.asyncio
async def test_send_pixels_accepts_list():
client = _make_connected_client()
await client.send_pixels([(1, 2, 3), (4, 5, 6)])
body = _sent_bytes(client)[4:]
assert body == bytes([1, 2, 3, 4, 5, 6])
@pytest.mark.asyncio
async def test_send_pixels_reshapes_flat_array():
client = _make_connected_client()
flat = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint8)
await client.send_pixels(flat)
body = _sent_bytes(client)[4:]
assert body == bytes([1, 2, 3, 4, 5, 6])
@pytest.mark.asyncio
async def test_send_pixels_drains_after_write():
client = _make_connected_client()
await client.send_pixels(np.array([[1, 2, 3]], dtype=np.uint8))
client._writer.drain.assert_awaited()
@pytest.mark.asyncio
async def test_send_pixels_when_not_connected_raises():
client = OPCClient("opc://127.0.0.1", led_count=1)
with pytest.raises(RuntimeError, match="not connected"):
await client.send_pixels([(1, 2, 3)])
def test_send_pixels_fast_writes_synchronously_without_drain():
"""Hot path skips drain so OS write-buffer flushes on its own schedule."""
client = _make_connected_client()
client.send_pixels_fast(np.array([[1, 2, 3]], dtype=np.uint8))
data = _sent_bytes(client)
assert data == bytes([0, OPC_CMD_SET_PIXELS, 0, 3, 1, 2, 3])
client._writer.drain.assert_not_called()
def test_send_pixels_fast_when_not_connected_raises():
client = OPCClient("opc://127.0.0.1", led_count=1)
with pytest.raises(RuntimeError, match="not connected"):
client.send_pixels_fast([(1, 2, 3)])
def test_supports_fast_send_is_true():
assert OPCClient("opc://127.0.0.1", led_count=1).supports_fast_send is True
def test_channel_clamps_to_byte():
client = OPCClient("opc://127.0.0.1", led_count=1, channel=300)
assert client.channel == 300 & 0xFF
def test_default_channel_is_broadcast():
client = OPCClient("opc://127.0.0.1", led_count=1)
assert client.channel == 0
@pytest.mark.asyncio
async def test_close_releases_transport():
client = _make_connected_client()
writer = client._writer
await client.close()
writer.close.assert_called_once()
assert client._writer is None
assert client.is_connected is False
# ============================================================================
# Provider
# ============================================================================
def test_provider_device_type_and_capabilities():
provider = OPCDeviceProvider()
assert provider.device_type == "opc"
caps = provider.capabilities
assert "manual_led_count" in caps
assert "health_check" in caps
# OPC has no reply channel; no native power/brightness control
assert "power_control" not in caps
assert "brightness_control" not in caps
@pytest.mark.asyncio
async def test_provider_validate_accepts_bare_host():
provider = OPCDeviceProvider()
assert await provider.validate_device("192.168.1.50") == {}
@pytest.mark.asyncio
async def test_provider_validate_rejects_empty():
provider = OPCDeviceProvider()
with pytest.raises(ValueError, match="Invalid OPC URL"):
await provider.validate_device("")
@pytest.mark.asyncio
async def test_provider_discover_returns_empty():
"""OPC has no native discovery — provider must return [], not raise."""
provider = OPCDeviceProvider()
assert await provider.discover() == []
def test_provider_create_client_threads_config():
provider = OPCDeviceProvider()
config = OPCConfig(
device_id="device_test",
device_url="opc://192.168.1.50:9000",
led_count=144,
opc_channel=3,
)
client = provider.create_client(config, deps=ProviderDeps())
assert isinstance(client, OPCClient)
assert client.host == "192.168.1.50"
assert client.port == 9000
assert client._led_count == 144
assert client.channel == 3
# ============================================================================
# Device.to_config() round-trip
# ============================================================================
def test_device_to_config_round_trip_opc():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Fadecandy 1",
url="opc://192.168.1.42",
led_count=512,
device_type="opc",
opc_channel=2,
)
config = device.to_config()
assert isinstance(config, OPCConfig)
assert config.device_url == "opc://192.168.1.42"
assert config.led_count == 512
assert config.opc_channel == 2
def test_device_to_dict_omits_opc_default_channel():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Default",
url="opc://192.168.1.42",
led_count=1,
device_type="opc",
)
assert "opc_channel" not in device.to_dict()
def test_device_to_dict_preserves_non_default_opc_channel():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Channel 3",
url="opc://192.168.1.42",
led_count=1,
device_type="opc",
opc_channel=3,
)
assert device.to_dict()["opc_channel"] == 3
def test_device_from_dict_opc_round_trip():
from ledgrab.storage.device_store import Device
restored = Device.from_dict(
{
"id": "device_abc12345",
"name": "Roundtrip",
"url": "opc://10.0.0.1",
"led_count": 64,
"device_type": "opc",
"opc_channel": 7,
}
)
assert restored.opc_channel == 7