host.match_monitor.match_data_client

Fetch official match data from The Blue Alliance (TBA) and FRC Events API.

Configuration is read from a JSON file (default: match_monitor_config.json next to the server log directory). Create it with your API keys:

{
    "tba_api_key": "YOUR_TBA_KEY",
    "frc_events_username": "YOUR_USERNAME",
    "frc_events_auth_key": "YOUR_AUTH_KEY",
    "team_number": 3200
}

TBA keys: https://www.thebluealliance.com (Account > Read API Keys) FRC Events keys: https://frc-events.firstinspires.org/services/api/register

  1"""
  2Fetch official match data from The Blue Alliance (TBA) and FRC Events API.
  3
  4Configuration is read from a JSON file (default: match_monitor_config.json
  5next to the server log directory).  Create it with your API keys:
  6
  7    {
  8        "tba_api_key": "YOUR_TBA_KEY",
  9        "frc_events_username": "YOUR_USERNAME",
 10        "frc_events_auth_key": "YOUR_AUTH_KEY",
 11        "team_number": 3200
 12    }
 13
 14TBA keys: https://www.thebluealliance.com (Account > Read API Keys)
 15FRC Events keys: https://frc-events.firstinspires.org/services/api/register
 16"""
 17
 18import base64
 19import json
 20import logging
 21import urllib.error
 22import urllib.request
 23from dataclasses import dataclass, field
 24from pathlib import Path
 25from typing import Optional
 26
 27logger = logging.getLogger("match_monitor")
 28
 29_TBA_BASE = "https://www.thebluealliance.com/api/v3"
 30_FRC_BASE = "https://frc-api.firstinspires.org/v2.0"
 31_TIMEOUT = 10  # seconds
 32
 33
 34@dataclass
 35class AllianceResult:
 36    teams: list[str] = field(default_factory=list)   # e.g. ["frc3200", "frc1234", "frc5678"]
 37    score: Optional[int] = None
 38    won: bool = False
 39
 40
 41@dataclass
 42class MatchResult:
 43    """Official match result from TBA / FRC Events API."""
 44    match_key: str = ""           # e.g. "2026calas_qm12"
 45    event_key: str = ""           # e.g. "2026calas"
 46    match_number: int = 0
 47    tournament_level: str = ""    # "qm", "sf", "f"
 48    red: AllianceResult = field(default_factory=AllianceResult)
 49    blue: AllianceResult = field(default_factory=AllianceResult)
 50    score_breakdown: dict = field(default_factory=dict)
 51    our_alliance: str = ""        # "red" or "blue"
 52    our_score: Optional[int] = None
 53    opponent_score: Optional[int] = None
 54    result: str = ""              # "win", "loss", "tie", "unknown"
 55    video_url: Optional[str] = None
 56
 57    def to_dict(self) -> dict:
 58        return {
 59            'match_key': self.match_key,
 60            'event_key': self.event_key,
 61            'match_number': self.match_number,
 62            'tournament_level': self.tournament_level,
 63            'red': {'teams': self.red.teams, 'score': self.red.score},
 64            'blue': {'teams': self.blue.teams, 'score': self.blue.score},
 65            'score_breakdown': self.score_breakdown,
 66            'our_alliance': self.our_alliance,
 67            'our_score': self.our_score,
 68            'opponent_score': self.opponent_score,
 69            'result': self.result,
 70            'video_url': self.video_url,
 71        }
 72
 73
 74@dataclass
 75class EventStats:
 76    """OPR/rankings data for the team at this event."""
 77    opr: Optional[float] = None
 78    dpr: Optional[float] = None
 79    ccwm: Optional[float] = None
 80    rank: Optional[int] = None
 81    wins: Optional[int] = None
 82    losses: Optional[int] = None
 83    ties: Optional[int] = None
 84    ranking_points: Optional[float] = None
 85
 86    def to_dict(self) -> dict:
 87        return {k: v for k, v in vars(self).items() if v is not None}
 88
 89
 90def _load_config(config_path: Path) -> dict:
 91    """Load API key config from JSON file.  Returns empty dict if missing."""
 92    if config_path.exists():
 93        try:
 94            return json.loads(config_path.read_text())
 95        except Exception:
 96            logger.warning(f"Failed to read match data config: {config_path}")
 97    return {}
 98
 99
