mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
2 Commits
be7eb3c034
...
codex/hide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91199ff5c0 | ||
|
|
144e93ee44 |
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
264
scripts/debug/f1_live_socket_probe.py
Normal file
264
scripts/debug/f1_live_socket_probe.py
Normal 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())
|
||||||
Reference in New Issue
Block a user