Merge "Make image debug handler on webpage reliable"
diff --git a/aos/events/logging/log_reader.cc b/aos/events/logging/log_reader.cc
index 4e89a0e..1c3a349 100644
--- a/aos/events/logging/log_reader.cc
+++ b/aos/events/logging/log_reader.cc
@@ -276,6 +276,9 @@
 
           // Otherwise collect this one up as a node to look for a combined
           // channel from.  It is more efficient to compare nodes than channels.
+          LOG(WARNING) << "Failed to find channel "
+                       << finder.SplitChannelName(channel, connection)
+                       << " on node " << aos::FlatbufferToJson(node);
           remote_nodes.insert(connection->name()->string_view());
         }
       }
diff --git a/aos/network/timestamp_channel.cc b/aos/network/timestamp_channel.cc
index ab61051..fdaa031 100644
--- a/aos/network/timestamp_channel.cc
+++ b/aos/network/timestamp_channel.cc
@@ -2,6 +2,10 @@
 
 #include "absl/strings/str_cat.h"
 
+DEFINE_bool(combined_timestamp_channel_fallback, true,
+            "If true, fall back to using the combined timestamp channel if the "
+            "single timestamp channel doesn't exist for a timestamp.");
+
 namespace aos {
 namespace message_bridge {
 
@@ -12,7 +16,8 @@
 
 std::string ChannelTimestampFinder::SplitChannelName(
     const Channel *channel, const Connection *connection) {
-  return SplitChannelName(channel->name()->string_view(), channel->type()->str(), connection);
+  return SplitChannelName(channel->name()->string_view(),
+                          channel->type()->str(), connection);
 }
 
 std::string ChannelTimestampFinder::SplitChannelName(
@@ -47,6 +52,15 @@
     return split_timestamp_channel;
   }
 
+  if (!FLAGS_combined_timestamp_channel_fallback) {
+    LOG(FATAL) << "Failed to find new timestamp channel {\"name\": \""
+               << split_timestamp_channel_name << "\", \"type\": \""
+               << RemoteMessage::GetFullyQualifiedName() << "\"} for "
+               << configuration::CleanedChannelToString(channel)
+               << " connection " << aos::FlatbufferToJson(connection)
+               << " and --nocombined_timestamp_channel_fallback is set";
+  }
+
   const std::string shared_timestamp_channel_name =
       CombinedChannelName(connection->name()->string_view());
   const Channel *shared_timestamp_channel = configuration::GetChannel(
diff --git a/aos/starter/BUILD b/aos/starter/BUILD
index 7590b3b..5bf3811 100644
--- a/aos/starter/BUILD
+++ b/aos/starter/BUILD
@@ -100,6 +100,8 @@
         "//aos/events:pingpong_config",
         "//aos/events:pong",
     ],
+    # TODO(james): Fix tihs.
+    flaky = True,
     linkopts = ["-lstdc++fs"],
     shard_count = 4,
     # The roborio compiler doesn't support <filesystem>.
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 85395e1..d3faa58 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -50,6 +50,12 @@
 	Notes      []string
 }
 
+type Ranking struct {
+	TeamNumber         int
+	Losses, Wins, Ties int32
+	Rank, Dq           int32
+}
+
 // Opens a database at the specified port on localhost. We currently don't
 // support connecting to databases on other hosts.
 func NewDatabase(user string, password string, port int) (*Database, error) {
@@ -138,6 +144,24 @@
 		return nil, errors.New(fmt.Sprint("Failed to create notes table: ", err))
 	}
 
+	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS rankings (" +
+		"id SERIAL PRIMARY KEY, " +
+		"Losses INTEGER, " +
+		"Wins INTEGER, " +
+		"Ties INTEGER, " +
+		"Rank INTEGER, " +
+		"Dq INTEGER, " +
+		"TeamNumber INTEGER)")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to prepare rankings table creation: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec()
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to create rankings table: ", err))
+	}
+
 	return database, nil
 }
 
@@ -169,6 +193,16 @@
 		return errors.New(fmt.Sprint("Failed to drop notes table: ", err))
 	}
 	return nil
+
+	statement, err = database.Prepare("DROP TABLE IF EXISTS rankings")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare dropping rankings table: ", err))
+	}
+	_, err = statement.Exec()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to drop rankings table: ", err))
+	}
+	return nil
 }
 
 // This function will also populate the Stats table with six empty rows every time a match is added
@@ -278,6 +312,48 @@
 	return nil
 }
 
+func (database *Database) AddOrUpdateRankings(r Ranking) error {
+	statement, err := database.Prepare("UPDATE rankings SET " +
+		"Losses = $1, Wins = $2, Ties = $3, " +
+		"Rank = $4, Dq = $5, TeamNumber = $6 " +
+		"WHERE TeamNumber = $6")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare rankings database update: ", err))
+	}
+	defer statement.Close()
+
+	result, err := statement.Exec(r.Losses, r.Wins, r.Ties,
+		r.Rank, r.Dq, r.TeamNumber)
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to update rankings database: ", err))
+	}
+
+	numRowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to query rows affected: ", err))
+	}
+	if numRowsAffected == 0 {
+		statement, err := database.Prepare("INSERT INTO rankings(" +
+			"Losses, Wins, Ties, " +
+			"Rank, Dq, TeamNumber) " +
+			"VALUES (" +
+			"$1, $2, $3, " +
+			"$4, $5, $6)")
+		if err != nil {
+			return errors.New(fmt.Sprint("Failed to prepare insertion into rankings database: ", err))
+		}
+		defer statement.Close()
+
+		_, err = statement.Exec(r.Losses, r.Wins, r.Ties,
+			r.Rank, r.Dq, r.TeamNumber)
+		if err != nil {
+			return errors.New(fmt.Sprint("Failed to insert into rankings database: ", err))
+		}
+	}
+
+	return nil
+}
+
 func (database *Database) ReturnMatches() ([]Match, error) {
 	rows, err := database.Query("SELECT * FROM matches")
 	if err != nil {
@@ -328,6 +404,28 @@
 	return teams, nil
 }
 
+func (database *Database) ReturnRankings() ([]Ranking, error) {
+	rows, err := database.Query("SELECT * FROM rankings")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to SELECT * FROM rankings: ", err))
+	}
+	defer rows.Close()
+
+	all_rankings := make([]Ranking, 0)
+	for rows.Next() {
+		var ranking Ranking
+		var id int
+		err = rows.Scan(&id,
+			&ranking.Losses, &ranking.Wins, &ranking.Ties,
+			&ranking.Rank, &ranking.Dq, &ranking.TeamNumber)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from rankings: ", err))
+		}
+		all_rankings = append(all_rankings, ranking)
+	}
+	return all_rankings, nil
+}
+
 func (database *Database) QueryMatches(teamNumber_ int32) ([]Match, error) {
 	rows, err := database.Query("SELECT * FROM matches WHERE "+
 		"R1 = $1 OR R2 = $2 OR R3 = $3 OR B1 = $4 OR B2 = $5 OR B3 = $6",
@@ -400,6 +498,28 @@
 	return NotesData{TeamNumber, notes}, nil
 }
 
+func (database *Database) QueryRankings(TeamNumber int) ([]Ranking, error) {
+	rows, err := database.Query("SELECT * FROM rankings WHERE TeamNumber = $1", TeamNumber)
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to select from rankings: ", err))
+	}
+	defer rows.Close()
+
+	all_rankings := make([]Ranking, 0)
+	for rows.Next() {
+		var ranking Ranking
+		var id int
+		err = rows.Scan(&id,
+			&ranking.Losses, &ranking.Wins, &ranking.Ties,
+			&ranking.Rank, &ranking.Dq, &ranking.TeamNumber)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from rankings: ", err))
+		}
+		all_rankings = append(all_rankings, ranking)
+	}
+	return all_rankings, nil
+}
+
 func (database *Database) AddNotes(data NotesData) error {
 	if len(data.Notes) > 1 {
 		return errors.New("Can only insert one row of notes at a time")
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 02e8d50..5725dcb 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -82,6 +82,36 @@
 	}
 }
 
+func TestAddOrUpdateRankingsDB(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	correct := []Ranking{
+		Ranking{
+			TeamNumber: 123,
+			Losses:     1, Wins: 7, Ties: 0,
+			Rank: 2, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 125,
+			Losses:     2, Wins: 4, Ties: 0,
+			Rank: 2, Dq: 0,
+		},
+	}
+
+	for i := 0; i < len(correct); i++ {
+		err := fixture.db.AddOrUpdateRankings(correct[i])
+		check(t, err, "Failed to add ranking data")
+	}
+
+	got, err := fixture.db.ReturnRankings()
+	check(t, err, "Failed ReturnRankings()")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestAddToStatsDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
@@ -283,6 +313,54 @@
 	}
 }
 
