mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Live telemetry
This commit is contained in:
355
internal/ui/official_live.go
Normal file
355
internal/ui/official_live.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type F1SignalRMessage struct {
|
||||
M []struct {
|
||||
A []json.RawMessage `json:"A"`
|
||||
} `json:"M"`
|
||||
R json.RawMessage `json:"R"`
|
||||
}
|
||||
|
||||
type F1TimingLine struct {
|
||||
GapToLeader interface{} `json:"GapToLeader"`
|
||||
IntervalToPositionAhead struct {
|
||||
Value interface{} `json:"Value"`
|
||||
} `json:"IntervalToPositionAhead"`
|
||||
Position string `json:"Position"`
|
||||
RacingNumber string `json:"RacingNumber"`
|
||||
}
|
||||
|
||||
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 LiveStreamData struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
}
|
||||
|
||||
type LiveDriverData struct {
|
||||
RacingNumber string
|
||||
Position int
|
||||
GapToLeader string
|
||||
Interval string
|
||||
}
|
||||
|
||||
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"]],"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)
|
||||
|
||||
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
|
||||
|
||||
// Check full state payload (R)
|
||||
if len(parsed.R) > 2 {
|
||||
var rMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
|
||||
if tdRaw, ok := rMap["TimingData"]; ok {
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if json.Unmarshal(tdRaw, &td) == nil {
|
||||
for num, lineRaw := range td.Lines {
|
||||
var line F1TimingLine
|
||||
if json.Unmarshal(lineRaw, &line) == nil {
|
||||
updateDriver(drivers, num, line)
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dlRaw, ok := rMap["DriverList"]; ok {
|
||||
var dlMap map[string]json.RawMessage
|
||||
if json.Unmarshal(dlRaw, &dlMap) == nil {
|
||||
for num, entryRaw := range dlMap {
|
||||
var entry F1DriverListEntry
|
||||
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
|
||||
driverInfo[num] = entry
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check incremental feed (M)
|
||||
for _, m := range parsed.M {
|
||||
if len(m.A) > 1 {
|
||||
var topic string
|
||||
json.Unmarshal(m.A[0], &topic)
|
||||
switch topic {
|
||||
case "TimingData":
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if err := json.Unmarshal(m.A[1], &td); err == 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 err := json.Unmarshal(m.A[1], &dlMap); err == nil {
|
||||
for num, entryRaw := range dlMap {
|
||||
var entry F1DriverListEntry
|
||||
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
|
||||
driverInfo[num] = entry
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
// Send copies to avoid race conditions
|
||||
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
|
||||
}
|
||||
select {
|
||||
case dataChan <- LiveStreamData{Drivers: cpyDrivers, DriverInfo: cpyInfo}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
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 != "" {
|
||||
fmt.Sscanf(line.Position, "%d", &d.Position)
|
||||
}
|
||||
if line.GapToLeader != nil {
|
||||
d.GapToLeader = fmt.Sprintf("%v", line.GapToLeader)
|
||||
}
|
||||
if line.IntervalToPositionAhead.Value != nil {
|
||||
d.Interval = fmt.Sprintf("%v", line.IntervalToPositionAhead.Value)
|
||||
}
|
||||
|
||||
drivers[num] = d
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model wrapper
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type wsDataMsg LiveStreamData
|
||||
|
||||
func listenForWSData(sub chan LiveStreamData) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return wsDataMsg(<-sub)
|
||||
}
|
||||
}
|
||||
|
||||
func parseGap(val string) string {
|
||||
if val == "" {
|
||||
return ""
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
type OfficialLiveModel struct {
|
||||
width int
|
||||
height int
|
||||
dataChan chan LiveStreamData
|
||||
drivers map[string]LiveDriverData
|
||||
driverInfo map[string]F1DriverListEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func NewOfficialLiveModel() OfficialLiveModel {
|
||||
return OfficialLiveModel{
|
||||
dataChan: make(chan LiveStreamData, 10),
|
||||
drivers: make(map[string]LiveDriverData),
|
||||
driverInfo: make(map[string]F1DriverListEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) Init() tea.Cmd {
|
||||
err := ConnectToF1LiveTiming(m.dataChan)
|
||||
if err != nil {
|
||||
return func() tea.Msg { return err }
|
||||
}
|
||||
return listenForWSData(m.dataChan)
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case error:
|
||||
m.err = msg
|
||||
return m, nil
|
||||
case wsDataMsg:
|
||||
m.drivers = msg.Drivers
|
||||
m.driverInfo = msg.DriverInfo
|
||||
return m, listenForWSData(m.dataChan)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) View() string {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("\n Error connecting to F1 live stream: %v", m.err)
|
||||
}
|
||||
if len(m.drivers) == 0 {
|
||||
return "\n Connecting to Official F1 Live Timing Stream...\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
|
||||
sb.WriteString("\n " + titleStyle.Render("LIVE TIMING") + "\n\n")
|
||||
|
||||
var drivers []LiveDriverData
|
||||
for _, d := range m.drivers {
|
||||
if d.Position > 0 {
|
||||
drivers = append(drivers, d)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(drivers, func(i, j int) bool {
|
||||
return drivers[i].Position < drivers[j].Position
|
||||
})
|
||||
|
||||
// Header
|
||||
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
||||
sb.WriteString(headerStyle.Render(fmt.Sprintf(" %-4s %-4s %-4s %-16s %-12s %-12s", "POS", "NO", "TLA", "DRIVER", "GAP", "INT")) + "\n")
|
||||
sb.WriteString(" " + strings.Repeat("─", 58) + "\n")
|
||||
|
||||
for _, d := range drivers {
|
||||
gap := parseGap(d.GapToLeader)
|
||||
intv := parseGap(d.Interval)
|
||||
if d.Position == 1 {
|
||||
gap = "LEADER"
|
||||
intv = ""
|
||||
}
|
||||
|
||||
// Look up driver info
|
||||
info, hasInfo := m.driverInfo[d.RacingNumber]
|
||||
tla := d.RacingNumber
|
||||
name := ""
|
||||
teamColor := colorMuted
|
||||
if hasInfo {
|
||||
tla = info.Tla
|
||||
name = info.BroadcastName
|
||||
if info.TeamColour != "" {
|
||||
teamColor = "#" + info.TeamColour
|
||||
} else {
|
||||
teamColor = teamColorFromName(info.TeamName)
|
||||
}
|
||||
}
|
||||
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
posStr := fmt.Sprintf("%-4d", d.Position)
|
||||
noStr := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).Render(fmt.Sprintf("%-4s", d.RacingNumber))
|
||||
tlaStr := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("%-4s", tla))
|
||||
|
||||
nameStr := styleMuted.Render(fmt.Sprintf("%-16s", name))
|
||||
gapStr := fmt.Sprintf("%-12s", gap)
|
||||
intvStr := fmt.Sprintf("%-12s", intv)
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s\n", colorBar, posStr, noStr, tlaStr, nameStr, gapStr, intvStr))
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + helpBar("1-6 tabs", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user