Skip to content

Commit

Permalink
Add integration with Nexus for automatically pulling practice and pla…
Browse files Browse the repository at this point in the history
…yoff match lineups.
  • Loading branch information
patfair committed Oct 15, 2023
1 parent c177997 commit 7fe23ed
Show file tree
Hide file tree
Showing 11 changed files with 306 additions and 37 deletions.
76 changes: 50 additions & 26 deletions field/arena.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Arena struct {
networkSwitch *network.Switch
Plc plc.Plc
TbaClient *partner.TbaClient
NexusClient *partner.NexusClient
AllianceStations map[string]*AllianceStation
Displays map[string]*Display
ScoringPanelRegistry
Expand Down Expand Up @@ -195,6 +196,7 @@ func (arena *Arena) LoadSettings() error {
arena.networkSwitch = network.NewSwitch(settings.SwitchAddress, settings.SwitchPassword)
arena.Plc.SetAddress(settings.PlcAddress)
arena.TbaClient = partner.NewTbaClient(settings.TbaEventCode, settings.TbaSecretId, settings.TbaSecret)
arena.NexusClient = partner.NewNexusClient(settings.TbaEventCode)

if arena.EventSettings.NetworkSecurityEnabled && arena.MatchState == PreMatch {
if err = arena.accessPoint.ConfigureAdminSettings(); err != nil {
Expand Down Expand Up @@ -263,34 +265,56 @@ func (arena *Arena) LoadMatch(match *model.Match) error {
}

arena.CurrentMatch = match
err := arena.assignTeam(match.Red1, "R1")
if err != nil {
return err
}
err = arena.assignTeam(match.Red2, "R2")
if err != nil {
return err
}
err = arena.assignTeam(match.Red3, "R3")
if err != nil {
return err
}
err = arena.assignTeam(match.Blue1, "B1")
if err != nil {
return err
}
err = arena.assignTeam(match.Blue2, "B2")
if err != nil {
return err
}
err = arena.assignTeam(match.Blue3, "B3")
if err != nil {
return err

loadedByNexus := false
if match.ShouldAllowNexusSubstitution() && arena.EventSettings.NexusEnabled {
// Attempt to get the match lineup from Nexus for FRC.
lineup, err := arena.NexusClient.GetLineup(match.TbaMatchKey)
if err != nil {
log.Printf("Failed to load lineup from Nexus: %s", err.Error())
} else {
err = arena.SubstituteTeams(lineup[0], lineup[1], lineup[2], lineup[3], lineup[4], lineup[5])
if err != nil {
log.Printf("Failed to substitute teams using Nexus lineup; loading match normally: %s", err.Error())
} else {
log.Printf(
"Successfully loaded lineup for match %s from Nexus: %v", match.TbaMatchKey.String(), *lineup,
)
loadedByNexus = true
}
}
}

arena.setupNetwork([6]*model.Team{arena.AllianceStations["R1"].Team, arena.AllianceStations["R2"].Team,
arena.AllianceStations["R3"].Team, arena.AllianceStations["B1"].Team, arena.AllianceStations["B2"].Team,
arena.AllianceStations["B3"].Team})
if !loadedByNexus {
err := arena.assignTeam(match.Red1, "R1")
if err != nil {
return err
}
err = arena.assignTeam(match.Red2, "R2")
if err != nil {
return err
}
err = arena.assignTeam(match.Red3, "R3")
if err != nil {
return err
}
err = arena.assignTeam(match.Blue1, "B1")
if err != nil {
return err
}
err = arena.assignTeam(match.Blue2, "B2")
if err != nil {
return err
}
err = arena.assignTeam(match.Blue3, "B3")
if err != nil {
return err
}

arena.setupNetwork([6]*model.Team{arena.AllianceStations["R1"].Team, arena.AllianceStations["R2"].Team,
arena.AllianceStations["R3"].Team, arena.AllianceStations["B1"].Team, arena.AllianceStations["B2"].Team,
arena.AllianceStations["B3"].Team})
}

// Reset the arena state and realtime scores.
arena.soundsPlayed = make(map[*game.MatchSound]struct{})
Expand Down
74 changes: 74 additions & 0 deletions field/arena_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ package field
import (
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/partner"
"github.com/Team254/cheesy-arena/playoff"
"github.com/Team254/cheesy-arena/tournament"
"github.com/Team254/cheesy-arena/websocket"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -482,6 +486,76 @@ func TestSubstituteTeam(t *testing.T) {
}
}

func TestLoadTeamsFromNexus(t *testing.T) {
arena := setupTestArena(t)

for i := 1; i <= 12; i++ {
arena.Database.CreateTeam(&model.Team{Id: 100 + i})
}
match := model.Match{
Type: model.Practice,
Red1: 101,
Red2: 102,
Red3: 103,
Blue1: 104,
Blue2: 105,
Blue3: 106,
TbaMatchKey: model.TbaMatchKey{CompLevel: "p", SetNumber: 0, MatchNumber: 1},
}
arena.Database.CreateMatch(&match)

assertTeams := func(red1, red2, red3, blue1, blue2, blue int) {
assert.Equal(t, red1, arena.CurrentMatch.Red1)
assert.Equal(t, red2, arena.CurrentMatch.Red2)
assert.Equal(t, red3, arena.CurrentMatch.Red3)
assert.Equal(t, blue1, arena.CurrentMatch.Blue1)
assert.Equal(t, blue2, arena.CurrentMatch.Blue2)
assert.Equal(t, blue, arena.CurrentMatch.Blue3)
assert.Equal(t, red1, arena.AllianceStations["R1"].Team.Id)
assert.Equal(t, red2, arena.AllianceStations["R2"].Team.Id)
assert.Equal(t, red3, arena.AllianceStations["R3"].Team.Id)
assert.Equal(t, blue1, arena.AllianceStations["B1"].Team.Id)
assert.Equal(t, blue2, arena.AllianceStations["B2"].Team.Id)
assert.Equal(t, blue, arena.AllianceStations["B3"].Team.Id)
}

// Sanity check that the match loads correctly without Nexus enabled.
assert.Nil(t, arena.LoadMatch(&match))
assertTeams(101, 102, 103, 104, 105, 106)

// Mock the Nexus server.
nexusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.String(), "/v1/my_event_code/p1/lineup") {
w.Write([]byte("{\"red\":[\"112\",\"111\",\"110\"],\"blue\":[\"109\",\"108\",\"107\"]}"))
} else {
http.Error(w, "Match not found", 404)
}
}))
defer nexusServer.Close()
arena.NexusClient = partner.NewNexusClient("my_event_code")
arena.NexusClient.BaseUrl = nexusServer.URL
arena.EventSettings.NexusEnabled = true

// Check that the correct teams are loaded from Nexus.
assert.Nil(t, arena.LoadMatch(&match))
assertTeams(112, 111, 110, 109, 108, 107)

// Check with a match that Nexus doesn't know about.
match = model.Match{
Type: model.Practice,
Red1: 106,
Red2: 105,
Red3: 104,
Blue1: 103,
Blue2: 102,
Blue3: 101,
TbaMatchKey: model.TbaMatchKey{CompLevel: "p", SetNumber: 0, MatchNumber: 2},
}
arena.Database.CreateMatch(&match)
assert.Nil(t, arena.LoadMatch(&match))
assertTeams(106, 105, 104, 103, 102, 101)
}

func TestArenaTimeout(t *testing.T) {
arena := setupTestArena(t)

Expand Down
1 change: 1 addition & 0 deletions model/event_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type EventSettings struct {
TbaEventCode string
TbaSecretId string
TbaSecret string
NexusEnabled bool
NetworkSecurityEnabled bool
ApType string
ApAddress string
Expand Down
13 changes: 13 additions & 0 deletions model/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ func (match *Match) ShouldAllowSubstitution() bool {
return match.Type != Qualification
}

// Returns true if the match is of a type that allows loading lineup information from Nexus.
func (match *Match) ShouldAllowNexusSubstitution() bool {
return match.Type == Practice || match.Type == Playoff
}

// Returns true if the red and yellow cards should be updated as a result of the match.
func (match *Match) ShouldUpdateCards() bool {
return match.Type == Qualification || match.Type == Playoff
Expand Down Expand Up @@ -155,3 +160,11 @@ func MatchTypeFromString(matchTypeString string) (MatchType, error) {
}
return 0, fmt.Errorf("invalid match type %q", matchTypeString)
}

// Returns the string equivalent of the given compound match key.
func (key TbaMatchKey) String() string {
if key.SetNumber == 0 {
return fmt.Sprintf("%s%d", key.CompLevel, key.MatchNumber)
}
return fmt.Sprintf("%s%dm%d", key.CompLevel, key.SetNumber, key.MatchNumber)
}
14 changes: 14 additions & 0 deletions model/match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,17 @@ func TestMatchTypeFromString(t *testing.T) {
assert.Equal(t, "invalid match type \"elimination\"", err.Error())
}
}

func TestTbaMatchKeyString(t *testing.T) {
key := TbaMatchKey{CompLevel: "p", SetNumber: 0, MatchNumber: 3}
assert.Equal(t, "p3", key.String())

key = TbaMatchKey{CompLevel: "qm", SetNumber: 0, MatchNumber: 17}
assert.Equal(t, "qm17", key.String())

key = TbaMatchKey{CompLevel: "sf", SetNumber: 5, MatchNumber: 1}
assert.Equal(t, "sf5m1", key.String())

key = TbaMatchKey{CompLevel: "f", SetNumber: 1, MatchNumber: 4}
assert.Equal(t, "f1m4", key.String())
}
76 changes: 76 additions & 0 deletions partner/nexus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2023 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Methods for pulling match lineups from Nexus for FRC.

package partner

import (
"encoding/json"
"fmt"
"github.com/Team254/cheesy-arena/model"
"io"
"net/http"
"strconv"
)

const nexusBaseUrl = "https://api.frc.nexus"

type NexusClient struct {
BaseUrl string
eventCode string
}

type nexusLineup struct {
Red [3]string `json:"red"`
Blue [3]string `json:"blue"`
}

func NewNexusClient(eventCode string) *NexusClient {
return &NexusClient{BaseUrl: nexusBaseUrl, eventCode: eventCode}
}

// Gets the team lineup for a given match from the Nexus API. Returns nil and an error if the lineup is not available.
func (client *NexusClient) GetLineup(tbaMatchKey model.TbaMatchKey) (*[6]int, error) {
path := fmt.Sprintf("/v1/%s/%s/lineup", client.eventCode, tbaMatchKey.String())
resp, err := client.getRequest(path)
if err != nil {
return nil, err
}

// Get the response and handle errors
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Error getting lineup from Nexus: %d, %s", resp.StatusCode, string(body))
}

var nexusLineup nexusLineup
if err = json.Unmarshal(body, &nexusLineup); err != nil {
return nil, err
}

var lineup [6]int
lineup[0], _ = strconv.Atoi(nexusLineup.Red[0])
lineup[1], _ = strconv.Atoi(nexusLineup.Red[1])
lineup[2], _ = strconv.Atoi(nexusLineup.Red[2])
lineup[3], _ = strconv.Atoi(nexusLineup.Blue[0])
lineup[4], _ = strconv.Atoi(nexusLineup.Blue[1])
lineup[5], _ = strconv.Atoi(nexusLineup.Blue[2])

return &lineup, err
}

// Sends a GET request to the Nexus API.
func (client *NexusClient) getRequest(path string) (*http.Response, error) {
url := client.BaseUrl + path
httpClient := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
return httpClient.Do(req)
}
41 changes: 41 additions & 0 deletions partner/nexus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2023 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)

