utils.match_monitor

Robot-side support for the match-monitor host connection.

Owns the TCP control channel, the log uploader, and the wiring that connects them. The companion host-side code lives in host/match_monitor/.

 1"""Robot-side support for the match-monitor host connection.
 2
 3Owns the TCP control channel, the log uploader, and the wiring that
 4connects them. The companion host-side code lives in `host/match_monitor/`.
 5"""
 6
 7from utils.match_monitor.connection import MatchMonitorConnection
 8from utils.match_monitor.control_listener import ControlListener
 9from utils.match_monitor.log_uploader import LogUploader
10
11__all__ = ["MatchMonitorConnection", "ControlListener", "LogUploader"]
class MatchMonitorConnection:
22class MatchMonitorConnection:
23    """Listener + uploader pair, wired together and started."""
24
25    def __init__(self) -> None:
26        self.listener: Optional[ControlListener] = None
27        self.uploader: Optional[LogUploader] = None
28
29        try:
30            self.listener = ControlListener()
31            self.listener.start()
32        except Exception:
33            wpilib.reportError("Unable to create ControlListener", printTrace=True)
34            return
35
36        try:
37            self.uploader = LogUploader(self.listener)
38        except Exception:
39            wpilib.reportError("Unable to create LogUploader", printTrace=True)
40            return
41
42        self.listener.on_force_upload = self.uploader.start_upload
43        self.listener.on_stop_upload = self.uploader.stop_upload
44        self.listener.on_clear_manifest_done = self._on_clear_manifest
45
46    def start_upload(self) -> None:
47        if self.uploader is not None:
48            self.uploader.start_upload()
49
50    def stop_upload(self) -> None:
51        if self.uploader is not None:
52            self.uploader.stop_upload()
53
54    def _on_clear_manifest(self) -> None:
55        # Let current file finish uploading, then stop, clear, restart.
56        self.uploader.stop_and_wait()
57        count = self.listener._clear_manifests()
58        self.listener.send_message({'type': 'MANIFEST_CLEARED', 'count': count})
59        self.uploader.start_upload()

Listener + uploader pair, wired together and started.

listener: ControlListener | None
uploader: LogUploader | None
def start_upload(self) -> None:
46    def start_upload(self) -> None:
47        if self.uploader is not None:
48            self.uploader.start_upload()
def stop_upload(self) -> None:
50    def stop_upload(self) -> None:
51        if self.uploader is not None:
52            self.uploader.stop_upload()
class ControlListener:
 21class ControlListener:
 22    """Listens for a host TCP connection and maintains a keepalive channel.
 23
 24    Protocol (newline-delimited JSON over TCP):
 25        Host → Robot: HELLO {http_port}
 26        Robot → Host: HELLO_ACK
 27        Host → Robot: PING
 28        Robot → Host: PONG
 29        Robot → Host: UPLOAD_STARTING / UPLOAD_COMPLETE
 30        Host → Robot: CLEAR_MANIFEST / FORCE_UPLOAD / STOP_UPLOAD / LIST_LOGS
 31    """
 32
 33    status = ntproperty('/ControlListener/status', 'idle',
 34                         writeDefault=True, persistent=False)
 35
 36    def __init__(self) -> None:
 37        self._host_ip: Optional[str] = None
 38        self._http_port: Optional[int] = None
 39        self._conn: Optional[socket.socket] = None
 40        self._conn_lock = threading.Lock()
 41        self._thread: Optional[threading.Thread] = None
 42        self.on_force_upload: Optional[Callable[[], None]] = None
 43        self.on_stop_upload: Optional[Callable[[], None]] = None
 44        self.on_clear_manifest_done: Optional[Callable[[], None]] = None
 45
 46    @property
 47    def host_ip(self) -> Optional[str]:
 48        return self._host_ip
 49
 50    @property
 51    def http_port(self) -> Optional[int]:
 52        return self._http_port
 53
 54    @property
 55    def is_connected(self) -> bool:
 56        return self._host_ip is not None and self._conn is not None
 57
 58    def start(self) -> None:
 59        """Start the listener thread. Safe to call multiple times."""
 60        if self._thread is not None and self._thread.is_alive():
 61            return
 62        self._thread = threading.Thread(target=self._listen_loop, daemon=True)
 63        self._thread.start()
 64
 65    def send_message(self, msg: dict) -> bool:
 66        """Send a JSON message to the connected host. Returns False if not connected."""
 67        with self._conn_lock:
 68            if self._conn is None:
 69                return False
 70            try:
 71                data = json.dumps(msg) + '\n'
 72                self._conn.sendall(data.encode('utf-8'))
 73                return True
 74            except Exception:
 75                logger.exception("Failed to send message to host")
 76                return False
 77
 78    def send_upload_starting(self, event_name: str, match_type: str,
 79                             match_number: str) -> bool:
 80        return self.send_message({
 81            'type': 'UPLOAD_STARTING',
 82            'event_name': event_name,
 83            'match_type': match_type,
 84            'match_number': match_number,
 85        })
 86
 87    def send_upload_complete(self, event_name: str, match_type: str,
 88                             match_number: str) -> bool:
 89        return self.send_message({
 90            'type': 'UPLOAD_COMPLETE',
 91            'event_name': event_name,
 92            'match_type': match_type,
 93            'match_number': match_number,
 94        })
 95
 96    def _listen_loop(self) -> None:
 97        """Main listener loop — binds, accepts, handles one connection at a time."""
 98        server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 99        server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
