diff --git a/arena.go b/arena.go index e48ed1b..c8b7427 100644 --- a/arena.go +++ b/arena.go @@ -42,12 +42,24 @@ type MatchTiming struct { EndgameTimeLeftSec int } +type RealtimeScore struct { + CurrentScore Score + CurrentCycle Cycle + AutoPreloadedBalls int + AutoCommitted bool + TeleopCommitted bool + undoAutoScores []Score + undoCycles []Cycle +} + type Arena struct { AllianceStations map[string]*AllianceStation MatchState int CanStartMatch bool matchTiming MatchTiming currentMatch *Match + redRealtimeScore *RealtimeScore + blueRealtimeScore *RealtimeScore matchStartTime time.Time lastDsPacketTime time.Time matchStateNotifier *Notifier @@ -164,6 +176,11 @@ func (arena *Arena) LoadMatch(match *Match) error { if err != nil { return err } + + // Reset the realtime scores. + arena.redRealtimeScore = new(RealtimeScore) + arena.blueRealtimeScore = new(RealtimeScore) + arena.matchLoadTeamsNotifier.Notify(nil) return nil } diff --git a/displays.go b/displays.go index 6d184f8..e6e0175 100644 --- a/displays.go +++ b/displays.go @@ -7,9 +7,11 @@ package main import ( "fmt" + "github.com/gorilla/mux" "io" "log" "net/http" + "strconv" "text/template" ) @@ -166,3 +168,205 @@ func AnnouncerDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) { } } } + +// Renders the scoring interface which enables input of scores in real-time. +func ScoringDisplayHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + alliance := vars["alliance"] + if alliance != "red" && alliance != "blue" { + handleWebErr(w, fmt.Errorf("Invalid alliance '%s'.", alliance)) + return + } + + template, err := template.ParseFiles("templates/scoring_display.html", "templates/base.html") + if err != nil { + handleWebErr(w, err) + return + } + data := struct { + *EventSettings + Alliance string + }{eventSettings, alliance} + err = template.ExecuteTemplate(w, "base", data) + if err != nil { + handleWebErr(w, err) + return + } +} + +// The websocket endpoint for the scoring interface client to send control commands and receive status updates. +func ScoringDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + alliance := vars["alliance"] + if alliance != "red" && alliance != "blue" { + handleWebErr(w, fmt.Errorf("Invalid alliance '%s'.", alliance)) + return + } + var score **RealtimeScore + if alliance == "red" { + score = &mainArena.redRealtimeScore + } else { + score = &mainArena.blueRealtimeScore + } + + websocket, err := NewWebsocket(w, r) + if err != nil { + handleWebErr(w, err) + return + } + defer websocket.Close() + + matchLoadTeamsListener := mainArena.matchLoadTeamsNotifier.Listen() + defer close(matchLoadTeamsListener) + + // Send the various notifications immediately upon connection. + err = websocket.Write("score", *score) + 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 = "score" + message = *score + } + 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 "preload": + if !(*score).AutoCommitted { + preloadedBallsStr, ok := data.(string) + if !ok { + websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType)) + continue + } + preloadedBalls, err := strconv.Atoi(preloadedBallsStr) + (*score).AutoPreloadedBalls = preloadedBalls + if err != nil { + websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType)) + continue + } + } + case "mobility": + if !(*score).AutoCommitted { + (*score).undoAutoScores = append((*score).undoAutoScores, (*score).CurrentScore) + (*score).CurrentScore.AutoMobilityBonuses += 1 + } + case "scoredHighHot": + if !(*score).AutoCommitted { + (*score).undoAutoScores = append((*score).undoAutoScores, (*score).CurrentScore) + (*score).CurrentScore.AutoHighHot += 1 + } + case "scoredHigh": + if !(*score).AutoCommitted { + (*score).undoAutoScores = append((*score).undoAutoScores, (*score).CurrentScore) + (*score).CurrentScore.AutoHigh += 1 + } else if !(*score).TeleopCommitted && !(*score).CurrentCycle.ScoredHigh { + (*score).undoCycles = append((*score).undoCycles, (*score).CurrentCycle) + (*score).CurrentCycle.ScoredHigh = true + (*score).CurrentCycle.ScoredLow = false + (*score).CurrentCycle.DeadBall = false + } + case "scoredLowHot": + if !(*score).AutoCommitted { + (*score).undoAutoScores = append((*score).undoAutoScores, (*score).CurrentScore) + (*score).CurrentScore.AutoLowHot += 1 + } + case "scoredLow": + if !(*score).AutoCommitted { + (*score).undoAutoScores = append((*score).undoAutoScores, (*score).CurrentScore) + (*score).CurrentScore.AutoLow += 1 + } else if !(*score).TeleopCommitted && !(*score).CurrentCycle.ScoredLow { + (*score).undoCycles = append((*score).undoCycles, (*score).CurrentCycle) + (*score).CurrentCycle.ScoredHigh = false + (*score).CurrentCycle.ScoredLow = true + (*score).CurrentCycle.DeadBall = false + } + case "assist": + if !(*score).TeleopCommitted && (*score).CurrentCycle.Assists < 3 { + (*score).undoCycles = append((*score).undoCycles, (*score).CurrentCycle) + (*score).CurrentCycle.Assists += 1 + } + case "truss": + if !(*score).TeleopCommitted && !(*score).CurrentCycle.Truss { + (*score).undoCycles = append((*score).undoCycles, (*score).CurrentCycle) + (*score).CurrentCycle.Truss = true + } + case "catch": + if !(*score).TeleopCommitted && !(*score).CurrentCycle.Catch && (*score).CurrentCycle.Truss { + (*score).undoCycles = append((*score).undoCycles, (*score).CurrentCycle) + (*score).CurrentCycle.Catch = true + } + case "deadBall": + if !(*score).TeleopCommitted && !(*score).CurrentCycle.DeadBall { + (*score).undoCycles = append((*score).undoCycles, (*score).CurrentCycle) + (*score).CurrentCycle.ScoredHigh = false + (*score).CurrentCycle.ScoredLow = false + (*score).CurrentCycle.DeadBall = true + } + case "commit": + if !(*score).AutoCommitted { + (*score).AutoCommitted = true + } else if !(*score).TeleopCommitted { + if (*score).CurrentCycle.ScoredLow || (*score).CurrentCycle.ScoredHigh || + (*score).CurrentCycle.DeadBall { + (*score).CurrentScore.Cycles = append((*score).CurrentScore.Cycles, (*score).CurrentCycle) + (*score).CurrentCycle = Cycle{} + (*score).undoCycles = []Cycle{} + } + } + case "commitMatch": + (*score).AutoCommitted = true + (*score).TeleopCommitted = true + if (*score).CurrentCycle != (Cycle{}) { + // Commit last cycle. + (*score).CurrentScore.Cycles = append((*score).CurrentScore.Cycles, (*score).CurrentCycle) + } + case "undo": + if !(*score).AutoCommitted && len((*score).undoAutoScores) > 0 { + (*score).CurrentScore = (*score).undoAutoScores[len((*score).undoAutoScores)-1] + (*score).undoAutoScores = (*score).undoAutoScores[0 : len((*score).undoAutoScores)-1] + } else if !(*score).TeleopCommitted && len((*score).undoCycles) > 0 { + (*score).CurrentCycle = (*score).undoCycles[len((*score).undoCycles)-1] + (*score).undoCycles = (*score).undoCycles[0 : len((*score).undoCycles)-1] + } + default: + websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType)) + continue + } + + // Send out the score again after handling the command, as it most likely changed as a result. + err = websocket.Write("score", *score) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + } +} diff --git a/static/css/cheesy-arena.css b/static/css/cheesy-arena.css index d56219f..3e2438c 100644 --- a/static/css/cheesy-arena.css +++ b/static/css/cheesy-arena.css @@ -60,3 +60,14 @@ .nowrap { white-space: nowrap; } +.scoring { + font-size: 20px; + font-weight: bold; + color: #333; +} +.scoring-comment { + font-size: 20px; +} +.scoring-message { + color: #f00; +} diff --git a/static/js/scoring_display.js b/static/js/scoring_display.js new file mode 100644 index 0000000..ff0e006 --- /dev/null +++ b/static/js/scoring_display.js @@ -0,0 +1,122 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Client-side logic for the scoring interface. + +var websocket; + +var handleScore = function(data) { + // Update autonomous period values. + var score = data.CurrentScore; + $("#autoPreloadedBalls").text(data.AutoPreloadedBalls); + $("#autoMobilityBonuses").text(score.AutoMobilityBonuses); + $("#autoHighHot").text(score.AutoHighHot); + $("#autoHigh").text(score.AutoHigh); + $("#autoLowHot").text(score.AutoLowHot); + $("#autoLow").text(score.AutoLow); + var unscoredBalls = data.AutoPreloadedBalls - score.AutoHighHot - score.AutoHigh - score.AutoLowHot - + score.AutoLow; + $("#autoUnscoredBalls").text(unscoredBalls); + + // Update teleoperated period current cycle values. + var cycle = data.CurrentCycle; + $("#assists").text(cycle.Assists); + $("#truss").text(cycle.Truss ? "X" : ""); + $("#catch").text(cycle.Catch ? "X" : ""); + $("#scoredHigh").text(cycle.ScoredHigh ? "X" : ""); + $("#scoredLow").text(cycle.ScoredLow ? "X" : ""); + $("#deadBall").text(cycle.DeadBall ? "X" : ""); + if (cycle.ScoredHigh || cycle.ScoredLow || cycle.DeadBall) { + $("#teleopMessage").html("Press Enter to commit cycle and light pedestal.
This cannot be undone."); + } else { + $("#teleopMessage").text(""); + } + + // Update component visibility. + if (!data.AutoCommitted) { + $("#autoCommands").show(); + $("#autoScore").show(); + $("#teleopCommands").hide(); + $("#teleopScore").hide(); + $("#commitMatchScore").show(); + $("#waitingMessage").hide(); + } else if (!data.TeleopCommitted) { + $("#autoCommands").hide(); + $("#autoScore").hide(); + $("#teleopCommands").show(); + $("#teleopScore").show(); + $("#commitMatchScore").show(); + $("#waitingMessage").hide(); + } else { + $("#autoCommands").hide(); + $("#autoScore").hide(); + $("#teleopCommands").hide(); + $("#teleopScore").hide(); + $("#commitMatchScore").hide(); + $("#waitingMessage").show(); + } +}; + +var handleKeyPress = function(event) { + var key = String.fromCharCode(event.keyCode); + switch(key) { + case "0": + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": + case "7": + case "8": + case "9": + websocket.send("preload", key); + break; + case "m": + websocket.send("mobility"); + break; + case "H": + websocket.send("scoredHighHot"); + break; + case "h": + websocket.send("scoredHigh"); + break; + case "L": + websocket.send("scoredLowHot"); + break; + case "l": + websocket.send("scoredLow"); + break; + case "a": + websocket.send("assist"); + break; + case "t": + websocket.send("truss"); + break; + case "c": + websocket.send("catch"); + break; + case "d": + websocket.send("deadBall"); + break; + case "\r": + websocket.send("commit"); + break; + case "u": + websocket.send("undo"); + break; + } +}; + +var commitMatchScore = function() { + websocket.send("commitMatch"); +}; + +$(function() { + // Set up the websocket back to the server. + websocket = new CheesyWebsocket("/displays/scoring/" + alliance + "/websocket", { + score: function(event) { handleScore(event.data); } + }); + + $(document).keypress(handleKeyPress); +}); diff --git a/templates/base.html b/templates/base.html index 9e2bc1f..41de336 100644 --- a/templates/base.html +++ b/templates/base.html @@ -59,7 +59,8 @@
  • Pit
  • Announcer
  • Referee
  • -
  • Scoring
  • +
  • Scoring – Red
  • +
  • Scoring – Blue
  • Field Monitor
  • diff --git a/templates/scoring_display.html b/templates/scoring_display.html new file mode 100644 index 0000000..4ba83de --- /dev/null +++ b/templates/scoring_display.html @@ -0,0 +1,156 @@ +{{define "title"}}Scoring{{end}} +{{define "body"}} +
    +
    + +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +{{end}} +{{define "script"}} + + +{{end}} diff --git a/web.go b/web.go index 0663e3a..a153da9 100644 --- a/web.go +++ b/web.go @@ -135,6 +135,8 @@ func newHandler() http.Handler { router.HandleFunc("/displays/pit", PitDisplayHandler).Methods("GET") router.HandleFunc("/displays/announcer", AnnouncerDisplayHandler).Methods("GET") router.HandleFunc("/displays/announcer/websocket", AnnouncerDisplayWebsocketHandler).Methods("GET") + router.HandleFunc("/displays/scoring/{alliance}", ScoringDisplayHandler).Methods("GET") + router.HandleFunc("/displays/scoring/{alliance}/websocket", ScoringDisplayWebsocketHandler).Methods("GET") router.HandleFunc("/api/rankings", RankingsApiHandler).Methods("GET") router.HandleFunc("/", IndexHandler).Methods("GET") return router