diff --git a/arena.go b/arena.go index c0fe60a..4b7e564 100644 --- a/arena.go +++ b/arena.go @@ -11,12 +11,14 @@ import ( ) // Loop and match timing constants. -const arenaLoopPeriodMs = 1 -const dsPacketPeriodMs = 250 -const autoDurationSec = 10 -const pauseDurationSec = 1 -const teleopDurationSec = 140 -const endgameTimeLeftSec = 30 +const ( + arenaLoopPeriodMs = 10 + dsPacketPeriodMs = 250 + autoDurationSec = 10 + pauseDurationSec = 1 + teleopDurationSec = 140 + endgameTimeLeftSec = 30 +) // Progression of match states. const ( @@ -30,16 +32,17 @@ const ( ) type AllianceStation struct { - team *Team - driverStationConnection *DriverStationConnection - emergencyStop bool - bypass bool + DsConn *DriverStationConnection + EmergencyStop bool + Bypass bool + team *Team } type Arena struct { - allianceStations map[string]*AllianceStation + AllianceStations map[string]*AllianceStation + MatchState int + CanStartMatch bool currentMatch *Match - matchState int matchStartTime time.Time lastDsPacketTime time.Time } @@ -48,28 +51,28 @@ var mainArena Arena // Named thusly to avoid polluting the global namespace with // Sets the arena to its initial state. func (arena *Arena) Setup() { - arena.allianceStations = make(map[string]*AllianceStation) - arena.allianceStations["R1"] = new(AllianceStation) - arena.allianceStations["R2"] = new(AllianceStation) - arena.allianceStations["R3"] = new(AllianceStation) - arena.allianceStations["B1"] = new(AllianceStation) - arena.allianceStations["B2"] = new(AllianceStation) - arena.allianceStations["B3"] = new(AllianceStation) + arena.AllianceStations = make(map[string]*AllianceStation) + arena.AllianceStations["R1"] = new(AllianceStation) + arena.AllianceStations["R2"] = new(AllianceStation) + arena.AllianceStations["R3"] = new(AllianceStation) + arena.AllianceStations["B1"] = new(AllianceStation) + arena.AllianceStations["B2"] = new(AllianceStation) + arena.AllianceStations["B3"] = new(AllianceStation) // Load empty match as current. - arena.matchState = PRE_MATCH - arena.LoadMatch(new(Match)) + arena.MatchState = PRE_MATCH + arena.LoadTestMatch() } // Loads a team into an alliance station, cleaning up the previous team there if there is one. func (arena *Arena) AssignTeam(teamId int, station string) error { // Reject invalid station values. - if _, ok := arena.allianceStations[station]; !ok { + if _, ok := arena.AllianceStations[station]; !ok { return fmt.Errorf("Invalid alliance station '%s'.", station) } // Do nothing if the station is already assigned to the requested team. - dsConn := arena.allianceStations[station].driverStationConnection + dsConn := arena.AllianceStations[station].DsConn if dsConn != nil && dsConn.TeamId == teamId { return nil } @@ -78,8 +81,8 @@ func (arena *Arena) AssignTeam(teamId int, station string) error { if err != nil { return err } - arena.allianceStations[station].team = nil - arena.allianceStations[station].driverStationConnection = nil + arena.AllianceStations[station].team = nil + arena.AllianceStations[station].DsConn = nil } // Leave the station empty if the team number is zero. @@ -96,8 +99,8 @@ func (arena *Arena) AssignTeam(teamId int, station string) error { return fmt.Errorf("Invalid team number '%d'.", teamId) } - arena.allianceStations[station].team = team - arena.allianceStations[station].driverStationConnection, err = NewDriverStationConnection(team.Id, station) + arena.AllianceStations[station].team = team + arena.AllianceStations[station].DsConn, err = NewDriverStationConnection(team.Id, station) if err != nil { return err } @@ -106,7 +109,7 @@ func (arena *Arena) AssignTeam(teamId int, station string) error { // Sets up the arena for the given match. func (arena *Arena) LoadMatch(match *Match) error { - if arena.matchState != PRE_MATCH { + if arena.MatchState != PRE_MATCH { return fmt.Errorf("Cannot load match while there is a match still in progress or with results pending.") } @@ -138,54 +141,126 @@ func (arena *Arena) LoadMatch(match *Match) error { return nil } -// Starts the match if all conditions are met. -func (arena *Arena) StartMatch() error { - if arena.matchState != PRE_MATCH { +// Sets a new test match as the current match. +func (arena *Arena) LoadTestMatch() error { + return arena.LoadMatch(&Match{Type: "test"}) +} + +// Loads the first unplayed match of the current match type. +func (arena *Arena) LoadNextMatch() error { + if arena.currentMatch.Type == "test" { + return arena.LoadTestMatch() + } + + matches, err := db.GetMatchesByType(arena.currentMatch.Type) + if err != nil { + return err + } + for _, match := range matches { + if match.Status != "complete" { + err = arena.LoadMatch(&match) + if err != nil { + return err + } + break + } + } + return nil +} + +// Assigns the given team to the given station, also substituting it into the match record. +func (arena *Arena) SubstituteTeam(teamId int, station string) error { + if arena.currentMatch.Type != "test" && arena.currentMatch.Type != "practice" { + return fmt.Errorf("Can only substitute teams for test and practice matches.") + } + err := arena.AssignTeam(teamId, station) + if err != nil { + return err + } + switch station { + case "R1": + arena.currentMatch.Red1 = teamId + case "R2": + arena.currentMatch.Red2 = teamId + case "R3": + arena.currentMatch.Red3 = teamId + case "B1": + arena.currentMatch.Blue1 = teamId + case "B2": + arena.currentMatch.Blue2 = teamId + case "B3": + arena.currentMatch.Blue3 = teamId + } + return nil +} + +// Returns nil if the match can be started, and an error otherwise. +func (arena *Arena) CheckCanStartMatch() error { + if arena.MatchState != PRE_MATCH { return fmt.Errorf("Cannot start match while there is a match still in progress or with results pending.") } - if arena.currentMatch == nil { - return fmt.Errorf("Cannot start match when no match is loaded.") - } - for _, allianceStation := range arena.allianceStations { - if allianceStation.emergencyStop { + for _, allianceStation := range arena.AllianceStations { + if allianceStation.EmergencyStop { return fmt.Errorf("Cannot start match while an emergency stop is active.") } - if !allianceStation.bypass { - dsConn := allianceStation.driverStationConnection - if dsConn == nil || !dsConn.DriverStationStatus.RobotLinked { + if !allianceStation.Bypass { + if allianceStation.DsConn == nil || !allianceStation.DsConn.DriverStationStatus.RobotLinked { return fmt.Errorf("Cannot start match until all robots are connected or bypassed.") } } } + return nil +} - arena.matchState = START_MATCH +// Starts the match if all conditions are met. +func (arena *Arena) StartMatch() error { + err := arena.CheckCanStartMatch() + if err == nil { + arena.MatchState = START_MATCH + } + return err +} + +// Kills the current match if it is underway. +func (arena *Arena) AbortMatch() error { + if arena.MatchState == PRE_MATCH || arena.MatchState == POST_MATCH { + return fmt.Errorf("Cannot abort match when it is not in progress.") + } + arena.MatchState = POST_MATCH return nil } // Clears out the match and resets the arena state unless there is a match underway. func (arena *Arena) ResetMatch() error { - if arena.matchState != POST_MATCH && arena.matchState != PRE_MATCH { + if arena.MatchState != POST_MATCH && arena.MatchState != PRE_MATCH { return fmt.Errorf("Cannot reset match while it is in progress.") } - arena.matchState = PRE_MATCH - arena.currentMatch = nil + arena.MatchState = PRE_MATCH + arena.AllianceStations["R1"].Bypass = false + arena.AllianceStations["R2"].Bypass = false + arena.AllianceStations["R3"].Bypass = false + arena.AllianceStations["B1"].Bypass = false + arena.AllianceStations["B2"].Bypass = false + arena.AllianceStations["B3"].Bypass = false return nil } // Performs a single iteration of checking inputs and timers and setting outputs accordingly to control the // flow of a match. func (arena *Arena) Update() { + arena.CanStartMatch = arena.CheckCanStartMatch() == nil + // Decide what state the robots need to be in, depending on where we are in the match. auto := false enabled := false sendDsPacket := false matchTimeSec := arena.MatchTimeSec() - switch arena.matchState { + switch arena.MatchState { case PRE_MATCH: auto = true enabled = false case START_MATCH: - arena.matchState = AUTO_PERIOD + arena.MatchState = AUTO_PERIOD arena.matchStartTime = time.Now() auto = true enabled = true @@ -194,7 +269,7 @@ func (arena *Arena) Update() { auto = true enabled = true if matchTimeSec >= autoDurationSec { - arena.matchState = PAUSE_PERIOD + arena.MatchState = PAUSE_PERIOD auto = false enabled = false sendDsPacket = true @@ -203,7 +278,7 @@ func (arena *Arena) Update() { auto = false enabled = false if matchTimeSec >= autoDurationSec+pauseDurationSec { - arena.matchState = TELEOP_PERIOD + arena.MatchState = TELEOP_PERIOD auto = false enabled = true sendDsPacket = true @@ -212,14 +287,14 @@ func (arena *Arena) Update() { auto = false enabled = true if matchTimeSec >= autoDurationSec+pauseDurationSec+teleopDurationSec-endgameTimeLeftSec { - arena.matchState = ENDGAME_PERIOD + arena.MatchState = ENDGAME_PERIOD sendDsPacket = false } case ENDGAME_PERIOD: auto = false enabled = true if matchTimeSec >= autoDurationSec+pauseDurationSec+teleopDurationSec { - arena.matchState = POST_MATCH + arena.MatchState = POST_MATCH auto = false enabled = false sendDsPacket = true @@ -241,12 +316,11 @@ func (arena *Arena) Run() { } func (arena *Arena) sendDsPacket(auto bool, enabled bool) { - for _, allianceStation := range arena.allianceStations { - dsConn := allianceStation.driverStationConnection - if dsConn != nil { - dsConn.Auto = auto - dsConn.Enabled = enabled && !allianceStation.emergencyStop && !allianceStation.bypass - err := dsConn.Update() + for _, allianceStation := range arena.AllianceStations { + if allianceStation.DsConn != nil { + allianceStation.DsConn.Auto = auto + allianceStation.DsConn.Enabled = enabled && !allianceStation.EmergencyStop && !allianceStation.Bypass + err := allianceStation.DsConn.Update() if err != nil { // TODO(pat): Handle errors. } @@ -257,7 +331,7 @@ func (arena *Arena) sendDsPacket(auto bool, enabled bool) { // Returns the fractional number of seconds since the start of the match. func (arena *Arena) MatchTimeSec() float64 { - if arena.matchState == PRE_MATCH || arena.matchState == POST_MATCH { + if arena.MatchState == PRE_MATCH || arena.MatchState == POST_MATCH { return 0 } else { return time.Since(arena.matchStartTime).Seconds() diff --git a/arena_test.go b/arena_test.go index 193f827..f80c5bf 100644 --- a/arena_test.go +++ b/arena_test.go @@ -25,23 +25,23 @@ func TestAssignTeam(t *testing.T) { err = mainArena.AssignTeam(254, "B1") assert.Nil(t, err) - assert.Equal(t, team, *mainArena.allianceStations["B1"].team) - dsConn := mainArena.allianceStations["B1"].driverStationConnection + assert.Equal(t, team, *mainArena.AllianceStations["B1"].team) + dsConn := mainArena.AllianceStations["B1"].DsConn assert.Equal(t, 254, dsConn.TeamId) assert.Equal(t, "B1", dsConn.AllianceStation) // Nothing should happen if the same team is assigned to the same station. err = mainArena.AssignTeam(254, "B1") assert.Nil(t, err) - assert.Equal(t, team, *mainArena.allianceStations["B1"].team) - dsConn2 := mainArena.allianceStations["B1"].driverStationConnection + assert.Equal(t, team, *mainArena.AllianceStations["B1"].team) + dsConn2 := mainArena.AllianceStations["B1"].DsConn assert.Equal(t, dsConn, dsConn2) // Pointer equality // Test reassignment to another team. err = mainArena.AssignTeam(1114, "B1") assert.Nil(t, err) - assert.NotEqual(t, team, *mainArena.allianceStations["B1"].team) - assert.Equal(t, 1114, mainArena.allianceStations["B1"].driverStationConnection.TeamId) + assert.NotEqual(t, team, *mainArena.AllianceStations["B1"].team) + assert.Equal(t, 1114, mainArena.AllianceStations["B1"].DsConn.TeamId) err = dsConn.conn.Close() assert.NotNil(t, err) // Connection should have already been closed. @@ -54,8 +54,8 @@ func TestAssignTeam(t *testing.T) { // Check assigning zero as the team number. err = mainArena.AssignTeam(0, "R2") assert.Nil(t, err) - assert.Nil(t, mainArena.allianceStations["R2"].team) - assert.Nil(t, mainArena.allianceStations["R2"].driverStationConnection) + assert.Nil(t, mainArena.AllianceStations["R2"].team) + assert.Nil(t, mainArena.AllianceStations["R2"].DsConn) // Check assigning to a non-existent station. err = mainArena.AssignTeam(254, "R4") @@ -77,120 +77,126 @@ func TestArenaMatchFlow(t *testing.T) { assert.Nil(t, err) // Check pre-match state and packet timing. - assert.Equal(t, PRE_MATCH, mainArena.matchState) + assert.Equal(t, PRE_MATCH, mainArena.MatchState) mainArena.Update() - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) - lastPacketCount := mainArena.allianceStations["B3"].driverStationConnection.packetCount + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) + lastPacketCount := mainArena.AllianceStations["B3"].DsConn.packetCount mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-10 * time.Millisecond) mainArena.Update() - assert.Equal(t, lastPacketCount, mainArena.allianceStations["B3"].driverStationConnection.packetCount) + assert.Equal(t, lastPacketCount, mainArena.AllianceStations["B3"].DsConn.packetCount) mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-300 * time.Millisecond) mainArena.Update() - assert.Equal(t, lastPacketCount+1, mainArena.allianceStations["B3"].driverStationConnection.packetCount) + assert.Equal(t, lastPacketCount+1, mainArena.AllianceStations["B3"].DsConn.packetCount) // Check match start, autonomous and transition to teleop. - mainArena.allianceStations["R1"].bypass = true - mainArena.allianceStations["R2"].bypass = true - mainArena.allianceStations["R3"].bypass = true - mainArena.allianceStations["B1"].bypass = true - mainArena.allianceStations["B2"].bypass = true - mainArena.allianceStations["B3"].driverStationConnection.DriverStationStatus.RobotLinked = true + mainArena.AllianceStations["R1"].Bypass = true + mainArena.AllianceStations["R2"].Bypass = true + mainArena.AllianceStations["R3"].Bypass = true + mainArena.AllianceStations["B1"].Bypass = true + mainArena.AllianceStations["B2"].Bypass = true + mainArena.AllianceStations["B3"].DsConn.DriverStationStatus.RobotLinked = true err = mainArena.StartMatch() assert.Nil(t, err) mainArena.Update() - assert.Equal(t, AUTO_PERIOD, mainArena.matchState) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, AUTO_PERIOD, mainArena.MatchState) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.Update() - assert.Equal(t, AUTO_PERIOD, mainArena.matchState) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, AUTO_PERIOD, mainArena.MatchState) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.matchStartTime = time.Now().Add(-autoDurationSec * time.Second) mainArena.Update() - assert.Equal(t, PAUSE_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, PAUSE_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.Update() - assert.Equal(t, PAUSE_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, PAUSE_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.matchStartTime = time.Now().Add(-(autoDurationSec + pauseDurationSec) * time.Second) mainArena.Update() - assert.Equal(t, TELEOP_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, TELEOP_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.Update() - assert.Equal(t, TELEOP_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, TELEOP_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) // Check e-stop and bypass. - mainArena.allianceStations["B3"].emergencyStop = true + mainArena.AllianceStations["B3"].EmergencyStop = true mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-300 * time.Millisecond) mainArena.Update() - assert.Equal(t, TELEOP_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) - mainArena.allianceStations["B3"].bypass = true + assert.Equal(t, TELEOP_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) + mainArena.AllianceStations["B3"].Bypass = true mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-300 * time.Millisecond) mainArena.Update() - assert.Equal(t, TELEOP_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) - mainArena.allianceStations["B3"].emergencyStop = false + assert.Equal(t, TELEOP_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) + mainArena.AllianceStations["B3"].EmergencyStop = false mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-300 * time.Millisecond) mainArena.Update() - assert.Equal(t, TELEOP_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) - mainArena.allianceStations["B3"].bypass = false + assert.Equal(t, TELEOP_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) + mainArena.AllianceStations["B3"].Bypass = false mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-300 * time.Millisecond) mainArena.Update() - assert.Equal(t, TELEOP_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, TELEOP_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) // Check endgame and match end. mainArena.matchStartTime = time.Now(). Add(-(autoDurationSec + pauseDurationSec + teleopDurationSec - endgameTimeLeftSec) * time.Second) mainArena.Update() - assert.Equal(t, ENDGAME_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, ENDGAME_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.Update() - assert.Equal(t, ENDGAME_PERIOD, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, ENDGAME_PERIOD, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.matchStartTime = time.Now().Add(-(autoDurationSec + pauseDurationSec + teleopDurationSec) * time.Second) mainArena.Update() - assert.Equal(t, POST_MATCH, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, POST_MATCH, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) mainArena.Update() - assert.Equal(t, POST_MATCH, mainArena.matchState) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, POST_MATCH, mainArena.MatchState) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) + mainArena.AllianceStations["R1"].Bypass = true mainArena.ResetMatch() mainArena.lastDsPacketTime = mainArena.lastDsPacketTime.Add(-300 * time.Millisecond) mainArena.Update() - assert.Equal(t, PRE_MATCH, mainArena.matchState) - assert.Equal(t, true, mainArena.allianceStations["B3"].driverStationConnection.Auto) - assert.Equal(t, false, mainArena.allianceStations["B3"].driverStationConnection.Enabled) + assert.Equal(t, PRE_MATCH, mainArena.MatchState) + assert.Equal(t, true, mainArena.AllianceStations["B3"].DsConn.Auto) + assert.Equal(t, false, mainArena.AllianceStations["B3"].DsConn.Enabled) + assert.Equal(t, false, mainArena.AllianceStations["R1"].Bypass) } func TestArenaStateEnforcement(t *testing.T) { mainArena.Setup() - mainArena.allianceStations["R1"].bypass = true - mainArena.allianceStations["R2"].bypass = true - mainArena.allianceStations["R3"].bypass = true - mainArena.allianceStations["B1"].bypass = true - mainArena.allianceStations["B2"].bypass = true - mainArena.allianceStations["B3"].bypass = true + mainArena.AllianceStations["R1"].Bypass = true + mainArena.AllianceStations["R2"].Bypass = true + mainArena.AllianceStations["R3"].Bypass = true + mainArena.AllianceStations["B1"].Bypass = true + mainArena.AllianceStations["B2"].Bypass = true + mainArena.AllianceStations["B3"].Bypass = true err := mainArena.LoadMatch(new(Match)) assert.Nil(t, err) + err = mainArena.AbortMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot abort match when") + } err = mainArena.StartMatch() assert.Nil(t, err) err = mainArena.LoadMatch(new(Match)) @@ -205,7 +211,7 @@ func TestArenaStateEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot reset match while") } - mainArena.matchState = AUTO_PERIOD + mainArena.MatchState = AUTO_PERIOD err = mainArena.LoadMatch(new(Match)) if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot load match while") @@ -218,7 +224,7 @@ func TestArenaStateEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot reset match while") } - mainArena.matchState = PAUSE_PERIOD + mainArena.MatchState = PAUSE_PERIOD err = mainArena.LoadMatch(new(Match)) if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot load match while") @@ -231,7 +237,7 @@ func TestArenaStateEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot reset match while") } - mainArena.matchState = TELEOP_PERIOD + mainArena.MatchState = TELEOP_PERIOD err = mainArena.LoadMatch(new(Match)) if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot load match while") @@ -244,7 +250,7 @@ func TestArenaStateEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot reset match while") } - mainArena.matchState = ENDGAME_PERIOD + mainArena.MatchState = ENDGAME_PERIOD err = mainArena.LoadMatch(new(Match)) if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot load match while") @@ -257,7 +263,9 @@ func TestArenaStateEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot reset match while") } - mainArena.matchState = POST_MATCH + err = mainArena.AbortMatch() + assert.Nil(t, err) + mainArena.MatchState = POST_MATCH err = mainArena.LoadMatch(new(Match)) if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot load match while") @@ -266,17 +274,16 @@ func TestArenaStateEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "Cannot start match while") } + err = mainArena.AbortMatch() + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Cannot abort match when") + } err = mainArena.ResetMatch() assert.Nil(t, err) - assert.Equal(t, PRE_MATCH, mainArena.matchState) - assert.Nil(t, mainArena.currentMatch) + assert.Equal(t, PRE_MATCH, mainArena.MatchState) err = mainArena.ResetMatch() assert.Nil(t, err) - err = mainArena.StartMatch() - if assert.NotNil(t, err) { - assert.Contains(t, err.Error(), "no match is loaded") - } err = mainArena.LoadMatch(new(Match)) assert.Nil(t, err) } @@ -300,30 +307,30 @@ func TestMatchStartRobotLinkEnforcement(t *testing.T) { err = mainArena.LoadMatch(&match) assert.Nil(t, err) - for _, station := range mainArena.allianceStations { - station.driverStationConnection.DriverStationStatus.RobotLinked = true + for _, station := range mainArena.AllianceStations { + station.DsConn.DriverStationStatus.RobotLinked = true } err = mainArena.StartMatch() assert.Nil(t, err) - mainArena.matchState = PRE_MATCH + mainArena.MatchState = PRE_MATCH // Check with a single team e-stopped, not linked and bypassed. - mainArena.allianceStations["R1"].emergencyStop = true + mainArena.AllianceStations["R1"].EmergencyStop = true err = mainArena.StartMatch() if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "while an emergency stop is active") } - mainArena.allianceStations["R1"].emergencyStop = false - mainArena.allianceStations["R1"].driverStationConnection.DriverStationStatus.RobotLinked = false + mainArena.AllianceStations["R1"].EmergencyStop = false + mainArena.AllianceStations["R1"].DsConn.DriverStationStatus.RobotLinked = false err = mainArena.StartMatch() if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "until all robots are connected or bypassed") } - mainArena.allianceStations["R1"].bypass = true + mainArena.AllianceStations["R1"].Bypass = true err = mainArena.StartMatch() assert.Nil(t, err) - mainArena.allianceStations["R1"].bypass = false - mainArena.matchState = PRE_MATCH + mainArena.AllianceStations["R1"].Bypass = false + mainArena.MatchState = PRE_MATCH // Check with a team missing. err = mainArena.AssignTeam(0, "R1") @@ -332,10 +339,10 @@ func TestMatchStartRobotLinkEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "until all robots are connected or bypassed") } - mainArena.allianceStations["R1"].bypass = true + mainArena.AllianceStations["R1"].Bypass = true err = mainArena.StartMatch() assert.Nil(t, err) - mainArena.matchState = PRE_MATCH + mainArena.MatchState = PRE_MATCH // Check with no teams present. mainArena.LoadMatch(new(Match)) @@ -343,18 +350,134 @@ func TestMatchStartRobotLinkEnforcement(t *testing.T) { if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "until all robots are connected or bypassed") } - mainArena.allianceStations["R1"].bypass = true - mainArena.allianceStations["R2"].bypass = true - mainArena.allianceStations["R3"].bypass = true - mainArena.allianceStations["B1"].bypass = true - mainArena.allianceStations["B2"].bypass = true - mainArena.allianceStations["B3"].bypass = true - mainArena.allianceStations["B3"].emergencyStop = true + mainArena.AllianceStations["R1"].Bypass = true + mainArena.AllianceStations["R2"].Bypass = true + mainArena.AllianceStations["R3"].Bypass = true + mainArena.AllianceStations["B1"].Bypass = true + mainArena.AllianceStations["B2"].Bypass = true + mainArena.AllianceStations["B3"].Bypass = true + mainArena.AllianceStations["B3"].EmergencyStop = true err = mainArena.StartMatch() if assert.NotNil(t, err) { assert.Contains(t, err.Error(), "while an emergency stop is active") } - mainArena.allianceStations["B3"].emergencyStop = false + mainArena.AllianceStations["B3"].EmergencyStop = false err = mainArena.StartMatch() assert.Nil(t, err) } + +func TestLoadNextMatch(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + mainArena.Setup() + + db.CreateTeam(&Team{Id: 1114}) + practiceMatch1 := Match{Type: "practice", DisplayName: "1"} + practiceMatch2 := Match{Type: "practice", DisplayName: "2", Status: "complete"} + practiceMatch3 := Match{Type: "practice", DisplayName: "3"} + db.CreateMatch(&practiceMatch1) + db.CreateMatch(&practiceMatch2) + db.CreateMatch(&practiceMatch3) + qualificationMatch1 := Match{Type: "qualification", DisplayName: "1", Status: "complete"} + qualificationMatch2 := Match{Type: "qualification", DisplayName: "2"} + db.CreateMatch(&qualificationMatch1) + db.CreateMatch(&qualificationMatch2) + + // Test match should be followed by another, empty test match. + assert.Equal(t, 0, mainArena.currentMatch.Id) + err = mainArena.SubstituteTeam(1114, "R1") + assert.Nil(t, err) + mainArena.currentMatch.Status = "complete" + err = mainArena.LoadNextMatch() + assert.Nil(t, err) + assert.Equal(t, 0, mainArena.currentMatch.Id) + assert.Equal(t, 0, mainArena.currentMatch.Red1) + assert.NotEqual(t, "complete", mainArena.currentMatch.Status) + + // Other matches should be loaded by type until they're all complete. + err = mainArena.LoadMatch(&practiceMatch2) + assert.Nil(t, err) + err = mainArena.LoadNextMatch() + assert.Nil(t, err) + assert.Equal(t, practiceMatch1.Id, mainArena.currentMatch.Id) + practiceMatch1.Status = "complete" + db.SaveMatch(&practiceMatch1) + err = mainArena.LoadNextMatch() + assert.Nil(t, err) + assert.Equal(t, practiceMatch3.Id, mainArena.currentMatch.Id) + practiceMatch3.Status = "complete" + db.SaveMatch(&practiceMatch3) + err = mainArena.LoadNextMatch() + assert.Nil(t, err) + assert.Equal(t, practiceMatch3.Id, mainArena.currentMatch.Id) + assert.Equal(t, "complete", practiceMatch3.Status) + + err = mainArena.LoadMatch(&qualificationMatch1) + assert.Nil(t, err) + err = mainArena.LoadNextMatch() + assert.Nil(t, err) + assert.Equal(t, qualificationMatch2.Id, mainArena.currentMatch.Id) +} + +func TestSubstituteTeam(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + mainArena.Setup() + db.CreateTeam(&Team{Id: 101}) + db.CreateTeam(&Team{Id: 102}) + db.CreateTeam(&Team{Id: 103}) + db.CreateTeam(&Team{Id: 104}) + db.CreateTeam(&Team{Id: 105}) + db.CreateTeam(&Team{Id: 106}) + db.CreateTeam(&Team{Id: 107}) + + // Substitute teams into test match. + err = mainArena.SubstituteTeam(101, "B1") + assert.Nil(t, err) + assert.Equal(t, 101, mainArena.currentMatch.Blue1) + assert.Equal(t, 101, mainArena.AllianceStations["B1"].team.Id) + err = mainArena.SubstituteTeam(1503, "R1") + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Invalid team number") + } + err = mainArena.AssignTeam(104, "R4") + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Invalid alliance station") + } + + // Substitute teams into practice match. Replacement should also be persisted in the DB. + match := Match{Type: "practice", Red1: 101, Red2: 102, Red3: 103, Blue1: 104, Blue2: 105, Blue3: 106} + db.CreateMatch(&match) + mainArena.LoadMatch(&match) + err = mainArena.SubstituteTeam(107, "R1") + assert.Nil(t, err) + assert.Equal(t, 107, mainArena.currentMatch.Red1) + assert.Equal(t, 107, mainArena.AllianceStations["R1"].team.Id) + CommitMatchScore(mainArena.currentMatch, &MatchResult{MatchId: mainArena.currentMatch.Id}) + match2, _ := db.GetMatchById(match.Id) + assert.Equal(t, 107, match2.Red1) + + // Check that substitution is disallowed in qualification and elimination matches. + match = Match{Type: "qualification", Red1: 101, Red2: 102, Red3: 103, Blue1: 104, Blue2: 105, Blue3: 106} + db.CreateMatch(&match) + mainArena.LoadMatch(&match) + err = mainArena.SubstituteTeam(107, "R1") + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Can only substitute teams for test and practice matches") + } + match = Match{Type: "elimination", Red1: 101, Red2: 102, Red3: 103, Blue1: 104, Blue2: 105, Blue3: 106} + db.CreateMatch(&match) + mainArena.LoadMatch(&match) + err = mainArena.SubstituteTeam(107, "R1") + if assert.NotNil(t, err) { + assert.Contains(t, err.Error(), "Can only substitute teams for test and practice matches") + } +} diff --git a/driver_station_connection.go b/driver_station_connection.go index f6e1ea9..8559da1 100644 --- a/driver_station_connection.go +++ b/driver_station_connection.go @@ -98,7 +98,7 @@ func ListenForDsPackets(listener *net.UDPConn) { dsStatus := decodeStatusPacket(data) // Update the status and last packet times for this alliance/team in the global struct. - dsConn := mainArena.allianceStations[dsStatus.AllianceStation].driverStationConnection + dsConn := mainArena.AllianceStations[dsStatus.AllianceStation].DsConn if dsConn != nil && dsConn.TeamId == dsStatus.TeamId { dsConn.DriverStationStatus = dsStatus dsConn.LastPacketTime = time.Now() diff --git a/driver_station_connection_test.go b/driver_station_connection_test.go index 3265e40..c64fb78 100644 --- a/driver_station_connection_test.go +++ b/driver_station_connection_test.go @@ -164,11 +164,11 @@ func TestListenForDsPackets(t *testing.T) { dsConn, err := NewDriverStationConnection(254, "B1") defer dsConn.Close() assert.Nil(t, err) - mainArena.allianceStations["B1"].driverStationConnection = dsConn + mainArena.AllianceStations["B1"].DsConn = dsConn dsConn, err = NewDriverStationConnection(1114, "R3") defer dsConn.Close() assert.Nil(t, err) - mainArena.allianceStations["R3"].driverStationConnection = dsConn + mainArena.AllianceStations["R3"].DsConn = dsConn // Create a socket to send fake DS packets to localhost. conn, err := net.Dial("udp4", fmt.Sprintf("127.0.0.1:%d", driverStationReceivePort)) @@ -180,7 +180,7 @@ func TestListenForDsPackets(t *testing.T) { _, err = conn.Write(packet[:]) assert.Nil(t, err) time.Sleep(time.Millisecond * 10) // Allow some time for the goroutine to process the incoming packet. - dsStatus := mainArena.allianceStations["B1"].driverStationConnection.DriverStationStatus + dsStatus := mainArena.AllianceStations["B1"].DsConn.DriverStationStatus if assert.NotNil(t, dsStatus) { assert.Equal(t, 254, dsStatus.TeamId) assert.Equal(t, "B1", dsStatus.AllianceStation) @@ -195,32 +195,32 @@ func TestListenForDsPackets(t *testing.T) { assert.Equal(t, 39072, dsStatus.MissedPacketCount) assert.Equal(t, 256, dsStatus.DsRobotTripTimeMs) } - assert.True(t, time.Since(mainArena.allianceStations["B1"].driverStationConnection.LastPacketTime).Seconds() < 0.1) - assert.True(t, time.Since(mainArena.allianceStations["B1"].driverStationConnection.LastRobotLinkedTime).Seconds() > 100) + assert.True(t, time.Since(mainArena.AllianceStations["B1"].DsConn.LastPacketTime).Seconds() < 0.1) + assert.True(t, time.Since(mainArena.AllianceStations["B1"].DsConn.LastRobotLinkedTime).Seconds() > 100) packet[2] = byte(98) _, err = conn.Write(packet[:]) assert.Nil(t, err) time.Sleep(time.Millisecond * 10) - dsStatus2 := mainArena.allianceStations["B1"].driverStationConnection.DriverStationStatus + dsStatus2 := mainArena.AllianceStations["B1"].DsConn.DriverStationStatus if assert.NotNil(t, dsStatus2) { assert.Equal(t, true, dsStatus2.RobotLinked) assert.Equal(t, false, dsStatus2.Auto) assert.Equal(t, true, dsStatus2.Enabled) assert.Equal(t, false, dsStatus2.EmergencyStop) } - assert.True(t, time.Since(mainArena.allianceStations["B1"].driverStationConnection.LastPacketTime).Seconds() < 0.1) - assert.True(t, time.Since(mainArena.allianceStations["B1"].driverStationConnection.LastRobotLinkedTime).Seconds() < 0.1) + assert.True(t, time.Since(mainArena.AllianceStations["B1"].DsConn.LastPacketTime).Seconds() < 0.1) + assert.True(t, time.Since(mainArena.AllianceStations["B1"].DsConn.LastRobotLinkedTime).Seconds() < 0.1) // Should ignore a packet coming from an expected team in the wrong position. - statusBefore := mainArena.allianceStations["R3"].driverStationConnection.DriverStationStatus + statusBefore := mainArena.AllianceStations["R3"].DsConn.DriverStationStatus packet[10] = 'R' packet[11] = '3' packet[2] = 48 _, err = conn.Write(packet[:]) assert.Nil(t, err) time.Sleep(time.Millisecond * 10) - assert.Equal(t, statusBefore, mainArena.allianceStations["R3"].driverStationConnection.DriverStationStatus) - assert.Equal(t, true, mainArena.allianceStations["B1"].driverStationConnection.DriverStationStatus.RobotLinked) + assert.Equal(t, statusBefore, mainArena.AllianceStations["R3"].DsConn.DriverStationStatus) + assert.Equal(t, true, mainArena.AllianceStations["B1"].DsConn.DriverStationStatus.RobotLinked) // Should ignore a packet coming from an unexpected team. packet[4] = byte(15) @@ -231,10 +231,10 @@ func TestListenForDsPackets(t *testing.T) { _, err = conn.Write(packet[:]) assert.Nil(t, err) time.Sleep(time.Millisecond * 10) - assert.Equal(t, true, mainArena.allianceStations["B1"].driverStationConnection.DriverStationStatus.RobotLinked) + assert.Equal(t, true, mainArena.AllianceStations["B1"].DsConn.DriverStationStatus.RobotLinked) // Should indicate that the connection has dropped if a response isn't received before the timeout. - dsConn = mainArena.allianceStations["B1"].driverStationConnection + dsConn = mainArena.AllianceStations["B1"].DsConn dsConn.Update() assert.Equal(t, true, dsConn.DriverStationStatus.DsLinked) assert.Equal(t, true, dsConn.DriverStationStatus.RobotLinked) diff --git a/match_play.go b/match_play.go index 3b7f8d0..41050ef 100644 --- a/match_play.go +++ b/match_play.go @@ -8,11 +8,15 @@ package main import ( "fmt" "github.com/gorilla/mux" + "github.com/mitchellh/mapstructure" "html/template" + "io" + "log" "math/rand" "net/http" "sort" "strconv" + "time" ) type MatchPlayListItem struct { @@ -46,7 +50,8 @@ func MatchPlayHandler(w http.ResponseWriter, r *http.Request) { return } - template, err := template.ParseFiles("templates/match_play.html", "templates/base.html") + template := template.New("").Funcs(templateHelpers) + _, err = template.ParseFiles("templates/match_play.html", "templates/base.html") if err != nil { handleWebErr(w, err) return @@ -56,12 +61,14 @@ func MatchPlayHandler(w http.ResponseWriter, r *http.Request) { if currentMatchType == "" { currentMatchType = "practice" } + allowSubstitution := mainArena.currentMatch.Type == "test" || mainArena.currentMatch.Type == "practice" data := struct { *EventSettings - MatchesByType map[string]MatchPlayList - CurrentMatchType string - Match *Match - }{eventSettings, matchesByType, currentMatchType, mainArena.currentMatch} + MatchesByType map[string]MatchPlayList + CurrentMatchType string + Match *Match + AllowSubstitution bool + }{eventSettings, matchesByType, currentMatchType, mainArena.currentMatch, allowSubstitution} err = template.ExecuteTemplate(w, "base", data) if err != nil { handleWebErr(w, err) @@ -69,25 +76,30 @@ func MatchPlayHandler(w http.ResponseWriter, r *http.Request) { } } -func MatchPlayQueueHandler(w http.ResponseWriter, r *http.Request) { +func MatchPlayLoadHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) matchId, _ := strconv.Atoi(vars["matchId"]) - match, err := db.GetMatchById(matchId) + var match *Match + var err error + if matchId == 0 { + err = mainArena.LoadTestMatch() + } else { + match, err = db.GetMatchById(matchId) + if err != nil { + handleWebErr(w, err) + return + } + if match == nil { + handleWebErr(w, fmt.Errorf("Invalid match ID %d.", matchId)) + return + } + err = mainArena.LoadMatch(match) + } if err != nil { handleWebErr(w, err) return } - if match == nil { - handleWebErr(w, fmt.Errorf("Invalid match ID %d.", matchId)) - return - } - - err = mainArena.LoadMatch(match) - if err != nil { - handleWebErr(w, err) - return - } - currentMatchType = match.Type + currentMatchType = mainArena.currentMatch.Type http.Redirect(w, r, "/match_play", 302) } @@ -118,7 +130,150 @@ func MatchPlayFakeResultHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/match_play", 302) } +// The websocket endpoint for the match play client to send control commands and receive status updates. +func MatchPlayWebsocketHandler(w http.ResponseWriter, r *http.Request) { + // Allow disabling of period updates, for easier testing. + _, disableUpdates := r.URL.Query()["test"] + + websocket, err := NewWebsocket(w, r) + if err != nil { + handleWebErr(w, err) + return + } + defer websocket.Close() + + // Send the arena status immediately upon connection. + err = websocket.Write("status", mainArena) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + + if !disableUpdates { + // Spin off a goroutine to periodically send a status update. + go func() { + for { + err = websocket.Write("status", mainArena) + if err != nil { + // The client has probably closed the connection; nothing to do here. + break + } + time.Sleep(500 * time.Millisecond) + } + }() + } + + // Loop, waiting for commands and responding to them, until the client closes the connection. + for { + messageType, data, err := websocket.Read() + if err != nil { + if err == io.EOF { + // Client has closed the connection; nothing to do here. + return + } + log.Printf("Websocket error: %s", err) + return + } + + switch messageType { + case "substituteTeam": + args := struct { + Team int + Position string + }{} + err = mapstructure.Decode(data, &args) + if err != nil { + websocket.WriteError(err.Error()) + continue + } + err = mainArena.SubstituteTeam(args.Team, args.Position) + if err != nil { + websocket.WriteError(err.Error()) + continue + } + case "toggleBypass": + station, ok := data.(string) + if !ok { + websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType)) + continue + } + if _, ok := mainArena.AllianceStations[station]; !ok { + websocket.WriteError(fmt.Sprintf("Invalid alliance station '%s'.", station)) + continue + } + mainArena.AllianceStations[station].Bypass = !mainArena.AllianceStations[station].Bypass + case "startMatch": + err = mainArena.StartMatch() + if err != nil { + websocket.WriteError(err.Error()) + continue + } + case "abortMatch": + err = mainArena.AbortMatch() + if err != nil { + websocket.WriteError(err.Error()) + continue + } + case "commitResults": + // TODO(pat): Deal with scoring here. For now, use an empty match result set for a 0-0 tie. + err = CommitMatchScore(mainArena.currentMatch, &MatchResult{MatchId: mainArena.currentMatch.Id}) + if err != nil { + websocket.WriteError(err.Error()) + continue + } + err = mainArena.ResetMatch() + if err != nil { + websocket.WriteError(err.Error()) + continue + } + err = mainArena.LoadNextMatch() + if err != nil { + websocket.WriteError(err.Error()) + continue + } + err = websocket.Write("reload", nil) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + continue // Skip sending the status update, as the client is about to terminate and reload. + case "discardResults": + err = mainArena.ResetMatch() + if err != nil { + websocket.WriteError(err.Error()) + continue + } + err = mainArena.LoadNextMatch() + if err != nil { + websocket.WriteError(err.Error()) + continue + } + err = websocket.Write("reload", nil) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + continue // Skip sending the status update, as the client is about to terminate and reload. + default: + websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType)) + continue + } + + // Send out the status again after handling the command, as it most likely changed as a result. + err = websocket.Write("status", mainArena) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + } +} + func CommitMatchScore(match *Match, matchResult *MatchResult) error { + if match.Type == "test" { + // Do nothing since this is a test match and doesn't exist in the database. + return nil + } + if matchResult.PlayNumber == 0 { // Determine the play number for this new match result. prevMatchResult, err := db.GetMatchResultForMatch(match.Id) diff --git a/match_play_test.go b/match_play_test.go index 048f0c8..93b4532 100644 --- a/match_play_test.go +++ b/match_play_test.go @@ -5,6 +5,7 @@ package main import ( "fmt" + "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "testing" ) @@ -39,7 +40,7 @@ func TestMatchPlay(t *testing.T) { assert.Contains(t, recorder.Body.String(), "SF1-2") } -func TestMatchPlayQueue(t *testing.T) { +func TestMatchPlayLoad(t *testing.T) { clearDb() defer clearDb() var err error @@ -67,8 +68,8 @@ func TestMatchPlayQueue(t *testing.T) { assert.NotContains(t, recorder.Body.String(), "105") assert.NotContains(t, recorder.Body.String(), "106") - // Queue the match and check for the team numbers again. - recorder = getHttpResponse(fmt.Sprintf("/match_play/%d/queue", match.Id)) + // Load the match and check for the team numbers again. + recorder = getHttpResponse(fmt.Sprintf("/match_play/%d/load", match.Id)) assert.Equal(t, 302, recorder.Code) recorder = getHttpResponse("/match_play") assert.Equal(t, 200, recorder.Code) @@ -78,6 +79,18 @@ func TestMatchPlayQueue(t *testing.T) { assert.Contains(t, recorder.Body.String(), "104") assert.Contains(t, recorder.Body.String(), "105") assert.Contains(t, recorder.Body.String(), "106") + + // Load a test match. + recorder = getHttpResponse("/match_play/0/load") + assert.Equal(t, 302, recorder.Code) + recorder = getHttpResponse("/match_play") + assert.Equal(t, 200, recorder.Code) + assert.NotContains(t, recorder.Body.String(), "101") + assert.NotContains(t, recorder.Body.String(), "102") + assert.NotContains(t, recorder.Body.String(), "103") + assert.NotContains(t, recorder.Body.String(), "104") + assert.NotContains(t, recorder.Body.String(), "105") + assert.NotContains(t, recorder.Body.String(), "106") } func TestMatchPlayErrors(t *testing.T) { @@ -89,8 +102,141 @@ func TestMatchPlayErrors(t *testing.T) { defer db.Close() eventSettings, _ = db.GetEventSettings() - // Queue an invalid match. - recorder := getHttpResponse("/match_play/1114/queue") + // Load an invalid match. + recorder := getHttpResponse("/match_play/1114/load") assert.Equal(t, 500, recorder.Code) assert.Contains(t, recorder.Body.String(), "Invalid match") } + +func TestCommitMatch(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + eventSettings, _ = db.GetEventSettings() + + // Committing test match should do nothing. + match := &Match{Id: 0, Type: "test", Red1: 101, Red2: 102, Red3: 103, Blue1: 104, Blue2: 105, Blue3: 106} + err = CommitMatchScore(match, &MatchResult{MatchId: match.Id}) + assert.Nil(t, err) + matchResult, err := db.GetMatchResultForMatch(match.Id) + assert.Nil(t, err) + assert.Nil(t, matchResult) + + // Committing the same match more than once should create a second match result record. + match.Id = 1 + match.Type = "qualification" + db.CreateMatch(match) + matchResult = &MatchResult{MatchId: match.Id, BlueScore: Score{AutoHigh: 1}} + err = CommitMatchScore(match, matchResult) + assert.Nil(t, err) + assert.Equal(t, 1, matchResult.PlayNumber) + match, _ = db.GetMatchById(1) + assert.Equal(t, "B", match.Winner) + matchResult = &MatchResult{MatchId: match.Id, RedScore: Score{AutoHigh: 1}} + err = CommitMatchScore(match, matchResult) + assert.Nil(t, err) + assert.Equal(t, 2, matchResult.PlayNumber) + match, _ = db.GetMatchById(1) + assert.Equal(t, "R", match.Winner) + matchResult = &MatchResult{MatchId: match.Id} + err = CommitMatchScore(match, matchResult) + assert.Nil(t, err) + assert.Equal(t, 3, matchResult.PlayNumber) + match, _ = db.GetMatchById(1) + assert.Equal(t, "T", match.Winner) +} + +func TestMatchPlayWebsocket(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + db.CreateTeam(&Team{Id: 254}) + mainArena.Setup() + + server, wsUrl := startTestServer() + defer server.Close() + conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/match_play/websocket?test", nil) + assert.Nil(t, err) + defer conn.Close() + ws := &Websocket{conn} + + // Should get a status update right after connection. + messageType, _, err := ws.Read() + assert.Nil(t, err) + assert.Equal(t, "status", messageType) + + // Test that a server-side error is communicated to the client. + ws.Write("nonexistenttype", nil) + assert.Contains(t, readWebsocketError(t, ws), "Invalid message type") + + // Test match setup commands. + ws.Write("substituteTeam", nil) + assert.Contains(t, readWebsocketError(t, ws), "Invalid alliance station") + ws.Write("substituteTeam", map[string]interface{}{"team": 254, "position": "B5"}) + assert.Contains(t, readWebsocketError(t, ws), "Invalid alliance station") + ws.Write("substituteTeam", map[string]interface{}{"team": 254, "position": "B1"}) + readWebsocketType(t, ws, "status") + assert.Equal(t, 254, mainArena.currentMatch.Blue1) + ws.Write("substituteTeam", map[string]interface{}{"team": 0, "position": "B1"}) + readWebsocketType(t, ws, "status") + assert.Equal(t, 0, mainArena.currentMatch.Blue1) + ws.Write("toggleBypass", nil) + assert.Contains(t, readWebsocketError(t, ws), "Failed to parse") + ws.Write("toggleBypass", "R4") + assert.Contains(t, readWebsocketError(t, ws), "Invalid alliance station") + ws.Write("toggleBypass", "R3") + readWebsocketType(t, ws, "status") + assert.Equal(t, true, mainArena.AllianceStations["R3"].Bypass) + ws.Write("toggleBypass", "R3") + readWebsocketType(t, ws, "status") + assert.Equal(t, false, mainArena.AllianceStations["R3"].Bypass) + + // Go through match flow. + ws.Write("abortMatch", nil) + assert.Contains(t, readWebsocketError(t, ws), "Cannot abort match") + ws.Write("startMatch", nil) + assert.Contains(t, readWebsocketError(t, ws), "Cannot start match") + mainArena.AllianceStations["R1"].Bypass = true + mainArena.AllianceStations["R2"].Bypass = true + mainArena.AllianceStations["R3"].Bypass = true + mainArena.AllianceStations["B1"].Bypass = true + mainArena.AllianceStations["B2"].Bypass = true + mainArena.AllianceStations["B3"].Bypass = true + ws.Write("startMatch", nil) + readWebsocketType(t, ws, "status") + assert.Equal(t, START_MATCH, mainArena.MatchState) + ws.Write("commitResults", nil) + assert.Contains(t, readWebsocketError(t, ws), "Cannot reset match") + ws.Write("discardResults", nil) + assert.Contains(t, readWebsocketError(t, ws), "Cannot reset match") + ws.Write("abortMatch", nil) + readWebsocketType(t, ws, "status") + assert.Equal(t, POST_MATCH, mainArena.MatchState) + ws.Write("commitResults", nil) + readWebsocketType(t, ws, "reload") + assert.Equal(t, PRE_MATCH, mainArena.MatchState) + ws.Write("discardResults", nil) + readWebsocketType(t, ws, "reload") + assert.Equal(t, PRE_MATCH, mainArena.MatchState) +} + +func readWebsocketError(t *testing.T, ws *Websocket) string { + messageType, data, err := ws.Read() + if assert.Nil(t, err) && assert.Equal(t, "error", messageType) { + return data.(string) + } + return "error" +} + +func readWebsocketType(t *testing.T, ws *Websocket, expectedMessageType string) { + messageType, _, err := ws.Read() + if assert.Nil(t, err) { + assert.Equal(t, expectedMessageType, messageType) + } +} diff --git a/static/css/cheesy-arena.css b/static/css/cheesy-arena.css index 53f282f..66fa887 100644 --- a/static/css/cheesy-arena.css +++ b/static/css/cheesy-arena.css @@ -1,3 +1,13 @@ +// Bootstrap overrides. +.form-control[disabled] { + cursor: default; +} +.btn[disabled] { + color: #000; + opacity: 0.2; +} + +// New styles. .red-text { color: #f00; } @@ -20,18 +30,27 @@ padding-left: 0; padding-right: 0; } -.ds-status, .robot-status, .battery-status, .bypass-button { +.ds-status, .robot-status, .battery-status, .bypass-status { background-color: #aaa; color: #000; border: 1px solid #999; border-radius: 4px; - padding: 5px; + padding: 0px; width: 40px; height: 27px; - margin: 2px; - font-family: Arial; + line-height: 25px; + font-size: 14px; margin: 0 auto; } -.bypass-button { +.bypass-status { + cursor: pointer; +} +[data-status-ok="true"] { background-color: #0e8; -} \ No newline at end of file +} +[data-status-ok="false"] { + background-color: #e66; +} +.btn-match-play { + width: 165px; +} diff --git a/static/js/cheesy-websocket.js b/static/js/cheesy-websocket.js new file mode 100644 index 0000000..e3bcc0c --- /dev/null +++ b/static/js/cheesy-websocket.js @@ -0,0 +1,46 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Shared code for initiating websocket connections back to the server for full-duplex communication. + +var CheesyWebsocket = function(path, events) { + var that = this; + + var url = "ws://" + window.location.hostname; + if (window.location.port != "") { + url += ":" + window.location.port; + } + url += path; + + // Insert a default error-handling event if a custom one doesn't already exist. + if (!events.hasOwnProperty("error")) { + events.error = function(event) { + // Data is just an error string. + console.log(event.data); + } + } + + // Insert an event to allow the server to force-reload the client. + events.reload = function(event) { + location.reload(); + }; + + this.connect = function() { + this.websocket = $.websocket(url, { + open: function() { + console.log("Websocket connected to the server at " + url + ".") + }, + close: function() { + console.log("Websocket lost connection to the server. Reconnecting in 3 seconds..."); + setTimeout(that.connect, 3000); + }, + events: events + }); + }; + + this.send = function(type, data) { + this.websocket.send(type, data); + }; + + this.connect(); +} diff --git a/static/js/match_play.js b/static/js/match_play.js new file mode 100644 index 0000000..f5e2c07 --- /dev/null +++ b/static/js/match_play.js @@ -0,0 +1,104 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Client-side logic for the match play page. + +var websocket; +var matchStates = { + 0: "PRE_MATCH", + 1: "START_MATCH", + 2: "AUTO_PERIOD", + 3: "PAUSE_PERIOD", + 4: "TELEOP_PERIOD", + 5: "ENDGAME_PERIOD", + 6: "POST_MATCH" +}; + +var substituteTeam = function(team, position) { + websocket.send("substituteTeam", { team: parseInt(team), position: position }) +}; + +var toggleBypass = function(station) { + websocket.send("toggleBypass", station); +}; + +var startMatch = function() { + websocket.send("startMatch"); +}; + +var abortMatch = function() { + websocket.send("abortMatch"); +}; + +var commitResults = function() { + websocket.send("commitResults"); +}; + +var discardResults = function() { + websocket.send("discardResults"); +}; + +var handleStatus = function(data) { + // Update the team status view. + $.each(data.AllianceStations, function(station, stationStatus) { + if (stationStatus.DsConn) { + var dsStatus = stationStatus.DsConn.DriverStationStatus; + $("#status" + station + " .ds-status").attr("data-status-ok", dsStatus.DsLinked); + $("#status" + station + " .robot-status").attr("data-status-ok", dsStatus.RobotLinked); + $("#status" + station + " .battery-status").attr("data-status-ok", dsStatus.BatteryVoltage > 0); + $("#status" + station + " .battery-status").text(dsStatus.BatteryVoltage.toFixed(1) + "V"); + } else { + $("#status" + station + " .ds-status").attr("data-status-ok", ""); + $("#status" + station + " .robot-status").attr("data-status-ok", ""); + $("#status" + station + " .battery-status").attr("data-status-ok", ""); + $("#status" + station + " .battery-status").text(""); + } + + if (stationStatus.EmergencyStop) { + $("#status" + station + " .bypass-status").attr("data-status-ok", false); + $("#status" + station + " .bypass-status").text("ES"); + } else if (stationStatus.Bypass) { + $("#status" + station + " .bypass-status").attr("data-status-ok", false); + $("#status" + station + " .bypass-status").text("B"); + } else { + $("#status" + station + " .bypass-status").attr("data-status-ok", true); + $("#status" + station + " .bypass-status").text(""); + } + }); + + // Enable/disable the buttons based on the current match state. + switch (matchStates[data.MatchState]) { + case "PRE_MATCH": + $("#startMatch").prop("disabled", !data.CanStartMatch); + $("#abortMatch").prop("disabled", true); + $("#commitResults").prop("disabled", true); + $("#discardResults").prop("disabled", true); + break; + case "START_MATCH": + case "AUTO_PERIOD": + case "PAUSE_PERIOD": + case "TELEOP_PERIOD": + case "ENDGAME_PERIOD": + $("#startMatch").prop("disabled", true); + $("#abortMatch").prop("disabled", false); + $("#commitResults").prop("disabled", true); + $("#discardResults").prop("disabled", true); + break; + case "POST_MATCH": + $("#startMatch").prop("disabled", true); + $("#abortMatch").prop("disabled", true); + $("#commitResults").prop("disabled", false); + $("#discardResults").prop("disabled", false); + break; + } +}; + +$(function() { + // Activate tooltips above the status headers. + $("[data-toggle=tooltip]").tooltip({"placement": "top"}); + + // Set up the websocket back to the server. + websocket = new CheesyWebsocket("/match_play/websocket", { + status: function(event) { handleStatus(event.data); } + }); +}); diff --git a/static/lib/jquery.json-2.4.min.js b/static/lib/jquery.json-2.4.min.js new file mode 100644 index 0000000..87050c9 --- /dev/null +++ b/static/lib/jquery.json-2.4.min.js @@ -0,0 +1,23 @@ +/*! jQuery JSON plugin 2.4.0 | code.google.com/p/jquery-json */ +(function($){'use strict';var escape=/["\\\x00-\x1f\x7f-\x9f]/g,meta={'\b':'\\b','\t':'\\t','\n':'\\n','\f':'\\f','\r':'\\r','"':'\\"','\\':'\\\\'},hasOwn=Object.prototype.hasOwnProperty;$.toJSON=typeof JSON==='object'&&JSON.stringify?JSON.stringify:function(o){if(o===null){return'null';} +var pairs,k,name,val,type=$.type(o);if(type==='undefined'){return undefined;} +if(type==='number'||type==='boolean'){return String(o);} +if(type==='string'){return $.quoteString(o);} +if(typeof o.toJSON==='function'){return $.toJSON(o.toJSON());} +if(type==='date'){var month=o.getUTCMonth()+1,day=o.getUTCDate(),year=o.getUTCFullYear(),hours=o.getUTCHours(),minutes=o.getUTCMinutes(),seconds=o.getUTCSeconds(),milli=o.getUTCMilliseconds();if(month<10){month='0'+month;} +if(day<10){day='0'+day;} +if(hours<10){hours='0'+hours;} +if(minutes<10){minutes='0'+minutes;} +if(seconds<10){seconds='0'+seconds;} +if(milli<100){milli='0'+milli;} +if(milli<10){milli='0'+milli;} +return'"'+year+'-'+month+'-'+day+'T'+ +hours+':'+minutes+':'+seconds+'.'+milli+'Z"';} +pairs=[];if($.isArray(o)){for(k=0;kAlliance Selection -