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:
2026-03-13 16:46:40 +03:00
parent 18c886cbc5
commit ff24ec95e6
18 changed files with 607 additions and 7 deletions

View File

@@ -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', {