Skip to content

Commit f8822ef

Browse files
committed
Check sACN START_CODE and only proceed if it's a START_CODE that we can handle (DMX512). Ref #108
1 parent 73cee52 commit f8822ef

File tree

5 files changed

+94
-37
lines changed

5 files changed

+94
-37
lines changed

custom_components/dmx/server/sacn_packet.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,16 @@ def __post_init__(self) -> None:
4848

4949
if len(self.dmx_data) == 0:
5050
self.dmx_data = bytearray(513)
51-
if self.dmx_data[0] != 0x00:
52-
self.dmx_data[0] = 0x00
51+
52+
@property
53+
def start_code(self) -> int:
54+
"""Return the DMX start code (first byte of dmx_data)."""
55+
return self.dmx_data[0] if len(self.dmx_data) > 0 else 0x00
56+
57+
@property
58+
def channel_data(self) -> bytearray:
59+
"""Return DMX channel data (bytes 1-512, excluding start code)."""
60+
return self.dmx_data[1:] if len(self.dmx_data) > 1 else bytearray()
5361

5462
def set_dmx_channel(self, channel: int, value: int) -> None:
5563
assert 1 <= channel <= 512, "Channel must be 1-512"
@@ -63,17 +71,17 @@ def set_dmx_channel(self, channel: int, value: int) -> None:
6371
def set_dmx_data(self, data: bytearray) -> None:
6472
assert len(data) <= 513, "DMX data must be <= 513 bytes"
6573
self.dmx_data = data
66-
if len(self.dmx_data) > 0 and self.dmx_data[0] != 0x00:
67-
self.dmx_data[0] = 0x00
6874

6975
def get_multicast_address(self) -> str:
7076
return f"239.255.{self.universe >> 8}.{self.universe & 0xFF}"
7177

7278
def serialize(self) -> bytes:
79+
dmx_data_out = bytearray(self.dmx_data)
80+
if len(dmx_data_out) > 0:
81+
dmx_data_out[0] = 0x00
82+
7383
# Calculate lengths
74-
dmp_layer_length = 10 + len(
75-
self.dmx_data
76-
) # DMP header (10) + property values (dmx_data contains the 0x00 byte)
84+
dmp_layer_length = 10 + len(dmx_data_out) # DMP header (10) + property values (dmx_data contains the 0x00 byte)
7785
framing_layer_length = 77 + dmp_layer_length # Framing header (77) + DMP layer
7886
root_layer_length = 22 + framing_layer_length # Root header (22) + Framing layer
7987

@@ -113,9 +121,9 @@ def serialize(self) -> bytes:
113121
packet.extend(struct.pack(">B", DMP_ADDRESS_TYPE_DATA_TYPE))
114122
packet.extend(struct.pack(">H", DMP_FIRST_PROPERTY_ADDRESS))
115123
packet.extend(struct.pack(">H", DMP_ADDRESS_INCREMENT))
116-
packet.extend(struct.pack(">H", len(self.dmx_data)))
124+
packet.extend(struct.pack(">H", len(dmx_data_out)))
117125

118-
packet.extend(self.dmx_data)
126+
packet.extend(dmx_data_out)
119127

120128
return bytes(packet)
121129

custom_components/dmx/server/sacn_server.py

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,10 @@ def send_dmx_data(self, universe_id: int, dmx_data: bytearray) -> bool:
179179
# Start streaming task if not already running
180180
if universe_state.stream_task is None or universe_state.stream_task.done():
181181
log.debug(f"Universe {universe_id}: creating new streaming task")
182-
universe_state.stream_task = self.hass.async_create_task(self._stream_universe_data(universe_id))
182+
universe_state.stream_task = self.hass.async_create_background_task(
183+
self._stream_universe_data(universe_id),
184+
name=f"sACN stream universe {universe_id}",
185+
)
183186

184187
self.hass.async_create_task(self._send_pending_frame(universe_id))
185188

@@ -394,32 +397,42 @@ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
394397
try:
395398
packet = SacnPacket.deserialize(data)
396399

397-
if packet.universe in self.subscribed_universes:
398-
log.debug(
399-
f"Received sACN data for universe {packet.universe} from {addr[0]} "
400-
f"(source: '{packet.source_name}', seq: {packet.sequence_number}, "
401-
f"priority: {packet.priority}, channels: {len(packet.dmx_data)})"
402-
)
403-
404-
# Ignore packets from our own sACN server to prevent feedback loops
405-
if self.own_source_name and packet.source_name == self.own_source_name:
406-
return
407-
408-
if self.data_callback:
409-
port_address = PortAddress(0, 0, packet.universe)
410-
self.data_callback(port_address, packet.dmx_data, packet.source_name)
411-
else:
412-
log.warning("No data callback configured for sACN receiver")
400+
if packet.start_code == 0x00:
401+
self._handle_dmx_packet(packet, addr)
413402
else:
414403
log.debug(
415-
f"Ignoring sACN data for universe {packet.universe} from {addr[0]} "
416-
f"(not subscribed, subscribed universes: {self.subscribed_universes})"
404+
f"Ignoring sACN packet with unsupported start code 0x{packet.start_code:02X} "
405+
f"for universe {packet.universe} from {addr[0]} (source: '{packet.source_name}')"
417406
)
418407

419408
except Exception as e:
420409
log.warning(f"Error processing sACN packet from {addr[0]}:{addr[1]} ({len(data)} bytes): {e}")
421410
log.debug(f"Raw packet data: {data[:50].hex()}{'...' if len(data) > 50 else ''}")
422411

