Compare commits

..

2 Commits

Author SHA1 Message Date
AmanTahiliani
91199ff5c0 chore: add F1 live socket probe 2026-07-04 07:22:25 -04:00
AmanTahiliani
144e93ee44 fix: hide live track map while GPS unavailable 2026-07-04 07:17:28 -04:00
5 changed files with 318 additions and 167 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchLiveTrackOutline } from '../api' import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
@@ -25,7 +25,6 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail' import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap'
import { TyreDegPanel } from '../components/live/TyreDegPanel' import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Archive, Radio } from 'lucide-react' import { Archive, Radio } from 'lucide-react'
@@ -58,18 +57,6 @@ export function LiveTimingPage() {
staleTime: 5_000, staleTime: 5_000,
}) })
const trackOutlineQuery = useQuery({
queryKey: [
'live-track-outline',
snapshot?.Session?.MeetingName ?? '',
snapshot?.Session?.CircuitName ?? '',
],
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
staleTime: Infinity,
retry: false,
})
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
const nextLive = data.is_live && Boolean(data.data) const nextLive = data.is_live && Boolean(data.data)
@@ -284,14 +271,6 @@ export function LiveTimingPage() {
/> />
<TrackStatusBanner status={snapshot.TrackStatus} /> <TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} /> <PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
<TrackMap
outline={trackOutlineQuery.data}
positions={positions}
telemetry={snapshot.Telemetry}
drivers={snapshot.Drivers}
driverInfo={snapshot.DriverInfo}
loading={trackOutlineQuery.isLoading}
/>
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} /> <TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
<div className="live-columns"> <div className="live-columns">
<div className="live-tower-col"> <div className="live-tower-col">

View File

@@ -152,4 +152,15 @@ describe('LiveTimingPage archive mode', () => {
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
}) })
it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({
is_live: true,
data: { ...archivedSnapshot, SessionStatus: 'Started' },
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
})
}) })

View File

@@ -26,15 +26,6 @@ type ChampionshipInputs struct {
} }
// GetChampionshipInputs derives championship hub inputs from ingested season data. // GetChampionshipInputs derives championship hub inputs from ingested season data.
//
// 2026 note: Bahrain (meeting 1282 / session 11261) and Saudi Arabia (1283 / 11269)
// are cancelled due to Force Majeure (regional conflict) — OpenF1 returns no results
// for those sessions. This is intentional, which is why 2026 shows Round 9/24 after
// Silverstone instead of Round 11.
//
// Points: F1 Sprint points (SessionName == "Sprint") must be included alongside Race
// points. Both appear as SessionType "Race" in OpenF1 /api/v1/sessions, so we match
// on SessionName. Fix for #57.
func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) { func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
meetings, err := s.ListMeetingsByYear(year) meetings, err := s.ListMeetingsByYear(year)
if err != nil { if err != nil {
@@ -60,24 +51,16 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
} }
raceKey := 0 raceKey := 0
sprintKey := 0
for _, sess := range sessions { for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") { if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey raceKey = sess.SessionKey
} else if strings.EqualFold(sess.SessionName, "Sprint") { break
sprintKey = sess.SessionKey
} }
} }
if raceKey == 0 && sprintKey == 0 { if raceKey == 0 {
continue continue
} }
// Always build Races slice from the Race session only (for UI),
// but include sprint points for standings.
var raceResults []models.SessionResult
var raceGrid []models.StartingGrid
if raceKey != 0 {
results, err := s.store.ListSessionResults(raceKey) results, err := s.store.ListSessionResults(raceKey)
if err != nil { if err != nil {
return ChampionshipInputs{}, err return ChampionshipInputs{}, err
@@ -86,6 +69,7 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
if err != nil { if err != nil {
return ChampionshipInputs{}, err return ChampionshipInputs{}, err
} }
race := ChampionshipRace{ race := ChampionshipRace{
Meeting: meeting, Meeting: meeting,
RaceSessionKey: raceKey, RaceSessionKey: raceKey,
@@ -99,9 +83,11 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
race.Grid = append(race.Grid, gridToModel(entry)) race.Grid = append(race.Grid, gridToModel(entry))
} }
inputs.Races = append(inputs.Races, race) inputs.Races = append(inputs.Races, race)
raceResults = race.Results
if len(results) > 0 { if len(results) == 0 {
continue
}
latestSessionKey = raceKey latestSessionKey = raceKey
latestMeetingKey = int(meeting.MeetingKey) latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey) drivers, err := s.driversForSession(raceKey)
@@ -111,6 +97,7 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
for _, d := range drivers { for _, d := range drivers {
inputs.DriverMap[d.DriverNumber] = d inputs.DriverMap[d.DriverNumber] = d
} }
for _, result := range results { for _, result := range results {
pointsByDriver[result.DriverNumber] += result.Points pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName team := inputs.DriverMap[result.DriverNumber].TeamName
@@ -119,43 +106,6 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
} }
} }
} }
}
// Include sprint points if present.
// Note: 2026 has 4 sprints up to British GP (Chinese GP 11240, Miami 11275,
// Canadian 11286, British 11321) totaling e.g. ANT 21 = 4+3+6+8, RUS 26.
if sprintKey != 0 {
sprintResults, err := s.store.ListSessionResults(sprintKey)
if err != nil {
return ChampionshipInputs{}, err
}
if len(sprintResults) > 0 {
// Ensure driver map includes sprint-only drivers if any.
sprintDrivers, err := s.driversForSession(sprintKey)
if err == nil {
for _, d := range sprintDrivers {
if _, ok := inputs.DriverMap[d.DriverNumber]; !ok {
inputs.DriverMap[d.DriverNumber] = d
}
}
}
for _, result := range sprintResults {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
}
}
// If this meeting had no race results (edge), still track latest.
if len(raceResults) == 0 {
latestSessionKey = sprintKey
latestMeetingKey = int(meeting.MeetingKey)
}
}
}
_ = raceResults
_ = raceGrid
}
inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey) inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey)
inputs.Teams = derivedTeamStandings(pointsByTeam, latestMeetingKey, latestSessionKey) inputs.Teams = derivedTeamStandings(pointsByTeam, latestMeetingKey, latestSessionKey)

