mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
9 Commits
f4f5071e26
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7212eb9b44 | ||
|
|
1782fd313c | ||
|
|
be7eb3c034 | ||
|
|
71523641ad | ||
|
|
e51cabba62 | ||
|
|
fc6fc36d8f | ||
|
|
16d82afad5 | ||
|
|
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">
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
|||||||
race_control: [],
|
race_control: [],
|
||||||
weather: [],
|
weather: [],
|
||||||
laps: [],
|
laps: [],
|
||||||
|
chapters: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
|||||||
is_pit_out_lap: false,
|
is_pit_out_lap: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chapters: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekend: Weekend = {
|
const weekend: Weekend = {
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ export interface RaceHub {
|
|||||||
race_control: RaceControlMessage[]
|
race_control: RaceControlMessage[]
|
||||||
weather: WeatherSample[]
|
weather: WeatherSample[]
|
||||||
laps: Lap[]
|
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 {
|
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
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ 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 {
|
||||||
@@ -51,60 +60,101 @@ 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
|
||||||
break
|
} else if strings.EqualFold(sess.SessionName, "Sprint") {
|
||||||
|
sprintKey = sess.SessionKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if raceKey == 0 {
|
if raceKey == 0 && sprintKey == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := s.store.ListSessionResults(raceKey)
|
// Always build Races slice from the Race session only (for UI),
|
||||||
if err != nil {
|
// but include sprint points for standings.
|
||||||
return ChampionshipInputs{}, err
|
var raceResults []models.SessionResult
|
||||||
}
|
var raceGrid []models.StartingGrid
|
||||||
grid, err := s.store.ListStartingGrid(raceKey)
|
|
||||||
if err != nil {
|
|
||||||
return ChampionshipInputs{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
race := ChampionshipRace{
|
if raceKey != 0 {
|
||||||
Meeting: meeting,
|
results, err := s.store.ListSessionResults(raceKey)
|
||||||
RaceSessionKey: raceKey,
|
if err != nil {
|
||||||
Results: make([]models.SessionResult, 0, len(results)),
|
return ChampionshipInputs{}, err
|
||||||
Grid: make([]models.StartingGrid, 0, len(grid)),
|
}
|
||||||
}
|
grid, err := s.store.ListStartingGrid(raceKey)
|
||||||
for _, result := range results {
|
if err != nil {
|
||||||
race.Results = append(race.Results, resultToModel(result))
|
return ChampionshipInputs{}, err
|
||||||
}
|
}
|
||||||
for _, entry := range grid {
|
race := ChampionshipRace{
|
||||||
race.Grid = append(race.Grid, gridToModel(entry))
|
Meeting: meeting,
|
||||||
}
|
RaceSessionKey: raceKey,
|
||||||
inputs.Races = append(inputs.Races, race)
|
Results: make([]models.SessionResult, 0, len(results)),
|
||||||
|
Grid: make([]models.StartingGrid, 0, len(grid)),
|
||||||
|
}
|
||||||
|
for _, result := range results {
|
||||||
|
race.Results = append(race.Results, resultToModel(result))
|
||||||
|
}
|
||||||
|
for _, entry := range grid {
|
||||||
|
race.Grid = append(race.Grid, gridToModel(entry))
|
||||||
|
}
|
||||||
|
inputs.Races = append(inputs.Races, race)
|
||||||
|
raceResults = race.Results
|
||||||
|
|
||||||
if len(results) == 0 {
|
if len(results) > 0 {
|
||||||
continue
|
latestSessionKey = raceKey
|
||||||
}
|
latestMeetingKey = int(meeting.MeetingKey)
|
||||||
|
drivers, err := s.driversForSession(raceKey)
|
||||||
latestSessionKey = raceKey
|
if err != nil {
|
||||||
latestMeetingKey = int(meeting.MeetingKey)
|
return ChampionshipInputs{}, err
|
||||||
drivers, err := s.driversForSession(raceKey)
|
}
|
||||||
if err != nil {
|
for _, d := range drivers {
|
||||||
return ChampionshipInputs{}, err
|
inputs.DriverMap[d.DriverNumber] = d
|
||||||
}
|
}
|
||||||
for _, d := range drivers {
|
for _, result := range results {
|
||||||
inputs.DriverMap[d.DriverNumber] = d
|
pointsByDriver[result.DriverNumber] += result.Points
|
||||||
}
|
team := inputs.DriverMap[result.DriverNumber].TeamName
|
||||||
|
if team != "" {
|
||||||
for _, result := range results {
|
pointsByTeam[team] += result.Points
|
||||||
pointsByDriver[result.DriverNumber] += result.Points
|
}
|
||||||
team := inputs.DriverMap[result.DriverNumber].TeamName
|
}
|
||||||
if team != "" {
|
|
||||||
pointsByTeam[team] += result.Points
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|||||||
@@ -375,3 +375,56 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
|||||||
RaceControl []models.RaceControl `json:"race_control"`
|
RaceControl []models.RaceControl `json:"race_control"`
|
||||||
Weather []models.Weather `json:"weather"`
|
Weather []models.Weather `json:"weather"`
|
||||||
Laps []models.Lap `json:"laps"`
|
Laps []models.Lap `json:"laps"`
|
||||||
|
Chapters []chapters.Chapter `json:"chapters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
// 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{},
|
RaceControl: []models.RaceControl{},
|
||||||
Weather: []models.Weather{},
|
Weather: []models.Weather{},
|
||||||
Laps: []models.Lap{},
|
Laps: []models.Lap{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(sessionKey)
|
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.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)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
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"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
|||||||
Drivers: []models.Driver{},
|
Drivers: []models.Driver{},
|
||||||
Results: []query.EnrichedResult{},
|
Results: []query.EnrichedResult{},
|
||||||
StartingGrid: []query.EnrichedGrid{},
|
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) {
|
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
seedRaceHubStore(t, st)
|
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