mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
4 Commits
be7eb3c034
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7212eb9b44 | ||
|
|
1782fd313c | ||
|
|
91199ff5c0 | ||
|
|
144e93ee44 |
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
import { fetchLiveState } from '../api'
|
||||
import type { LivePosition, LiveStreamData } from '../types'
|
||||
import {
|
||||
loadPinnedDrivers,
|
||||
@@ -25,7 +25,6 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||
import { EventRail } from '../components/live/EventRail'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TrackMap } from '../components/live/TrackMap'
|
||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||
import { Archive, Radio } from 'lucide-react'
|
||||
|
||||
@@ -58,18 +57,6 @@ export function LiveTimingPage() {
|
||||
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(() => {
|
||||
if (!data) return
|
||||
const nextLive = data.is_live && Boolean(data.data)
|
||||
@@ -284,14 +271,6 @@ export function LiveTimingPage() {
|
||||
/>
|
||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||
<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} />
|
||||
<div className="live-columns">
|
||||
<div className="live-tower-col">
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -152,4 +152,15 @@ describe('LiveTimingPage archive mode', () => {
|
||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
|
||||
@@ -89,6 +89,17 @@ export interface RaceHub {
|
||||
race_control: RaceControlMessage[]
|
||||
weather: WeatherSample[]
|
||||
laps: Lap[]
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||
title: string
|
||||
start_lap: number
|
||||
end_lap: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
driver_numbers: number[]
|
||||
}
|
||||
|
||||
export interface Stint {
|
||||
|
||||
628
internal/chapters/chapters.go
Normal file
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
KindStart = "start"
|
||||
KindSafetyCar = "safety_car"
|
||||
KindVirtualSafetyCar = "virtual_safety_car"
|
||||
KindRedFlag = "red_flag"
|
||||
KindPitPhase = "pit_phase"
|
||||
KindDecisiveSwing = "decisive_swing"
|
||||
KindFinish = "finish"
|
||||
|
||||
pitPhaseWindowLaps = 3
|
||||
pitPhaseShare = 0.30
|
||||
minPitPhaseStops = 2
|
||||
maxDecisiveSwings = 3
|
||||
decisiveAfterLap = 5
|
||||
structuralPriority = 110
|
||||
flagPriority = 100
|
||||
pitPhasePriority = 50
|
||||
decisivePriority = 40
|
||||
)
|
||||
|
||||
type RaceControl = models.RaceControl
|
||||
type PositionSample = models.Position
|
||||
type Lap = models.Lap
|
||||
|
||||
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||
type Chapter struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
StartLap int `json:"start_lap"`
|
||||
EndLap int `json:"end_lap"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
DriverNumbers []int `json:"driver_numbers"`
|
||||
}
|
||||
|
||||
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||
return []Chapter{}
|
||||
}
|
||||
if totalLaps <= 0 {
|
||||
totalLaps = 1
|
||||
}
|
||||
|
||||
lapIndex := buildLapIndex(laps)
|
||||
chapters := []Chapter{
|
||||
{
|
||||
Kind: KindStart,
|
||||
Title: "Start",
|
||||
StartLap: 1,
|
||||
EndLap: minInt(1, totalLaps),
|
||||
StartTime: lapIndex.lapStart(1),
|
||||
EndTime: lapIndex.lapEnd(1),
|
||||
},
|
||||
}
|
||||
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||
|
||||
return resolveConflicts(chapters)
|
||||
}
|
||||
|
||||
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||
for _, l := range laps {
|
||||
if l.LapNumber > totalLaps {
|
||||
totalLaps = l.LapNumber
|
||||
}
|
||||
}
|
||||
for _, msg := range rc {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||
totalLaps = *msg.LapNumber
|
||||
}
|
||||
}
|
||||
return totalLaps
|
||||
}
|
||||
|
||||
type lapIndex struct {
|
||||
byLap map[int]string
|
||||
events []lapEvent
|
||||
}
|
||||
|
||||
type lapEvent struct {
|
||||
lap int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func buildLapIndex(laps []Lap) lapIndex {
|
||||
idx := lapIndex{byLap: map[int]string{}}
|
||||
for _, l := range laps {
|
||||
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||
idx.byLap[l.LapNumber] = l.DateStart
|
||||
}
|
||||
at, ok := parseTime(l.DateStart)
|
||||
if ok {
|
||||
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||
}
|
||||
}
|
||||
sort.Slice(idx.events, func(i, j int) bool {
|
||||
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||
return idx.events[i].lap < idx.events[j].lap
|
||||
}
|
||||
return idx.events[i].at.Before(idx.events[j].at)
|
||||
})
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapStart(lap int) string {
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapEnd(lap int) string {
|
||||
if v := idx.byLap[lap+1]; v != "" {
|
||||
return v
|
||||
}
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapForTime(raw string) int {
|
||||
at, ok := parseTime(raw)
|
||||
if !ok || len(idx.events) == 0 {
|
||||
return 0
|
||||
}
|
||||
lap := 0
|
||||
for _, event := range idx.events {
|
||||
if event.at.After(at) {
|
||||
break
|
||||
}
|
||||
lap = event.lap
|
||||
}
|
||||
if lap == 0 {
|
||||
return idx.events[0].lap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
type flagState struct {
|
||||
startLap int
|
||||
startTime string
|
||||
}
|
||||
|
||||
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||
var chapters []Chapter
|
||||
active := map[string]flagState{}
|
||||
for _, msg := range rc {
|
||||
kind, ok := flagKind(msg)
|
||||
if !ok && greenFlagClear(msg) {
|
||||
for activeKind, st := range active {
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = st.startLap
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: activeKind,
|
||||
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, activeKind)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = 1
|
||||
}
|
||||
if flagCleared(msg) {
|
||||
st, ok := active[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, kind)
|
||||
continue
|
||||
}
|
||||
if flagStarted(msg) {
|
||||
active[kind] = flagState{
|
||||
startLap: clampLap(lap, 1, totalLaps),
|
||||
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind, st := range active {
|
||||
endLap := totalLaps
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: idx.lapEnd(endLap),
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
func flagKind(msg RaceControl) (string, bool) {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||
return KindVirtualSafetyCar, true
|
||||
}
|
||||
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||
return KindRedFlag, true
|
||||
}
|
||||
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||
return KindSafetyCar, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func flagStarted(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "DEPLOY") ||
|
||||
strings.Contains(text, "RED FLAG") ||
|
||||
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||
strings.Contains(text, "VSC") ||
|
||||
strings.Contains(text, "SAFETY CAR") ||
|
||||
string(msg.Flag) == string(models.FlagRed)
|
||||
}
|
||||
|
||||
func flagCleared(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "CLEAR") ||
|
||||
strings.Contains(text, "ENDING") ||
|
||||
strings.Contains(text, "IN THIS LAP") ||
|
||||
strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func greenFlagClear(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func flagTitle(kind string, startLap, endLap int) string {
|
||||
name := "Flag period"
|
||||
switch kind {
|
||||
case KindSafetyCar:
|
||||
name = "Safety Car"
|
||||
case KindVirtualSafetyCar:
|
||||
name = "Virtual Safety Car"
|
||||
case KindRedFlag:
|
||||
name = "Red Flag"
|
||||
}
|
||||
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||
}
|
||||
|
||||
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||
type pitOut struct {
|
||||
lap int
|
||||
driver int
|
||||
}
|
||||
var stops []pitOut
|
||||
for _, l := range laps {
|
||||
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||
}
|
||||
}
|
||||
if len(stops) < minPitPhaseStops {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(stops, func(i, j int) bool {
|
||||
if stops[i].lap == stops[j].lap {
|
||||
return stops[i].driver < stops[j].driver
|
||||
}
|
||||
return stops[i].lap < stops[j].lap
|
||||
})
|
||||
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||
if needed < minPitPhaseStops {
|
||||
needed = minPitPhaseStops
|
||||
}
|
||||
|
||||
var windows []Chapter
|
||||
for i := 0; i < len(stops); i++ {
|
||||
start := stops[i].lap
|
||||
end := start + pitPhaseWindowLaps - 1
|
||||
drivers := map[int]bool{}
|
||||
count := 0
|
||||
for _, stop := range stops {
|
||||
if stop.lap < start || stop.lap > end {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
drivers[stop.driver] = true
|
||||
}
|
||||
if count < needed {
|
||||
continue
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindPitPhase,
|
||||
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||
StartLap: start,
|
||||
EndLap: end,
|
||||
StartTime: idx.lapStart(start),
|
||||
EndTime: idx.lapEnd(end),
|
||||
DriverNumbers: sortedDriverNumbers(drivers),
|
||||
}
|
||||
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||
last := &windows[len(windows)-1]
|
||||
if ch.EndLap > last.EndLap {
|
||||
last.EndLap = ch.EndLap
|
||||
last.EndTime = idx.lapEnd(last.EndLap)
|
||||
}
|
||||
drivers := sliceToSet(last.DriverNumbers)
|
||||
for _, driver := range ch.DriverNumbers {
|
||||
drivers[driver] = true
|
||||
}
|
||||
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||
continue
|
||||
}
|
||||
windows = append(windows, ch)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
type swingCandidate struct {
|
||||
chapter Chapter
|
||||
significance int
|
||||
}
|
||||
|
||||
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||
return nil
|
||||
}
|
||||
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||
if len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
final := snapshots[totalLaps]
|
||||
if len(final) == 0 {
|
||||
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||
if len(snapshots[lap]) > 0 {
|
||||
final = snapshots[lap]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var candidates []swingCandidate
|
||||
seenDriver := map[int]bool{}
|
||||
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||
prev := snapshots[lap-1]
|
||||
curr := snapshots[lap]
|
||||
if len(prev) == 0 || len(curr) == 0 {
|
||||
continue
|
||||
}
|
||||
for driver, pos := range curr {
|
||||
prevPos, ok := prev[driver]
|
||||
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||
continue
|
||||
}
|
||||
finalPos, ok := final[driver]
|
||||
if !ok || finalPos > pos {
|
||||
continue
|
||||
}
|
||||
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||
drivers := []int{driver}
|
||||
if overtaken != 0 {
|
||||
drivers = append(drivers, overtaken)
|
||||
}
|
||||
candidates = append(candidates, swingCandidate{
|
||||
chapter: Chapter{
|
||||
Kind: KindDecisiveSwing,
|
||||
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||
StartLap: lap,
|
||||
EndLap: lap,
|
||||
StartTime: idx.lapStart(lap),
|
||||
EndTime: idx.lapEnd(lap),
|
||||
DriverNumbers: drivers,
|
||||
},
|
||||
significance: (prevPos-pos)*10 + (6 - pos),
|
||||
})
|
||||
seenDriver[driver] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].significance == candidates[j].significance {
|
||||
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||
}
|
||||
return candidates[i].significance > candidates[j].significance
|
||||
})
|
||||
if len(candidates) > maxDecisiveSwings {
|
||||
candidates = candidates[:maxDecisiveSwings]
|
||||
}
|
||||
out := make([]Chapter, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
out = append(out, c.chapter)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||
byLap := map[int][]PositionSample{}
|
||||
for _, p := range positions {
|
||||
if p.Position <= 0 {
|
||||
continue
|
||||
}
|
||||
lap := idx.lapForTime(p.Date)
|
||||
if lap <= 0 || lap > totalLaps {
|
||||
continue
|
||||
}
|
||||
byLap[lap] = append(byLap[lap], p)
|
||||
}
|
||||
last := map[int]int{}
|
||||
snapshots := map[int]map[int]int{}
|
||||
for lap := 1; lap <= totalLaps; lap++ {
|
||||
for _, p := range byLap[lap] {
|
||||
last[p.DriverNumber] = p.Position
|
||||
}
|
||||
if len(last) == 0 {
|
||||
continue
|
||||
}
|
||||
cp := make(map[int]int, len(last))
|
||||
for driver, pos := range last {
|
||||
cp[driver] = pos
|
||||
}
|
||||
snapshots[lap] = cp
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||
for driver, driverPos := range snapshot {
|
||||
if driver != exclude && driverPos == pos {
|
||||
return driver
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||
finishLap := totalLaps
|
||||
finishTime := idx.lapEnd(totalLaps)
|
||||
for _, msg := range rc {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||
if lap := messageLap(msg, idx); lap > 0 {
|
||||
finishLap = lap
|
||||
}
|
||||
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||
}
|
||||
}
|
||||
startLap := finishLap - 1
|
||||
if startLap < 1 {
|
||||
startLap = 1
|
||||
}
|
||||
return Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||
StartLap: startLap,
|
||||
EndLap: finishLap,
|
||||
StartTime: idx.lapStart(startLap),
|
||||
EndTime: finishTime,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||
normalized := make([]Chapter, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
if ch.StartLap <= 0 {
|
||||
ch.StartLap = 1
|
||||
}
|
||||
if ch.EndLap <= 0 {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.EndLap < ch.StartLap {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.DriverNumbers == nil {
|
||||
ch.DriverNumbers = []int{}
|
||||
}
|
||||
normalized = append(normalized, ch)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
if normalized[i].StartLap == normalized[j].StartLap {
|
||||
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||
}
|
||||
return normalized[i].StartLap < normalized[j].StartLap
|
||||
})
|
||||
|
||||
out := make([]Chapter, 0, len(normalized))
|
||||
for _, ch := range normalized {
|
||||
if len(out) == 0 {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
last := &out[len(out)-1]
|
||||
if ch.StartLap > last.EndLap {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||
continue
|
||||
}
|
||||
if priority(ch.Kind) > priority(last.Kind) {
|
||||
if last.StartLap < ch.StartLap {
|
||||
last.EndLap = ch.StartLap - 1
|
||||
out = append(out, ch)
|
||||
} else {
|
||||
*last = ch
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch.EndLap > last.EndLap {
|
||||
ch.StartLap = last.EndLap + 1
|
||||
if ch.StartLap <= ch.EndLap {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isFlag(kind string) bool {
|
||||
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||
}
|
||||
|
||||
func priority(kind string) int {
|
||||
switch kind {
|
||||
case KindStart, KindFinish:
|
||||
return structuralPriority
|
||||
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||
return flagPriority
|
||||
case KindPitPhase:
|
||||
return pitPhasePriority
|
||||
case KindDecisiveSwing:
|
||||
return decisivePriority
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||
return *msg.LapNumber
|
||||
}
|
||||
return idx.lapForTime(msg.Date)
|
||||
}
|
||||
|
||||
func clampLap(lap, minLap, maxLap int) int {
|
||||
if lap < minLap {
|
||||
return minLap
|
||||
}
|
||||
if maxLap > 0 && lap > maxLap {
|
||||
return maxLap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
func parseTime(raw string) (time.Time, bool) {
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return at, true
|
||||
}
|
||||
|
||||
func upperText(parts ...string) string {
|
||||
return strings.ToUpper(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||
out := make([]int, 0, len(drivers))
|
||||
for driver := range drivers {
|
||||
out = append(out, driver)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sliceToSet(values []int) map[int]bool {
|
||||
out := make(map[int]bool, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
218
internal/chapters/chapters_test.go
Normal file
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func TestDetectStructuralChapters(t *testing.T) {
|
||||
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||
|
||||
if len(chapters) < 2 {
|
||||
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||
}
|
||||
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||
}
|
||||
got := chapters[len(chapters)-1]
|
||||
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start RaceControl
|
||||
end RaceControl
|
||||
wantKind string
|
||||
wantTitle string
|
||||
}{
|
||||
{
|
||||
name: "safety car",
|
||||
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
wantKind: KindSafetyCar,
|
||||
wantTitle: "Safety Car (L12-L15)",
|
||||
},
|
||||
{
|
||||
name: "virtual safety car",
|
||||
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||
wantKind: KindVirtualSafetyCar,
|
||||
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||
},
|
||||
{
|
||||
name: "red flag",
|
||||
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||
wantKind: KindRedFlag,
|
||||
wantTitle: "Red Flag (L31-L33)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||
got := findKind(chapters, tt.wantKind)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||
}
|
||||
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
5: {1},
|
||||
10: {2},
|
||||
20: {3},
|
||||
21: {4},
|
||||
22: {5},
|
||||
30: {6},
|
||||
35: {7},
|
||||
40: {8},
|
||||
45: {9},
|
||||
50: {10},
|
||||
}
|
||||
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||
|
||||
got := findKind(chapters, KindPitPhase)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||
}
|
||||
if got.StartLap != 20 || got.EndLap != 22 {
|
||||
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||
positions := []PositionSample{
|
||||
pos(1, 1, 1),
|
||||
pos(1, 16, 4),
|
||||
pos(1, 55, 3),
|
||||
pos(6, 16, 3),
|
||||
pos(6, 55, 4),
|
||||
pos(8, 44, 5),
|
||||
pos(8, 63, 6),
|
||||
pos(10, 44, 6),
|
||||
}
|
||||
|
||||
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||
got := findKind(chapters, KindDecisiveSwing)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||
}
|
||||
if got.StartLap != 6 || got.EndLap != 6 {
|
||||
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
12: {1, 2},
|
||||
13: {3, 4},
|
||||
14: {5, 6},
|
||||
}
|
||||
rcs := []RaceControl{
|
||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
}
|
||||
|
||||
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||
}
|
||||
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||
}
|
||||
for i := 1; i < len(chapters); i++ {
|
||||
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rcLap(r RaceControl) int {
|
||||
if r.LapNumber == nil {
|
||||
return 0
|
||||
}
|
||||
return *r.LapNumber
|
||||
}
|
||||
|
||||
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||
for i := range chapters {
|
||||
if chapters[i].Kind == kind {
|
||||
return &chapters[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||
var laps []Lap
|
||||
for lap := 1; lap <= total; lap++ {
|
||||
drivers := []int{1}
|
||||
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||
drivers = pitDrivers
|
||||
}
|
||||
for _, driver := range drivers {
|
||||
laps = append(laps, Lap{
|
||||
DriverNumber: driver,
|
||||
LapNumber: lap,
|
||||
DateStart: lapTime(lap),
|
||||
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||
})
|
||||
}
|
||||
}
|
||||
return laps
|
||||
}
|
||||
|
||||
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||
return RaceControl{
|
||||
Category: category,
|
||||
Flag: flag,
|
||||
Message: message,
|
||||
LapNumber: &lap,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func pos(lap int, driver int, position int) PositionSample {
|
||||
return PositionSample{
|
||||
DriverNumber: driver,
|
||||
Position: position,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func lapTime(lap int) string {
|
||||
minute := lap - 1
|
||||
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||
}
|
||||
|
||||
func containsDriver(drivers []int, driver int) bool {
|
||||
for _, candidate := range drivers {
|
||||
if candidate == driver {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
||||
RaceControl []models.RaceControl `json:"race_control"`
|
||||
Weather []models.Weather `json:"weather"`
|
||||
Laps []models.Lap `json:"laps"`
|
||||
Chapters []chapters.Chapter `json:"chapters"`
|
||||
}
|
||||
|
||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
RaceControl: []models.RaceControl{},
|
||||
Weather: []models.Weather{},
|
||||
Laps: []models.Lap{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||
}
|
||||
|
||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||
total := 0
|
||||
for _, result := range results {
|
||||
if result.NumberOfLaps > total {
|
||||
total = result.NumberOfLaps
|
||||
}
|
||||
}
|
||||
for _, lap := range laps {
|
||||
if lap.LapNumber > total {
|
||||
total = lap.LapNumber
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
)
|
||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
||||
Drivers: []models.Driver{},
|
||||
Results: []query.EnrichedResult{},
|
||||
StartingGrid: []query.EnrichedGrid{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
sessionKey := 9472
|
||||
meetingKey := 1229
|
||||
|
||||
for lap := 1; lap <= 12; lap++ {
|
||||
if err := st.UpsertLap(store.Lap{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
LapNumber: lap,
|
||||
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
LapDuration: 75,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||
}
|
||||
}
|
||||
for _, sample := range []store.PositionSample{
|
||||
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||
} {
|
||||
if err := st.UpsertPositionSample(sample); err != nil {
|
||||
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := testServer(t, st)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRaceHub(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var hub query.RaceHub
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(hub.Chapters) == 0 {
|
||||
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||
}
|
||||
if hub.Chapters[0].Kind != "start" {
|
||||
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||
}
|
||||
foundSwing := false
|
||||
for _, chapter := range hub.Chapters {
|
||||
if chapter.Kind == "decisive_swing" {
|
||||
foundSwing = true
|
||||
}
|
||||
}
|
||||
if !foundSwing {
|
||||
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
|
||||
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