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

@@ -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()