Add local-first refactor foundation

This commit is contained in:
2026-05-25 00:45:46 -04:00
parent 161e871c53
commit 517c6b987b
45 changed files with 11545 additions and 697 deletions

View File

@@ -0,0 +1,323 @@
package live_test
import (
"encoding/json"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
)
func TestProcessMessageFullState(t *testing.T) {
state := live.NewState()
msg := []byte(`{
"R": {
"TimingData": {"Lines": {"1": {"Position": "1", "RacingNumber": "1", "LastLapTime": {"Value": "1:32.456"}}}},
"DriverList": {"1": {"RacingNumber": "1", "Tla": "VER", "TeamName": "Red Bull"}},
"LapCount": {"CurrentLap": 12, "TotalLaps": 57},
"TrackStatus": {"Status": "1", "Message": "AllClear"},
"WeatherData": {"AirTemp": "24.5", "TrackTemp": "38.0", "Humidity": "55", "WindSpeed": "2.1", "WindDirection": "180", "Rainfall": "0"},
"SessionInfo": {"Meeting": {"Name": "Monaco Grand Prix"}, "Name": "Race", "Type": "Race"},
"ExtrapolatedClock": {"Remaining": "0:45:00", "Utc": "2025-05-25T14:00:00Z", "Extrapolating": true}
}
}`)
if !state.ProcessMessage(msg) {
t.Fatal("expected full-state message to produce updates")
}
snap := state.Snapshot()
if snap.Drivers["1"].Position != 1 {
t.Errorf("driver position = %d, want 1", snap.Drivers["1"].Position)
}
if snap.Drivers["1"].LastLapTime != "1:32.456" {
t.Errorf("last lap = %q, want 1:32.456", snap.Drivers["1"].LastLapTime)
}
if snap.DriverInfo["1"].Tla != "VER" {
t.Errorf("TLA = %q, want VER", snap.DriverInfo["1"].Tla)
}
if snap.CurrentLap != 12 || snap.TotalLaps != 57 {
t.Errorf("laps = %d/%d, want 12/57", snap.CurrentLap, snap.TotalLaps)
}
if snap.TrackStatus != "1" {
t.Errorf("track status = %q, want 1", snap.TrackStatus)
}
if snap.Weather.AirTemp != 24.5 || snap.Weather.TrackTemp != 38.0 {
t.Errorf("weather temps = %.1f/%.1f, want 24.5/38.0", snap.Weather.AirTemp, snap.Weather.TrackTemp)
}
if snap.Session.MeetingName != "Monaco Grand Prix" || snap.Session.SessionName != "Race" {
t.Errorf("session = %+v", snap.Session)
}
if snap.Clock != "0:45:00" || !snap.ClockExtrapolating {
t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating)
}
}
func TestProcessMessageIncremental(t *testing.T) {
state := live.NewState()
msg := []byte(`{
"M": [{
"A": ["TimingData", {"Lines": {"44": {"Position": "2", "GapToLeader": "+1.234", "IntervalToPositionAhead": {"Value": "+0.456"}}}}]
}]
}`)
if !state.ProcessMessage(msg) {
t.Fatal("expected incremental message to produce updates")
}
d := state.Snapshot().Drivers["44"]
if d.Position != 2 {
t.Errorf("position = %d, want 2", d.Position)
}
if d.GapToLeader != "+1.234" {
t.Errorf("gap = %q, want +1.234", d.GapToLeader)
}
if d.Interval != "+0.456" {
t.Errorf("interval = %q, want +0.456", d.Interval)
}
}
func TestProcessTopicTimingData(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
"Lines": {
"16": {
"Position": 3,
"GapToLeader": 2.5,
"NumberOfLaps": "15",
"Sectors": {
"0": {"Value": "28.123", "PersonalFastest": true},
"1": {"Value": "31.456"},
"2": {"Value": ""}
},
"Speeds": {"ST": {"Value": "312"}}
}
}
}`)
if !state.ProcessTopic("TimingData", data) {
t.Fatal("TimingData should update state")
}
d := state.Snapshot().Drivers["16"]
if d.Position != 3 {
t.Errorf("position = %d, want 3", d.Position)
}
if d.GapToLeader != "+2.500" {
t.Errorf("gap = %q, want +2.500", d.GapToLeader)
}
if d.NumberOfLaps != 15 {
t.Errorf("laps = %d, want 15", d.NumberOfLaps)
}
if d.Sectors[0].Value != "28.123" || !d.Sectors[0].PersonalFastest {
t.Errorf("sector 0 = %+v", d.Sectors[0])
}
if d.Sectors[2].Value != "" {
t.Errorf("sector 2 should be cleared, got %q", d.Sectors[2].Value)
}
if !d.OnFlyingLap {
t.Error("expected OnFlyingLap=true when S1/S2 set and S3 empty")
}
if d.SpeedTrap != "312" {
t.Errorf("speed trap = %q, want 312", d.SpeedTrap)
}
}
func TestProcessTopicDriverList(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
state.ProcessTopic("DriverList", data)
if len(state.Snapshot().DriverInfo) != 1 {
t.Fatalf("expected 1 driver info entry")
}
// Entries without TLA are ignored.
data2 := json.RawMessage(`{"99": {"RacingNumber": "99", "TeamName": "Unknown"}}`)
state.ProcessTopic("DriverList", data2)
if _, ok := state.Snapshot().DriverInfo["99"]; ok {
t.Error("driver without TLA should be ignored")
}
}
func TestProcessTopicLapCount(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{"CurrentLap": "5", "TotalLaps": "78"}`)
state.ProcessTopic("LapCount", data)
snap := state.Snapshot()
if snap.CurrentLap != 5 || snap.TotalLaps != 78 {
t.Errorf("laps = %d/%d, want 5/78", snap.CurrentLap, snap.TotalLaps)
}
}
func TestProcessTopicExtrapolatedClock(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{"Remaining": "1:00:00", "Utc": "2025-05-25T15:04:05.123Z", "Extrapolating": true}`)
state.ProcessTopic("ExtrapolatedClock", data)
snap := state.Snapshot()
if snap.Clock != "1:00:00" || !snap.ClockExtrapolating {
t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating)
}
want := time.Date(2025, 5, 25, 15, 4, 5, 123000000, time.UTC)
if !snap.ClockRefTime.Equal(want) {
t.Errorf("ClockRefTime = %v, want %v", snap.ClockRefTime, want)
}
}
func TestProcessTopicTrackStatus(t *testing.T) {
state := live.NewState()
state.ProcessTopic("TrackStatus", json.RawMessage(`{"Status": "4", "Message": "SC DEPLOYED"}`))
if state.Snapshot().TrackStatus != "4" {
t.Errorf("track status = %q, want 4", state.Snapshot().TrackStatus)
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
"Messages": {
"1": {"Utc": "2025-05-25T15:04:30Z", "Category": "Flag", "Flag": "YELLOW", "Message": "Yellow in sector 2", "Lap": 8}
}
}`)
state.ProcessTopic("RaceControlMessages", data)
rc := state.Snapshot().RCMessages
if len(rc) != 1 {
t.Fatalf("expected 1 RC message, got %d", len(rc))
}
if rc[0].Time != "15:04" || rc[0].Flag != "YELLOW" || rc[0].Message != "Yellow in sector 2" || rc[0].Lap != 8 {
t.Errorf("RC message = %+v", rc[0])
}
}
func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{
"AirTemp": 22, "TrackTemp": 35, "Humidity": 60, "WindSpeed": 3.5, "WindDirection": 90, "Rainfall": 1
}`))
w := state.Snapshot().Weather
if w.AirTemp != 22 || w.TrackTemp != 35 || w.Humidity != 60 || w.WindSpeed != 3.5 || w.WindDir != 90 || !w.Rainfall {
t.Errorf("weather = %+v", w)
}
}
func TestProcessTopicSessionInfo(t *testing.T) {
state := live.NewState()
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix"},
"Name": "Qualifying",
"Type": "Qualifying"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.SessionName != "Qualifying" || s.SessionType != "Qualifying" {
t.Errorf("session = %+v", s)
}
}
func TestProcessTopicCurrentTyres(t *testing.T) {
state := live.NewState()
state.Tyres["1"] = live.LiveTyreData{Age: 7}
state.ProcessTopic("CurrentTyres", json.RawMessage(`{
"1": {"Compound": "SOFT", "New": "true"},
"_kf": {"Compound": "ignore"}
}`))
tyre := state.Snapshot().Tyres["1"]
if tyre.Compound != "SOFT" || !tyre.New {
t.Errorf("tyre = %+v", tyre)
}
if tyre.Age != 7 {
t.Errorf("age should be preserved from prior state, got %d", tyre.Age)
}
}
func TestProcessTopicTimingAppData(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
"Lines": {
"4": {
"Stints": {
"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0},
"1": {"Compound": "HARD", "New": "false", "TotalLaps": 18}
}
}
}
}`)
state.ProcessTopic("TimingAppData", data)
snap := state.Snapshot()
stints := snap.Stints["4"]
if len(stints) != 2 {
t.Fatalf("expected 2 stints, got %d", len(stints))
}
tyre := snap.Tyres["4"]
if tyre.Compound != "HARD" || tyre.Age != 18 || tyre.New {
t.Errorf("tyre synced from stint = %+v", tyre)
}
}
func TestProcessTopicTimingStats(t *testing.T) {
state := live.NewState()
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
state.ProcessTopic("TimingStats", json.RawMessage(`{
"Lines": {"55": {"PersonalBestLapTime": {"Value": "1:28.999"}}}
}`))
if state.Snapshot().Drivers["55"].BestLapTime != "1:28.999" {
t.Errorf("best lap = %q", state.Snapshot().Drivers["55"].BestLapTime)
}
}
func TestProcessTopicUnknownIgnored(t *testing.T) {
state := live.NewState()
if state.ProcessTopic("Heartbeat", json.RawMessage(`{"Seq": 1}`)) {
t.Error("unknown topic should not mark state updated")
}
if state.ProcessTopic("TotallyUnknown", json.RawMessage(`{"foo": "bar"}`)) {
t.Error("unknown topic should not mark state updated")
}
}
func TestSnapshotCopiesMapsAndSlices(t *testing.T) {
state := live.NewState()
state.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 1, GapToLeader: "+0.000"}
state.DriverInfo["1"] = live.F1DriverListEntry{Tla: "VER"}
state.Tyres["1"] = live.LiveTyreData{Compound: "SOFT", Age: 5}
state.Stints["1"] = []live.LiveStintData{{Compound: "SOFT", Laps: 5}}
state.RCMessages = []live.LiveRCMessage{{Message: "Green flag"}}
snap := state.Snapshot()
snap.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 99}
snap.DriverInfo["1"] = live.F1DriverListEntry{Tla: "MUTATED"}
snap.Tyres["1"] = live.LiveTyreData{Compound: "WET"}
snap.Stints["1"][0].Compound = "WET"
snap.RCMessages[0].Message = "mutated"
inner := state.Snapshot()
if inner.Drivers["1"].Position != 1 {
t.Error("mutating snapshot drivers leaked into state")
}
if inner.DriverInfo["1"].Tla != "VER" {
t.Error("mutating snapshot driverInfo leaked into state")
}
if inner.Tyres["1"].Compound != "SOFT" {
t.Error("mutating snapshot tyres leaked into state")
}
if inner.Stints["1"][0].Compound != "SOFT" {
t.Error("mutating snapshot stints leaked into state")
}
if inner.RCMessages[0].Message != "Green flag" {
t.Error("mutating snapshot RC messages leaked into state")
}
}
func TestProcessMessageInvalidJSON(t *testing.T) {
state := live.NewState()
if state.ProcessMessage([]byte(`not json`)) {
t.Error("invalid JSON should not update state")
}
}
func TestProcessMessageEmptyPayload(t *testing.T) {
state := live.NewState()
if state.ProcessMessage([]byte(`{}`)) {
t.Error("empty envelope should not update state")
}
}