+func TestQueryRankingsDB(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	testDatabase := []Ranking{
+		Ranking{
+			TeamNumber: 123,
+			Losses:     1, Wins: 7, Ties: 2,
+			Rank: 2, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 124,
+			Losses:     3, Wins: 4, Ties: 0,
+			Rank: 4, Dq: 2,
+		},
+		Ranking{
+			TeamNumber: 125,
+			Losses:     5, Wins: 2, Ties: 0,
+			Rank: 17, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 126,
+			Losses:     0, Wins: 7, Ties: 0,
+			Rank: 5, Dq: 0,
+		},
+	}
+
+	for i := 0; i < len(testDatabase); i++ {
+		err := fixture.db.AddOrUpdateRankings(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add rankings ", i))
+	}
+
+	correct := []Ranking{
+		Ranking{
+			TeamNumber: 126,
+			Losses:     0, Wins: 7, Ties: 0,
+			Rank: 5, Dq: 0,
+		},
+	}
+
+	got, err := fixture.db.QueryRankings(126)
+	check(t, err, "Failed QueryRankings()")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestReturnMatchDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
@@ -328,6 +406,46 @@
 	}
 }
 
+func TestReturnRankingsDB(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	correct := []Ranking{
+		Ranking{
+			TeamNumber: 123,
+			Losses:     1, Wins: 7, Ties: 2,
+			Rank: 2, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 124,
+			Losses:     3, Wins: 4, Ties: 0,
+			Rank: 4, Dq: 2,
+		},
+		Ranking{
+			TeamNumber: 125,
+			Losses:     5, Wins: 2, Ties: 0,
+			Rank: 17, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 126,
+			Losses:     0, Wins: 7, Ties: 0,
+			Rank: 5, Dq: 0,
+		},
+	}
+
+	for i := 0; i < len(correct); i++ {
+		err := fixture.db.AddOrUpdateRankings(correct[i])
+		check(t, err, fmt.Sprint("Failed to add rankings", i))
+	}
+
+	got, err := fixture.db.ReturnRankings()
+	check(t, err, "Failed ReturnRankings()")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestReturnStatsDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
@@ -400,6 +518,59 @@
 	}
 }
 
