mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.
Observed live at lap 49 of the 70-lap 2026 Hungarian GP: all 22 drivers
reported exactly one stint with age 0 or 3, after most had pitted twice.
With the fix, the same feed at lap 51 yields 3 stints for 15 drivers and
2 for 6, ages spread 1-30 — e.g. car 1 as MEDIUM 17 / HARD 22 / HARD 11.
Stints now merge by index, and Compound, New and TotalLaps each apply only
when the delta actually carries them.
Also stop folding non-numeric keys into index 0 in indexedRawValues. The
feed's "_kf" key-frame marker parsed as 0 and overwrote the first entry;
only the CurrentTyres path guarded against it, leaving the other five
callers exposed.
This feeds the tyre column, the deg model, stint history and the pit
window, all of which were reading near-zero tyre age all race.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
915 lines
23 KiB
Go
915 lines
23 KiB
Go
package live
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/zlib"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"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
|
|
Telemetry map[string]LiveTelemetryData
|
|
Positions map[string]LivePositionData
|
|
Stints map[string][]LiveStintData
|
|
RCMessages []LiveRCMessage
|
|
Weather LiveWeatherData
|
|
Session LiveSessionMeta
|
|
TeamRadio []LiveRadioCapture
|
|
SessionStatus string
|
|
TrackStatus string
|
|
CurrentLap int
|
|
TotalLaps int
|
|
Clock string
|
|
ClockRefTime time.Time
|
|
ClockExtrapolating bool
|
|
positionUpdated bool
|
|
snapshotUpdated bool
|
|
}
|
|
|
|
const signalRRecordSeparator = byte(0x1e)
|
|
const maxTeamRadioCaptures = 20
|
|
|
|
// 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),
|
|
Telemetry: make(map[string]LiveTelemetryData),
|
|
Positions: make(map[string]LivePositionData),
|
|
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
|
|
}
|
|
cpyTelemetry := make(map[string]LiveTelemetryData, len(s.Telemetry))
|
|
for k, v := range s.Telemetry {
|
|
cpyTelemetry[k] = v
|
|
}
|
|
cpyPositions := make(map[string]LivePositionData, len(s.Positions))
|
|
for k, v := range s.Positions {
|
|
cpyPositions[k] = v
|
|
}
|
|
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
|
|
copy(cpyRC, s.RCMessages)
|
|
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
|
|
copy(cpyRadio, s.TeamRadio)
|
|
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,
|
|
Telemetry: cpyTelemetry,
|
|
RCMessages: cpyRC,
|
|
Weather: s.Weather,
|
|
Session: s.Session,
|
|
TeamRadio: cpyRadio,
|
|
SessionStatus: s.SessionStatus,
|
|
TrackStatus: s.TrackStatus,
|
|
CurrentLap: s.CurrentLap,
|
|
TotalLaps: s.TotalLaps,
|
|
Clock: s.Clock,
|
|
ClockRefTime: s.ClockRefTime,
|
|
ClockExtrapolating: s.ClockExtrapolating,
|
|
Stints: cpyStints,
|
|
Positions: cpyPositions,
|
|
PositionUpdated: s.positionUpdated,
|
|
SnapshotUpdated: s.snapshotUpdated,
|
|
}
|
|
}
|
|
|
|
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
|
|
func (s *State) ProcessMessage(message []byte) bool {
|
|
s.clearTransientFlags()
|
|
|
|
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
|
|
}
|
|
|
|
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
|
|
// completion snapshots and feed deltas from the current official F1 live timing hub.
|
|
func (s *State) ProcessCoreMessage(message []byte) bool {
|
|
s.clearTransientFlags()
|
|
|
|
updated := false
|
|
for _, frame := range splitSignalRFrames(message) {
|
|
var envelope struct {
|
|
Type int `json:"type"`
|
|
Target string `json:"target"`
|
|
Args []json.RawMessage `json:"arguments"`
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(frame, &envelope); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch envelope.Type {
|
|
case 1:
|
|
if envelope.Target != "feed" || len(envelope.Args) < 2 {
|
|
continue
|
|
}
|
|
var topic string
|
|
if err := json.Unmarshal(envelope.Args[0], &topic); err != nil {
|
|
continue
|
|
}
|
|
if s.ProcessTopic(topic, envelope.Args[1]) {
|
|
updated = true
|
|
}
|
|
case 3:
|
|
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
|
continue
|
|
}
|
|
var result map[string]json.RawMessage
|
|
if err := json.Unmarshal(envelope.Result, &result); err != nil {
|
|
continue
|
|
}
|
|
for topic, data := range result {
|
|
if s.ProcessTopic(topic, data) {
|
|
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
|
|
baseTopic := strings.TrimSuffix(topic, ".z")
|
|
if topic != baseTopic {
|
|
var ok bool
|
|
data, ok = inflateTopicPayload(data)
|
|
if !ok {
|
|
return false
|
|
}
|
|
}
|
|
switch baseTopic {
|
|
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 "Position":
|
|
updated = s.updatePositions(data)
|
|
case "CarData":
|
|
updated = s.updateTelemetry(data)
|
|
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 "SessionStatus":
|
|
var ss struct {
|
|
Status string `json:"Status"`
|
|
}
|
|
if json.Unmarshal(data, &ss) == nil && ss.Status != "" {
|
|
s.SessionStatus = ss.Status
|
|
updated = true
|
|
}
|
|
case "RaceControlMessages":
|
|
var rcm struct {
|
|
Messages json.RawMessage `json:"Messages"`
|
|
}
|
|
if json.Unmarshal(data, &rcm) == nil {
|
|
for _, msgRaw := range indexedRawValues(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.Raw, &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"`
|
|
Circuit struct {
|
|
ShortName string `json:"ShortName"`
|
|
} `json:"Circuit"`
|
|
} `json:"Meeting"`
|
|
Name string `json:"Name"`
|
|
Type string `json:"Type"`
|
|
Path string `json:"Path"`
|
|
}
|
|
if json.Unmarshal(data, &si) == nil {
|
|
if si.Meeting.Name != "" {
|
|
s.Session.MeetingName = si.Meeting.Name
|
|
}
|
|
if si.Meeting.Circuit.ShortName != "" {
|
|
s.Session.CircuitName = si.Meeting.Circuit.ShortName
|
|
}
|
|
if si.Name != "" {
|
|
s.Session.SessionName = si.Name
|
|
}
|
|
if si.Type != "" {
|
|
s.Session.SessionType = si.Type
|
|
}
|
|
if si.Path != "" {
|
|
s.Session.Path = si.Path
|
|
}
|
|
updated = true
|
|
}
|
|
case "TeamRadio":
|
|
updated = s.updateTeamRadio(data)
|
|
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 json.RawMessage `json:"Stints"`
|
|
}
|
|
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
|
// The feed sends stints as sparse deltas keyed by stint index:
|
|
// a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge
|
|
// each entry into the stint it addresses. Replacing the slice
|
|
// wholesale discarded every earlier stint, so pit history
|
|
// collapsed to one entry and tyre age stuck near zero for the
|
|
// whole race.
|
|
driverStints := append([]LiveStintData(nil), s.Stints[num]...)
|
|
changed := false
|
|
for _, sRaw := range indexedRawValues(line.Stints) {
|
|
var st struct {
|
|
Compound *string `json:"Compound"`
|
|
New *string `json:"New"`
|
|
TotalLaps *int `json:"TotalLaps"`
|
|
}
|
|
if json.Unmarshal(sRaw.Raw, &st) != nil {
|
|
continue
|
|
}
|
|
if st.Compound == nil && st.New == nil && st.TotalLaps == nil {
|
|
continue
|
|
}
|
|
for len(driverStints) <= sRaw.Index {
|
|
driverStints = append(driverStints, LiveStintData{})
|
|
}
|
|
entry := &driverStints[sRaw.Index]
|
|
if st.Compound != nil && *st.Compound != "" {
|
|
entry.Compound = *st.Compound
|
|
}
|
|
if st.New != nil {
|
|
entry.New = *st.New == "true" || *st.New == "True"
|
|
}
|
|
if st.TotalLaps != nil {
|
|
entry.Laps = *st.TotalLaps
|
|
}
|
|
changed = true
|
|
}
|
|
if changed {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if updated {
|
|
if baseTopic == "Position" {
|
|
s.positionUpdated = true
|
|
} else {
|
|
s.snapshotUpdated = true
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
func (s *State) clearTransientFlags() {
|
|
s.positionUpdated = false
|
|
s.snapshotUpdated = false
|
|
}
|
|
|
|
func inflateTopicPayload(data json.RawMessage) (json.RawMessage, bool) {
|
|
var encoded string
|
|
if err := json.Unmarshal(data, &encoded); err != nil {
|
|
var wrapper struct {
|
|
Z string `json:"z"`
|
|
}
|
|
if json.Unmarshal(data, &wrapper) != nil || wrapper.Z == "" {
|
|
return nil, false
|
|
}
|
|
encoded = wrapper.Z
|
|
}
|
|
|
|
compressed, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
if inflated, ok := readCompressed(zlib.NewReader(bytes.NewReader(compressed))); ok {
|
|
return json.RawMessage(inflated), true
|
|
}
|
|
if inflated, ok := readCompressed(func() (io.ReadCloser, error) {
|
|
return flate.NewReader(bytes.NewReader(compressed)), nil
|
|
}()); ok {
|
|
return json.RawMessage(inflated), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer r.Close()
|
|
out, err := io.ReadAll(r)
|
|
return out, err == nil
|
|
}
|
|
|
|
func (s *State) updateTeamRadio(data json.RawMessage) bool {
|
|
var payload struct {
|
|
Captures json.RawMessage `json:"Captures"`
|
|
}
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
log.Printf("live: skipping malformed TeamRadio payload: %v", err)
|
|
return false
|
|
}
|
|
if len(payload.Captures) == 0 || string(payload.Captures) == "null" {
|
|
log.Printf("live: skipping TeamRadio payload without Captures")
|
|
return false
|
|
}
|
|
|
|
captures := indexedRawValues(payload.Captures)
|
|
if len(captures) == 0 {
|
|
log.Printf("live: skipping TeamRadio payload with unexpected Captures shape")
|
|
return false
|
|
}
|
|
|
|
updated := false
|
|
for _, captureRaw := range captures {
|
|
var capture LiveRadioCapture
|
|
if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil {
|
|
log.Printf("live: skipping malformed TeamRadio capture: %v", err)
|
|
continue
|
|
}
|
|
if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" {
|
|
log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path)
|
|
continue
|
|
}
|
|
if s.hasTeamRadioCapture(capture) {
|
|
continue
|
|
}
|
|
s.TeamRadio = append(s.TeamRadio, capture)
|
|
updated = true
|
|
}
|
|
if updated {
|
|
sort.SliceStable(s.TeamRadio, func(i, j int) bool {
|
|
return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc
|
|
})
|
|
if len(s.TeamRadio) > maxTeamRadioCaptures {
|
|
s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...)
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool {
|
|
for _, existing := range s.TeamRadio {
|
|
if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *State) updatePositions(data json.RawMessage) bool {
|
|
var payload struct {
|
|
Position json.RawMessage `json:"Position"`
|
|
}
|
|
if json.Unmarshal(data, &payload) != nil || len(payload.Position) == 0 {
|
|
return false
|
|
}
|
|
|
|
updated := false
|
|
for _, sampleRaw := range indexedRawValues(payload.Position) {
|
|
var sample struct {
|
|
Entries map[string]struct {
|
|
Status string `json:"Status"`
|
|
X json.Number `json:"X"`
|
|
Y json.Number `json:"Y"`
|
|
Z json.Number `json:"Z"`
|
|
} `json:"Entries"`
|
|
}
|
|
if json.Unmarshal(sampleRaw.Raw, &sample) != nil {
|
|
continue
|
|
}
|
|
for num, entry := range sample.Entries {
|
|
x, okX := numberToFloat(entry.X)
|
|
y, okY := numberToFloat(entry.Y)
|
|
z, okZ := numberToFloat(entry.Z)
|
|
if !okX || !okY {
|
|
continue
|
|
}
|
|
if !okZ {
|
|
z = 0
|
|
}
|
|
s.Positions[num] = LivePositionData{
|
|
X: x,
|
|
Y: y,
|
|
Z: z,
|
|
Status: entry.Status,
|
|
}
|
|
updated = true
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
func (s *State) updateTelemetry(data json.RawMessage) bool {
|
|
var payload struct {
|
|
Entries json.RawMessage `json:"Entries"`
|
|
}
|
|
if json.Unmarshal(data, &payload) != nil || len(payload.Entries) == 0 {
|
|
return false
|
|
}
|
|
|
|
updated := false
|
|
for _, entryRaw := range indexedRawValues(payload.Entries) {
|
|
var entry struct {
|
|
Cars map[string]struct {
|
|
Channels map[string]json.RawMessage `json:"Channels"`
|
|
} `json:"Cars"`
|
|
}
|
|
if json.Unmarshal(entryRaw.Raw, &entry) != nil {
|
|
continue
|
|
}
|
|
for num, car := range entry.Cars {
|
|
t := s.Telemetry[num]
|
|
if v, ok := channelInt(car.Channels, "0"); ok {
|
|
t.RPM = v
|
|
}
|
|
if v, ok := channelInt(car.Channels, "2"); ok {
|
|
t.Speed = v
|
|
}
|
|
if v, ok := channelInt(car.Channels, "3"); ok {
|
|
t.NGear = v
|
|
}
|
|
if v, ok := channelInt(car.Channels, "4"); ok {
|
|
t.Throttle = v
|
|
}
|
|
if v, ok := channelInt(car.Channels, "5"); ok {
|
|
t.Brake = v
|
|
}
|
|
if v, ok := channelInt(car.Channels, "45"); ok {
|
|
t.DRS = v
|
|
}
|
|
s.Telemetry[num] = t
|
|
updated = true
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
func channelInt(channels map[string]json.RawMessage, key string) (int, bool) {
|
|
raw, ok := channels[key]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
var n json.Number
|
|
if json.Unmarshal(raw, &n) == nil {
|
|
if i, err := n.Int64(); err == nil {
|
|
return int(i), true
|
|
}
|
|
if f, err := n.Float64(); err == nil {
|
|
return int(f), true
|
|
}
|
|
}
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
var i int
|
|
if _, err := fmt.Sscanf(s, "%d", &i); err == nil {
|
|
return i, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func numberToFloat(n json.Number) (float64, bool) {
|
|
if n == "" {
|
|
return 0, false
|
|
}
|
|
v, err := n.Float64()
|
|
return v, err == 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
|
|
}
|
|
}
|
|
|
|
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 _, sector := range indexedRawValues(line.Sectors) {
|
|
i := sector.Index
|
|
if i >= 0 && i < 3 {
|
|
var sec struct {
|
|
Value string `json:"Value"`
|
|
PersonalFastest bool `json:"PersonalFastest"`
|
|
OverallFastest bool `json:"OverallFastest"`
|
|
}
|
|
if json.Unmarshal(sector.Raw, &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
|
|
}
|
|
|
|
type indexedRaw struct {
|
|
Index int
|
|
Raw json.RawMessage
|
|
}
|
|
|
|
func splitSignalRFrames(message []byte) []json.RawMessage {
|
|
parts := []json.RawMessage{}
|
|
start := 0
|
|
for i, b := range message {
|
|
if b != signalRRecordSeparator {
|
|
continue
|
|
}
|
|
if i > start {
|
|
parts = append(parts, json.RawMessage(message[start:i]))
|
|
}
|
|
start = i + 1
|
|
}
|
|
if start < len(message) {
|
|
parts = append(parts, json.RawMessage(message[start:]))
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil
|
|
}
|
|
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(raw, &arr); err == nil {
|
|
values := make([]indexedRaw, 0, len(arr))
|
|
for i, v := range arr {
|
|
values = append(values, indexedRaw{Index: i, Raw: v})
|
|
}
|
|
return values
|
|
}
|
|
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
|
values := make([]indexedRaw, 0, len(obj))
|
|
for k, v := range obj {
|
|
// Keys are array indices in the feed's delta form. Non-numeric keys
|
|
// are feed metadata — "_kf" (key frame) is the common one — and must
|
|
// not be folded in as index 0, which would clobber the first entry.
|
|
i, err := strconv.Atoi(k)
|
|
if err != nil || i < 0 {
|
|
continue
|
|
}
|
|
values = append(values, indexedRaw{Index: i, Raw: v})
|
|
}
|
|
sort.Slice(values, func(i, j int) bool {
|
|
return values[i].Index < values[j].Index
|
|
})
|
|
return values
|
|
}
|
|
|
|
return nil
|
|
}
|