Skip to content

Commit 0b0c398

Browse files
committed
Finish sACN receiver
1 parent 732270c commit 0b0c398

File tree

3 files changed

+150
-26
lines changed

3 files changed

+150
-26
lines changed

custom_components/dmx/__init__.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from custom_components.dmx.io.dmx_io import DmxUniverse
2424
from custom_components.dmx.server import PortAddress, ArtPollReply
2525
from custom_components.dmx.server.artnet_server import ArtNetServer, Node, ManualNode
26-
from custom_components.dmx.server.sacn_server import SacnServer, SacnServerConfig
26+
from custom_components.dmx.server.sacn_server import SacnServer, SacnServerConfig, create_sacn_receiver
2727
from custom_components.dmx.util.rate_limiter import RateLimiter
2828

2929
log = logging.getLogger(__name__)
@@ -222,6 +222,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
222222
entities: list[Entity] = []
223223
universes: dict[PortAddress, DmxUniverse] = {}
224224
sacn_server = None
225+
sacn_receiver = None
225226

226227
# Initialize sACN server if configured
227228
if (sacn_yaml := dmx_yaml.get(CONF_NODE_TYPE_SACN)) is not None:
@@ -237,6 +238,59 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
237238
sacn_server.start_server()
238239
log.info(f"sACN server started with source name: {sacn_config.source_name}")
239240

241+
# Set up rate limiting for sACN reception (similar to Art-Net)
242+
rate_limit = sacn_yaml.get(CONF_RATE_LIMIT, CONF_RATE_LIMIT_DEFAULT)
243+
sacn_universe_rate_limiters = {}
244+
245+
# Function to process sACN universe updates with rate limiting
246+
def sacn_state_callback(port_address: PortAddress, data: bytearray, source: str | None = None):
247+
log.info(f"sACN state callback triggered for {port_address} from source '{source}' with {len(data)} channels")
248+
249+
callback_universe: DmxUniverse = universes.get(port_address)
250+
if callback_universe is None:
251+
log.warning(f"Received sACN data for unknown universe: {port_address}")
252+
return
253+
254+
if port_address not in sacn_universe_rate_limiters:
255+
updates_dict = {}
256+
257+
async def process_updates():
258+
nonlocal updates_dict
259+
updates_to_process = updates_dict.copy()
260+
updates_dict.clear()
261+
262+
if updates_to_process:
263+
log.debug(f"Processing {len(updates_to_process)} sACN channel updates for {port_address}")
264+
await callback_universe.update_multiple_values(updates_to_process, source, send_update=False)
265+
266+
limiter = RateLimiter(
267+
hass,
268+
update_method=lambda: hass.async_create_task(process_updates()),
269+
update_interval=rate_limit,
270+
force_update_after=rate_limit * 4
271+
)
272+
273+
sacn_universe_rate_limiters[port_address] = (limiter, updates_dict)
274+
275+
limiter, updates_dict = sacn_universe_rate_limiters[port_address]
276+
277+
changes_detected = False
278+
279+
for channel, value in enumerate(data, start=1): # DMX channels are 1-based
280+
if value > 0 or callback_universe.get_channel_value(channel) != value:
281+
updates_dict[channel] = value
282+
changes_detected = True
283+
284+
if changes_detected:
285+
log.debug(f"Detected changes in {len([k for k, v in updates_dict.items()])} channels for {port_address}")
286+
limiter.schedule_update()
287+
else:
288+
log.debug(f"No changes detected for {port_address}")
289+
290+
# Create sACN receiver for incoming multicast data
291+
sacn_receiver = await create_sacn_receiver(hass, sacn_state_callback)
292+
log.info("sACN receiver started for multicast reception")
293+
240294
# Process sACN universes and devices
241295
for universe_dict in sacn_yaml[CONF_UNIVERSES]:
242296
(universe_value, universe_yaml), = universe_dict.items()
@@ -257,6 +311,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
257311
# Add universe to sACN server with unicast addresses
258312
sacn_server.add_universe(universe_id, unicast_addresses)
259313

314+
# Subscribe receiver to this universe for incoming multicast data
315+
sacn_receiver.subscribe_universe(universe_id)
316+
log.info(f"Subscribed sACN receiver to universe {universe_id}")
317+
260318
# Create universe with sACN support, passing the actual sACN universe ID
261319
universe = DmxUniverse(port_address, None, True, sacn_server, universe_id)
262320
universes[port_address] = universe
@@ -502,6 +560,7 @@ def node_lost_callback(node: Node):
502560
vol.Optional(CONF_SYNC_ADDRESS, default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=63999)),
503561
vol.Optional(CONF_MULTICAST_TTL, default=CONF_MULTICAST_TTL_DEFAULT): vol.All(vol.Coerce(int), vol.Range(min=1, max=255)),
504562
vol.Optional(CONF_ENABLE_PREVIEW_DATA, default=False): cv.boolean,
563+
vol.Optional(CONF_RATE_LIMIT, default=CONF_RATE_LIMIT_DEFAULT): cv.positive_float,
505564