100def _tba_get(endpoint: str, api_key: str) -> Optional[dict | list]:
101    """Make a GET request to the TBA API.  Returns parsed JSON or None."""
102    url = f"{_TBA_BASE}/{endpoint.lstrip('/')}"
103    req = urllib.request.Request(url, headers={
104        'X-TBA-Auth-Key': api_key,
105        'User-Agent': 'FRC3200-MatchMonitor/1.0',
106    })
107    try:
108        with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
109            return json.loads(resp.read().decode())
110    except urllib.error.HTTPError as e:
111        logger.warning(f"TBA API error {e.code} for {endpoint}")
112    except urllib.error.URLError as e:
113        # Connection failure (no internet, DNS, timeout) — expected offline
114        logger.debug(f"TBA API unreachable ({e.reason})")
115    except Exception as e:
116        logger.debug(f"TBA API request failed for {endpoint}: {e}")
117    return None
118
119
120def _frc_get(endpoint: str, username: str, auth_key: str) -> Optional[dict | list]:
121    """Make a GET request to the FRC Events API.  Returns parsed JSON or None."""
122    url = f"{_FRC_BASE}/{endpoint.lstrip('/')}"
123    token = base64.b64encode(f"{username}:{auth_key}".encode()).decode()
124    req = urllib.request.Request(url, headers={
125        'Authorization': f'Basic {token}',
126        'Accept': 'application/json',
127    })
128    try:
129        with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
130            return json.loads(resp.read().decode())
131    except urllib.error.HTTPError as e:
132        logger.warning(f"FRC Events API error {e.code} for {endpoint}")
133    except urllib.error.URLError as e:
134        # Connection failure (no internet, DNS, timeout) — expected offline
135        logger.debug(f"FRC Events API unreachable ({e.reason})")
136    except Exception as e:
137        logger.debug(f"FRC Events API request failed for {endpoint}: {e}")
138    return None
139
140
141def _alliance_result_from_tba(raw: dict) -> AllianceResult:
142    return AllianceResult(
143        teams=raw.get('team_keys', []),
144        score=raw.get('score'),
145    )
146
147
148class MatchDataClient:
149    """Fetches match data from TBA and FRC Events API for a given team/event."""
150
151    def __init__(self, config_path: Path) -> None:
152        cfg = _load_config(config_path)
153        self._tba_key: str = cfg.get('tba_api_key', '')
154        self._frc_user: str = cfg.get('frc_events_username', '')
155        self._frc_auth: str = cfg.get('frc_events_auth_key', '')
156        self._team_num: int = int(cfg.get('team_number', 3200))
157        self._team_key = f"frc{self._team_num}"
158
159    @property
160    def configured(self) -> bool:
161        return bool(self._tba_key or (self._frc_user and self._frc_auth))
162
163    def fetch_match(self, event_key: str, match_number: int,
164                    level: str = 'qm') -> Optional[MatchResult]:
165        """
166        Fetch a single match result.
167        level: 'qm' (qualification), 'sf' (semifinal), 'f' (final)
168        """
169        match_key = f"{event_key}_{level}{match_number}"
170
171        if self._tba_key:
172            result = self._fetch_match_tba(match_key)
173            if result:
174                return result
175
176        if self._frc_user and self._frc_auth:
177            return self._fetch_match_frc(event_key, match_number, level)
178
179        return None
180
181    def _fetch_match_tba(self, match_key: str) -> Optional[MatchResult]:
182        data = _tba_get(f"/match/{match_key}", self._tba_key)
183        if not data or not isinstance(data, dict):
184            return None
185
186        alliances = data.get('alliances', {})
187        red = _alliance_result_from_tba(alliances.get('red', {}))
188        blue = _alliance_result_from_tba(alliances.get('blue', {}))
189
190        # Determine winner
191        winning_alliance = data.get('winning_alliance', '')
192        if winning_alliance == 'red':
193            red.won = True
194        elif winning_alliance == 'blue':
195            blue.won = True
196
197        # Find our alliance
198        our_alliance = ''
199        if self._team_key in red.teams:
200            our_alliance = 'red'
201        elif self._team_key in blue.teams:
202            our_alliance = 'blue'
203
204        our_score = (red.score if our_alliance == 'red' else
205                     blue.score if our_alliance == 'blue' else None)
206        opp_score = (blue.score if our_alliance == 'red' else
207                     red.score if our_alliance == 'blue' else None)
208
209        result_str = 'unknown'
210        if our_alliance:
211            if winning_alliance == our_alliance:
212                result_str = 'win'
213            elif winning_alliance and winning_alliance != our_alliance:
214                result_str = 'loss'
215            elif winning_alliance == '':
216                result_str = 'tie'
217
218        # Video URL (first available)
219        video_url = None
220        for vid in data.get('videos', []):
221            if vid.get('type') == 'youtube':
222                video_url = f"https://www.youtube.com/watch?v={vid['key']}"
223                break
224
225        # Score breakdown (game-specific)
226        breakdown = data.get('score_breakdown') or {}
227
228        return MatchResult(
229            match_key=data.get('key', ''),
230            event_key=data.get('event_key', ''),
231            match_number=data.get('match_number', 0),
232            tournament_level=data.get('comp_level', ''),
233            red=red,
234            blue=blue,
235            score_breakdown=breakdown,
236            our_alliance=our_alliance,
237            our_score=our_score,
238            opponent_score=opp_score,
239            result=result_str,
240            video_url=video_url,
241        )
242
243    def _fetch_match_frc(self, event_key: str, match_number: int,
244                          level: str) -> Optional[MatchResult]:
245        year = event_key[:4]
246        event_code = event_key[4:]
247        level_map = {'qm': 'Qualification', 'sf': 'Playoff', 'f': 'Playoff'}
248        tournament_level = level_map.get(level, 'Qualification')
249        data = _frc_get(
250            f"/{year}/matches/{event_code}?matchNumber={match_number}"
251            f"&tournamentLevel={tournament_level}",
252            self._frc_user, self._frc_auth,
253        )
254        if not data or not isinstance(data, dict):
255            return None
256
257        matches = data.get('Matches', [])
258        if not matches:
259            return None
260
261        m = matches[0]
262        red_teams = [f"frc{t['teamNumber']}" for t in m.get('teams', [])
263                     if t.get('station', '').startswith('Red')]
264        blue_teams = [f"frc{t['teamNumber']}" for t in m.get('teams', [])
265                      if t.get('station', '').startswith('Blue')]
266        red_score = m.get('scoreRedFinal')
267        blue_score = m.get('scoreBlueFinal')
268
269        our_alliance = ''
270        if self._team_key in red_teams:
271            our_alliance = 'red'
272        elif self._team_key in blue_teams:
273            our_alliance = 'blue'
274
275        our_score = red_score if our_alliance == 'red' else blue_score if our_alliance == 'blue' else None
276        opp_score = blue_score if our_alliance == 'red' else red_score if our_alliance == 'blue' else None
277
278        result_str = 'unknown'
279        if our_score is not None and opp_score is not None:
280            if our_score > opp_score:
281                result_str = 'win'
282            elif our_score < opp_score:
283                result_str = 'loss'
284            else:
285                result_str = 'tie'
286
287        return MatchResult(
288            match_key=f"{event_key}_{level}{match_number}",
289            event_key=event_key,
290            match_number=match_number,
291            tournament_level=level,
292            red=AllianceResult(teams=red_teams, score=red_score),
293            blue=AllianceResult(teams=blue_teams, score=blue_score),
294            our_alliance=our_alliance,
295            our_score=our_score,
296            opponent_score=opp_score,
297            result=result_str,
298        )
299
300    def fetch_event_stats(self, event_key: str) -> Optional[EventStats]:
301        """Fetch OPR, DPR, CCWM, and ranking for our team at this event."""
302        if not self._tba_key:
303            return None
304
305        stats = EventStats()
306
307        # OPR/DPR/CCWM
308        oprs = _tba_get(f"/event/{event_key}/oprs", self._tba_key)
309        if oprs and isinstance(oprs, dict):
310            stats.opr = oprs.get('oprs', {}).get(self._team_key)
311            stats.dpr = oprs.get('dprs', {}).get(self._team_key)
312            stats.ccwm = oprs.get('ccwms', {}).get(self._team_key)
313
314        # Rankings
315        rankings = _tba_get(f"/event/{event_key}/rankings", self._tba_key)
316        if rankings and isinstance(rankings, dict):
317            for entry in rankings.get('rankings', []):
318                if entry.get('team_key') == self._team_key:
319                    stats.rank = entry.get('rank')
320                    record = entry.get('record', {})
321                    stats.wins = record.get('wins')
322                    stats.losses = record.get('losses')
323                    stats.ties = record.get('ties')
324                    stats.ranking_points = entry.get('extra_stats', [None])[0]
325                    break
326
327        return stats if any(v is not None for v in vars(stats).values()) else None
logger = <Logger match_monitor (DEBUG)>
@dataclass
class AllianceResult:
35@dataclass
36class AllianceResult:
37    teams: list[str] = field(default_factory=list)   # e.g. ["frc3200", "frc1234", "frc5678"]
38    score: Optional[int] = None
39    won: bool = False
AllianceResult( teams: list[str] = <factory>, score: int | None = None, won: bool = False)
teams: list[str]
score: int | None = None
won: bool = False
@dataclass
class MatchResult:
42@dataclass
43class MatchResult:
44    """Official match result from TBA / FRC Events API."""
45    match_key: str = ""           # e.g. "2026calas_qm12"
46    event_key: str = ""           # e.g. "2026calas"
47    match_number: int = 0
48    tournament_level: str = ""    # "qm", "sf", "f"
49    red: AllianceResult = field(default_factory=AllianceResult)
50    blue: AllianceResult = field(default_factory=AllianceResult)
51    score_breakdown: dict = field(default_factory=dict)
52    our_alliance: str = ""        # "red" or "blue"
53    our_score: Optional[int] = None
54    opponent_score: Optional[int] = None
55    result: str = ""              # "win", "loss", "tie", "unknown"
56    video_url: Optional[str] = None
57
58    def to_dict(self) -> dict:
59        return {
60            'match_key': self.match_key,
61            'event_key': self.event_key,
62            'match_number': self.match_number,
63            'tournament_level': self.tournament_level,
64            'red': {'teams': self.red.teams, 'score': self.red.score},
65            'blue': {'teams': self.blue.teams, 'score': self.blue.score},
66            'score_breakdown': self.score_breakdown,
67            'our_alliance': self.our_alliance,
68            'our_score': self.our_score,
69            'opponent_score': self.opponent_score,
70            'result': self.result,
71            'video_url': self.video_url,
72        }