412+
def _handle_dmx_packet(self, packet: SacnPacket, addr: tuple[str, int]) -> None:
413+
"""Handle a DMX512-A data packet (start code 0x00)."""
414+
if self.own_source_name and packet.source_name == self.own_source_name:
415+
return
416+
417+
if packet.universe not in self.subscribed_universes:
418+
log.debug(
419+
f"Ignoring sACN data for universe {packet.universe} from {addr[0]} "
420+
f"(not subscribed, subscribed universes: {self.subscribed_universes})"
421+
)
422+
return
423+
424+
log.debug(
425+
f"Received sACN DMX data for universe {packet.universe} from {addr[0]} "
426+
f"(source: '{packet.source_name}', seq: {packet.sequence_number}, "
427+
f"priority: {packet.priority}, channels: {len(packet.channel_data)})"
428+
)
429+
430+
if self.data_callback:
431+
port_address = PortAddress(0, 0, packet.universe)
432+
self.data_callback(port_address, packet.channel_data, packet.source_name)
433+
else:
434+
log.warning("No data callback configured for sACN receiver")
435+
423436
def subscribe_universe(self, universe_id: int) -> None:
424437
if not (1 <= universe_id <= 63999):
425438
log.error(f"Invalid universe ID: {universe_id}")

deploy_staging.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import shutil
1+
import subprocess
22
import time
33
import webbrowser
44

@@ -16,8 +16,15 @@
1616
def copy_repo():
1717
print("Copying repo...")
1818
# Requires Samba share and it being configured as network location in Windows
19-
shutil.rmtree(STAGING_FOLDER)
20-
shutil.copytree("custom_components/dmx", STAGING_FOLDER, dirs_exist_ok=True)
19+
result = subprocess.run(
20+
["robocopy", "custom_components/dmx", STAGING_FOLDER, "/MIR", "/NJH", "/NJS"],
21+
capture_output=True,
22+
text=True,
23+
)
24+
print(result.stdout)
25+
# robocopy exit codes 0-7 are success (bitmask: 1=copied, 2=extras deleted, 4=mismatches)
26+
if result.returncode >= 8:
27+
raise RuntimeError(f"robocopy failed with exit code {result.returncode}\n{result.stderr}")
2128

2229

2330
def restart_ha() -> Response:

tests/test_sacn_packet.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,27 @@ def test_set_dmx_data(self):
9494
packet.set_dmx_data(test_data)
9595

9696
assert packet.dmx_data == test_data
97-
assert packet.dmx_data[0] == 0x00
97+
assert packet.start_code == 0x00
98+
assert packet.channel_data == test_data[1:]
9899

99-
no_start_code_data = bytearray([255] + [i % 256 for i in range(1, 513)])
100-
packet.set_dmx_data(no_start_code_data)
100+
non_zero_start_code_data = bytearray([0xDD] + [i % 256 for i in range(1, 513)])
101+
packet.set_dmx_data(non_zero_start_code_data)
101102

102-
assert packet.dmx_data[0] == 0x00
103-
assert packet.dmx_data[1:] == no_start_code_data[1:]
103+
assert packet.dmx_data == non_zero_start_code_data
104+
assert packet.start_code == 0xDD
105+
assert packet.channel_data == non_zero_start_code_data[1:]
106+
107+
def test_serialize_forces_start_code_zero(self):
108+
"""Test that serialize() always outputs start code 0x00 regardless of internal value."""
109+
packet = SacnPacket()
110+
111+
non_zero_start_code_data = bytearray([0xDD] + [100] * 512)
112+
packet.set_dmx_data(non_zero_start_code_data)
113+
114+
assert packet.start_code == 0xDD
115+
116+
serialized = packet.serialize()
117+
assert serialized[125] == 0x00
104118

105119
def test_multicast_address_calculation(self):
106120
test_cases = [

tests/test_sacn_server.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def test_packet_processing(self, sacn_receiver, data_callback):
225225
assert port_address.universe == 1
226226

227227
dmx_data = call_args[1]
228-
assert dmx_data == test_packet.dmx_data
228+
assert dmx_data == test_packet.dmx_data[1:]
229229

230230
source_name = call_args[2]
231231
assert source_name == test_packet.source_name
@@ -242,6 +242,21 @@ def test_packet_processing_unsubscribed(self, sacn_receiver, data_callback):
242242

243243
data_callback.assert_not_called()
244244

245+
def test_packet_processing_non_dmx_start_code_ignored(self, sacn_receiver, data_callback):
246+
"""Test that packets with non-DMX start codes (e.g., 0xDD per-address priority) are ignored."""
247+
sacn_receiver.subscribe_universe(1)
248+
249+
from custom_components.dmx.server.sacn_packet import SacnPacket
250+
251+
test_packet = SacnPacket(universe=1, dmx_data=bytearray([0] + [100] * 512))
252+
serialized_packet = bytearray(test_packet.serialize())
253+
254+
serialized_packet[125] = 0xDD # Per-address priority start code
255+
256+
sacn_receiver.datagram_received(bytes(serialized_packet), ("192.168.1.100", 5568))
257+
258+
data_callback.assert_not_called()
259+
245260
def test_connection_lifecycle(self, sacn_receiver):
246261
mock_transport = MagicMock()
247262

0 commit comments

Comments
 (0)