mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Subscribe to SignalR Position.z and CarData.z, decode DEFLATE payloads, stream throttled positions over SSE, and render live car dots against cached track outline bounds with tap telemetry. Alignment spike: no checked-in or locally cached real Position.z samples were available in this isolated worktree; verified both streams expose the official raw F1 X/Y/Z coordinate contract and implemented shared-bounds normalization against prefetched OpenF1 outlines. Fallback build-outline-from-stream was not taken. Playwright remains out of scope for live rendering because BOXBOX_DISABLE_LIVE is used there; coverage is via parser, web handler/SSE, pure transform, and seeded component tests.
807 lines
19 KiB
Go
807 lines
19 KiB
Go
package live
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/zlib"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
TrackStatus string
|
|
CurrentLap int
|
|
TotalLaps int
|
|
Clock string
|
|
ClockRefTime time.Time
|
|
ClockExtrapolating bool
|
|
positionUpdated bool
|
|
snapshotUpdated bool
|
|
}
|
|
|
|
const signalRRecordSeparator = byte(0x1e)
|
|
|
|
// 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)
|
|
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,
|
|
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 "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"`
|
|
}
|
|
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
|
|
}
|
|
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 json.RawMessage `json:"Stints"`
|
|
}
|
|
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
|
var driverStints []LiveStintData
|
|
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 && 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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) 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 {
|
|
i := 0
|
|
fmt.Sscanf(k, "%d", &i)
|
|
values = append(values, indexedRaw{Index: i, Raw: v})
|
|
}
|
|
return values
|
|
}
|
|
|
|
return nil
|
|
}
|