Add Art-Net / sACN (E1.31) DMX device support
Full-stack implementation of DMX output for stage lighting and LED controllers: - DMXClient with Art-Net and sACN packet builders, multi-universe splitting - DMXDeviceProvider with manual_led_count capability and URL parsing - Device store, API schemas, routes wired with dmx_protocol/start_universe/start_channel - Frontend: add/settings modals with DMX fields, IconSelect protocol picker - Fix add device modal dirty check on type change (re-snapshot after switch) - i18n keys for DMX in en/ru/zh locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,9 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
zone_mode=device.zone_mode,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
tags=getattr(device, 'tags', []),
|
||||
dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'),
|
||||
dmx_start_universe=getattr(device, 'dmx_start_universe', 0),
|
||||
dmx_start_channel=getattr(device, 'dmx_start_channel', 1),
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -129,6 +132,9 @@ async def create_device(
|
||||
rgbw=device_data.rgbw or False,
|
||||
zone_mode=device_data.zone_mode or "combined",
|
||||
tags=device_data.tags,
|
||||
dmx_protocol=device_data.dmx_protocol or "artnet",
|
||||
dmx_start_universe=device_data.dmx_start_universe or 0,
|
||||
dmx_start_channel=device_data.dmx_start_channel or 1,
|
||||
)
|
||||
|
||||
# WS devices: auto-set URL to ws://{device_id}
|
||||
@@ -313,6 +319,9 @@ async def update_device(
|
||||
rgbw=update_data.rgbw,
|
||||
zone_mode=update_data.zone_mode,
|
||||
tags=update_data.tags,
|
||||
dmx_protocol=update_data.dmx_protocol,
|
||||
dmx_start_universe=update_data.dmx_start_universe,
|
||||
dmx_start_channel=update_data.dmx_start_channel,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
|
||||
@@ -19,6 +19,10 @@ class DeviceCreate(BaseModel):
|
||||
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
# DMX (Art-Net / sACN) fields
|
||||
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
|
||||
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -34,6 +38,9 @@ class DeviceUpdate(BaseModel):
|
||||
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
tags: Optional[List[str]] = None
|
||||
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
|
||||
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -128,6 +135,9 @@ class DeviceResponse(BaseModel):
|
||||
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: int = Field(default=0, description="DMX start universe")
|
||||
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
245
server/src/wled_controller/core/devices/dmx_client.py
Normal file
245
server/src/wled_controller/core/devices/dmx_client.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Art-Net / sACN (E1.31) DMX client for stage lighting and LED controllers."""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import uuid
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import LEDClient, DeviceHealth
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Art-Net constants
|
||||
ARTNET_PORT = 6454
|
||||
ARTNET_HEADER = b"Art-Net\x00"
|
||||
ARTNET_OPCODE_DMX = 0x5000
|
||||
ARTNET_PROTOCOL_VERSION = 14
|
||||
|
||||
# sACN / E1.31 constants
|
||||
SACN_PORT = 5568
|
||||
ACN_PACKET_IDENTIFIER = b"\x00\x10\x00\x00\x41\x53\x43\x2d\x45\x31\x2e\x31\x37\x00\x00\x00"
|
||||
SACN_VECTOR_ROOT = 0x00000004
|
||||
SACN_VECTOR_FRAMING = 0x00000002
|
||||
SACN_VECTOR_DMP = 0x02
|
||||
|
||||
# DMX512 limits
|
||||
DMX_CHANNELS_PER_UNIVERSE = 512
|
||||
DMX_PIXELS_PER_UNIVERSE = 170 # floor(512 / 3)
|
||||
|
||||
|
||||
class DMXClient(LEDClient):
|
||||
"""UDP client for Art-Net and sACN (E1.31) DMX protocols.
|
||||
|
||||
Supports sending RGB pixel data across multiple DMX universes.
|
||||
Both protocols are UDP fire-and-forget, similar to DDP.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: Optional[int] = None,
|
||||
led_count: int = 1,
|
||||
protocol: str = "artnet",
|
||||
start_universe: int = 0,
|
||||
start_channel: int = 1,
|
||||
**kwargs,
|
||||
):
|
||||
self.host = host
|
||||
self.protocol = protocol.lower()
|
||||
self.port = port or (ARTNET_PORT if self.protocol == "artnet" else SACN_PORT)
|
||||
self.led_count = led_count
|
||||
self.start_universe = start_universe
|
||||
self.start_channel = max(1, min(512, start_channel)) # clamp 1-512
|
||||
self._transport = None
|
||||
self._protocol_obj = None
|
||||
self._sequence = 0
|
||||
# sACN requires a stable 16-byte CID (Component Identifier)
|
||||
self._sacn_cid = uuid.uuid4().bytes
|
||||
self._sacn_source_name = b"WLED Controller\x00" + b"\x00" * 48 # 64 bytes padded
|
||||
|
||||
# Pre-compute universe mapping
|
||||
self._universe_map = self._compute_universe_map()
|
||||
|
||||
def _compute_universe_map(self) -> List[Tuple[int, int, int]]:
|
||||
"""Pre-compute which channels go to which universe.
|
||||
|
||||
Returns list of (universe, channel_offset, num_channels) tuples.
|
||||
channel_offset is 0-based index into the flat RGB byte array.
|
||||
"""
|
||||
total_channels = self.led_count * 3
|
||||
start_ch_0 = self.start_channel - 1 # convert to 0-based
|
||||
mapping = []
|
||||
byte_offset = 0
|
||||
|
||||
universe = self.start_universe
|
||||
ch_in_universe = start_ch_0
|
||||
|
||||
while byte_offset < total_channels:
|
||||
available = DMX_CHANNELS_PER_UNIVERSE - ch_in_universe
|
||||
needed = total_channels - byte_offset
|
||||
count = min(available, needed)
|
||||
mapping.append((universe, ch_in_universe, count, byte_offset))
|
||||
byte_offset += count
|
||||
universe += 1
|
||||
ch_in_universe = 0 # subsequent universes start at channel 0
|
||||
|
||||
return mapping
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._transport is not None
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
async def connect(self) -> bool:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._transport, self._protocol_obj = await loop.create_datagram_endpoint(
|
||||
asyncio.DatagramProtocol,
|
||||
remote_addr=(self.host, self.port),
|
||||
)
|
||||
num_universes = len(self._universe_map)
|
||||
logger.info(
|
||||
f"DMX/{self.protocol} client connected to {self.host}:{self.port} "
|
||||
f"({self.led_count} LEDs across {num_universes} universe(s), "
|
||||
f"starting at universe {self.start_universe} channel {self.start_channel})"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect DMX client: {e}")
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._protocol_obj = None
|
||||
logger.debug(f"Closed DMX/{self.protocol} connection to {self.host}:{self.port}")
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self._transport:
|
||||
raise RuntimeError("DMX client not connected")
|
||||
self.send_pixels_fast(pixels, brightness)
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
if not self._transport:
|
||||
raise RuntimeError("DMX client not connected")
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_bytes = pixels.tobytes()
|
||||
else:
|
||||
pixel_bytes = np.array(pixels, dtype=np.uint8).tobytes()
|
||||
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
|
||||
for universe, ch_offset, num_channels, byte_offset in self._universe_map:
|
||||
# Build a full 512-channel DMX frame (zero-padded)
|
||||
dmx_data = bytearray(DMX_CHANNELS_PER_UNIVERSE)
|
||||
chunk = pixel_bytes[byte_offset:byte_offset + num_channels]
|
||||
dmx_data[ch_offset:ch_offset + len(chunk)] = chunk
|
||||
|
||||
if self.protocol == "sacn":
|
||||
packet = self._build_sacn_packet(universe, bytes(dmx_data), self._sequence)
|
||||
else:
|
||||
packet = self._build_artnet_packet(universe, bytes(dmx_data), self._sequence)
|
||||
|
||||
self._transport.sendto(packet)
|
||||
|
||||
def _build_artnet_packet(self, universe: int, dmx_data: bytes, sequence: int) -> bytes:
|
||||
"""Build an Art-Net DMX (OpDmx / 0x5000) packet.
|
||||
|
||||
Art-Net packet structure:
|
||||
- 8 bytes: "Art-Net\\0" header
|
||||
- 2 bytes: OpCode (0x5000 little-endian)
|
||||
- 2 bytes: Protocol version (14, big-endian)
|
||||
- 1 byte: Sequence number
|
||||
- 1 byte: Physical port (0)
|
||||
- 2 bytes: Universe (little-endian, 15-bit: subnet+universe)
|
||||
- 2 bytes: Data length (big-endian, must be even, 2-512)
|
||||
- N bytes: DMX channel data
|
||||
"""
|
||||
data_len = len(dmx_data)
|
||||
# Art-Net requires even data length
|
||||
if data_len % 2 != 0:
|
||||
dmx_data = dmx_data + b"\x00"
|
||||
data_len += 1
|
||||
|
||||
packet = bytearray()
|
||||
packet.extend(ARTNET_HEADER) # 8 bytes: "Art-Net\0"
|
||||
packet.extend(struct.pack("<H", ARTNET_OPCODE_DMX)) # 2 bytes: OpCode LE
|
||||
packet.extend(struct.pack(">H", ARTNET_PROTOCOL_VERSION)) # 2 bytes: version BE
|
||||
packet.append(sequence & 0xFF) # 1 byte: sequence
|
||||
packet.append(0) # 1 byte: physical
|
||||
packet.extend(struct.pack("<H", universe & 0x7FFF)) # 2 bytes: universe LE
|
||||
packet.extend(struct.pack(">H", data_len)) # 2 bytes: length BE
|
||||
packet.extend(dmx_data) # N bytes: data
|
||||
return bytes(packet)
|
||||
|
||||
def _build_sacn_packet(
|
||||
self, universe: int, dmx_data: bytes, sequence: int, priority: int = 100,
|
||||
) -> bytes:
|
||||
"""Build an sACN / E1.31 data packet.
|
||||
|
||||
Structure:
|
||||
- Root layer (38 bytes)
|
||||
- Framing layer (77 bytes)
|
||||
- DMP layer (10 bytes + 1 start code + DMX data)
|
||||
"""
|
||||
slot_count = len(dmx_data) + 1 # +1 for DMX start code (0x00)
|
||||
dmp_len = 10 + slot_count # DMP layer
|
||||
framing_len = 77 + dmp_len # framing layer
|
||||
root_len = 22 + framing_len # root layer (after preamble)
|
||||
|
||||
packet = bytearray()
|
||||
|
||||
# ── Root Layer ──
|
||||
packet.extend(struct.pack(">H", 0x0010)) # preamble size
|
||||
packet.extend(struct.pack(">H", 0x0000)) # post-amble size
|
||||
packet.extend(ACN_PACKET_IDENTIFIER) # 12 bytes ACN packet ID
|
||||
# Flags + length (high 4 bits = 0x7, low 12 = root_len)
|
||||
packet.extend(struct.pack(">H", 0x7000 | (root_len & 0x0FFF)))
|
||||
packet.extend(struct.pack(">I", SACN_VECTOR_ROOT)) # vector
|
||||
packet.extend(self._sacn_cid) # 16 bytes CID
|
||||
|
||||
# ── Framing Layer ──
|
||||
packet.extend(struct.pack(">H", 0x7000 | (framing_len & 0x0FFF)))
|
||||
packet.extend(struct.pack(">I", SACN_VECTOR_FRAMING)) # vector
|
||||
packet.extend(self._sacn_source_name[:64]) # 64 bytes source name
|
||||
packet.append(priority & 0xFF) # priority
|
||||
packet.extend(struct.pack(">H", 0)) # sync address (0 = none)
|
||||
packet.append(sequence & 0xFF) # sequence
|
||||
packet.append(0) # options (0 = normal)
|
||||
packet.extend(struct.pack(">H", universe)) # universe
|
||||
|
||||
# ── DMP Layer ──
|
||||
packet.extend(struct.pack(">H", 0x7000 | (dmp_len & 0x0FFF)))
|
||||
packet.append(SACN_VECTOR_DMP) # vector
|
||||
packet.append(0xA1) # address type & data type
|
||||
packet.extend(struct.pack(">H", 0)) # first property address
|
||||
packet.extend(struct.pack(">H", 1)) # address increment
|
||||
packet.extend(struct.pack(">H", slot_count)) # property value count
|
||||
packet.append(0x00) # DMX start code
|
||||
packet.extend(dmx_data) # DMX channel data
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
82
server/src/wled_controller/core/devices/dmx_provider.py
Normal file
82
server/src/wled_controller/core/devices/dmx_provider.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""DMX device provider — Art-Net / sACN (E1.31) factory, validation, health."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.dmx_client import DMXClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def parse_dmx_url(url: str) -> dict:
|
||||
"""Parse a DMX URL like 'artnet://192.168.1.50' or 'sacn://192.168.1.50'.
|
||||
|
||||
Returns dict with 'host', 'port', 'protocol'.
|
||||
Also accepts plain IP addresses (defaults to artnet).
|
||||
"""
|
||||
url = url.strip()
|
||||
if "://" not in url:
|
||||
url = f"artnet://{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
protocol = parsed.scheme.lower()
|
||||
if protocol not in ("artnet", "sacn"):
|
||||
protocol = "artnet"
|
||||
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port # None = use protocol default
|
||||
|
||||
return {"host": host, "port": port, "protocol": protocol}
|
||||
|
||||
|
||||
class DMXDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Art-Net and sACN (E1.31) DMX devices."""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "dmx"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
parsed = parse_dmx_url(url)
|
||||
return DMXClient(
|
||||
host=parsed["host"],
|
||||
port=parsed["port"],
|
||||
led_count=kwargs.get("led_count", 1),
|
||||
protocol=kwargs.get("dmx_protocol", parsed["protocol"]),
|
||||
start_universe=kwargs.get("dmx_start_universe", 0),
|
||||
start_channel=kwargs.get("dmx_start_channel", 1),
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
) -> DeviceHealth:
|
||||
# DMX is UDP fire-and-forget — no reliable health probe.
|
||||
# Report as always online (same pattern as WS/Mock providers).
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=0.0,
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate DMX device URL."""
|
||||
parsed = parse_dmx_url(url)
|
||||
if not parsed["host"]:
|
||||
raise ValueError("DMX device requires a valid IP address or hostname")
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
# No auto-discovery for DMX devices
|
||||
return []
|
||||
@@ -296,5 +296,8 @@ def _register_builtin_providers():
|
||||
from wled_controller.core.devices.openrgb_provider import OpenRGBDeviceProvider
|
||||
register_provider(OpenRGBDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.dmx_provider import DMXDeviceProvider
|
||||
register_provider(DMXDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
|
||||
@@ -169,14 +169,20 @@ class ProcessorManager:
|
||||
ds = self._devices.get(device_id)
|
||||
if ds is None:
|
||||
return None
|
||||
# Read mock-specific fields from persistent storage
|
||||
# Read device-specific fields from persistent storage
|
||||
send_latency_ms = 0
|
||||
rgbw = False
|
||||
dmx_protocol = "artnet"
|
||||
dmx_start_universe = 0
|
||||
dmx_start_channel = 1
|
||||
if self._device_store:
|
||||
dev = self._device_store.get_device(ds.device_id)
|
||||
if dev:
|
||||
send_latency_ms = getattr(dev, "send_latency_ms", 0)
|
||||
rgbw = getattr(dev, "rgbw", False)
|
||||
dmx_protocol = getattr(dev, "dmx_protocol", "artnet")
|
||||
dmx_start_universe = getattr(dev, "dmx_start_universe", 0)
|
||||
dmx_start_channel = getattr(dev, "dmx_start_channel", 1)
|
||||
|
||||
return DeviceInfo(
|
||||
device_id=ds.device_id,
|
||||
@@ -190,6 +196,9 @@ class ProcessorManager:
|
||||
rgbw=rgbw,
|
||||
zone_mode=ds.zone_mode,
|
||||
auto_shutdown=ds.auto_shutdown,
|
||||
dmx_protocol=dmx_protocol,
|
||||
dmx_start_universe=dmx_start_universe,
|
||||
dmx_start_channel=dmx_start_channel,
|
||||
)
|
||||
|
||||
# ===== EVENT SYSTEM (state change notifications) =====
|
||||
|
||||
@@ -76,6 +76,10 @@ class DeviceInfo:
|
||||
rgbw: bool = False
|
||||
zone_mode: str = "combined"
|
||||
auto_shutdown: bool = False
|
||||
# DMX (Art-Net / sACN) fields
|
||||
dmx_protocol: str = "artnet"
|
||||
dmx_start_universe: int = 0
|
||||
dmx_start_channel: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -109,6 +109,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
send_latency_ms=device_info.send_latency_ms,
|
||||
rgbw=device_info.rgbw,
|
||||
zone_mode=device_info.zone_mode,
|
||||
dmx_protocol=device_info.dmx_protocol,
|
||||
dmx_start_universe=device_info.dmx_start_universe,
|
||||
dmx_start_channel=device_info.dmx_start_channel,
|
||||
)
|
||||
await self._led_client.connect()
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ export function isOpenrgbDevice(type) {
|
||||
return type === 'openrgb';
|
||||
}
|
||||
|
||||
export function isDmxDevice(type) {
|
||||
return type === 'dmx';
|
||||
}
|
||||
|
||||
export function handle401Error() {
|
||||
if (!apiKey) return; // Already handled or no session
|
||||
localStorage.removeItem('wled_api_key');
|
||||
|
||||
@@ -36,7 +36,7 @@ const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume
|
||||
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),
|
||||
mock: _svg(P.wrench),
|
||||
dmx: _svg(P.radio), mock: _svg(P.wrench),
|
||||
};
|
||||
const _engineTypeIcons = {
|
||||
mss: _svg(P.monitor), dxcam: _svg(P.zap), bettercam: _svg(P.rocket),
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, escapeHtml } from '../core/api.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, escapeHtml } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { _computeMaxFps, _renderFpsHint } from './devices.js';
|
||||
import { getDeviceTypeIcon } from '../core/icons.js';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE } from '../core/icons.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
|
||||
class AddDeviceModal extends Modal {
|
||||
@@ -30,6 +30,9 @@ class AddDeviceModal extends Modal {
|
||||
sendLatency: document.getElementById('device-send-latency')?.value || '0',
|
||||
zones: JSON.stringify(_getCheckedZones('device-zone-list')),
|
||||
zoneMode: _getZoneMode(),
|
||||
dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet',
|
||||
dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0',
|
||||
dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,7 +41,7 @@ const addDeviceModal = new AddDeviceModal();
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'mock'];
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'mock'];
|
||||
|
||||
function _buildDeviceTypeItems() {
|
||||
return DEVICE_TYPE_KEYS.map(key => ({
|
||||
@@ -58,6 +61,38 @@ function _ensureDeviceTypeIconSelect() {
|
||||
_deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 });
|
||||
}
|
||||
|
||||
/* ── Icon-grid DMX protocol selector ─────────────────────────── */
|
||||
|
||||
function _buildDmxProtocolItems() {
|
||||
return [
|
||||
{ value: 'artnet', icon: ICON_RADIO, label: 'Art-Net', desc: t('device.dmx_protocol.artnet.desc') },
|
||||
{ value: 'sacn', icon: ICON_GLOBE, label: 'sACN (E1.31)', desc: t('device.dmx_protocol.sacn.desc') },
|
||||
];
|
||||
}
|
||||
|
||||
const _dmxProtocolIconSelects = {};
|
||||
|
||||
export function ensureDmxProtocolIconSelect(selectId) {
|
||||
const sel = document.getElementById(selectId);
|
||||
if (!sel) return;
|
||||
if (_dmxProtocolIconSelects[selectId]) {
|
||||
_dmxProtocolIconSelects[selectId].updateItems(_buildDmxProtocolItems());
|
||||
return;
|
||||
}
|
||||
_dmxProtocolIconSelects[selectId] = new IconSelect({
|
||||
target: sel,
|
||||
items: _buildDmxProtocolItems(),
|
||||
columns: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyDmxProtocolIconSelect(selectId) {
|
||||
if (_dmxProtocolIconSelects[selectId]) {
|
||||
_dmxProtocolIconSelects[selectId].destroy();
|
||||
delete _dmxProtocolIconSelects[selectId];
|
||||
}
|
||||
}
|
||||
|
||||
export function onDeviceTypeChanged() {
|
||||
const deviceType = document.getElementById('device-type').value;
|
||||
if (_deviceTypeIconSelect) _deviceTypeIconSelect.setValue(deviceType);
|
||||
@@ -77,12 +112,20 @@ export function onDeviceTypeChanged() {
|
||||
|
||||
const zoneGroup = document.getElementById('device-zone-group');
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group');
|
||||
const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group');
|
||||
const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group');
|
||||
|
||||
// Hide zone group + mode group by default (shown only for openrgb)
|
||||
if (zoneGroup) zoneGroup.style.display = 'none';
|
||||
const zoneModeGroup = document.getElementById('device-zone-mode-group');
|
||||
if (zoneModeGroup) zoneModeGroup.style.display = 'none';
|
||||
|
||||
// Hide DMX fields by default
|
||||
if (dmxProtocolGroup) dmxProtocolGroup.style.display = 'none';
|
||||
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = 'none';
|
||||
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = 'none';
|
||||
|
||||
if (isMqttDevice(deviceType)) {
|
||||
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
|
||||
urlGroup.style.display = '';
|
||||
@@ -145,6 +188,27 @@ export function onDeviceTypeChanged() {
|
||||
serialSelect.appendChild(opt);
|
||||
}
|
||||
updateBaudFpsHint();
|
||||
} else if (isDmxDevice(deviceType)) {
|
||||
// DMX: show URL (IP address), LED count, DMX-specific fields; hide serial/baud/discovery
|
||||
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';
|
||||
// Show DMX-specific fields
|
||||
if (dmxProtocolGroup) dmxProtocolGroup.style.display = '';
|
||||
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = '';
|
||||
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = '';
|
||||
ensureDmxProtocolIconSelect('device-dmx-protocol');
|
||||
// Relabel URL field as IP Address
|
||||
if (urlLabel) urlLabel.textContent = t('device.dmx.url');
|
||||
if (urlHint) urlHint.textContent = t('device.dmx.url.hint');
|
||||
urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50';
|
||||
} else if (isOpenrgbDevice(deviceType)) {
|
||||
urlGroup.style.display = '';
|
||||
urlInput.setAttribute('required', '');
|
||||
@@ -185,6 +249,9 @@ export function onDeviceTypeChanged() {
|
||||
scanForDevices();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-snapshot after type change so switching types alone doesn't mark as dirty
|
||||
addDeviceModal.snapshot();
|
||||
}
|
||||
|
||||
export function updateBaudFpsHint() {
|
||||
@@ -453,6 +520,11 @@ export async function handleAddDevice(event) {
|
||||
if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) {
|
||||
body.zone_mode = _getZoneMode();
|
||||
}
|
||||
if (isDmxDevice(deviceType)) {
|
||||
body.dmx_protocol = document.getElementById('device-dmx-protocol')?.value || 'artnet';
|
||||
body.dmx_start_universe = parseInt(document.getElementById('device-dmx-start-universe')?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt(document.getElementById('device-dmx-start-channel')?.value || '1', 10);
|
||||
}
|
||||
if (lastTemplateId) body.capture_template_id = lastTemplateId;
|
||||
|
||||
const response = await fetchWithAuth('/devices', {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect } from './device-discovery.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -35,6 +35,9 @@ class DeviceSettingsModal extends Modal {
|
||||
zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
|
||||
zoneMode: _getZoneMode('settings-zone-mode'),
|
||||
tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []),
|
||||
dmxProtocol: document.getElementById('settings-dmx-protocol')?.value || 'artnet',
|
||||
dmxStartUniverse: document.getElementById('settings-dmx-start-universe')?.value || '0',
|
||||
dmxStartChannel: document.getElementById('settings-dmx-start-channel')?.value || '1',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,6 +362,31 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// DMX-specific fields
|
||||
const dmxProtocolGroup = document.getElementById('settings-dmx-protocol-group');
|
||||
const dmxStartUniverseGroup = document.getElementById('settings-dmx-start-universe-group');
|
||||
const dmxStartChannelGroup = document.getElementById('settings-dmx-start-channel-group');
|
||||
if (isDmxDevice(device.device_type)) {
|
||||
if (dmxProtocolGroup) dmxProtocolGroup.style.display = '';
|
||||
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = '';
|
||||
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = '';
|
||||
document.getElementById('settings-dmx-protocol').value = device.dmx_protocol || 'artnet';
|
||||
ensureDmxProtocolIconSelect('settings-dmx-protocol');
|
||||
document.getElementById('settings-dmx-start-universe').value = device.dmx_start_universe ?? 0;
|
||||
document.getElementById('settings-dmx-start-channel').value = device.dmx_start_channel ?? 1;
|
||||
// Relabel URL field as IP Address
|
||||
const urlLabel2 = urlGroup.querySelector('label[for="settings-device-url"]');
|
||||
const urlHint2 = urlGroup.querySelector('.input-hint');
|
||||
if (urlLabel2) urlLabel2.textContent = t('device.dmx.url');
|
||||
if (urlHint2) urlHint2.textContent = t('device.dmx.url.hint');
|
||||
urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50';
|
||||
} else {
|
||||
destroyDmxProtocolIconSelect('settings-dmx-protocol');
|
||||
if (dmxProtocolGroup) dmxProtocolGroup.style.display = 'none';
|
||||
if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = 'none';
|
||||
if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_deviceTagsInput) _deviceTagsInput.destroy();
|
||||
_deviceTagsInput = new TagInput(document.getElementById('device-tags-container'), {
|
||||
@@ -416,6 +444,11 @@ export async function saveDeviceSettings() {
|
||||
if (isOpenrgbDevice(settingsModal.deviceType)) {
|
||||
body.zone_mode = _getZoneMode('settings-zone-mode');
|
||||
}
|
||||
if (isDmxDevice(settingsModal.deviceType)) {
|
||||
body.dmx_protocol = document.getElementById('settings-dmx-protocol')?.value || 'artnet';
|
||||
body.dmx_start_universe = parseInt(document.getElementById('settings-dmx-start-universe')?.value || '0', 10);
|
||||
body.dmx_start_channel = parseInt(document.getElementById('settings-dmx-start-channel')?.value || '1', 10);
|
||||
}
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
|
||||
@@ -142,8 +142,21 @@
|
||||
"device.type.ws.desc": "Stream LED data to WebSocket clients",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.type.openrgb.desc": "Control RGB peripherals via OpenRGB",
|
||||
"device.type.dmx": "DMX",
|
||||
"device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting",
|
||||
"device.type.mock": "Mock",
|
||||
"device.type.mock.desc": "Virtual device for testing",
|
||||
"device.dmx_protocol": "DMX Protocol:",
|
||||
"device.dmx_protocol.hint": "Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568",
|
||||
"device.dmx_protocol.artnet.desc": "UDP unicast, port 6454",
|
||||
"device.dmx_protocol.sacn.desc": "Multicast/unicast, port 5568",
|
||||
"device.dmx_start_universe": "Start Universe:",
|
||||
"device.dmx_start_universe.hint": "First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.",
|
||||
"device.dmx_start_channel": "Start Channel:",
|
||||
"device.dmx_start_channel.hint": "First DMX channel within the universe (1-512)",
|
||||
"device.dmx.url": "IP Address:",
|
||||
"device.dmx.url.hint": "IP address of the DMX node (e.g. 192.168.1.50)",
|
||||
"device.dmx.url.placeholder": "192.168.1.50",
|
||||
"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",
|
||||
|
||||
@@ -142,8 +142,21 @@
|
||||
"device.type.ws.desc": "Стриминг LED данных через WebSocket",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.type.openrgb.desc": "Управление RGB через OpenRGB",
|
||||
"device.type.dmx": "DMX",
|
||||
"device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение",
|
||||
"device.type.mock": "Mock",
|
||||
"device.type.mock.desc": "Виртуальное устройство для тестов",
|
||||
"device.dmx_protocol": "Протокол DMX:",
|
||||
"device.dmx_protocol.hint": "Art-Net использует UDP порт 6454, sACN (E1.31) — UDP порт 5568",
|
||||
"device.dmx_protocol.artnet.desc": "UDP unicast, порт 6454",
|
||||
"device.dmx_protocol.sacn.desc": "Multicast/unicast, порт 5568",
|
||||
"device.dmx_start_universe": "Начальный Universe:",
|
||||
"device.dmx_start_universe.hint": "Первый DMX-юниверс (0-32767). Дополнительные юниверсы используются автоматически при >170 светодиодах.",
|
||||
"device.dmx_start_channel": "Начальный канал:",
|
||||
"device.dmx_start_channel.hint": "Первый DMX-канал в юниверсе (1-512)",
|
||||
"device.dmx.url": "IP адрес:",
|
||||
"device.dmx.url.hint": "IP адрес DMX-узла (напр. 192.168.1.50)",
|
||||
"device.dmx.url.placeholder": "192.168.1.50",
|
||||
"device.serial_port": "Серийный порт:",
|
||||
"device.serial_port.hint": "Выберите COM порт устройства Adalight",
|
||||
"device.serial_port.none": "Серийные порты не найдены",
|
||||
|
||||
@@ -142,8 +142,21 @@
|
||||
"device.type.ws.desc": "通过WebSocket流式传输LED数据",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.type.openrgb.desc": "通过OpenRGB控制RGB外设",
|
||||
"device.type.dmx": "DMX",
|
||||
"device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光",
|
||||
"device.type.mock": "Mock",
|
||||
"device.type.mock.desc": "用于测试的虚拟设备",
|
||||
"device.dmx_protocol": "DMX 协议:",
|
||||
"device.dmx_protocol.hint": "Art-Net 使用 UDP 端口 6454,sACN (E1.31) 使用 UDP 端口 5568",
|
||||
"device.dmx_protocol.artnet.desc": "UDP 单播,端口 6454",
|
||||
"device.dmx_protocol.sacn.desc": "组播/单播,端口 5568",
|
||||
"device.dmx_start_universe": "起始 Universe:",
|
||||
"device.dmx_start_universe.hint": "第一个 DMX universe (0-32767)。超过 170 个 LED 时自动使用多个 universe。",
|
||||
"device.dmx_start_channel": "起始通道:",
|
||||
"device.dmx_start_channel.hint": "universe 中的第一个 DMX 通道 (1-512)",
|
||||
"device.dmx.url": "IP 地址:",
|
||||
"device.dmx.url.hint": "DMX 节点的 IP 地址(例如 192.168.1.50)",
|
||||
"device.dmx.url.placeholder": "192.168.1.50",
|
||||
"device.serial_port": "串口:",
|
||||
"device.serial_port.hint": "选择 Adalight 设备的 COM 端口",
|
||||
"device.serial_port.none": "未找到串口",
|
||||
|
||||
@@ -34,6 +34,10 @@ class Device:
|
||||
rgbw: bool = False,
|
||||
zone_mode: str = "combined",
|
||||
tags: List[str] = None,
|
||||
# DMX (Art-Net / sACN) fields
|
||||
dmx_protocol: str = "artnet",
|
||||
dmx_start_universe: int = 0,
|
||||
dmx_start_channel: int = 1,
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
@@ -50,6 +54,9 @@ class Device:
|
||||
self.rgbw = rgbw
|
||||
self.zone_mode = zone_mode
|
||||
self.tags = tags or []
|
||||
self.dmx_protocol = dmx_protocol
|
||||
self.dmx_start_universe = dmx_start_universe
|
||||
self.dmx_start_channel = dmx_start_channel
|
||||
self.created_at = created_at or datetime.now(timezone.utc)
|
||||
self.updated_at = updated_at or datetime.now(timezone.utc)
|
||||
|
||||
@@ -79,6 +86,12 @@ class Device:
|
||||
d["zone_mode"] = self.zone_mode
|
||||
if self.tags:
|
||||
d["tags"] = self.tags
|
||||
if self.dmx_protocol != "artnet":
|
||||
d["dmx_protocol"] = self.dmx_protocol
|
||||
if self.dmx_start_universe != 0:
|
||||
d["dmx_start_universe"] = self.dmx_start_universe
|
||||
if self.dmx_start_channel != 1:
|
||||
d["dmx_start_channel"] = self.dmx_start_channel
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -98,6 +111,9 @@ class Device:
|
||||
rgbw=data.get("rgbw", False),
|
||||
zone_mode=data.get("zone_mode", "combined"),
|
||||
tags=data.get("tags", []),
|
||||
dmx_protocol=data.get("dmx_protocol", "artnet"),
|
||||
dmx_start_universe=data.get("dmx_start_universe", 0),
|
||||
dmx_start_channel=data.get("dmx_start_channel", 1),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
)
|
||||
@@ -183,6 +199,9 @@ class DeviceStore:
|
||||
rgbw: bool = False,
|
||||
zone_mode: str = "combined",
|
||||
tags: Optional[List[str]] = None,
|
||||
dmx_protocol: str = "artnet",
|
||||
dmx_start_universe: int = 0,
|
||||
dmx_start_channel: int = 1,
|
||||
) -> Device:
|
||||
"""Create a new device."""
|
||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||
@@ -203,6 +222,9 @@ class DeviceStore:
|
||||
rgbw=rgbw,
|
||||
zone_mode=zone_mode,
|
||||
tags=tags or [],
|
||||
dmx_protocol=dmx_protocol,
|
||||
dmx_start_universe=dmx_start_universe,
|
||||
dmx_start_channel=dmx_start_channel,
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
@@ -232,6 +254,9 @@ class DeviceStore:
|
||||
rgbw: Optional[bool] = None,
|
||||
zone_mode: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
dmx_protocol: Optional[str] = None,
|
||||
dmx_start_universe: Optional[int] = None,
|
||||
dmx_start_channel: Optional[int] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
@@ -258,6 +283,12 @@ class DeviceStore:
|
||||
device.zone_mode = zone_mode
|
||||
if tags is not None:
|
||||
device.tags = tags
|
||||
if dmx_protocol is not None:
|
||||
device.dmx_protocol = dmx_protocol
|
||||
if dmx_start_universe is not None:
|
||||
device.dmx_start_universe = dmx_start_universe
|
||||
if dmx_start_channel is not None:
|
||||
device.dmx_start_channel = dmx_start_channel
|
||||
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
self.save()
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<option value="mqtt">MQTT</option>
|
||||
<option value="ws">WebSocket</option>
|
||||
<option value="openrgb">OpenRGB</option>
|
||||
<option value="dmx">DMX</option>
|
||||
<option value="mock">Mock</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -126,6 +127,33 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="device.send_latency.hint">Simulated network/serial delay per frame in milliseconds</small>
|
||||
<input type="number" id="device-send-latency" min="0" max="5000" value="0">
|
||||
</div>
|
||||
<div class="form-group" id="device-dmx-protocol-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.dmx_protocol.hint">Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568</small>
|
||||
<select id="device-dmx-protocol">
|
||||
<option value="artnet">Art-Net</option>
|
||||
<option value="sacn">sACN (E1.31)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="device-dmx-start-universe-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-dmx-start-universe" data-i18n="device.dmx_start_universe">Start Universe:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_universe.hint">First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.</small>
|
||||
<input type="number" id="device-dmx-start-universe" min="0" max="32767" value="0">
|
||||
</div>
|
||||
<div class="form-group" id="device-dmx-start-channel-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-dmx-start-channel" data-i18n="device.dmx_start_channel">Start Channel:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_channel.hint">First DMX channel within the universe (1-512)</small>
|
||||
<input type="number" id="device-dmx-start-channel" min="1" max="512" value="1">
|
||||
</div>
|
||||
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +104,34 @@
|
||||
<input type="number" id="settings-send-latency" min="0" max="5000" value="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-dmx-protocol-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.dmx_protocol.hint">Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568</small>
|
||||
<select id="settings-dmx-protocol">
|
||||
<option value="artnet">Art-Net</option>
|
||||
<option value="sacn">sACN (E1.31)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="settings-dmx-start-universe-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-dmx-start-universe" data-i18n="device.dmx_start_universe">Start Universe:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_universe.hint">First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.</small>
|
||||
<input type="number" id="settings-dmx-start-universe" min="0" max="32767" value="0">
|
||||
</div>
|
||||
<div class="form-group" id="settings-dmx-start-channel-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-dmx-start-channel" data-i18n="device.dmx_start_channel">Start Channel:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_channel.hint">First DMX channel within the universe (1-512)</small>
|
||||
<input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-health-interval-group">
|
||||
<div class="label-row">
|
||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||
|
||||
Reference in New Issue
Block a user