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:
@@ -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": "未找到串口",
|
||||
|
||||
Reference in New Issue
Block a user