mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add local-first refactor foundation
This commit is contained in:
323
internal/live/parser_test.go
Normal file
323
internal/live/parser_test.go
Normal 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
82
internal/live/signalr.go
Normal 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
495
internal/live/state.go
Normal 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
144
internal/live/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user