100        server_sock.bind(('0.0.0.0', CONTROL_PORT))
101        server_sock.listen(1)
102        logger.info(f"ControlListener listening on port {CONTROL_PORT}")
103        self.status = 'listening'
104        print(f"[ControlListener] Listening on port {CONTROL_PORT}")
105
106        while True:
107            try:
108                conn, addr = server_sock.accept()
109                print(f"[ControlListener] Connection from {addr[0]}:{addr[1]}")
110                self._handle_connection(conn, addr)
111            except Exception:
112                logger.exception("Error accepting connection")
113
114    def _handle_connection(self, conn: socket.socket, addr: tuple) -> None:
115        """Handle a single host connection: handshake, then keepalive loop."""
116        # Close any existing connection
117        with self._conn_lock:
118            if self._conn is not None:
119                try:
120                    self._conn.close()
121                except Exception:
122                    logger.debug("Error closing previous connection", exc_info=True)
123            self._conn = conn
124
125        conn.settimeout(35)  # slightly more than keepalive interval + timeout
126
127        try:
128            buf = ''
129            # Read HELLO message
130            buf = self._read_message(conn, buf)
131            if buf is None:
132                return
133
134            msg, buf = self._parse_next_message(buf)
135            if msg is None or msg.get('type') != 'HELLO':
136                logger.warning(f"Expected HELLO, got: {msg}")
137                return
138
139            host_ip = addr[0]
140            http_port = msg.get('http_port', 5800)
141            self._host_ip = host_ip
142            self._http_port = int(http_port)
143
144            # Send HELLO_ACK
145            ack = json.dumps({'type': 'HELLO_ACK'}) + '\n'
146            conn.sendall(ack.encode('utf-8'))
147
148            self.status = f'connected ({host_ip})'
149            logger.info(f"Host connected: {host_ip}, HTTP port {http_port}")
150            print(f"[ControlListener] Host connected: {host_ip}:{http_port}")
151
152            # Keepalive loop — respond to PINGs and host commands
153            while True:
154                buf = self._read_message(conn, buf)
155                if buf is None:
156                    break
157                msg, buf = self._parse_next_message(buf)
158                if msg is None:
159                    continue
160                self._handle_host_message(msg, conn)
161
162        except socket.timeout:
163            logger.warning("Host connection timed out (no keepalive)")
164            print("[ControlListener] Host connection timed out")
165        except (ConnectionResetError, BrokenPipeError, OSError):
166            logger.info("Host disconnected")
167            print("[ControlListener] Host disconnected")
168        except Exception:
169            logger.exception("Error in host connection handler")
170        finally:
171            self._host_ip = None
172            self._http_port = None
173            with self._conn_lock:
174                self._conn = None
175            try:
176                conn.close()
177            except Exception:
178                logger.debug("Error closing connection socket", exc_info=True)
179            self.status = 'listening'
180            print("[ControlListener] Back to listening")
181
182    def _handle_host_message(self, msg: dict, conn: socket.socket) -> None:
183        """Process a message from the host."""
184        msg_type = msg.get('type', '')
185
186        if msg_type == 'PING':
187            pong = json.dumps({'type': 'PONG'}) + '\n'
188            conn.sendall(pong.encode('utf-8'))
189
190        elif msg_type == 'CLEAR_MANIFEST':
191            if self.on_clear_manifest_done:
192                try:
193                    # Callback handles: stop upload, wait, clear, respond, restart
194                    self.on_clear_manifest_done()
195                except Exception:
196                    logger.exception("on_clear_manifest_done callback failed")
197            else:
198                # No callback wired — just clear and respond
199                count = self._clear_manifests()
200                self.send_message({'type': 'MANIFEST_CLEARED', 'count': count})
201
202        elif msg_type == 'FORCE_UPLOAD':
203            if self.on_force_upload:
204                try:
205                    self.on_force_upload()
206                except Exception:
207                    logger.exception("on_force_upload callback failed")
208            self.send_message({'type': 'FORCE_UPLOAD_ACK'})
209
210        elif msg_type == 'STOP_UPLOAD':
211            if self.on_stop_upload:
212                try:
213                    self.on_stop_upload()
214                except Exception:
215                    logger.exception("on_stop_upload callback failed")
216            self.send_message({'type': 'STOP_UPLOAD_ACK'})
217
218        elif msg_type == 'LIST_LOGS':
219            files = self._list_log_files()
220            self.send_message({'type': 'LIST_LOGS_RESPONSE', 'files': files})
221
222        else:
223            logger.debug(f"Unexpected message from host: {msg}")
224
225    def _clear_manifests(self) -> int:
226        """Delete all .uploaded_manifest files from log directories."""
227        from utils.match_monitor.log_uploader import LOG_DIRS, MANIFEST_FILENAME
228        count = 0
229        for log_dir in LOG_DIRS:
230            manifest = log_dir / MANIFEST_FILENAME
231            if manifest.exists():
232                try:
233                    manifest.unlink()
234                    count += 1
235                    logger.info(f"Deleted manifest: {manifest}")
236                except Exception:
237                    logger.exception(f"Failed to delete {manifest}")
238        return count
239
240    def _list_log_files(self) -> list:
241        """Scan log directories and return file info with upload status."""
242        from utils.match_monitor.log_uploader import LOG_DIRS, LOG_EXTENSIONS, MANIFEST_FILENAME
243        files = []
244        for log_dir in LOG_DIRS:
245            if not log_dir.is_dir():
246                continue
247            # Load manifest for this directory
248            manifest_path = log_dir / MANIFEST_FILENAME
249            manifest = set()
250            if manifest_path.exists():
251                try:
252                    manifest = set(manifest_path.read_text().strip().splitlines())
253                except Exception:
254                    logger.debug("Could not read manifest: %s", manifest_path, exc_info=True)
255            for f in sorted(log_dir.iterdir()):
256                if f.is_file() and f.suffix.lower() in LOG_EXTENSIONS:
257                    files.append({
258                        'name': f.name,
259                        'size': f.stat().st_size,
260                        'uploaded': f.name in manifest,
261                        'dir': str(log_dir),
262                    })
263        return files
264
265    @staticmethod
266    def _read_message(conn: socket.socket, buf: str) -> Optional[str]:
267        """Read data from socket until we have at least one newline. Returns updated buf or None on disconnect."""
268        while '\n' not in buf:
269            data = conn.recv(4096)
270            if not data:
271                return None
272            buf += data.decode('utf-8')
273        return buf
274
275    @staticmethod
276    def _parse_next_message(buf: str) -> tuple:
277        """Extract the next JSON message from the buffer. Returns (msg_dict, remaining_buf)."""
278        idx = buf.index('\n')
279        line = buf[:idx].strip()
280        remaining = buf[idx + 1:]
281        if not line:
282            return None, remaining
283        try:
284            return json.loads(line), remaining
285        except json.JSONDecodeError:
286            logger.warning(f"Invalid JSON from host: {line!r}")
287            return None, remaining

