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:
@@ -1,707 +1,29 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/live"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SignalR protocol types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type F1SignalRMessage struct {
|
||||
M []struct {
|
||||
A []json.RawMessage `json:"A"`
|
||||
} `json:"M"`
|
||||
R json.RawMessage `json:"R"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data types from the WebSocket feed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type LiveTyreData struct {
|
||||
Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET
|
||||
New bool
|
||||
Age int // laps on current set
|
||||
}
|
||||
|
||||
type LiveRCMessage struct {
|
||||
Time string // "15:04" formatted
|
||||
Category string // Flag, SafetyCar, Drs, Other
|
||||
Flag string // GREEN, YELLOW, RED, etc.
|
||||
Message string
|
||||
Lap int
|
||||
}
|
||||
|
||||
type LiveWeatherData struct {
|
||||
AirTemp float64
|
||||
TrackTemp float64
|
||||
Humidity float64
|
||||
WindSpeed float64
|
||||
WindDir int
|
||||
Rainfall bool
|
||||
}
|
||||
|
||||
type LiveSessionMeta struct {
|
||||
MeetingName string
|
||||
CircuitName string
|
||||
SessionType string
|
||||
SessionName string
|
||||
}
|
||||
|
||||
type LiveSectorData struct {
|
||||
Value string
|
||||
PersonalFastest bool
|
||||
OverallFastest bool
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type LiveStintData struct {
|
||||
Compound string
|
||||
New bool
|
||||
Laps int
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket connection & parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Subscribe to all desired topics
|
||||
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()
|
||||
drivers := make(map[string]LiveDriverData)
|
||||
driverInfo := make(map[string]F1DriverListEntry)
|
||||
tyres := make(map[string]LiveTyreData)
|
||||
stints := make(map[string][]LiveStintData)
|
||||
var rcMessages []LiveRCMessage
|
||||
var weather LiveWeatherData
|
||||
var session LiveSessionMeta
|
||||
var trackStatus string
|
||||
var currentLap, totalLaps int
|
||||
var clock string
|
||||
var clockRefTime time.Time
|
||||
var clockExtrapolating bool
|
||||
|
||||
sendUpdate := func() {
|
||||
cpyDrivers := make(map[string]LiveDriverData)
|
||||
for k, v := range drivers {
|
||||
cpyDrivers[k] = v
|
||||
}
|
||||
cpyInfo := make(map[string]F1DriverListEntry)
|
||||
for k, v := range driverInfo {
|
||||
cpyInfo[k] = v
|
||||
}
|
||||
cpyTyres := make(map[string]LiveTyreData)
|
||||
for k, v := range tyres {
|
||||
cpyTyres[k] = v
|
||||
}
|
||||
cpyRC := make([]LiveRCMessage, len(rcMessages))
|
||||
copy(cpyRC, rcMessages)
|
||||
cpyStints := make(map[string][]LiveStintData)
|
||||
for k, v := range stints {
|
||||
s := make([]LiveStintData, len(v))
|
||||
copy(s, v)
|
||||
cpyStints[k] = s
|
||||
}
|
||||
|
||||
select {
|
||||
case dataChan <- LiveStreamData{
|
||||
Drivers: cpyDrivers,
|
||||
DriverInfo: cpyInfo,
|
||||
Tyres: cpyTyres,
|
||||
RCMessages: cpyRC,
|
||||
Weather: weather,
|
||||
Session: session,
|
||||
TrackStatus: trackStatus,
|
||||
CurrentLap: currentLap,
|
||||
TotalLaps: totalLaps,
|
||||
Clock: clock,
|
||||
ClockRefTime: clockRefTime,
|
||||
ClockExtrapolating: clockExtrapolating,
|
||||
Stints: cpyStints,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// processTopic handles a single topic's JSON payload (shared by R and M paths)
|
||||
processTopic := func(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(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 != "" {
|
||||
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 {
|
||||
currentLap = int(v)
|
||||
}
|
||||
if v, err := lc.TotalLaps.Int64(); err == nil {
|
||||
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 != "" {
|
||||
clock = ec.Remaining
|
||||
clockExtrapolating = ec.Extrapolating
|
||||
if ec.Utc != "" {
|
||||
// Try RFC3339 first, then with milliseconds
|
||||
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
|
||||
clockRefTime = t
|
||||
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
|
||||
clockRefTime = t
|
||||
} else {
|
||||
clockRefTime = time.Now()
|
||||
}
|
||||
} else {
|
||||
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 != "" {
|
||||
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]
|
||||
}
|
||||
rcMessages = append(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 {
|
||||
weather.AirTemp = v
|
||||
}
|
||||
if v, err := wd.TrackTemp.Float64(); err == nil {
|
||||
weather.TrackTemp = v
|
||||
}
|
||||
if v, err := wd.Humidity.Float64(); err == nil {
|
||||
weather.Humidity = v
|
||||
}
|
||||
if v, err := wd.WindSpeed.Float64(); err == nil {
|
||||
weather.WindSpeed = v
|
||||
}
|
||||
if v, err := wd.WindDirection.Int64(); err == nil {
|
||||
weather.WindDir = int(v)
|
||||
}
|
||||
if v, err := wd.Rainfall.Float64(); err == nil {
|
||||
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 != "" {
|
||||
session.MeetingName = si.Meeting.Name
|
||||
}
|
||||
if si.Name != "" {
|
||||
session.SessionName = si.Name
|
||||
}
|
||||
if si.Type != "" {
|
||||
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 != "" {
|
||||
// Preserve the existing Age — CurrentTyres only carries
|
||||
// compound and newness, not lap count.
|
||||
t := tyres[num]
|
||||
t.Compound = td.Compound
|
||||
t.New = td.New == "true" || td.New == "True"
|
||||
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 {
|
||||
stints[num] = driverStints
|
||||
// Sync compound and age from the latest stint.
|
||||
// Stints are authoritative: they include historical data and
|
||||
// carry both compound and laps on the current set.
|
||||
lastStint := driverStints[len(driverStints)-1]
|
||||
t := tyres[num]
|
||||
t.Age = lastStint.Laps
|
||||
if lastStint.Compound != "" {
|
||||
t.Compound = lastStint.Compound
|
||||
t.New = lastStint.New
|
||||
}
|
||||
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 := drivers[num]; ok && line.PersonalBestLapTime.Value != "" {
|
||||
d.BestLapTime = line.PersonalBestLapTime.Value
|
||||
drivers[num] = d
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
for {
|
||||
_, message, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("WS Read Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var parsed F1SignalRMessage
|
||||
if err := json.Unmarshal(message, &parsed); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
updated := false
|
||||
|
||||
// Full state payload (R)
|
||||
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 processTopic(topic, data) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental feed (M)
|
||||
for _, m := range parsed.M {
|
||||
if len(m.A) > 1 {
|
||||
var topic string
|
||||
json.Unmarshal(m.A[0], &topic)
|
||||
if processTopic(topic, m.A[1]) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
sendUpdate()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Parse speed trap (ST = highest speed on track)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sector times — handle empty Value as a sector clear (new lap starting)
|
||||
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{} // clear = new lap starting
|
||||
} else {
|
||||
d.Sectors[i] = LiveSectorData{
|
||||
Value: sec.Value,
|
||||
PersonalFastest: sec.PersonalFastest,
|
||||
OverallFastest: sec.OverallFastest,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derive: driver is on a flying lap if S1 or S2 populated but S3 not yet
|
||||
d.OnFlyingLap = !d.InPit && !d.Retired &&
|
||||
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
|
||||
d.Sectors[2].Value == ""
|
||||
|
||||
drivers[num] = d
|
||||
}
|
||||
|
||||
// extractStringVal extracts a string from a timing value that may arrive as a
|
||||
// plain string, a float64, or a {"Value": "..."} object from the SignalR feed.
|
||||
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
|
||||
}
|
||||
// Live timing types re-exported from internal/live for TUI sub-views.
|
||||
type (
|
||||
F1DriverListEntry = live.F1DriverListEntry
|
||||
LiveTyreData = live.LiveTyreData
|
||||
LiveRCMessage = live.LiveRCMessage
|
||||
LiveWeatherData = live.LiveWeatherData
|
||||
LiveSessionMeta = live.LiveSessionMeta
|
||||
LiveSectorData = live.LiveSectorData
|
||||
LiveDriverData = live.LiveDriverData
|
||||
LiveStintData = live.LiveStintData
|
||||
LiveStreamData = live.LiveStreamData
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model wrapper
|
||||
@@ -931,7 +253,7 @@ func NewOfficialLiveModel() OfficialLiveModel {
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) Init() tea.Cmd {
|
||||
err := ConnectToF1LiveTiming(m.dataChan)
|
||||
err := live.ConnectToF1LiveTiming(m.dataChan)
|
||||
if err != nil {
|
||||
return func() tea.Msg { return err }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user