"""UDS client connection — Python mirror of src/uds_server.h client side.
Length-prefixed messages: [4-byte LE length] [payload].
Non-blocking receive for use with Blender's timer callbacks.
"""
import select
import socket
import struct
class BridgeConnection:
"""Unix Domain Socket client for BlenderBridge signaling."""
def __init__(self, socket_path: str = "/tmp/blender_bridge.sock"):
self._sock = None
self._path = socket_path
self._recv_buf = bytearray()
self._disconnected_unexpectedly = False
# ── Lifecycle ────────────────────────────────────────────────────────
def connect(self) -> bool:
"""Connect to the Godot-side UDS server."""
try:
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._sock.connect(self._path)
self._sock.setblocking(False)
self._disconnected_unexpectedly = False
# Send handshake
self.send_message(b"hello")
return True
except OSError:
self._sock = None
return False
def close(self):
"""Intentional close — no auto-reconnect."""
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._recv_buf.clear()
self._disconnected_unexpectedly = False
def _close_unexpected(self):
"""Unexpected disconnect (EOF/error) — triggers auto-reconnect."""
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._recv_buf.clear()
self._disconnected_unexpectedly = True
@property
def connected(self) -> bool:
return self._sock is not None
@property
def should_reconnect(self) -> bool:
"""True if disconnected unexpectedly and should attempt reconnect."""
return self._disconnected_unexpectedly and self._sock is None
def clear_reconnect(self):
"""Clear the reconnect flag (e.g. after successful reconnect)."""
self._disconnected_unexpectedly = False
# ── Send ─────────────────────────────────────────────────────────────
def send_message(self, data: bytes):
"""Send a length-prefixed message.
Handles BlockingIOError (send buffer full) by waiting for writability
instead of treating it as a fatal disconnect. This is critical when
streaming many tile_ready messages — the non-blocking socket's send
buffer can fill up, and sendall() would raise BlockingIOError.
"""
if self._sock is None:
return
frame = struct.pack("<I", len(data)) + data
sent = 0
while sent < len(frame):
try:
n = self._sock.send(frame[sent:])
if n == 0:
self._close_unexpected()
return
sent += n
except BlockingIOError:
# Send buffer full — wait up to 5s for writability
_, writable, _ = select.select([], [self._sock], [], 5.0)
if not writable:
self._close_unexpected()
return
except OSError:
self._close_unexpected()
return
# ── Receive (non-blocking) ───────────────────────────────────────────
def poll_messages(self) -> list[bytes]:
"""Read all available messages (non-blocking). Returns list of payloads."""
if self._sock is None:
return []
# Read available data
try:
while True:
chunk = self._sock.recv(4096)
if not chunk:
# Server closed
self._close_unexpected()
return []
self._recv_buf.extend(chunk)
except BlockingIOError:
pass # No more data available
except OSError:
self._close_unexpected()
return []
# Parse complete messages
messages = []
while len(self._recv_buf) >= 4:
msg_len = struct.unpack_from("<I", self._recv_buf, 0)[0]
if msg_len > 16 * 1024 * 1024: # 16 MB sanity limit
self._close_unexpected()
return messages
if len(self._recv_buf) < 4 + msg_len:
break # Incomplete
messages.append(bytes(self._recv_buf[4 : 4 + msg_len]))
del self._recv_buf[: 4 + msg_len]
return messages