Listens for a host TCP connection and maintains a keepalive channel.

Protocol (newline-delimited JSON over TCP): Host → Robot: HELLO {http_port} Robot → Host: HELLO_ACK Host → Robot: PING Robot → Host: PONG Robot → Host: UPLOAD_STARTING / UPLOAD_COMPLETE Host → Robot: CLEAR_MANIFEST / FORCE_UPLOAD / STOP_UPLOAD / LIST_LOGS

status
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

on_force_upload: Callable[[], NoneType] | None
on_stop_upload: Callable[[], NoneType] | None
on_clear_manifest_done: Callable[[], NoneType] | None
host_ip: str | None
46    @property
47    def host_ip(self) -> Optional[str]:
48        return self._host_ip
http_port: int | None
50    @property
51    def http_port(self) -> Optional[int]:
52        return self._http_port
is_connected: bool
54    @property
55    def is_connected(self) -> bool:
56        return self._host_ip is not None and self._conn is not None
def start(self) -> None:
58    def start(self) -> None:
59        """Start the listener thread. Safe to call multiple times."""
60        if self._thread is not None and self._thread.is_alive():
61            return
62        self._thread = threading.Thread(target=self._listen_loop, daemon=True)
63        self._thread.start()

Start the listener thread. Safe to call multiple times.

