host.match_monitor.connector

Host-side TCP connector that polls for the roboRIO and maintains a control channel.

  1"""Host-side TCP connector that polls for the roboRIO and maintains a control channel."""
  2
  3import json
  4import logging
  5import socket
  6import threading
  7import time
  8from typing import Optional
  9
 10logger = logging.getLogger("match_monitor")
 11
 12CONTROL_PORT = 5805
 13ROBOT_ADDRESSES = ['roboRIO-3200-FRC.local', '10.32.0.2', '172.22.11.2']
 14POLL_INTERVAL = 10  # seconds between connection attempts
 15KEEPALIVE_INTERVAL = 10  # seconds between PINGs
 16KEEPALIVE_TIMEOUT = 30  # seconds to wait for PONG
 17
 18
 19class RobotConnector:
 20    """Polls for the roboRIO, opens a TCP control channel, and handles keepalive.
 21
 22    When the robot sends UPLOAD_COMPLETE, triggers .wpilog analysis via the
 23    provided callback.
 24    """
 25
 26    def __init__(self, http_port: int, on_upload_complete=None) -> None:
 27        self._http_port = http_port
 28        self._on_upload_complete = on_upload_complete
 29        self._thread: Optional[threading.Thread] = None
 30        self._stop_event = threading.Event()
 31        self._paused = threading.Event()  # set = paused (don't poll)
 32        self._sock: Optional[socket.socket] = None
 33        self._sock_lock = threading.Lock()
 34        self._robot_addr: Optional[str] = None
 35        self._connect_attempts = 0
 36        self._connected_since: Optional[float] = None
 37        self._last_attempt_time: Optional[float] = None
 38        self._last_attempt_addr: Optional[str] = None
 39
 40    @property
 41    def is_connected(self) -> bool:
 42        return self._sock is not None
 43
 44    @property
 45    def robot_address(self) -> Optional[str]:
 46        return self._robot_addr
 47
 48    @property
 49    def status_info(self) -> dict:
 50        """Return a dict of connection status details."""
 51        if self._paused.is_set():
 52            state = 'paused'
 53        elif self._sock is not None:
 54            state = 'connected'
 55        else:
 56            state = 'polling'
 57        info = {
 58            'state': state,
 59            'robot_address': self._robot_addr,
 60            'http_port': self._http_port,
 61            'connect_attempts': self._connect_attempts,
 62            'poll_addresses': ROBOT_ADDRESSES,
 63            'last_attempt_addr': self._last_attempt_addr,
 64            'last_attempt_time': self._last_attempt_time,
 65            'connected_since': self._connected_since,
 66        }
 67        return info
 68
 69    def start(self) -> None:
 70        """Start the connector thread."""
 71        if self._thread is not None and self._thread.is_alive():
 72            return
 73        self._stop_event.clear()
 74        self._paused.clear()
 75        self._thread = threading.Thread(target=self._poll_loop, daemon=True)
 76        self._thread.start()
 77
 78    def stop(self) -> None:
 79        self._stop_event.set()
 80
 81    def connect(self) -> None:
 82        """Resume polling / reconnect immediately."""
 83        self._paused.clear()
 84        # If thread died or was never started, restart it
 85        if self._thread is None or not self._thread.is_alive():
 86            self.start()
 87        logger.info("Connection polling resumed")
 88
 89    def disconnect(self) -> None:
 90        """Close current connection and stop polling."""
 91        self._paused.set()
 92        with self._sock_lock:
 93            if self._sock is not None:
 94                try:
 95                    self._sock.close()
 96                except Exception:
 97                    logger.debug("Error closing socket during disconnect", exc_info=True)
 98                self._sock = None
 99                self._robot_addr = None