package partner

import (
"github.com/Team254/cheesy-arena/model"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestGetLineup(t *testing.T) {
// Mock the Nexus server.
nexusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Contains(t, r.URL.String(), "/v1/my_event_code/")
if strings.Contains(r.URL.String(), "/v1/my_event_code/p1/lineup") {
w.Write([]byte("{\"red\":[\"101\",\"102\",\"103\"],\"blue\":[\"104\",\"105\",\"106\"]}"))
} else {
http.Error(w, "Match not found", 404)
}
}))
defer nexusServer.Close()
client := NewNexusClient("my_event_code")
client.BaseUrl = nexusServer.URL

tbaMatchKey := model.TbaMatchKey{CompLevel: "p", SetNumber: 0, MatchNumber: 1}
lineup, err := client.GetLineup(tbaMatchKey)
if assert.Nil(t, err) {
assert.Equal(t, [6]int{101, 102, 103, 104, 105, 106}, *lineup)
}

tbaMatchKey = model.TbaMatchKey{CompLevel: "sf", SetNumber: 6, MatchNumber: 1}
lineup, err = client.GetLineup(tbaMatchKey)
assert.Nil(t, lineup)
if assert.NotNil(t, err) {
assert.Contains(t, err.Error(), "Match not found")
}
}
13 changes: 12 additions & 1 deletion templates/setup_settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
<div class="form-group">
<label class="col-lg-9 control-label">Enable Automatic Team Info Download (From TBA)</label>
<div class="col-lg-1 checkbox">
<input type="checkbox" name="TbaDownloadEnabled"{{if .TbaDownloadEnabled}} checked{{end}}>
<input type="checkbox" name="tbaDownloadEnabled"{{if .TbaDownloadEnabled}} checked{{end}}>
</div>
</div>
</fieldset>
Expand Down Expand Up @@ -135,6 +135,17 @@
</div>
</div>
</fieldset>
<fieldset>
<legend>Nexus</legend>
<p>Automatically populates practice and playoff match lineups from Nexus. Uses the same event code as TBA;
configure it above if enabling.</p>
<div class="form-group">
<label class="col-lg-7 control-label">Enable pulling lineup from Nexus</label>
<div class="col-lg-1 checkbox">
<input type="checkbox" name="nexusEnabled"{{if .NexusEnabled}} checked{{end}}>
</div>
</div>
</fieldset>
<fieldset>
<legend>Authentication</legend>
<p>Configure password to enable authentication, or leave blank to disable.</p>
Expand Down
Loading

0 comments on commit 7fe23ed

Please sign in to comment.