View File

@@ -375,56 +375,3 @@ func TestGetWeekendWithSessions(t *testing.T) {
t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"]) t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"])
} }
} }
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
// Regression for #57: Race-only aggregation dropped Sprint points.
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
svc := openTestService(t)
seedRaceHubData(t, svc.store)
// Add sprint session for same meeting
if err := svc.store.UpsertSession(store.Session{
SessionKey: 9473,
MeetingKey: 1229,
SessionName: "Sprint",
SessionType: "Race", // OpenF1 uses SessionType Race even for Sprint
DateStart: "2025-05-24T13:00:00+00:00",
}); err != nil {
t.Fatalf("UpsertSession sprint error = %v", err)
}
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, TeamName: "Red Bull Racing",
}); err != nil {
t.Fatalf("UpsertSessionDriver sprint error = %v", err)
}
// Race points 25
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult race error = %v", err)
}
// Sprint points 8
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 8,
}); err != nil {
t.Fatalf("UpsertSessionResult sprint error = %v", err)
}
inputs, err := svc.GetChampionshipInputs(2025)
if err != nil {
t.Fatalf("GetChampionshipInputs() error = %v", err)
}
if len(inputs.Races) != 1 {
t.Fatalf("Races len = %d, want 1 race entry", len(inputs.Races))
}
if len(inputs.Champ) != 1 {
t.Fatalf("Champ len = %d, want 1", len(inputs.Champ))
}
if inputs.Champ[0].PointsCurrent != 33 {
t.Fatalf("PointsCurrent = %v, want 33 (25 race + 8 sprint)", inputs.Champ[0].PointsCurrent)
}
if len(inputs.Teams) != 1 || inputs.Teams[0].PointsCurrent != 33 {
t.Fatalf("Teams = %+v, want Red Bull 33", inputs.Teams)
}
}

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Probe the official F1 live timing sockets for GPS position topics.
This intentionally does not call OpenF1 REST. It connects directly to the F1
SignalR endpoints used by the app and reports which topics arrive.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import random
import socket
import ssl
import struct
import sys
import time
import urllib.parse
import urllib.request
RS = b"\x1e"
CORE_NEGOTIATE = "https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1"
CORE_WS_HOST = "livetiming.formula1.com"
LEGACY_HUB = '[{"name":"Streaming"}]'
TOPICS = [
"Heartbeat",
"TimingData",
"Position",
"Position.z",
"CarData",
"CarData.z",
"DriverList",
"LapCount",
"ExtrapolatedClock",
"TrackStatus",
"RaceControlMessages",
"WeatherData",
"SessionInfo",
"TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
"SessionStatus",
"TopThree",
]
class WebSocket:
def __init__(self, host: str, path: str, headers: dict[str, str] | None = None):
self.host = host
self.sock = ssl.create_default_context().wrap_socket(
socket.create_connection((host, 443), timeout=10),
server_hostname=host,
)
key = base64.b64encode(os.urandom(16)).decode("ascii")
request_headers = {
"Host": host,
"Upgrade": "websocket",
"Connection": "Upgrade",
"Sec-WebSocket-Key": key,
"Sec-WebSocket-Version": "13",
"Origin": "https://www.formula1.com",
"User-Agent": "Mozilla/5.0",
}
request_headers.update(headers or {})
lines = [f"GET {path} HTTP/1.1", *[f"{k}: {v}" for k, v in request_headers.items()], "", ""]
self.sock.sendall("\r\n".join(lines).encode("utf-8"))
response = self._read_http_response()
if b" 101 " not in response.split(b"\r\n", 1)[0]:
raise RuntimeError(response.decode("utf-8", "replace"))
def _read_http_response(self) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
data += self.sock.recv(4096)
return data
def send_text(self, text: str) -> None:
payload = text.encode("utf-8")
header = bytearray([0x81])
if len(payload) < 126:
header.append(0x80 | len(payload))
elif len(payload) < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", len(payload)))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", len(payload)))
mask = random.randbytes(4) if hasattr(random, "randbytes") else os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + mask + masked)
def recv_text(self, timeout: float = 5.0) -> str | None:
self.sock.settimeout(timeout)
while True:
first = self.sock.recv(2)
if not first:
return None
opcode = first[0] & 0x0F
masked = bool(first[1] & 0x80)
length = first[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._read_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._read_exact(8))[0]
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode == 0x8:
return None
if opcode == 0x9:
self._send_pong(payload)
continue
if opcode == 0x1:
return payload.decode("utf-8", "replace")
def _send_pong(self, payload: bytes) -> None:
self.sock.sendall(bytes([0x8A, len(payload)]) + payload)
def _read_exact(self, n: int) -> bytes:
chunks = []
remaining = n
while remaining:
chunk = self.sock.recv(remaining)
if not chunk:
raise EOFError("socket closed")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def close(self) -> None:
self.sock.close()
def http_json(url: str, method: str = "GET", headers: dict[str, str] | None = None) -> tuple[dict, list[str]]:
req = urllib.request.Request(url, method=method, headers=headers or {})
with urllib.request.urlopen(req, timeout=15) as resp:
cookies = resp.headers.get_all("Set-Cookie") or []
return json.loads(resp.read()), cookies
def core_probe(seconds: int) -> None:
print("== SignalR Core ==")
neg, cookies = http_json(
CORE_NEGOTIATE,
method="POST",
headers={"Origin": "https://www.formula1.com", "User-Agent": "Mozilla/5.0", "Content-Length": "0"},
)
token = urllib.parse.quote(neg["connectionToken"], safe="")
ws = WebSocket(
CORE_WS_HOST,
f"/signalrcore?id={token}",
{"Cookie": "; ".join(cookies)} if cookies else None,
)
try:
ws.send_text(json.dumps({"protocol": "json", "version": 1}, separators=(",", ":")) + RS.decode())
print("handshake:", repr(ws.recv_text()))
ws.send_text(
json.dumps(
{"type": 1, "target": "subscribe", "arguments": [TOPICS], "invocationId": "1"},
separators=(",", ":"),
)
+ RS.decode()
)
collect(ws, seconds)
finally:
ws.close()
def legacy_probe(seconds: int, bearer: str | None) -> None:
print("\n== Legacy SignalR ==")
query = urllib.parse.quote(LEGACY_HUB, safe="")
headers = {"User-Agent": "BestHTTP"}
if bearer:
headers["Authorization"] = f"Bearer {bearer}"
neg_url = f"https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData={query}"
try:
neg, cookies = http_json(neg_url, headers=headers)
except Exception as exc:
print("negotiate failed:", exc)
return
token = urllib.parse.quote(neg["ConnectionToken"], safe="")
path = f"/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken={token}&connectionData={query}"
ws_headers = {"User-Agent": "BestHTTP"}
if cookies:
ws_headers["Cookie"] = "; ".join(cookies)
if bearer:
ws_headers["Authorization"] = f"Bearer {bearer}"
ws = WebSocket(CORE_WS_HOST, path, ws_headers)
try:
ws.send_text(json.dumps({"H": "Streaming", "M": "Subscribe", "A": [TOPICS], "I": 1}, separators=(",", ":")))
collect(ws, seconds)
finally:
ws.close()
def collect(ws: WebSocket, seconds: int) -> None:
deadline = time.time() + seconds
seen: dict[str, int] = {}
while time.time() < deadline:
try:
message = ws.recv_text(timeout=min(5, max(1, deadline - time.time())))
except TimeoutError:
continue
if not message:
break
for frame in message.split(RS.decode()):
if not frame:
continue
try:
payload = json.loads(frame)
except json.JSONDecodeError:
continue
record_topics(payload, seen)
print("seen topics:")
for topic in sorted(seen):
print(f" {topic}: {seen[topic]}")
if not any(topic.startswith("Position") for topic in seen):
print(" (no Position / Position.z topics observed)")
def record_topics(payload: dict, seen: dict[str, int]) -> None:
result = payload.get("result") or payload.get("R")
if isinstance(result, dict):
for topic, data in result.items():
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, data)
for message in payload.get("M") or []:
args = message.get("A") or []
if len(args) >= 2:
topic = args[0]
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, args[1])
if payload.get("target") == "feed":
args = payload.get("arguments") or []
if len(args) >= 2:
topic = args[0]
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, args[1])
def maybe_print_position(topic: str, data) -> None:
if topic in {"Position", "Position.z"}:
text = json.dumps(data)[:600] if not isinstance(data, str) else data[:600]
print(f"POSITION TOPIC {topic}: {text}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--seconds", type=int, default=20)
parser.add_argument("--legacy", action="store_true", help="also probe legacy SignalR")
args = parser.parse_args()
core_probe(args.seconds)
if args.legacy:
legacy_probe(args.seconds, os.environ.get("BOXBOX_F1_LIVE_BEARER_TOKEN") or os.environ.get("F1_LIVE_BEARER_TOKEN"))
return 0
if __name__ == "__main__":
raise SystemExit(main())