Refactor websocket model to reduce duplicated code.

This commit is contained in:
Patrick Fairbank
2018-08-31 22:40:08 -07:00
parent b49a86bdca
commit 27c38f7393
42 changed files with 1190 additions and 1619 deletions

View File

@@ -1 +1 @@
go test -coverprofile=coverage.out && sleep 1 && go tool cover -html=coverage.out
go test -coverprofile=coverage.out ./... && sleep 1 && go tool cover -html=coverage.out

View File

@@ -46,58 +46,37 @@ type Arena struct {
TbaClient *partner.TbaClient
StemTvClient *partner.StemTvClient
AllianceStations map[string]*AllianceStation
CurrentMatch *model.Match
ArenaNotifiers
MatchState
lastMatchState MatchState
MatchStartTime time.Time
LastMatchTimeSec float64
RedRealtimeScore *RealtimeScore
BlueRealtimeScore *RealtimeScore
lastDsPacketTime time.Time
FieldVolunteers bool
FieldReset bool
AudienceDisplayScreen string
SavedMatch *model.Match
SavedMatchResult *model.MatchResult
AllianceStationDisplays map[string]string
AllianceStationDisplayScreen string
MuteMatchSounds bool
matchAborted bool
matchStateNotifier *Notifier
MatchTimeNotifier *Notifier
RobotStatusNotifier *Notifier
MatchLoadTeamsNotifier *Notifier
ScoringStatusNotifier *Notifier
RealtimeScoreNotifier *Notifier
ScorePostedNotifier *Notifier
AudienceDisplayNotifier *Notifier
PlaySoundNotifier *Notifier
AllianceStationDisplayNotifier *Notifier
AllianceSelectionNotifier *Notifier
LowerThirdNotifier *Notifier
ReloadDisplaysNotifier *Notifier
Scale *game.Seesaw
RedSwitch *game.Seesaw
BlueSwitch *game.Seesaw
RedVault *game.Vault
BlueVault *game.Vault
ScaleLeds led.Controller
RedSwitchLeds led.Controller
BlueSwitchLeds led.Controller
RedVaultLeds vaultled.Controller
BlueVaultLeds vaultled.Controller
warmupLedMode led.Mode
lastRedAllianceReady bool
lastBlueAllianceReady bool
}
type ArenaStatus struct {
AllianceStations map[string]*AllianceStation
MatchState
CanStartMatch bool
PlcIsHealthy bool
FieldEstop bool
GameSpecificData string
lastMatchState MatchState
CurrentMatch *model.Match
MatchStartTime time.Time
LastMatchTimeSec float64
RedRealtimeScore *RealtimeScore
BlueRealtimeScore *RealtimeScore
lastDsPacketTime time.Time
FieldVolunteers bool
FieldReset bool
AudienceDisplayMode string
SavedMatch *model.Match
SavedMatchResult *model.MatchResult
AllianceStationDisplays map[string]string
AllianceStationDisplayMode string
MuteMatchSounds bool
matchAborted bool
Scale *game.Seesaw
RedSwitch *game.Seesaw
BlueSwitch *game.Seesaw
RedVault *game.Vault
BlueVault *game.Vault
ScaleLeds led.Controller
RedSwitchLeds led.Controller
BlueSwitchLeds led.Controller
RedVaultLeds vaultled.Controller
BlueVaultLeds vaultled.Controller
warmupLedMode led.Mode
lastRedAllianceReady bool
lastBlueAllianceReady bool
}
type AllianceStation struct {
@@ -130,32 +109,20 @@ func NewArena(dbPath string) (*Arena, error) {
arena.AllianceStations["B2"] = new(AllianceStation)
arena.AllianceStations["B3"] = new(AllianceStation)
arena.matchStateNotifier = NewNotifier()
arena.MatchTimeNotifier = NewNotifier()
arena.RobotStatusNotifier = NewNotifier()
arena.MatchLoadTeamsNotifier = NewNotifier()
arena.ScoringStatusNotifier = NewNotifier()
arena.RealtimeScoreNotifier = NewNotifier()
arena.ScorePostedNotifier = NewNotifier()
arena.AudienceDisplayNotifier = NewNotifier()
arena.PlaySoundNotifier = NewNotifier()
arena.AllianceStationDisplayNotifier = NewNotifier()
arena.AllianceSelectionNotifier = NewNotifier()
arena.LowerThirdNotifier = NewNotifier()
arena.ReloadDisplaysNotifier = NewNotifier()
arena.configureNotifiers()
// Load empty match as current.
arena.MatchState = PreMatch
arena.LoadTestMatch()
arena.lastMatchState = -1
arena.LastMatchTimeSec = 0
arena.lastMatchState = -1
// Initialize display parameters.
arena.AudienceDisplayScreen = "blank"
arena.AudienceDisplayMode = "blank"
arena.SavedMatch = &model.Match{}
arena.SavedMatchResult = model.NewMatchResult()
arena.AllianceStationDisplays = make(map[string]string)
arena.AllianceStationDisplayScreen = "match"
arena.AllianceStationDisplayMode = "match"
return arena, nil
}
@@ -257,10 +224,10 @@ func (arena *Arena) LoadMatch(match *model.Match) error {
arena.BlueSwitchLeds.SetSidedness(true)
// Notify any listeners about the new match.
arena.MatchLoadTeamsNotifier.Notify(nil)
arena.RealtimeScoreNotifier.Notify(nil)
arena.AllianceStationDisplayScreen = "match"
arena.AllianceStationDisplayNotifier.Notify(nil)
arena.MatchLoadNotifier.Notify()
arena.RealtimeScoreNotifier.Notify()
arena.AllianceStationDisplayMode = "match"
arena.AllianceStationDisplayModeNotifier.Notify()
// Set the initial state of the lights.
arena.ScaleLeds.SetMode(led.OffMode, led.OffMode)
@@ -325,7 +292,7 @@ func (arena *Arena) SubstituteTeam(teamId int, station string) error {
arena.CurrentMatch.Blue3 = teamId
}
arena.setupNetwork()
arena.MatchLoadTeamsNotifier.Notify(nil)
arena.MatchLoadNotifier.Notify()
if arena.CurrentMatch.Type != "test" {
arena.Database.SaveMatch(arena.CurrentMatch)
@@ -379,12 +346,14 @@ func (arena *Arena) AbortMatch() error {
return fmt.Errorf("Cannot abort match when it is not in progress.")
}
if !arena.MuteMatchSounds && arena.MatchState != WarmupPeriod {
arena.PlaySoundNotifier.Notify("match-abort")
arena.PlaySoundNotifier.NotifyWithMessage("match-abort")
}
arena.MatchState = PostMatch
arena.matchAborted = true
arena.AudienceDisplayScreen = "blank"
arena.AudienceDisplayNotifier.Notify(nil)
arena.AudienceDisplayMode = "blank"
arena.AudienceDisplayModeNotifier.Notify()
arena.AllianceStationDisplayMode = "logo"
arena.AllianceStationDisplayModeNotifier.Notify()
return nil
}
@@ -432,11 +401,13 @@ func (arena *Arena) Update() {
arena.LastMatchTimeSec = -1
auto = true
enabled = false
arena.AudienceDisplayScreen = "match"
arena.AudienceDisplayNotifier.Notify(nil)
arena.AudienceDisplayMode = "match"
arena.AudienceDisplayModeNotifier.Notify()
arena.AllianceStationDisplayMode = "match"
arena.AllianceStationDisplayModeNotifier.Notify()
arena.sendGameSpecificDataPacket()
if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-warmup")
arena.PlaySoundNotifier.NotifyWithMessage("match-warmup")
}
// Pick an LED warmup mode at random to keep things interesting.
allWarmupModes := []led.Mode{led.WarmupMode, led.Warmup2Mode, led.Warmup3Mode, led.Warmup4Mode}
@@ -450,7 +421,7 @@ func (arena *Arena) Update() {
enabled = true
sendDsPacket = true
if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-start")
arena.PlaySoundNotifier.NotifyWithMessage("match-start")
}
}
case AutoPeriod:
@@ -462,7 +433,7 @@ func (arena *Arena) Update() {
enabled = false
sendDsPacket = true
if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-end")
arena.PlaySoundNotifier.NotifyWithMessage("match-end")
}
}
case PausePeriod:
@@ -475,7 +446,7 @@ func (arena *Arena) Update() {
enabled = true
sendDsPacket = true
if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-resume")
arena.PlaySoundNotifier.NotifyWithMessage("match-resume")
}
}
case TeleopPeriod:
@@ -486,7 +457,7 @@ func (arena *Arena) Update() {
arena.MatchState = EndgamePeriod
sendDsPacket = false
if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-endgame")
arena.PlaySoundNotifier.NotifyWithMessage("match-endgame")
}
}
case EndgamePeriod:
@@ -501,38 +472,32 @@ func (arena *Arena) Update() {
go func() {
// Leave the scores on the screen briefly at the end of the match.
time.Sleep(time.Second * matchEndScoreDwellSec)
arena.AudienceDisplayScreen = "blank"
arena.AudienceDisplayNotifier.Notify(nil)
arena.AllianceStationDisplayScreen = "logo"
arena.AllianceStationDisplayNotifier.Notify(nil)
arena.AudienceDisplayMode = "blank"
arena.AudienceDisplayModeNotifier.Notify()
arena.AllianceStationDisplayMode = "logo"
arena.AllianceStationDisplayModeNotifier.Notify()
}()
if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-end")
arena.PlaySoundNotifier.NotifyWithMessage("match-end")
}
}
}
// Send a notification if the match state has changed.
if arena.MatchState != arena.lastMatchState {
arena.matchStateNotifier.Notify(arena.MatchState)
}
arena.lastMatchState = arena.MatchState
// Send a match tick notification if passing an integer second threshold.
if int(matchTimeSec) != int(arena.LastMatchTimeSec) {
arena.MatchTimeNotifier.Notify(int(matchTimeSec))
// Send a match tick notification if passing an integer second threshold or if the match state changed.
if int(matchTimeSec) != int(arena.LastMatchTimeSec) || arena.MatchState != arena.lastMatchState {
arena.MatchTimeNotifier.Notify()
}
arena.LastMatchTimeSec = matchTimeSec
arena.lastMatchState = arena.MatchState
// Send a packet if at a period transition point or if it's been long enough since the last one.
if sendDsPacket || time.Since(arena.lastDsPacketTime).Seconds()*1000 >= dsPacketPeriodMs {
arena.sendDsPacket(auto, enabled)
arena.RobotStatusNotifier.Notify(nil)
arena.ArenaStatusNotifier.Notify()
}
// Handle field sensors/lights/motors.
arena.handlePlcInput()
arena.handlePlcOutput()
arena.handleLeds()
}
@@ -560,11 +525,6 @@ func (arena *Arena) BlueScoreSummary() *game.ScoreSummary {
return arena.BlueRealtimeScore.CurrentScore.Summarize(arena.RedRealtimeScore.CurrentScore.Fouls)
}
func (arena *Arena) GetStatus() *ArenaStatus {
return &ArenaStatus{arena.AllianceStations, arena.MatchState, arena.checkCanStartMatch() == nil,
arena.Plc.IsHealthy, arena.Plc.GetFieldEstop(), arena.CurrentMatch.GameSpecificData}
}
// 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.
@@ -766,23 +726,18 @@ func (arena *Arena) handlePlcInput() {
// Check if a power up has been newly played and trigger the accompanying sound effect if so.
newRedPowerUp := arena.RedVault.CheckForNewlyPlayedPowerUp()
if newRedPowerUp != "" && !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-" + newRedPowerUp)
arena.PlaySoundNotifier.NotifyWithMessage("match-" + newRedPowerUp)
}
newBluePowerUp := arena.BlueVault.CheckForNewlyPlayedPowerUp()
if newBluePowerUp != "" && !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-" + newBluePowerUp)
arena.PlaySoundNotifier.NotifyWithMessage("match-" + newBluePowerUp)
}
if !oldRedScore.Equals(redScore) || !oldBlueScore.Equals(blueScore) || ownershipChanged {
arena.RealtimeScoreNotifier.Notify(nil)
arena.RealtimeScoreNotifier.Notify()
}
}
// Writes light/motor commands to the field PLC.
func (arena *Arena) handlePlcOutput() {
// TODO(patrick): Update for 2018.
}
func (arena *Arena) handleLeds() {
switch arena.MatchState {
case PreMatch:

184
field/arena_notifiers.go Normal file
View File

@@ -0,0 +1,184 @@
// Copyright 2018 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Contains configuration of the publish-subscribe notifiers that allow the arena to push updates to websocket clients.
package field
import (
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/websocket"
"strconv"
"time"
)
type ArenaNotifiers struct {
AllianceSelectionNotifier *websocket.Notifier
AllianceStationDisplayModeNotifier *websocket.Notifier
ArenaStatusNotifier *websocket.Notifier
AudienceDisplayModeNotifier *websocket.Notifier
LowerThirdNotifier *websocket.Notifier
MatchLoadNotifier *websocket.Notifier
MatchTimeNotifier *websocket.Notifier
PlaySoundNotifier *websocket.Notifier
RealtimeScoreNotifier *websocket.Notifier
ReloadDisplaysNotifier *websocket.Notifier
ScorePostedNotifier *websocket.Notifier
ScoringStatusNotifier *websocket.Notifier
}
type MatchTimeMessage struct {
MatchState int
MatchTimeSec int
}
type audienceAllianceScoreFields struct {
Score int
RealtimeScore *RealtimeScore
ForceState game.PowerUpState
LevitateState game.PowerUpState
BoostState game.PowerUpState
SwitchOwnedBy game.Alliance
}
// Instantiates notifiers and configures their message producing methods.
func (arena *Arena) configureNotifiers() {
arena.AllianceSelectionNotifier = websocket.NewNotifier("allianceSelection", nil)
arena.AllianceStationDisplayModeNotifier = websocket.NewNotifier("allianceStationDisplayMode",
arena.generateAllianceStationDisplayModeMessage)
arena.ArenaStatusNotifier = websocket.NewNotifier("arenaStatus", arena.generateArenaStatusMessage)
arena.AudienceDisplayModeNotifier = websocket.NewNotifier("audienceDisplayMode",
arena.generateAudienceDisplayModeMessage)
arena.LowerThirdNotifier = websocket.NewNotifier("lowerThird", nil)
arena.MatchLoadNotifier = websocket.NewNotifier("matchLoad", arena.generateMatchLoadMessage)
arena.MatchTimeNotifier = websocket.NewNotifier("matchTime", arena.generateMatchTimeMessage)
arena.PlaySoundNotifier = websocket.NewNotifier("playSound", nil)
arena.RealtimeScoreNotifier = websocket.NewNotifier("realtimeScore", arena.generateRealtimeScoreMessage)
arena.ReloadDisplaysNotifier = websocket.NewNotifier("reload", nil)
arena.ScorePostedNotifier = websocket.NewNotifier("scorePosted", arena.generateScorePostedMessage)
arena.ScoringStatusNotifier = websocket.NewNotifier("scoringStatus", arena.generateScoringStatusMessage)
}
func (arena *Arena) generateAllianceStationDisplayModeMessage() interface{} {
return arena.AllianceStationDisplayMode
}
func (arena *Arena) generateArenaStatusMessage() interface{} {
return &struct {
AllianceStations map[string]*AllianceStation
MatchState
CanStartMatch bool
PlcIsHealthy bool
FieldEstop bool
GameSpecificData string
}{arena.AllianceStations, arena.MatchState, arena.checkCanStartMatch() == nil, arena.Plc.IsHealthy,
arena.Plc.GetFieldEstop(), arena.CurrentMatch.GameSpecificData}
}
func (arena *Arena) generateAudienceDisplayModeMessage() interface{} {
return arena.AudienceDisplayMode
}
func (arena *Arena) generateMatchLoadMessage() interface{} {
teams := make(map[string]*model.Team)
for station, allianceStation := range arena.AllianceStations {
teams[station] = allianceStation.Team
}
rankings := make(map[string]*game.Ranking)
for _, allianceStation := range arena.AllianceStations {
if allianceStation.Team != nil {
rankings[strconv.Itoa(allianceStation.Team.Id)], _ =
arena.Database.GetRankingForTeam(allianceStation.Team.Id)
}
}
return &struct {
MatchType string
Match *model.Match
Teams map[string]*model.Team
Rankings map[string]*game.Ranking
}{arena.CurrentMatch.CapitalizedType(), arena.CurrentMatch, teams, rankings}
}
func (arena *Arena) generateMatchTimeMessage() interface{} {
return MatchTimeMessage{int(arena.MatchState), int(arena.MatchTimeSec())}
}
func (arena *Arena) generateRealtimeScoreMessage() interface{} {
fields := struct {
Red *audienceAllianceScoreFields
Blue *audienceAllianceScoreFields
ScaleOwnedBy game.Alliance
}{}
fields.Red = getAudienceAllianceScoreFields(arena.RedRealtimeScore, arena.RedScoreSummary(),
arena.RedVault, arena.RedSwitch)
fields.Blue = getAudienceAllianceScoreFields(arena.BlueRealtimeScore, arena.BlueScoreSummary(),
arena.BlueVault, arena.BlueSwitch)
fields.ScaleOwnedBy = arena.Scale.GetOwnedBy()
return &fields
}
func (arena *Arena) generateScorePostedMessage() interface{} {
return &struct {
MatchType string
Match *model.Match
RedScoreSummary *game.ScoreSummary
BlueScoreSummary *game.ScoreSummary
RedFouls []game.Foul
BlueFouls []game.Foul
RedCards map[string]string
BlueCards map[string]string
}{arena.SavedMatch.CapitalizedType(), arena.SavedMatch, arena.SavedMatchResult.RedScoreSummary(),
arena.SavedMatchResult.BlueScoreSummary(), populateFoulDescriptions(arena.SavedMatchResult.RedScore.Fouls),
populateFoulDescriptions(arena.SavedMatchResult.BlueScore.Fouls), arena.SavedMatchResult.RedCards,
arena.SavedMatchResult.BlueCards}
}
func (arena *Arena) generateScoringStatusMessage() interface{} {
return &struct {
RefereeScoreReady bool
RedScoreReady bool
BlueScoreReady bool
}{arena.RedRealtimeScore.FoulsCommitted && arena.BlueRealtimeScore.FoulsCommitted,
arena.RedRealtimeScore.TeleopCommitted, arena.BlueRealtimeScore.TeleopCommitted}
}
// Constructs the data object for one alliance sent to the audience display for the realtime scoring overlay.
func getAudienceAllianceScoreFields(allianceScore *RealtimeScore, allianceScoreSummary *game.ScoreSummary,
allianceVault *game.Vault, allianceSwitch *game.Seesaw) *audienceAllianceScoreFields {
fields := new(audienceAllianceScoreFields)
fields.RealtimeScore = allianceScore
fields.Score = allianceScoreSummary.Score
if allianceVault.ForcePowerUp != nil {
fields.ForceState = allianceVault.ForcePowerUp.GetState(time.Now())
} else {
fields.ForceState = game.Unplayed
}
if allianceVault.LevitatePlayed {
fields.LevitateState = game.Expired
} else {
fields.LevitateState = game.Unplayed
}
if allianceVault.BoostPowerUp != nil {
fields.BoostState = allianceVault.BoostPowerUp.GetState(time.Now())
} else {
fields.BoostState = game.Unplayed
}
fields.SwitchOwnedBy = allianceSwitch.GetOwnedBy()
return fields
}
// Copy the description from the rules to the fouls so that they are available to the announcer.
func populateFoulDescriptions(fouls []game.Foul) []game.Foul {
for i := range fouls {
for _, rule := range game.Rules {
if fouls[i].RuleNumber == rule.RuleNumber {
fouls[i].Description = rule.Description
break
}
}
}
return fouls
}

View File

@@ -1,57 +0,0 @@
// Copyright 2014 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Publish-subscribe model for nonblocking notification of server events to websocket clients.
package field
import (
"log"
)
// Allow the listeners to buffer a small number of notifications to streamline delivery.
const notifyBufferSize = 3
type Notifier struct {
// The map is essentially a set; the value is ignored.
listeners map[chan interface{}]struct{}
}
func NewNotifier() *Notifier {
notifier := new(Notifier)
notifier.listeners = make(map[chan interface{}]struct{})
return notifier
}
// Registers and returns a channel that can be read from to receive notification messages. The caller is
// responsible for closing the channel, which will cause it to be reaped from the list of listeners.
func (notifier *Notifier) Listen() chan interface{} {
listener := make(chan interface{}, notifyBufferSize)
notifier.listeners[listener] = struct{}{}
return listener
}
// Sends the given message to all registered listeners, and cleans up any listeners that have closed.
func (notifier *Notifier) Notify(message interface{}) {
for listener, _ := range notifier.listeners {
notifier.notifyListener(listener, message)
}
}
func (notifier *Notifier) notifyListener(listener chan interface{}, message interface{}) {
defer func() {
// If channel is closed sending to it will cause a panic; recover and remove it from the list.
if r := recover(); r != nil {
delete(notifier.listeners, listener)
}
}()
// Do a non-blocking send. This guarantees that sending notifications won't interrupt the main event loop,
// at the risk of clients missing some messages if they don't read them all promptly.
select {
case listener <- message:
// The notification was sent and received successfully.
default:
log.Println("Failed to send a notification due to blocked listener.")
}
}

View File

@@ -1,84 +0,0 @@
// Copyright 2014 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
package field
import (
"github.com/stretchr/testify/assert"
"io/ioutil"
"log"
"testing"
)
func TestNotifier(t *testing.T) {
notifier := NewNotifier()
// Should do nothing when there are no listeners.
notifier.Notify("test message")
notifier.Notify(12345)
notifier.Notify(struct{}{})
listener := notifier.Listen()
notifier.Notify("test message")
assert.Equal(t, "test message", <-listener)
notifier.Notify(12345)
assert.Equal(t, 12345, <-listener)
// Should allow multiple messages without blocking.
notifier.Notify("message1")
notifier.Notify("message2")
notifier.Notify("message3")
assert.Equal(t, "message1", <-listener)
assert.Equal(t, "message2", <-listener)
assert.Equal(t, "message3", <-listener)
// Should stop sending messages and not block once the buffer is full.
log.SetOutput(ioutil.Discard) // Silence noisy log output.
for i := 0; i < 20; i++ {
notifier.Notify(i)
}
var value interface{}
var lastValue interface{}
for lastValue == nil {
select {
case value = <-listener:
default:
lastValue = value
return
}
}
notifier.Notify("next message")
assert.True(t, lastValue.(int) < 10)
assert.Equal(t, "next message", <-listener)
}
func TestNotifyMultipleListeners(t *testing.T) {
notifier := NewNotifier()
listeners := [50]chan interface{}{}
for i := 0; i < len(listeners); i++ {
listeners[i] = notifier.Listen()
}
notifier.Notify("test message")
notifier.Notify(12345)
for listener, _ := range notifier.listeners {
assert.Equal(t, "test message", <-listener)
assert.Equal(t, 12345, <-listener)
}
// Should reap closed channels automatically.
close(listeners[4])
notifier.Notify("message1")
assert.Equal(t, 49, len(notifier.listeners))
for listener, _ := range notifier.listeners {
assert.Equal(t, "message1", <-listener)
}
close(listeners[16])
close(listeners[21])
close(listeners[49])
notifier.Notify("message2")
assert.Equal(t, 46, len(notifier.listeners))
for listener, _ := range notifier.listeners {
assert.Equal(t, "message2", <-listener)
}
}

View File

@@ -7,6 +7,7 @@ package field
import (
"fmt"
"github.com/Team254/cheesy-arena/websocket"
"github.com/goburrow/modbus"
"log"
"time"
@@ -14,16 +15,16 @@ import (
type Plc struct {
IsHealthy bool
IoChangeNotifier *websocket.Notifier
address string
handler *modbus.TCPClientHandler
client modbus.Client
Inputs [inputCount]bool
Registers [registerCount]uint16
Coils [coilCount]bool
inputs [inputCount]bool
registers [registerCount]uint16
coils [coilCount]bool
oldInputs [inputCount]bool
oldRegisters [registerCount]uint16
oldCoils [coilCount]bool
IoChangeNotifier *Notifier
cycleCounter int
}
@@ -112,7 +113,8 @@ func (plc *Plc) SetAddress(address string) {
// Loops indefinitely to read inputs from and write outputs to PLC.
func (plc *Plc) Run() {
plc.IoChangeNotifier = NewNotifier()
// Register a notifier that listeners can subscribe to to get websocket updates about I/O value changes.
plc.IoChangeNotifier = websocket.NewNotifier("plcIoChange", plc.generateIoChangeMessage)
for {
if plc.handler == nil {
@@ -146,11 +148,11 @@ func (plc *Plc) Run() {
}
// Detect any changes in input or output and notify listeners if so.
if plc.Inputs != plc.oldInputs || plc.Registers != plc.oldRegisters || plc.Coils != plc.oldCoils {
plc.IoChangeNotifier.Notify(nil)
plc.oldInputs = plc.Inputs
plc.oldRegisters = plc.Registers
plc.oldCoils = plc.Coils
if plc.inputs != plc.oldInputs || plc.registers != plc.oldRegisters || plc.coils != plc.oldCoils {
plc.IoChangeNotifier.Notify()
plc.oldInputs = plc.inputs
plc.oldRegisters = plc.registers
plc.oldCoils = plc.coils
}
time.Sleep(time.Until(startTime.Add(time.Millisecond * plcLoopPeriodMs)))
@@ -159,19 +161,19 @@ func (plc *Plc) Run() {
// Returns the state of the field emergency stop button (true if e-stop is active).
func (plc *Plc) GetFieldEstop() bool {
return plc.address != "" && !plc.Inputs[fieldEstop]
return plc.address != "" && !plc.inputs[fieldEstop]
}
// Returns the state of the red and blue driver station emergency stop buttons (true if e-stop is active).
func (plc *Plc) GetTeamEstops() ([3]bool, [3]bool) {
var redEstops, blueEstops [3]bool
if plc.address != "" {
redEstops[0] = !plc.Inputs[redEstop1]
redEstops[1] = !plc.Inputs[redEstop2]
redEstops[2] = !plc.Inputs[redEstop3]
blueEstops[0] = !plc.Inputs[blueEstop1]
blueEstops[1] = !plc.Inputs[blueEstop2]
blueEstops[2] = !plc.Inputs[blueEstop3]
redEstops[0] = !plc.inputs[redEstop1]
redEstops[1] = !plc.inputs[redEstop2]
redEstops[2] = !plc.inputs[redEstop3]
blueEstops[0] = !plc.inputs[blueEstop1]
blueEstops[1] = !plc.inputs[blueEstop2]
blueEstops[2] = !plc.inputs[blueEstop3]
}
return redEstops, blueEstops
}
@@ -180,38 +182,38 @@ func (plc *Plc) GetTeamEstops() ([3]bool, [3]bool) {
func (plc *Plc) GetScaleAndSwitches() ([2]bool, [2]bool, [2]bool) {
var scale, redSwitch, blueSwitch [2]bool
scale[0] = plc.Inputs[scaleNear]
scale[1] = plc.Inputs[scaleFar]
redSwitch[0] = plc.Inputs[redSwitchNear]
redSwitch[1] = plc.Inputs[redSwitchFar]
blueSwitch[0] = plc.Inputs[blueSwitchNear]
blueSwitch[1] = plc.Inputs[blueSwitchFar]
scale[0] = plc.inputs[scaleNear]
scale[1] = plc.inputs[scaleFar]
redSwitch[0] = plc.inputs[redSwitchNear]
redSwitch[1] = plc.inputs[redSwitchFar]
blueSwitch[0] = plc.inputs[blueSwitchNear]
blueSwitch[1] = plc.inputs[blueSwitchFar]
return scale, redSwitch, blueSwitch
}
// Returns the state of the red and blue vault power cube sensors.
func (plc *Plc) GetVaults() (uint16, uint16, uint16, uint16, uint16, uint16) {
return plc.Registers[redForceDistance], plc.Registers[redLevitateDistance], plc.Registers[redBoostDistance],
plc.Registers[blueForceDistance], plc.Registers[blueLevitateDistance], plc.Registers[blueBoostDistance]
return plc.registers[redForceDistance], plc.registers[redLevitateDistance], plc.registers[redBoostDistance],
plc.registers[blueForceDistance], plc.registers[blueLevitateDistance], plc.registers[blueBoostDistance]
}
// Returns the state of the red and blue power up buttons on the vaults.
func (plc *Plc) GetPowerUpButtons() (bool, bool, bool, bool, bool, bool) {
return plc.Inputs[redForceActivate], plc.Inputs[redLevitateActivate], plc.Inputs[redBoostActivate],
plc.Inputs[blueForceActivate], plc.Inputs[blueLevitateActivate], plc.Inputs[blueBoostActivate]
return plc.inputs[redForceActivate], plc.inputs[redLevitateActivate], plc.inputs[redBoostActivate],
plc.inputs[blueForceActivate], plc.inputs[blueLevitateActivate], plc.inputs[blueBoostActivate]
}
// Set the on/off state of the stack lights on the scoring table.
func (plc *Plc) SetStackLights(red, blue, green bool) {
plc.Coils[stackLightRed] = red
plc.Coils[stackLightBlue] = blue
plc.Coils[stackLightGreen] = green
plc.coils[stackLightRed] = red
plc.coils[stackLightBlue] = blue
plc.coils[stackLightGreen] = green
}
// Set the on/off state of the stack lights on the scoring table.
func (plc *Plc) SetStackBuzzer(state bool) {
plc.Coils[stackLightBuzzer] = state
plc.coils[stackLightBuzzer] = state
}
func (plc *Plc) GetCycleState(max, index, duration int) bool {
@@ -220,7 +222,7 @@ func (plc *Plc) GetCycleState(max, index, duration int) bool {
func (plc *Plc) GetInputNames() []string {
inputNames := make([]string, inputCount)
for i := range plc.Inputs {
for i := range plc.inputs {
inputNames[i] = input(i).String()
}
return inputNames
@@ -228,7 +230,7 @@ func (plc *Plc) GetInputNames() []string {
func (plc *Plc) GetRegisterNames() []string {
registerNames := make([]string, registerCount)
for i := range plc.Registers {
for i := range plc.registers {
registerNames[i] = register(i).String()
}
return registerNames
@@ -236,7 +238,7 @@ func (plc *Plc) GetRegisterNames() []string {
func (plc *Plc) GetCoilNames() []string {
coilNames := make([]string, coilCount)
for i := range plc.Coils {
for i := range plc.coils {
coilNames[i] = coil(i).String()
}
return coilNames
@@ -267,50 +269,50 @@ func (plc *Plc) resetConnection() {
}
func (plc *Plc) readInputs() bool {
if len(plc.Inputs) == 0 {
if len(plc.inputs) == 0 {
return true
}
inputs, err := plc.client.ReadDiscreteInputs(0, uint16(len(plc.Inputs)))
inputs, err := plc.client.ReadDiscreteInputs(0, uint16(len(plc.inputs)))
if err != nil {
log.Printf("PLC error reading inputs: %v", err)
return false
}
if len(inputs)*8 < len(plc.Inputs) {
log.Printf("Insufficient length of PLC inputs: got %d bytes, expected %d bits.", len(inputs), len(plc.Inputs))
if len(inputs)*8 < len(plc.inputs) {
log.Printf("Insufficient length of PLC inputs: got %d bytes, expected %d bits.", len(inputs), len(plc.inputs))
return false
}
copy(plc.Inputs[:], byteToBool(inputs, len(plc.Inputs)))
copy(plc.inputs[:], byteToBool(inputs, len(plc.inputs)))
return true
}
func (plc *Plc) readCounters() bool {
if len(plc.Registers) == 0 {
if len(plc.registers) == 0 {
return true
}
registers, err := plc.client.ReadHoldingRegisters(0, uint16(len(plc.Registers)))
registers, err := plc.client.ReadHoldingRegisters(0, uint16(len(plc.registers)))
if err != nil {
log.Printf("PLC error reading registers: %v", err)
return false
}
if len(registers)/2 < len(plc.Registers) {
if len(registers)/2 < len(plc.registers) {
log.Printf("Insufficient length of PLC counters: got %d bytes, expected %d words.", len(registers),
len(plc.Registers))
len(plc.registers))
return false
}
copy(plc.Registers[:], byteToUint(registers, len(plc.Registers)))
copy(plc.registers[:], byteToUint(registers, len(plc.registers)))
return true
}
func (plc *Plc) writeCoils() bool {
// Send a heartbeat to the PLC so that it can disable outputs if the connection is lost.
plc.Coils[heartbeat] = true
plc.coils[heartbeat] = true
coils := boolToByte(plc.Coils[:])
_, err := plc.client.WriteMultipleCoils(0, uint16(len(plc.Coils)), coils)
coils := boolToByte(plc.coils[:])
_, err := plc.client.WriteMultipleCoils(0, uint16(len(plc.coils)), coils)
if err != nil {
log.Printf("PLC error writing coils: %v", err)
return false
@@ -319,6 +321,14 @@ func (plc *Plc) writeCoils() bool {
return true
}
func (plc *Plc) generateIoChangeMessage() interface{} {
return &struct {
Inputs []bool
Registers []uint16
Coils []bool
}{plc.inputs[:], plc.registers[:], plc.coils[:]}
}
func byteToBool(bytes []byte, size int) []bool {
bools := make([]bool, size)
for i := 0; i < size; i++ {

View File

@@ -10,6 +10,7 @@ import "github.com/Team254/cheesy-arena/game"
type RealtimeScore struct {
CurrentScore game.Score
Cards map[string]string
AutoCommitted bool
TeleopCommitted bool
FoulsCommitted bool
}

View File

@@ -83,8 +83,8 @@ body[data-mode=fieldReset] .mode#fieldReset {
width: 100%;
height: 100%;
}
#match[data-state=AUTO_PERIOD], #match[data-state=PAUSE_PERIOD], #match[data-state=TELEOP_PERIOD],
#match[data-state=ENDGAME_PERIOD], #match[data-state=POST_MATCH] {
#match[data-state=WARMUP_PERIOD], #match[data-state=AUTO_PERIOD], #match[data-state=PAUSE_PERIOD],
#match[data-state=TELEOP_PERIOD], #match[data-state=ENDGAME_PERIOD], #match[data-state=POST_MATCH] {
background-color: #fff;
color: #000;
}
@@ -94,9 +94,9 @@ body[data-mode=fieldReset] .mode#fieldReset {
#match[data-state=PRE_MATCH] #preMatch {
display: block;
}
#match[data-state=AUTO_PERIOD] #inMatch, #match[data-state=PAUSE_PERIOD] #inMatch,
#match[data-state=TELEOP_PERIOD] #inMatch, #match[data-state=ENDGAME_PERIOD] #inMatch,
#match[data-state=POST_MATCH] #inMatch {
#match[data-state=WARMUP_PERIOD] #inMatch, #match[data-state=AUTO_PERIOD] #inMatch,
#match[data-state=PAUSE_PERIOD] #inMatch, #match[data-state=TELEOP_PERIOD] #inMatch,
#match[data-state=ENDGAME_PERIOD] #inMatch, #match[data-state=POST_MATCH] #inMatch {
display: block;
}

View File

@@ -8,12 +8,17 @@ var blinkInterval;
var currentScreen = "blank";
var websocket;
// Handles a websocket message to set which alliance station this display represents.
var handleAllianceStation = function(station) {
allianceStation = station;
}
// Handles a websocket message to change which screen is displayed.
var handleSetAllianceStationDisplay = function(targetScreen) {
var handleAllianceStationDisplayMode = function(targetScreen) {
currentScreen = targetScreen;
if (allianceStation == "") {
// Don't show anything if this screen hasn't been assigned a position yet.
targetScreen = "blank";
if (allianceStation === "") {
// Don't do anything if this screen hasn't been assigned a position yet.
return;
}
$("body").attr("data-mode", targetScreen);
switch (allianceStation[1]) {
@@ -30,16 +35,7 @@ var handleSetAllianceStationDisplay = function(targetScreen) {
};
// Handles a websocket message to update the team to display.
var handleSetMatch = function(data) {
if (allianceStation != "" && data.AllianceStation == "") {
// The client knows better what display this should be; let the server know.
websocket.send("setAllianceStation", allianceStation);
} else if (allianceStation != data.AllianceStation) {
// The server knows better what display this should be; sync up.
allianceStation = data.AllianceStation;
handleSetAllianceStationDisplay(currentScreen);
}
var handleMatchLoad = function(data) {
if (allianceStation != "") {
var team = data.Teams[allianceStation];
if (team) {
@@ -47,7 +43,7 @@ var handleSetMatch = function(data) {
$("#teamNameText").attr("data-alliance-bg", allianceStation[0]).text(team.Nickname);
var ranking = data.Rankings[team.Id];
if (ranking && data.MatchType == "qualification") {
if (ranking && data.MatchType == "Qualification") {
var rankingText = ranking.Rank;
$("#teamRank").attr("data-alliance-bg", allianceStation[0]).text(rankingText);
} else {
@@ -64,7 +60,7 @@ var handleSetMatch = function(data) {
};
// Handles a websocket message to update the team connection status.
var handleStatus = function(data) {
var handleArenaStatus = function(data) {
stationStatus = data.AllianceStations[allianceStation];
var blink = false;
if (stationStatus && stationStatus.Bypass) {
@@ -106,8 +102,8 @@ var handleMatchTime = function(data) {
// Handles a websocket message to update the match score.
var handleRealtimeScore = function(data) {
$("#redScore").text(data.RedScore);
$("#blueScore").text(data.BlueScore);
$("#redScore").text(data.Red.Score);
$("#blueScore").text(data.Blue.Score);
};
$(function() {
@@ -119,11 +115,12 @@ $(function() {
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/alliance_station/websocket?displayId=" + displayId, {
setAllianceStationDisplay: function(event) { handleSetAllianceStationDisplay(event.data); },
setMatch: function(event) { handleSetMatch(event.data); },
status: function(event) { handleStatus(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
allianceStation: function(event) { handleAllianceStation(event.data); },
allianceStationDisplayMode: function(event) { handleAllianceStationDisplayMode(event.data); },
arenaStatus: function(event) { handleArenaStatus(event.data); },
matchLoad: function(event) { handleMatchLoad(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); }
});
});

View File

@@ -16,7 +16,7 @@ Handlebars.registerHelper("eachMapEntry", function(context, options) {
});
// Handles a websocket message to hide the score dialog once the next match is being introduced.
var handleSetAudienceDisplay = function(targetScreen) {
var handleAudienceDisplayMode = function(targetScreen) {
// Hide the final results so that they aren't blocking the current teams when the announcer needs them most.
if (targetScreen == "intro" || targetScreen == "match") {
$("#matchResult").modal("hide");
@@ -24,14 +24,14 @@ var handleSetAudienceDisplay = function(targetScreen) {
};
// Handles a websocket message to update the teams for the current match.
var handleSetMatch = function(data) {
$("#matchName").text(data.MatchType + " Match " + data.MatchDisplayName);
$("#red1").html(teamTemplate(formatTeam(data.Red1)));
$("#red2").html(teamTemplate(formatTeam(data.Red2)));
$("#red3").html(teamTemplate(formatTeam(data.Red3)));
$("#blue1").html(teamTemplate(formatTeam(data.Blue1)));
$("#blue2").html(teamTemplate(formatTeam(data.Blue2)));
$("#blue3").html(teamTemplate(formatTeam(data.Blue3)));
var handleMatchLoad = function(data) {
$("#matchName").text(data.MatchType + " Match " + data.Match.DisplayName);
$("#red1").html(teamTemplate(formatTeam(data.Teams["R1"])));
$("#red2").html(teamTemplate(formatTeam(data.Teams["R2"])));
$("#red3").html(teamTemplate(formatTeam(data.Teams["R3"])));
$("#blue1").html(teamTemplate(formatTeam(data.Teams["B1"])));
$("#blue2").html(teamTemplate(formatTeam(data.Teams["B2"])));
$("#blue3").html(teamTemplate(formatTeam(data.Teams["B3"])));
};
// Handles a websocket message to update the match time countdown.
@@ -44,13 +44,13 @@ var handleMatchTime = function(data) {
// Handles a websocket message to update the match score.
var handleRealtimeScore = function(data) {
$("#redScore").text(data.RedScore);
$("#blueScore").text(data.BlueScore);
$("#redScore").text(data.Red.Score);
$("#blueScore").text(data.Blue.Score);
};
// Handles a websocket message to populate the final score data.
var handleSetFinalScore = function(data) {
$("#scoreMatchName").text(data.MatchType + " Match " + data.MatchDisplayName);
var handleScorePosted = function(data) {
$("#scoreMatchName").text(data.MatchType + " Match " + data.Match.DisplayName);
$("#redScoreDetails").html(matchResultTemplate({score: data.RedScoreSummary, fouls: data.RedFouls,
cards: data.RedCards}));
$("#blueScoreDetails").html(matchResultTemplate({score: data.BlueScoreSummary, fouls: data.BlueFouls,
@@ -61,11 +61,6 @@ var handleSetFinalScore = function(data) {
$("[data-toggle=tooltip]").tooltip({"placement": "top"});
};
var postMatchResult = function(data) {
$("#savedMatchResult").attr("data-blink", false);
websocket.send("setAudienceDisplay", "score");
}
// Replaces newlines in team fields with HTML line breaks.
var formatTeam = function(team) {
if (team) {
@@ -77,12 +72,12 @@ var formatTeam = function(team) {
$(function() {
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/announcer/websocket", {
setMatch: function(event) { handleSetMatch(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
audienceDisplayMode: function(event) { handleAudienceDisplayMode(event.data); },
matchLoad: function(event) { handleMatchLoad(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); },
setFinalScore: function(event) { handleSetFinalScore(event.data); },
setAudienceDisplay: function(event) { handleSetAudienceDisplay(event.data); }
scorePosted: function(event) { handleScorePosted(event.data); }
});
// Make the score blink.

View File

@@ -10,7 +10,7 @@ var currentScreen = "blank";
var allianceSelectionTemplate = Handlebars.compile($("#allianceSelectionTemplate").html());
// Handles a websocket message to change which screen is displayed.
var handleSetAudienceDisplay = function(targetScreen) {
var handleAudienceDisplayMode = function(targetScreen) {
if (targetScreen == currentScreen) {
return;
}
@@ -28,7 +28,7 @@ var handleSetAudienceDisplay = function(targetScreen) {
};
// Handles a websocket message to update the teams for the current match.
var handleSetMatch = function(data) {
var handleMatchLoad = function(data) {
$("#redTeam1").text(data.Match.Red1)
$("#redTeam2").text(data.Match.Red2)
$("#redTeam3").text(data.Match.Red3)
@@ -52,21 +52,23 @@ var handleMatchTime = function(data) {
// Handles a websocket message to update the match score.
var handleRealtimeScore = function(data) {
var redScoreBreakdown = data.Red.RealtimeScore.CurrentScore;
$("#redScoreNumber").text(data.Red.Score);
$("#redForceCubesIcon").attr("data-state", data.Red.ForceState);
$("#redForceCubes").text(data.Red.ForceCubes).attr("data-state", data.Red.ForceState);
$("#redForceCubes").text(redScoreBreakdown.ForceCubes).attr("data-state", data.Red.ForceState);
$("#redLevitateCubesIcon").attr("data-state", data.Red.LevitateState);
$("#redLevitateCubes").text(data.Red.LevitateCubes).attr("data-state", data.Red.LevitateState);
$("#redLevitateCubes").text(redScoreBreakdown.LevitateCubes).attr("data-state", data.Red.LevitateState);
$("#redBoostCubesIcon").attr("data-state", data.Red.BoostState);
$("#redBoostCubes").text(data.Red.BoostCubes).attr("data-state", data.Red.BoostState);
$("#redBoostCubes").text(redScoreBreakdown.BoostCubes).attr("data-state", data.Red.BoostState);
var blueScoreBreakdown = data.Blue.RealtimeScore.CurrentScore;
$("#blueScoreNumber").text(data.Blue.Score);
$("#blueForceCubesIcon").attr("data-state", data.Blue.ForceState);
$("#blueForceCubes").text(data.Blue.ForceCubes).attr("data-state", data.Blue.ForceState);
$("#blueForceCubes").text(blueScoreBreakdown.ForceCubes).attr("data-state", data.Blue.ForceState);
$("#blueLevitateCubesIcon").attr("data-state", data.Blue.LevitateState);
$("#blueLevitateCubes").text(data.Blue.LevitateCubes).attr("data-state", data.Blue.LevitateState);
$("#blueLevitateCubes").text(blueScoreBreakdown.LevitateCubes).attr("data-state", data.Blue.LevitateState);
$("#blueBoostCubesIcon").attr("data-state", data.Blue.BoostState);
$("#blueBoostCubes").text(data.Blue.BoostCubes).attr("data-state", data.Blue.BoostState);
$("#blueBoostCubes").text(blueScoreBreakdown.BoostCubes).attr("data-state", data.Blue.BoostState);
// Switch/scale indicators.
$("#scaleIndicator").attr("data-owned-by", data.ScaleOwnedBy);
@@ -85,33 +87,33 @@ var handleRealtimeScore = function(data) {
};
// Handles a websocket message to populate the final score data.
var handleSetFinalScore = function(data) {
$("#redFinalScore").text(data.RedScore.Score);
var handleScorePosted = function(data) {
$("#redFinalScore").text(data.RedScoreSummary.Score);
$("#redFinalTeam1").text(data.Match.Red1);
$("#redFinalTeam2").text(data.Match.Red2);
$("#redFinalTeam3").text(data.Match.Red3);
$("#redFinalAutoRunPoints").text(data.RedScore.AutoRunPoints);
$("#redFinalOwnershipPoints").text(data.RedScore.OwnershipPoints);
$("#redFinalVaultPoints").text(data.RedScore.VaultPoints);
$("#redFinalParkClimbPoints").text(data.RedScore.ParkClimbPoints);
$("#redFinalFoulPoints").text(data.RedScore.FoulPoints);
$("#redFinalAutoQuest").html(data.RedScore.AutoQuest ? "&#x2714;" : "&#x2718;");
$("#redFinalAutoQuest").attr("data-checked", data.RedScore.AutoQuest);
$("#redFinalFaceTheBoss").html(data.RedScore.FaceTheBoss ? "&#x2714;" : "&#x2718;");
$("#redFinalFaceTheBoss").attr("data-checked", data.RedScore.FaceTheBoss);
$("#blueFinalScore").text(data.BlueScore.Score);
$("#redFinalAutoRunPoints").text(data.RedScoreSummary.AutoRunPoints);
$("#redFinalOwnershipPoints").text(data.RedScoreSummary.OwnershipPoints);
$("#redFinalVaultPoints").text(data.RedScoreSummary.VaultPoints);
$("#redFinalParkClimbPoints").text(data.RedScoreSummary.ParkClimbPoints);
$("#redFinalFoulPoints").text(data.RedScoreSummary.FoulPoints);
$("#redFinalAutoQuest").html(data.RedScoreSummary.AutoQuest ? "&#x2714;" : "&#x2718;");
$("#redFinalAutoQuest").attr("data-checked", data.RedScoreSummary.AutoQuest);
$("#redFinalFaceTheBoss").html(data.RedScoreSummary.FaceTheBoss ? "&#x2714;" : "&#x2718;");
$("#redFinalFaceTheBoss").attr("data-checked", data.RedScoreSummary.FaceTheBoss);
$("#blueFinalScore").text(data.BlueScoreSummary.Score);
$("#blueFinalTeam1").text(data.Match.Blue1);
$("#blueFinalTeam2").text(data.Match.Blue2);
$("#blueFinalTeam3").text(data.Match.Blue3);
$("#blueFinalAutoRunPoints").text(data.BlueScore.AutoRunPoints);
$("#blueFinalOwnershipPoints").text(data.BlueScore.OwnershipPoints);
$("#blueFinalVaultPoints").text(data.BlueScore.VaultPoints);
$("#blueFinalParkClimbPoints").text(data.BlueScore.ParkClimbPoints);
$("#blueFinalFoulPoints").text(data.BlueScore.FoulPoints);
$("#blueFinalAutoQuest").html(data.BlueScore.AutoQuest ? "&#x2714;" : "&#x2718;");
$("#blueFinalAutoQuest").attr("data-checked", data.BlueScore.AutoQuest);
$("#blueFinalFaceTheBoss").html(data.BlueScore.FaceTheBoss ? "&#x2714;" : "&#x2718;");
$("#blueFinalFaceTheBoss").attr("data-checked", data.BlueScore.FaceTheBoss);
$("#blueFinalAutoRunPoints").text(data.BlueScoreSummary.AutoRunPoints);
$("#blueFinalOwnershipPoints").text(data.BlueScoreSummary.OwnershipPoints);
$("#blueFinalVaultPoints").text(data.BlueScoreSummary.VaultPoints);
$("#blueFinalParkClimbPoints").text(data.BlueScoreSummary.ParkClimbPoints);
$("#blueFinalFoulPoints").text(data.BlueScoreSummary.FoulPoints);
$("#blueFinalAutoQuest").html(data.BlueScoreSummary.AutoQuest ? "&#x2714;" : "&#x2718;");
$("#blueFinalAutoQuest").attr("data-checked", data.BlueScoreSummary.AutoQuest);
$("#blueFinalFaceTheBoss").html(data.BlueScoreSummary.FaceTheBoss ? "&#x2714;" : "&#x2718;");
$("#blueFinalFaceTheBoss").attr("data-checked", data.BlueScoreSummary.FaceTheBoss);
$("#finalMatchName").text(data.MatchName + " " + data.Match.DisplayName);
};
@@ -127,7 +129,7 @@ var handlePlaySound = function(sound) {
// Handles a websocket message to update the alliance selection screen.
var handleAllianceSelection = function(alliances) {
if (alliances) {
if (alliances && alliances.length > 0) {
var numColumns = alliances[0].length + 1;
$.each(alliances, function(k, v) {
v.Index = k + 1;
@@ -399,15 +401,15 @@ var initializeSponsorDisplay = function() {
$(function() {
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/audience/websocket", {
setAudienceDisplay: function(event) { handleSetAudienceDisplay(event.data); },
setMatch: function(event) { handleSetMatch(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); },
setFinalScore: function(event) { handleSetFinalScore(event.data); },
playSound: function(event) { handlePlaySound(event.data); },
allianceSelection: function(event) { handleAllianceSelection(event.data); },
lowerThird: function(event) { handleLowerThird(event.data); }
audienceDisplayMode: function(event) { handleAudienceDisplayMode(event.data); },
lowerThird: function(event) { handleLowerThird(event.data); },
matchLoad: function(event) { handleMatchLoad(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
playSound: function(event) { handlePlaySound(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); },
scorePosted: function(event) { handleScorePosted(event.data); }
});
initializeSponsorDisplay();

View File

@@ -20,14 +20,10 @@ var CheesyWebsocket = function(path, events) {
events.error = function(event) {
// Data is just an error string.
console.log(event.data);
alert(event.data);
}
}
// Insert an event to show a dialog when the server wishes it.
events.dialog = function(event) {
alert(event.data);
}
// Insert an event to allow the server to force-reload the client for any display.
events.reload = function(event) {
location.reload();

View File

@@ -7,7 +7,7 @@
var websocket;
// Handles a websocket message to update the team connection status.
var handleStatus = function(data) {
var handleArenaStatus = function(data) {
// Update the team status view.
$.each(data.AllianceStations, function(station, stationStatus) {
if (stationStatus.Team) {
@@ -72,6 +72,6 @@ $(function() {
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/fta/websocket", {
status: function(event) { handleStatus(event.data); }
arenaStatus: function(event) { handleArenaStatus(event.data); }
});
});

View File

@@ -59,7 +59,7 @@ var confirmCommit = function(isReplay) {
};
// Handles a websocket message to update the team connection status.
var handleStatus = function(data) {
var handleArenaStatus = function(data) {
// Update the team status view.
$.each(data.AllianceStations, function(station, stationStatus) {
if (stationStatus.DsConn) {
@@ -153,12 +153,12 @@ var handleMatchTime = function(data) {
// Handles a websocket message to update the match score.
var handleRealtimeScore = function(data) {
$("#redScore").text(data.RedScore);
$("#blueScore").text(data.BlueScore);
$("#redScore").text(data.Red.Score);
$("#blueScore").text(data.Blue.Score);
};
// Handles a websocket message to update the audience display screen selector.
var handleSetAudienceDisplay = function(data) {
var handleAudienceDisplayMode = function(data) {
$("input[name=audienceDisplay]:checked").prop("checked", false);
$("input[name=audienceDisplay][value=" + data + "]").prop("checked", true);
};
@@ -172,7 +172,7 @@ var handleScoringStatus = function(data) {
};
// Handles a websocket message to update the alliance station display screen selector.
var handleSetAllianceStationDisplay = function(data) {
var handleAllianceStationDisplayMode = function(data) {
$("input[name=allianceStationDisplay]:checked").prop("checked", false);
$("input[name=allianceStationDisplay][value=" + data + "]").prop("checked", true);
};
@@ -183,12 +183,12 @@ $(function() {
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/match_play/websocket", {
status: function(event) { handleStatus(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
allianceStationDisplayMode: function(event) { handleAllianceStationDisplayMode(event.data); },
arenaStatus: function(event) { handleArenaStatus(event.data); },
audienceDisplayMode: function(event) { handleAudienceDisplayMode(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); },
setAudienceDisplay: function(event) { handleSetAudienceDisplay(event.data); },
scoringStatus: function(event) { handleScoringStatus(event.data); },
setAllianceStationDisplay: function(event) { handleSetAllianceStationDisplay(event.data); }
scoringStatus: function(event) { handleScoringStatus(event.data); }
});
});

View File

@@ -6,6 +6,7 @@
var websocket;
var foulTeamButton;
var foulRuleButton;
var firstMatchLoad = true;
// Handles a click on a team button.
var setFoulTeam = function(teamButton) {
@@ -94,12 +95,22 @@ var commitMatch = function() {
websocket.send("commitMatch");
};
// Handles a websocket message to update the teams for the current match.
var handleMatchLoad = function(data) {
// Since the server always sends a matchLoad message upon establishing the websocket connection, ignore the first one.
if (!firstMatchLoad) {
location.reload();
}
firstMatchLoad = false;
};
$(function() {
// Activate tooltips above the rule buttons.
$("[data-toggle=tooltip]").tooltip({"placement": "top"});
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/referee/websocket", {
matchLoad: function(event) { handleMatchLoad(event.data) }
});
clearFoul();

View File

@@ -5,22 +5,30 @@
var websocket;
var scoreCommitted = false;
var alliance;
// Handles a websocket message to update the realtime scoring fields.
var handleScore = function(data) {
var handleRealtimeScore = function(data) {
var realtimeScore;
if (alliance === "red") {
realtimeScore = data.Red.RealtimeScore;
} else {
realtimeScore = data.Blue.RealtimeScore;
}
// Update autonomous period values.
var score = data.Score.CurrentScore;
var score = realtimeScore.CurrentScore;
$("#autoRuns").text(score.AutoRuns);
$("#climbs").text(score.Climbs);
$("#parks").text(score.Parks);
// Update component visibility.
if (!data.AutoCommitted) {
if (!realtimeScore.AutoCommitted) {
$("#autoScoring").fadeTo(0, 1);
$("#teleopScoring").hide();
$("#waitingMessage").hide();
scoreCommitted = false;
} else if (!data.Score.TeleopCommitted) {
} else if (!realtimeScore.TeleopCommitted) {
$("#autoScoring").fadeTo(0, 0.25);
$("#teleopScoring").show();
$("#waitingMessage").hide();
@@ -54,10 +62,12 @@ var commitMatchScore = function() {
};
$(function() {
alliance = window.location.href.split("/").slice(-1)[0];
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/scoring/" + alliance + "/websocket", {
score: function(event) { handleScore(event.data); },
matchTime: function(event) { handleMatchTime(event.data); }
matchTime: function(event) { handleMatchTime(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); }
});
$(document).keypress(handleKeyPress);

View File

@@ -62,32 +62,32 @@
<script id="matchResultTemplate" type="text/x-handlebars-template">
<h4>Score</h4>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Auto Mobility Points</div>
<div class="col-lg-2">{{"{{score.AutoMobilityPoints}}"}}</div>
<div class="col-lg-7 col-lg-offset-1 control-label">Auto Run Points</div>
<div class="col-lg-2">{{"{{score.AutoRunPoints}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Pressure Points</div>
<div class="col-lg-2">{{"{{score.PressurePoints}}"}}</div>
<div class="col-lg-7 col-lg-offset-1 control-label">Ownership Points</div>
<div class="col-lg-2">{{"{{score.OwnershipPoints}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Rotor Points</div>
<div class="col-lg-2">{{"{{score.RotorPoints}}"}}</div>
<div class="col-lg-7 col-lg-offset-1 control-label">Vault Points</div>
<div class="col-lg-2">{{"{{score.VaultPoints}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Takeoff Points</div>
<div class="col-lg-2">{{"{{score.TakeoffPoints}}"}}</div>
<div class="col-lg-7 col-lg-offset-1 control-label">Park/Climb Points</div>
<div class="col-lg-2">{{"{{score.ParkClimbPoints}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Foul Points</div>
<div class="col-lg-2">{{"{{score.FoulPoints}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Pressure Bonus</div>
<div class="col-lg-2">{{"{{score.PressureGoalReached}}"}}</div>
<div class="col-lg-7 col-lg-offset-1 control-label">Auto Quest</div>
<div class="col-lg-2">{{"{{score.AutoQuest}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label">Rotor Bonus</div>
<div class="col-lg-2">{{"{{score.RotorGoalReached}}"}}</div>
<div class="col-lg-7 col-lg-offset-1 control-label">Face the Boss</div>
<div class="col-lg-2">{{"{{score.FaceTheBoss}}"}}</div>
</div>
<div class="row">
<div class="col-lg-7 col-lg-offset-1 control-label"><b>Final Score</b></div>

View File

@@ -6,13 +6,11 @@
package web
import (
"fmt"
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"io"
"github.com/Team254/cheesy-arena/websocket"
"log"
"net/http"
"strconv"
)
// Renders the team number and status display shown above each alliance station.
@@ -50,175 +48,36 @@ func (web *Web) allianceStationDisplayWebsocketHandler(w http.ResponseWriter, r
return
}
websocket, err := NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
displayId := r.URL.Query()["displayId"][0]
station, ok := web.arena.AllianceStationDisplays[displayId]
if !ok {
station = ""
web.arena.AllianceStationDisplays[displayId] = station
}
rankings := make(map[string]*game.Ranking)
for _, allianceStation := range web.arena.AllianceStations {
if allianceStation.Team != nil {
rankings[strconv.Itoa(allianceStation.Team.Id)], _ =
web.arena.Database.GetRankingForTeam(allianceStation.Team.Id)
}
}
allianceStationDisplayListener := web.arena.AllianceStationDisplayNotifier.Listen()
defer close(allianceStationDisplayListener)
matchLoadTeamsListener := web.arena.MatchLoadTeamsNotifier.Listen()
defer close(matchLoadTeamsListener)
robotStatusListener := web.arena.RobotStatusNotifier.Listen()
defer close(robotStatusListener)
matchTimeListener := web.arena.MatchTimeNotifier.Listen()
defer close(matchTimeListener)
realtimeScoreListener := web.arena.RealtimeScoreNotifier.Listen()
defer close(realtimeScoreListener)
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer ws.Close()
// Send the various notifications immediately upon connection.
var data interface{}
err = websocket.Write("setAllianceStationDisplay", web.arena.AllianceStationDisplayScreen)
// Inform the client which alliance station it should represent.
err = ws.Write("allianceStation", station)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTime", MatchTimeMessage{int(web.arena.MatchState), int(web.arena.LastMatchTimeSec)})
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
AllianceStation string
Teams map[string]*model.Team
Rankings map[string]*game.Ranking
}{station, map[string]*model.Team{"R1": web.arena.AllianceStations["R1"].Team,
"R2": web.arena.AllianceStations["R2"].Team, "R3": web.arena.AllianceStations["R3"].Team,
"B1": web.arena.AllianceStations["B1"].Team, "B2": web.arena.AllianceStations["B2"].Team,
"B3": web.arena.AllianceStations["B3"].Team}, rankings}
err = websocket.Write("setMatch", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
RedScore int
BlueScore int
}{web.arena.RedScoreSummary().Score, web.arena.BlueScoreSummary().Score}
err = websocket.Write("realtimeScore", data)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-allianceStationDisplayListener:
if !ok {
return
}
websocket.Write("matchTime",
MatchTimeMessage{int(web.arena.MatchState), int(web.arena.LastMatchTimeSec)})
messageType = "setAllianceStationDisplay"
message = web.arena.AllianceStationDisplayScreen
case _, ok := <-matchLoadTeamsListener:
if !ok {
return
}
messageType = "setMatch"
station = web.arena.AllianceStationDisplays[displayId]
rankings := make(map[string]*game.Ranking)
for _, allianceStation := range web.arena.AllianceStations {
if allianceStation.Team != nil {
rankings[strconv.Itoa(allianceStation.Team.Id)], _ =
web.arena.Database.GetRankingForTeam(allianceStation.Team.Id)
}
}
message = struct {
AllianceStation string
Teams map[string]*model.Team
Rankings map[string]*game.Ranking
MatchType string
}{station, map[string]*model.Team{"R1": web.arena.AllianceStations["R1"].Team,
"R2": web.arena.AllianceStations["R2"].Team, "R3": web.arena.AllianceStations["R3"].Team,
"B1": web.arena.AllianceStations["B1"].Team, "B2": web.arena.AllianceStations["B2"].Team,
"B3": web.arena.AllianceStations["B3"].Team}, rankings, web.arena.CurrentMatch.Type}
case _, ok := <-robotStatusListener:
if !ok {
return
}
messageType = "status"
message = web.arena.GetStatus()
case matchTimeSec, ok := <-matchTimeListener:
if !ok {
return
}
messageType = "matchTime"
message = MatchTimeMessage{int(web.arena.MatchState), matchTimeSec.(int)}
case _, ok := <-realtimeScoreListener:
if !ok {
return
}
messageType = "realtimeScore"
message = struct {
RedScore int
BlueScore int
}{web.arena.RedScoreSummary().Score, web.arena.BlueScoreSummary().Score}
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// 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 "setAllianceStation":
// The client knows what station it is (e.g. across a server restart) and is informing the server.
station, ok := data.(string)
if !ok {
websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
continue
}
web.arena.AllianceStationDisplays[displayId] = station
default:
websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
}
// Inform the client what the match period timing parameters are configured to.
err = ws.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Println(err)
return
}
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.AllianceStationDisplayModeNotifier, web.arena.ArenaStatusNotifier,
web.arena.MatchLoadNotifier, web.arena.MatchTimeNotifier, web.arena.RealtimeScoreNotifier,
web.arena.ReloadDisplaysNotifier)
}

View File

@@ -5,9 +5,9 @@ package web
import (
"github.com/Team254/cheesy-arena/game"
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
"time"
)
@@ -25,33 +25,28 @@ func TestAllianceStationDisplayWebsocket(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/alliance_station/websocket?displayId=1", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/alliance_station/websocket?displayId=1", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "setAllianceStationDisplay")
readWebsocketType(t, ws, "allianceStation")
readWebsocketType(t, ws, "matchTiming")
readWebsocketType(t, ws, "allianceStationDisplayMode")
readWebsocketType(t, ws, "arenaStatus")
readWebsocketType(t, ws, "matchLoad")
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "setMatch")
readWebsocketType(t, ws, "realtimeScore")
// Change to a different screen.
web.arena.AllianceStationDisplayScreen = "logo"
web.arena.AllianceStationDisplayNotifier.Notify(nil)
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "setAllianceStationDisplay")
// Inform the server what display ID this is.
assert.Equal(t, "", web.arena.AllianceStationDisplays["1"])
ws.Write("setAllianceStation", "R3")
time.Sleep(time.Millisecond * 10) // Allow some time for the command to be processed.
assert.Equal(t, "R3", web.arena.AllianceStationDisplays["1"])
web.arena.AllianceStationDisplayMode = "logo"
web.arena.AllianceStationDisplayModeNotifier.Notify()
readWebsocketType(t, ws, "allianceStationDisplayMode")
// Run through a match cycle.
web.arena.MatchLoadTeamsNotifier.Notify(nil)
readWebsocketType(t, ws, "setMatch")
web.arena.MatchLoadNotifier.Notify()
readWebsocketType(t, ws, "matchLoad")
web.arena.AllianceStations["R1"].Bypass = true
web.arena.AllianceStations["R2"].Bypass = true
web.arena.AllianceStations["R3"].Bypass = true
@@ -60,14 +55,16 @@ func TestAllianceStationDisplayWebsocket(t *testing.T) {
web.arena.AllianceStations["B3"].Bypass = true
web.arena.StartMatch()
web.arena.Update()
readWebsocketType(t, ws, "matchTime")
messages := readWebsocketMultiple(t, ws, 2)
_, ok := messages["matchTime"]
assert.True(t, ok)
web.arena.MatchStartTime = time.Now().Add(-time.Duration(game.MatchTiming.WarmupDurationSec) * time.Second)
web.arena.Update()
messages := readWebsocketMultiple(t, ws, 2)
_, ok := messages["status"]
messages = readWebsocketMultiple(t, ws, 2)
_, ok = messages["arenaStatus"]
assert.True(t, ok)
_, ok = messages["matchTime"]
assert.True(t, ok)
web.arena.RealtimeScoreNotifier.Notify(nil)
web.arena.RealtimeScoreNotifier.Notify()
readWebsocketType(t, ws, "realtimeScore")
}

View File

@@ -6,10 +6,9 @@
package web
import (
"fmt"
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"io"
"github.com/Team254/cheesy-arena/websocket"
"log"
"net/http"
)
@@ -25,6 +24,7 @@ func (web *Web) announcerDisplayHandler(w http.ResponseWriter, r *http.Request)
handleWebErr(w, err)
return
}
data := struct {
*model.EventSettings
}{web.arena.EventSettings}
@@ -41,182 +41,21 @@ func (web *Web) announcerDisplayWebsocketHandler(w http.ResponseWriter, r *http.
return
}
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
matchLoadTeamsListener := web.arena.MatchLoadTeamsNotifier.Listen()
defer close(matchLoadTeamsListener)
matchTimeListener := web.arena.MatchTimeNotifier.Listen()
defer close(matchTimeListener)
realtimeScoreListener := web.arena.RealtimeScoreNotifier.Listen()
defer close(realtimeScoreListener)
scorePostedListener := web.arena.ScorePostedNotifier.Listen()
defer close(scorePostedListener)
audienceDisplayListener := web.arena.AudienceDisplayNotifier.Listen()
defer close(audienceDisplayListener)
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
// Send the various notifications immediately upon connection.
var data interface{}
data = struct {
MatchType string
MatchDisplayName string
Red1 *model.Team
Red2 *model.Team
Red3 *model.Team
Blue1 *model.Team
Blue2 *model.Team
Blue3 *model.Team
}{web.arena.CurrentMatch.CapitalizedType(), web.arena.CurrentMatch.DisplayName,
web.arena.AllianceStations["R1"].Team, web.arena.AllianceStations["R2"].Team,
web.arena.AllianceStations["R3"].Team, web.arena.AllianceStations["B1"].Team,
web.arena.AllianceStations["B2"].Team, web.arena.AllianceStations["B3"].Team}
err = websocket.Write("setMatch", data)
// Inform the client what the match period timing parameters are configured to.
err = ws.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTime", MatchTimeMessage{int(web.arena.MatchState), int(web.arena.LastMatchTimeSec)})
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
RedScore int
BlueScore int
}{web.arena.RedScoreSummary().Score, web.arena.BlueScoreSummary().Score}
err = websocket.Write("realtimeScore", data)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-matchLoadTeamsListener:
if !ok {
return
}
messageType = "setMatch"
message = struct {
MatchType string
MatchDisplayName string
Red1 *model.Team
Red2 *model.Team
Red3 *model.Team
Blue1 *model.Team
Blue2 *model.Team
Blue3 *model.Team
}{web.arena.CurrentMatch.CapitalizedType(), web.arena.CurrentMatch.DisplayName,
web.arena.AllianceStations["R1"].Team, web.arena.AllianceStations["R2"].Team,
web.arena.AllianceStations["R3"].Team, web.arena.AllianceStations["B1"].Team,
web.arena.AllianceStations["B2"].Team, web.arena.AllianceStations["B3"].Team}
case matchTimeSec, ok := <-matchTimeListener:
if !ok {
return
}
messageType = "matchTime"
message = MatchTimeMessage{int(web.arena.MatchState), matchTimeSec.(int)}
case _, ok := <-realtimeScoreListener:
if !ok {
return
}
messageType = "realtimeScore"
message = struct {
RedScore int
BlueScore int
}{web.arena.RedScoreSummary().Score, web.arena.BlueScoreSummary().Score}
case _, ok := <-scorePostedListener:
if !ok {
return
}
messageType = "setFinalScore"
message = struct {
MatchType string
MatchDisplayName string
RedScoreSummary *game.ScoreSummary
BlueScoreSummary *game.ScoreSummary
RedFouls []game.Foul
BlueFouls []game.Foul
RedCards map[string]string
BlueCards map[string]string
}{web.arena.SavedMatch.CapitalizedType(), web.arena.SavedMatch.DisplayName,
web.arena.SavedMatchResult.RedScoreSummary(), web.arena.SavedMatchResult.BlueScoreSummary(),
populateFoulDescriptions(web.arena.SavedMatchResult.RedScore.Fouls),
populateFoulDescriptions(web.arena.SavedMatchResult.BlueScore.Fouls),
web.arena.SavedMatchResult.RedCards, web.arena.SavedMatchResult.BlueCards}
case _, ok := <-audienceDisplayListener:
if !ok {
return
}
messageType = "setAudienceDisplay"
message = web.arena.AudienceDisplayScreen
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// 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 "setAudienceDisplay":
// The announcer can make the final score screen show when they are ready to announce the score.
screen, ok := data.(string)
if !ok {
websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
continue
}
web.arena.AudienceDisplayScreen = screen
web.arena.AudienceDisplayNotifier.Notify(nil)
default:
websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
}
}
}
// Copy the description from the rules to the fouls so that they are available to the announcer.
func populateFoulDescriptions(fouls []game.Foul) []game.Foul {
for i := range fouls {
for _, rule := range game.Rules {
if fouls[i].RuleNumber == rule.RuleNumber {
fouls[i].Description = rule.Description
break
}
}
}
return fouls
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.MatchLoadNotifier, web.arena.MatchTimeNotifier, web.arena.RealtimeScoreNotifier,
web.arena.ScorePostedNotifier, web.arena.AudienceDisplayModeNotifier, web.arena.ReloadDisplaysNotifier)
}

View File

@@ -4,11 +4,10 @@
package web
import (
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
"time"
)
func TestAnnouncerDisplay(t *testing.T) {
@@ -24,19 +23,21 @@ func TestAnnouncerDisplayWebsocket(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/announcer/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/announcer/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "setMatch")
readWebsocketType(t, ws, "matchTiming")
readWebsocketType(t, ws, "matchLoad")
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "realtimeScore")
readWebsocketType(t, ws, "scorePosted")
readWebsocketType(t, ws, "audienceDisplayMode")
web.arena.MatchLoadTeamsNotifier.Notify(nil)
readWebsocketType(t, ws, "setMatch")
web.arena.MatchLoadNotifier.Notify()
readWebsocketType(t, ws, "matchLoad")
web.arena.AllianceStations["R1"].Bypass = true
web.arena.AllianceStations["R2"].Bypass = true
web.arena.AllianceStations["R3"].Bypass = true
@@ -46,17 +47,12 @@ func TestAnnouncerDisplayWebsocket(t *testing.T) {
web.arena.StartMatch()
web.arena.Update()
messages := readWebsocketMultiple(t, ws, 2)
_, ok := messages["setAudienceDisplay"]
_, ok := messages["audienceDisplayMode"]
assert.True(t, ok)
_, ok = messages["matchTime"]
assert.True(t, ok)
web.arena.RealtimeScoreNotifier.Notify(nil)
web.arena.RealtimeScoreNotifier.Notify()
readWebsocketType(t, ws, "realtimeScore")
web.arena.ScorePostedNotifier.Notify(nil)
readWebsocketType(t, ws, "setFinalScore")
// Test triggering the final score screen.
ws.Write("setAudienceDisplay", "score")
time.Sleep(time.Millisecond * 10) // Allow some time for the command to be processed.
assert.Equal(t, "score", web.arena.AudienceDisplayScreen)
web.arena.ScorePostedNotifier.Notify()
readWebsocketType(t, ws, "scorePosted")
}

View File

@@ -8,29 +8,11 @@ package web
import (
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"io"
"github.com/Team254/cheesy-arena/websocket"
"log"
"net/http"
"time"
)
type audienceScoreFields struct {
Red *audienceAllianceScoreFields
Blue *audienceAllianceScoreFields
ScaleOwnedBy game.Alliance
}
type audienceAllianceScoreFields struct {
Score int
ForceCubes int
LevitateCubes int
BoostCubes int
ForceState game.PowerUpState
LevitateState game.PowerUpState
BoostState game.PowerUpState
SwitchOwnedBy game.Alliance
}
// Renders the audience display to be chroma keyed over the video feed.
func (web *Web) audienceDisplayHandler(w http.ResponseWriter, r *http.Request) {
if !web.userIsReader(w, r) {
@@ -59,208 +41,22 @@ func (web *Web) audienceDisplayWebsocketHandler(w http.ResponseWriter, r *http.R
return
}
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
audienceDisplayListener := web.arena.AudienceDisplayNotifier.Listen()
defer close(audienceDisplayListener)
matchLoadTeamsListener := web.arena.MatchLoadTeamsNotifier.Listen()
defer close(matchLoadTeamsListener)
matchTimeListener := web.arena.MatchTimeNotifier.Listen()
defer close(matchTimeListener)
realtimeScoreListener := web.arena.RealtimeScoreNotifier.Listen()
defer close(realtimeScoreListener)
scorePostedListener := web.arena.ScorePostedNotifier.Listen()
defer close(scorePostedListener)
playSoundListener := web.arena.PlaySoundNotifier.Listen()
defer close(playSoundListener)
allianceSelectionListener := web.arena.AllianceSelectionNotifier.Listen()
defer close(allianceSelectionListener)
lowerThirdListener := web.arena.LowerThirdNotifier.Listen()
defer close(lowerThirdListener)
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
// Send the various notifications immediately upon connection.
var data interface{}
err = websocket.Write("matchTiming", game.MatchTiming)
// Inform the client what the match period timing parameters are configured to.
err = ws.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTime", MatchTimeMessage{int(web.arena.MatchState), int(web.arena.LastMatchTimeSec)})
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("setAudienceDisplay", web.arena.AudienceDisplayScreen)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
Match *model.Match
MatchName string
}{web.arena.CurrentMatch, web.arena.CurrentMatch.CapitalizedType()}
err = websocket.Write("setMatch", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = web.getAudienceScoreFields()
err = websocket.Write("realtimeScore", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
Match *model.Match
MatchName string
RedScore *game.ScoreSummary
BlueScore *game.ScoreSummary
}{web.arena.SavedMatch, web.arena.SavedMatch.CapitalizedType(),
web.arena.SavedMatchResult.RedScoreSummary(), web.arena.SavedMatchResult.BlueScoreSummary()}
err = websocket.Write("setFinalScore", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("allianceSelection", cachedAlliances)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-audienceDisplayListener:
if !ok {
return
}
messageType = "setAudienceDisplay"
message = web.arena.AudienceDisplayScreen
case _, ok := <-matchLoadTeamsListener:
if !ok {
return
}
messageType = "setMatch"
message = struct {
Match *model.Match
MatchName string
}{web.arena.CurrentMatch, web.arena.CurrentMatch.CapitalizedType()}
case matchTimeSec, ok := <-matchTimeListener:
if !ok {
return
}
messageType = "matchTime"
message = MatchTimeMessage{int(web.arena.MatchState), matchTimeSec.(int)}
case _, ok := <-realtimeScoreListener:
if !ok {
return
}
messageType = "realtimeScore"
message = web.getAudienceScoreFields()
case _, ok := <-scorePostedListener:
if !ok {
return
}
messageType = "setFinalScore"
message = struct {
Match *model.Match
MatchName string
RedScore *game.ScoreSummary
BlueScore *game.ScoreSummary
}{web.arena.SavedMatch, web.arena.SavedMatch.CapitalizedType(),
web.arena.SavedMatchResult.RedScoreSummary(), web.arena.SavedMatchResult.BlueScoreSummary()}
case sound, ok := <-playSoundListener:
if !ok {
return
}
messageType = "playSound"
message = sound
case _, ok := <-allianceSelectionListener:
if !ok {
return
}
messageType = "allianceSelection"
message = cachedAlliances
case lowerThird, ok := <-lowerThirdListener:
if !ok {
return
}
messageType = "lowerThird"
message = lowerThird
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
_, _, 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
}
}
}
// Constructs the data object sent to the audience display for the realtime scoring overlay.
func (web *Web) getAudienceScoreFields() *audienceScoreFields {
fields := new(audienceScoreFields)
fields.Red = getAudienceAllianceScoreFields(&web.arena.RedRealtimeScore.CurrentScore, web.arena.RedScoreSummary(),
web.arena.RedVault, web.arena.RedSwitch)
fields.Blue = getAudienceAllianceScoreFields(&web.arena.BlueRealtimeScore.CurrentScore,
web.arena.BlueScoreSummary(), web.arena.BlueVault, web.arena.BlueSwitch)
fields.ScaleOwnedBy = web.arena.Scale.GetOwnedBy()
return fields
}
// Constructs the data object for one alliance sent to the audience display for the realtime scoring overlay.
func getAudienceAllianceScoreFields(allianceScore *game.Score, allianceScoreSummary *game.ScoreSummary,
allianceVault *game.Vault, allianceSwitch *game.Seesaw) *audienceAllianceScoreFields {
fields := new(audienceAllianceScoreFields)
fields.Score = allianceScoreSummary.Score
fields.ForceCubes = allianceScore.ForceCubes
fields.LevitateCubes = allianceScore.LevitateCubes
fields.BoostCubes = allianceScore.BoostCubes
if allianceVault.ForcePowerUp != nil {
fields.ForceState = allianceVault.ForcePowerUp.GetState(time.Now())
} else {
fields.ForceState = game.Unplayed
}
if allianceVault.LevitatePlayed {
fields.LevitateState = game.Expired
} else {
fields.LevitateState = game.Unplayed
}
if allianceVault.BoostPowerUp != nil {
fields.BoostState = allianceVault.BoostPowerUp.GetState(time.Now())
} else {
fields.BoostState = game.Unplayed
}
fields.SwitchOwnedBy = allianceSwitch.GetOwnedBy()
return fields
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.AudienceDisplayModeNotifier, web.arena.MatchLoadNotifier, web.arena.MatchTimeNotifier,
web.arena.RealtimeScoreNotifier, web.arena.PlaySoundNotifier, web.arena.ScorePostedNotifier,
web.arena.AllianceSelectionNotifier, web.arena.LowerThirdNotifier, web.arena.ReloadDisplaysNotifier)
}

View File

@@ -4,9 +4,9 @@
package web
import (
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
)
@@ -23,23 +23,22 @@ func TestAudienceDisplayWebsocket(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/audience/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/audience/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "matchTiming")
readWebsocketType(t, ws, "audienceDisplayMode")
readWebsocketType(t, ws, "matchLoad")
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "setAudienceDisplay")
readWebsocketType(t, ws, "setMatch")
readWebsocketType(t, ws, "realtimeScore")
readWebsocketType(t, ws, "setFinalScore")
readWebsocketType(t, ws, "allianceSelection")
readWebsocketType(t, ws, "scorePosted")
// Run through a match cycle.
web.arena.MatchLoadTeamsNotifier.Notify(nil)
readWebsocketType(t, ws, "setMatch")
web.arena.MatchLoadNotifier.Notify()
readWebsocketType(t, ws, "matchLoad")
web.arena.AllianceStations["R1"].Bypass = true
web.arena.AllianceStations["R2"].Bypass = true
web.arena.AllianceStations["R3"].Bypass = true
@@ -49,7 +48,7 @@ func TestAudienceDisplayWebsocket(t *testing.T) {
web.arena.StartMatch()
web.arena.Update()
messages := readWebsocketMultiple(t, ws, 3)
screen, ok := messages["setAudienceDisplay"]
screen, ok := messages["audienceDisplayMode"]
if assert.True(t, ok) {
assert.Equal(t, "match", screen)
}
@@ -59,14 +58,14 @@ func TestAudienceDisplayWebsocket(t *testing.T) {
}
_, ok = messages["matchTime"]
assert.True(t, ok)
web.arena.RealtimeScoreNotifier.Notify(nil)
web.arena.RealtimeScoreNotifier.Notify()
readWebsocketType(t, ws, "realtimeScore")
web.arena.ScorePostedNotifier.Notify(nil)
readWebsocketType(t, ws, "setFinalScore")
web.arena.ScorePostedNotifier.Notify()
readWebsocketType(t, ws, "scorePosted")
// Test other overlays.
web.arena.AllianceSelectionNotifier.Notify(nil)
web.arena.AllianceSelectionNotifier.Notify()
readWebsocketType(t, ws, "allianceSelection")
web.arena.LowerThirdNotifier.Notify(nil)
web.arena.LowerThirdNotifier.Notify()
readWebsocketType(t, ws, "lowerThird")
}

View File

@@ -7,8 +7,7 @@ package web
import (
"github.com/Team254/cheesy-arena/model"
"io"
"log"
"github.com/Team254/cheesy-arena/websocket"
"net/http"
)
@@ -37,62 +36,13 @@ func (web *Web) ftaDisplayHandler(w http.ResponseWriter, r *http.Request) {
func (web *Web) ftaDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) {
// TODO(patrick): Enable authentication once Safari (for iPad) supports it over Websocket.
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
robotStatusListener := web.arena.RobotStatusNotifier.Listen()
defer close(robotStatusListener)
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
// Send the various notifications immediately upon connection.
err = websocket.Write("status", web.arena.GetStatus())
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-robotStatusListener:
if !ok {
return
}
messageType = "status"
message = web.arena.GetStatus()
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
_, _, 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
}
}
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.ArenaStatusNotifier, web.arena.ReloadDisplaysNotifier)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/tournament"
"github.com/Team254/cheesy-arena/websocket"
"github.com/gorilla/mux"
"github.com/mitchellh/mapstructure"
"io"
@@ -30,11 +31,6 @@ type MatchPlayListItem struct {
type MatchPlayList []MatchPlayListItem
type MatchTimeMessage struct {
MatchState int
MatchTimeSec int
}
// Global var to hold the current active tournament so that its matches are displayed by default.
var currentMatchType string
@@ -153,7 +149,7 @@ func (web *Web) matchPlayShowResultHandler(w http.ResponseWriter, r *http.Reques
}
web.arena.SavedMatch = match
web.arena.SavedMatchResult = matchResult
web.arena.ScorePostedNotifier.Notify(nil)
web.arena.ScorePostedNotifier.Notify()
http.Redirect(w, r, "/match_play", 303)
}
@@ -164,143 +160,34 @@ func (web *Web) matchPlayWebsocketHandler(w http.ResponseWriter, r *http.Request
return
}
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
matchTimeListener := web.arena.MatchTimeNotifier.Listen()
defer close(matchTimeListener)
realtimeScoreListener := web.arena.RealtimeScoreNotifier.Listen()
defer close(realtimeScoreListener)
robotStatusListener := web.arena.RobotStatusNotifier.Listen()
defer close(robotStatusListener)
audienceDisplayListener := web.arena.AudienceDisplayNotifier.Listen()
defer close(audienceDisplayListener)
scoringStatusListener := web.arena.ScoringStatusNotifier.Listen()
defer close(scoringStatusListener)
allianceStationDisplayListener := web.arena.AllianceStationDisplayNotifier.Listen()
defer close(allianceStationDisplayListener)
// Send the various notifications immediately upon connection.
var data interface{}
err = websocket.Write("status", web.arena.GetStatus())
// Inform the client what the match period timing parameters are configured to.
err = ws.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTiming", game.MatchTiming)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = MatchTimeMessage{int(web.arena.MatchState), int(web.arena.LastMatchTimeSec)}
err = websocket.Write("matchTime", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
RedScore int
BlueScore int
}{web.arena.RedScoreSummary().Score, web.arena.BlueScoreSummary().Score}
err = websocket.Write("realtimeScore", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("setAudienceDisplay", web.arena.AudienceDisplayScreen)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
data = struct {
RefereeScoreReady bool
RedScoreReady bool
BlueScoreReady bool
}{web.arena.RedRealtimeScore.FoulsCommitted && web.arena.BlueRealtimeScore.FoulsCommitted,
web.arena.RedRealtimeScore.TeleopCommitted, web.arena.BlueRealtimeScore.TeleopCommitted}
err = websocket.Write("scoringStatus", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("setAllianceStationDisplay", web.arena.AllianceStationDisplayScreen)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case matchTimeSec, ok := <-matchTimeListener:
if !ok {
return
}
messageType = "matchTime"
message = MatchTimeMessage{int(web.arena.MatchState), matchTimeSec.(int)}
case _, ok := <-realtimeScoreListener:
if !ok {
return
}
messageType = "realtimeScore"
message = struct {
RedScore int
BlueScore int
}{web.arena.RedScoreSummary().Score, web.arena.BlueScoreSummary().Score}
case _, ok := <-robotStatusListener:
if !ok {
return
}
messageType = "status"
message = web.arena.GetStatus()
case _, ok := <-audienceDisplayListener:
if !ok {
return
}
messageType = "setAudienceDisplay"
message = web.arena.AudienceDisplayScreen
case _, ok := <-scoringStatusListener:
if !ok {
return
}
messageType = "scoringStatus"
message = struct {
RefereeScoreReady bool
RedScoreReady bool
BlueScoreReady bool
}{web.arena.RedRealtimeScore.FoulsCommitted && web.arena.BlueRealtimeScore.FoulsCommitted,
web.arena.RedRealtimeScore.TeleopCommitted, web.arena.BlueRealtimeScore.TeleopCommitted}
case _, ok := <-allianceStationDisplayListener:
if !ok {
return
}
messageType = "setAllianceStationDisplay"
message = web.arena.AllianceStationDisplayScreen
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// Subscribe the websocket to the notifiers whose messages will be passed on to the client, in a separate goroutine.
go ws.HandleNotifiers(web.arena.ArenaStatusNotifier, web.arena.MatchTimeNotifier, web.arena.RealtimeScoreNotifier,
web.arena.ScoringStatusNotifier, web.arena.AudienceDisplayModeNotifier,
web.arena.AllianceStationDisplayModeNotifier)
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
messageType, data, err := websocket.Read()
messageType, data, err := ws.Read()
if err != nil {
if err == io.EOF {
// Client has closed the connection; nothing to do here.
return
}
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
@@ -312,22 +199,22 @@ func (web *Web) matchPlayWebsocketHandler(w http.ResponseWriter, r *http.Request
}{}
err = mapstructure.Decode(data, &args)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = web.arena.SubstituteTeam(args.Team, args.Position)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
case "toggleBypass":
station, ok := data.(string)
if !ok {
websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
ws.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
continue
}
if _, ok := web.arena.AllianceStations[station]; !ok {
websocket.WriteError(fmt.Sprintf("Invalid alliance station '%s'.", station))
ws.WriteError(fmt.Sprintf("Invalid alliance station '%s'.", station))
continue
}
web.arena.AllianceStations[station].Bypass = !web.arena.AllianceStations[station].Bypass
@@ -338,88 +225,88 @@ func (web *Web) matchPlayWebsocketHandler(w http.ResponseWriter, r *http.Request
}{}
err = mapstructure.Decode(data, &args)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
web.arena.MuteMatchSounds = args.MuteMatchSounds
web.arena.CurrentMatch.GameSpecificData = args.GameSpecificData
err = web.arena.StartMatch()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
case "abortMatch":
err = web.arena.AbortMatch()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
case "commitResults":
err = web.commitCurrentMatchScore()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = web.arena.ResetMatch()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = web.arena.LoadNextMatch()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = websocket.Write("reload", nil)
err = ws.WriteNotifier(web.arena.ReloadDisplaysNotifier)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
continue // Skip sending the status update, as the client is about to terminate and reload.
case "discardResults":
err = web.arena.ResetMatch()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = web.arena.LoadNextMatch()
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = websocket.Write("reload", nil)
err = ws.WriteNotifier(web.arena.ReloadDisplaysNotifier)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
continue // Skip sending the status update, as the client is about to terminate and reload.
case "setAudienceDisplay":
screen, ok := data.(string)
if !ok {
websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
ws.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
continue
}
web.arena.AudienceDisplayScreen = screen
web.arena.AudienceDisplayNotifier.Notify(nil)
web.arena.AudienceDisplayMode = screen
web.arena.AudienceDisplayModeNotifier.Notify()
continue
case "setAllianceStationDisplay":
screen, ok := data.(string)
if !ok {
websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
ws.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType))
continue
}
web.arena.AllianceStationDisplayScreen = screen
web.arena.AllianceStationDisplayNotifier.Notify(nil)
web.arena.AllianceStationDisplayMode = screen
web.arena.AllianceStationDisplayModeNotifier.Notify()
continue
default:
websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
ws.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", web.arena.GetStatus())
err = ws.WriteNotifier(web.arena.ArenaStatusNotifier)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
}
@@ -436,7 +323,7 @@ func (web *Web) commitMatchScore(match *model.Match, matchResult *model.MatchRes
// Store the result in the buffer to be shown in the audience display.
web.arena.SavedMatch = match
web.arena.SavedMatchResult = matchResult
web.arena.ScorePostedNotifier.Notify(nil)
web.arena.ScorePostedNotifier.Notify()
}
if match.Type == "test" {

View File

@@ -10,11 +10,11 @@ import (
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/tournament"
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
"log"
"sync"
"testing"
"time"
)
@@ -100,7 +100,7 @@ func TestMatchPlayShowResult(t *testing.T) {
recorder = web.getHttpResponse(fmt.Sprintf("/match_play/%d/show_result", match.Id))
assert.Equal(t, 500, recorder.Code)
assert.Contains(t, recorder.Body.String(), "No result found")
web.arena.Database.CreateMatchResult(&model.MatchResult{MatchId: match.Id})
web.arena.Database.CreateMatchResult(model.BuildTestMatchResult(match.Id, 1))
recorder = web.getHttpResponse(fmt.Sprintf("/match_play/%d/show_result", match.Id))
assert.Equal(t, 303, recorder.Code)
assert.Equal(t, match.Id, web.arena.SavedMatch.Id)
@@ -241,19 +241,19 @@ func TestMatchPlayWebsocketCommands(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/match_play/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/match_play/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "status")
readWebsocketType(t, ws, "matchTiming")
readWebsocketType(t, ws, "arenaStatus")
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "realtimeScore")
readWebsocketType(t, ws, "setAudienceDisplay")
readWebsocketType(t, ws, "scoringStatus")
readWebsocketType(t, ws, "setAllianceStationDisplay")
readWebsocketType(t, ws, "audienceDisplayMode")
readWebsocketType(t, ws, "allianceStationDisplayMode")
// Test that a server-side error is communicated to the client.
ws.Write("nonexistenttype", nil)
@@ -265,20 +265,20 @@ func TestMatchPlayWebsocketCommands(t *testing.T) {
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")
readWebsocketType(t, ws, "arenaStatus")
assert.Equal(t, 254, web.arena.CurrentMatch.Blue1)
ws.Write("substituteTeam", map[string]interface{}{"team": 0, "position": "B1"})
readWebsocketType(t, ws, "status")
readWebsocketType(t, ws, "arenaStatus")
assert.Equal(t, 0, web.arena.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")
readWebsocketType(t, ws, "arenaStatus")
assert.Equal(t, true, web.arena.AllianceStations["R3"].Bypass)
ws.Write("toggleBypass", "R3")
readWebsocketType(t, ws, "status")
readWebsocketType(t, ws, "arenaStatus")
assert.Equal(t, false, web.arena.AllianceStations["R3"].Bypass)
// Go through match flow.
@@ -293,15 +293,16 @@ func TestMatchPlayWebsocketCommands(t *testing.T) {
web.arena.AllianceStations["B2"].Bypass = true
web.arena.AllianceStations["B3"].Bypass = true
ws.Write("startMatch", nil)
readWebsocketType(t, ws, "status")
readWebsocketType(t, ws, "arenaStatus")
assert.Equal(t, field.StartMatch, web.arena.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")
readWebsocketType(t, ws, "setAudienceDisplay")
readWebsocketType(t, ws, "arenaStatus")
readWebsocketType(t, ws, "audienceDisplayMode")
readWebsocketType(t, ws, "allianceStationDisplayMode")
assert.Equal(t, field.PostMatch, web.arena.MatchState)
web.arena.RedRealtimeScore.CurrentScore.AutoRuns = 1
web.arena.BlueRealtimeScore.CurrentScore.BoostCubes = 2
@@ -316,11 +317,11 @@ func TestMatchPlayWebsocketCommands(t *testing.T) {
// Test changing the displays.
ws.Write("setAudienceDisplay", "logo")
readWebsocketType(t, ws, "setAudienceDisplay")
assert.Equal(t, "logo", web.arena.AudienceDisplayScreen)
readWebsocketType(t, ws, "audienceDisplayMode")
assert.Equal(t, "logo", web.arena.AudienceDisplayMode)
ws.Write("setAllianceStationDisplay", "logo")
readWebsocketType(t, ws, "setAllianceStationDisplay")
assert.Equal(t, "logo", web.arena.AllianceStationDisplayScreen)
readWebsocketType(t, ws, "allianceStationDisplayMode")
assert.Equal(t, "logo", web.arena.AllianceStationDisplayMode)
}
func TestMatchPlayWebsocketNotifications(t *testing.T) {
@@ -330,18 +331,19 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/match_play/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/match_play/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "status")
readWebsocketType(t, ws, "matchTiming")
readWebsocketType(t, ws, "arenaStatus")
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "realtimeScore")
readWebsocketType(t, ws, "setAudienceDisplay")
readWebsocketType(t, ws, "scoringStatus")
readWebsocketType(t, ws, "audienceDisplayMode")
readWebsocketType(t, ws, "allianceStationDisplayMode")
web.arena.AllianceStations["R1"].Bypass = true
web.arena.AllianceStations["R2"].Bypass = true
@@ -349,14 +351,15 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) {
web.arena.AllianceStations["B1"].Bypass = true
web.arena.AllianceStations["B2"].Bypass = true
web.arena.AllianceStations["B3"].Bypass = true
web.arena.StartMatch()
assert.Nil(t, web.arena.StartMatch())
web.arena.Update()
messages := readWebsocketMultiple(t, ws, 3)
_, ok := messages["matchTime"]
assert.True(t, ok)
_, ok = messages["setAudienceDisplay"]
_, ok = messages["audienceDisplayMode"]
assert.True(t, ok)
_, ok = messages["allianceStationDisplayMode"]
assert.True(t, ok)
_, ok = messages["setAllianceStationDisplay"]
web.arena.MatchStartTime = time.Now().Add(-time.Duration(game.MatchTiming.WarmupDurationSec) * time.Second)
web.arena.Update()
messages = readWebsocketMultiple(t, ws, 2)
@@ -365,7 +368,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) {
assert.Equal(t, 3, matchTime.MatchState)
assert.Equal(t, 3, matchTime.MatchTimeSec)
assert.True(t, ok)
web.arena.ScoringStatusNotifier.Notify(nil)
web.arena.ScoringStatusNotifier.Notify()
readWebsocketType(t, ws, "scoringStatus")
// Should get a tick notification when an integer second threshold is crossed.
@@ -395,14 +398,14 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) {
}
// Handles the status and matchTime messages arriving in either order.
func readWebsocketStatusMatchTime(t *testing.T, ws *Websocket) (bool, MatchTimeMessage) {
func readWebsocketStatusMatchTime(t *testing.T, ws *websocket.Websocket) (bool, field.MatchTimeMessage) {
return getStatusMatchTime(t, readWebsocketMultiple(t, ws, 2))
}
func getStatusMatchTime(t *testing.T, messages map[string]interface{}) (bool, MatchTimeMessage) {
_, statusReceived := messages["status"]
func getStatusMatchTime(t *testing.T, messages map[string]interface{}) (bool, field.MatchTimeMessage) {
_, statusReceived := messages["arenaStatus"]
message, ok := messages["matchTime"]
var matchTime MatchTimeMessage
var matchTime field.MatchTimeMessage
if assert.True(t, ok) {
err := mapstructure.Decode(message, &matchTime)
assert.Nil(t, err)

View File

@@ -7,8 +7,7 @@ package web
import (
"github.com/Team254/cheesy-arena/model"
"io"
"log"
"github.com/Team254/cheesy-arena/websocket"
"net/http"
)
@@ -39,47 +38,13 @@ func (web *Web) pitDisplayWebsocketHandler(w http.ResponseWriter, r *http.Reques
return
}
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
_, _, 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
}
}
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.ReloadDisplaysNotifier)
}

View File

@@ -4,9 +4,9 @@
package web
import (
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
)
@@ -23,10 +23,10 @@ func TestPitDisplayWebsocket(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/pit/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/pit/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Check forced reloading as that is the only purpose the pit websocket serves.
recorder := web.getHttpResponse("/setup/field/reload_displays")

View File

@@ -10,6 +10,7 @@ import (
"github.com/Team254/cheesy-arena/field"
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/websocket"
"github.com/mitchellh/mapstructure"
"io"
"log"
@@ -86,54 +87,25 @@ func (web *Web) refereeDisplayHandler(w http.ResponseWriter, r *http.Request) {
func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) {
// TODO(patrick): Enable authentication once Safari (for iPad) supports it over Websocket.
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
matchLoadTeamsListener := web.arena.MatchLoadTeamsNotifier.Listen()
defer close(matchLoadTeamsListener)
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-matchLoadTeamsListener:
if !ok {
return
}
messageType = "reload"
message = nil
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// Subscribe the websocket to the notifiers whose messages will be passed on to the client, in a separate goroutine.
go ws.HandleNotifiers(web.arena.MatchLoadNotifier, web.arena.ReloadDisplaysNotifier)
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
messageType, data, err := websocket.Read()
messageType, data, err := ws.Read()
if err != nil {
if err == io.EOF {
// Client has closed the connection; nothing to do here.
return
}
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
@@ -147,7 +119,7 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
}{}
err = mapstructure.Decode(data, &args)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
@@ -161,7 +133,7 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
web.arena.BlueRealtimeScore.CurrentScore.Fouls =
append(web.arena.BlueRealtimeScore.CurrentScore.Fouls, foul)
}
web.arena.RealtimeScoreNotifier.Notify(nil)
web.arena.RealtimeScoreNotifier.Notify()
case "deleteFoul":
args := struct {
Alliance string
@@ -172,7 +144,7 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
}{}
err = mapstructure.Decode(data, &args)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
@@ -191,7 +163,7 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
break
}
}
web.arena.RealtimeScoreNotifier.Notify(nil)
web.arena.RealtimeScoreNotifier.Notify()
case "card":
args := struct {
Alliance string
@@ -200,7 +172,7 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
}{}
err = mapstructure.Decode(data, &args)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
@@ -226,8 +198,8 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
continue
}
web.arena.FieldReset = true
web.arena.AllianceStationDisplayScreen = "fieldReset"
web.arena.AllianceStationDisplayNotifier.Notify(nil)
web.arena.AllianceStationDisplayMode = "fieldReset"
web.arena.AllianceStationDisplayModeNotifier.Notify()
continue // Don't reload.
case "commitMatch":
if web.arena.MatchState != field.PostMatch {
@@ -237,18 +209,18 @@ func (web *Web) refereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
web.arena.RedRealtimeScore.FoulsCommitted = true
web.arena.BlueRealtimeScore.FoulsCommitted = true
web.arena.FieldReset = true
web.arena.AllianceStationDisplayScreen = "fieldReset"
web.arena.AllianceStationDisplayNotifier.Notify(nil)
web.arena.ScoringStatusNotifier.Notify(nil)
web.arena.AllianceStationDisplayMode = "fieldReset"
web.arena.AllianceStationDisplayModeNotifier.Notify()
web.arena.ScoringStatusNotifier.Notify()
default:
websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
ws.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
continue
}
// Force a reload of the client to render the updated foul list.
err = websocket.Write("reload", nil)
err = ws.WriteNotifier(web.arena.ReloadDisplaysNotifier)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
}

View File

@@ -5,9 +5,9 @@ package web
import (
"github.com/Team254/cheesy-arena/field"
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
"time"
)
@@ -25,10 +25,13 @@ func TestRefereeDisplayWebsocket(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/referee/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/referee/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "matchLoad")
// Test foul addition.
foulData := struct {
@@ -104,17 +107,17 @@ func TestRefereeDisplayWebsocket(t *testing.T) {
web.arena.MatchState = field.PostMatch
ws.Write("signalReset", nil)
time.Sleep(time.Millisecond * 10)
assert.Equal(t, "fieldReset", web.arena.AllianceStationDisplayScreen)
assert.Equal(t, "fieldReset", web.arena.AllianceStationDisplayMode)
assert.False(t, web.arena.RedRealtimeScore.FoulsCommitted)
assert.False(t, web.arena.BlueRealtimeScore.FoulsCommitted)
web.arena.AllianceStationDisplayScreen = "logo"
web.arena.AllianceStationDisplayMode = "logo"
ws.Write("commitMatch", nil)
readWebsocketType(t, ws, "reload")
assert.Equal(t, "fieldReset", web.arena.AllianceStationDisplayScreen)
assert.Equal(t, "fieldReset", web.arena.AllianceStationDisplayMode)
assert.True(t, web.arena.RedRealtimeScore.FoulsCommitted)
assert.True(t, web.arena.BlueRealtimeScore.FoulsCommitted)
// Should refresh the page when the next match is loaded.
web.arena.MatchLoadTeamsNotifier.Notify(nil)
readWebsocketType(t, ws, "reload")
web.arena.MatchLoadNotifier.Notify()
readWebsocketType(t, ws, "matchLoad")
}

View File

@@ -8,8 +8,8 @@ package web
import (
"fmt"
"github.com/Team254/cheesy-arena/field"
"github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/websocket"
"github.com/gorilla/mux"
"io"
"log"
@@ -58,162 +58,110 @@ func (web *Web) scoringDisplayWebsocketHandler(w http.ResponseWriter, r *http.Re
return
}
var score **field.RealtimeScore
var scoreSummaryFunc func() *game.ScoreSummary
if alliance == "red" {
score = &web.arena.RedRealtimeScore
scoreSummaryFunc = web.arena.RedScoreSummary
} else {
score = &web.arena.BlueRealtimeScore
scoreSummaryFunc = web.arena.BlueScoreSummary
}
autoCommitted := false
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
matchLoadTeamsListener := web.arena.MatchLoadTeamsNotifier.Listen()
defer close(matchLoadTeamsListener)
matchTimeListener := web.arena.MatchTimeNotifier.Listen()
defer close(matchTimeListener)
reloadDisplaysListener := web.arena.ReloadDisplaysNotifier.Listen()
defer close(reloadDisplaysListener)
// Send the various notifications immediately upon connection.
data := struct {
Score *field.RealtimeScore
ScoreSummary *game.ScoreSummary
AutoCommitted bool
}{*score, scoreSummaryFunc(), autoCommitted}
err = websocket.Write("score", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
err = websocket.Write("matchTime", MatchTimeMessage{int(web.arena.MatchState), int(web.arena.LastMatchTimeSec)})
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
// Spin off a goroutine to listen for notifications and pass them on through the websocket.
go func() {
for {
var messageType string
var message interface{}
select {
case _, ok := <-matchLoadTeamsListener:
if !ok {
return
}
messageType = "reload"
message = nil
case matchTimeSec, ok := <-matchTimeListener:
if !ok {
return
}
messageType = "matchTime"
message = MatchTimeMessage{int(web.arena.MatchState), matchTimeSec.(int)}
case _, ok := <-reloadDisplaysListener:
if !ok {
return
}
messageType = "reload"
message = nil
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}()
// Subscribe the websocket to the notifiers whose messages will be passed on to the client, in a separate goroutine.
go ws.HandleNotifiers(web.arena.MatchTimeNotifier, web.arena.RealtimeScoreNotifier,
web.arena.ReloadDisplaysNotifier)
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
messageType, data, err := websocket.Read()
messageType, _, err := ws.Read()
if err != nil {
if err == io.EOF {
// Client has closed the connection; nothing to do here.
return
}
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
scoreChanged := false
switch messageType {
case "r":
if !autoCommitted {
if !(*score).AutoCommitted {
if (*score).CurrentScore.AutoRuns < 3 {
(*score).CurrentScore.AutoRuns++
scoreChanged = true
}
}
case "R":
if !autoCommitted {
if !(*score).AutoCommitted {
if (*score).CurrentScore.AutoRuns > 0 {
(*score).CurrentScore.AutoRuns--
scoreChanged = true
}
}
case "c":
if autoCommitted {
if (*score).AutoCommitted {
if (*score).CurrentScore.Climbs+(*score).CurrentScore.Parks < 3 {
(*score).CurrentScore.Climbs++
scoreChanged = true
}
}
case "C":
if autoCommitted {
if (*score).AutoCommitted {
if (*score).CurrentScore.Climbs > 0 {
(*score).CurrentScore.Climbs--
scoreChanged = true
}
}
case "p":
if autoCommitted {
if (*score).AutoCommitted {
if (*score).CurrentScore.Climbs+(*score).CurrentScore.Parks < 3 {
(*score).CurrentScore.Parks++
scoreChanged = true
}
}
case "P":
if autoCommitted {
if (*score).AutoCommitted {
if (*score).CurrentScore.Parks > 0 {
(*score).CurrentScore.Parks--
scoreChanged = true
}
}
case "\r":
if web.arena.MatchState != field.PreMatch || web.arena.CurrentMatch.Type == "test" {
autoCommitted = true
if (web.arena.MatchState != field.PreMatch || web.arena.CurrentMatch.Type == "test") &&
!(*score).AutoCommitted {
(*score).AutoCommitted = true
scoreChanged = true
}
case "a":
autoCommitted = false
if (*score).AutoCommitted {
(*score).AutoCommitted = false
scoreChanged = true
}
case "commitMatch":
if web.arena.MatchState != field.PostMatch {
// Don't allow committing the score until the match is over.
websocket.WriteError("Cannot commit score: Match is not over.")
ws.WriteError("Cannot commit score: Match is not over.")
continue
}
autoCommitted = true
(*score).TeleopCommitted = true
web.arena.ScoringStatusNotifier.Notify(nil)
if !(*score).TeleopCommitted {
(*score).AutoCommitted = true
(*score).TeleopCommitted = true
web.arena.ScoringStatusNotifier.Notify()
scoreChanged = true
}
default:
websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
// Unknown keypress; just swallow the message without doing anything.
continue
}
web.arena.RealtimeScoreNotifier.Notify(nil)
// Send out the score again after handling the command, as it most likely changed as a result.
data = struct {
Score *field.RealtimeScore
ScoreSummary *game.ScoreSummary
AutoCommitted bool
}{*score, scoreSummaryFunc(), autoCommitted}
err = websocket.Write("score", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
if scoreChanged {
web.arena.RealtimeScoreNotifier.Notify()
}
}
}

View File

@@ -5,10 +5,11 @@ package web
import (
"github.com/Team254/cheesy-arena/field"
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
"time"
)
func TestScoringDisplay(t *testing.T) {
@@ -29,22 +30,22 @@ func TestScoringDisplayWebsocket(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
_, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/scoring/blorpy/websocket", nil)
_, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/scoring/blorpy/websocket", nil)
assert.NotNil(t, err)
redConn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/scoring/red/websocket", nil)
redConn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/scoring/red/websocket", nil)
assert.Nil(t, err)
defer redConn.Close()
redWs := &Websocket{redConn, new(sync.Mutex)}
blueConn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/displays/scoring/blue/websocket", nil)
redWs := websocket.NewTestWebsocket(redConn)
blueConn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/scoring/blue/websocket", nil)
assert.Nil(t, err)
defer blueConn.Close()
blueWs := &Websocket{blueConn, new(sync.Mutex)}
blueWs := websocket.NewTestWebsocket(blueConn)
// Should receive a score update right after connection.
readWebsocketType(t, redWs, "score")
readWebsocketType(t, redWs, "matchTime")
readWebsocketType(t, blueWs, "score")
readWebsocketType(t, redWs, "realtimeScore")
readWebsocketType(t, blueWs, "matchTime")
readWebsocketType(t, blueWs, "realtimeScore")
// Send a match worth of scoring commands in.
redWs.Write("r", nil)
@@ -53,27 +54,23 @@ func TestScoringDisplayWebsocket(t *testing.T) {
blueWs.Write("r", nil)
blueWs.Write("r", nil)
blueWs.Write("R", nil)
for i := 0; i < 5; i++ {
readWebsocketType(t, redWs, "realtimeScore")
readWebsocketType(t, blueWs, "realtimeScore")
}
redWs.Write("\r", nil)
blueWs.Write("\r", nil)
redWs.Write("a", nil)
redWs.Write("\r", nil)
for i := 0; i < 4; i++ {
readWebsocketType(t, redWs, "score")
}
for i := 0; i < 6; i++ {
readWebsocketType(t, blueWs, "score")
readWebsocketType(t, redWs, "realtimeScore")
readWebsocketType(t, blueWs, "realtimeScore")
}
assert.Equal(t, 1, web.arena.RedRealtimeScore.CurrentScore.AutoRuns)
assert.Equal(t, 2, web.arena.BlueRealtimeScore.CurrentScore.AutoRuns)
redWs.Write("r", nil)
for i := 0; i < 1; i++ {
readWebsocketType(t, redWs, "score")
}
for i := 0; i < 0; i++ {
readWebsocketType(t, blueWs, "score")
}
// Make sure auto scores haven't changed in teleop.
assert.Equal(t, 1, web.arena.RedRealtimeScore.CurrentScore.AutoRuns)
@@ -82,17 +79,22 @@ func TestScoringDisplayWebsocket(t *testing.T) {
// Test committing logic.
redWs.Write("commitMatch", nil)
readWebsocketType(t, redWs, "error")
blueWs.Write("commitMatch", nil)
readWebsocketType(t, blueWs, "error")
assert.False(t, web.arena.RedRealtimeScore.TeleopCommitted)
assert.False(t, web.arena.BlueRealtimeScore.TeleopCommitted)
web.arena.MatchState = field.PostMatch
redWs.Write("commitMatch", nil)
blueWs.Write("commitMatch", nil)
readWebsocketType(t, redWs, "score")
readWebsocketType(t, blueWs, "score")
time.Sleep(time.Millisecond * 10) // Allow some time for the commands to be processed.
assert.True(t, web.arena.RedRealtimeScore.TeleopCommitted)
assert.True(t, web.arena.BlueRealtimeScore.TeleopCommitted)
// Load another match to reset the results.
web.arena.ResetMatch()
web.arena.LoadTestMatch()
readWebsocketType(t, redWs, "reload")
readWebsocketType(t, blueWs, "reload")
readWebsocketType(t, redWs, "realtimeScore")
readWebsocketType(t, blueWs, "realtimeScore")
assert.Equal(t, field.NewRealtimeScore(), web.arena.RedRealtimeScore)
assert.Equal(t, field.NewRealtimeScore(), web.arena.BlueRealtimeScore)
}

View File

@@ -84,7 +84,7 @@ func (web *Web) allianceSelectionPostHandler(w http.ResponseWriter, r *http.Requ
}
cachedRankedTeams = newRankedTeams
web.arena.AllianceSelectionNotifier.Notify(nil)
web.arena.AllianceSelectionNotifier.NotifyWithMessage(cachedAlliances)
http.Redirect(w, r, "/setup/alliance_selection", 303)
}
@@ -127,7 +127,7 @@ func (web *Web) allianceSelectionStartHandler(w http.ResponseWriter, r *http.Req
cachedRankedTeams[i] = &RankedTeam{i + 1, ranking.TeamId, false}
}
web.arena.AllianceSelectionNotifier.Notify(nil)
web.arena.AllianceSelectionNotifier.NotifyWithMessage(cachedAlliances)
http.Redirect(w, r, "/setup/alliance_selection", 303)
}
@@ -144,7 +144,7 @@ func (web *Web) allianceSelectionResetHandler(w http.ResponseWriter, r *http.Req
cachedAlliances = [][]model.AllianceTeam{}
cachedRankedTeams = []*RankedTeam{}
web.arena.AllianceSelectionNotifier.Notify(nil)
web.arena.AllianceSelectionNotifier.NotifyWithMessage(cachedAlliances)
http.Redirect(w, r, "/setup/alliance_selection", 303)
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/Team254/cheesy-arena/led"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/vaultled"
"log"
"github.com/Team254/cheesy-arena/websocket"
"net/http"
"strconv"
)
@@ -56,7 +56,7 @@ func (web *Web) fieldPostHandler(w http.ResponseWriter, r *http.Request) {
displayId := r.PostFormValue("displayId")
allianceStation := r.PostFormValue("allianceStation")
web.arena.AllianceStationDisplays[displayId] = allianceStation
web.arena.MatchLoadTeamsNotifier.Notify(nil)
web.arena.MatchLoadNotifier.Notify()
http.Redirect(w, r, "/setup/field", 303)
}
@@ -66,7 +66,7 @@ func (web *Web) fieldReloadDisplaysHandler(w http.ResponseWriter, r *http.Reques
return
}
web.arena.ReloadDisplaysNotifier.Notify(nil)
web.arena.ReloadDisplaysNotifier.Notify()
http.Redirect(w, r, "/setup/field", 303)
}
@@ -101,47 +101,13 @@ func (web *Web) fieldWebsocketHandler(w http.ResponseWriter, r *http.Request) {
return
}
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
plcIoChangeListener := web.arena.Plc.IoChangeNotifier.Listen()
defer close(plcIoChangeListener)
// Send the PLC status immediately upon connection.
data := struct {
Inputs []bool
Registers []uint16
Coils []bool
}{web.arena.Plc.Inputs[:], web.arena.Plc.Registers[:], web.arena.Plc.Coils[:]}
err = websocket.Write("plcIoChange", data)
if err != nil {
log.Printf("Websocket error: %s", err)
return
}
for {
var messageType string
var message interface{}
select {
case _, ok := <-plcIoChangeListener:
if !ok {
return
}
messageType = "plcIoChange"
message = struct {
Inputs []bool
Registers []uint16
Coils []bool
}{web.arena.Plc.Inputs[:], web.arena.Plc.Registers[:], web.arena.Plc.Coils[:]}
}
err = websocket.Write(messageType, message)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.Plc.IoChangeNotifier)
}

View File

@@ -8,6 +8,7 @@ package web
import (
"fmt"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/websocket"
"github.com/mitchellh/mapstructure"
"io"
"log"
@@ -47,22 +48,22 @@ func (web *Web) lowerThirdsWebsocketHandler(w http.ResponseWriter, r *http.Reque
return
}
websocket, err := NewWebsocket(w, r)
ws, err := websocket.NewWebsocket(w, r)
if err != nil {
handleWebErr(w, err)
return
}
defer websocket.Close()
defer ws.Close()
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
messageType, data, err := websocket.Read()
messageType, data, err := ws.Read()
if err != nil {
if err == io.EOF {
// Client has closed the connection; nothing to do here.
return
}
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
@@ -71,7 +72,7 @@ func (web *Web) lowerThirdsWebsocketHandler(w http.ResponseWriter, r *http.Reque
var lowerThird model.LowerThird
err = mapstructure.Decode(data, &lowerThird)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
web.saveLowerThird(&lowerThird)
@@ -79,36 +80,36 @@ func (web *Web) lowerThirdsWebsocketHandler(w http.ResponseWriter, r *http.Reque
var lowerThird model.LowerThird
err = mapstructure.Decode(data, &lowerThird)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = web.arena.Database.DeleteLowerThird(&lowerThird)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
case "showLowerThird":
var lowerThird model.LowerThird
err = mapstructure.Decode(data, &lowerThird)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
web.saveLowerThird(&lowerThird)
web.arena.LowerThirdNotifier.Notify(lowerThird)
web.arena.AudienceDisplayScreen = "lowerThird"
web.arena.AudienceDisplayNotifier.Notify(nil)
web.arena.LowerThirdNotifier.NotifyWithMessage(lowerThird)
web.arena.AudienceDisplayMode = "lowerThird"
web.arena.AudienceDisplayModeNotifier.Notify()
continue
case "hideLowerThird":
var lowerThird model.LowerThird
err = mapstructure.Decode(data, &lowerThird)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
web.saveLowerThird(&lowerThird)
web.arena.AudienceDisplayScreen = "blank"
web.arena.AudienceDisplayNotifier.Notify(nil)
web.arena.AudienceDisplayMode = "blank"
web.arena.AudienceDisplayModeNotifier.Notify()
continue
case "reorderLowerThird":
args := struct {
@@ -117,23 +118,23 @@ func (web *Web) lowerThirdsWebsocketHandler(w http.ResponseWriter, r *http.Reque
}{}
err = mapstructure.Decode(data, &args)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
err = web.reorderLowerThird(args.Id, args.MoveUp)
if err != nil {
websocket.WriteError(err.Error())
ws.WriteError(err.Error())
continue
}
default:
websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
ws.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType))
continue
}
// Force a reload of the client to render the updated lower thirds list.
err = websocket.Write("reload", nil)
err = ws.WriteNotifier(web.arena.ReloadDisplaysNotifier)
if err != nil {
log.Printf("Websocket error: %s", err)
log.Println(err)
return
}
}

View File

@@ -5,9 +5,9 @@ package web
import (
"github.com/Team254/cheesy-arena/model"
"github.com/gorilla/websocket"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"sync"
"testing"
"time"
)
@@ -26,10 +26,10 @@ func TestSetupLowerThirds(t *testing.T) {
server, wsUrl := web.startTestServer()
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsUrl+"/setup/lower_thirds/websocket", nil)
conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/setup/lower_thirds/websocket", nil)
assert.Nil(t, err)
defer conn.Close()
ws := &Websocket{conn, new(sync.Mutex)}
ws := websocket.NewTestWebsocket(conn)
ws.Write("saveLowerThird", model.LowerThird{1, "Top Text 4", "Bottom Text 1", 0})
time.Sleep(time.Millisecond * 10) // Allow some time for the command to be processed.
@@ -41,18 +41,18 @@ func TestSetupLowerThirds(t *testing.T) {
lowerThird, _ = web.arena.Database.GetLowerThirdById(1)
assert.Nil(t, lowerThird)
assert.Equal(t, "blank", web.arena.AudienceDisplayScreen)
assert.Equal(t, "blank", web.arena.AudienceDisplayMode)
ws.Write("showLowerThird", model.LowerThird{2, "Top Text 5", "Bottom Text 1", 0})
time.Sleep(time.Millisecond * 10)
lowerThird, _ = web.arena.Database.GetLowerThirdById(2)
assert.Equal(t, "Top Text 5", lowerThird.TopText)
assert.Equal(t, "lowerThird", web.arena.AudienceDisplayScreen)
assert.Equal(t, "lowerThird", web.arena.AudienceDisplayMode)
ws.Write("hideLowerThird", model.LowerThird{2, "Top Text 6", "Bottom Text 1", 0})
time.Sleep(time.Millisecond * 10)
lowerThird, _ = web.arena.Database.GetLowerThirdById(2)
assert.Equal(t, "Top Text 6", lowerThird.TopText)
assert.Equal(t, "blank", web.arena.AudienceDisplayScreen)
assert.Equal(t, "blank", web.arena.AudienceDisplayMode)
ws.Write("reorderLowerThird", map[string]interface{}{"Id": 2, "moveUp": false})
time.Sleep(time.Millisecond * 100)

View File

@@ -5,11 +5,13 @@ package web
import (
"github.com/Team254/cheesy-arena/field"
"github.com/Team254/cheesy-arena/websocket"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestIndex(t *testing.T) {
@@ -42,7 +44,7 @@ func (web *Web) startTestServer() (*httptest.Server, string) {
}
// Receives the next websocket message and asserts that it is an error.
func readWebsocketError(t *testing.T, ws *Websocket) string {
func readWebsocketError(t *testing.T, ws *websocket.Websocket) string {
messageType, data, err := ws.Read()
if assert.Nil(t, err) && assert.Equal(t, "error", messageType) {
return data.(string)
@@ -51,18 +53,18 @@ func readWebsocketError(t *testing.T, ws *Websocket) string {
}
// Receives the next websocket message and asserts that it is of the given type.
func readWebsocketType(t *testing.T, ws *Websocket, expectedMessageType string) interface{} {
messageType, message, err := ws.Read()
func readWebsocketType(t *testing.T, ws *websocket.Websocket, expectedMessageType string) interface{} {
messageType, message, err := ws.ReadWithTimeout(time.Second)
if assert.Nil(t, err) {
assert.Equal(t, expectedMessageType, messageType)
}
return message
}
func readWebsocketMultiple(t *testing.T, ws *Websocket, count int) map[string]interface{} {
func readWebsocketMultiple(t *testing.T, ws *websocket.Websocket, count int) map[string]interface{} {
messages := make(map[string]interface{})
for i := 0; i < count; i++ {
messageType, message, err := ws.Read()
messageType, message, err := ws.ReadWithTimeout(time.Second)
if assert.Nil(t, err) {
messages[messageType] = message
}

View File

@@ -1,68 +0,0 @@
// Copyright 2014 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Functions for the server side of handling websockets.
package web
import (
"github.com/gorilla/websocket"
"io"
"net/http"
"sync"
)
// Wraps the Gorilla Websocket module so that we can define additional functions on it.
type Websocket struct {
conn *websocket.Conn
writeMutex *sync.Mutex
}
type WebsocketMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"`
}
var websocketUpgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 2014}
// Upgrades the given HTTP request to a websocket connection.
func NewWebsocket(w http.ResponseWriter, r *http.Request) (*Websocket, error) {
conn, err := websocketUpgrader.Upgrade(w, r, nil)
if err != nil {
return nil, err
}
return &Websocket{conn, new(sync.Mutex)}, nil
}
func (ws *Websocket) Close() {
ws.conn.Close()
}
func (ws *Websocket) Read() (string, interface{}, error) {
var message WebsocketMessage
err := ws.conn.ReadJSON(&message)
if websocket.IsCloseError(err, websocket.CloseNoStatusReceived) {
// This error indicates that the browser terminated the connection normally; rewwrite it so that clients don't
// log it.
return "", nil, io.EOF
}
return message.Type, message.Data, err
}
func (ws *Websocket) Write(messageType string, data interface{}) error {
ws.writeMutex.Lock()
defer ws.writeMutex.Unlock()
return ws.conn.WriteJSON(WebsocketMessage{messageType, data})
}
func (ws *Websocket) WriteError(errorMessage string) error {
ws.writeMutex.Lock()
defer ws.writeMutex.Unlock()
return ws.conn.WriteJSON(WebsocketMessage{"error", errorMessage})
}
func (ws *Websocket) ShowDialog(message string) error {
ws.writeMutex.Lock()
defer ws.writeMutex.Unlock()
return ws.conn.WriteJSON(WebsocketMessage{"dialog", message})
}

80
websocket/notifier.go Normal file
View File

@@ -0,0 +1,80 @@
// Copyright 2014 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Publish-subscribe model for nonblocking notification of server events to websocket clients.
package websocket
import (
"log"
)
// Allow the listeners to buffer a small number of notifications to streamline delivery.
const notifyBufferSize = 5
type Notifier struct {
messageType string
messageProducer func() interface{}
listeners map[chan messageEnvelope]struct{} // The map is essentially a set; the value is ignored.
}
type messageEnvelope struct {
messageType string
messageBody interface{}
}
func NewNotifier(messageType string, messageProducer func() interface{}) *Notifier {
notifier := &Notifier{messageType: messageType, messageProducer: messageProducer}
notifier.listeners = make(map[chan messageEnvelope]struct{})
return notifier
}
// Calls the messageProducer function and sends a message containing the results to all registered listeners, and cleans
// up any listeners that have closed.
func (notifier *Notifier) Notify() {
notifier.NotifyWithMessage(notifier.getMessageBody())
}
// Sends the given message to all registered listeners, and cleans up any listeners that have closed. If there is a
// messageProducer function defined it is ignored.
func (notifier *Notifier) NotifyWithMessage(messageBody interface{}) {
message := messageEnvelope{messageType: notifier.messageType, messageBody: messageBody}
for listener := range notifier.listeners {
notifier.notifyListener(listener, message)
}
}
func (notifier *Notifier) notifyListener(listener chan messageEnvelope, message messageEnvelope) {
defer func() {
// If channel is closed sending to it will cause a panic; recover and remove it from the list.
if r := recover(); r != nil {
delete(notifier.listeners, listener)
}
}()
// Do a non-blocking send. This guarantees that sending notifications won't interrupt the main event loop,
// at the risk of clients missing some messages if they don't read them all promptly.
select {
case listener <- message:
// The notification was sent and received successfully.
default:
log.Println("Failed to send a notification due to blocked listener.")
}
}
// Registers and returns a channel that can be read from to receive notification messages. The caller is
// responsible for closing the channel, which will cause it to be reaped from the list of listeners.
func (notifier *Notifier) listen() chan messageEnvelope {
listener := make(chan messageEnvelope, notifyBufferSize)
notifier.listeners[listener] = struct{}{}
return listener
}
// Invokes the message producer to get the message, or returns nil if no producer is defined.
func (notifier *Notifier) getMessageBody() interface{} {
if notifier.messageProducer == nil {
return nil
} else {
return notifier.messageProducer()
}
}

View File

@@ -0,0 +1,90 @@
// Copyright 2014 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
package websocket
import (
"github.com/stretchr/testify/assert"
"io/ioutil"
"log"
"testing"
)
func TestNotifier(t *testing.T) {
notifier := NewNotifier("testMessageType", generateTestMessage)
// Should do nothing when there are no listeners.
notifier.Notify()
notifier.NotifyWithMessage(12345)
notifier.NotifyWithMessage(struct{}{})
listener := notifier.listen()
notifier.Notify()
message := <-listener
assert.Equal(t, "testMessageType", message.messageType)
assert.Equal(t, "test message", message.messageBody)
notifier.NotifyWithMessage(12345)
assert.Equal(t, 12345, (<-listener).messageBody)
// Should allow multiple messages without blocking.
notifier.NotifyWithMessage("message1")
notifier.NotifyWithMessage("message2")
notifier.Notify()
assert.Equal(t, "message1", (<-listener).messageBody)
assert.Equal(t, "message2", (<-listener).messageBody)
assert.Equal(t, "test message", (<-listener).messageBody)
// Should stop sending messages and not block once the buffer is full.
log.SetOutput(ioutil.Discard) // Silence noisy log output.
for i := 0; i < 20; i++ {
notifier.NotifyWithMessage(i)
}
var value messageEnvelope
var lastValue interface{}
for lastValue == nil {
select {
case value = <-listener:
default:
lastValue = value.messageBody
return
}
}
notifier.NotifyWithMessage("next message")
assert.True(t, lastValue.(int) < 10)
assert.Equal(t, "next message", (<-listener).messageBody)
}
func TestNotifyMultipleListeners(t *testing.T) {
notifier := NewNotifier("testMessageType2", nil)
listeners := [50]chan messageEnvelope{}
for i := 0; i < len(listeners); i++ {
listeners[i] = notifier.listen()
}
notifier.Notify()
notifier.NotifyWithMessage(12345)
for listener := range notifier.listeners {
assert.Equal(t, nil, (<-listener).messageBody)
assert.Equal(t, 12345, (<-listener).messageBody)
}
// Should reap closed channels automatically.
close(listeners[4])
notifier.NotifyWithMessage("message1")
assert.Equal(t, 49, len(notifier.listeners))
for listener := range notifier.listeners {
assert.Equal(t, "message1", (<-listener).messageBody)
}
close(listeners[16])
close(listeners[21])
close(listeners[49])
notifier.NotifyWithMessage("message2")
assert.Equal(t, 46, len(notifier.listeners))
for listener := range notifier.listeners {
assert.Equal(t, "message2", (<-listener).messageBody)
}
}
func generateTestMessage() interface{} {
return "test message"
}

148
websocket/websocket.go Normal file
View File

@@ -0,0 +1,148 @@
// Copyright 2014 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Functions for the server side of handling websockets.
package websocket
import (
"fmt"
"github.com/gorilla/websocket"
"io"
"log"
"net/http"
"reflect"
"runtime"
"strings"
"sync"
"time"
)
// Wraps the Gorilla Websocket module so that we can define additional functions on it.
type Websocket struct {
conn *websocket.Conn
writeMutex *sync.Mutex
}
type WebsocketMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"`
}
var websocketUpgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 2014}
// Upgrades the given HTTP request to a websocket connection.
func NewWebsocket(w http.ResponseWriter, r *http.Request) (*Websocket, error) {
conn, err := websocketUpgrader.Upgrade(w, r, nil)
if err != nil {
return nil, err
}
return &Websocket{conn, new(sync.Mutex)}, nil
}
func NewTestWebsocket(conn *websocket.Conn) *Websocket {
return &Websocket{conn, new(sync.Mutex)}
}
func (ws *Websocket) Close() error {
return ws.conn.Close()
}
func (ws *Websocket) Read() (string, interface{}, error) {
var message WebsocketMessage
err := ws.conn.ReadJSON(&message)
if websocket.IsCloseError(err, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) {
// This error indicates that the browser terminated the connection normally; rewrite it so that clients don't
// log it.
return "", nil, io.EOF
}
if err != nil {
// Include the caller of this method in the error message.
_, file, line, _ := runtime.Caller(1)
filePathParts := strings.Split(file, "/")
return "", nil, fmt.Errorf("[%s:%d] Websocket read error: %v", filePathParts[len(filePathParts)-1], line, err)
}
return message.Type, message.Data, nil
}
func (ws *Websocket) ReadWithTimeout(timeout time.Duration) (string, interface{}, error) {
type wsReadResult struct {
messageType string
message interface{}
err error
}
readChan := make(chan wsReadResult, 1)
go func() {
messageType, message, err := ws.Read()
readChan <- wsReadResult{messageType, message, err}
}()
select {
case result := <-readChan:
return result.messageType, result.message, result.err
case <-time.After(timeout):
return "", nil, fmt.Errorf("Websocket read timed out after waiting for %v", timeout)
}
}
func (ws *Websocket) Write(messageType string, data interface{}) error {
ws.writeMutex.Lock()
defer ws.writeMutex.Unlock()
err := ws.conn.WriteJSON(WebsocketMessage{messageType, data})
if err != nil {
// Include the caller of this method in the error message.
_, file, line, _ := runtime.Caller(1)
filePathParts := strings.Split(file, "/")
return fmt.Errorf("[%s:%d] Websocket write error: %v", filePathParts[len(filePathParts)-1], line, err)
}
return nil
}
func (ws *Websocket) WriteNotifier(notifier *Notifier) error {
return ws.Write(notifier.messageType, notifier.getMessageBody())
}
func (ws *Websocket) WriteError(errorMessage string) error {
return ws.Write("error", errorMessage)
}
// Creates listeners for the given notifiers and loops forever to pass their output directly through to the websocket.
func (ws *Websocket) HandleNotifiers(notifiers ...*Notifier) {
// Use reflection to dynamically build a select/case structure for all the notifiers.
listeners := make([]reflect.SelectCase, len(notifiers))
for i, notifier := range notifiers {
listener := notifier.listen()
defer close(listener)
listeners[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(listener)}
// Send each notifier's respective data immediately upon connection to bootstrap the client state.
if notifier.messageProducer != nil {
err := ws.WriteNotifier(notifier)
if err != nil {
log.Printf("Websocket error writing inital value for notifier %v: %v", notifier, err)
return
}
}
}
for {
// Block until a message is available on any of the channels.
chosenIndex, value, ok := reflect.Select(listeners)
if !ok {
log.Printf("Channel for notifier %v closed unexpectedly.", notifiers[chosenIndex])
return
}
message, ok := value.Interface().(messageEnvelope)
if !ok {
log.Printf("Channel for notifier %v sent unexpected value %v.", notifiers[chosenIndex], value)
continue
}
// Forward the message verbatim on to the websocket.
err := ws.Write(message.messageType, message.messageBody)
if err != nil {
// The client has probably closed the connection; nothing to do here.
return
}
}
}

116
websocket/websocket_test.go Normal file
View File

@@ -0,0 +1,116 @@
// Copyright 2018 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
package websocket
import (
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestWebsocket(t *testing.T) {
// Set up some fake notifiers.
notifier1 := NewNotifier("messageType1", func() interface{} { return "test message" })
notifier2 := NewNotifier("messageType2", nil)
changingValue := 123.45
notifier3 := NewNotifier("messageType3", func() interface{} { return changingValue })
// Start up a fake server with a trivial websocket handler.
testWebsocketHandler := func(w http.ResponseWriter, r *http.Request) {
ws, err := NewWebsocket(w, r)
assert.Nil(t, err)
defer ws.Close()
// Subscribe the websocket to the notifiers whose messages will be passed on, in a separate goroutine.
go ws.HandleNotifiers(notifier3, notifier2, notifier1)
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {
messageType, data, err := ws.Read()
if err != nil {
if err == io.EOF {
// Client has closed the connection; nothing to do here.
return
} else {
assert.Fail(t, err.Error())
return
}
}
switch messageType {
case "sendMessageType1":
ws.WriteNotifier(notifier1)
case "sendError":
ws.WriteError("error message")
default:
// Echo the commands back out.
err = ws.Write(messageType, data)
assert.Nil(t, err)
}
}
}
handler := http.NewServeMux()
handler.HandleFunc("/", testWebsocketHandler)
server := httptest.NewServer(handler)
defer server.Close()
wsUrl := "ws" + server.URL[len("http"):]
// Create a client connection to the websocket handler on the server.
conn, _, err := websocket.DefaultDialer.Dial(wsUrl, nil)
assert.Nil(t, err)
ws := NewTestWebsocket(conn)
// Ensure the initial messages are sent upon connection.
assertMessage(t, ws, "messageType3", changingValue)
assertMessage(t, ws, "messageType1", "test message")
// Trigger and read notifications.
notifier2.Notify()
assertMessage(t, ws, "messageType2", nil)
notifier1.Notify()
assertMessage(t, ws, "messageType1", "test message")
notifier3.Notify()
assertMessage(t, ws, "messageType3", changingValue)
changingValue = 254.254
notifier3.Notify()
assertMessage(t, ws, "messageType3", changingValue)
notifier1.NotifyWithMessage("test message 2")
assertMessage(t, ws, "messageType1", "test message 2")
notifier3.NotifyWithMessage("test message 3")
assertMessage(t, ws, "messageType3", "test message 3")
// Test sending commands back.
ws.Write("sendMessageType1", nil)
assertMessage(t, ws, "messageType1", "test message")
ws.Write("messageType4", "test message 4")
assertMessage(t, ws, "messageType4", "test message 4")
ws.Write("sendError", nil)
assertMessage(t, ws, "error", "error message")
// Ensure the read times out if there is nothing to read.
_, _, err = ws.ReadWithTimeout(time.Millisecond)
if assert.NotNil(t, err) {
assert.Contains(t, err.Error(), "timed out")
}
// Test that closing the connection eliminates the listeners once another message is sent.
assert.Nil(t, ws.Close())
time.Sleep(time.Millisecond)
notifier1.Notify()
time.Sleep(time.Millisecond)
notifier1.Notify()
assert.Equal(t, 0, len(notifier1.listeners))
}
func assertMessage(t *testing.T, ws *Websocket, expectedMessageType string, expectedMessageBody interface{}) {
messageType, messageBody, err := ws.ReadWithTimeout(time.Second)
if assert.Nil(t, err) {
assert.Equal(t, expectedMessageType, messageType)
assert.Equal(t, expectedMessageBody, messageBody)
}
}