Official match result from TBA / FRC Events API.

MatchResult( match_key: str = '', event_key: str = '', match_number: int = 0, tournament_level: str = '', red: AllianceResult = <factory>, blue: AllianceResult = <factory>, score_breakdown: dict = <factory>, our_alliance: str = '', our_score: int | None = None, opponent_score: int | None = None, result: str = '', video_url: str | None = None)
match_key: str = ''
event_key: str = ''
match_number: int = 0
tournament_level: str = ''
score_breakdown: dict
our_alliance: str = ''
our_score: int | None = None
opponent_score: int | None = None
result: str = ''
video_url: str | None = None
def to_dict(self) -> dict:
58    def to_dict(self) -> dict:
59        return {
60            'match_key': self.match_key,
61            'event_key': self.event_key,
62            'match_number': self.match_number,
63            'tournament_level': self.tournament_level,
64            'red': {'teams': self.red.teams, 'score': self.red.score},
65            'blue': {'teams': self.blue.teams, 'score': self.blue.score},
66            'score_breakdown': self.score_breakdown,
67            'our_alliance': self.our_alliance,
68            'our_score': self.our_score,
69            'opponent_score': self.opponent_score,
70            'result': self.result,
71            'video_url': self.video_url,
72        }
@dataclass
class EventStats:
75@dataclass
76class EventStats:
77    """OPR/rankings data for the team at this event."""
78    opr: Optional[float] = None
79    dpr: Optional[float] = None
80    ccwm: Optional[float] = None
81    rank: Optional[int] = None
82    wins: Optional[int] = None
83    losses: Optional[int] = None
84    ties: Optional[int] = None
85    ranking_points: Optional[float] = None
86
87    def to_dict(self) -> dict:
88        return {k: v for k, v in vars(self).items() if v is not None}

