host.match_monitor.receiver
HTTP server that receives log files uploaded from the robot.
1"""HTTP server that receives log files uploaded from the robot.""" 2 3import hashlib 4import json 5import logging 6import sys 7import threading 8import time 9import zlib 10from dataclasses import dataclass 11from datetime import datetime 12from http.server import HTTPServer, BaseHTTPRequestHandler 13from pathlib import Path 14from typing import Optional 15 16from .analyzer import WpilogAnalyzer 17from .callbacks import CallbackRegistry 18from .connector import RobotConnector 19from .ds_log_reader import collect_for_match 20from .match_data_client import MatchDataClient 21 22logger = logging.getLogger("match_monitor") 23 24 25def _save_config_key(config_path: Path, key: str, value) -> None: 26 """Read/update/write a single key in match_monitor_config.json.""" 27 cfg: dict = {} 28 if config_path.exists(): 29 try: 30 cfg = json.loads(config_path.read_text()) 31 except Exception: 32 logger.debug("Could not read config %s, using defaults", config_path, exc_info=True) 33 cfg[key] = value 34 try: 35 config_path.write_text(json.dumps(cfg, indent=4) + '\n') 36 except Exception as exc: 37 logger.warning(f"Could not save config: {exc}") 38 39 40@dataclass 41class ServerContext: 42 """All live components created by setup_server().""" 43 server: HTTPServer 44 connector: 'RobotConnector' 45 registry: 'CallbackRegistry' 46 discord_holder: list 47 match_client: object 48 console_handler: logging.Handler 49 file_handler: logging.Handler 50 log_dir: Path 51 out_path: Path 52 config_path: Path 53 code_dir_holder: list # [Path | None] — mutable; updated by codedir command / tray 54 log_file: Path 55 analyzer: 'WpilogAnalyzer' = None 56 57 58LOG_LEVELS = { 59 'debug': logging.DEBUG, 60 'info': logging.INFO, 61 'warning': logging.WARNING, 62 'error': logging.ERROR, 63} 64LOG_DISABLED = logging.CRITICAL + 1 65 66 67class LogReceiverHandler(BaseHTTPRequestHandler): 68 """Handles POST /upload, GET /check, and GET /status requests.""" 69 70 output_dir: Path # Set by run_server before starting 71 callback_registry: CallbackRegistry # Set by run_server 72 analyzer: WpilogAnalyzer # Set by run_server 73 74 def do_GET(self): 75 if self.path == '/status': 76 self._send_json(200, { 77 'status': 'ready', 78 'output_dir': str(self.output_dir), 79 'timestamp': datetime.now().isoformat(), 80 }) 81 elif self.path == '/check': 82 self._handle_check() 83 else: 84 self._send_json(404, {'error': 'not found'}) 85 86 def _handle_check(self): 87 """Return SHA-256 of a stored file so the client can compare.""" 88 filename = self.headers.get('X-Filename') 89 if not filename: 90 self._send_json(400, {'error': 'X-Filename header required'}) 91 return 92 93 filename = Path(filename).name 94 sub_dir = self._build_match_dir() 95 dest = self.output_dir / sub_dir / filename 96 97 if not dest.exists(): 98 self._send_json(200, {'exists': False, 'filename': filename}) 99 return 100 101 sha256 = hashlib.sha256(dest.read_bytes()).hexdigest() 102 self._send_json(200, { 103 'exists': True, 104 'filename': filename, 105 'sha256': sha256, 106 'size': dest.stat().st_size, 107 }) 108 109 def do_POST(self): 110 if self.path != '/upload': 111 self._send_json(404, {'error': 'not found'}) 112 return 113 114 filename = self.headers.get('X-Filename') 115 if not filename: 116 self._send_json(400, {'error': 'X-Filename header required'}) 117 return 118 119 # Sanitize filename 120 filename = Path(filename).name 121 if not filename: 122 self._send_json(400, {'error': 'invalid filename'}) 123 return 124 125 # Build subdirectory from match metadata 126 sub_dir = self._build_match_dir() 127 dest_dir = self.output_dir / sub_dir 128 dest_dir.mkdir(parents=True, exist_ok=True) 129 130 dest = dest_dir / filename 131 overwrite = self.headers.get('X-Overwrite', '').lower() == 'true' 132 if dest.exists() and not overwrite: 133 logger.info(f"Duplicate skipped: {sub_dir}/{filename}") 134 self._send_json(409, {'error': 'file already exists', 135 'filename': filename}) 136 return 137 138 content_length = int(self.headers.get('Content-Length', 0)) 139 if content_length == 0: 140 self._send_json(400, {'error': 'empty body'}) 141 return 142 143 part_path = dest.with_suffix(dest.suffix + '.part') 144 try: 145 start_time = time.monotonic() 146 raw_data = self.rfile.read(content_length) 147 compressed_size = len(raw_data) 148 149 # Decompress if client sent compressed data 150 content_encoding = self.headers.get('Content-Encoding', '') 151 if content_encoding == 'deflate': 152 data = zlib.decompress(raw_data) 153 uncompressed_size = len(data) 154 else: 155 data = raw_data 156 uncompressed_size = len(data) 157 158 part_path.write_bytes(data) 159 import os as _os 160 _os.replace(str(part_path), str(dest)) # os.replace() atomically overwrites on all platforms 161 elapsed = time.monotonic() - start_time 162 rate_mbps = (compressed_size / (1024 * 1024)) / elapsed if elapsed > 0 else 0 163 164 action = "Updated" if overwrite and dest.exists() else "Received" 165 if content_encoding == 'deflate': 166 savings = (1 - compressed_size / uncompressed_size) * 100 if uncompressed_size else 0 167 compressed_mb = compressed_size / (1024 * 1024) 168 uncompressed_mb = uncompressed_size / (1024 * 1024) 169 msg = (f"{action}: {sub_dir}/{filename} " 170 f"({compressed_mb:.1f} MB compressed -> {uncompressed_mb:.1f} MB, " 171 f"{savings:.0f}% savings, {elapsed:.2f}s, {rate_mbps:.1f} MB/s)") 172 else: 173 msg = (f"{action}: {sub_dir}/{filename} " 174 f"({len(data):,} bytes in {elapsed:.2f}s, {rate_mbps:.1f} MB/s)") 175 logger.info(msg) 176 print(f"[{datetime.now():%H:%M:%S}] {msg}") 177 178 self._send_json(200, {'status': 'ok', 'filename': filename, 179 'size': len(data), 180 'transfer_seconds': round(elapsed, 3)}) 181 except Exception as e: 182 logger.exception(f"Failed to save {filename}") 183 if part_path.exists(): 184 part_path.unlink() 185 self._send_json(500, {'error': str(e)}) 186 187 def _build_match_dir(self) -> str: 188 event_name = self.headers.get('X-Event-Name', '').strip() 189 match_type = self.headers.get('X-Match-Type', '').strip() 190 match_number = self.headers.get('X-Match-Number', '').strip() 191 192 if event_name and match_type and match_number: 193 return f"{event_name}/{match_type}_{match_number}" 194 elif event_name: 195 return event_name 196 else: 197 return f"unknown/{datetime.now():%Y-%m-%d}" 198 199 def _send_json(self, status_code: int, body: dict): 200 payload = json.dumps(body).encode('utf-8') 201 self.send_response(status_code) 202 self.send_header('Content-Type', 'application/json') 203 self.send_header('Content-Length', str(len(payload))) 204 self.end_headers() 205 self.wfile.write(payload) 206 207 def log_message(self, format, *args): 208 pass 209 210 211def _analyze_and_report(analyzer: WpilogAnalyzer, registry: CallbackRegistry, 212 match_dir: Path, wpilog_file: Path, metadata: dict, 213 match_client: 'MatchDataClient') -> None: 214 """Analyze a single .wpilog file, collect DS logs, fetch match data, run callbacks.""" 215 logger.info(f"Analyzing: {wpilog_file.name}") 216 stats = analyzer.analyze(wpilog_file) 217 218 # Collect and correlate DS log files using the wpilog wall clock start 219 match_time = stats.wpilog_wall_start or datetime.now() 220 try: 221 stats.ds_log = collect_for_match(match_time, match_dir) 222 if stats.ds_log.copied_files: 223 logger.info(f"DS logs collected: {', '.join(stats.ds_log.copied_files)}") 224 except Exception: 225 logger.exception("DS log collection failed (non-fatal)") 226 227 # Fetch official match data from TBA / FRC Events API 228 match_result = None 229 event_stats = None 230 if match_client and match_client.configured: 231 event_name = metadata.get('event_name', '').strip() 232 match_number_str = metadata.get('match_number', '').strip() 233 match_type = metadata.get('match_type', '').strip().lower() 234 if event_name and match_number_str: 235 try: 236 level = 'qm' if 'qual' in match_type else \ 237 'sf' if 'semi' in match_type else \ 238 'f' if 'final' in match_type else 'qm' 239 match_result = match_client.fetch_match( 240 event_name, int(match_number_str), level) 241 event_stats = match_client.fetch_event_stats(event_name) 242 if match_result: 243 logger.info(f"Official result: {match_result.result} " 244 f"({match_result.our_score}-{match_result.opponent_score})") 245 except Exception: 246 logger.exception("Match data fetch failed (non-fatal)") 247 248 msg = (f"Analysis complete: {wpilog_file.name} — " 249 f"brownouts={stats.brownout_count}, " 250 f"disconnects={stats.disconnect_count}, " 251 f"voltage={stats.avg_voltage}") 252 if stats.ds_log and stats.ds_log.avg_packet_loss_pct is not None: 253 msg += f", pkt_loss={stats.ds_log.avg_packet_loss_pct}%" 254 if match_result: 255 msg += f", result={match_result.result} ({match_result.our_score}-{match_result.opponent_score})" 256 logger.info(msg) 257 print(f"[{datetime.now():%H:%M:%S}] {msg}") 258 registry.run_all(match_dir, metadata, stats, match_result, event_stats) 259 260 261def _run_analysis(analyzer: WpilogAnalyzer, registry: CallbackRegistry, 262 match_dir: Path, metadata: dict, 263 match_client: 'MatchDataClient') -> None: 264 """Background worker: analyze the most recent .wpilog file in match_dir.""" 265 try: 266 wpilog_files = sorted(match_dir.glob('*.wpilog'), 267 key=lambda f: f.stat().st_mtime, reverse=True) 268 if not wpilog_files: 269 logger.info(f"No .wpilog files found in {match_dir}") 270 return 271 _analyze_and_report(analyzer, registry, match_dir, wpilog_files[0], 272 metadata, match_client) 273 except Exception: 274 logger.exception(f"Analysis failed for {match_dir}") 275 276 277def _run_analysis_single(analyzer: WpilogAnalyzer, registry: CallbackRegistry, 278 match_dir: Path, wpilog_file: Path, metadata: dict, 279 match_client: 'MatchDataClient') -> None: 280 """Background worker: analyze a specific .wpilog file.""" 281 try: 282 _analyze_and_report(analyzer, registry, match_dir, wpilog_file, 283 metadata, match_client) 284 except Exception: 285 logger.exception(f"Analysis failed for {wpilog_file}") 286 287 288def _level_name(level: int) -> str: 289 """Return a human-readable name for a logging level.""" 290 if level >= LOG_DISABLED: 291 return 'off' 292 return logging.getLevelName(level).lower() 293 294 295def _console_loop(connector: RobotConnector, 296 console_handler: logging.Handler, 297 file_handler: logging.Handler, 298 log_dir: Path, 299 registry: 'CallbackRegistry', 300 discord_holder: list, 301 config_path: Path, 302 code_dir_holder: list, 303 analyzer: WpilogAnalyzer = None, 304 out_path: Path = None, 305 match_client: 'MatchDataClient' = None, 306 quit_callback=None, 307 close_callback=None) -> None: 308 """Interactive command loop running in a daemon thread. 309 310 quit_callback: called when the user types quit/exit/q. If None, sends 311 KeyboardInterrupt to the main thread. 312 close_callback: called when the console is dismissed without quitting 313 (e.g. user closes the console window). In tray mode this 314 frees the console window and resets state without stopping 315 the tray. 316 """ 317 saved_console_level = console_handler.level 318 saved_file_level = file_handler.level 319 console_off = False 320 file_off = False 321 test_mode = False 322 _html_preview_cb = None # HtmlPreviewCallback instance when test mode is on 323 324 def _require_connection() -> bool: 325 if not connector.is_connected: 326 print(" Not connected to robot") 327 return False 328 return True 329 330 def _set_console_level(level: int) -> None: 331 nonlocal saved_console_level, console_off 332 console_handler.setLevel(level) 333 saved_console_level = level 334 console_off = False 335 print(f" Console log level: {_level_name(level)}") 336 337 def _handle_log(parts: list) -> None: 338 nonlocal saved_console_level, saved_file_level, console_off, file_off, file_handler 339 340 if len(parts) == 1: 341 # Just "log" — show current state 342 c_level = _level_name(console_handler.level) 343 f_level = _level_name(file_handler.level) 344 c_state = " (off)" if console_off else "" 345 f_state = " (off)" if file_off else "" 346 print(f" Console: {c_level}{c_state}") 347 print(f" File: {f_level}{f_state}") 348 return 349 350 arg1 = parts[1].lower() 351 352 # log off — disable console 353 if arg1 == 'off': 354 saved_console_level = console_handler.level 355 console_handler.setLevel(LOG_DISABLED) 356 console_off = True 357 print(" Console logging disabled") 358 return 359 360 # log on — re-enable console 361 if arg1 == 'on': 362 console_handler.setLevel(saved_console_level) 363 console_off = False 364 print(f" Console logging enabled ({_level_name(saved_console_level)})") 365 return 366 367 # log rotate 368 if arg1 == 'rotate': 369 logger.removeHandler(file_handler) 370 file_handler.close() 371 new_log_file = log_dir / f"match_monitor_{datetime.now():%Y%m%d_%H%M%S}.log" 372 file_handler = logging.FileHandler(new_log_file) 373 file_handler.setFormatter(logging.Formatter( 374 '%(asctime)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S' 375 )) 376 file_handler.setLevel(saved_file_level) 377 if not file_off: 378 logger.addHandler(file_handler) 379 print(f" Rotated log file: {new_log_file}") 380 return 381 382 # log console <level|off|on> 383 if arg1 == 'console': 384 if len(parts) < 3: 385 print(f" Console: {_level_name(console_handler.level)}") 386 return 387 arg2 = parts[2].lower() 388 if arg2 == 'off': 389 saved_console_level = console_handler.level 390 console_handler.setLevel(LOG_DISABLED) 391 console_off = True 392 print(" Console logging disabled") 393 elif arg2 == 'on': 394 console_handler.setLevel(saved_console_level) 395 console_off = False 396 print(f" Console logging enabled ({_level_name(saved_console_level)})") 397 elif arg2 in LOG_LEVELS: 398 _set_console_level(LOG_LEVELS[arg2]) 399 else: 400 print(f" Unknown level: {arg2} (use debug, info, warning, error, off, on)") 401 return 402 403 # log file <level|off|on> 404 if arg1 == 'file': 405 if len(parts) < 3: 406 print(f" File: {_level_name(file_handler.level)}") 407 return 408 arg2 = parts[2].lower() 409 if arg2 == 'off': 410 saved_file_level = file_handler.level 411 if not file_off: 412 logger.removeHandler(file_handler) 413 file_off = True 414 print(" File logging disabled") 415 elif arg2 == 'on': 416 if file_off: 417 file_handler.setLevel(saved_file_level) 418 logger.addHandler(file_handler) 419 file_off = False 420 print(f" File logging enabled ({_level_name(saved_file_level)})") 421 elif arg2 in LOG_LEVELS: 422 file_handler.setLevel(LOG_LEVELS[arg2]) 423 saved_file_level = LOG_LEVELS[arg2] 424 if file_off: 425 logger.addHandler(file_handler) 426 file_off = False 427 print(f" File log level: {arg2}") 428 else: 429 print(f" Unknown level: {arg2} (use debug, info, warning, error, off, on)") 430 return 431 432 # log <level> — set console level (and re-enable if off) 433 if arg1 in LOG_LEVELS: 434 _set_console_level(LOG_LEVELS[arg1]) 435 return 436 437 print(f" Unknown log command: {arg1}") 438 print(" Usage: log [debug|info|warning|error|off|on]") 439 print(" log console <level> | log file <level> | log rotate") 440 441 def _handle_discord(parts: list) -> None: 442 from .discord_notifier import DiscordNotifier 443 444 notifier: DiscordNotifier = discord_holder[0] 445 446 if len(parts) == 1: 447 # Show current status 448 if notifier and notifier.webhook_url: 449 url = notifier.webhook_url 450 # Mask middle of token for safety 451 masked = url[:45] + '...' + url[-6:] if len(url) > 55 else url 452 print(f" Discord: enabled — {masked}") 453 else: 454 print(" Discord: disabled (no webhook URL set)") 455 return 456 457 arg = parts[1].lower() 458 459 if arg == 'off': 460 if notifier: 461 notifier.webhook_url = '' 462 _save_config_key(config_path, 'discord_webhook_url', '') 463 print(" Discord notifications disabled") 464 return 465 466 if arg == 'test': 467 if not notifier or not notifier.webhook_url: 468 print(" No webhook URL configured — use: discord <url>") 469 return 470 # Send a test message 471 try: 472 notifier._post({ 473 'content': '✅ Match Monitor test message — Discord webhook is working!', 474 }) 475 print(" Test message sent") 476 except Exception as exc: 477 print(f" Test failed: {exc}") 478 return 479 480 # Treat as a URL 481 url = parts[1] # preserve original case 482 if not url.startswith('https://discord.com/api/webhooks/'): 483 print(" Expected a Discord webhook URL " 484 "(https://discord.com/api/webhooks/...)") 485 return 486 487 if notifier is None: 488 notifier = DiscordNotifier(url, code_dir_holder[0]) 489 registry.register(notifier) 490 discord_holder[0] = notifier 491 print(" Discord notifier registered") 492 else: 493 notifier.webhook_url = url 494 print(" Discord webhook URL updated") 495 496 _save_config_key(config_path, 'discord_webhook_url', url) 497 logger.info("Discord webhook URL updated via console") 498 499 def _handle_codedir(parts: list) -> None: 500 if len(parts) == 1: 501 path = code_dir_holder[0] 502 if path: 503 is_git = (path / '.git').exists() 504 git_str = ' (git ✓)' if is_git else ' ⚠ no .git' 505 print(f" Code dir: {path}{git_str}") 506 else: 507 print(" Code dir: not set — use: codedir <path>") 508 return 509 510 new_path = Path(' '.join(parts[1:])).expanduser().resolve() 511 if not new_path.is_dir(): 512 print(f" Not a directory: {new_path}") 513 return 514 515 code_dir_holder[0] = new_path 516 notifier = discord_holder[0] 517 if notifier: 518 notifier._repo_root = new_path 519 _save_config_key(config_path, 'robot_code_dir', str(new_path)) 520 is_git = (new_path / '.git').exists() 521 suffix = '' if is_git else ' ⚠ no .git directory found' 522 print(f" Code dir set: {new_path}{suffix}") 523 logger.info(f"Code directory set to: {new_path}") 524 525 def _print_help() -> None: 526 print() 527 print(" Robot commands (require connection):") 528 print(" list List log files on robot") 529 print(" upload Force robot to start uploading now") 530 print(" stop Stop robot log upload") 531 print(" clear-manifest Clear upload manifest (re-upload all)") 532 print() 533 print(" Connection:") 534 print(" status Show connection state") 535 print(" connect Resume polling / reconnect") 536 print(" disconnect Close connection and stop polling") 537 print() 538 print(" Analysis:") 539 print(" analyze List available .wpilog files (with index)") 540 print(" analyze last Re-analyze the most recent .wpilog file") 541 print(" analyze <N> Analyze Nth file (0=most recent)") 542 print(" analyze <name> Analyze a file by name (partial match)") 543 print(" test enable Open HTML preview in browser on analyze") 544 print(" test disable Send to Discord on analyze (normal mode)") 545 print() 546 print(" Settings:") 547 print(" codedir Show current robot code directory") 548 print(" codedir <path> Set robot code directory (for git info)") 549 print() 550 print(" Discord:") 551 print(" discord Show current webhook status") 552 print(" discord <url> Set/update webhook URL (saved to config)") 553 print(" discord off Disable Discord notifications") 554 print(" discord test Send a test message") 555 print() 556 print(" Logging:") 557 print(" log Show current log levels") 558 print(" log <level> Set console log level (debug/info/warning/error)") 559 print(" log off|on Disable/enable console logging") 560 print(" log console <lv> Set console level explicitly") 561 print(" log file <lv> Set file log level") 562 print(" log file off|on Disable/enable file logging") 563 print(" log rotate Rotate to a new log file") 564 print() 565 print(" help, h, ? Show this help") 566 if close_callback: 567 # Tray mode: quit/exit just closes the console window 568 print(" quit, exit, q Close this console (tray keeps running)") 569 print(" stop-server Shut down the server and exit") 570 else: 571 print(" quit, exit, q Shut down the server and exit") 572 print() 573 574 _quit_requested = False 575 while True: 576 try: 577 line = input("monitor> ").strip() 578 except (EOFError, KeyboardInterrupt): 579 break 580 581 if not line: 582 continue 583 584 parts = line.split() 585 cmd = parts[0].lower() 586 587 if cmd in ('quit', 'exit', 'q'): 588 if close_callback: 589 # Tray mode: just close the console, keep tray running 590 print(" Closing console...") 591 else: 592 # Terminal mode: stop the server 593 _quit_requested = True 594 print(" Shutting down...") 595 import _thread 596 _thread.interrupt_main() 597 break 598 599 if cmd == 'stop-server': 600 print(" Shutting down...") 601 if quit_callback: 602 quit_callback() 603 else: 604 import _thread 605 _thread.interrupt_main() 606 # Don't set _quit_requested so close_callback runs to close the 607 # console window before the process exits. 608 break 609 610 elif cmd in ('help', 'h', '?'): 611 _print_help() 612 613 elif cmd == 'status': 614 info = connector.status_info 615 state = info['state'] 616 print(f" State: {state}") 617 if info['robot_address']: 618 print(f" Robot: {info['robot_address']}") 619 if info['connected_since'] is not None: 620 uptime = time.monotonic() - info['connected_since'] 621 mins, secs = divmod(int(uptime), 60) 622 hrs, mins = divmod(mins, 60) 623 print(f" Uptime: {hrs}h {mins}m {secs}s") 624 print(f" HTTP port: {info['http_port']}") 625 print(f" Attempts: {info['connect_attempts']}") 626 print(f" Polling: {', '.join(info['poll_addresses'])}") 627 if info['last_attempt_addr']: 628 print(f" Last tried: {info['last_attempt_addr']}") 629 630 elif cmd == 'connect': 631 connector.connect() 632 print(" Polling resumed") 633 634 elif cmd == 'disconnect': 635 connector.disconnect() 636 print(" Disconnected, polling stopped") 637 638 elif cmd == 'list': 639 if _require_connection(): 640 connector.send_to_robot({'type': 'LIST_LOGS'}) 641 642 elif cmd == 'upload': 643 if _require_connection(): 644 connector.send_to_robot({'type': 'FORCE_UPLOAD'}) 645 646 elif cmd == 'stop': 647 if _require_connection(): 648 connector.send_to_robot({'type': 'STOP_UPLOAD'}) 649 650 elif cmd == 'clear-manifest': 651 if _require_connection(): 652 connector.send_to_robot({'type': 'CLEAR_MANIFEST'}) 653 654 elif cmd == 'analyze': 655 if analyzer is None or out_path is None: 656 print(" Analyzer not available") 657 elif len(parts) >= 2 and (parts[1].lower() == 'last' or parts[1].isdigit()): 658 # analyze last → index 0 (most recent) 659 # analyze N → index N (0=most recent, 1=second most recent, ...) 660 idx = 0 if parts[1].lower() == 'last' else int(parts[1]) 661 wpilog_files = sorted(out_path.rglob('*.wpilog'), 662 key=lambda f: f.stat().st_mtime, 663 reverse=True) 664 if not wpilog_files: 665 print(" No .wpilog files found") 666 elif idx >= len(wpilog_files): 667 print(f" Index {idx} out of range (have {len(wpilog_files)} files, 0-{len(wpilog_files) - 1})") 668 else: 669 target_file = wpilog_files[idx] 670 match_dir = target_file.parent 671 print(f" Analyzing [{idx}] {target_file.relative_to(out_path)}...") 672 metadata = {'event_name': '', 'match_type': '', 'match_number': ''} 673 threading.Thread( 674 target=_run_analysis_single, 675 args=(analyzer, registry, match_dir, target_file, 676 metadata, match_client), 677 daemon=True, 678 ).start() 679 elif len(parts) < 2: 680 # List available wpilog files 681 wpilog_files = sorted(out_path.rglob('*.wpilog'), 682 key=lambda f: f.stat().st_mtime, 683 reverse=True) 684 if not wpilog_files: 685 print(" No .wpilog files found") 686 else: 687 print(f" {'#':<4} {'File':<52} {'Size':>10}") 688 print(f" {'-' * 4} {'-' * 52} {'-' * 10}") 689 for i, f in enumerate(wpilog_files[:20]): 690 rel = f.relative_to(out_path) 691 size = f.stat().st_size 692 if size >= 1024 * 1024: 693 size_str = f"{size / (1024 * 1024):.1f} MB" 694 elif size >= 1024: 695 size_str = f"{size / 1024:.1f} KB" 696 else: 697 size_str = f"{size} B" 698 print(f" {i:<4} {str(rel):<52} {size_str:>10}") 699 if len(wpilog_files) > 20: 700 print(f" ... and {len(wpilog_files) - 20} more") 701 print() 702 print(" Usage: analyze <filename>") 703 else: 704 # Find and analyze specific file 705 target = ' '.join(parts[1:]) 706 matches = [f for f in out_path.rglob('*.wpilog') 707 if target in f.name or target in str(f.relative_to(out_path))] 708 if not matches: 709 print(f" No .wpilog file matching '{target}'") 710 elif len(matches) > 1: 711 print(f" Multiple matches for '{target}':") 712 for f in matches: 713 print(f" {f.relative_to(out_path)}") 714 else: 715 wpilog_file = matches[0] 716 match_dir = wpilog_file.parent 717 print(f" Analyzing {wpilog_file.name}...") 718 metadata = {'event_name': '', 'match_type': '', 'match_number': ''} 719 threading.Thread( 720 target=_run_analysis_single, 721 args=(analyzer, registry, match_dir, wpilog_file, 722 metadata, match_client), 723 daemon=True, 724 ).start() 725 726 elif cmd == 'test': 727 if len(parts) < 2: 728 print(f" Test mode: {'enabled' if test_mode else 'disabled'}") 729 elif parts[1].lower() in ('enable', 'on'): 730 if not test_mode: 731 from .callbacks import HtmlPreviewCallback 732 _html_preview_cb = HtmlPreviewCallback() 733 registry.register(_html_preview_cb) 734 # Disable Discord while testing 735 notifier = discord_holder[0] 736 if notifier: 737 notifier._saved_url = notifier.webhook_url 738 notifier.webhook_url = '' 739 test_mode = True 740 print(" Test mode enabled — analyze will open HTML preview in browser") 741 elif parts[1].lower() in ('disable', 'off'): 742 if test_mode and _html_preview_cb is not None: 743 registry.unregister(_html_preview_cb) 744 _html_preview_cb = None 745 # Re-enable Discord 746 notifier = discord_holder[0] 747 if notifier and hasattr(notifier, '_saved_url'): 748 notifier.webhook_url = notifier._saved_url 749 del notifier._saved_url 750 test_mode = False 751 print(" Test mode disabled — analyze will send to Discord") 752 else: 753 print(" Usage: test enable|disable") 754 755 elif cmd == 'discord': 756 _handle_discord(parts) 757 758 elif cmd == 'codedir': 759 _handle_codedir(parts) 760 761 elif cmd == 'log': 762 _handle_log(parts) 763 764 else: 765 print(f" Unknown command: {cmd}. Type 'help' for available commands.") 766 767 # Loop exited — if not a deliberate quit, notify caller so it can clean up 768 # the console window without stopping the tray. 769 if not _quit_requested and close_callback: 770 close_callback() 771 772 773def setup_server(bind: str, port: int, output_dir: Optional[str] = None, 774 debug: bool = False) -> ServerContext: 775 """Create all server components and return a ServerContext (does not start serving).""" 776 if output_dir is None: 777 output_dir = r'C:\Users\Public\Documents\FRC\Log Files\WPILogs' 778 779 out_path = Path(output_dir).resolve() 780 out_path.mkdir(parents=True, exist_ok=True) 781 782 # Set up file logging under <output_dir>/server/ 783 log_dir = out_path / 'server' 784 log_dir.mkdir(parents=True, exist_ok=True) 785 log_file = log_dir / f"match_monitor_{datetime.now():%Y%m%d_%H%M%S}.log" 786 787 file_handler = logging.FileHandler(log_file) 788 file_handler.setFormatter(logging.Formatter( 789 '%(asctime)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S' 790 )) 791 logger.addHandler(file_handler) 792 793 # pythonw.exe sets sys.stderr = None; use NullHandler so no file is needed. 794 # The stream is replaced when the tray console is opened. 795 if sys.stderr is not None: 796 console_handler = logging.StreamHandler(sys.stderr) 797 else: 798 console_handler = logging.NullHandler() 799 console_handler.setFormatter(logging.Formatter( 800 '[%(asctime)s] %(message)s', datefmt='%H:%M:%S' 801 )) 802 logger.addHandler(console_handler) 803 logger.setLevel(logging.DEBUG if debug else logging.INFO) 804 805 LogReceiverHandler.output_dir = out_path 806 analyzer = WpilogAnalyzer() 807 LogReceiverHandler.analyzer = analyzer 808 809 registry = CallbackRegistry() 810 from .callbacks import JsonSummaryCallback 811 registry.register(JsonSummaryCallback()) 812 LogReceiverHandler.callback_registry = registry 813 814 # Load shared config once 815 config_path = out_path / 'match_monitor_config.json' 816 _cfg: dict = {} 817 if config_path.exists(): 818 try: 819 import json as _json 820 _cfg = _json.loads(config_path.read_text()) 821 except Exception: 822 logger.warning(f"Failed to read {config_path}") 823 824 # Match data client 825 match_client = MatchDataClient(config_path) 826 if match_client.configured: 827 logger.info("Match data client configured (TBA/FRC Events API)") 828 else: 829 logger.info("Match data client not configured — skipping official match data " 830 "(add tba_api_key or frc_events credentials to match_monitor_config.json)") 831 832 # Code directory — used for git info in Discord embeds and hash verification. 833 # Prefer a user-saved path; fall back to source-relative root when .git exists. 834 _source_root = Path(__file__).parent.parent.parent 835 _saved_code_dir = _cfg.get('robot_code_dir', '').strip() 836 if _saved_code_dir and Path(_saved_code_dir).is_dir(): 837 _code_dir: Optional[Path] = Path(_saved_code_dir) 838 elif (_source_root / '.git').exists(): 839 _code_dir = _source_root 840 else: 841 _code_dir = None 842 code_dir_holder: list = [_code_dir] # mutable ref shared with console loop / tray 843 844 if _code_dir: 845 logger.info(f"Code directory: {_code_dir}") 846 else: 847 logger.info("Code directory not set — git info unavailable " 848 "(use 'codedir <path>' or Set Code Directory in the tray menu)") 849 850 # Discord notifier 851 discord_url = _cfg.get('discord_webhook_url', '').strip() 852 discord_holder: list = [None] # mutable ref shared with console loop 853 if discord_url: 854 from .discord_notifier import DiscordNotifier 855 discord_holder[0] = DiscordNotifier(discord_url, code_dir_holder[0]) 856 registry.register(discord_holder[0]) 857 logger.info("Discord notifier registered") 858 else: 859 logger.info("Discord notifier not configured " 860 "(use 'discord <url>' at the monitor prompt, or add to match_monitor_config.json)") 861 862 # Build match directory path from metadata and trigger analysis 863 def on_upload_complete(metadata: dict) -> None: 864 event_name = metadata.get('event_name', '').strip() 865 match_type = metadata.get('match_type', '').strip() 866 match_number = metadata.get('match_number', '').strip() 867 868 if event_name and match_type and match_number: 869 sub_dir = f"{event_name}/{match_type}_{match_number}" 870 elif event_name: 871 sub_dir = event_name 872 else: 873 sub_dir = f"unknown/{datetime.now():%Y-%m-%d}" 874 875 match_dir = out_path / sub_dir 876 if not match_dir.is_dir(): 877 logger.warning(f"Match dir not found for analysis: {sub_dir}") 878 print(f" [!] Match dir not found for analysis: {match_dir}") 879 return 880 881 print(f" Starting analysis for {sub_dir}...") 882 threading.Thread( 883 target=_run_analysis, 884 args=(analyzer, registry, match_dir, metadata, match_client), 885 daemon=True, 886 ).start() 887 888 connector = RobotConnector(http_port=port, on_upload_complete=on_upload_complete) 889 connector.start() 890 891 server = HTTPServer((bind, port), LogReceiverHandler) 892 893 return ServerContext( 894 server=server, 895 connector=connector, 896 registry=registry, 897 discord_holder=discord_holder, 898 match_client=match_client, 899 console_handler=console_handler, 900 file_handler=file_handler, 901 log_dir=log_dir, 902 out_path=out_path, 903 config_path=config_path, 904 code_dir_holder=code_dir_holder, 905 log_file=log_file, 906 analyzer=analyzer, 907 ) 908 909 910def _start_console_thread(ctx: ServerContext, analyzer: WpilogAnalyzer = None, 911 match_client: 'MatchDataClient' = None) -> None: 912 """Start the interactive console loop as a daemon thread.""" 913 threading.Thread( 914 target=_console_loop, 915 args=(ctx.connector, ctx.console_handler, ctx.file_handler, ctx.log_dir, 916 ctx.registry, ctx.discord_holder, ctx.config_path, ctx.code_dir_holder), 917 kwargs={'analyzer': analyzer, 'out_path': ctx.out_path, 918 'match_client': match_client}, 919 daemon=True, 920 ).start() 921 922 923def run_server(bind: str, port: int, output_dir: Optional[str] = None, 924 debug: bool = False) -> None: 925 """Start the log receiver HTTP server with an interactive console.""" 926 ctx = setup_server(bind, port, output_dir, debug) 927 928 logger.info(f"Match Monitor started on {bind}:{port}, saving to {ctx.out_path}") 929 print("Match Monitor - Log Receiver") 930 print(f"Listening on {bind}:{port}") 931 print(f"Saving files to {ctx.out_path}") 932 print(f"Server log: {ctx.log_file}") 933 print("TCP connector polling for roboRIO on port 5805") 934 print("Type 'help' for commands. Press Ctrl+C to stop.") 935 936 _start_console_thread(ctx, analyzer=ctx.analyzer, match_client=ctx.match_client) 937 938 try: 939 ctx.server.serve_forever() 940 except KeyboardInterrupt: 941 ctx.connector.stop() 942 logger.info("Shutting down") 943 print("\nShutting down") 944 ctx.server.shutdown()
41@dataclass 42class ServerContext: 43 """All live components created by setup_server().""" 44 server: HTTPServer 45 connector: 'RobotConnector' 46 registry: 'CallbackRegistry' 47 discord_holder: list 48 match_client: object 49 console_handler: logging.Handler 50 file_handler: logging.Handler 51 log_dir: Path 52 out_path: Path 53 config_path: Path 54 code_dir_holder: list # [Path | None] — mutable; updated by codedir command / tray 55 log_file: Path 56 analyzer: 'WpilogAnalyzer' = None
All live components created by setup_server().
68class LogReceiverHandler(BaseHTTPRequestHandler): 69 """Handles POST /upload, GET /check, and GET /status requests.""" 70 71 output_dir: Path # Set by run_server before starting 72 callback_registry: CallbackRegistry # Set by run_server 73 analyzer: WpilogAnalyzer # Set by run_server 74 75 def do_GET(self): 76 if self.path == '/status': 77 self._send_json(200, { 78 'status': 'ready', 79 'output_dir': str(self.output_dir), 80 'timestamp': datetime.now().isoformat(), 81 }) 82 elif self.path == '/check': 83 self._handle_check() 84 else: 85 self._send_json(404, {'error': 'not found'}) 86 87 def _handle_check(self): 88 """Return SHA-256 of a stored file so the client can compare.""" 89 filename = self.headers.get('X-Filename') 90 if not filename: 91 self._send_json(400, {'error': 'X-Filename header required'}) 92 return 93 94 filename = Path(filename).name 95 sub_dir = self._build_match_dir() 96 dest = self.output_dir / sub_dir / filename 97 98 if not dest.exists(): 99 self._send_json(200, {'exists': False, 'filename': filename}) 100 return 101 102 sha256 = hashlib.sha256(dest.read_bytes()).hexdigest() 103 self._send_json(200, { 104 'exists': True, 105 'filename': filename, 106 'sha256': sha256, 107 'size': dest.stat().st_size, 108 }) 109 110 def do_POST(self): 111 if self.path != '/upload': 112 self._send_json(404, {'error': 'not found'}) 113 return 114 115 filename = self.headers.get('X-Filename') 116 if not filename: 117 self._send_json(400, {'error': 'X-Filename header required'}) 118 return 119 120 # Sanitize filename 121 filename = Path(filename).name 122 if not filename: 123 self._send_json(400, {'error': 'invalid filename'}) 124 return 125 126 # Build subdirectory from match metadata 127 sub_dir = self._build_match_dir() 128 dest_dir = self.output_dir / sub_dir 129 dest_dir.mkdir(parents=True, exist_ok=True) 130 131 dest = dest_dir / filename 132 overwrite = self.headers.get('X-Overwrite', '').lower() == 'true' 133 if dest.exists() and not overwrite: 134 logger.info(f"Duplicate skipped: {sub_dir}/{filename}") 135 self._send_json(409, {'error': 'file already exists', 136 'filename': filename}) 137 return 138 139 content_length = int(self.headers.get('Content-Length', 0)) 140 if content_length == 0: 141 self._send_json(400, {'error': 'empty body'}) 142 return 143 144 part_path = dest.with_suffix(dest.suffix + '.part') 145 try: 146 start_time = time.monotonic() 147 raw_data = self.rfile.read(content_length) 148 compressed_size = len(raw_data) 149 150 # Decompress if client sent compressed data 151 content_encoding = self.headers.get('Content-Encoding', '') 152 if content_encoding == 'deflate': 153 data = zlib.decompress(raw_data) 154 uncompressed_size = len(data) 155 else: 156 data = raw_data 157 uncompressed_size = len(data) 158 159 part_path.write_bytes(data) 160 import os as _os 161 _os.replace(str(part_path), str(dest)) # os.replace() atomically overwrites on all platforms 162 elapsed = time.monotonic() - start_time 163 rate_mbps = (compressed_size / (1024 * 1024)) / elapsed if elapsed > 0 else 0 164 165 action = "Updated" if overwrite and dest.exists() else "Received" 166 if content_encoding == 'deflate': 167 savings = (1 - compressed_size / uncompressed_size) * 100 if uncompressed_size else 0 168 compressed_mb = compressed_size / (1024 * 1024) 169 uncompressed_mb = uncompressed_size / (1024 * 1024) 170 msg = (f"{action}: {sub_dir}/{filename} " 171 f"({compressed_mb:.1f} MB compressed -> {uncompressed_mb:.1f} MB, " 172 f"{savings:.0f}% savings, {elapsed:.2f}s, {rate_mbps:.1f} MB/s)") 173 else: 174 msg = (f"{action}: {sub_dir}/{filename} " 175 f"({len(data):,} bytes in {elapsed:.2f}s, {rate_mbps:.1f} MB/s)") 176 logger.info(msg) 177 print(f"[{datetime.now():%H:%M:%S}] {msg}") 178 179 self._send_json(200, {'status': 'ok', 'filename': filename, 180 'size': len(data), 181 'transfer_seconds': round(elapsed, 3)}) 182 except Exception as e: 183 logger.exception(f"Failed to save {filename}") 184 if part_path.exists(): 185 part_path.unlink() 186 self._send_json(500, {'error': str(e)}) 187 188 def _build_match_dir(self) -> str: 189 event_name = self.headers.get('X-Event-Name', '').strip() 190 match_type = self.headers.get('X-Match-Type', '').strip() 191 match_number = self.headers.get('X-Match-Number', '').strip() 192 193 if event_name and match_type and match_number: 194 return f"{event_name}/{match_type}_{match_number}" 195 elif event_name: 196 return event_name 197 else: 198 return f"unknown/{datetime.now():%Y-%m-%d}" 199 200 def _send_json(self, status_code: int, body: dict): 201 payload = json.dumps(body).encode('utf-8') 202 self.send_response(status_code) 203 self.send_header('Content-Type', 'application/json') 204 self.send_header('Content-Length', str(len(payload))) 205 self.end_headers() 206 self.wfile.write(payload) 207 208 def log_message(self, format, *args): 209 pass
Handles POST /upload, GET /check, and GET /status requests.
110 def do_POST(self): 111 if self.path != '/upload': 112 self._send_json(404, {'error': 'not found'}) 113 return 114 115 filename = self.headers.get('X-Filename') 116 if not filename: 117 self._send_json(400, {'error': 'X-Filename header required'}) 118 return 119 120 # Sanitize filename 121 filename = Path(filename).name 122 if not filename: 123 self._send_json(400, {'error': 'invalid filename'}) 124 return 125 126 # Build subdirectory from match metadata 127 sub_dir = self._build_match_dir() 128 dest_dir = self.output_dir / sub_dir 129 dest_dir.mkdir(parents=True, exist_ok=True) 130 131 dest = dest_dir / filename 132 overwrite = self.headers.get('X-Overwrite', '').lower() == 'true' 133 if dest.exists() and not overwrite: 134 logger.info(f"Duplicate skipped: {sub_dir}/{filename}") 135 self._send_json(409, {'error': 'file already exists', 136 'filename': filename}) 137 return 138 139 content_length = int(self.headers.get('Content-Length', 0)) 140 if content_length == 0: 141 self._send_json(400, {'error': 'empty body'}) 142 return 143 144 part_path = dest.with_suffix(dest.suffix + '.part') 145 try: 146 start_time = time.monotonic() 147 raw_data = self.rfile.read(content_length) 148 compressed_size = len(raw_data) 149 150 # Decompress if client sent compressed data 151 content_encoding = self.headers.get('Content-Encoding', '') 152 if content_encoding == 'deflate': 153 data = zlib.decompress(raw_data) 154 uncompressed_size = len(data) 155 else: 156 data = raw_data 157 uncompressed_size = len(data) 158 159 part_path.write_bytes(data) 160 import os as _os 161 _os.replace(str(part_path), str(dest)) # os.replace() atomically overwrites on all platforms 162 elapsed = time.monotonic() - start_time 163 rate_mbps = (compressed_size / (1024 * 1024)) / elapsed if elapsed > 0 else 0 164 165 action = "Updated" if overwrite and dest.exists() else "Received" 166 if content_encoding == 'deflate': 167 savings = (1 - compressed_size / uncompressed_size) * 100 if uncompressed_size else 0 168 compressed_mb = compressed_size / (1024 * 1024) 169 uncompressed_mb = uncompressed_size / (1024 * 1024) 170 msg = (f"{action}: {sub_dir}/{filename} " 171 f"({compressed_mb:.1f} MB compressed -> {uncompressed_mb:.1f} MB, " 172 f"{savings:.0f}% savings, {elapsed:.2f}s, {rate_mbps:.1f} MB/s)") 173 else: 174 msg = (f"{action}: {sub_dir}/{filename} " 175 f"({len(data):,} bytes in {elapsed:.2f}s, {rate_mbps:.1f} MB/s)") 176 logger.info(msg) 177 print(f"[{datetime.now():%H:%M:%S}] {msg}") 178 179 self._send_json(200, {'status': 'ok', 'filename': filename, 180 'size': len(data), 181 'transfer_seconds': round(elapsed, 3)}) 182 except Exception as e: 183 logger.exception(f"Failed to save {filename}") 184 if part_path.exists(): 185 part_path.unlink() 186 self._send_json(500, {'error': str(e)})
Log an arbitrary message.
This is used by all other logging functions. Override it if you have specific logging wishes.
The first argument, FORMAT, is a format string for the message to be logged. If the format string contains any % escapes requiring parameters, they should be specified as subsequent arguments (it's just like printf!).
The client ip and current date/time are prefixed to every message.
Unicode control characters are replaced with escaped hex before writing the output to stderr.
774def setup_server(bind: str, port: int, output_dir: Optional[str] = None, 775 debug: bool = False) -> ServerContext: 776 """Create all server components and return a ServerContext (does not start serving).""" 777 if output_dir is None: 778 output_dir = r'C:\Users\Public\Documents\FRC\Log Files\WPILogs' 779 780 out_path = Path(output_dir).resolve() 781 out_path.mkdir(parents=True, exist_ok=True) 782 783 # Set up file logging under <output_dir>/server/ 784 log_dir = out_path / 'server' 785 log_dir.mkdir(parents=True, exist_ok=True) 786 log_file = log_dir / f"match_monitor_{datetime.now():%Y%m%d_%H%M%S}.log" 787 788 file_handler = logging.FileHandler(log_file) 789 file_handler.setFormatter(logging.Formatter( 790 '%(asctime)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S' 791 )) 792 logger.addHandler(file_handler) 793 794 # pythonw.exe sets sys.stderr = None; use NullHandler so no file is needed. 795 # The stream is replaced when the tray console is opened. 796 if sys.stderr is not None: 797 console_handler = logging.StreamHandler(sys.stderr) 798 else: 799 console_handler = logging.NullHandler() 800 console_handler.setFormatter(logging.Formatter( 801 '[%(asctime)s] %(message)s', datefmt='%H:%M:%S' 802 )) 803 logger.addHandler(console_handler) 804 logger.setLevel(logging.DEBUG if debug else logging.INFO) 805 806 LogReceiverHandler.output_dir = out_path 807 analyzer = WpilogAnalyzer() 808 LogReceiverHandler.analyzer = analyzer 809 810 registry = CallbackRegistry() 811 from .callbacks import JsonSummaryCallback 812 registry.register(JsonSummaryCallback()) 813 LogReceiverHandler.callback_registry = registry 814 815 # Load shared config once 816 config_path = out_path / 'match_monitor_config.json' 817 _cfg: dict = {} 818 if config_path.exists(): 819 try: 820 import json as _json 821 _cfg = _json.loads(config_path.read_text()) 822 except Exception: 823 logger.warning(f"Failed to read {config_path}") 824 825 # Match data client 826 match_client = MatchDataClient(config_path) 827 if match_client.configured: 828 logger.info("Match data client configured (TBA/FRC Events API)") 829 else: 830 logger.info("Match data client not configured — skipping official match data " 831 "(add tba_api_key or frc_events credentials to match_monitor_config.json)") 832 833 # Code directory — used for git info in Discord embeds and hash verification. 834 # Prefer a user-saved path; fall back to source-relative root when .git exists. 835 _source_root = Path(__file__).parent.parent.parent 836 _saved_code_dir = _cfg.get('robot_code_dir', '').strip() 837 if _saved_code_dir and Path(_saved_code_dir).is_dir(): 838 _code_dir: Optional[Path] = Path(_saved_code_dir) 839 elif (_source_root / '.git').exists(): 840 _code_dir = _source_root 841 else: 842 _code_dir = None 843 code_dir_holder: list = [_code_dir] # mutable ref shared with console loop / tray 844 845 if _code_dir: 846 logger.info(f"Code directory: {_code_dir}") 847 else: 848 logger.info("Code directory not set — git info unavailable " 849 "(use 'codedir <path>' or Set Code Directory in the tray menu)") 850 851 # Discord notifier 852 discord_url = _cfg.get('discord_webhook_url', '').strip() 853 discord_holder: list = [None] # mutable ref shared with console loop 854 if discord_url: 855 from .discord_notifier import DiscordNotifier 856 discord_holder[0] = DiscordNotifier(discord_url, code_dir_holder[0]) 857 registry.register(discord_holder[0]) 858 logger.info("Discord notifier registered") 859 else: 860 logger.info("Discord notifier not configured " 861 "(use 'discord <url>' at the monitor prompt, or add to match_monitor_config.json)") 862 863 # Build match directory path from metadata and trigger analysis 864 def on_upload_complete(metadata: dict) -> None: 865 event_name = metadata.get('event_name', '').strip() 866 match_type = metadata.get('match_type', '').strip() 867 match_number = metadata.get('match_number', '').strip() 868 869 if event_name and match_type and match_number: 870 sub_dir = f"{event_name}/{match_type}_{match_number}" 871 elif event_name: 872 sub_dir = event_name 873 else: 874 sub_dir = f"unknown/{datetime.now():%Y-%m-%d}" 875 876 match_dir = out_path / sub_dir 877 if not match_dir.is_dir(): 878 logger.warning(f"Match dir not found for analysis: {sub_dir}") 879 print(f" [!] Match dir not found for analysis: {match_dir}") 880 return 881 882 print(f" Starting analysis for {sub_dir}...") 883 threading.Thread( 884 target=_run_analysis, 885 args=(analyzer, registry, match_dir, metadata, match_client), 886 daemon=True, 887 ).start() 888 889 connector = RobotConnector(http_port=port, on_upload_complete=on_upload_complete) 890 connector.start() 891 892 server = HTTPServer((bind, port), LogReceiverHandler) 893 894 return ServerContext( 895 server=server, 896 connector=connector, 897 registry=registry, 898 discord_holder=discord_holder, 899 match_client=match_client, 900 console_handler=console_handler, 901 file_handler=file_handler, 902 log_dir=log_dir, 903 out_path=out_path, 904 config_path=config_path, 905 code_dir_holder=code_dir_holder, 906 log_file=log_file, 907 analyzer=analyzer, 908 )
Create all server components and return a ServerContext (does not start serving).
924def run_server(bind: str, port: int, output_dir: Optional[str] = None, 925 debug: bool = False) -> None: 926 """Start the log receiver HTTP server with an interactive console.""" 927 ctx = setup_server(bind, port, output_dir, debug) 928 929 logger.info(f"Match Monitor started on {bind}:{port}, saving to {ctx.out_path}") 930 print("Match Monitor - Log Receiver") 931 print(f"Listening on {bind}:{port}") 932 print(f"Saving files to {ctx.out_path}") 933 print(f"Server log: {ctx.log_file}") 934 print("TCP connector polling for roboRIO on port 5805") 935 print("Type 'help' for commands. Press Ctrl+C to stop.") 936 937 _start_console_thread(ctx, analyzer=ctx.analyzer, match_client=ctx.match_client) 938 939 try: 940 ctx.server.serve_forever() 941 except KeyboardInterrupt: 942 ctx.connector.stop() 943 logger.info("Shutting down") 944 print("\nShutting down") 945 ctx.server.shutdown()
Start the log receiver HTTP server with an interactive console.