diff --git a/field/arena.go b/field/arena.go index a3c112ff..b9a1b150 100644 --- a/field/arena.go +++ b/field/arena.go @@ -56,6 +56,7 @@ type Arena struct { NexusClient *partner.NexusClient AllianceStations map[string]*AllianceStation Displays map[string]*Display + TeamSigns *TeamSigns ScoringPanelRegistry ArenaNotifiers MatchState @@ -114,6 +115,8 @@ func NewArena(dbPath string) (*Arena, error) { arena.Displays = make(map[string]*Display) + arena.TeamSigns = NewTeamSigns() + var err error arena.Database, err = model.OpenDatabase(dbPath) if err != nil { @@ -150,6 +153,14 @@ func (arena *Arena) LoadSettings() error { arena.EventSettings = settings // Initialize the components that depend on settings. + arena.TeamSigns.Red1.SetAddress(settings.TeamSignRed1Address) + arena.TeamSigns.Red2.SetAddress(settings.TeamSignRed2Address) + arena.TeamSigns.Red3.SetAddress(settings.TeamSignRed3Address) + arena.TeamSigns.RedTimer.SetAddress(settings.TeamSignRedTimerAddress) + arena.TeamSigns.Blue1.SetAddress(settings.TeamSignBlue1Address) + arena.TeamSigns.Blue2.SetAddress(settings.TeamSignBlue2Address) + arena.TeamSigns.Blue3.SetAddress(settings.TeamSignBlue3Address) + arena.TeamSigns.BlueTimer.SetAddress(settings.TeamSignBlueTimerAddress) accessPointWifiStatuses := [6]*network.TeamWifiStatus{ &arena.AllianceStations["R1"].WifiStatus, &arena.AllianceStations["R2"].WifiStatus, @@ -290,7 +301,6 @@ func (arena *Arena) LoadMatch(match *model.Match) error { arena.soundsPlayed = make(map[*game.MatchSound]struct{}) arena.RedRealtimeScore = NewRealtimeScore() arena.BlueRealtimeScore = NewRealtimeScore() - arena.FieldReset = false arena.ScoringPanelRegistry.resetScoreCommitted() arena.Plc.ResetMatch() @@ -546,6 +556,7 @@ func (arena *Arena) Update() { sendDsPacket = true } arena.Plc.ResetMatch() + arena.FieldReset = false case WarmupPeriod: auto = true enabled = false @@ -569,7 +580,6 @@ func (arena *Arena) Update() { enabled = true } } - arena.FieldReset = false case PausePeriod: auto = false enabled = false @@ -601,7 +611,6 @@ func (arena *Arena) Update() { arena.preLoadNextMatch() }() } - arena.FieldReset = false case TimeoutActive: if matchTimeSec >= float64(game.MatchTiming.TimeoutDurationSec) { arena.MatchState = PostTimeout @@ -636,6 +645,9 @@ func (arena *Arena) Update() { // Handle field sensors/lights/actuators. arena.handlePlcInputOutput() + // Handle the team number / timer displays. + arena.TeamSigns.Update(arena) + arena.LastMatchTimeSec = matchTimeSec arena.lastMatchState = arena.MatchState } @@ -934,6 +946,7 @@ func (arena *Arena) handlePlcInputOutput() { // Turn off lights if all teams become ready. if redAllianceReady && blueAllianceReady { + arena.FieldReset = false arena.Plc.SetFieldResetLight(false) if arena.CurrentMatch.FieldReadyAt.IsZero() { arena.CurrentMatch.FieldReadyAt = time.Now() diff --git a/field/team_sign.go b/field/team_sign.go new file mode 100644 index 00000000..e8bc3dcc --- /dev/null +++ b/field/team_sign.go @@ -0,0 +1,292 @@ +// Copyright 2024 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Models and logic for controlling a Cypress team number / timer sign. + +package field + +import ( + "fmt" + "github.com/Team254/cheesy-arena/game" + "image/color" + "log" + "net" + "strconv" + "strings" +) + +// Represents a collection of team number and timer signs. +type TeamSigns struct { + Red1 TeamSign + Red2 TeamSign + Red3 TeamSign + RedTimer TeamSign + Blue1 TeamSign + Blue2 TeamSign + Blue3 TeamSign + BlueTimer TeamSign +} + +// Represents a team number or timer sign. +type TeamSign struct { + isTimer bool + address byte + frontText string + frontColor color.RGBA + rearText string + lastFrontText string + lastFrontColor color.RGBA + lastRearText string + udpConn net.Conn + packetData [128]byte + packetIndex int +} + +const ( + teamSignPort = 10011 + teamSignPacketMagicString = "CYPRX" + teamSignPacketHeaderLength = 7 + teamSignCommandSetDisplay = 0x04 + teamSignAddressSingle = 0x01 + teamSignPacketTypeFrontText = 0x01 + teamSignPacketTypeRearText = 0x02 + teamSignPacketTypeFrontIntensity = 0x03 + teamSignPacketTypeColor = 0x04 +) + +// Predefined colors for the team sign front text. The "A" channel is used as the intensity. +var redColor = color.RGBA{255, 0, 0, 255} +var blueColor = color.RGBA{0, 0, 255, 255} +var greenColor = color.RGBA{0, 255, 0, 255} +var orangeColor = color.RGBA{255, 165, 0, 255} +var whiteColor = color.RGBA{255, 255, 255, 255} + +// Creates a new collection of team signs. +func NewTeamSigns() *TeamSigns { + signs := new(TeamSigns) + signs.RedTimer.isTimer = true + signs.BlueTimer.isTimer = true + return signs +} + +// Updates the state of all signs with the latest data and sends packets to the signs if anything has changed. +func (signs *TeamSigns) Update(arena *Arena) { + // Generate the countdown string which is used in multiple places. + matchTimeSec := int(arena.MatchTimeSec()) + var countdownSec int + switch arena.MatchState { + case PreMatch: + fallthrough + case StartMatch: + fallthrough + case WarmupPeriod: + countdownSec = game.MatchTiming.AutoDurationSec + case AutoPeriod: + countdownSec = game.MatchTiming.WarmupDurationSec + game.MatchTiming.AutoDurationSec - matchTimeSec + case TeleopPeriod: + countdownSec = game.MatchTiming.WarmupDurationSec + game.MatchTiming.AutoDurationSec + + game.MatchTiming.TeleopDurationSec + game.MatchTiming.PauseDurationSec - matchTimeSec + case TimeoutActive: + countdownSec = game.MatchTiming.TimeoutDurationSec - matchTimeSec + default: + countdownSec = 0 + } + countdown := fmt.Sprintf("%02d:%02d", countdownSec/60, countdownSec%60) + + // Generate the in-match rear text which is common to a whole alliance. + redInMatchRearText := generateInMatchRearText(countdown, arena.RedRealtimeScore, arena.BlueRealtimeScore) + blueInMatchRearText := generateInMatchRearText(countdown, arena.BlueRealtimeScore, arena.RedRealtimeScore) + + signs.Red1.update(arena, arena.AllianceStations["R1"], true, countdown, redInMatchRearText) + signs.Red2.update(arena, arena.AllianceStations["R2"], true, countdown, redInMatchRearText) + signs.Red3.update(arena, arena.AllianceStations["R3"], true, countdown, redInMatchRearText) + signs.RedTimer.update(arena, nil, true, countdown, redInMatchRearText) + signs.Blue1.update(arena, arena.AllianceStations["B1"], false, countdown, blueInMatchRearText) + signs.Blue2.update(arena, arena.AllianceStations["B2"], false, countdown, blueInMatchRearText) + signs.Blue3.update(arena, arena.AllianceStations["B3"], false, countdown, blueInMatchRearText) + signs.BlueTimer.update(arena, nil, false, countdown, blueInMatchRearText) +} + +// Sets the IP address of the sign. +func (sign *TeamSign) SetAddress(ipAddress string) { + if sign.udpConn != nil { + _ = sign.udpConn.Close() + } + if ipAddress == "" { + // The sign is not configured. + sign.address = 0 + } + + var err error + sign.udpConn, err = net.Dial("udp4", fmt.Sprintf("%s:%d", ipAddress, teamSignPort)) + if err != nil { + log.Printf("Failed to connect to team sign at %s: %v", ipAddress, err) + return + } + addressParts := strings.Split(ipAddress, ".") + if len(addressParts) != 4 { + log.Printf("Failed to configure team sign: invalid IP address: %s", ipAddress) + return + } + address, _ := strconv.Atoi(addressParts[3]) + sign.address = byte(address) + + sign.lastFrontText = "dummy value to ensure it gets cleared" + sign.lastFrontColor = color.RGBA{} + sign.lastRearText = "dummy value to ensure it gets cleared" +} + +// Updates the sign's internal state with the latest data and sends packets to the sign if anything has changed. +func (sign *TeamSign) update( + arena *Arena, allianceStation *AllianceStation, isRed bool, countdown, inMatchRearText string, +) { + if sign.address == 0 { + // Don't do anything if there is no sign configured in this position. + return + } + + if sign.isTimer { + sign.frontText, sign.frontColor = generateTimerText(arena.FieldReset, countdown) + sign.rearText = inMatchRearText + } else { + sign.frontText, sign.frontColor, sign.rearText = generateTeamNumberTexts( + arena, allianceStation, isRed, inMatchRearText, + ) + } + + if err := sign.sendPacket(); err != nil { + log.Printf("Failed to send team sign packet: %v", err) + } +} + +// Returns the in-match rear text that is common to a whole alliance. +func generateInMatchRearText(countdown string, realtimeScore, opponentRealtimeScore *RealtimeScore) string { + var amplifiedCountdown string + if realtimeScore.AmplifiedTimeRemainingSec > 0 { + amplifiedCountdown = fmt.Sprintf("Amp:%2d", realtimeScore.AmplifiedTimeRemainingSec) + } + scoreSummary := realtimeScore.CurrentScore.Summarize(&opponentRealtimeScore.CurrentScore) + return fmt.Sprintf( + "%s %02d/%02d %6s", countdown, scoreSummary.NumNotes, scoreSummary.NumNotesGoal, amplifiedCountdown, + ) +} + +// Returns the front text and color to display on the timer display. +func generateTimerText(fieldReset bool, countdown string) (string, color.RGBA) { + var frontText string + var frontColor color.RGBA + if fieldReset { + frontText = "SAFE" + frontColor = greenColor + } else { + frontText = countdown + frontColor = whiteColor + } + return frontText, frontColor +} + +// Returns the front text, front color, and rear text to display on the sign for the given alliance station. +func generateTeamNumberTexts( + arena *Arena, allianceStation *AllianceStation, isRed bool, inMatchRearText string, +) (string, color.RGBA, string) { + if allianceStation.Team == nil { + return "", whiteColor, fmt.Sprintf("%20s", "No Team Assigned") + } + + frontText := fmt.Sprintf("%5d", allianceStation.Team.Id) + + var frontColor color.RGBA + if allianceStation.EStop || allianceStation.AStop && arena.MatchState == AutoPeriod { + frontColor = orangeColor + } else if arena.FieldReset { + frontColor = greenColor + } else if isRed { + frontColor = redColor + } else { + frontColor = blueColor + } + + var message string + if allianceStation.EStop { + message = "E-STOP" + } else if allianceStation.AStop && arena.MatchState == AutoPeriod { + message = "A-STOP" + } else if arena.MatchState == PreMatch { + if allianceStation.Bypass { + message = "Bypassed" + } else if !allianceStation.Ethernet { + message = "Connect PC" + } else if allianceStation.DsConn == nil { + message = "Start DS" + } else if allianceStation.DsConn.WrongStation != "" { + message = "Move Station" + } else if !allianceStation.DsConn.RadioLinked { + message = "No Radio" + } else if !allianceStation.DsConn.RioLinked { + message = "No Rio" + } else if !allianceStation.DsConn.RobotLinked { + message = "No Code" + } else { + message = "Ready" + } + } + + var rearText string + if len(message) > 0 { + rearText = fmt.Sprintf("%-5d %14s", allianceStation.Team.Id, message) + } else { + rearText = inMatchRearText + } + + return frontText, frontColor, rearText +} + +// Sends a UDP packet to the sign if its state has changed. +func (sign *TeamSign) sendPacket() error { + if sign.packetIndex == 0 { + // Write the static packet header the first time this method is invoked. + sign.writePacketData([]byte(teamSignPacketMagicString)) + sign.writePacketData([]byte{sign.address, teamSignCommandSetDisplay}) + } else { + // Reset the write index to just after the header. + sign.packetIndex = teamSignPacketHeaderLength + } + + if sign.frontText != sign.lastFrontText { + sign.writePacketData([]byte{teamSignAddressSingle, sign.address, teamSignPacketTypeFrontText}) + sign.writePacketData([]byte(sign.frontText)) + sign.writePacketData([]byte{0, 0}) // Second byte is "show decimal point". + sign.lastFrontText = sign.frontText + } + + if sign.frontColor != sign.lastFrontColor { + sign.writePacketData([]byte{teamSignAddressSingle, sign.address, teamSignPacketTypeColor}) + sign.writePacketData([]byte{sign.frontColor.R, sign.frontColor.G, sign.frontColor.B}) + sign.writePacketData([]byte{teamSignAddressSingle, sign.address, teamSignPacketTypeFrontIntensity}) + sign.writePacketData([]byte{sign.frontColor.A}) + sign.lastFrontColor = sign.frontColor + } + + if sign.rearText != sign.lastRearText { + sign.writePacketData([]byte{teamSignAddressSingle, sign.address, teamSignPacketTypeRearText}) + sign.writePacketData([]byte(sign.rearText)) + sign.writePacketData([]byte{0}) + sign.lastRearText = sign.rearText + } + + if sign.packetIndex > teamSignPacketHeaderLength { + if _, err := sign.udpConn.Write(sign.packetData[:sign.packetIndex]); err != nil { + return err + } + } + + return nil +} + +// Writes the given data to the packet buffer and advances the write index. +func (sign *TeamSign) writePacketData(data []byte) { + for _, value := range data { + sign.packetData[sign.packetIndex] = value + sign.packetIndex++ + } +} diff --git a/field/team_sign_test.go b/field/team_sign_test.go new file mode 100644 index 00000000..60e8cafd --- /dev/null +++ b/field/team_sign_test.go @@ -0,0 +1,117 @@ +// Copyright 2024 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) + +package field + +import ( + "github.com/Team254/cheesy-arena/game" + "github.com/Team254/cheesy-arena/model" + "github.com/stretchr/testify/assert" + "image/color" + "testing" +) + +func TestTeamSign_GenerateInMatchRearText(t *testing.T) { + realtimeScore1 := &RealtimeScore{AmplifiedTimeRemainingSec: 9} + realtimeScore2 := &RealtimeScore{CurrentScore: game.Score{AmpSpeaker: game.AmpSpeaker{AutoSpeakerNotes: 12}}} + + assert.Equal(t, "01:23 00/18 Amp: 9", generateInMatchRearText("01:23", realtimeScore1, realtimeScore2)) + game.MelodyBonusThresholdWithoutCoop = 23 + assert.Equal(t, "34:56 12/23 ", generateInMatchRearText("34:56", realtimeScore2, realtimeScore1)) +} + +func TestTeamSign_Timer(t *testing.T) { + arena := setupTestArena(t) + sign := TeamSign{isTimer: true} + + // Should do nothing if no address is set. + sign.update(arena, nil, true, "12:34", "Rear Text") + assert.Equal(t, [128]byte{}, sign.packetData) + + // Check some basics about the data but don't unit-test the whole packet. + sign.SetAddress("10.0.100.56") + sign.update(arena, nil, true, "12:34", "Rear Text") + assert.Equal(t, "CYPRX", string(sign.packetData[0:5])) + assert.Equal(t, 56, int(sign.packetData[5])) + assert.Equal(t, 0x04, int(sign.packetData[6])) + assert.Equal(t, "12:34", string(sign.packetData[10:15])) + assert.Equal(t, "Rear Text", string(sign.packetData[30:39])) + assert.Equal(t, 40, sign.packetIndex) + + arena.FieldReset = false + frontText, frontColor := generateTimerText(false, "23:45") + assert.Equal(t, "23:45", frontText) + assert.Equal(t, whiteColor, frontColor) + frontText, frontColor = generateTimerText(true, "23:45") + assert.Equal(t, "SAFE", frontText) + assert.Equal(t, greenColor, frontColor) +} + +func TestTeamSign_TeamNumber(t *testing.T) { + arena := setupTestArena(t) + allianceStation := arena.AllianceStations["R1"] + arena.Database.CreateTeam(&model.Team{Id: 254}) + sign := &TeamSign{isTimer: false} + + // Should do nothing if no address is set. + sign.update(arena, allianceStation, true, "12:34", "Rear Text") + assert.Equal(t, [128]byte{}, sign.packetData) + + // Check some basics about the data but don't unit-test the whole packet. + sign.SetAddress("10.0.100.53") + sign.update(arena, allianceStation, true, "12:34", "Rear Text") + assert.Equal(t, "CYPRX", string(sign.packetData[0:5])) + assert.Equal(t, 53, int(sign.packetData[5])) + assert.Equal(t, 0x04, int(sign.packetData[6])) + assert.Equal(t, []byte{0x01, 53, 0x01, 0, 0}, sign.packetData[7:12]) + assert.Equal(t, "No Team Assigned", string(sign.packetData[29:45])) + assert.Equal(t, 46, sign.packetIndex) + + assertSign := func(isRed bool, expectedFrontText string, expectedFrontColor color.RGBA, expectedRearText string) { + frontText, frontColor, rearText := generateTeamNumberTexts(arena, allianceStation, isRed, "Rear Text") + assert.Equal(t, expectedFrontText, frontText) + assert.Equal(t, expectedFrontColor, frontColor) + assert.Equal(t, expectedRearText, rearText) + } + + assertSign(true, "", whiteColor, " No Team Assigned") + arena.FieldReset = true + arena.assignTeam(254, "R1") + assertSign(true, " 254", greenColor, "254 Connect PC") + assertSign(false, " 254", greenColor, "254 Connect PC") + arena.FieldReset = false + assertSign(true, " 254", redColor, "254 Connect PC") + assertSign(false, " 254", blueColor, "254 Connect PC") + + // Check through pre-match sequence. + allianceStation.Ethernet = true + assertSign(true, " 254", redColor, "254 Start DS") + allianceStation.DsConn = &DriverStationConnection{} + assertSign(true, " 254", redColor, "254 No Radio") + allianceStation.DsConn.WrongStation = "R1" + assertSign(true, " 254", redColor, "254 Move Station") + allianceStation.DsConn.WrongStation = "" + allianceStation.DsConn.RadioLinked = true + assertSign(true, " 254", redColor, "254 No Rio") + allianceStation.DsConn.RioLinked = true + assertSign(true, " 254", redColor, "254 No Code") + allianceStation.DsConn.RobotLinked = true + assertSign(true, " 254", redColor, "254 Ready") + allianceStation.Bypass = true + assertSign(true, " 254", redColor, "254 Bypassed") + + // Check E-stop and A-stop. + arena.MatchState = AutoPeriod + assertSign(true, " 254", redColor, "Rear Text") + allianceStation.AStop = true + assertSign(true, " 254", orangeColor, "254 A-STOP") + allianceStation.EStop = true + assertSign(false, " 254", orangeColor, "254 E-STOP") + allianceStation.EStop = false + arena.MatchState = TeleopPeriod + assertSign(false, " 254", blueColor, "Rear Text") + allianceStation.EStop = true + assertSign(false, " 254", orangeColor, "254 E-STOP") + arena.MatchState = PostMatch + assertSign(false, " 254", orangeColor, "254 E-STOP") +} diff --git a/model/event_settings.go b/model/event_settings.go index 6a32d420..a34aa042 100644 --- a/model/event_settings.go +++ b/model/event_settings.go @@ -35,6 +35,14 @@ type EventSettings struct { SwitchPassword string PlcAddress string AdminPassword string + TeamSignRed1Address string + TeamSignRed2Address string + TeamSignRed3Address string + TeamSignRedTimerAddress string + TeamSignBlue1Address string + TeamSignBlue2Address string + TeamSignBlue3Address string + TeamSignBlueTimerAddress string WarmupDurationSec int AutoDurationSec int PauseDurationSec int diff --git a/templates/setup_settings.html b/templates/setup_settings.html index 8ccaff4a..998c9c41 100644 --- a/templates/setup_settings.html +++ b/templates/setup_settings.html @@ -226,6 +226,62 @@ +
+ Team Signs +

+ If you are using a set of the (2024+) official team number / timer signs, enter their IP addresses here. +

+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
Game-Specific
diff --git a/web/setup_settings.go b/web/setup_settings.go index 4147da1e..07fc3487 100644 --- a/web/setup_settings.go +++ b/web/setup_settings.go @@ -85,6 +85,14 @@ func (web *Web) settingsPostHandler(w http.ResponseWriter, r *http.Request) { eventSettings.SwitchPassword = r.PostFormValue("switchPassword") eventSettings.PlcAddress = r.PostFormValue("plcAddress") eventSettings.AdminPassword = r.PostFormValue("adminPassword") + eventSettings.TeamSignRed1Address = r.PostFormValue("teamSignRed1Address") + eventSettings.TeamSignRed2Address = r.PostFormValue("teamSignRed2Address") + eventSettings.TeamSignRed3Address = r.PostFormValue("teamSignRed3Address") + eventSettings.TeamSignRedTimerAddress = r.PostFormValue("teamSignRedTimerAddress") + eventSettings.TeamSignBlue1Address = r.PostFormValue("teamSignBlue1Address") + eventSettings.TeamSignBlue2Address = r.PostFormValue("teamSignBlue2Address") + eventSettings.TeamSignBlue3Address = r.PostFormValue("teamSignBlue3Address") + eventSettings.TeamSignBlueTimerAddress = r.PostFormValue("teamSignBlueTimerAddress") eventSettings.WarmupDurationSec, _ = strconv.Atoi(r.PostFormValue("warmupDurationSec")) eventSettings.AutoDurationSec, _ = strconv.Atoi(r.PostFormValue("autoDurationSec")) eventSettings.PauseDurationSec, _ = strconv.Atoi(r.PostFormValue("pauseDurationSec"))