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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user