def send_message(self, msg: dict) -> bool:
65    def send_message(self, msg: dict) -> bool:
66        """Send a JSON message to the connected host. Returns False if not connected."""
67        with self._conn_lock:
68            if self._conn is None:
69                return False
70            try:
71                data = json.dumps(msg) + '\n'
72                self._conn.sendall(data.encode('utf-8'))
73                return True
74            except Exception:
75                logger.exception("Failed to send message to host")
76                return False

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

def send_upload_starting(self, event_name: str, match_type: str, match_number: str) -> bool:
78    def send_upload_starting(self, event_name: str, match_type: str,
79                             match_number: str) -> bool:
80        return self.send_message({
81            'type': 'UPLOAD_STARTING',
82            'event_name': event_name,
83            'match_type': match_type,
84            'match_number': match_number,
85        })
def send_upload_complete(self, event_name: str, match_type: str, match_number: str) -> bool:
87    def send_upload_complete(self, event_name: str, match_type: str,
88                             match_number: str) -> bool:
89        return self.send_message({
90            'type': 'UPLOAD_COMPLETE',
91            'event_name': event_name,
92            'match_type': match_type,
93            'match_number': match_number,
94        })
class LogUploader:
 34class LogUploader:
 35    """Uploads robot log files to a match_monitor receiver via HTTP.
 36
 37    The host IP and port are discovered via the ControlListener TCP channel.
 38    Uploads run in a background daemon thread triggered from disabledInit().
 39    Calling start_upload() when an upload is already running is a safe no-op.
 40    Call stop_upload() when leaving disabled mode to free bandwidth.
 41    """
 42
 43    upload_enabled = ntproperty('/LogUploader/enabled', True,
 44                                writeDefault=True, persistent=True)
 45    status = ntproperty('/LogUploader/status', 'idle',
 46                        writeDefault=True, persistent=False)
 47
 48    def __init__(self, control_listener: ControlListener) -> None:
 49        self._control = control_listener
 50        self._thread: Optional[threading.Thread] = None
 51        self._lock = threading.Lock()
 52        self._stop_event = threading.Event()
 53
 54    def start_upload(self) -> None:
 55        """Begin a non-blocking upload cycle. No-op if already uploading."""
 56        if not self.upload_enabled:
 57            self.status = 'disabled'
 58            logger.info("Upload disabled via NT, skipping")
 59            return
 60        if not self._control.is_connected:
 61            self.status = 'no host connected'
 62            logger.info("No host connected, skipping upload")
 63            return
 64
 65        host_ip = self._control.host_ip
 66        http_port = self._control.http_port
 67        logger.info("start_upload: host=%s:%s", host_ip, http_port)
 68
 69        with self._lock:
 70            if self._thread is not None and self._thread.is_alive():
 71                logger.info("Upload already in progress, skipping start_upload")
 72                return
 73            logger.info("Starting upload worker thread")
 74            self._stop_event.clear()
 75            self._thread = threading.Thread(
 76                target=self._upload_worker, daemon=True
 77            )
 78            self._thread.start()
 79
 80    def stop_upload(self) -> None:
 81        """Signal the upload thread to stop after the current file."""
 82        self._stop_event.set()
 83
 84    def stop_and_wait(self, timeout: float = 60) -> None:
 85        """Stop the upload thread and wait for it to finish."""
 86        self._stop_event.set()
 87        if self._thread is not None and self._thread.is_alive():
 88            self._thread.join(timeout=timeout)
 89
 90    def is_uploading(self) -> bool:
 91        return self._thread is not None and self._thread.is_alive()
 92
 93    def _upload_worker(self) -> None:
 94        try:
 95            host_ip = self._control.host_ip
 96            http_port = self._control.http_port
 97            if not host_ip or not http_port:
 98                self.status = 'no host connected'
 99                return
