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 @@