diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 69703e5..53f5d14 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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 diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 90c22ba..b437abd 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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") diff --git a/server/src/wled_controller/core/devices/dmx_client.py b/server/src/wled_controller/core/devices/dmx_client.py new file mode 100644 index 0000000..ca9b42a --- /dev/null +++ b/server/src/wled_controller/core/devices/dmx_client.py @@ -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_PROTOCOL_VERSION)) # 2 bytes: version BE + packet.append(sequence & 0xFF) # 1 byte: sequence + packet.append(0) # 1 byte: physical + 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() diff --git a/server/src/wled_controller/core/devices/dmx_provider.py b/server/src/wled_controller/core/devices/dmx_provider.py new file mode 100644 index 0000000..5eaae85 --- /dev/null +++ b/server/src/wled_controller/core/devices/dmx_provider.py @@ -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 [] diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index 3043961..ba178a3 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -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() diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 398c5b2..ee6404d 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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) ===== diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 665eed8..f43af36 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 81f887d..803522e 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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() diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 60089b6..d9cddaa 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -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'); diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 7350f52..7882e45 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -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), diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index 4f610c0..1a02def 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -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', { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index dddb04a..62de6d0 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -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) diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index cea4701..847acec 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 085a887..71dc28c 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Серийные порты не найдены", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 28a24d7..d3fa679 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "未找到串口", diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index c796f8a..56bb2b7 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/add-device.html b/server/src/wled_controller/templates/modals/add-device.html index 10ed67f..b9db1a3 100644 --- a/server/src/wled_controller/templates/modals/add-device.html +++ b/server/src/wled_controller/templates/modals/add-device.html @@ -33,6 +33,7 @@ + @@ -126,6 +127,33 @@ + + + diff --git a/server/src/wled_controller/templates/modals/device-settings.html b/server/src/wled_controller/templates/modals/device-settings.html index 173dea9..7c2faae 100644 --- a/server/src/wled_controller/templates/modals/device-settings.html +++ b/server/src/wled_controller/templates/modals/device-settings.html @@ -104,6 +104,34 @@ + + + +