Skip to content

Commit 6b877ec

Browse files
committed
Nexus
1 parent bf8e5ab commit 6b877ec

3 files changed

Lines changed: 303 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,5 @@ cython_debug/
164164

165165
# CodeQL
166166
output.sarif
167-
codeqldb/
167+
codeqldb/
168+
testing_*.py

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ https://github.com/user-attachments/assets/09c607ce-628d-41e6-ab82-b5fb1a4ec928
2121
SECRET_KEY=your_secret_key
2222
MONGO_URI=mongodb://localhost:27017/scouting_app
2323
TBA_AUTH_KEY=your_tba_api_key
24+
NEXUS_API_KEY=your_nexus_api_key
2425
DEBUG=False
2526
HOST=localhost
2627
PORT=5000

app/scout/nexus.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import logging
2+
import os
3+
import time
4+
from concurrent.futures import ThreadPoolExecutor, as_completed
5+
6+
import requests
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class NexusInterface:
12+
"""Interface for the FRC Nexus API (v1.5.0)"""
13+
14+
BASE_URL = "https://frc.nexus/api/v1"
15+
16+
# Cache: maps team_number (str) -> event_key (str), refreshed every 5 min
17+
_team_event_cache: dict[str, str] = {}
18+
_team_event_cache_built_at: float = 0.0
19+
_CACHE_TTL: float = 300.0 # seconds
20+
_events_cache: dict | None = None
21+
_events_cache_at: float = 0.0
22+
23+
def __init__(self, api_key: str | None = None):
24+
self.api_key = api_key or os.getenv("NEXUS_API_KEY", "")
25+
if not self.api_key:
26+
logger.warning(
27+
"NEXUS_API_KEY not found in environment variables or provided as parameter"
28+
)
29+
else:
30+
logger.info("Initialized NexusInterface with API key")
31+
self.headers = {
32+
"Nexus-Api-Key": self.api_key,
33+
"Accept": "application/json",
34+
}
35+
self.timeout = 8
36+
37+
38+
def get_event_status(self, event_key: str) -> dict | None:
39+
"""
40+
GET /event/{eventKey}
41+
Live event status including matches, announcements, parts requests, and nowQueuing.
42+
43+
Parameters:
44+
event_key: str - the event key (e.g. "2025tvr")
45+
46+
Returns:
47+
dict with keys:
48+
- matches: list of match objects
49+
- announcements: list of announcement objects
50+
- partsRequests: list of parts request objects
51+
- nowQueuing: str or None
52+
or None if there was an error fetching the data.
53+
"""
54+
try:
55+
response = requests.get(
56+
f"{self.BASE_URL}/event/{event_key}",
57+
headers=self.headers,
58+
timeout=self.timeout,
59+
)
60+
if response.status_code == 200:
61+
return response.json()
62+
logger.warning("Nexus get_event_status %s returned %s", event_key, response.status_code)
63+
return None
64+
except Exception as e:
65+
logger.error("Error fetching Nexus event status for %s: %s", event_key, e)
66+
return None
67+
68+
def get_pit_addresses(self, event_key: str) -> dict | None:
69+
"""
70+
GET /event/{eventKey}/pits
71+
Fetch pit address mapping.
72+
73+
Parameters:
74+
event_key: str - the event key (e.g. "2025tvr")
75+
76+
Returns:
77+
dict mapping team numbers (as strings) to pit addresses,
78+
or None if there was an error fetching the data.
79+
"""
80+
try:
81+
response = requests.get(
82+
f"{self.BASE_URL}/event/{event_key}/pits",
83+
headers=self.headers,
84+
timeout=self.timeout,
85+
)
86+
if response.status_code == 200:
87+
return response.json()
88+
logger.warning("Nexus get_pit_addresses %s returned %s", event_key, response.status_code)
89+
return None
90+
except Exception as e:
91+
logger.error("Error fetching Nexus pit addresses for %s: %s", event_key, e)
92+
return None
93+
94+
def get_pit_map(self, event_key: str) -> dict | None:
95+
"""
96+
GET /event/{eventKey}/map
97+
Fetch pit map
98+
99+
Parameters:
100+
event_key: str - the event key (e.g. "2025tvr")
101+
102+
Returns:
103+
dict representing the pit map, or None if there was an error fetching the data.
104+
"""
105+
try:
106+
response = requests.get(
107+
f"{self.BASE_URL}/event/{event_key}/map",
108+
headers=self.headers,
109+
timeout=self.timeout,
110+
)
111+
if response.status_code == 200:
112+
return response.json()
113+
logger.warning("Nexus get_pit_map %s returned %s", event_key, response.status_code)
114+
return None
115+
except Exception as e:
116+
logger.error("Error fetching Nexus pit map for %s: %s", event_key, e)
117+
return None
118+
119+
def get_inspection_status(self, event_key: str) -> dict | None:
120+
"""
121+
GET /event/{eventKey}/inspection
122+
Fetch the team inspection status
123+
124+
Parameters:
125+
event_key: str - the event key (e.g. "2025tvr")
126+
127+
Returns:
128+
dict mapping team numbers (as strings) to inspection status,
129+
or None if there was an error fetching the data.
130+
"""
131+
try:
132+
response = requests.get(
133+
f"{self.BASE_URL}/event/{event_key}/inspection",
134+
headers=self.headers,
135+
timeout=self.timeout,
136+
)
137+
if response.status_code == 200:
138+
return response.json()
139+
logger.warning("Nexus get_inspection_status %s returned %s", event_key, response.status_code)
140+
return None
141+
except Exception as e:
142+
logger.error("Error fetching Nexus inspection status for %s: %s", event_key, e)
143+
return None
144+
145+
def get_events(self) -> dict | None:
146+
"""
147+
GET /events
148+
Fetch all events currently registered on Nexus.
149+
150+
Returns:
151+
dict mapping event keys to event info dicts, or None if there was an error fetching the data.
152+
"""
153+
# Return cached copy if fresh (reuse same TTL as team cache)
154+
now = time.monotonic()
155+
if hasattr(NexusInterface, '_events_cache') and \
156+
now - NexusInterface._events_cache_at < NexusInterface._CACHE_TTL:
157+
return NexusInterface._events_cache
158+
159+
try:
160+
response = requests.get(
161+
f"{self.BASE_URL}/events",
162+
headers=self.headers,
163+
timeout=self.timeout,
164+
)
165+
if response.status_code == 200:
166+
NexusInterface._events_cache = response.json()
167+
NexusInterface._events_cache_at = now
168+
return NexusInterface._events_cache
169+
logger.warning("Nexus get_events returned %s", response.status_code)
170+
return None
171+
except Exception as e:
172+
logger.error("Error fetching Nexus events: %s", e)
173+
return None
174+
175+
def find_team_event(self, team_number: int | str) -> tuple[str, str] | None:
176+
"""Find the current Nexus event key and name for a team.
177+
178+
Fetches all event statuses in parallel so the scan completes in one
179+
round-trip worth of latency rather than N serial requests.
180+
Result is cached for _CACHE_TTL seconds.
181+
182+
Returns (event_key, event_name) or None if not found.
183+
"""
184+
team_number = str(team_number)
185+
now = time.monotonic()
186+
187+
# Return from cache if still fresh
188+
if (now - NexusInterface._team_event_cache_built_at <= NexusInterface._CACHE_TTL
189+
and team_number in NexusInterface._team_event_cache):
190+
ekey = NexusInterface._team_event_cache[team_number]
191+
all_events = self.get_events() or {}
192+
ename = (all_events.get(ekey) or {}).get("name", ekey)
193+
return ekey, ename
194+
195+
# Rebuild cache — fetch all event statuses in parallel
196+
if now - NexusInterface._team_event_cache_built_at > NexusInterface._CACHE_TTL:
197+
logger.info("Nexus: rebuilding team->event cache (parallel)")
198+
all_events = self.get_events() or {}
199+
new_cache: dict[str, str] = {}
200+
201+
def _fetch(ekey: str):
202+
return ekey, self.get_event_status(ekey)
203+
204+
with ThreadPoolExecutor(max_workers=10) as pool:
205+
futures = {pool.submit(_fetch, ekey): ekey for ekey in all_events}
206+
for fut in as_completed(futures):
207+
ekey, ev_status = fut.result()
208+
if not ev_status:
209+
continue
210+
for match in ev_status.get("matches", []):
211+
for team in (match.get("redTeams") or []) + (match.get("blueTeams") or []):
212+
if team is not None:
213+
new_cache.setdefault(str(team), ekey)
214+
215+
NexusInterface._team_event_cache = new_cache
216+
NexusInterface._team_event_cache_built_at = time.monotonic()
217+
logger.info("Nexus: cache built with %d teams", len(new_cache))
218+
219+
ekey = NexusInterface._team_event_cache.get(team_number)
220+
if not ekey:
221+
return None
222+
223+
all_events = self.get_events() or {}
224+
ename = (all_events.get(ekey) or {}).get("name", ekey)
225+
return ekey, ename
226+
227+
def get_team_nexus_data(self, event_key: str, team_number: int | str) -> dict:
228+
"""Aggregate Nexus data relevant to a specific team at an event.
229+
230+
Parameters:
231+
- event_key: str - the event key (e.g. "2025tvr")
232+
- team_number: int or str - the team number (e.g. 334)
233+
234+
Returns a dict with keys:
235+
- now_queuing: str | None
236+
- team_matches: list
237+
- announcements: list
238+
- parts_requests: list
239+
- pit_address: str | None
240+
- inspection: dict | None
241+
"""
242+
team_number = str(team_number)
243+
result = {
244+
"now_queuing": None,
245+
"team_matches": [],
246+
"announcements": [],
247+
"parts_requests": [],
248+
"pit_address": None,
249+
"inspection": None,
250+
"nexus_available": False,
251+
}
252+
253+
# Event status (matches, queue, announcements, parts requests)
254+
event_status = self.get_event_status(event_key)
255+
if event_status:
256+
result["nexus_available"] = True
257+
result["now_queuing"] = event_status.get("nowQueuing")
258+
result["announcements"] = event_status.get("announcements", [])
259+
result["parts_requests"] = event_status.get("partsRequests", [])
260+
261+
# Filter matches for the requested team
262+
for match in event_status.get("matches", []):
263+
red_teams = [str(t) for t in (match.get("redTeams") or match.get("red", [])) if t is not None]
264+
blue_teams = [str(t) for t in (match.get("blueTeams") or match.get("blue", [])) if t is not None]
265+
if team_number in red_teams or team_number in blue_teams:
266+
result["team_matches"].append(match)
267+
268+
# Pit address
269+
pit_addresses = self.get_pit_addresses(event_key)
270+
if pit_addresses and team_number in pit_addresses:
271+
result["pit_address"] = pit_addresses[team_number]
272+
273+
# Inspection status
274+
inspection = self.get_inspection_status(event_key)
275+
if inspection and team_number in inspection:
276+
result["inspection"] = inspection[team_number]
277+
278+
return result
279+
280+
def get_event_nexus_overview(self, event_key: str) -> dict:
281+
"""Get a full Nexus overview for an event (for rendering the page).
282+
283+
Parameters:
284+
- event_key: str - the event key (e.g. "2025tvr")
285+
286+
Returns a dict with keys:
287+
- event_status: full event status or None
288+
- pit_addresses: dict or None
289+
- pit_map: dict or None
290+
- inspection: dict or None
291+
- nexus_available: bool
292+
"""
293+
event_status = self.get_event_status(event_key)
294+
return {
295+
"event_status": event_status,
296+
"pit_addresses": self.get_pit_addresses(event_key),
297+
"pit_map": self.get_pit_map(event_key),
298+
"inspection": self.get_inspection_status(event_key),
299+
"nexus_available": event_status is not None,
300+
}

0 commit comments

Comments
 (0)