82
internal/live/signalr.go Normal file
View File

@@ -0,0 +1,82 @@
package live
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
)
// ConnectToF1LiveTiming negotiates with the official F1 SignalR hub, subscribes
// to timing topics, and sends defensive snapshots on dataChan until the
// connection closes.
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
hubName := `[{"name":"Streaming"}]`
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
req, err := http.NewRequest("GET", negotiateURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
cookies := resp.Cookies()
defer resp.Body.Close()
var neg struct {
ConnectionToken string `json:"ConnectionToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
return err
}
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
url.QueryEscape(neg.ConnectionToken),
url.QueryEscape(hubName),
)
header := http.Header{}
for _, cookie := range cookies {
header.Add("Cookie", cookie.String())
}
header.Add("User-Agent", "BestHTTP")
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
}
go func() {
defer c.Close()
state := NewState()
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("WS Read Error:", err)
return
}
if state.ProcessMessage(message) {
select {
case dataChan <- state.Snapshot():
default:
}
}
}
}()
return nil
}

495
internal/live/state.go Normal file
View File

@@ -0,0 +1,495 @@
package live
import (
"encoding/json"
"fmt"
"time"
)
// State accumulates live timing updates from SignalR topic payloads.
type State struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData
Stints map[string][]LiveStintData
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TrackStatus string
CurrentLap int
TotalLaps int
Clock string
ClockRefTime time.Time
ClockExtrapolating bool
}
// NewState returns an empty live timing accumulator.
func NewState() *State {
return &State{
Drivers: make(map[string]LiveDriverData),
DriverInfo: make(map[string]F1DriverListEntry),
Tyres: make(map[string]LiveTyreData),
Stints: make(map[string][]LiveStintData),
}
}
// Snapshot returns a defensive copy of the current state.
func (s *State) Snapshot() LiveStreamData {
cpyDrivers := make(map[string]LiveDriverData, len(s.Drivers))
for k, v := range s.Drivers {
cpyDrivers[k] = v
}
cpyInfo := make(map[string]F1DriverListEntry, len(s.DriverInfo))
for k, v := range s.DriverInfo {
cpyInfo[k] = v
}
cpyTyres := make(map[string]LiveTyreData, len(s.Tyres))
for k, v := range s.Tyres {
cpyTyres[k] = v
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages)
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints {
st := make([]LiveStintData, len(v))
copy(st, v)
cpyStints[k] = st
}
return LiveStreamData{
Drivers: cpyDrivers,
DriverInfo: cpyInfo,
Tyres: cpyTyres,
RCMessages: cpyRC,
Weather: s.Weather,
Session: s.Session,
TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps,
Clock: s.Clock,
ClockRefTime: s.ClockRefTime,
ClockExtrapolating: s.ClockExtrapolating,
Stints: cpyStints,
}
}
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
func (s *State) ProcessMessage(message []byte) bool {
var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil {
return false
}
updated := false
if len(parsed.R) > 2 {
var rMap map[string]json.RawMessage
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
for topic, data := range rMap {
if s.ProcessTopic(topic, data) {
updated = true
}
}
}
}
for _, m := range parsed.M {
if len(m.A) > 1 {
var topic string
json.Unmarshal(m.A[0], &topic)
if s.ProcessTopic(topic, m.A[1]) {
updated = true
}
}
}
return updated
}
// ProcessTopic applies a single topic payload to the accumulator.
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
updated := false
switch topic {
case "TimingData":
var td struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &td) == nil {
for num, lineRaw := range td.Lines {
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(s.Drivers, num, line)
updated = true
}
}
}
case "DriverList":
var dlMap map[string]json.RawMessage
if json.Unmarshal(data, &dlMap) == nil {
for num, entryRaw := range dlMap {
var entry F1DriverListEntry
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
s.DriverInfo[num] = entry
updated = true
}
}
}
case "LapCount":
var lc struct {
CurrentLap json.Number `json:"CurrentLap"`
TotalLaps json.Number `json:"TotalLaps"`
}
if json.Unmarshal(data, &lc) == nil {
if v, err := lc.CurrentLap.Int64(); err == nil {
s.CurrentLap = int(v)
}
if v, err := lc.TotalLaps.Int64(); err == nil {
s.TotalLaps = int(v)
}
updated = true
}
case "ExtrapolatedClock":
var ec struct {
Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Extrapolating bool `json:"Extrapolating"`
}
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
s.Clock = ec.Remaining
s.ClockExtrapolating = ec.Extrapolating
if ec.Utc != "" {
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
s.ClockRefTime = t
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
s.ClockRefTime = t
} else {
s.ClockRefTime = time.Now()
}
} else {
s.ClockRefTime = time.Now()
}
updated = true
}
case "TrackStatus":
var ts struct {
Status string `json:"Status"`
Message string `json:"Message"`
}
if json.Unmarshal(data, &ts) == nil && ts.Status != "" {
s.TrackStatus = ts.Status
updated = true
}
case "RaceControlMessages":
var rcm struct {
Messages map[string]json.RawMessage `json:"Messages"`
}
if json.Unmarshal(data, &rcm) == nil {
for _, msgRaw := range rcm.Messages {
var msg struct {
Utc string `json:"Utc"`
Category string `json:"Category"`
Flag string `json:"Flag"`
Message string `json:"Message"`
Lap int `json:"Lap"`
}
if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" {
t := ""
if len(msg.Utc) >= 19 {
t = msg.Utc[11:16]
}
s.RCMessages = append(s.RCMessages, LiveRCMessage{
Time: t,
Category: msg.Category,
Flag: msg.Flag,
Message: msg.Message,
Lap: msg.Lap,
})
updated = true
}
}
}
case "WeatherData":
var wd struct {
AirTemp json.Number `json:"AirTemp"`
TrackTemp json.Number `json:"TrackTemp"`
Humidity json.Number `json:"Humidity"`
WindSpeed json.Number `json:"WindSpeed"`
WindDirection json.Number `json:"WindDirection"`
Rainfall json.Number `json:"Rainfall"`
}
if json.Unmarshal(data, &wd) == nil {
if v, err := wd.AirTemp.Float64(); err == nil {
s.Weather.AirTemp = v
}
if v, err := wd.TrackTemp.Float64(); err == nil {
s.Weather.TrackTemp = v
}
if v, err := wd.Humidity.Float64(); err == nil {
s.Weather.Humidity = v
}
if v, err := wd.WindSpeed.Float64(); err == nil {
s.Weather.WindSpeed = v
}
if v, err := wd.WindDirection.Int64(); err == nil {
s.Weather.WindDir = int(v)
}
if v, err := wd.Rainfall.Float64(); err == nil {
s.Weather.Rainfall = v > 0
}
updated = true
}
case "SessionInfo":
var si struct {
Meeting struct {
Name string `json:"Name"`
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
s.Session.MeetingName = si.Meeting.Name
}
if si.Name != "" {
s.Session.SessionName = si.Name
}
if si.Type != "" {
s.Session.SessionType = si.Type
}
updated = true
}
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
for num, raw := range ct {
if num == "_kf" {
continue
}
var td struct {
Compound string `json:"Compound"`
New string `json:"New"`
}
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
t := s.Tyres[num]
t.Compound = td.Compound
t.New = td.New == "true" || td.New == "True"
s.Tyres[num] = t
updated = true
}
}
}
case "TimingAppData":
var tad struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &tad) == nil {
for num, lineRaw := range tad.Lines {
var line struct {
Stints map[string]json.RawMessage `json:"Stints"`
}
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData
for _, sRaw := range line.Stints {
var st struct {
Compound string `json:"Compound"`
New string `json:"New"`
TotalLaps int `json:"TotalLaps"`
}
if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" {
driverStints = append(driverStints, LiveStintData{
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
}
}
if len(driverStints) > 0 {
s.Stints[num] = driverStints
lastStint := driverStints[len(driverStints)-1]
t := s.Tyres[num]
t.Age = lastStint.Laps
if lastStint.Compound != "" {
t.Compound = lastStint.Compound
t.New = lastStint.New
}
s.Tyres[num] = t
updated = true
}
}
}
}
case "TimingStats":
var ts struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &ts) == nil {
for num, lineRaw := range ts.Lines {
var line struct {
PersonalBestLapTime struct {
Value string `json:"Value"`
} `json:"PersonalBestLapTime"`
}
if json.Unmarshal(lineRaw, &line) == nil {
if d, ok := s.Drivers[num]; ok && line.PersonalBestLapTime.Value != "" {
d.BestLapTime = line.PersonalBestLapTime.Value
s.Drivers[num] = d
updated = true
}
}
}
}
}
return updated
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num]
if !exists {
d = LiveDriverData{RacingNumber: num}
if line.RacingNumber != "" {
d.RacingNumber = line.RacingNumber
}
}
if line.Position != nil {
var newPos int
switch v := line.Position.(type) {
case string:
fmt.Sscanf(v, "%d", &newPos)
case float64:
newPos = int(v)
}
if newPos > 0 && newPos != d.Position {
d.PrevPosition = d.Position
d.Position = newPos
}
}
if line.GapToLeader != nil {
if s := extractStringVal(line.GapToLeader); s != "" {
d.GapToLeader = s
}
}
if line.IntervalToPositionAhead.Value != nil {
if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" {
d.Interval = s
}
}
if line.LastLapTime.Value != "" {
d.LastLapTime = line.LastLapTime.Value
d.LastLapPB = line.LastLapTime.PersonalFastest
d.LastLapOB = line.LastLapTime.OverallFastest
}
if line.BestLapTime.Value != "" {
d.BestLapTime = line.BestLapTime.Value
d.BestLapPB = line.BestLapTime.PersonalFastest
d.BestLapOB = line.BestLapTime.OverallFastest
if line.BestLapTime.Lap > 0 {
d.BestLapNum = line.BestLapTime.Lap
}
}
if line.InPit != nil {
d.InPit = toBool(line.InPit)
}
if line.PitOut != nil {
d.PitOut = toBool(line.PitOut)
}
if line.Retired != nil {
d.Retired = toBool(line.Retired)
}
if line.KnockedOut != nil {
d.KnockedOut = toBool(line.KnockedOut)
}
if line.Cutoff != nil {
d.Cutoff = toBool(line.Cutoff)
}
if line.NumberOfLaps != nil {
if v, ok := toInt(line.NumberOfLaps); ok {
d.NumberOfLaps = v
}
}
if st, ok := line.Speeds["ST"]; ok {
var sp struct {
Value string `json:"Value"`
}
if json.Unmarshal(st, &sp) == nil && sp.Value != "" {
d.SpeedTrap = sp.Value
}
}
for idx, sRaw := range line.Sectors {
i := 0
fmt.Sscanf(idx, "%d", &i)
if i >= 0 && i < 3 {
var sec struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
}
if json.Unmarshal(sRaw, &sec) == nil {
if sec.Value == "" {
d.Sectors[i] = LiveSectorData{}
} else {
d.Sectors[i] = LiveSectorData{
Value: sec.Value,
PersonalFastest: sec.PersonalFastest,
OverallFastest: sec.OverallFastest,
}
}
}
}
}
d.OnFlyingLap = !d.InPit && !d.Retired &&
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
d.Sectors[2].Value == ""
drivers[num] = d
}
func extractStringVal(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
if val == 0 {
return ""
}
return fmt.Sprintf("+%.3f", val)
case map[string]interface{}:
if s, ok := val["Value"].(string); ok {
return s
}
}
return ""
}
func toBool(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true" || val == "True"
}
return false
}
func toInt(v interface{}) (int, bool) {
switch val := v.(type) {
case float64:
return int(val), true
case json.Number:
if i, err := val.Int64(); err == nil {
return int(i), true
}
case string:
var i int
if _, err := fmt.Sscanf(val, "%d", &i); err == nil {
return i, true
}
}
return 0, false
}

144
internal/live/types.go Normal file
View File

@@ -0,0 +1,144 @@
package live
import (
"encoding/json"
"time"
)
// F1SignalRMessage is the top-level envelope from the official F1 SignalR feed.
type F1SignalRMessage struct {
M []struct {
A []json.RawMessage `json:"A"`
} `json:"M"`
R json.RawMessage `json:"R"`
}
// F1TimingLine is a single driver's timing row from TimingData.
type F1TimingLine struct {
GapToLeader interface{} `json:"GapToLeader"`
IntervalToPositionAhead struct {
Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"`
Position interface{} `json:"Position"`
RacingNumber string `json:"RacingNumber"`
LastLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
} `json:"LastLapTime"`
BestLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
Lap int `json:"Lap"`
} `json:"BestLapTime"`
InPit interface{} `json:"InPit"`
PitOut interface{} `json:"PitOut"`
Retired interface{} `json:"Retired"`
KnockedOut interface{} `json:"KnockedOut"`
Cutoff interface{} `json:"Cutoff"`
NumberOfLaps interface{} `json:"NumberOfLaps"`
Sectors map[string]json.RawMessage `json:"Sectors"`
Speeds map[string]json.RawMessage `json:"Speeds"`
}
// F1DriverListEntry is driver metadata from the DriverList topic.
type F1DriverListEntry struct {
RacingNumber string `json:"RacingNumber"`
BroadcastName string `json:"BroadcastName"`
Tla string `json:"Tla"`
TeamName string `json:"TeamName"`
TeamColour string `json:"TeamColour"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
}
// LiveTyreData holds current tyre compound and age for a driver.
type LiveTyreData struct {
Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET
New bool
Age int // laps on current set
}
// LiveRCMessage is a parsed race control message.
type LiveRCMessage struct {
Time string // "15:04" formatted
Category string // Flag, SafetyCar, Drs, Other
Flag string // GREEN, YELLOW, RED, etc.
Message string
Lap int
}
// LiveWeatherData holds session weather readings.
type LiveWeatherData struct {
AirTemp float64
TrackTemp float64
Humidity float64
WindSpeed float64
WindDir int
Rainfall bool
}
// LiveSessionMeta holds session and meeting metadata.
type LiveSessionMeta struct {
MeetingName string
CircuitName string
SessionType string
SessionName string
}
// LiveSectorData holds a single sector time and flags.
type LiveSectorData struct {
Value string
PersonalFastest bool
OverallFastest bool
}
// LiveDriverData is the normalized timing state for one driver.
type LiveDriverData struct {
RacingNumber string
Position int
PrevPosition int
GapToLeader string
Interval string
LastLapTime string
LastLapPB bool // personal best
LastLapOB bool // overall best
BestLapTime string
BestLapPB bool // just set a new personal best
BestLapOB bool // overall fastest in session
BestLapNum int // lap number when best was set
InPit bool
PitOut bool
Retired bool
KnockedOut bool // eliminated in qualifying
Cutoff bool // currently in elimination zone (danger zone)
OnFlyingLap bool // currently running a timed lap (derived from sector state)
NumberOfLaps int
SpeedTrap string // fastest recorded speed at speed trap
Sectors [3]LiveSectorData
}
// LiveStintData is one stint in a driver's tyre history.
type LiveStintData struct {
Compound string
New bool
Laps int
}
// LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
Clock string // "HH:MM:SS" remaining at ClockRefTime
ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData
}