506565
vol.Required(CONF_UNIVERSES): vol.Schema(
507566
[{

custom_components/dmx/entity/light/light_state.py

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,62 @@ def __init__(self, color_mode: ColorMode, converter: ColorConverter, channels: D
1717

1818
self.is_on = False
1919
self.brightness = 255
20-
self.rgb = (255, 255, 255)
2120
self.cold_white = 255
2221
self.warm_white = 255
2322
self.color_temp_kelvin = 3000
2423
self.color_temp_dmx = 255
2524

2625
self.last_brightness = 255
27-
self.last_rgb = (255, 255, 255)
2826
self.last_cold_white = 255
2927
self.last_warm_white = 255
3028
self.last_color_temp_kelvin = 3000
3129
self.last_color_temp_dmx = 255
32-
33-
self._channel_handlers = {
34-
ChannelType.DIMMER: self._handle_dimmer_update,
35-
ChannelType.RED: lambda v: self._handle_rgb_component_update(0, v),
36-
ChannelType.GREEN: lambda v: self._handle_rgb_component_update(1, v),
37-
ChannelType.BLUE: lambda v: self._handle_rgb_component_update(2, v),
38-
ChannelType.COLD_WHITE: self._handle_cold_white_update,
39-
ChannelType.WARM_WHITE: self._handle_warm_white_update,
40-
ChannelType.COLOR_TEMPERATURE: self.update_color_temp_dmx,
41-
}
30+
31+
# Only initialize RGB for color modes that use it
32+
if color_mode in [ColorMode.RGB, ColorMode.RGBW, ColorMode.RGBWW]:
33+
self.rgb = (255, 255, 255)
34+
self.last_rgb = (255, 255, 255)
35+
else:
36+
self.rgb = None
37+
self.last_rgb = None
38+
39+
self._channel_handlers = {}
40+
41+
if ChannelType.DIMMER in channels:
42+
self._channel_handlers[ChannelType.DIMMER] = self._handle_dimmer_update
43+
44+
if ChannelType.RED in channels:
45+
self._channel_handlers[ChannelType.RED] = lambda v: self._handle_rgb_component_update(0, v)
46+
if ChannelType.GREEN in channels:
47+
self._channel_handlers[ChannelType.GREEN] = lambda v: self._handle_rgb_component_update(1, v)
48+
if ChannelType.BLUE in channels:
49+
self._channel_handlers[ChannelType.BLUE] = lambda v: self._handle_rgb_component_update(2, v)
50+
51+
if ChannelType.COLD_WHITE in channels:
52+
self._channel_handlers[ChannelType.COLD_WHITE] = self._handle_cold_white_update
53+
if ChannelType.WARM_WHITE in channels:
54+
self._channel_handlers[ChannelType.WARM_WHITE] = self._handle_warm_white_update
55+
if ChannelType.COLOR_TEMPERATURE in channels:
56+
self._channel_handlers[ChannelType.COLOR_TEMPERATURE] = self.update_color_temp_dmx
57+
58+
# Ensure state consistency after init/restoration
59+
self._validate_and_fix_state()
60+
61+
def _validate_and_fix_state(self):
62+
"""Ensure state fields are consistent with color mode after init/restoration"""
63+
if self.color_mode in [ColorMode.RGB, ColorMode.RGBW, ColorMode.RGBWW]:
64+
# RGB color modes must have valid RGB values
65+
if self.rgb is None:
66+
self.rgb = (255, 255, 255)
67+
log.warning(f"Fixed None RGB value for {self.color_mode} color mode")
68+
if self.last_rgb is None:
69+
self.last_rgb = (255, 255, 255)
70+
else:
71+
# Non-RGB color modes should have RGB as None
72+
if self.rgb is not None:
73+
self.rgb = None
74+
if self.last_rgb is not None:
75+
self.last_rgb = None
4276

4377
def has_channel(self, t: ChannelType) -> bool:
4478
return t in self.channels
@@ -62,6 +96,16 @@ def _handle_dimmer_update(self, value: int):
6296
self.is_on = value > 0
6397

6498
def _handle_rgb_component_update(self, component_index: int, value: int):
99+
# This should only be called for RGB color modes
100+
if self.color_mode not in [ColorMode.RGB, ColorMode.RGBW, ColorMode.RGBWW]:
101+
log.warning(f"RGB component update called for non-RGB color mode {self.color_mode}")
102+
return
103+
104+
# Safety check: ensure RGB is valid (in case of state restoration corruption)
105+
if self.rgb is None:
106+
self.rgb = (255, 255, 255)
107+
log.warning(f"Fixed None RGB during RGB update for {self.color_mode} color mode")
108+
65109
new_rgb = list(self.rgb)
66110
new_rgb[component_index] = value
67111
self._update_rgb_based_on_color_mode(*new_rgb)
@@ -254,7 +298,6 @@ def update_color_temp_kelvin(self, kelvin: int):
254298

255299
def reset(self):
256300
self.brightness = 0
257-
self.rgb = (0, 0, 0)
258301
self.cold_white = 0
259302
self.warm_white = 0
260303
self.is_on = False
@@ -269,7 +312,7 @@ def is_all_zero(self, has_dimmer: bool) -> bool:
269312
def _update_brightness_from_channels(self):
270313
values = []
271314

272-
if self.has_rgb():
315+
if self.has_rgb() and self.rgb is not None:
273316
values.extend(self.rgb)
274317

275318
if self.has_channel(ChannelType.COLD_WHITE):

custom_components/dmx/server/sacn_server.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,17 +295,27 @@ def connection_lost(self, exc: Optional[Exception]):
295295

296296
def datagram_received(self, data: bytes, addr):
297297
try:
298+
log.debug(f"Received {len(data)} bytes from {addr[0]}:{addr[1]}")
298299
packet = SacnPacket.deserialize(data)
299300

300301
if packet.universe in self.subscribed_universes:
301-
log.debug(f"Received sACN data for universe {packet.universe} from {addr}")
302+
log.debug(f"Received sACN data for universe {packet.universe} from {addr[0]} "
303+
f"(source: '{packet.source_name}', seq: {packet.sequence_number}, "
304+
f"priority: {packet.priority}, channels: {len(packet.dmx_data)})")
302305

303306
if self.data_callback:
304307
port_address = PortAddress(0, 0, packet.universe)
305308
self.data_callback(port_address, packet.dmx_data, packet.source_name)
309+
else:
310+
log.warning("No data callback configured for sACN receiver")
311+
else:
312+
log.debug(f"Ignoring sACN data for universe {packet.universe} from {addr[0]} "
313+
f"(not subscribed, subscribed universes: {self.subscribed_universes})")
306314

307315
except Exception as e:
308-
log.debug(f"Error processing sACN packet from {addr}: {e}")
316+
log.warning(f"Error processing sACN packet from {addr[0]}:{addr[1]} "
317+
f"({len(data)} bytes): {e}")
318+
log.debug(f"Raw packet data: {data[:50].hex()}{'...' if len(data) > 50 else ''}")
309319

310320
def subscribe_universe(self, universe_id: int):
311321
if not (1 <= universe_id <= 63999):
@@ -340,10 +350,12 @@ def _manage_multicast_group(self, universe_id: int, join: bool):
340350
action = "Joined" if join else "Left"
341351
sock.setsockopt(socket.IPPROTO_IP, operation, mreq)
342352
log.debug(f"{action} multicast group {multicast_addr} for universe {universe_id}")
353+
else:
354+
log.error(f"No socket available to {'join' if join else 'leave'} multicast group for universe {universe_id}")
343355

344356
except Exception as e:
345357
action = "join" if join else "leave"
346-
log.error(f"Failed to {action} multicast group for universe {universe_id}: {e}")
358+
log.error(f"Failed to {action} multicast group {multicast_addr} for universe {universe_id}: {e}")
347359

348360
def _join_multicast_group(self, universe_id: int):
349361
self._manage_multicast_group(universe_id, True)
@@ -357,11 +369,21 @@ async def create_sacn_receiver(hass: HomeAssistant,
357369
receiver = SacnReceiver(hass, data_callback)
358370

359371
loop = hass.loop
360-
transport, protocol = await loop.create_datagram_endpoint(
361-
lambda: receiver,
362-
local_addr=('0.0.0.0', SACN_PORT),
363-
reuse_address=True
364-
)
365-
366-
log.info("sACN receiver started")
367-
return receiver
372+
try:
373+
transport, protocol = await loop.create_datagram_endpoint(
374+
lambda: receiver,
375+
local_addr=('0.0.0.0', SACN_PORT)
376+
)
377+
378+
# Set socket options for multicast reception
379+
sock = transport.get_extra_info('socket')
380+
if sock:
381+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
382+
log.debug("Set socket reuse option for sACN receiver")
383+
384+
log.info("sACN receiver started successfully")
385+
return receiver
386+
387+
except Exception as e:
388+
log.error(f"Failed to create sACN receiver: {e}")
389+
raise

0 commit comments

Comments
 (0)