Skip to content

Commit 7d9c4b9

Browse files
committed
Fix send partial universe
1 parent c95ed5a commit 7d9c4b9

File tree

5 files changed

+196
-89
lines changed

5 files changed

+196
-89
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,4 @@ dmypy.json
127127

128128
# Pyre type checker
129129
.pyre/
130+
staging/.ha_token

custom_components/dmx/io/dmx_io.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ def __init__(
2929
self._channel_values: dict[int, int] = {}
3030
self._constant_values: dict[int, int] = {}
3131
self._channel_callbacks: dict[int, list[Callable[[str | None], None]]] = {}
32-
self._changed_channels: set[int] = set()
33-
self._first_send: bool = True
3432
self._output_enabled: bool = True
3533
self.animation_engine: DmxAnimationEngine | None = None
3634

@@ -51,7 +49,6 @@ def set_constant_value(self, channels: list[int], value: int) -> None:
5149
for ch in channels:
5250
self._constant_values[ch] = value
5351
self._channel_values[ch] = value
54-
self._changed_channels.add(ch)
5552

5653
def register_channel_listener(self, channels: int | list[int], callback: Callable[[str | None], None]) -> None:
5754
if isinstance(channels, int):
@@ -79,7 +76,6 @@ async def update_value(
7976

8077
if ch not in self._channel_values or self._channel_values[ch] != value:
8178
self._channel_values[ch] = value
82-
self._changed_channels.add(ch)
8379
changed_channels.append(ch)
8480

8581
for ch in changed_channels:
@@ -128,7 +124,6 @@ def send_universe_data(self) -> None:
128124
for channel, constant_value in self._constant_values.items():
129125
if self._channel_values.get(channel) != constant_value:
130126
self._channel_values[channel] = constant_value
131-
self._changed_channels.add(channel)
132127

133128
if not self._channel_values:
134129
data = bytearray(2) # Minimum size is 2 bytes
@@ -141,19 +136,12 @@ def send_universe_data(self) -> None:
141136
sacn_data = bytearray([0] + [0] * 24)
142137
self.sacn_server.send_dmx_data(self.sacn_universe, sacn_data)
143138

144-
self._changed_channels.clear()
145-
self._first_send = False
146139
return
147140

148-
if self.use_partial_universe and not self._first_send and self._changed_channels:
149-
max_changed_channel = max(self._changed_channels)
150-
151-
data_length = (
152-
(max_changed_channel + (2 - (max_changed_channel % 2)))
153-
if max_changed_channel % 2
154-
else max_changed_channel
155-
)
141+
if self.use_partial_universe:
142+
max_channel = max(self._channel_values.keys()) if self._channel_values else 2
156143

144+
data_length = max_channel + (max_channel % 2)
157145
data_length = max(2, data_length)
158146

159147
data = bytearray(data_length)
@@ -174,6 +162,3 @@ def send_universe_data(self) -> None:
174162
if len(sacn_data) < 25:
175163
sacn_data.extend([0] * (25 - len(sacn_data)))
176164
self.sacn_server.send_dmx_data(self.sacn_universe, sacn_data)
177-
178-
self._changed_channels.clear()
179-
self._first_send = False

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ All your current fixtures are now still supported! Here's the process to get you
66

77
Our integration name changed from `artnet_led` to `dmx`. HACS doesn't allow this, so we are unable to upgrade this in-place. Therefore a manual release is needed for the beta.
88

9-
Under [the release](https://github.com/Breina/ha-artnet-led/releases/tag/v1.0.0-BETA.7) download the [source code](https://github.com/Breina/ha-artnet-led/archive/refs/tags/v1.0.0-BETA.7.zip).
9+
Under [the release](https://github.com/Breina/ha-artnet-led/releases/tag/v1.0.0-BETA.8) download the [source code](https://github.com/Breina/ha-artnet-led/archive/refs/tags/v1.0.0-BETA.8.zip).
1010

1111
In this ZIP, navigate to `custom_components/dmx` and copy this `dmx` folder into your Home Assistant's `custom_components` folder.
1212

staging/.storage/auth

Lines changed: 17 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,11 @@
55
"data": {
66
"users": [
77
{
8-
"id": "f2b2dcac5a654d48b1621916499b7522",
9-
"group_ids": [
10-
"system-read-only"
11-
],
12-
"is_owner": false,
13-
"is_active": true,
14-
"name": "Home Assistant Content",
15-
"system_generated": true,
16-
"local_only": false
17-
},
18-
{
19-
"id": "2618b9c6d3774a8ba3f32c13c7d54536",
20-
"group_ids": [
21-
"system-admin"
22-
],
8+
"id": "test_user_id_12345",
9+
"group_ids": ["system-admin"],
2310
"is_owner": true,
2411
"is_active": true,
25-
"name": "admin",
12+
"name": "Test User",
2613
"system_generated": false,
2714
"local_only": false
2815
}
@@ -31,76 +18,36 @@
3118
{
3219
"id": "system-admin",
3320
"name": "Administrators"
34-
},
35-
{
36-
"id": "system-users",
37-
"name": "Users"
38-
},
39-
{
40-
"id": "system-read-only",
41-
"name": "Read Only"
4221
}
4322
],
4423
"credentials": [
4524
{
46-
"id": "57dfab0bf2b34bd6a44ad1ebf0f5c660",
47-
"user_id": "2618b9c6d3774a8ba3f32c13c7d54536",
25+
"id": "test_cred_id_12345",
26+
"user_id": "test_user_id_12345",
4827
"auth_provider_type": "homeassistant",
4928
"auth_provider_id": null,
5029
"data": {
51-
"username": "admin"
30+
"username": "testuser"
5231
}
5332
}
5433
],
5534
"refresh_tokens": [
5635
{
57-
"id": "d5ffca58818b441da61fdfb060c3af00",
58-
"user_id": "f2b2dcac5a654d48b1621916499b7522",
59-
"client_id": null,
36+
"id": "test_token_id_12345",
37+
"user_id": "test_user_id_12345",
38+
"client_id": "http://localhost:8123/",
6039
"client_name": null,
6140
"client_icon": null,
62-
"token_type": "system",
63-
"created_at": "2023-02-22T14:20:52.545537+00:00",
64-
"access_token_expiration": 1800.0,
65-
"token": "74dcc0ae7702b7d571ea8c72dfcd8c223d32cdaeca9550b694c4c2fa95bc1d9e03684e4cbe03bafc3f59ad27ddad0b91f62cc07bddaf4d81442c9e2f1cbaff8e",
66-
"jwt_key": "8e46f343fbb09dd47a46ee89f8b87fa1587db8a89e4f6cc1019fd0fcbc225798bd7f2287ad741316803bb409088ce487b78ae094f754501fb55670ec1d58aa81",
41+
"token_type": "long_lived_access_token",
42+
"created_at": "2026-02-03T00:00:00.000000+00:00",
43+
"access_token_expiration": 315360000.0,
44+
"token": "bff9194d0071fdff04b83d5380b711546dba1a88ca0c1bba46c2a6bc533835ea00bebdd8483c6336ca42b9e5f78eb8f974176bde3d7e80e77a56dea745a8cbcf",
45+
"jwt_key": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456789012345678901234567890abcdef1234567890abcdef1234567890abcdef12",
6746
"last_used_at": null,
6847
"last_used_ip": null,
69-
"credential_id": null,
70-
"version": "2023.2.5"
71-
},
72-
{
73-
"id": "d242f3768d694f139521707df646a5a9",
74-
"user_id": "2618b9c6d3774a8ba3f32c13c7d54536",
75-
"client_id": "http://localhost:8123/",
76-
"client_name": null,
77-
"client_icon": null,
78-
"token_type": "normal",
79-
"created_at": "2023-02-22T14:23:15.191639+00:00",
80-
"access_token_expiration": 18000000.0,
81-
"token": "d2064eacb6033a2a93a80baec5a49aa157a79f825901d0f621a99db1ce19adffc2371b20bcce5e0f7903f7b271f39bfc08f0b7b7a8e1e17ad8312b37866757e3",
82-
"jwt_key": "fa49adcdc3c592081b4efaf119f7bc0ef68485b1458085e8d4f8361ba8d24b4dae02483b924c8f1666539b37f829772218e71cf2bde50eeaac1e2742275121cb",
83-
"last_used_at": "2023-02-22T14:23:15.191960+00:00",
84-
"last_used_ip": "172.17.0.1",
85-
"credential_id": "57dfab0bf2b34bd6a44ad1ebf0f5c660",
86-
"version": "2023.2.5"
87-
},
88-
{
89-
"id": "974663ee0df343549df9c5b19ec9e95c",
90-
"user_id": "2618b9c6d3774a8ba3f32c13c7d54536",
91-
"client_id": "http://localhost:8123/",
92-
"client_name": null,
93-
"client_icon": null,
94-
"token_type": "normal",
95-
"created_at": "2023-02-23T17:07:37.476197+00:00",
96-
"access_token_expiration": 18000000.0,
97-
"token": "d4b387d6db99714f57ae257deb0463547cb41929f89208da751cb293b392ecd06c80832c3bdafe05d3dd05f1c832ed943c0e8070f74b24424091313870499794",
98-
"jwt_key": "ff5757f044f6bcc10484f80b5225043124b4653dbf8b1cd3a54201322b8816806d253b267d00832e382c5644bb47fb56225926c2d49ac2e4200192f09546b9af",
99-
"last_used_at": "2023-02-23T17:07:37.476424+00:00",
100-
"last_used_ip": "172.17.0.1",
101-
"credential_id": "57dfab0bf2b34bd6a44ad1ebf0f5c660",
102-
"version": "2023.2.5"
48+
"credential_id": "test_cred_id_12345",
49+
"version": "2026.1.0"
10350
}
10451
]
10552
}
106-
}
53+
}

tests/test_partial_universe_bug.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Test for partial universe bug fix (issue #106)
2+
3+
Tests that partial universe optimization doesn't truncate DMX packets
4+
and lose channel values beyond the changed channels.
5+
"""
6+
7+
from unittest.mock import MagicMock, Mock
8+
9+
import pytest
10+
11+
from custom_components.dmx.io.dmx_io import DmxUniverse
12+
from custom_components.dmx.server import PortAddress
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_partial_universe_includes_all_configured_channels():
17+
"""Test that partial universe packets include all configured channels, not just changed ones.
18+
19+
This is a regression test for issue #106 where moving heads would return to home position
20+
after animations because their pan/tilt channels were being excluded from partial packets.
21+
"""
22+
# Setup mock controller
23+
mock_controller = Mock()
24+
mock_controller.send_dmx = MagicMock()
25+
26+
# Create universe with partial universe enabled
27+
port_address = PortAddress(0, 0, 1)
28+
universe = DmxUniverse(
29+
port_address=port_address,
30+
controller=mock_controller,
31+
use_partial_universe=True,
32+
sacn_server=None,
33+
sacn_universe=None,
34+
hass=None,
35+
max_fps=30,
36+
)
37+
38+
# Simulate a moving head fixture with channels 1-10
39+
# Channels 1-5: Pan (coarse), Pan (fine), Tilt (coarse), Tilt (fine), Speed
40+
# Channels 6-10: Dimmer, Red, Green, Blue, White
41+
initial_values = {
42+
1: 128, # Pan coarse - middle position
43+
2: 0, # Pan fine
44+
3: 64, # Tilt coarse - middle position
45+
4: 0, # Tilt fine
46+
5: 100, # Speed
47+
6: 255, # Dimmer - full
48+
7: 255, # Red - full
49+
8: 0, # Green - off
50+
9: 0, # Blue - off
51+
10: 0, # White - off
52+
}
53+
54+
# Set initial values (simulating fixture setup)
55+
await universe.update_multiple_values(initial_values, send_update=True)
56+
57+
# Verify first send (now uses partial universe too - no special "first send" behavior)
58+
assert mock_controller.send_dmx.call_count == 1
59+
first_call_data = mock_controller.send_dmx.call_args[0][1]
60+
# Should be partial universe: up to channel 10, rounded to even = 10 bytes
61+
assert len(first_call_data) == 10, f"Expected 10 bytes, got {len(first_call_data)}"
62+
63+
# Verify all initial values are in the packet
64+
for channel, value in initial_values.items():
65+
assert first_call_data[channel - 1] == value, f"Channel {channel} should be {value}"
66+
67+
mock_controller.send_dmx.reset_mock()
68+
69+
# Now simulate an animation that only changes color channels (7-9)
70+
# This simulates what happens when a light entity animates colors
71+
# but leaves pan/tilt channels untouched
72+
color_update = {
73+
7: 128, # Red - dimmed
74+
8: 64, # Green - some green
75+
9: 192, # Blue - mostly blue
76+
}
77+
78+
await universe.update_multiple_values(color_update, send_update=True)
79+
80+
# Verify second send
81+
assert mock_controller.send_dmx.call_count == 1
82+
second_call_data = mock_controller.send_dmx.call_args[0][1]
83+
84+
# THE BUG FIX: Packet should include ALL channels up to channel 10,
85+
# not just up to channel 9 (the highest changed channel)
86+
# Before fix: len would be 9 or 10 (rounded to even)
87+
# After fix: len should be 10 (includes all configured channels)
88+
assert (
89+
len(second_call_data) >= 10
90+
), f"Partial universe packet should include all {max(initial_values.keys())} configured channels"
91+
92+
# Verify pan/tilt channels (1-5) are still present with original values
93+
# This is the critical test - these channels were NOT in the update,
94+
# but they should still be included in the packet
95+
assert second_call_data[0] == 128, "Pan coarse should still be 128"
96+
assert second_call_data[1] == 0, "Pan fine should still be 0"
97+
assert second_call_data[2] == 64, "Tilt coarse should still be 64"
98+
assert second_call_data[3] == 0, "Tilt fine should still be 0"
99+
assert second_call_data[4] == 100, "Speed should still be 100"
100+
101+
# Verify color channels have the new values
102+
assert second_call_data[6] == 128, "Red should be updated to 128"
103+
assert second_call_data[7] == 64, "Green should be updated to 64"
104+
assert second_call_data[8] == 192, "Blue should be updated to 192"
105+
106+
# Verify dimmer and white are still at original values
107+
assert second_call_data[5] == 255, "Dimmer should still be 255"
108+
assert second_call_data[9] == 0, "White should still be 0"
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_partial_universe_with_only_low_channels_changed():
113+
"""Test that changing only low channels doesn't truncate high channel values."""
114+
mock_controller = Mock()
115+
mock_controller.send_dmx = MagicMock()
116+
117+
port_address = PortAddress(0, 0, 1)
118+
universe = DmxUniverse(
119+
port_address=port_address,
120+
controller=mock_controller,
121+
use_partial_universe=True,
122+
sacn_server=None,
123+
sacn_universe=None,
124+
hass=None,
125+
max_fps=30,
126+
)
127+
128+
# Set a high channel value (simulating a fixture at a high DMX address)
129+
initial_values = {
130+
1: 100, # Low channel
131+
50: 200, # High channel
132+
}
133+
134+
await universe.update_multiple_values(initial_values, send_update=True)
135+
mock_controller.send_dmx.reset_mock()
136+
137+
# Update only the low channel
138+
await universe.update_multiple_values({1: 150}, send_update=True)
139+
140+
# Verify the packet still includes the high channel
141+
call_data = mock_controller.send_dmx.call_args[0][1]
142+
assert len(call_data) >= 50, "Packet should include channel 50"
143+
assert call_data[0] == 150, "Channel 1 should be updated"
144+
assert call_data[49] == 200, "Channel 50 should still have original value"
145+
146+
147+
@pytest.mark.asyncio
148+
async def test_partial_universe_packet_size_is_even():
149+
"""Test that partial universe packets are properly rounded to even sizes."""
150+
mock_controller = Mock()
151+
mock_controller.send_dmx = MagicMock()
152+
153+
port_address = PortAddress(0, 0, 1)
154+
universe = DmxUniverse(
155+
port_address=port_address,
156+
controller=mock_controller,
157+
use_partial_universe=True,
158+
sacn_server=None,
159+
sacn_universe=None,
160+
hass=None,
161+
max_fps=30,
162+
)
163+
164+
# Set an odd number of channels
165+
await universe.update_multiple_values({1: 100, 2: 150, 3: 200}, send_update=True)
166+
mock_controller.send_dmx.reset_mock()
167+
168+
# Update causing the max channel to be odd (3)
169+
await universe.update_multiple_values({3: 255}, send_update=True)
170+
171+
call_data = mock_controller.send_dmx.call_args[0][1]
172+
# Should be rounded up to 4 (even number)
173+
assert len(call_data) % 2 == 0, "Partial universe packet should have even length"
174+
assert len(call_data) >= 4, "Should be rounded up from 3 to 4"

0 commit comments

Comments
 (0)