OPR/rankings data for the team at this event.

EventStats( opr: float | None = None, dpr: float | None = None, ccwm: float | None = None, rank: int | None = None, wins: int | None = None, losses: int | None = None, ties: int | None = None, ranking_points: float | None = None)
opr: float | None = None
dpr: float | None = None
ccwm: float | None = None
rank: int | None = None
wins: int | None = None
losses: int | None = None
ties: int | None = None
ranking_points: float | None = None
def to_dict(self) -> dict:
87    def to_dict(self) -> dict:
88        return {k: v for k, v in vars(self).items() if v is not None}
class MatchDataClient:
149class MatchDataClient:
150    """Fetches match data from TBA and FRC Events API for a given team/event."""
151
152    def __init__(self, config_path: Path) -> None:
153        cfg = _load_config(config_path)
154        self._tba_key: str = cfg.get('tba_api_key', '')
155        self._frc_user: str = cfg.get('frc_events_username', '')
156        self._frc_auth: str = cfg.get('frc_events_auth_key', '')
157        self._team_num: int = int(cfg.get('team_number', 3200))
158        self._team_key = f"frc{self._team_num}"
159
160    @property
161    def configured(self) -> bool:
162        return bool(self._tba_key or (self._frc_user and self._frc_auth))
163
164    def fetch_match(self, event_key: str, match_number: int,
165                    level: str = 'qm') -> Optional[MatchResult]:
166        """
167        Fetch a single match result.
168        level: 'qm' (qualification), 'sf' (semifinal), 'f' (final)
169        """
170        match_key = f"{event_key}_{level}{match_number}"
171
172        if self._tba_key:
173            result = self._fetch_match_tba(match_key)
174            if result:
175                return result
176
177        if self._frc_user and self._frc_auth:
178            return self._fetch_match_frc(event_key, match_number, level)
179
180        return None
181
182    def _fetch_match_tba(self, match_key: str) -> Optional[MatchResult]:
183        data = _tba_get(f"/match/{match_key}", self._tba_key)
184        if not data or not isinstance(data, dict):
185            return None
186
187        alliances = data.get('alliances', {})
188        red = _alliance_result_from_tba(alliances.get('red', {}))
189        blue = _alliance_result_from_tba(alliances.get('blue', {}))
190
191        # Determine winner
192        winning_alliance = data.get('winning_alliance', '')
193        if winning_alliance == 'red':
194            red.won = True
195        elif winning_alliance == 'blue':
196            blue.won = True
197
198        # Find our alliance
199        our_alliance = ''
200        if self._team_key in red.teams:
201            our_alliance = 'red'
202        elif self._team_key in blue.teams:
203            our_alliance = 'blue'
204
205        our_score = (red.score if our_alliance == 'red' else
206                     blue.score if our_alliance == 'blue' else None)
207        opp_score = (blue.score if our_alliance == 'red' else
208                     red.score if our_alliance == 'blue' else None)
209
210        result_str = 'unknown'
211        if our_alliance:
212            if winning_alliance == our_alliance:
213                result_str = 'win'
214            elif winning_alliance and winning_alliance != our_alliance:
215                result_str = 'loss'
216            elif winning_alliance == '':
217                result_str = 'tie'
218
219        # Video URL (first available)
220        video_url = None
221        for vid in data.get('videos', []):
222            if vid.get('type') == 'youtube':
223                video_url = f"https://www.youtube.com/watch?v={vid['key']}"
224                break
225
226        # Score breakdown (game-specific)
227        breakdown = data.get('score_breakdown') or {}
228
229        return MatchResult(
230            match_key=data.get('key', ''),
231            event_key=data.get('event_key', ''),
232            match_number=data.get('match_number', 0),
233            tournament_level=data.get('comp_level', ''),
234            red=red,
235            blue=blue,
236            score_breakdown=breakdown,
237            our_alliance=our_alliance,
238            our_score=our_score,
239            opponent_score=opp_score,
240            result=result_str,
241            video_url=video_url,
242        )
243
244    def _fetch_match_frc(self, event_key: str, match_number: int,
245                          level: str) -> Optional[MatchResult]:
246        year = event_key[:4]
247        event_code = event_key[4:]
248        level_map = {'qm': 'Qualification', 'sf': 'Playoff', 'f': 'Playoff'}
249        tournament_level = level_map.get(level, 'Qualification')
250        data = _frc_get(
251            f"/{year}/matches/{event_code}?matchNumber={match_number}"
252            f"&tournamentLevel={tournament_level}",
253            self._frc_user, self._frc_auth,
254        )
255        if not data or not isinstance(data, dict):
256            return None
257
258        matches = data.get('Matches', [])
259        if not matches:
260            return None
261
262        m = matches[0]
263        red_teams = [f"frc{t['teamNumber']}" for t in m.get('teams', [])
264                     if t.get('station', '').startswith('Red')]
265        blue_teams = [f"frc{t['teamNumber']}" for t in m.get('teams', [])
266                      if t.get('station', '').startswith('Blue')]
267        red_score = m.get('scoreRedFinal')
268        blue_score = m.get('scoreBlueFinal')
269
270        our_alliance = ''
271        if self._team_key in red_teams:
272            our_alliance = 'red'
273        elif self._team_key in blue_teams:
274            our_alliance = 'blue'
275
276        our_score = red_score if our_alliance == 'red' else blue_score if our_alliance == 'blue' else None
277        opp_score = blue_score if our_alliance == 'red' else red_score if our_alliance == 'blue' else None
278
279        result_str = 'unknown'
280        if our_score is not None and opp_score is not None:
281            if our_score > opp_score:
282                result_str = 'win'
283            elif our_score < opp_score:
284                result_str = 'loss'
285            else:
286                result_str = 'tie'
287
288        return MatchResult(
289            match_key=f"{event_key}_{level}{match_number}",
290            event_key=event_key,
291            match_number=match_number,
292            tournament_level=level,
293            red=AllianceResult(teams=red_teams, score=red_score),
294            blue=AllianceResult(teams=blue_teams, score=blue_score),
295            our_alliance=our_alliance,
296            our_score=our_score,
297            opponent_score=opp_score,
298            result=result_str,
299        )
300
301    def fetch_event_stats(self, event_key: str) -> Optional[EventStats]:
302        """Fetch OPR, DPR, CCWM, and ranking for our team at this event."""
303        if not self._tba_key:
304            return None
305
306        stats = EventStats()
307
308        # OPR/DPR/CCWM
309        oprs = _tba_get(f"/event/{event_key}/oprs", self._tba_key)
310        if oprs and isinstance(oprs, dict):
311            stats.opr = oprs.get('oprs', {}).get(self._team_key)
312            stats.dpr = oprs.get('dprs', {}).get(self._team_key)
313            stats.ccwm = oprs.get('ccwms', {}).get(self._team_key)
314
315        # Rankings
316        rankings = _tba_get(f"/event/{event_key}/rankings", self._tba_key)
317        if rankings and isinstance(rankings, dict):
318            for entry in rankings.get('rankings', []):
319                if entry.get('team_key') == self._team_key:
320                    stats.rank = entry.get('rank')
321                    record = entry.get('record', {})
322                    stats.wins = record.get('wins')
323                    stats.losses = record.get('losses')
324                    stats.ties = record.get('ties')
325                    stats.ranking_points = entry.get('extra_stats', [None])[0]
326                    break
327
328        return stats if any(v is not None for v in vars(stats).values()) else None

