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()
logger = <Logger match_monitor (DEBUG)>
@dataclass
class ServerContext:
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().

ServerContext( server: http.server.HTTPServer, connector: host.match_monitor.connector.RobotConnector, registry: host.match_monitor.callbacks.CallbackRegistry, discord_holder: list, match_client: object, console_handler: logging.Handler, file_handler: logging.Handler, log_dir: pathlib.Path, out_path: pathlib.Path, config_path: pathlib.Path, code_dir_holder: list, log_file: pathlib.Path, analyzer: host.match_monitor.analyzer.WpilogAnalyzer = None)
server: http.server.HTTPServer
discord_holder: list
match_client: object
console_handler: logging.Handler
file_handler: logging.Handler
log_dir: pathlib.Path
out_path: pathlib.Path
config_path: pathlib.Path
code_dir_holder: list
log_file: pathlib.Path
LOG_LEVELS = {'debug': 10, 'info': 20, 'warning': 30, 'error': 40}
LOG_DISABLED = 51
class LogReceiverHandler(http.server.BaseHTTPRequestHandler):
 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.

output_dir: pathlib.Path
def do_GET(self):
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'})
def do_POST(self):
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)})
def log_message(self, format, *args):
208    def log_message(self, format, *args):
209        pass

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.

def setup_server( bind: str, port: int, output_dir: str | None = None, debug: bool = False) -> ServerContext:
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).

def run_server( bind: str, port: int, output_dir: str | None = None, debug: bool = False) -> None:
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.