100        logger.info("Disconnected, polling paused")
101
102    def send_to_robot(self, msg: dict) -> bool:
103        """Send a JSON message to the connected robot. Returns False if not connected."""
104        with self._sock_lock:
105            if self._sock is None:
106                return False
107            try:
108                data = json.dumps(msg) + '\n'
109                self._sock.sendall(data.encode('utf-8'))
110                return True
111            except Exception:
112                logger.exception("Failed to send message to robot")
113                return False
114
115    def _poll_loop(self) -> None:
116        """Poll for the roboRIO and connect when found."""
117        logger.info(f"Polling for roboRIO on port {CONTROL_PORT} "
118                    f"(addresses: {', '.join(ROBOT_ADDRESSES)})")
119
120        while not self._stop_event.is_set():
121            # If paused, wait until unpaused or stopped
122            while self._paused.is_set() and not self._stop_event.is_set():
123                self._stop_event.wait(1)
124
125            if self._stop_event.is_set():
126                return
127
128            for addr in ROBOT_ADDRESSES:
129                if self._stop_event.is_set() or self._paused.is_set():
130                    break
131                try:
132                    self._connect_attempts += 1
133                    self._last_attempt_addr = addr
134                    self._last_attempt_time = time.monotonic()
135                    # Resolve address to support both IPv4 and IPv6 (mDNS may return IPv6)
136                    addrinfo = socket.getaddrinfo(addr, CONTROL_PORT, socket.AF_UNSPEC, socket.SOCK_STREAM)
137                    if not addrinfo:
138                        raise OSError(f"Could not resolve {addr}")
139                    af, socktype, proto, canonname, sockaddr = addrinfo[0]
140                    sock = socket.socket(af, socktype, proto)
141                    sock.settimeout(3)
142                    sock.connect(sockaddr)
143                    logger.info(f"Connected to robot at {addr}:{CONTROL_PORT}")
144                    self._handle_connection(sock, addr)
145                    logger.info(f"Disconnected from robot at {addr}, resuming polling")
146                except (socket.timeout, ConnectionRefusedError, OSError) as e:
147                    logger.debug(f"Cannot reach {addr}:{CONTROL_PORT} — {e}")
148                except Exception:
149                    logger.exception(f"Error connecting to {addr}")
150
151            # Wait before next poll cycle
152            self._stop_event.wait(POLL_INTERVAL)
153
154    def _handle_connection(self, sock: socket.socket, robot_addr: str) -> None:
155        """Run handshake and keepalive loop on an established connection."""
156        with self._sock_lock:
157            self._sock = sock
158            self._robot_addr = robot_addr
159        self._connected_since = time.monotonic()
160
161        try:
162            # Send HELLO
163            hello = json.dumps({
164                'type': 'HELLO',
165                'http_port': self._http_port,
166            }) + '\n'
167            sock.sendall(hello.encode('utf-8'))
168
169            # Wait for HELLO_ACK
170            sock.settimeout(10)
171            buf = ''
172            buf = self._read_message(sock, buf)
173            if buf is None:
174                return
175            msg, buf = self._parse_next_message(buf)
176            if msg is None or msg.get('type') != 'HELLO_ACK':
177                logger.warning(f"Expected HELLO_ACK, got: {msg}")
178                return
179
180            logger.info(f"Handshake complete with {robot_addr}")
181
182            # Keepalive + message loop
183            last_ping = time.monotonic()
184            sock.settimeout(1.0)  # short timeout for non-blocking reads
185
186            while not self._stop_event.is_set() and not self._paused.is_set():
187                now = time.monotonic()
188
189                # Send PING if interval elapsed
190                if now - last_ping >= KEEPALIVE_INTERVAL:
191                    ping = json.dumps({'type': 'PING'}) + '\n'
192                    sock.sendall(ping.encode('utf-8'))
193                    last_ping = now
194
195                # Try to read messages (non-blocking-ish with 1s timeout)
196                try:
197                    buf = self._read_message(sock, buf)
198                    if buf is None:
199                        # Connection closed
200                        return
201                    while '\n' in buf:
202                        msg, buf = self._parse_next_message(buf)
203                        if msg is None:
204                            continue
205                        self._handle_robot_message(msg, robot_addr)
206                except socket.timeout:
207                    # No data available, check if we've exceeded keepalive timeout
208                    continue
209
210        except (ConnectionResetError, BrokenPipeError, OSError):
211            logger.info(f"Connection to {robot_addr} lost")
212        except Exception:
213            logger.exception(f"Error in connection to {robot_addr}")
214        finally:
215            self._connected_since = None
216            with self._sock_lock:
217                self._sock = None
218                self._robot_addr = None
219            try:
220                sock.close()
221            except Exception:
222                logger.debug("Error closing socket after connection", exc_info=True)
223
224    def _handle_robot_message(self, msg: dict, robot_addr: str) -> None:
225        """Process a message received from the robot."""
226        msg_type = msg.get('type', '')
227
228        if msg_type == 'PONG':
229            pass  # keepalive response, all good
230
231        elif msg_type == 'FILE_SKIPPED':
232            filename = msg.get('filename', '?')
233            reason = msg.get('reason', 'unknown')
234            logger.info(f"Robot skipped {filename}: {reason}")
235
236        elif msg_type == 'UPLOAD_STARTING':
237            event = msg.get('event_name', '')
238            mtype = msg.get('match_type', '')
239            mnum = msg.get('match_number', '')
240            logger.info(f"Robot starting upload: {event} {mtype}_{mnum}")
241            print(f"  Robot starting upload: {event} {mtype}_{mnum}")
242
243        elif msg_type == 'UPLOAD_COMPLETE':
244            event = msg.get('event_name', '')
245            mtype = msg.get('match_type', '')
246            mnum = msg.get('match_number', '')
247            logger.info(f"Robot upload complete: {event} {mtype}_{mnum}")
248            print(f"  Robot upload complete: {event} {mtype}_{mnum}")
249
250            if self._on_upload_complete:
251                metadata = {
252                    'event_name': event,
253                    'match_type': mtype,
254                    'match_number': mnum,
255                }
256                try:
257                    self._on_upload_complete(metadata)
258                except Exception:
259                    logger.exception("on_upload_complete callback failed")
260
261        elif msg_type == 'MANIFEST_CLEARED':
262            count = msg.get('count', 0)
263            print(f"  Cleared {count} manifest(s) on robot")
264
265        elif msg_type == 'FORCE_UPLOAD_ACK':
266            print("  Robot upload started")
267
268        elif msg_type == 'STOP_UPLOAD_ACK':
269            print("  Robot upload stopped")
270
271        elif msg_type == 'LIST_LOGS_RESPONSE':
272            files = msg.get('files', [])
273            if not files:
274                print("  No log files on robot")
275            else:
276                print(f"  {'File':<45} {'Size':>10}  Uploaded")
277                print(f"  {'-' * 45} {'-' * 10}  {'-' * 8}")
278                for f in files:
279                    name = f.get('name', '?')
280                    size = f.get('size', 0)
281                    uploaded = 'yes' if f.get('uploaded') else 'no'
282                    if size >= 1024 * 1024:
283                        size_str = f"{size / (1024 * 1024):.1f} MB"
284                    elif size >= 1024:
285                        size_str = f"{size / 1024:.1f} KB"
286                    else:
287                        size_str = f"{size} B"
288                    print(f"  {name:<45} {size_str:>10}  {uploaded}")
289                print(f"  {len(files)} file(s)")
290
291        else:
292            logger.debug(f"Unknown message from robot: {msg}")
293
294    @staticmethod
295    def _read_message(sock: socket.socket, buf: str) -> Optional[str]:
296        """Read data until we have at least one newline. Returns None on disconnect."""
297        while '\n' not in buf:
298            data = sock.recv(4096)
299            if not data:
300                return None
301            buf += data.decode('utf-8')
302        return buf
303
304    @staticmethod
305    def _parse_next_message(buf: str) -> tuple:
306        """Extract the next JSON message from the buffer."""
307        idx = buf.index('\n')
308        line = buf[:idx].strip()
309        remaining = buf[idx + 1:]
310        if not line:
311            return None, remaining
312        try:
313            return json.loads(line), remaining
314        except json.JSONDecodeError:
315            logger.warning(f"Invalid JSON from robot: {line!r}")
316            return None, remaining
logger = <Logger match_monitor (DEBUG)>
CONTROL_PORT = 5805
ROBOT_ADDRESSES = ['roboRIO-3200-FRC.local', '10.32.0.2', '172.22.11.2']
POLL_INTERVAL = 10
KEEPALIVE_INTERVAL = 10
KEEPALIVE_TIMEOUT = 30
class RobotConnector:
 20class RobotConnector:
 21    """Polls for the roboRIO, opens a TCP control channel, and handles keepalive.
 22
 23    When the robot sends UPLOAD_COMPLETE, triggers .wpilog analysis via the
 24    provided callback.
 25    """
 26
 27    def __init__(self, http_port: int, on_upload_complete=None) -> None:
 28        self._http_port = http_port
 29        self._on_upload_complete = on_upload_complete
 30        self._thread: Optional[threading.Thread] = None
 31        self._stop_event = threading.Event()
 32        self._paused = threading.Event()  # set = paused (don't poll)
 33        self._sock: Optional[socket.socket] = None
 34        self._sock_lock = threading.Lock()
 35        self._robot_addr: Optional[str] = None
 36        self._connect_attempts = 0
 37        self._connected_since: Optional[float] = None
 38        self._last_attempt_time: Optional[float] = None
 39        self._last_attempt_addr: Optional[str] = None
 40
 41    @property
 42    def is_connected(self) -> bool:
 43        return self._sock is not None
 44
 45    @property
 46    def robot_address(self) -> Optional[str]:
 47        return self._robot_addr
 48
 49    @property
 50    def status_info(self) -> dict:
 51        """Return a dict of connection status details."""
 52        if self._paused.is_set():
 53            state = 'paused'
 54        elif self._sock is not None:
 55            state = 'connected'
 56        else:
 57            state = 'polling'
 58        info = {
 59            'state': state,
 60            'robot_address': self._robot_addr,
 61            'http_port': self._http_port,
 62            'connect_attempts': self._connect_attempts,
 63            'poll_addresses': ROBOT_ADDRESSES,
 64            'last_attempt_addr': self._last_attempt_addr,
 65            'last_attempt_time': self._last_attempt_time,
 66            'connected_since': self._connected_since,
 67        }
 68        return info
 69
 70    def start(self) -> None:
 71        """Start the connector thread."""
 72        if self._thread is not None and self._thread.is_alive():
 73            return
 74        self._stop_event.clear()
 75        self._paused.clear()
 76        self._thread = threading.Thread(target=self._poll_loop, daemon=True)
 77        self._thread.start()
 78
 79    def stop(self) -> None:
 80        self._stop_event.set()
 81
 82    def connect(self) -> None:
 83        """Resume polling / reconnect immediately."""
 84        self._paused.clear()
 85        # If thread died or was never started, restart it
 86        if self._thread is None or not self._thread.is_alive():
 87            self.start()
 88        logger.info("Connection polling resumed")
 89
 90    def disconnect(self) -> None:
 91        """Close current connection and stop polling."""
 92        self._paused.set()
 93        with self._sock_lock:
 94            if self._sock is not None:
 95                try:
 96                    self._sock.close()
 97                except Exception:
 98                    logger.debug("Error closing socket during disconnect", exc_info=True)
 99                self._sock = None