100
101            self.status = 'scanning'
102            log_dirs = self._find_log_dirs()
103            logger.info("Found log dirs: %s", log_dirs)
104            if not log_dirs:
105                self.status = 'no log directory found'
106                return
107
108            # Gather new files (not in manifest)
109            new_files: List[Path] = []
110            for log_dir in log_dirs:
111                new_files.extend(self._find_new_files(log_dir))
112
113            # Gather previously uploaded files that may have changed
114            manifest_files: List[Path] = []
115            for log_dir in log_dirs:
116                manifest_files.extend(self._find_manifest_files(log_dir))
117
118            logger.info("new=%d, manifest=%d", len(new_files), len(manifest_files))
119            if not new_files and not manifest_files:
120                self.status = 'idle (no files)'
121                return
122
123            self.status = 'connecting'
124            if not self._check_receiver(host_ip, http_port):
125                self.status = 'receiver unreachable'
126                logger.warning("Receiver unreachable at %s:%s", host_ip, http_port)
127                return
128
129            event_name, match_type, match_number = self._get_match_metadata()
130
131            # Notify host that uploads are starting
132            self._control.send_upload_starting(event_name, match_type, match_number)
133
134            uploaded_count = 0
135            total = len(new_files) + len(manifest_files)
136
137            # Upload new files first
138            for filepath in new_files:
139                if self._stop_event.is_set():
140                    self.status = f'stopped ({uploaded_count}/{total} uploaded)'
141                    return
142                # Re-check connection in case host disappeared
143                if not self._control.is_connected:
144                    self.status = 'host disconnected during upload'
145                    return
146                self.status = f'uploading {filepath.name}'
147                if self._upload_file(filepath, host_ip, http_port,
148                                     event_name, match_type, match_number):
149                    self._add_to_manifest(filepath)
150                    uploaded_count += 1
151
152            # Check previously uploaded files for changes (SHA-256 mismatch)
153            for filepath in manifest_files:
154                if self._stop_event.is_set():
155                    self.status = f'stopped ({uploaded_count}/{total} uploaded)'
156                    return
157                if not self._control.is_connected:
158                    self.status = 'host disconnected during upload'
159                    return
160                self.status = f'checking {filepath.name}'
161                if self._needs_reupload(filepath, host_ip, http_port,
162                                        event_name, match_type, match_number):
163                    self.status = f're-uploading {filepath.name}'
164                    self._upload_file(filepath, host_ip, http_port,
165                                      event_name, match_type, match_number,
166                                      overwrite=True)
167                    uploaded_count += 1
168
169            self.status = f'done ({uploaded_count}/{total} uploaded)'
170
171            # Notify host that uploads are complete
172            if not self._stop_event.is_set():
173                self._control.send_upload_complete(event_name, match_type, match_number)
174        except Exception:
175            logger.exception("Upload worker failed")
176            self.status = 'error'
177
178    def _find_log_dirs(self) -> List[Path]:
179        return [d for d in LOG_DIRS if d.is_dir()]
180
181    def _find_new_files(self, log_dir: Path) -> List[Path]:
182        manifest = self._load_manifest(log_dir)
183        files = []
184        for f in log_dir.iterdir():
185            if f.is_file() and f.suffix.lower() in LOG_EXTENSIONS and f.name not in manifest:
186                files.append(f)
187        files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
188        return files
189
190    def _find_manifest_files(self, log_dir: Path) -> List[Path]:
191        """Return files that are in the manifest and still exist on disk."""
192        manifest = self._load_manifest(log_dir)
193        files = []
194        for name in manifest:
195            f = log_dir / name
196            if f.is_file():
197                files.append(f)
198        files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
199        return files
200
201    def _add_to_manifest(self, filepath: Path) -> None:
202        log_dir = filepath.parent
203        manifest = self._load_manifest(log_dir)
204        manifest.add(filepath.name)
205        self._save_manifest(log_dir, manifest)
206
207    def _load_manifest(self, log_dir: Path) -> Set[str]:
208        manifest_path = log_dir / MANIFEST_FILENAME
209        if not manifest_path.exists():
210            return set()
211        try:
212            return set(manifest_path.read_text().strip().splitlines())
213        except Exception:
214            logger.exception("Failed to read upload manifest")
215            return set()
216
217    def _save_manifest(self, log_dir: Path, manifest: Set[str]) -> None:
218        manifest_path = log_dir / MANIFEST_FILENAME
219        try:
220            manifest_path.write_text('\n'.join(sorted(manifest)) + '\n')
221        except Exception:
222            logger.exception("Failed to write upload manifest")
223
224    def _get_match_metadata(self) -> tuple:
225        try:
226            event_name = wpilib.DriverStation.getEventName()
227            match_type = wpilib.DriverStation.getMatchType()
228            match_number = wpilib.DriverStation.getMatchNumber()
229
230            type_names = {
231                wpilib.DriverStation.MatchType.kNone: '',
232                wpilib.DriverStation.MatchType.kPractice: 'practice',
233                wpilib.DriverStation.MatchType.kQualification: 'qual',
234                wpilib.DriverStation.MatchType.kElimination: 'elim',
235            }
236            match_type_str = type_names.get(match_type, '')
237            return event_name, match_type_str, str(match_number)
238        except Exception:
239            logger.exception("Failed to get match metadata")
240            return '', '', ''
241
242    def _check_receiver(self, host_ip: str, http_port: int) -> bool:
243        try:
244            conn = http.client.HTTPConnection(host_ip, http_port, timeout=3)
245            conn.request('GET', '/status')
246            resp = conn.getresponse()
247            conn.close()
248            return resp.status == 200
249        except Exception:
250            return False
251
252    def _needs_reupload(self, filepath: Path, host_ip: str, http_port: int,
253                        event_name: str, match_type: str,
254                        match_number: str) -> bool:
255        """Check if a previously uploaded file has changed by comparing SHA-256."""
256        try:
257            local_sha = hashlib.sha256(filepath.read_bytes()).hexdigest()
258
259            conn = http.client.HTTPConnection(host_ip, http_port, timeout=5)
260            headers = {
261                'X-Filename': filepath.name,
262                'X-Event-Name': event_name or '',
263                'X-Match-Type': match_type or '',
264                'X-Match-Number': match_number or '',
265            }
266            conn.request('GET', '/check', headers=headers)
267            resp = conn.getresponse()
268            body = json.loads(resp.read().decode('utf-8'))
269            conn.close()
270
271            if resp.status != 200:
272                return False
273
274            if not body.get('exists'):
275                return True
276
277            remote_sha = body.get('sha256', '')
278            if local_sha != remote_sha:
279                logger.info(f"SHA-256 mismatch for {filepath.name}, will re-upload")
280                return True
281
282            return False
283        except Exception:
284            logger.exception(f"Failed to check {filepath.name}")
285            return False
286
287    def _upload_file(self, filepath: Path, host_ip: str, http_port: int,
288                     event_name: str, match_type: str, match_number: str,
289                     overwrite: bool = False) -> bool:
290        try:
291            raw_data = filepath.read_bytes()
292            if len(raw_data) == 0:
293                logger.info(f"Skipping {filepath.name}: file is empty (0 bytes)")
294                self._control.send_message({
295                    'type': 'FILE_SKIPPED',
296                    'filename': filepath.name,
297                    'reason': 'empty (0 bytes)',
298                })
299                return True  # Treat as "handled" so it gets added to manifest
300            compressed_data = zlib.compress(raw_data)
301
302            conn = http.client.HTTPConnection(host_ip, http_port, timeout=30)
303            headers = {
304                'Content-Type': 'application/octet-stream',
305                'Content-Encoding': 'deflate',
306                'X-Filename': filepath.name,
307                'X-Event-Name': event_name or '',
308                'X-Match-Type': match_type or '',
309                'X-Match-Number': match_number or '',
310                'X-Uncompressed-Size': str(len(raw_data)),
311                'Content-Length': str(len(compressed_data)),
312            }
313            if overwrite:
314                headers['X-Overwrite'] = 'true'
315
316            start_time = time.monotonic()
317            conn.request('POST', '/upload', body=compressed_data, headers=headers)
318            resp = conn.getresponse()
319            conn.close()
320            elapsed = time.monotonic() - start_time
321            raw_mb = len(raw_data) / (1024 * 1024)
322            compressed_mb = len(compressed_data) / (1024 * 1024)
323            rate_mbps = compressed_mb / elapsed if elapsed > 0 else 0
324            savings = (1 - len(compressed_data) / len(raw_data)) * 100 if raw_data else 0
325
326            if resp.status == 200:
327                action = "Re-uploaded" if overwrite else "Uploaded"
328                logger.info(f"{action} {filepath.name} "
329                            f"({raw_mb:.1f} MB → {compressed_mb:.1f} MB compressed, "
330                            f"{savings:.0f}% savings, {elapsed:.2f}s, {rate_mbps:.1f} MB/s)")
331                return True
332            elif resp.status == 409:
333                logger.info(f"Already exists on receiver: {filepath.name}")
334                return True
335            else:
336                logger.warning(
337                    f"Upload failed for {filepath.name}: HTTP {resp.status}"
338                )
339                return False
340        except Exception:
341            logger.exception(f"Upload failed for {filepath.name}")
342            return False

