Skip to content

Commit d36c1cd

Browse files
committed
fallback to unicast when multicast unavailable
1 parent 1caa458 commit d36c1cd

4 files changed

Lines changed: 231 additions & 62 deletions

File tree

parol6/client/status_subscriber.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,26 @@
1313
logger = logging.getLogger(__name__)
1414

1515

16-
class MulticastProtocol(asyncio.DatagramProtocol):
17-
"""Protocol handler for multicast UDP datagrams that works with uvloop."""
16+
class UDPProtocol(asyncio.DatagramProtocol):
17+
"""Protocol handler for UDP datagrams (multicast or unicast)."""
1818

1919
def __init__(self, queue: asyncio.Queue):
2020
self.queue = queue
2121
self.transport = None
2222
self.receive_count = 0
2323
self.last_log_time = time.time()
2424

25-
# EMA rate tracking for multicast RX
25+
# EMA rate tracking for RX
2626
self._rx_count = 0
2727
self._rx_last_time = time.monotonic()
28-
self._rx_ema_period = 0.05 # Initialize with 20 Hz expected
28+
self._rx_ema_period = 0.05 # Initialize with ~20 Hz expected
2929
self._rx_last_log_time = time.monotonic()
3030

3131
def connection_made(self, transport):
3232
self.transport = transport
3333

3434
def datagram_received(self, data, addr):
35-
# Track multicast RX rate with EMA
35+
# Track RX rate with EMA
3636
now = time.monotonic()
3737
if self._rx_count > 0: # Skip first sample for period calculation
3838
period = now - self._rx_last_time
@@ -45,7 +45,7 @@ def datagram_received(self, data, addr):
4545
# Log rate every 3 seconds
4646
if now - self._rx_last_log_time >= 3.0 and self._rx_ema_period > 0:
4747
rx_hz = 1.0 / self._rx_ema_period
48-
logger.debug(f"Multicast RX: {rx_hz:.1f} Hz (count={self._rx_count})")
48+
logger.debug(f"Status RX: {rx_hz:.1f} Hz (count={self._rx_count})")
4949
self._rx_last_log_time = now
5050

5151
try:
@@ -55,7 +55,7 @@ def datagram_received(self, data, addr):
5555
try:
5656
self.queue.get_nowait()
5757
self.queue.put_nowait((data, addr))
58-
except:
58+
except Exception:
5959
pass
6060

6161
def error_received(self, exc):
@@ -115,19 +115,35 @@ def _detect_primary_ip() -> str:
115115
return sock
116116

117117

118+
def _create_unicast_socket(port: int, host: str) -> socket.socket:
119+
"""Create and configure a plain UDP socket for unicast reception.
120+
121+
Binds to the provided host (default 127.0.0.1) and port with large RCVBUF.
122+
"""
123+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
124+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
125+
try:
126+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
127+
except Exception:
128+
pass
129+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
130+
try:
131+
sock.bind((host, port))
132+
except OSError:
133+
# Fallback to wildcard
134+
sock.bind(("", port))
135+
sock.setblocking(False)
136+
return sock
137+
138+
118139
async def subscribe_status(
119140
group: str | None = None, port: int | None = None, iface_ip: str | None = None
120141
) -> AsyncIterator[StatusAggregate]:
121142
"""
122-
Async generator that yields decoded STATUS dicts from the UDP multicast broadcaster.
143+
Async generator that yields decoded STATUS dicts from the UDP broadcaster.
123144
124145
Uses create_datagram_endpoint for uvloop compatibility.
125146
126-
Usage:
127-
async for status in subscribe_status():
128-
# status is a dict with keys pose, angles, io, gripper (or None on parse failure)
129-
...
130-
131147
Notes:
132148
- Uses loopback multicast by default (cfg.MCAST_* values).
133149
- Yields only messages that decode successfully via decode_status; otherwise skips.
@@ -136,19 +152,25 @@ async def subscribe_status(
136152
port = port or cfg.MCAST_PORT
137153
iface_ip = iface_ip or cfg.MCAST_IF
138154

139-
logger.info(f"subscribe_status starting: group={group}, port={port}, iface_ip={iface_ip}")
155+
logger.info(
156+
f"subscribe_status starting: transport={cfg.STATUS_TRANSPORT} group={group}, port={port}, iface_ip={iface_ip}"
157+
)
140158

141159
loop = asyncio.get_running_loop()
142160
queue = asyncio.Queue(maxsize=100) # type: ignore
143161

144-
# Create the socket with multicast configuration
145-
sock = _create_multicast_socket(group, port, iface_ip)
162+
# Create the socket based on configured transport
163+
if cfg.STATUS_TRANSPORT == "UNICAST":
164+
sock = _create_unicast_socket(port, cfg.STATUS_UNICAST_HOST)
165+
else:
166+
# Multicast socket bound to ("", port) will also receive unicast datagrams to that port
167+
sock = _create_multicast_socket(group, port, iface_ip)
146168

147169
# Create the datagram endpoint with our protocol
148170
transport = None
149171
try:
150172
transport, _ = await loop.create_datagram_endpoint(
151-
lambda: MulticastProtocol(queue), sock=sock
173+
lambda: UDPProtocol(queue), sock=sock
152174
)
153175

154176
while True:
@@ -162,7 +184,9 @@ async def subscribe_status(
162184
yield parsed
163185

164186
except TimeoutError:
165-
logger.warning(f"No multicast received for 2s on {group}:{port} (iface={iface_ip})")
187+
logger.warning(
188+
f"No status received for 2s on {('unicast' if cfg.STATUS_TRANSPORT=='UNICAST' else 'multicast')} {group}:{port} (iface={iface_ip})"
189+
)
166190
continue
167191

168192
except asyncio.CancelledError:
@@ -178,5 +202,5 @@ async def subscribe_status(
178202
transport.close()
179203
try:
180204
sock.close()
181-
except:
205+
except Exception:
182206
pass

parol6/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ def _trace(self, msg, *args, **kwargs):
5757
MCAST_TTL: int = int(os.getenv("PAROL6_MCAST_TTL", "1"))
5858
MCAST_IF: str = os.getenv("PAROL6_MCAST_IF", "127.0.0.1")
5959

60+
# Transport selection for status updates. Default MULTICAST; set to UNICAST on CI if multicast is not available.
61+
STATUS_TRANSPORT: str = os.getenv("PAROL6_STATUS_TRANSPORT", "MULTICAST").strip().upper()
62+
# Host to use for unicast fallback (defaults to loopback)
63+
STATUS_UNICAST_HOST: str = os.getenv("PAROL6_STATUS_UNICAST_HOST", "127.0.0.1")
64+
6065
# Status update/broadcast rates
6166
STATUS_RATE_HZ: float = float(os.getenv("PAROL6_STATUS_RATE_HZ", "50"))
6267
STATUS_STALE_S: float = float(os.getenv("PAROL6_STATUS_STALE_S", "0.2"))

parol6/server/status_broadcast.py

Lines changed: 104 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,21 @@
1414

1515
class 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

Comments
 (0)