100                self._robot_addr = None
101        logger.info("Disconnected, polling paused")
102
103    def send_to_robot(self, msg: dict) -> bool:
104        """Send a JSON message to the connected robot. Returns False if not connected."""
105        with self._sock_lock:
106            if self._sock is None:
107                return False
108            try:
109                data = json.dumps(msg) + '\n'
110                self._sock.sendall(data.encode('utf-8'))
111                return True
112            except Exception:
113                logger.exception("Failed to send message to robot")
114                return False
115
116    def _poll_loop(self) -> None:
117        """Poll for the roboRIO and connect when found."""
118        logger.info(f"Polling for roboRIO on port {CONTROL_PORT} "
119                    f"(addresses: {', '.join(ROBOT_ADDRESSES)})")
120
121        while not self._stop_event.is_set():
122            # If paused, wait until unpaused or stopped
123            while self._paused.is_set() and not self._stop_event.is_set():
124                self._stop_event.wait(1)
125
126            if self._stop_event.is_set():
127                return
128
129            for addr in ROBOT_ADDRESSES:
130                if self._stop_event.is_set() or self._paused.is_set():
131                    break
132                try:
133                    self._connect_attempts += 1
134                    self._last_attempt_addr = addr
135                    self._last_attempt_time = time.monotonic()
136                    # Resolve address to support both IPv4 and IPv6 (mDNS may return IPv6)
137                    addrinfo = socket.getaddrinfo(addr, CONTROL_PORT, socket.AF_UNSPEC, socket.SOCK_STREAM)
138                    if not addrinfo:
139                        raise OSError(f"Could not resolve {addr}")
140                    af, socktype, proto, canonname, sockaddr = addrinfo[0]
141                    sock = socket.socket(af, socktype, proto)
142                    sock.settimeout(3)
143                    sock.connect(sockaddr)
144                    logger.info(f"Connected to robot at {addr}:{CONTROL_PORT}")
145                    self._handle_connection(sock, addr)
146                    logger.info(f"Disconnected from robot at {addr}, resuming polling")
147                except (socket.timeout, ConnectionRefusedError, OSError) as e:
148                    logger.debug(f"Cannot reach {addr}:{CONTROL_PORT} — {e}")
149                except Exception:
150                    logger.exception(f"Error connecting to {addr}")
151
152            # Wait before next poll cycle
153            self._stop_event.wait(POLL_INTERVAL)
154
155    def _handle_connection(self, sock: socket.socket, robot_addr: str) -> None:
156        """Run handshake and keepalive loop on an established connection."""
157        with self._sock_lock:
158            self._sock = sock
159            self._robot_addr = robot_addr
160        self._connected_since = time.monotonic()
161
162        try:
163            # Send HELLO
164            hello = json.dumps({
165                'type': 'HELLO',
166                'http_port': self._http_port,
167            }) + '\n'
168            sock.sendall(hello.encode('utf-8'))
169
170            # Wait for HELLO_ACK
171            sock.settimeout(10)
172            buf = ''
173            buf = self._read_message(sock, buf)
174            if buf is None:
175                return
176            msg, buf = self._parse_next_message(buf)
177            if msg is None or msg.get('type') != 'HELLO_ACK':
178                logger.warning(f"Expected HELLO_ACK, got: {msg}")
179                return
180
181            logger.info(f"Handshake complete with {robot_addr}")
182
183            # Keepalive + message loop
184            last_ping = time.monotonic()
185            sock.settimeout(1.0)  # short timeout for non-blocking reads
186
187            while not self._stop_event.is_set() and not self._paused.is_set():
188                now = time.monotonic()
189
190                # Send PING if interval elapsed
191                if now - last_ping >= KEEPALIVE_INTERVAL:
192                    ping = json.dumps({'type': 'PING'}) + '\n'
193                    sock.sendall(ping.encode('utf-8'))
194                    last_ping = now
195
196                # Try to read messages (non-blocking-ish with 1s timeout)
197                try:
198                    buf = self._read_message(sock, buf)
199                    if buf is None:
200                        # Connection closed
201                        return
202                    while '\n' in buf:
203                        msg, buf = self._parse_next_message(buf)
204                        if msg is None:
205                            continue
206                        self._handle_robot_message(msg, robot_addr)
207                except socket.timeout:
208                    # No data available, check if we've exceeded keepalive timeout
209                    continue
210
211        except (ConnectionResetError, BrokenPipeError, OSError):
212            logger.info(f"Connection to {robot_addr} lost")
213        except Exception:
214            logger.exception(f"Error in connection to {robot_addr}")
215        finally:
216            self._connected_since = None
217            with self._sock_lock:
218                self._sock = None
219                self._robot_addr = None
220            try:
221                sock.close()
222            except Exception:
223                logger.debug("Error closing socket after connection", exc_info=True)
224
225    def _handle_robot_message(self, msg: dict, robot_addr: str) -> None:
226        """Process a message received from the robot."""
227        msg_type = msg.get('type', '')
228
229        if msg_type == 'PONG':
230            pass  # keepalive response, all good
231
232        elif msg_type == 'FILE_SKIPPED':
233            filename = msg.get('filename', '?')
234            reason = msg.get('reason', 'unknown')
235            logger.info(f"Robot skipped {filename}: {reason}")
236
237        elif msg_type == 'UPLOAD_STARTING':
238            event = msg.get('event_name', '')
239            mtype = msg.get('match_type', '')
240            mnum = msg.get('match_number', '')
241            logger.info(f"Robot starting upload: {event} {mtype}_{mnum}")
242            print(f"  Robot starting upload: {event} {mtype}_{mnum}")
243
244        elif msg_type == 'UPLOAD_COMPLETE':
245            event = msg.get('event_name', '')
246            mtype = msg.get('match_type', '')
247            mnum = msg.get('match_number', '')
248            logger.info(f"Robot upload complete: {event} {mtype}_{mnum}")
249            print(f"  Robot upload complete: {event} {mtype}_{mnum}")
250
251            if self._on_upload_complete:
252                metadata = {
253                    'event_name': event,
254                    'match_type': mtype,
255                    'match_number': mnum,
256                }
257                try:
258                    self._on_upload_complete(metadata)
259                except Exception:
260                    logger.exception("on_upload_complete callback failed")
261
262        elif msg_type == 'MANIFEST_CLEARED':
263            count = msg.get('count', 0)
264            print(f"  Cleared {count} manifest(s) on robot")
265
266        elif msg_type == 'FORCE_UPLOAD_ACK':
267            print("  Robot upload started")
268
269        elif msg_type == 'STOP_UPLOAD_ACK':
270            print("  Robot upload stopped")
271
272        elif msg_type == 'LIST_LOGS_RESPONSE':
273            files = msg.get('files', [])
274            if not files:
275                print("  No log files on robot")
276            else:
277                print(f"  {'File':<45} {'Size':>10}  Uploaded")
278                print(f"  {'-' * 45} {'-' * 10}  {'-' * 8}")
279                for f in files:
280                    name = f.get('name', '?')
281                    size = f.get('size', 0)
282                    uploaded = 'yes' if f.get('uploaded') else 'no'
283                    if size >= 1024 * 1024:
284                        size_str = f"{size / (1024 * 1024):.1f} MB"
285                    elif size >= 1024:
286                        size_str = f"{size / 1024:.1f} KB"
287                    else:
288                        size_str = f"{size} B"
289                    print(f"  {name:<45} {size_str:>10}  {uploaded}")
290                print(f"  {len(files)} file(s)")
291
292        else:
293            logger.debug(f"Unknown message from robot: {msg}")
294
295    @staticmethod
296    def _read_message(sock: socket.socket, buf: str) -> Optional[str]:
297        """Read data until we have at least one newline. Returns None on disconnect."""
298        while '\n' not in buf:
299            data = sock.recv(4096)
300            if not data:
301                return None
302            buf += data.decode('utf-8')
303        return buf
304
305    @staticmethod
306    def _parse_next_message(buf: str) -> tuple:
307        """Extract the next JSON message from the buffer."""
308        idx = buf.index('\n')
309        line = buf[:idx].strip()
310        remaining = buf[idx + 1:]
311        if not line:
312            return None, remaining
313        try:
314            return json.loads(line), remaining
315        except json.JSONDecodeError:
316            logger.warning(f"Invalid JSON from robot: {line!r}")
317            return None, remaining