Uploads robot log files to a match_monitor receiver via HTTP.

The host IP and port are discovered via the ControlListener TCP channel. Uploads run in a background daemon thread triggered from disabledInit(). Calling start_upload() when an upload is already running is a safe no-op. Call stop_upload() when leaving disabled mode to free bandwidth.

LogUploader( control_listener: ControlListener)
48    def __init__(self, control_listener: ControlListener) -> None:
49        self._control = control_listener
50        self._thread: Optional[threading.Thread] = None
51        self._lock = threading.Lock()
52        self._stop_event = threading.Event()
upload_enabled
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

status
87    def get(self, _):
88        return self.entry.value

The type of the None singleton.

def start_upload(self) -> None:
54    def start_upload(self) -> None:
55        """Begin a non-blocking upload cycle. No-op if already uploading."""
56        if not self.upload_enabled:
57            self.status = 'disabled'
58            logger.info("Upload disabled via NT, skipping")
59            return
60        if not self._control.is_connected:
61            self.status = 'no host connected'
62            logger.info("No host connected, skipping upload")
63            return
64
65        host_ip = self._control.host_ip
66        http_port = self._control.http_port
67        logger.info("start_upload: host=%s:%s", host_ip, http_port)
68
69        with self._lock:
70            if self._thread is not None and self._thread.is_alive():
71                logger.info("Upload already in progress, skipping start_upload")
72                return
73            logger.info("Starting upload worker thread")
74            self._stop_event.clear()
75            self._thread = threading.Thread(
76                target=self._upload_worker, daemon=True
77            )
78            self._thread.start()

Begin a non-blocking upload cycle. No-op if already uploading.

def stop_upload(self) -> None:
80    def stop_upload(self) -> None:
81        """Signal the upload thread to stop after the current file."""
82        self._stop_event.set()

Signal the upload thread to stop after the current file.

def stop_and_wait(self, timeout: float = 60) -> None:
84    def stop_and_wait(self, timeout: float = 60) -> None:
85        """Stop the upload thread and wait for it to finish."""
86        self._stop_event.set()
87        if self._thread is not None and self._thread.is_alive():
88            self._thread.join(timeout=timeout)

Stop the upload thread and wait for it to finish.

def is_uploading(self) -> bool:
90    def is_uploading(self) -> bool:
91        return self._thread is not None and self._thread.is_alive()