diff --git a/TODO.md b/TODO.md index cdbba61..48e245a 100644 --- a/TODO.md +++ b/TODO.md @@ -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) -- [ ] 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 ### Phase 6 — PC gaming RGB completion diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 1b48a31..029bf9d 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -70,6 +70,7 @@ def _device_to_response(device) -> DeviceResponse: wiz_min_interval_ms=device.wiz_min_interval_ms, lifx_min_interval_ms=device.lifx_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_led_type=device.spi_led_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 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_led_type=device_data.spi_led_type or "WS2812B", 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, lifx_min_interval_ms=update_data.lifx_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_led_type=update_data.spi_led_type, chroma_device_type=update_data.chroma_device_type, diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index a4ce81a..37dab0e 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -91,6 +91,13 @@ class DeviceCreate(BaseModel): le=10000, 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_speed_hz: Optional[int] = Field( 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( 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_led_type: Optional[str] = Field(None, description="LED chipset 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") 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") + 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_led_type: str = Field(default="WS2812B", description="LED chipset type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index 1dd555e..e6af135 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -124,6 +124,18 @@ class GoveeConfig(BaseDeviceConfig): 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) class SPIConfig(BaseDeviceConfig): device_type: Literal["spi"] = "spi" @@ -198,6 +210,7 @@ DeviceConfig = Union[ WiZConfig, LIFXConfig, GoveeConfig, + OPCConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index 33f279f..2a0cfdf 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -354,6 +354,10 @@ def _register_builtin_providers(): 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`` # 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 diff --git a/server/src/ledgrab/core/devices/opc_client.py b/server/src/ledgrab/core/devices/opc_client.py new file mode 100644 index 0000000..bf75242 --- /dev/null +++ b/server/src/ledgrab/core/devices/opc_client.py @@ -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://[:port]`` or bare ``[: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) diff --git a/server/src/ledgrab/core/devices/opc_provider.py b/server/src/ledgrab/core/devices/opc_provider.py new file mode 100644 index 0000000..55b8305 --- /dev/null +++ b/server/src/ledgrab/core/devices/opc_provider.py @@ -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 [] diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 9d8ceec..99a1f29 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -151,6 +151,10 @@ export function isDdpDevice(type: string) { return type === 'ddp'; } +export function isOpcDevice(type: string) { + return type === 'opc'; +} + export function isEspnowDevice(type: string) { return type === 'espnow'; } diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index 233a9bd..96985d8 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -47,7 +47,7 @@ const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.sl const _deviceTypeIcons = { wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb), 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), usbhid: _svg(P.usb), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index f5c63c6..546de28 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -7,7 +7,7 @@ import { _discoveryCache, set_discoveryCache, csptCache, } 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 { t } from '../core/i18n.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', ddpDestinationId: (document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.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 || '', bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '', yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500', @@ -55,7 +56,7 @@ const addDeviceModal = new AddDeviceModal(); /* ── 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() { return DEVICE_TYPE_KEYS.map(key => ({ @@ -291,6 +292,7 @@ export function onDeviceTypeChanged() { _showChromaFields(false); _showGameSenseFields(false); _showGroupFields(false); + _showOpcFields(false); if (isMqttDevice(deviceType)) { // 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 (urlHint) urlHint.textContent = t('device.ddp.url.hint'); 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)) { urlGroup.style.display = ''; 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)); } } + // 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 if (isYeelightDevice(presetType)) { 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_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)) { 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); @@ -1500,6 +1531,11 @@ function _showYeelightFields(show: boolean) { 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) { const el = document.getElementById('device-wiz-min-interval-group') as HTMLElement | null; if (el) el.style.display = show ? '' : 'none'; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 1f5a74d..49ae3ae 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -6,7 +6,7 @@ import { _deviceBrightnessCache, updateDeviceBrightness, csptCache, } 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 { _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'; @@ -93,6 +93,7 @@ class DeviceSettingsModal extends Modal { dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet', dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 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 || '', bleGoveeKey: (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value || '', 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'; } + // 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 // 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 @@ -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_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)) { const raw = (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value; const parsed = parseInt(raw || '500', 10); diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index 0cc4c67..444c62c 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -47,7 +47,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string { export type DeviceType = | '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' | 'chroma' | 'gamesense' | 'group'; @@ -71,6 +71,7 @@ export interface Device { ddp_port: number; ddp_destination_id: number; ddp_color_order: number; + opc_channel: number; espnow_peer_mac: string; espnow_channel: number; hue_username: string; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index f503522..86007f6 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -191,6 +191,8 @@ "device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting", "device.type.ddp": "DDP", "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.desc": "Virtual device for testing", "device.type.espnow": "ESP-NOW", @@ -328,6 +330,11 @@ "device.ddp.color_order.rbg.desc": "RBG byte order", "device.ddp.color_order.bgr.desc": "BGR 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.hint": "Select the COM port of the Adalight device", "device.serial_port.none": "No serial ports found", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index d4cf07a..1999429 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -246,6 +246,8 @@ "device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение", "device.type.ddp": "DDP", "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.desc": "Виртуальное устройство для тестов", "device.type.espnow": "ESP-NOW", @@ -381,6 +383,11 @@ "device.ddp.color_order.rbg.desc": "RBG byte order", "device.ddp.color_order.bgr.desc": "BGR 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.hint": "Выберите COM порт устройства Adalight", "device.serial_port.none": "Серийные порты не найдены", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 4f094b5..cd07b8e 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -244,6 +244,8 @@ "device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光", "device.type.ddp": "DDP", "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.desc": "用于测试的虚拟设备", "device.type.espnow": "ESP-NOW", @@ -379,6 +381,11 @@ "device.ddp.color_order.rbg.desc": "RBG byte order", "device.ddp.color_order.bgr.desc": "BGR 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.hint": "选择 Adalight 设备的 COM 端口", "device.serial_port.none": "未找到串口", diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 5c9fa1c..4b31268 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -70,6 +70,8 @@ class Device: lifx_min_interval_ms: int = 50, # Govee fields govee_min_interval_ms: int = 50, + # OPC fields + opc_channel: int = 0, # SPI Direct fields spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", @@ -121,6 +123,7 @@ class Device: self.wiz_min_interval_ms = wiz_min_interval_ms self.lifx_min_interval_ms = lifx_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_led_type = spi_led_type self.chroma_device_type = chroma_device_type @@ -161,6 +164,7 @@ class Device: SPIConfig, GoveeConfig, LIFXConfig, + OPCConfig, USBHIDConfig, WiZConfig, WLEDConfig, @@ -231,6 +235,11 @@ class Device: **base, govee_min_interval_ms=self.govee_min_interval_ms, ) + if dt == "opc": + return OPCConfig( + **base, + opc_channel=self.opc_channel, + ) if dt == "spi": return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type) if dt == "chroma": @@ -317,6 +326,8 @@ class Device: d["lifx_min_interval_ms"] = self.lifx_min_interval_ms if self.govee_min_interval_ms != 50: 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: d["spi_speed_hz"] = self.spi_speed_hz if self.spi_led_type != "WS2812B": @@ -376,6 +387,7 @@ class Device: wiz_min_interval_ms=data.get("wiz_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), + opc_channel=data.get("opc_channel", 0), spi_speed_hz=data.get("spi_speed_hz", 800000), spi_led_type=data.get("spi_led_type", "WS2812B"), chroma_device_type=data.get("chroma_device_type", "chromalink"), @@ -427,6 +439,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "wiz_min_interval_ms", "lifx_min_interval_ms", "govee_min_interval_ms", + "opc_channel", "spi_speed_hz", "spi_led_type", "chroma_device_type", @@ -531,6 +544,7 @@ class DeviceStore(BaseSqliteStore[Device]): wiz_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50, govee_min_interval_ms: int = 50, + opc_channel: int = 0, spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", chroma_device_type: str = "chromalink", @@ -578,6 +592,7 @@ class DeviceStore(BaseSqliteStore[Device]): 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=opc_channel, spi_speed_hz=spi_speed_hz, spi_led_type=spi_led_type, chroma_device_type=chroma_device_type, diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index 14998a2..b7ce95d 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -44,6 +44,7 @@ + @@ -219,6 +220,15 @@ + +