Polls for the roboRIO, opens a TCP control channel, and handles keepalive.

When the robot sends UPLOAD_COMPLETE, triggers .wpilog analysis via the provided callback.

RobotConnector(http_port: int, on_upload_complete=None)
27    def __init__(self, http_port: int, on_upload_complete=None) -> None:
28        self._http_port = http_port
29        self._on_upload_complete = on_upload_complete
30        self._thread: Optional[threading.Thread] = None
31        self._stop_event = threading.Event()
32        self._paused = threading.Event()  # set = paused (don't poll)
33        self._sock: Optional[socket.socket] = None
34        self._sock_lock = threading.Lock()
35        self._robot_addr: Optional[str] = None
36        self._connect_attempts = 0
37        self._connected_since: Optional[float] = None
38        self._last_attempt_time: Optional[float] = None
39        self._last_attempt_addr: Optional[str] = None
is_connected: bool
41    @property
42    def is_connected(self) -> bool:
43        return self._sock is not None
robot_address: str | None
45    @property
46    def robot_address(self) -> Optional[str]:
47        return self._robot_addr
status_info: dict
49    @property
50    def status_info(self) -> dict:
51        """Return a dict of connection status details."""
52        if self._paused.is_set():
53            state = 'paused'
54        elif self._sock is not None:
55            state = 'connected'
56        else:
57            state = 'polling'
58        info = {
59            'state': state,
60            'robot_address': self._robot_addr,
61            'http_port': self._http_port,
62            'connect_attempts': self._connect_attempts,
63            'poll_addresses': ROBOT_ADDRESSES,
64            'last_attempt_addr': self._last_attempt_addr,
65            'last_attempt_time': self._last_attempt_time,
66            'connected_since': self._connected_since,
67        }
68        return info