Fetches match data from TBA and FRC Events API for a given team/event.

MatchDataClient(config_path: pathlib.Path)
152    def __init__(self, config_path: Path) -> None:
153        cfg = _load_config(config_path)
154        self._tba_key: str = cfg.get('tba_api_key', '')
155        self._frc_user: str = cfg.get('frc_events_username', '')
156        self._frc_auth: str = cfg.get('frc_events_auth_key', '')
157        self._team_num: int = int(cfg.get('team_number', 3200))
158        self._team_key = f"frc{self._team_num}"
configured: bool
160    @property
161    def configured(self) -> bool:
162        return bool(self._tba_key or (self._frc_user and self._frc_auth))
def fetch_match( self, event_key: str, match_number: int, level: str = 'qm') -> MatchResult | None:
164    def fetch_match(self, event_key: str, match_number: int,
165                    level: str = 'qm') -> Optional[MatchResult]:
166        """
167        Fetch a single match result.
168        level: 'qm' (qualification), 'sf' (semifinal), 'f' (final)
169        """
170        match_key = f"{event_key}_{level}{match_number}"
171
172        if self._tba_key:
173            result = self._fetch_match_tba(match_key)
174            if result:
175                return result
176
177        if self._frc_user and self._frc_auth:
178            return self._fetch_match_frc(event_key, match_number, level)
179
180        return None

