1414
1515class StatusBroadcaster (threading .Thread ):
1616 """
17- Broadcasts ASCII STATUS frames via UDP multicast .
17+ Broadcasts ASCII STATUS frames via UDP.
1818
19- Config:
19+ Transport:
20+ - cfg.STATUS_TRANSPORT: "MULTICAST" (default) or "UNICAST"
21+
22+ Multicast Config (used when STATUS_TRANSPORT == MULTICAST):
2023 - cfg.MCAST_GROUP (default "239.255.0.101")
21- - cfg.MCAST_PORT (default 50510)
22- - cfg.MCAST_TTL (default 1)
23- - cfg.MCAST_IF (default "127.0.0.1")
24+ - cfg.MCAST_PORT (default 50510)
25+ - cfg.MCAST_TTL (default 1)
26+ - cfg.MCAST_IF (default "127.0.0.1")
27+
28+ Unicast Config (used when STATUS_TRANSPORT == UNICAST):
29+ - cfg.STATUS_UNICAST_HOST (default "127.0.0.1")
30+
31+ General:
2432 - cfg.STATUS_RATE_HZ (default 50)
2533 - cfg.STATUS_STALE_S (default 0.2) -> skip broadcast if cache is stale
2634 """
@@ -44,56 +52,103 @@ def __init__(
4452 self ._period = 1.0 / max (rate_hz , 1.0 )
4553 self ._stale_s = stale_s
4654
55+ # Negotiated transport (can be forced via env or auto-fallback at runtime)
56+ self ._use_unicast : bool = (cfg .STATUS_TRANSPORT == "UNICAST" )
57+
4758 self ._sock : socket .socket | None = None
4859 self ._running = threading .Event ()
4960 self ._running .set ()
5061
51- # EMA rate tracking for multicast TX
62+ # EMA rate tracking for TX
5263 self ._tx_count = 0
5364 self ._tx_last_time = time .monotonic ()
5465 self ._tx_ema_period = 1.0 / rate_hz # Initialize with expected period
5566 self ._tx_last_log_time = time .monotonic () # For 3-second logging interval
5667
68+ def _detect_primary_ip (self ) -> str :
69+ tmp = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
70+ try :
71+ tmp .connect (("1.1.1.1" , 80 ))
72+ return tmp .getsockname ()[0 ]
73+ except Exception :
74+ return "127.0.0.1"
75+ finally :
76+ try :
77+ tmp .close ()
78+ except Exception :
79+ pass
80+
5781 def _setup_socket (self ) -> None :
82+ # UNICAST: simple UDP socket without multicast options
83+ if self ._use_unicast :
84+ sock = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
85+ sock .setsockopt (socket .SOL_SOCKET , socket .SO_SNDBUF , 1 << 20 )
86+ self ._sock = sock
87+ logger .info (
88+ f"StatusBroadcaster (UNICAST) -> dest={ cfg .STATUS_UNICAST_HOST } :{ self .port } "
89+ )
90+ return
91+
92+ # MULTICAST: configure multicast TTL/IF with verification and fallback
5893 sock = socket .socket (socket .AF_INET , socket .SOCK_DGRAM , socket .IPPROTO_UDP )
5994 sock .setsockopt (socket .IPPROTO_IP , socket .IP_MULTICAST_TTL , self .ttl )
6095 sock .setsockopt (socket .IPPROTO_IP , socket .IP_MULTICAST_LOOP , 1 )
6196
62- # Prefer loopback interface for multicast; if that fails, fall back to primary NIC IP
63- def _detect_primary_ip () -> str :
64- tmp = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
65- try :
66- tmp .connect (("1.1.1.1" , 80 ))
67- return tmp .getsockname ()[0 ]
68- except Exception :
69- return "127.0.0.1"
70- finally :
71- try :
72- tmp .close ()
73- except Exception :
74- pass
75-
7697 try :
7798 sock .setsockopt (
7899 socket .IPPROTO_IP , socket .IP_MULTICAST_IF , socket .inet_aton (self .iface_ip )
79100 )
80- except Exception :
101+ # Verify interface actually routes by sending a tiny dummy datagram
81102 try :
82- primary_ip = _detect_primary_ip ()
103+ sock .sendto (b"\0 " , (self .group , self .port ))
104+ except OSError as e :
105+ raise RuntimeError (
106+ f"Initial multicast send failed on iface { self .iface_ip } : { e } "
107+ )
108+ except Exception as e :
109+ logger .warning (
110+ f"StatusBroadcaster: interface { self .iface_ip } failed verification: { e } "
111+ )
112+ try :
113+ primary_ip = self ._detect_primary_ip ()
83114 sock .setsockopt (
84115 socket .IPPROTO_IP , socket .IP_MULTICAST_IF , socket .inet_aton (primary_ip )
85116 )
86117 logger .info (f"StatusBroadcaster: fallback IP_MULTICAST_IF to { primary_ip } " )
87- except Exception as e :
88- logger .warning (f"StatusBroadcaster: failed to set IP_MULTICAST_IF: { e } " )
118+ # Verify fallback
119+ sock .sendto (b"\0 " , (self .group , self .port ))
120+ except Exception as e2 :
121+ logger .warning (f"StatusBroadcaster: failed to set IP_MULTICAST_IF: { e2 } " )
122+ # As a last resort, switch to UNICAST
123+ try :
124+ sock .close ()
125+ except Exception :
126+ pass
127+ self ._use_unicast = True
128+ usock = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
129+ usock .setsockopt (socket .SOL_SOCKET , socket .SO_SNDBUF , 1 << 20 )
130+ self ._sock = usock
131+ logger .info (
132+ f"StatusBroadcaster (UNICAST-FALLBACK) -> dest={ cfg .STATUS_UNICAST_HOST } :{ self .port } "
133+ )
134+ return
89135
90136 sock .setsockopt (socket .SOL_SOCKET , socket .SO_SNDBUF , 1 << 20 )
91137 self ._sock = sock
138+ logger .info (
139+ f"StatusBroadcaster (MULTICAST) -> group={ self .group } port={ self .port } iface={ self .iface_ip } ttl={ self .ttl } "
140+ )
92141
93142 def run (self ) -> None :
94143 self ._setup_socket ()
95144 cache = get_cache ()
96- dest = (self .group , self .port )
145+
146+ # Destination based on negotiated transport
147+ if self ._use_unicast :
148+ dest = (cfg .STATUS_UNICAST_HOST , self .port )
149+ else :
150+ dest = (self .group , self .port )
151+
97152 sock = self ._sock
98153 if sock is None :
99154 logger .error ("StatusBroadcaster socket not initialized" )
@@ -113,24 +168,30 @@ def run(self) -> None:
113168 # Skip broadcast if cache is stale (e.g., serial disconnected)
114169 if cache .age_s () <= self ._stale_s :
115170 payload = cache .to_ascii ().encode ("ascii" , errors = "ignore" )
116- # memoryview avoids an extra copy in some implementations
117- sock .sendto (memoryview (payload ), dest )
118-
119- # Track multicast TX rate with EMA
120- now = time .monotonic ()
121- if self ._tx_count > 0 : # Skip first sample for period calculation
122- period = now - self ._tx_last_time
123- if period > 0 :
124- # EMA update: 0.1 * new + 0.9 * old
125- self ._tx_ema_period = 0.1 * period + 0.9 * self ._tx_ema_period
126- self ._tx_last_time = now
127- self ._tx_count += 1
128-
129- # Log rate every 3 seconds
130- if now - self ._tx_last_log_time >= 3.0 and self ._tx_ema_period > 0 :
131- tx_hz = 1.0 / self ._tx_ema_period
132- logger .debug (f"Multicast TX: { tx_hz :.1f} Hz (count={ self ._tx_count } )" )
133- self ._tx_last_log_time = now
171+ try :
172+ sock .sendto (memoryview (payload ), dest )
173+ except OSError as e :
174+ # Log occasionally to avoid flooding
175+ if time .monotonic () - self ._tx_last_log_time >= 5.0 :
176+ logger .warning (f"StatusBroadcaster send failed: { e } " )
177+ self ._tx_last_log_time = time .monotonic ()
178+ # Do not stop thread; continue trying
179+ else :
180+ # Track TX rate with EMA
181+ now = time .monotonic ()
182+ if self ._tx_count > 0 : # Skip first sample for period calculation
183+ period = now - self ._tx_last_time
184+ if period > 0 :
185+ # EMA update: 0.1 * new + 0.9 * old
186+ self ._tx_ema_period = 0.1 * period + 0.9 * self ._tx_ema_period
187+ self ._tx_last_time = now
188+ self ._tx_count += 1
189+
190+ # Log rate every 3 seconds
191+ if now - self ._tx_last_log_time >= 3.0 and self ._tx_ema_period > 0 :
192+ tx_hz = 1.0 / self ._tx_ema_period
193+ logger .debug (f"Status TX: { tx_hz :.1f} Hz (count={ self ._tx_count } )" )
194+ self ._tx_last_log_time = now
134195
135196 # Sleep until next deadline (compensates for work time)
136197 sleep_time = next_deadline - time .monotonic ()
0 commit comments