Return a dict of connection status details.

def start(self) -> None:
70    def start(self) -> None:
71        """Start the connector thread."""
72        if self._thread is not None and self._thread.is_alive():
73            return
74        self._stop_event.clear()
75        self._paused.clear()
76        self._thread = threading.Thread(target=self._poll_loop, daemon=True)
77        self._thread.start()

Start the connector thread.

def stop(self) -> None:
79    def stop(self) -> None:
80        self._stop_event.set()
def connect(self) -> None:
82    def connect(self) -> None:
83        """Resume polling / reconnect immediately."""
84        self._paused.clear()
85        # If thread died or was never started, restart it
86        if self._thread is None or not self._thread.is_alive():
87            self.start()
88        logger.info("Connection polling resumed")

Resume polling / reconnect immediately.

def disconnect(self) -> None:
 90    def disconnect(self) -> None:
 91        """Close current connection and stop polling."""
 92        self._paused.set()
 93        with self._sock_lock:
 94            if self._sock is not None:
 95                try:
 96                    self._sock.close()
 97                except Exception:
 98                    logger.debug("Error closing socket during disconnect", exc_info=True)
 99                self._sock = None
100                self._robot_addr = None
101        logger.info("Disconnected, polling paused")

Close current connection and stop polling.

def send_to_robot(self, msg: dict) -> bool:
103    def send_to_robot(self, msg: dict) -> bool:
104        """Send a JSON message to the connected robot. Returns False if not connected."""
105        with self._sock_lock:
106            if self._sock is None:
107                return False
108            try:
109                data = json.dumps(msg) + '\n'
110                self._sock.sendall(data.encode('utf-8'))
111                return True
112            except Exception:
113                logger.exception("Failed to send message to robot")
114                return False

Send a JSON message to the connected robot. Returns False if not connected.