Fetch a single match result. level: 'qm' (qualification), 'sf' (semifinal), 'f' (final)

def fetch_event_stats( self, event_key: str) -> EventStats | None:
301    def fetch_event_stats(self, event_key: str) -> Optional[EventStats]:
302        """Fetch OPR, DPR, CCWM, and ranking for our team at this event."""
303        if not self._tba_key:
304            return None
305
306        stats = EventStats()
307
308        # OPR/DPR/CCWM
309        oprs = _tba_get(f"/event/{event_key}/oprs", self._tba_key)
310        if oprs and isinstance(oprs, dict):
311            stats.opr = oprs.get('oprs', {}).get(self._team_key)
312            stats.dpr = oprs.get('dprs', {}).get(self._team_key)
313            stats.ccwm = oprs.get('ccwms', {}).get(self._team_key)
314
315        # Rankings
316        rankings = _tba_get(f"/event/{event_key}/rankings", self._tba_key)
317        if rankings and isinstance(rankings, dict):
318            for entry in rankings.get('rankings', []):
319                if entry.get('team_key') == self._team_key:
320                    stats.rank = entry.get('rank')
321                    record = entry.get('record', {})
322                    stats.wins = record.get('wins')
323                    stats.losses = record.get('losses')
324                    stats.ties = record.get('ties')
325                    stats.ranking_points = entry.get('extra_stats', [None])[0]
326                    break
327
328        return stats if any(v is not None for v in vars(stats).values()) else None

Fetch OPR, DPR, CCWM, and ranking for our team at this event.