+func TestRankingsDbUpdate(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	testDatabase := []Ranking{
+		Ranking{
+			TeamNumber: 123,
+			Losses:     1, Wins: 7, Ties: 2,
+			Rank: 2, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 124,
+			Losses:     3, Wins: 4, Ties: 0,
+			Rank: 4, Dq: 2,
+		},
+		Ranking{
+			TeamNumber: 125,
+			Losses:     5, Wins: 2, Ties: 0,
+			Rank: 17, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 126,
+			Losses:     0, Wins: 7, Ties: 0,
+			Rank: 5, Dq: 0,
+		},
+		Ranking{
+			TeamNumber: 125,
+			Losses:     2, Wins: 4, Ties: 1,
+			Rank: 5, Dq: 0,
+		},
+	}
+
+	for i := 0; i < len(testDatabase); i++ {
+		err := fixture.db.AddOrUpdateRankings(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add rankings ", i))
+	}
+
+	correct := []Ranking{
+		Ranking{
+			TeamNumber: 125,
+			Losses:     2, Wins: 4, Ties: 1,
+			Rank: 5, Dq: 0,
+		},
+	}
+
+	got, err := fixture.db.QueryRankings(125)
+	check(t, err, "Failed QueryRankings()")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestNotes(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
diff --git a/scouting/scraping/types.go b/scouting/scraping/types.go
index 9283ac2..d8a79f1 100644
--- a/scouting/scraping/types.go
+++ b/scouting/scraping/types.go
@@ -5,20 +5,20 @@
 }
 
 type Rank struct {
-	MatchesPlayed int       `json:"matches_played"`
-	QualAverage   int       `json:"qual_average"`
+	MatchesPlayed int32     `json:"matches_played"`
+	QualAverage   int32     `json:"qual_average"`
 	ExtraStats    []float64 `json:"extra_stats"`
 	SortOrders    []float64 `json:"sort_orders"`
 	Records       Record    `json:"record"`
-	Rank          int       `json:"rank"`
-	Dq            int       `json:"dq"`
+	Rank          int32     `json:"rank"`
+	Dq            int32     `json:"dq"`
 	TeamKey       string    `json:"team_key"`
 }
 
 type Record struct {
-	Losses int `json:"losses"`
-	Wins   int `json:"wins"`
-	Ties   int `json:"ties"`
+	Losses int32 `json:"losses"`
+	Wins   int32 `json:"wins"`
+	Ties   int32 `json:"ties"`
 }
 
 // Match holds the TBA data for a given match
diff --git a/scouting/webserver/static/BUILD b/scouting/webserver/static/BUILD
index 3166853..0cf22f3 100644
--- a/scouting/webserver/static/BUILD
+++ b/scouting/webserver/static/BUILD
@@ -13,6 +13,7 @@
     name = "static_test",
     srcs = ["static_test.go"],
     data = [
+        "test_pages/index.html",
         "test_pages/page.txt",
         "test_pages/root.txt",
     ],
diff --git a/scouting/webserver/static/static.go b/scouting/webserver/static/static.go
index 92d8086..4c46fe7 100644
--- a/scouting/webserver/static/static.go
+++ b/scouting/webserver/static/static.go
@@ -2,7 +2,15 @@
 
 // A year agnostic way to serve static http files.
 import (
+	"crypto/sha256"
+	"errors"
+	"fmt"
+	"io"
+	"log"
 	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
 	"time"
 
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
@@ -20,10 +28,98 @@
 	"X-Accel-Expires": "0",
 }
 
-func NoCache(h http.Handler) http.Handler {
+func MaybeNoCache(h http.Handler) http.Handler {
 	fn := func(w http.ResponseWriter, r *http.Request) {
-		for k, v := range noCacheHeaders {
-			w.Header().Set(k, v)
+		// We force the browser not to cache index.html so that
+		// browsers will notice when the bundle gets updated.
+		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
+			for k, v := range noCacheHeaders {
+				w.Header().Set(k, v)
+			}
+		}
+
+		h.ServeHTTP(w, r)
+	}
+
+	return http.HandlerFunc(fn)
+}
+
+// Computes the sha256 of the specified file.
+func computeSha256(path string) (string, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return "", errors.New(fmt.Sprint("Failed to open ", path, ": ", err))
+	}
+	defer file.Close()
+
+	hash := sha256.New()
+	if _, err := io.Copy(hash, file); err != nil {
+		return "", errors.New(fmt.Sprint("Failed to compute sha256 of ", path, ": ", err))
+	}
+	return fmt.Sprintf("%x", hash.Sum(nil)), nil
+}
+
+// Finds the checksums for all the files in the specified directory. This is a
+// best effort only. If for some reason we fail to compute the checksum of
+// something, we just move on.
+func findAllFileShas(directory string) map[string]string {
+	shaSums := make(map[string]string)
+
+	// Find the checksums for all the files.
+	err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			log.Println("Walk() didn't want to deal with ", path, ":", err)
+			return nil
+		}
+		if info.IsDir() {
+			// We only care about computing checksums of files.
+			// Ignore directories.
+			return nil
+		}
+		hash, err := computeSha256(path)
+		if err != nil {
+			log.Println(err)
+			return nil
+		}
+		shaSums[hash] = "/" + strings.TrimPrefix(path, directory)
+		return nil
+	})
+	if err != nil {
+		log.Fatal("Got unexpected error from Walk(): ", err)
+	}
+
+	return shaSums
+}
+
+func HandleShaUrl(directory string, h http.Handler) http.Handler {
+	shaSums := findAllFileShas(directory)
+
+	fn := func(w http.ResponseWriter, r *http.Request) {
+		// We expect the path portion to look like this:
+		// /sha256/<checksum>/path...
+		// Splitting on / means we end up with this list:
+		// [0] ""
+		// [1] "sha256"
+		// [2] "<checksum>"
+		// [3-] path...
+		parts := strings.Split(r.URL.Path, "/")
+		if len(parts) < 4 {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		if parts[0] != "" || parts[1] != "sha256" {
+			// Something is fundamentally wrong. We told the
+			// framework to only give is /sha256/ requests.
+			log.Fatal("This handler should not be called for " + r.URL.Path)
+		}
+		hash := parts[2]
+		if path, ok := shaSums[hash]; ok {
+			// We found a file with this checksum. Serve that file.
+			r.URL.Path = path
+		} else {
+			// No file with this checksum found.
+			w.WriteHeader(http.StatusNotFound)
+			return
 		}
 
 		h.ServeHTTP(w, r)
@@ -35,8 +131,8 @@
 // Serve pages in the specified directory.
 func ServePages(scoutingServer server.ScoutingServer, directory string) {
 	// Serve the / endpoint given a folder of pages.
-	scoutingServer.Handle("/", NoCache(http.FileServer(http.Dir(directory))))
-	// Make an exception for pictures. We don't want the pictures to be
-	// pulled every time the page is refreshed.
-	scoutingServer.Handle("/pictures/", http.FileServer(http.Dir(directory)))
+	scoutingServer.Handle("/", MaybeNoCache(http.FileServer(http.Dir(directory))))
+
+	// Also serve files in a checksum-addressable manner.
+	scoutingServer.Handle("/sha256/", HandleShaUrl(directory, http.FileServer(http.Dir(directory))))
 }
diff --git a/scouting/webserver/static/static_test.go b/scouting/webserver/static/static_test.go
index 3524c05..09ed940 100644
--- a/scouting/webserver/static/static_test.go
+++ b/scouting/webserver/static/static_test.go
@@ -9,6 +9,12 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 )
 
+func expectEqual(t *testing.T, actual string, expected string) {
+	if actual != expected {
+		t.Error("Expected ", actual, " to equal ", expected)
+	}
+}
+
 func TestServing(t *testing.T) {
 	cases := []struct {
 		// The path to request from the server.
@@ -17,6 +23,7 @@
 		// specified path.
 		expectedData string
 	}{
+		{"/", "<h1>This is the index</h1>\n"},
 		{"/root.txt", "Hello, this is the root page!"},
 		{"/page.txt", "Hello from a page!"},
 	}
@@ -24,37 +31,67 @@
 	scoutingServer := server.NewScoutingServer()
 	ServePages(scoutingServer, "test_pages")
 	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
 
 	// Go through all the test cases, and run them against the running webserver.
 	for _, c := range cases {
 		dataReceived := getData(c.path, t)
-		if dataReceived != c.expectedData {
-			t.Errorf("Got %q, but expected %q", dataReceived, c.expectedData)
-		}
+		expectEqual(t, dataReceived, c.expectedData)
 	}
-
-	scoutingServer.Stop()
 }
 
-func TestCache(t *testing.T) {
+// Makes sure that requesting / sets the proper headers so it doesn't get
+// cached.
+func TestDisallowedCache(t *testing.T) {
 	scoutingServer := server.NewScoutingServer()
 	ServePages(scoutingServer, "test_pages")
 	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	resp, err := http.Get("http://localhost:8080/")
+	if err != nil {
+		t.Fatal("Failed to get data ", err)
+	}
+	expectEqual(t, resp.Header.Get("Expires"), "Thu, 01 Jan 1970 00:00:00 UTC")
+	expectEqual(t, resp.Header.Get("Cache-Control"), "no-cache, private, max-age=0")
+	expectEqual(t, resp.Header.Get("Pragma"), "no-cache")
+	expectEqual(t, resp.Header.Get("X-Accel-Expires"), "0")
+}
+
+// Makes sure that requesting anything other than / doesn't set the "do not
+// cache" headers.
+func TestAllowedCache(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	ServePages(scoutingServer, "test_pages")
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
 
 	resp, err := http.Get("http://localhost:8080/root.txt")
 	if err != nil {
 		t.Fatalf("Failed to get data ", err)
 	}
-	compareString(resp.Header.Get("Expires"), "Thu, 01 Jan 1970 00:00:00 UTC", t)
-	compareString(resp.Header.Get("Cache-Control"), "no-cache, private, max-age=0", t)
-	compareString(resp.Header.Get("Pragma"), "no-cache", t)
-	compareString(resp.Header.Get("X-Accel-Expires"), "0", t)
+	expectEqual(t, resp.Header.Get("Expires"), "")
+	expectEqual(t, resp.Header.Get("Cache-Control"), "")
+	expectEqual(t, resp.Header.Get("Pragma"), "")
+	expectEqual(t, resp.Header.Get("X-Accel-Expires"), "")
 }
 
-func compareString(actual string, expected string, t *testing.T) {
-	if actual != expected {
-		t.Errorf("Expected ", actual, " to equal ", expected)
+func TestSha256(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	ServePages(scoutingServer, "test_pages")
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	// Validate a valid checksum.
+	dataReceived := getData("sha256/553b9b29647a112136986cf93c57b988d1f12dc43d3b774f14a24e58d272dbff/root.txt", t)
+	expectEqual(t, dataReceived, "Hello, this is the root page!")
+
+	// Make a request with an invalid checksum and make sure we get a 404.
+	resp, err := http.Get("http://localhost:8080/sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/root.txt")
+	if err != nil {
+		t.Fatal("Failed to get data ", err)
 	}
+	expectEqual(t, resp.Status, "404 Not Found")
 }
 
 // Retrieves the data at the specified path. If an error occurs, the test case
@@ -66,7 +103,7 @@
 	}
 	// Error out if the return status is anything other than 200 OK.
 	if resp.Status != "200 OK" {
-		t.Fatalf("Received a status code other than 200")
+		t.Fatal("Received a status code other than 200:", resp.Status)
 	}
 	// Read the response body.
 	body, err := ioutil.ReadAll(resp.Body)
diff --git a/scouting/webserver/static/test_pages/index.html b/scouting/webserver/static/test_pages/index.html
new file mode 100644
index 0000000..d769db4
--- /dev/null
+++ b/scouting/webserver/static/test_pages/index.html
@@ -0,0 +1 @@
+<h1>This is the index</h1>
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 726749b..647af14 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -66,6 +66,29 @@
     cmd = "cp $(location :main_bundle_compiled)/main_bundle.min.js $(OUTS)",
 )
 
+py_binary(
+    name = "index_html_generator",
+    srcs = ["index_html_generator.py"],
+)
+
+genrule(
+    name = "generate_index_html",
+    srcs = [
+        "index.template.html",
+        "main_bundle_file.js",
+    ],
+    outs = ["index.html"],
+    cmd = " ".join([
+        "$(location :index_html_generator)",
+        "--template $(location index.template.html)",
+        "--bundle $(location main_bundle_file.js)",
+        "--output $(location index.html)",
+    ]),
+    tools = [
+        ":index_html_generator",
+    ],
+)
+
 # Create a copy of zone.js here so that we can have a predictable path to
 # source it from on the webserver.
 genrule(
diff --git a/scouting/www/app.ts b/scouting/www/app.ts
index 85620f1..ab9c229 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -6,6 +6,10 @@
   | 'Entry'
   | 'ImportMatchList'
   | 'ShiftSchedule';
+
+// Ignore the guard for tabs that don't require the user to enter any data.
+const unguardedTabs: Tab[] = ['MatchList', 'ImportMatchList'];
+
 type TeamInMatch = {
   teamNumber: number;
   matchNumber: number;
@@ -29,12 +33,14 @@
 
   constructor() {
     window.addEventListener('beforeunload', (e) => {
-      if (!this.block_alerts.nativeElement.checked) {
-        // Based on
-        // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
-        // This combination ensures a dialog will be shown on most browsers.
-        e.preventDefault();
-        e.returnValue = '';
+      if (!unguardedTabs.includes(this.tab)) {
+        if (!this.block_alerts.nativeElement.checked) {
+          // Based on
+          // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
+          // This combination ensures a dialog will be shown on most browsers.
+          e.preventDefault();
+          e.returnValue = '';
+        }
       }
     });
   }
@@ -51,8 +57,12 @@
   switchTabToGuarded(tab: Tab) {
     let shouldSwitch = true;
     if (this.tab !== tab) {
-      if (!this.block_alerts.nativeElement.checked) {
-        shouldSwitch = window.confirm('Leave current page?');
+      if (!unguardedTabs.includes(this.tab)) {
+        if (!this.block_alerts.nativeElement.checked) {
+          shouldSwitch = window.confirm(
+            'Leave current page? You will lose all data.'
+          );
+        }
       }
     }
     if (shouldSwitch) {
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 0e76268..d0805d4 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -37,7 +37,10 @@
 
   <div *ngSwitchCase="'Auto'" id="auto" class="container-fluid">
     <div class="row">
-      <img src="/pictures/field/quadrants.jpeg" alt="Quadrants Image" />
+      <img
+        src="/sha256/cbb99a057a2504e80af526dae7a0a04121aed84c56a6f4889e9576fe1c20c61e/pictures/field/quadrants.jpeg"
+        alt="Quadrants Image"
+      />
       <form>
         <input
           type="radio"
@@ -75,7 +78,10 @@
       </form>
     </div>
     <div class="row">
-      <img src="/pictures/field/balls.jpeg" alt="Image" />
+      <img
+        src="/sha256/cbb99a057a2504e80af526dae7a0a04121aed84c56a6f4889e9576fe1c20c61e/pictures/field/balls.jpeg"
+        alt="Image"
+      />
       <form>
         <!--Choice for each ball location-->
         <input
diff --git a/scouting/www/index.html b/scouting/www/index.template.html
similarity index 79%
rename from scouting/www/index.html
rename to scouting/www/index.template.html
index c9e2bb3..303dca1 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.template.html
@@ -14,6 +14,7 @@
   </head>
   <body>
     <my-app></my-app>
-    <script src="./main_bundle_file.js"></script>
+    <!-- The path here is auto-generated to be /sha256/<checksum>/main_bundle_file.js. -->
+    <script src="{MAIN_BUNDLE_FILE}"></script>
   </body>
 </html>
diff --git a/scouting/www/index_html_generator.py b/scouting/www/index_html_generator.py
new file mode 100644
index 0000000..3b057fd
--- /dev/null
+++ b/scouting/www/index_html_generator.py
@@ -0,0 +1,28 @@
+"""Generates index.html with the right checksum for main_bundle_file.js filled in."""
+
+import argparse
+import hashlib
+import sys
+from pathlib import Path
+
+def compute_sha256(filepath):
+    return hashlib.sha256(filepath.read_bytes()).hexdigest()
+
+def main(argv):
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--template", type=str)
+    parser.add_argument("--bundle", type=str)
+    parser.add_argument("--output", type=str)
+    args = parser.parse_args(argv[1:])
+
+    template = Path(args.template).read_text()
+    bundle_path = Path(args.bundle)
+    bundle_sha256 = compute_sha256(bundle_path)
+
+    output = template.format(
+        MAIN_BUNDLE_FILE = f"/sha256/{bundle_sha256}/{bundle_path.name}",
+    )
+    Path(args.output).write_text(output)
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/y2022/BUILD b/y2022/BUILD
index 2e2f26d..5d29f04 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -7,6 +7,7 @@
     binaries = [
         ":setpoint_setter",
         "//aos/network:web_proxy_main",
+        "//aos/events/logging:log_cat",
     ],
     data = [
         ":aos_config",
@@ -58,6 +59,7 @@
         "//aos/network:message_bridge_server",
         "//aos/network:web_proxy_main",
         "//y2022/vision:camera_reader",
+        "//y2022/vision:ball_color_detector",
     ],
     target_compatible_with = ["//tools/platforms/hardware:raspberry_pi"],
     target_type = "pi",
@@ -100,6 +102,7 @@
             "//y2022/localizer:localizer_output_fbs",
             "//y2022/vision:calibration_fbs",
             "//y2022/vision:target_estimate_fbs",
+            "//y2022/vision:ball_color_fbs",
         ],
         target_compatible_with = ["@platforms//os:linux"],
         visibility = ["//visibility:public"],
@@ -147,6 +150,7 @@
         "//aos/network:remote_message_fbs",
         "//frc971/vision:vision_fbs",
         "//y2022/vision:calibration_fbs",
+        "//y2022/vision:ball_color_fbs",
     ],
     target_compatible_with = ["@platforms//os:linux"],
     visibility = ["//visibility:public"],
@@ -166,6 +170,7 @@
         "//aos/network:message_bridge_client_fbs",
         "//aos/network:message_bridge_server_fbs",
         "//aos/network:timestamp_fbs",
+        "//y2022/vision:ball_color_fbs",
         "//y2019/control_loops/drivetrain:target_selector_fbs",
         "//y2022/control_loops/superstructure:superstructure_goal_fbs",
         "//y2022/control_loops/superstructure:superstructure_output_fbs",
diff --git a/y2022/constants.cc b/y2022/constants.cc
index 88b0e90..cb85e94 100644
--- a/y2022/constants.cc
+++ b/y2022/constants.cc
@@ -132,15 +132,19 @@
 
   // Interpolation table for comp and practice robots
   r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
-      {1, {0.1, 19.0}},
-      {1.9, {0.1, 19.0}},  // 1.7 in reality
-      {2.12, {0.15, 18.8}},  // 2.006 in reality
-      {2.9, {0.25, 19.2}},  // 2.92 in reality
-      {3.8, {0.30, 20.8}},  // 3.8 in reality
-      {4.9, {0.32, 22.8}},  // 4.97 in reality
-      {6.9, {0.40, 24.0}},  // 6.1 in reality
-      {7.9, {0.40, 25.0}},  // 6.5 in reality
-      {10, {0.40, 24.0}},
+      {1.0, {0.0, 19.0}},
+      {1.6, {0.0, 19.0}},
+      {1.9, {0.1, 19.0}},
+      {2.12, {0.15, 18.8}},
+      {2.9, {0.25, 19.2}},
+
+      {3.8, {0.35, 20.6}},
+      {4.9, {0.4,  21.9}},
+      {6.0, {0.40, 24.0}},
+      {7.0, {0.40, 25.5}},
+
+      {7.8, {0.35, 26.9}},
+      {10.0, {0.35, 26.9}},
   });
 
   switch (team) {
@@ -169,16 +173,18 @@
       break;
 
     case kCompTeamNumber:
-      climber->potentiometer_offset = -0.0463847608752 - 0.0376876182111;
+      climber->potentiometer_offset =
+          -0.0463847608752 - 0.0376876182111 + 0.0629263851579;
 
       intake_front->potentiometer_offset =
-          2.79628370453323 - 0.0250288114832881;
+          2.79628370453323 - 0.0250288114832881 + 0.577152542437606;
       intake_front->subsystem_params.zeroing_constants
-          .measured_absolute_position = 0.274930238824366;
+          .measured_absolute_position = 0.26963366701647;
 
-      intake_back->potentiometer_offset = 3.1409576474047;
+      intake_back->potentiometer_offset =
+          3.1409576474047 + 0.278653334013286 + 0.00879137908308503;
       intake_back->subsystem_params.zeroing_constants
-          .measured_absolute_position = 0.280099007470002;
+          .measured_absolute_position = 0.242434593996789;
 
       turret->potentiometer_offset = -9.99970387166721 + 0.06415943 +
                                      0.073290115367682 - 0.0634440443622909 +
diff --git a/y2022/control_loops/superstructure/superstructure.cc b/y2022/control_loops/superstructure/superstructure.cc
index 3d5284e..b21dd0d 100644
--- a/y2022/control_loops/superstructure/superstructure.cc
+++ b/y2022/control_loops/superstructure/superstructure.cc
@@ -243,7 +243,7 @@
        .shooting = true});
 
   // Dont shoot if the robot is moving faster than this
-  constexpr double kMaxShootSpeed = 1.0;
+  constexpr double kMaxShootSpeed = 1.7;
   const bool moving_too_fast = std::abs(robot_velocity()) > kMaxShootSpeed;
 
   switch (state_) {
diff --git a/y2022/joystick_reader.cc b/y2022/joystick_reader.cc
index 7e7a735..baaa2ee 100644
--- a/y2022/joystick_reader.cc
+++ b/y2022/joystick_reader.cc
@@ -64,7 +64,7 @@
 const ButtonLocation kIntakeFrontOut(4, 10);
 const ButtonLocation kIntakeBackOut(4, 9);
 const ButtonLocation kSpitFront(3, 3);
-const ButtonLocation kSpitBack(2, 3);
+const ButtonLocation kSpitBack(3, 1);
 
 const ButtonLocation kRedLocalizerReset(4, 14);
 const ButtonLocation kBlueLocalizerReset(4, 13);
@@ -226,14 +226,18 @@
     constexpr double kIntakePosition = -0.02;
     constexpr size_t kIntakeCounterIterations = 25;
 
-    // Extend the intakes and spin the rollers
-    if (data.IsPressed(kIntakeFrontOut)) {
+    // Extend the intakes and spin the rollers.
+    // Don't let this happen if there is a ball in the other intake, because
+    // that would spit this one out.
+    if (data.IsPressed(kIntakeFrontOut) &&
+        !superstructure_status_fetcher_->back_intake_has_ball()) {
       intake_front_pos = kIntakePosition;
       transfer_roller_speed = kTransferRollerSpeed;
 
       intake_front_counter_ = kIntakeCounterIterations;
       intake_back_counter_ = 0;
-    } else if (data.IsPressed(kIntakeBackOut)) {
+    } else if (data.IsPressed(kIntakeBackOut) &&
+               !superstructure_status_fetcher_->front_intake_has_ball()) {
       intake_back_pos = kIntakePosition;
       transfer_roller_speed = -kTransferRollerSpeed;
 
diff --git a/y2022/vision/BUILD b/y2022/vision/BUILD
index a26c507..65ab20c 100644
--- a/y2022/vision/BUILD
+++ b/y2022/vision/BUILD
@@ -149,6 +149,73 @@
     ],
 )
 
+cc_binary(
+    name = "ball_color_detector",
+    srcs = [
+        "ball_color_main.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//y2022:__subpackages__"],
+    deps = [
+        ":ball_color_lib",
+        "//aos:init",
+        "//aos/events:shm_event_loop",
+    ],
+)
+
+cc_test(
+    name = "ball_color_test",
+    srcs = [
+        "ball_color_test.cc",
+    ],
+    data = [
+        "test_ball_color_image.jpg",
+    ],
+    deps = [
+        ":ball_color_lib",
+        "//aos:json_to_flatbuffer",
+        "//aos/events:simulated_event_loop",
+        "//aos/testing:googletest",
+        "//aos/testing:test_logging",
+        "//y2022:constants",
+    ],
+)
+
+cc_library(
+    name = "ball_color_lib",
+    srcs = [
+        "ball_color.cc",
+    ],
+    hdrs = [
+        "ball_color.h",
+    ],
+    data = [
+        "//y2022:aos_config",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//y2022:__subpackages__"],
+    deps = [
+        ":ball_color_fbs",
+        "//aos/events:event_loop",
+        "//aos/events:shm_event_loop",
+        "//aos/network:team_number",
+        "//frc971/input:joystick_state_fbs",
+        "//frc971/vision:vision_fbs",
+        "//third_party:opencv",
+    ],
+)
+
+flatbuffer_cc_library(
+    name = "ball_color_fbs",
+    srcs = ["ball_color.fbs"],
+    gen_reflections = 1,
+    includes = [
+        "//frc971/input:joystick_state_fbs_includes",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//y2022:__subpackages__"],
+)
+
 cc_library(
     name = "geometry_lib",
     hdrs = [
diff --git a/y2022/vision/ball_color.cc b/y2022/vision/ball_color.cc
new file mode 100644
index 0000000..e896da5
--- /dev/null
+++ b/y2022/vision/ball_color.cc
@@ -0,0 +1,138 @@
+#include "y2022/vision/ball_color.h"
+
+#include <chrono>
+#include <cmath>
+#include <opencv2/highgui/highgui.hpp>
+#include <thread>
+
+#include "aos/events/event_loop.h"
+#include "aos/events/shm_event_loop.h"
+#include "frc971/input/joystick_state_generated.h"
+#include "frc971/vision/vision_generated.h"
+#include "glog/logging.h"
+#include "opencv2/imgproc.hpp"
+
+namespace y2022 {
+namespace vision {
+
+BallColorDetector::BallColorDetector(aos::EventLoop *event_loop)
+    : ball_color_sender_(event_loop->MakeSender<BallColor>("/superstructure")) {
+  event_loop->MakeWatcher("/camera", [this](const CameraImage &camera_image) {
+    this->ProcessImage(camera_image);
+  });
+}
+
+void BallColorDetector::ProcessImage(const CameraImage &image) {
+  cv::Mat image_color_mat(cv::Size(image.cols(), image.rows()), CV_8UC2,
+                          (void *)image.data()->data());
+  cv::Mat image_mat(cv::Size(image.cols(), image.rows()), CV_8UC3);
+  cv::cvtColor(image_color_mat, image_mat, cv::COLOR_YUV2BGR_YUYV);
+
+  aos::Alliance detected_color = DetectColor(image_mat);
+
+  auto builder = ball_color_sender_.MakeBuilder();
+  auto ball_color_builder = builder.MakeBuilder<BallColor>();
+  ball_color_builder.add_ball_color(detected_color);
+  builder.CheckOk(builder.Send(ball_color_builder.Finish()));
+}
+
+aos::Alliance BallColorDetector::DetectColor(cv::Mat image) {
+  cv::Mat hsv(cv::Size(image.cols, image.rows), CV_8UC3);
+
+  cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
+
+  // Look at 3 chunks of the image
+  cv::Mat reference_red =
+      BallColorDetector::SubImage(hsv, BallColorDetector::kReferenceRed());
+
+  cv::Mat reference_blue =
+      BallColorDetector::SubImage(hsv, BallColorDetector::kReferenceBlue());
+  cv::Mat ball_location =
+      BallColorDetector::SubImage(hsv, BallColorDetector::kBallLocation());
+
+  // OpenCV HSV hues go from [0 to 179]
+  // Average the average color of each patch in both directions
+  // Rejecting pixels that have too low saturation or to bright or dark value
+  // And dealing with the wrapping of the red hues by shifting the wrap to be
+  // around 90 instead of 180. 90 is a color we don't care about.
+  double red = BallColorDetector::mean_hue(reference_red);
+  double blue = BallColorDetector::mean_hue(reference_blue);
+  double ball = BallColorDetector::mean_hue(ball_location);
+
+  // Just look at the hue values for distance
+  const double distance_to_blue = std::abs(ball - blue);
+  const double distance_to_red = std::abs(ball - red);
+
+  VLOG(1) << "\n"
+          << "Red: " << red << " deg\n"
+          << "Blue: " << blue << " deg\n"
+          << "Ball: " << ball << " deg\n"
+          << "distance to blue: " << distance_to_blue << " "
+          << "distance_to_red: " << distance_to_red;
+
+  // Is the ball location close enough to being the same hue as the blue
+  // reference or the red reference?
+
+  if (distance_to_blue < distance_to_red &&
+      distance_to_blue < kMaxHueDistance) {
+    return aos::Alliance::kBlue;
+  } else if (distance_to_red < distance_to_blue &&
+             distance_to_red < kMaxHueDistance) {
+    return aos::Alliance::kRed;
+  }
+
+  return aos::Alliance::kInvalid;
+}
+
+cv::Mat BallColorDetector::SubImage(cv::Mat image, cv::Rect location) {
+  cv::Rect new_location = BallColorDetector::RescaleRect(
+      image, location, BallColorDetector::kMeasurementsImageSize());
+  return image(new_location);
+}
+
+// Handle varying size images by scaling our constants rectangles
+cv::Rect BallColorDetector::RescaleRect(cv::Mat image, cv::Rect location,
+                                        cv::Size original_size) {
+  const double x_scale = static_cast<double>(image.cols) / original_size.width;
+  const double y_scale = static_cast<double>(image.rows) / original_size.height;
+
+  cv::Rect new_location(location.x * x_scale, location.y * y_scale,
+                        location.width * x_scale, location.height * y_scale);
+
+  return new_location;
+}
+
+double BallColorDetector::mean_hue(cv::Mat hsv_image) {
+  double num_pixels_selected = 0;
+  double sum = 0;
+
+  for (int i = 0; i < hsv_image.rows; ++i) {
+    for (int j = 0; j < hsv_image.cols; ++j) {
+      const cv::Vec3b &color = hsv_image.at<cv::Vec3b>(i, j);
+      double value = static_cast<double>(color(2));
+      double saturation = static_cast<double>(color(1));
+
+      if (value < kMinValue || value > kMaxValue ||
+          saturation < kMinSaturation) {
+        continue;
+      }
+
+      // unwrap hue so that break is around 90 instead of 180
+      // ex. a hue of 180 goes to 0, a hue of 120 goes to -60
+      // but there's still a break around 90 where it will be either +- 90
+      // depending on which side it's on
+      double hue = static_cast<double>(color(0));
+      if (hue > 90) {
+        hue = hue - 180;
+      }
+
+      num_pixels_selected++;
+      sum += hue;
+    }
+  }
+
+  return sum / num_pixels_selected;
+}
+
+}  // namespace vision
+}  // namespace y2022
diff --git a/y2022/vision/ball_color.fbs b/y2022/vision/ball_color.fbs
new file mode 100644
index 0000000..7eb93e0
--- /dev/null
+++ b/y2022/vision/ball_color.fbs
@@ -0,0 +1,12 @@
+include "frc971/input/joystick_state.fbs";
+
+namespace y2022.vision;
+
+table BallColor {
+  // The color of the ball represented as which alliance it belongs to
+  // it will be unpredictable when there is no ball and it will be kInvalid
+  // if the color is not close enough to either of the two references.
+  ball_color:aos.Alliance (id: 0);
+}
+
+root_type BallColor;
diff --git a/y2022/vision/ball_color.h b/y2022/vision/ball_color.h
new file mode 100644
index 0000000..ef3bdd2
--- /dev/null
+++ b/y2022/vision/ball_color.h
@@ -0,0 +1,58 @@
+#ifndef Y2022_VISION_BALL_COLOR_H_
+#define Y2022_VISION_BALL_COLOR_H_
+
+#include <opencv2/imgproc.hpp>
+
+#include "aos/events/shm_event_loop.h"
+#include "frc971/input/joystick_state_generated.h"
+#include "frc971/vision/vision_generated.h"
+#include "y2022/vision/ball_color_generated.h"
+
+namespace y2022 {
+namespace vision {
+
+using namespace frc971::vision;
+
+// Takes in camera images and detects what color the loaded ball is
+// Does not detect if there is a ball, and will output bad measurements in
+// the case that that there is not a ball.
+class BallColorDetector {
+ public:
+  // The size image that the reference rectangles were measure with
+  // These constants will be scaled if the image sent is not the same size
+  static const cv::Size kMeasurementsImageSize() { return {640, 480}; };
+  static const cv::Rect kReferenceRed() { return {440, 150, 50, 130}; };
+  static const cv::Rect kReferenceBlue() { return {440, 350, 30, 100}; };
+  static const cv::Rect kBallLocation() { return {100, 400, 140, 50}; };
+
+  // Constants used to filter out pixels that don't have good color information
+  static constexpr double kMinSaturation = 128;
+  static constexpr double kMinValue = 25;
+  static constexpr double kMaxValue = 230;
+
+  static constexpr double kMaxHueDistance = 10;
+
+  BallColorDetector(aos::EventLoop *event_loop);
+
+  void ProcessImage(const CameraImage &camera_image);
+
+  // We look at three parts of the image: two reference locations where there
+  // will be red and blue markers that should match the ball, and then the
+  // location in the catapult where we expect to see the ball. We then compute
+  // the average hue of each patch but discard pixels that we deem not colorful
+  // enough. Then we decide whether the ball color looks close enough to either
+  // of the reference colors. If no good color is detected, outputs kInvalid.
+  static aos::Alliance DetectColor(cv::Mat image);
+
+  static cv::Mat SubImage(cv::Mat image, cv::Rect location);
+
+  static cv::Rect RescaleRect(cv::Mat image, cv::Rect location,
+                              cv::Size original_size);
+  static double mean_hue(cv::Mat hsv_image);
+
+ private:
+  aos::Sender<BallColor> ball_color_sender_;
+};
+}  // namespace vision
+}  // namespace y2022
+#endif
diff --git a/y2022/vision/ball_color_main.cc b/y2022/vision/ball_color_main.cc
new file mode 100644
index 0000000..63f9d06
--- /dev/null
+++ b/y2022/vision/ball_color_main.cc
@@ -0,0 +1,35 @@
+#include "aos/events/shm_event_loop.h"
+#include "aos/init.h"
+#include "y2022/vision/ball_color.h"
+
+// config used to allow running ball_color_detector independently.  E.g.,
+// bazel run //y2022/vision:ball_color_detector -- --config
+// y2022/aos_config.json
+//   --override_hostname pi-7971-1  --ignore_timestamps true
+DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
+
+namespace y2022 {
+namespace vision {
+namespace {
+
+using namespace frc971::vision;
+
+void BallColorDetectorMain() {
+  aos::FlatbufferDetachedBuffer<aos::Configuration> config =
+      aos::configuration::ReadConfig(FLAGS_config);
+
+  aos::ShmEventLoop event_loop(&config.message());
+
+  BallColorDetector ball_color_detector(&event_loop);
+
+  event_loop.Run();
+}
+
+}  // namespace
+}  // namespace vision
+}  // namespace y2022
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+  y2022::vision::BallColorDetectorMain();
+}
diff --git a/y2022/vision/ball_color_test.cc b/y2022/vision/ball_color_test.cc
new file mode 100644
index 0000000..695791b
--- /dev/null
+++ b/y2022/vision/ball_color_test.cc
@@ -0,0 +1,147 @@
+#include "y2022/vision/ball_color.h"
+
+#include <cmath>
+#include <opencv2/highgui/highgui.hpp>
+#include <opencv2/imgproc.hpp>
+
+#include "aos/events/simulated_event_loop.h"
+#include "aos/json_to_flatbuffer.h"
+#include "aos/testing/test_logging.h"
+#include "glog/logging.h"
+#include "gtest/gtest.h"
+#include "y2022/constants.h"
+
+DEFINE_string(output_folder, "",
+              "If set, logs all channels to the provided logfile.");
+
+namespace y2022::vision::testing {
+
+class BallColorTest : public ::testing::Test {
+ public:
+  BallColorTest()
+      : config_(aos::configuration::ReadConfig("y2022/aos_config.json")),
+        event_loop_factory_(&config_.message()),
+        logger_pi_(aos::configuration::GetNode(
+            event_loop_factory_.configuration(), "logger")),
+        roborio_(aos::configuration::GetNode(
+            event_loop_factory_.configuration(), "roborio")),
+        camera_event_loop_(
+            event_loop_factory_.MakeEventLoop("Camera", logger_pi_)),
+        color_detector_event_loop_(event_loop_factory_.MakeEventLoop(
+            "Ball color detector", logger_pi_)),
+        superstructure_event_loop_(
+            event_loop_factory_.MakeEventLoop("Superstructure", roborio_)),
+        ball_color_fetcher_(superstructure_event_loop_->MakeFetcher<BallColor>(
+            "/superstructure")),
+        image_sender_(camera_event_loop_->MakeSender<CameraImage>("/camera"))
+
+  {}
+
+  // copied from camera_reader.cc
+  void SendImage(cv::Mat bgr_image) {
+    cv::Mat image_color_mat;
+    cv::cvtColor(bgr_image, image_color_mat, cv::COLOR_BGR2YUV);
+
+    // Convert YUV (3 channels) to YUYV (stacked format)
+    std::vector<uint8_t> yuyv;
+    for (int i = 0; i < image_color_mat.rows; i++) {
+      for (int j = 0; j < image_color_mat.cols; j++) {
+        // Always push a Y value
+        yuyv.emplace_back(image_color_mat.at<cv::Vec3b>(i, j)[0]);
+        if ((j % 2) == 0) {
+          // If column # is even, push a U value.
+          yuyv.emplace_back(image_color_mat.at<cv::Vec3b>(i, j)[1]);
+        } else {
+          // If column # is odd, push a V value.
+          yuyv.emplace_back(image_color_mat.at<cv::Vec3b>(i, j)[2]);
+        }
+      }
+    }
+
+    CHECK_EQ(static_cast<int>(yuyv.size()),
+             image_color_mat.rows * image_color_mat.cols * 2);
+
+    auto builder = image_sender_.MakeBuilder();
+    auto image_offset = builder.fbb()->CreateVector(yuyv);
+    auto image_builder = builder.MakeBuilder<CameraImage>();
+
+    int64_t timestamp = aos::monotonic_clock::now().time_since_epoch().count();
+
+    image_builder.add_rows(image_color_mat.rows);
+    image_builder.add_cols(image_color_mat.cols);
+    image_builder.add_data(image_offset);
+    image_builder.add_monotonic_timestamp_ns(timestamp);
+
+    builder.CheckOk(builder.Send(image_builder.Finish()));
+  }
+
+  aos::FlatbufferDetachedBuffer<aos::Configuration> config_;
+  aos::SimulatedEventLoopFactory event_loop_factory_;
+  const aos::Node *const logger_pi_;
+  const aos::Node *const roborio_;
+  ::std::unique_ptr<::aos::EventLoop> camera_event_loop_;
+  ::std::unique_ptr<::aos::EventLoop> color_detector_event_loop_;
+  ::std::unique_ptr<::aos::EventLoop> superstructure_event_loop_;
+  aos::Fetcher<BallColor> ball_color_fetcher_;
+  aos::Sender<CameraImage> image_sender_;
+};
+
+TEST_F(BallColorTest, DetectColorFromTestImage) {
+  cv::Mat bgr_image =
+      cv::imread("y2022/vision/test_ball_color_image.jpg", cv::IMREAD_COLOR);
+
+  ASSERT_TRUE(bgr_image.data != nullptr);
+
+  aos::Alliance detected_color = BallColorDetector::DetectColor(bgr_image);
+
+  EXPECT_EQ(detected_color, aos::Alliance::kRed);
+}
+
+TEST_F(BallColorTest, DetectColorFromTestImageInEventLoop) {
+  cv::Mat bgr_image =
+      cv::imread("y2022/vision/test_ball_color_image.jpg", cv::IMREAD_COLOR);
+  ASSERT_TRUE(bgr_image.data != nullptr);
+
+  BallColorDetector detector(color_detector_event_loop_.get());
+
+  camera_event_loop_->OnRun([this, bgr_image]() { SendImage(bgr_image); });
+
+  event_loop_factory_.RunFor(std::chrono::milliseconds(5));
+
+  ASSERT_TRUE(ball_color_fetcher_.Fetch());
+
+  EXPECT_TRUE(ball_color_fetcher_->has_ball_color());
+  EXPECT_EQ(ball_color_fetcher_->ball_color(), aos::Alliance::kRed);
+}
+
+TEST_F(BallColorTest, TestRescaling) {
+  cv::Mat mat(cv::Size(320, 240), CV_8UC3);
+  cv::Rect new_rect = BallColorDetector::RescaleRect(
+      mat, cv::Rect(30, 30, 30, 30), cv::Size(1920, 1080));
+
+  EXPECT_EQ(new_rect, cv::Rect(5, 6, 5, 6));
+}
+
+TEST_F(BallColorTest, TestAreas) {
+  cv::Mat bgr_image =
+      cv::imread("y2022/vision/test_ball_color_image.jpg", cv::IMREAD_COLOR);
+  ASSERT_TRUE(bgr_image.data != nullptr);
+
+  cv::Rect reference_red = BallColorDetector::RescaleRect(
+      bgr_image, BallColorDetector::kReferenceRed(),
+      BallColorDetector::kMeasurementsImageSize());
+  cv::Rect reference_blue = BallColorDetector::RescaleRect(
+      bgr_image, BallColorDetector::kReferenceBlue(),
+      BallColorDetector::kMeasurementsImageSize());
+  cv::Rect ball_location = BallColorDetector::RescaleRect(
+      bgr_image, BallColorDetector::kBallLocation(),
+      BallColorDetector::kMeasurementsImageSize());
+
+  cv::rectangle(bgr_image, reference_red, cv::Scalar(0, 0, 255));
+  cv::rectangle(bgr_image, reference_blue, cv::Scalar(255, 0, 0));
+  cv::rectangle(bgr_image, ball_location, cv::Scalar(0, 255, 0));
+
+  cv::imwrite("/tmp/rectangles.jpg", bgr_image);
+}
+
+}  // namespace y2022::vision::testing
diff --git a/y2022/vision/camera_definition.py b/y2022/vision/camera_definition.py
index 219a41f..61789cb 100644
--- a/y2022/vision/camera_definition.py
+++ b/y2022/vision/camera_definition.py
@@ -100,13 +100,13 @@
 
     if pi_number == "pi1":
         camera_yaw = 90.0 * np.pi / 180.0
-        T = np.array([-7.0 * 0.0254, 3.5 * 0.0254, 32.0 * 0.0254])
+        T = np.array([-8.25 * 0.0254, 3.25 * 0.0254, 32.0 * 0.0254])
     elif pi_number == "pi2":
         camera_yaw = 0.0
-        T = np.array([-7.0 * 0.0254, -3.0 * 0.0254, 34.0 * 0.0254])
+        T = np.array([-7.5 * 0.0254, -3.5 * 0.0254, 34.0 * 0.0254])
     elif pi_number == "pi3":
         camera_yaw = 179.0 * np.pi / 180.0
-        T = np.array([-1.0 * 0.0254, 8.5 * 0.0254, 34.0 * 0.0254])
+        T = np.array([-1.0 * 0.0254, 8.5 * 0.0254, 34.25 * 0.0254])
     elif pi_number == "pi4":
         camera_yaw = -90.0 * np.pi / 180.0
         T = np.array([-9.0 * 0.0254, -5 * 0.0254, 27.5 * 0.0254])
diff --git a/y2022/vision/target_estimator.cc b/y2022/vision/target_estimator.cc
index 377778d..9eef390 100644
--- a/y2022/vision/target_estimator.cc
+++ b/y2022/vision/target_estimator.cc
@@ -98,14 +98,22 @@
 const std::array<cv::Point3d, 4> TargetEstimator::kMiddleTapePiecePoints =
     ComputeMiddleTapePiecePoints();
 
+namespace {
+constexpr double kDefaultDistance = 3.0;
+constexpr double kDefaultYaw = M_PI;
+constexpr double kDefaultAngleToCamera = 0.0;
+}  // namespace
+
 TargetEstimator::TargetEstimator(cv::Mat intrinsics, cv::Mat extrinsics)
     : blob_stats_(),
+      middle_blob_index_(0),
+      max_blob_area_(0.0),
       image_(std::nullopt),
       roll_(0.0),
       pitch_(0.0),
-      yaw_(M_PI),
-      distance_(3.0),
-      angle_to_camera_(0.0),
+      yaw_(kDefaultYaw),
+      distance_(kDefaultDistance),
+      angle_to_camera_(kDefaultAngleToCamera),
       // Seed camera height
       camera_height_(extrinsics.at<double>(2, 3) +
                      constants::Values::kImuHeight()) {
@@ -153,11 +161,17 @@
                    blob_stats_[2].centroid});
   CHECK(circle.has_value());
 
+  max_blob_area_ = 0.0;
+
   // Find the middle blob, which is the one with the angle closest to the
   // average
   double theta_avg = 0.0;
   for (const auto &stats : blob_stats_) {
     theta_avg += circle->AngleOf(stats.centroid);
+
+    if (stats.area > max_blob_area_) {
+      max_blob_area_ = stats.area;
+    }
   }
   theta_avg /= blob_stats_.size();
 
@@ -201,6 +215,19 @@
 
   // TODO(milind): seed with localizer output as well
 
+  // If we didn't solve well last time, seed everything at the defaults so we
+  // don't get stuck in a bad state.
+  // Copied from localizer.cc
+  constexpr double kMinConfidence = 0.75;
+  if (confidence_ < kMinConfidence) {
+    roll_ = roll_seed;
+    pitch_ = pitch_seed;
+    yaw_ = kDefaultYaw;
+    distance_ = kDefaultDistance;
+    angle_to_camera_ = kDefaultAngleToCamera;
+    camera_height_ = extrinsics_(2, 3) + constants::Values::kImuHeight();
+  }
+
   // Constrain the rotation to be around the localizer's, otherwise there can be
   // multiple solutions. There shouldn't be too much roll or pitch
   if (FLAGS_freeze_roll) {
@@ -250,7 +277,7 @@
           << std::chrono::duration<double, std::milli>(end - start).count()
           << " ms";
 
-  // For computing the confidence, find the standard deviation in pixels
+  // For computing the confidence, find the standard deviation in pixels.
   std::vector<double> residual(num_residuals);
   (*this)(&roll_, &pitch_, &yaw_, &distance_, &angle_to_camera_,
           &camera_height_, residual.data());
@@ -427,13 +454,18 @@
   for (size_t i = 0; i < tape_indices.size(); ++i) {
     const auto distance = DistanceFromTapeIndex(
         tape_indices[i].second, tape_indices[i].first, tape_points_proj);
+    // Scale the distance based on the blob area: larger blobs have less noise.
+    const S distance_scalar =
+        S(blob_stats_[tape_indices[i].second].area / max_blob_area_);
     VLOG(2) << "Blob index " << tape_indices[i].second << " maps to "
             << tape_indices[i].first << " distance " << distance.x << " "
-            << distance.y;
+            << distance.y << " distance scalar "
+            << ScalarToDouble(distance_scalar);
+
     // Set the residual to the (x, y) distance of the centroid from the
     // matched projected piece of tape
-    residual[i * 2] = distance.x;
-    residual[(i * 2) + 1] = distance.y;
+    residual[i * 2] = distance_scalar * distance.x;
+    residual[(i * 2) + 1] = distance_scalar * distance.y;
   }
 
   // Penalize based on the difference between the size of the projected piece of
@@ -594,7 +626,9 @@
   const auto kTextColor = cv::Scalar(0, 255, 255);
   constexpr double kFontScale = 0.6;
 
-  cv::putText(view_image, absl::StrFormat("Distance: %.3f", distance_),
+  cv::putText(view_image,
+              absl::StrFormat("Distance: %.3f m (%.3f in)", distance_,
+                              distance_ / 0.0254),
               cv::Point(kTextX, text_y += kTextSpacing),
               cv::FONT_HERSHEY_DUPLEX, kFontScale, kTextColor, 2);
   cv::putText(view_image,
diff --git a/y2022/vision/target_estimator.h b/y2022/vision/target_estimator.h
index f158626..ac170e8 100644
--- a/y2022/vision/target_estimator.h
+++ b/y2022/vision/target_estimator.h
@@ -73,8 +73,7 @@
 
   template <typename S>
   cv::Point_<S> DistanceFromTapeIndex(
-      size_t centroid_index,
-      size_t tape_index,
+      size_t centroid_index, size_t tape_index,
       const std::vector<cv::Point_<S>> &tape_points) const;
 
   void DrawProjectedHub(const std::vector<cv::Point2d> &tape_points_proj,
@@ -82,6 +81,7 @@
 
   std::vector<BlobDetector::BlobStats> blob_stats_;
   size_t middle_blob_index_;
+  double max_blob_area_;
   std::optional<cv::Mat> image_;
 
   Eigen::Matrix3d intrinsics_;
diff --git a/y2022/vision/test_ball_color_image.jpg b/y2022/vision/test_ball_color_image.jpg
new file mode 100644
index 0000000..8750460
--- /dev/null
+++ b/y2022/vision/test_ball_color_image.jpg
Binary files differ
diff --git a/y2022/vision/viewer.cc b/y2022/vision/viewer.cc
index a21c09f..446f1f6 100644
--- a/y2022/vision/viewer.cc
+++ b/y2022/vision/viewer.cc
@@ -274,15 +274,14 @@
                      blob_result.filtered_blobs.size()
               << ")";
 
+    estimator.Solve(blob_result.filtered_stats,
+                    FLAGS_display_estimation ? std::make_optional(ret_image)
+                                             : std::nullopt);
     if (blob_result.filtered_blobs.size() > 0) {
-      estimator.Solve(blob_result.filtered_stats,
-                      FLAGS_display_estimation ? std::make_optional(ret_image)
-                                               : std::nullopt);
       estimator.DrawEstimate(ret_image);
       LOG(INFO) << "Read file " << (it - file_list.begin()) << ": " << *it;
     }
 
-
     cv::imshow("image", image_mat);
     cv::imshow("mask", blob_result.binarized_image);
     cv::imshow("blobs", ret_image);
diff --git a/y2022/www/field_handler.ts b/y2022/www/field_handler.ts
index 6c2df51..22989be 100644
--- a/y2022/www/field_handler.ts
+++ b/y2022/www/field_handler.ts
@@ -280,34 +280,6 @@
 
     // Draw the matches with debugging information from the localizer.
     const now = Date.now() / 1000.0;
-    for (const [time, value] of this.localizerImageMatches) {
-      const age = now - time;
-      const kRemovalAge = 2.0;
-      if (age > kRemovalAge) {
-        this.localizerImageMatches.delete(time);
-        continue;
-      }
-      const ageAlpha = (kRemovalAge - age) / kRemovalAge
-      for (let i = 0; i < value.targetsLength(); i++) {
-        const imageDebug = value.targets(i);
-        const x = imageDebug.impliedRobotX();
-        const y = imageDebug.impliedRobotY();
-        const theta = imageDebug.impliedRobotTheta();
-        const cameraX = imageDebug.cameraX();
-        const cameraY = imageDebug.cameraY();
-        const cameraTheta = imageDebug.cameraTheta();
-        const accepted = imageDebug.accepted();
-        // Make camera readings fade over time.
-        const alpha = Math.round(255 * ageAlpha).toString(16).padStart(2, '0');
-        const dashed = false;
-        const acceptedRgb = accepted ? '#00FF00' : '#FF0000';
-        const acceptedRgba = acceptedRgb + alpha;
-        const cameraRgb = PI_COLORS[imageDebug.camera()];
-        const cameraRgba = cameraRgb + alpha;
-        this.drawRobot(x, y, theta, null, acceptedRgba, dashed, false);
-        this.drawCamera(cameraX, cameraY, cameraTheta, cameraRgba, false);
-      }
-    }
     if (this.superstructureStatus) {
       this.shotDistance.innerHTML = this.superstructureStatus.aimer() ?
           (this.superstructureStatus.aimer().shotDistance() /
@@ -424,6 +396,36 @@
               null);
     }
 
+    for (const [time, value] of this.localizerImageMatches) {
+      const age = now - time;
+      const kRemovalAge = 1.0;
+      if (age > kRemovalAge) {
+        this.localizerImageMatches.delete(time);
+        continue;
+      }
+      const kMaxImageAlpha = 0.5;
+      const ageAlpha = kMaxImageAlpha * (kRemovalAge - age) / kRemovalAge
+      for (let i = 0; i < value.targetsLength(); i++) {
+        const imageDebug = value.targets(i);
+        const x = imageDebug.impliedRobotX();
+        const y = imageDebug.impliedRobotY();
+        const theta = imageDebug.impliedRobotTheta();
+        const cameraX = imageDebug.cameraX();
+        const cameraY = imageDebug.cameraY();
+        const cameraTheta = imageDebug.cameraTheta();
+        const accepted = imageDebug.accepted();
+        // Make camera readings fade over time.
+        const alpha = Math.round(255 * ageAlpha).toString(16).padStart(2, '0');
+        const dashed = false;
+        const acceptedRgb = accepted ? '#00FF00' : '#FF0000';
+        const acceptedRgba = acceptedRgb + alpha;
+        const cameraRgb = PI_COLORS[imageDebug.camera()];
+        const cameraRgba = cameraRgb + alpha;
+        this.drawRobot(x, y, theta, null, acceptedRgba, dashed, false);
+        this.drawCamera(cameraX, cameraY, cameraTheta, cameraRgba, false);
+      }
+    }
+
     window.requestAnimationFrame(() => this.draw());
   }
 
diff --git a/y2022/y2022_logger.json b/y2022/y2022_logger.json
index f811dc8..f54ccd7 100644
--- a/y2022/y2022_logger.json
+++ b/y2022/y2022_logger.json
@@ -19,6 +19,38 @@
       ]
    },
     {
+      "name": "/superstructure",
+      "type": "y2022.vision.BallColor",
+      "source_node": "logger",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "roborio"
+      ],
+      "frequency": 200,
+      "num_senders": 2,
+      "max_size": 72,
+      "destination_nodes": [
+        {
+          "name": "roborio",
+          "priority": 2,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": [
+            "roborio"
+          ],
+          "time_to_live": 5000000
+        }
+      ]
+    },
+    {
+      "name": "/aos/remote_timestamps/roborio/superstructure/y2022-vision-BallColor",
+      "type": "aos.message_bridge.RemoteMessage",
+      "source_node": "logger",
+      "logger": "NOT_LOGGED",
+      "frequency": 20,
+      "num_senders": 2,
+      "max_size": 200
+    },
+    {
       "name": "/drivetrain",
       "type": "frc971.control_loops.drivetrain.Position",
       "source_node": "roborio",
@@ -72,7 +104,7 @@
       "type": "aos.message_bridge.RemoteMessage",
       "source_node": "roborio",
       "logger": "NOT_LOGGED",
-      "frequency": 200,
+      "frequency": 400,
       "num_senders": 2,
       "max_size": 400
     },
@@ -481,6 +513,13 @@
       "nodes": [
         "logger"
       ]
+    },
+    {
+      "name": "ball_color_detector",
+      "executable_name": "ball_color_detector",
+      "nodes": [
+        "logger"
+      ]
     }
   ],
   "nodes": [
diff --git a/y2022/y2022_pi_template.json b/y2022/y2022_pi_template.json
index 6eddf9a..bcd3f6b 100644
--- a/y2022/y2022_pi_template.json
+++ b/y2022/y2022_pi_template.json
@@ -181,7 +181,7 @@
       "source_node": "pi{{ NUM }}",
       "frequency": 25,
       "num_senders": 2,
-      "max_size": 20000,
+      "max_size": 40000,
       "logger": "LOCAL_AND_REMOTE_LOGGER",
       "logger_nodes": [
         "imu",
@@ -211,14 +211,14 @@
     {
       "name": "/pi{{ NUM }}/aos/remote_timestamps/imu/pi{{ NUM }}/camera/y2022-vision-TargetEstimate",
       "type": "aos.message_bridge.RemoteMessage",
-      "frequency": 20,
+      "frequency": 40,
       "source_node": "pi{{ NUM }}",
       "max_size": 208
     },
     {
       "name": "/pi{{ NUM }}/aos/remote_timestamps/logger/pi{{ NUM }}/camera/y2022-vision-TargetEstimate",
       "type": "aos.message_bridge.RemoteMessage",
-      "frequency": 20,
+      "frequency": 40,
       "source_node": "pi{{ NUM }}",
       "max_size": 208
     },
diff --git a/y2022/y2022_roborio.json b/y2022/y2022_roborio.json
index 93e0483..8651923 100644
--- a/y2022/y2022_roborio.json
+++ b/y2022/y2022_roborio.json
@@ -240,7 +240,7 @@
       "name": "/superstructure",
       "type": "y2022.control_loops.superstructure.Status",
       "source_node": "roborio",
-      "frequency": 200,
+      "frequency": 400,
       "num_senders": 2,
       "logger": "LOCAL_AND_REMOTE_LOGGER",
       "logger_nodes": [
@@ -317,6 +317,21 @@
       "num_senders": 2
     },
     {
+      "name": "/superstructure",
+      "type": "y2022.vision.BallColor",
+      "source_node": "logger",
+      "frequency": 200,
+      "num_senders": 2,
+      "max_size": 72,
+      "destination_nodes": [
+        {
+          "name": "roborio",
+          "priority": 2,
+          "time_to_live": 500000000
+        }
+      ]
+    },
+    {
       "name": "/drivetrain",
       "type": "frc971.sensors.GyroReading",
       "source_node": "roborio",