Merge "Add boilerplate localizer code for LED control"
diff --git a/WORKSPACE b/WORKSPACE
index 58fb14f..f574965 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -12,6 +12,10 @@
     apache2_debs = "files",
 )
 load(
+    "//debian:postgresql_amd64.bzl",
+    postgresql_amd64_debs = "files",
+)
+load(
     "//debian:patch.bzl",
     patch_debs = "files",
 )
@@ -93,6 +97,8 @@
 
 generate_repositories_for_debs(apache2_debs)
 
+generate_repositories_for_debs(postgresql_amd64_debs)
+
 generate_repositories_for_debs(patch_debs)
 
 generate_repositories_for_debs(pandoc_debs)
@@ -525,6 +531,13 @@
 )
 
 http_archive(
+    name = "postgresql_amd64",
+    build_file = "@//debian:postgresql_amd64.BUILD",
+    sha256 = "2b8bb77deaf58f798c296ce31ee7a32781395d55e05dcddc8a7da7e827f38d7f",
+    url = "https://www.frc971.org/Build-Dependencies/postgresql_amd64.tar.gz",
+)
+
+http_archive(
     name = "patch",
     build_file = "@//debian:patch.BUILD",
     sha256 = "b5ce139648a2e04f5585948ddad2fdae24dd4ee7976ac5a22d6ae7bd5674631e",
diff --git a/debian/BUILD b/debian/BUILD
index 2aeabf2..3f13244 100644
--- a/debian/BUILD
+++ b/debian/BUILD
@@ -7,6 +7,10 @@
     apache2_debs = "files",
 )
 load(
+    ":postgresql_amd64.bzl",
+    postgresql_amd64_debs = "files",
+)
+load(
     ":patch.bzl",
     patch_debs = "files",
 )
@@ -173,6 +177,28 @@
 )
 
 download_packages(
+    name = "download_postgresql_deps",
+    excludes = [
+        "adduser",
+        "debconf",
+        "debconf-2.0",
+        "libsystemd0",
+        "lsb-base",
+        "libstdc++6",
+        "libc-bin",
+        "libc-l10n",
+        "netbase",
+        "ucf",
+        "locales",
+        "locales-all",
+    ],
+    packages = [
+        "postgresql",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
+download_packages(
     name = "download_patch_deps",
     packages = [
         "patch",
@@ -287,6 +313,12 @@
 )
 
 generate_deb_tarball(
+    name = "postgresql_amd64",
+    files = postgresql_amd64_debs,
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
+generate_deb_tarball(
     name = "patch",
     files = patch_debs,
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/debian/postgresql_amd64.BUILD b/debian/postgresql_amd64.BUILD
new file mode 100644
index 0000000..c2f8331
--- /dev/null
+++ b/debian/postgresql_amd64.BUILD
@@ -0,0 +1,56 @@
+load("@bazel_skylib//rules:write_file.bzl", "write_file")
+
+TEMPLATE = """\
+#!/bin/bash
+
+# --- begin runfiles.bash initialization v2 ---
+# Copy-pasted from the Bazel Bash runfiles library v2.
+set -uo pipefail; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$0.runfiles/$f" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+  { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+# --- end runfiles.bash initialization v2 ---
+
+add_ld_library_path_for() {
+  local file="$1"
+  local dir
+  local resolved_file
+  if ! resolved_file="$(rlocation "postgresql_amd64/$file")"; then
+    echo "Couldn't find file postgresql_amd64/${file}" >&2
+    exit 1
+  fi
+  dir="$(dirname "${resolved_file}")"
+  export LD_LIBRARY_PATH="${dir}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+}
+
+add_ld_library_path_for usr/lib/x86_64-linux-gnu/libbsd.so.0.11.3
+add_ld_library_path_for lib/x86_64-linux-gnu/libreadline.so.8.1
+
+exec $(rlocation postgresql_amd64/usr/lib/postgresql/13/bin/%s) "$@"
+"""
+
+[(
+    write_file(
+        name = "generate_%s_wrapper" % binary,
+        out = "%s.sh" % binary,
+        content = [TEMPLATE % binary],
+    ),
+    sh_binary(
+        name = binary,
+        srcs = ["%s.sh" % binary],
+        data = glob([
+            "usr/lib/**/*",
+            "lib/**/*",
+        ]),
+        visibility = ["//visibility:public"],
+        deps = [
+            "@bazel_tools//tools/bash/runfiles",
+        ],
+    ),
+) for binary in (
+    "postgres",
+    "initdb",
+)]
diff --git a/debian/postgresql_amd64.bzl b/debian/postgresql_amd64.bzl
new file mode 100644
index 0000000..6fc9ba5
--- /dev/null
+++ b/debian/postgresql_amd64.bzl
@@ -0,0 +1,39 @@
+files = {
+    "libbsd0_0.11.3-1_amd64.deb": "284a7b8dcfcad74770f57360721365317448b38ab773db542bf630e94e60c13e",
+    "libedit2_3.1-20191231-2+b1_amd64.deb": "ac545f6ad10ba791aca24b09255ad1d6d943e6bc7c5511d5998e104aee51c943",
+    "libffi7_3.3-6_amd64.deb": "30ca89bfddae5fa6e0a2a044f22b6e50cd17c4bc6bc850c579819aeab7101f0f",
+    "libgdbm-compat4_1.19-2_amd64.deb": "e62caed68b0ffaa03b5fa539d6fdc08c4151f66236d5878949bead0b71b7bb09",
+    "libgdbm6_1.19-2_amd64.deb": "e54cfe4d8b8f209bb7df31a404ce040f7c2f9b1045114a927a7e1061cdf90727",
+    "libgnutls30_3.7.1-5_amd64.deb": "20b0189b72ad4c791cf5b280c111d41ce071a04dab0e9a9d7daa9504a7a7b543",
+    "libhogweed6_3.7.3-1_amd64.deb": "6aab2e892cdb2dfba45707601bc6c3b19aa228f70ae5841017f14c3b0ca3d22f",
+    "libicu67_67.1-7_amd64.deb": "2bf5c46254f527865bfd6368e1120908755fa57d83634bd7d316c9b3cfd57303",
+    "libidn2-0_2.3.0-5_amd64.deb": "cb80cd769171537bafbb4a16c12ec427065795946b3415781bc9792e92d60b59",
+    "libldap-2.4-2_2.4.57+dfsg-3_amd64.deb": "4186d0d3f086202d391da49d1bb5ced6dde5eafba1dbcffef9a8e1238a7ef7c3",
+    "libllvm11_11.0.1-2_amd64.deb": "eaff3c8dd6039af90b8b6bdbf33433e35d8c808a7aa195d0e3800ef5e61affff",
+    "libmd0_1.0.3-3_amd64.deb": "9e425b3c128b69126d95e61998e1b5ef74e862dd1fc953d91eebcc315aea62ea",
+    "libnettle8_3.7.3-1_amd64.deb": "e4f8ec31ed14518b241eb7b423ad5ed3f4a4e8ac50aae72c9fd475c569582764",
+    "libp11-kit0_0.23.22-1_amd64.deb": "bfef5f31ee1c730e56e16bb62cc5ff8372185106c75bf1ed1756c96703019457",
+    "libperl5.32_5.32.1-4+deb11u2_amd64.deb": "224cafe65968deb83168113b74dff2d2f13b115a41d99eb209ed3b8f981df0b3",
+    "libpq5_13.5-0+deb11u1_amd64.deb": "0bfa1dc24e1275963961efdcc6d2ff4d2eec390d7acd5a6aee3162569ae1886c",
+    "libreadline8_8.1-1_amd64.deb": "162ba9fdcde81b5502953ed4d84b24e8ad4e380bbd02990ab1a0e3edffca3c22",
+    "libsasl2-2_2.1.27+dfsg-2.1+deb11u1_amd64.deb": "2e86ab7a3329aad4b7350a9b067fe8f80b680302f2f82d94f73f9bf075404460",
+    "libsasl2-modules-db_2.1.27+dfsg-2.1+deb11u1_amd64.deb": "122bf3de4ca0ec873bc35bdde1f21ec9d91ace4f5245c3b1240e077f866e1ae9",
+    "libtasn1-6_4.16.0-2_amd64.deb": "fd7a200100298c2556e67bdc1a5faf5cf21c3136fa47f381d7e9769233ee88a1",
+    "libtinfo6_6.2+20201114-2_amd64.deb": "aeaf942c71ecc0ed081efdead1a1de304dcd513a9fc06791f26992e76986597b",
+    "libunistring2_0.9.10-4_amd64.deb": "654433ad02d3a8b05c1683c6c29a224500bf343039c34dcec4e5e9515345e3d4",
+    "libuuid1_2.36.1-8+deb11u1_amd64.deb": "31250af4dd3b7d1519326a9a6764d1466a93d8f498cf6545058761ebc38b2823",
+    "libxml2_2.9.10+dfsg-6.7_amd64.deb": "023296a15e1a28607609cb15c7ca0dd8a25160f3e89a0da58368319c7e17d4e0",
+    "libxslt1.1_1.1.34-4_amd64.deb": "17eb62d8973867b61e7f8b21b5c16ed33e151799656e49caf670081707853fb8",
+    "libz3-4_4.8.10-1_amd64.deb": "7a38c2dd985eb9315857588ee06ff297e2b16de159dec85bd2777a43ebe9f458",
+    "openssl_1.1.1k-1+deb11u1_amd64.deb": "ed998755dabb96ffe107c2d41ce685ecbb4fa200f7825ff82c1092f8334bf3cb",
+    "perl-modules-5.32_5.32.1-4+deb11u2_all.deb": "6fa15be322c3c89ec4a07d704ad58d4a2d1aabf866135a859f6d8d58c59e9df4",
+    "perl_5.32.1-4+deb11u2_amd64.deb": "1cebc4516ed7c240b812c7bdd7e6ea0810f513152717ca17ce139ee0dfbc7b0d",
+    "postgresql-13_13.5-0+deb11u1_amd64.deb": "e475540f43756dc1c64de0a8a3b33f2c0e45b39610f091afbfe3b6ef72573c7b",
+    "postgresql-client-13_13.5-0+deb11u1_amd64.deb": "cd1779abafdee712d9ea4ebae62d873b61540fd76beab1cc86e604c12813d005",
+    "postgresql-client-common_225_all.deb": "a867f301751692f9ad127c1dd921c3bce7f3969bdf58c6bf38c57303d1b51d2c",
+    "postgresql-common_225_all.deb": "90216c317fd9f247d8fb1597fb4677cbdf2bbb83811213ce4344a44820449e66",
+    "postgresql_13+225_all.deb": "c8791bd0fd7cce76341cbd2c6ba98991a206441fe948534394239e95d102b4b8",
+    "readline-common_8.1-1_all.deb": "3f947176ef949f93e4ad5d76c067d33fa97cf90b62ee0748acb4f5f64790edc8",
+    "ssl-cert_1.1.0+nmu1_all.deb": "6f3b0c20b0a37b2b196d832910a754cf784f96854daa02a16f4ac46d366cdcb8",
+    "tzdata_2021a-1+deb11u2_all.deb": "4a34cbe17d391e6351386f3530b7ffd096c2cc8582e970f745addc636fa7c397",
+}
diff --git a/scouting/BUILD b/scouting/BUILD
index a769426..0c2c641 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -33,7 +33,7 @@
         "//scouting/www:index.html",
         "//scouting/www:zonejs_copy",
     ],
-    visibility = ["//scouting/deploy:__pkg__"],
+    visibility = ["//visibility:public"],
 )
 
 protractor_ts_test(
@@ -42,5 +42,5 @@
         ":scouting_test.ts",
     ],
     on_prepare = ":scouting_test.protractor.on-prepare.js",
-    server = ":scouting",
+    server = "//scouting/testing:scouting_test_servers",
 )
diff --git a/scouting/db/db.go b/scouting/db/db.go
index d666f79..94e9056 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -27,6 +27,11 @@
 	Climbing                                                     int32
 }
 
+type NotesData struct {
+	TeamNumber int32
+	Notes      []string
+}
+
 // Opens a database at the specified path. If the path refers to a non-existent
 // file, the database will be created and initialized with empty tables.
 func NewDatabase(path string) (*Database, error) {
@@ -86,6 +91,20 @@
 		return nil, errors.New(fmt.Sprint("Failed to create team_match_stats table: ", err))
 	}
 
+	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS team_notes (" +
+		"id INTEGER PRIMARY KEY, " +
+		"TeamNumber INTEGER, " +
+		"Notes TEXT)")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to prepare notes table creation: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec()
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to create notes table: ", err))
+	}
+
 	return database, nil
 }
 
@@ -107,6 +126,15 @@
 	if err != nil {
 		return errors.New(fmt.Sprint("Failed to drop stats table: ", err))
 	}
+
+	statement, err = database.Prepare("DROP TABLE IF EXISTS team_notes")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare dropping notes table: ", err))
+	}
+	_, err = statement.Exec()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to drop notes table: ", err))
+	}
 	return nil
 }
 
@@ -285,3 +313,42 @@
 	}
 	return teams, nil
 }
+
+func (database *Database) QueryNotes(TeamNumber int32) (NotesData, error) {
+	rows, err := database.Query("SELECT * FROM team_notes WHERE TeamNumber = ?", TeamNumber)
+	if err != nil {
+		return NotesData{}, errors.New(fmt.Sprint("Failed to select from notes: ", err))
+	}
+	defer rows.Close()
+
+	var notes []string
+	for rows.Next() {
+		var id int32
+		var data string
+		err = rows.Scan(&id, &TeamNumber, &data)
+		if err != nil {
+			return NotesData{}, errors.New(fmt.Sprint("Failed to scan from notes: ", err))
+		}
+		notes = append(notes, data)
+	}
+	return NotesData{TeamNumber, notes}, 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")
+	}
+	statement, err := database.Prepare("INSERT INTO " +
+		"team_notes(TeamNumber, Notes)" +
+		"VALUES (?, ?)")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare insertion into notes table: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec(data.TeamNumber, data.Notes[0])
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to insert into Notes database: ", err))
+	}
+	return nil
+}
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 474ea41..13a43f8 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -324,3 +324,27 @@
 		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
 	}
 }
+
+func TestNotes(t *testing.T) {
+	db := createDatabase(t)
+	defer db.Delete()
+
+	expected := NotesData{
+		TeamNumber: 1234,
+		Notes:      []string{"Note 1", "Note 3"},
+	}
+
+	err := db.AddNotes(NotesData{1234, []string{"Note 1"}})
+	check(t, err, "Failed to add Note")
+	err = db.AddNotes(NotesData{1235, []string{"Note 2"}})
+	check(t, err, "Failed to add Note")
+	err = db.AddNotes(NotesData{1234, []string{"Note 3"}})
+	check(t, err, "Failed to add Note")
+
+	actual, err := db.QueryNotes(1234)
+	check(t, err, "Failed to get Notes")
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Got %#v,\nbut expected %#v.", actual, expected)
+	}
+}
diff --git a/scouting/testing/BUILD b/scouting/testing/BUILD
new file mode 100644
index 0000000..43bbe06
--- /dev/null
+++ b/scouting/testing/BUILD
@@ -0,0 +1,12 @@
+py_binary(
+    name = "scouting_test_servers",
+    testonly = True,
+    srcs = [
+        "scouting_test_servers.py",
+    ],
+    data = [
+        "//scouting",
+        "//scouting/scraping:test_data",
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/scouting/testing/scouting_test_servers.py b/scouting/testing/scouting_test_servers.py
new file mode 100644
index 0000000..3447815
--- /dev/null
+++ b/scouting/testing/scouting_test_servers.py
@@ -0,0 +1,108 @@
+"""This library is here to run the various servers involved in the scouting app.
+
+The servers are:
+ - The fake TBA server
+ - The actual web server
+"""
+
+import argparse
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import time
+from typing import List
+
+def wait_for_server(port: int):
+    """Waits for the server at the specified port to respond to TCP connections."""
+    while True:
+        try:
+            connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            connection.connect(("localhost", port))
+            connection.close()
+            break
+        except ConnectionRefusedError:
+            connection.close()
+            time.sleep(0.01)
+
+def create_tba_config(tmpdir: Path) -> Path:
+    # Configure the scouting webserver to scrape data from our fake TBA
+    # server.
+    config = tmpdir / "scouting_config.json"
+    config.write_text(json.dumps({
+        "api_key": "dummy_key_that_is_not_actually_used_in_this_test",
+        "base_url": "http://localhost:7000",
+    }))
+    return config
+
+def set_up_tba_api_dir(tmpdir: Path, year: int, event_code: str):
+    tba_api_dir = tmpdir / "api" / "v3" / "event" / f"{year}{event_code}"
+    tba_api_dir.mkdir(parents=True, exist_ok=True)
+    (tba_api_dir / "matches").write_text(
+        Path(f"scouting/scraping/test_data/{year}_{event_code}.json").read_text()
+    )
+
+class Runner:
+    """Helps manage the services we need for testing the scouting app."""
+
+    def start(self, port: int):
+        """Starts the services needed for testing the scouting app."""
+        self.tmpdir = Path(os.environ["TEST_TMPDIR"]) / "servers"
+        self.tmpdir.mkdir(exist_ok=True)
+
+        db_path = self.tmpdir / "scouting.db"
+        tba_config = create_tba_config(self.tmpdir)
+
+        self.webserver = subprocess.Popen([
+            "scouting/scouting",
+            f"--port={port}",
+            f"--database={db_path}",
+            f"--tba_config={tba_config}",
+        ])
+
+        # Create a fake TBA server to serve the static match list.
+        set_up_tba_api_dir(self.tmpdir, year=2016, event_code="nytr")
+        set_up_tba_api_dir(self.tmpdir, year=2020, event_code="fake")
+        self.fake_tba_api = subprocess.Popen(
+            ["python3", "-m", "http.server", "7000"],
+            cwd=self.tmpdir,
+        )
+
+        # Wait for the TBA server and the scouting webserver to start up.
+        wait_for_server(7000)
+        wait_for_server(port)
+
+    def stop(self):
+        """Stops the services needed for testing the scouting app."""
+        servers = (self.webserver, self.fake_tba_api)
+        for server in servers:
+            server.terminate()
+        for server in servers:
+            server.wait()
+
+        try:
+            shutil.rmtree(self.tmpdir)
+        except FileNotFoundError:
+            pass
+
+
+def main(argv: List[str]):
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, help="The port for the actual web server.")
+    args = parser.parse_args(argv[1:])
+
+    runner = Runner()
+    runner.start(args.port)
+
+    # Wait until we're asked to shut down via CTRL-C or SIGTERM.
+    signal.pause()
+
+    runner.stop()
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/scouting/webserver/requests/debug/cli/BUILD b/scouting/webserver/requests/debug/cli/BUILD
index 903f8c8..371f66e 100644
--- a/scouting/webserver/requests/debug/cli/BUILD
+++ b/scouting/webserver/requests/debug/cli/BUILD
@@ -30,7 +30,8 @@
     ],
     data = [
         ":cli",
-        "//scouting/scraping:test_data",
-        "//scouting/webserver",
+    ],
+    deps = [
+        "//scouting/testing:scouting_test_servers",
     ],
 )
diff --git a/scouting/webserver/requests/debug/cli/cli_test.py b/scouting/webserver/requests/debug/cli/cli_test.py
index 64d79a6..f4b82b4 100644
--- a/scouting/webserver/requests/debug/cli/cli_test.py
+++ b/scouting/webserver/requests/debug/cli/cli_test.py
@@ -7,11 +7,15 @@
 import shutil
 import socket
 import subprocess
+import sys
 import textwrap
 import time
 from typing import Any, Dict, List
 import unittest
 
+import scouting.testing.scouting_test_servers
+
+
 def write_json_request(content: Dict[str, Any]):
     """Writes a JSON file with the specified dict content."""
     json_path = Path(os.environ["TEST_TMPDIR"]) / "test.json"
@@ -31,72 +35,15 @@
         run_result.stderr.decode("utf-8"),
     )
 
-def wait_for_server(port: int):
-    """Waits for the server at the specified port to respond to TCP connections."""
-    while True:
-        try:
-            connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            connection.connect(("localhost", port))
-            connection.close()
-            break
-        except ConnectionRefusedError:
-            connection.close()
-            time.sleep(0.01)
-
 
 class TestDebugCli(unittest.TestCase):
 
     def setUp(self):
-        tmpdir = Path(os.environ["TEST_TMPDIR"]) / "temp"
-        try:
-            shutil.rmtree(tmpdir)
-        except FileNotFoundError:
-            pass
-        os.mkdir(tmpdir)
-
-        # Copy the test data into place so that the final API call can be
-        # emulated.
-        self.set_up_tba_api_dir(tmpdir, year=2016, event_code="nytr")
-        self.set_up_tba_api_dir(tmpdir, year=2020, event_code="fake")
-
-        # Create a fake TBA server to serve the static match list.
-        self.fake_tba_api = subprocess.Popen(
-            ["python3", "-m", "http.server", "7000"],
-            cwd=tmpdir,
-        )
-
-        # Configure the scouting webserver to scrape data from our fake TBA
-        # server.
-        scouting_config = tmpdir / "scouting_config.json"
-        scouting_config.write_text(json.dumps({
-            "api_key": "dummy_key_that_is_not_actually_used_in_this_test",
-            "base_url": "http://localhost:7000",
-        }))
-
-        # Run the scouting webserver.
-        self.webserver = subprocess.Popen([
-            "scouting/webserver/webserver_/webserver",
-            "-port=8080",
-            "-database=%s/database.db" % tmpdir,
-            "-tba_config=%s/scouting_config.json" % tmpdir,
-        ])
-
-        # Wait for the servers to be reachable.
-        wait_for_server(7000)
-        wait_for_server(8080)
+        self.servers = scouting.testing.scouting_test_servers.Runner()
+        self.servers.start(8080)
 
     def tearDown(self):
-        self.fake_tba_api.terminate()
-        self.webserver.terminate()
-        self.fake_tba_api.wait()
-        self.webserver.wait()
-
-    def set_up_tba_api_dir(self, tmpdir, year, event_code):
-        tba_api_dir = tmpdir / "api" / "v3" / "event" / f"{year}{event_code}"
-        os.makedirs(tba_api_dir)
-        (tba_api_dir / "matches").write_text(
-            Path(f"scouting/scraping/test_data/{year}_{event_code}.json").read_text()
-        )
+        self.servers.stop()
 
     def refresh_match_list(self, year=2016, event_code="nytr"):
         """Triggers the webserver to fetch the match list."""
diff --git a/y2022/BUILD b/y2022/BUILD
index 4420894..f2bdb8b 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -203,6 +203,7 @@
         "//frc971:constants",
         "//frc971/control_loops:pose",
         "//frc971/control_loops:static_zeroing_single_dof_profiled_subsystem",
+        "//frc971/shooter_interpolation:interpolation",
         "//y2022/control_loops/drivetrain:polydrivetrain_plants",
         "//y2022/control_loops/superstructure/catapult:catapult_plants",
         "//y2022/control_loops/superstructure/climber:climber_plants",
diff --git a/y2022/constants.cc b/y2022/constants.cc
index 980735f..2e7c5a9 100644
--- a/y2022/constants.cc
+++ b/y2022/constants.cc
@@ -130,9 +130,20 @@
   catapult_params->zeroing_constants.moving_buffer_size = 20;
   catapult_params->zeroing_constants.allowable_encoder_error = 0.9;
 
+  // Interpolation table for comp and practice robots
+  r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
+      {2, {0.08, 8.0}},
+      {5, {0.6, 10.0}},
+  });
+
   switch (team) {
     // A set of constants for tests.
     case 1:
+      r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
+          {2, {0.08, 8.0}},
+          {5, {0.6, 10.0}},
+      });
+
       climber->potentiometer_offset = 0.0;
       intake_front->potentiometer_offset = 0.0;
       intake_front->subsystem_params.zeroing_constants
@@ -193,6 +204,11 @@
       break;
 
     case kCodingRobotTeamNumber:
+      r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
+          {2, {0.08, 8.0}},
+          {5, {0.6, 10.0}},
+      });
+
       climber->potentiometer_offset = 0.0;
       intake_front->potentiometer_offset = 0.0;
       intake_front->subsystem_params.zeroing_constants
diff --git a/y2022/constants.h b/y2022/constants.h
index ffe1283..21ea7a4 100644
--- a/y2022/constants.h
+++ b/y2022/constants.h
@@ -8,12 +8,15 @@
 #include "frc971/constants.h"
 #include "frc971/control_loops/pose.h"
 #include "frc971/control_loops/static_zeroing_single_dof_profiled_subsystem.h"
+#include "frc971/shooter_interpolation/interpolation.h"
 #include "y2022/control_loops/drivetrain/drivetrain_dog_motor_plant.h"
 #include "y2022/control_loops/superstructure/catapult/catapult_plant.h"
 #include "y2022/control_loops/superstructure/climber/climber_plant.h"
 #include "y2022/control_loops/superstructure/intake/intake_plant.h"
 #include "y2022/control_loops/superstructure/turret/turret_plant.h"
 
+using ::frc971::shooter_interpolation::InterpolationTable;
+
 namespace y2022 {
 namespace constants {
 
@@ -205,6 +208,23 @@
 
   // TODO(milind): set this
   static constexpr double kImuHeight() { return 0.0; }
+
+  struct ShotParams {
+    // Measured in radians
+    double shot_angle;
+    // Muzzle velocity (m/s) of the ball as it is released from the catapult.
+    double shot_velocity;
+
+    static ShotParams BlendY(double coefficient, ShotParams a1, ShotParams a2) {
+      using ::frc971::shooter_interpolation::Blend;
+      return ShotParams{
+          Blend(coefficient, a1.shot_angle, a2.shot_angle),
+          Blend(coefficient, a1.shot_velocity, a2.shot_velocity),
+      };
+    }
+  };
+
+  InterpolationTable<ShotParams> shot_interpolation_table;
 };
 
 // Creates and returns a Values instance for the constants.
diff --git a/y2022/control_loops/superstructure/BUILD b/y2022/control_loops/superstructure/BUILD
index e7b6559..4739cd1 100644
--- a/y2022/control_loops/superstructure/BUILD
+++ b/y2022/control_loops/superstructure/BUILD
@@ -82,6 +82,7 @@
         ":superstructure_output_fbs",
         ":superstructure_position_fbs",
         ":superstructure_status_fbs",
+        "//aos:flatbuffer_merge",
         "//aos/events:event_loop",
         "//frc971/control_loops:control_loop",
         "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
diff --git a/y2022/control_loops/superstructure/catapult/catapult.cc b/y2022/control_loops/superstructure/catapult/catapult.cc
index 4a644e2..8b5c7eb 100644
--- a/y2022/control_loops/superstructure/catapult/catapult.cc
+++ b/y2022/control_loops/superstructure/catapult/catapult.cc
@@ -312,25 +312,28 @@
 
 const flatbuffers::Offset<
     frc971::control_loops::PotAndAbsoluteEncoderProfiledJointStatus>
-Catapult::Iterate(const Goal *unsafe_goal, const Position *position,
+Catapult::Iterate(const CatapultGoal *catapult_goal, const Position *position,
                   double battery_voltage, double *catapult_voltage, bool fire,
                   flatbuffers::FlatBufferBuilder *fbb) {
   const frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal
-      *catapult_goal = unsafe_goal != nullptr && unsafe_goal->has_catapult()
-                           ? (unsafe_goal->catapult()->return_position())
-                           : nullptr;
+      *return_goal =
+          catapult_goal != nullptr && catapult_goal->has_return_position()
+              ? catapult_goal->return_position()
+              : nullptr;
 
   const bool catapult_disabled = catapult_.Correct(
-      catapult_goal, position->catapult(), catapult_voltage == nullptr);
+      return_goal, position->catapult(), catapult_voltage == nullptr);
 
   if (catapult_disabled) {
     catapult_state_ = CatapultState::PROFILE;
-  } else if (catapult_.running() && unsafe_goal &&
-             unsafe_goal->has_catapult() && fire && !last_firing_) {
+  } else if (catapult_.running() && catapult_goal != nullptr && fire &&
+             !last_firing_) {
     catapult_state_ = CatapultState::FIRING;
+    latched_shot_position = catapult_goal->shot_position();
+    latched_shot_velocity = catapult_goal->shot_velocity();
   }
 
-  if (catapult_.running() && unsafe_goal && unsafe_goal->has_catapult()) {
+  if (catapult_.running()) {
     last_firing_ = fire;
   }
 
@@ -354,8 +357,7 @@
 
       catapult_mpc_.SetState(
           next_X.block<2, 1>(0, 0),
-          Eigen::Vector2d(unsafe_goal->catapult()->shot_position(),
-                          unsafe_goal->catapult()->shot_velocity()));
+          Eigen::Vector2d(latched_shot_position, latched_shot_velocity));
 
       const bool solved = catapult_mpc_.Solve();
 
@@ -379,7 +381,7 @@
           use_profile_ = false;
         }
       } else {
-        if (unsafe_goal && unsafe_goal->has_catapult() && !fire) {
+        if (!fire) {
           // Eh, didn't manage to solve before it was time to fire.  Give up.
           catapult_state_ = CatapultState::PROFILE;
         }
diff --git a/y2022/control_loops/superstructure/catapult/catapult.h b/y2022/control_loops/superstructure/catapult/catapult.h
index 0606c0d..6a2c834 100644
--- a/y2022/control_loops/superstructure/catapult/catapult.h
+++ b/y2022/control_loops/superstructure/catapult/catapult.h
@@ -210,14 +210,11 @@
   // shooting or not.  Returns the status.
   const flatbuffers::Offset<
       frc971::control_loops::PotAndAbsoluteEncoderProfiledJointStatus>
-  Iterate(const Goal *unsafe_goal, const Position *position,
+  Iterate(const CatapultGoal *unsafe_goal, const Position *position,
           double battery_voltage, double *catapult_voltage, bool fire,
           flatbuffers::FlatBufferBuilder *fbb);
 
  private:
-  // TODO(austin): Prototype is just an encoder.  Catapult has both an encoder
-  // and pot.  Switch back once we have a catapult.
-  // PotAndAbsoluteEncoderSubsystem catapult_;
   PotAndAbsoluteEncoderSubsystem catapult_;
 
   catapult::CatapultController catapult_mpc_;
@@ -226,6 +223,9 @@
 
   CatapultState catapult_state_ = CatapultState::PROFILE;
 
+  double latched_shot_position = 0.0;
+  double latched_shot_velocity = 0.0;
+
   bool last_firing_ = false;
   bool use_profile_ = true;
 
diff --git a/y2022/control_loops/superstructure/superstructure.cc b/y2022/control_loops/superstructure/superstructure.cc
index 0b693ef..56e58cc 100644
--- a/y2022/control_loops/superstructure/superstructure.cc
+++ b/y2022/control_loops/superstructure/superstructure.cc
@@ -1,6 +1,7 @@
 #include "y2022/control_loops/superstructure/superstructure.h"
 
 #include "aos/events/event_loop.h"
+#include "aos/flatbuffer_merge.h"
 #include "y2022/control_loops/superstructure/collision_avoidance.h"
 
 namespace y2022 {
@@ -48,6 +49,7 @@
   aos::FlatbufferFixedAllocatorArray<
       frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal, 64>
       turret_goal_buffer;
+  aos::FlatbufferFixedAllocatorArray<CatapultGoal, 64> catapult_goal_buffer;
 
   const aos::monotonic_clock::time_point timestamp =
       event_loop()->context().monotonic_event_time;
@@ -64,6 +66,7 @@
 
   const frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal
       *turret_goal = nullptr;
+  const CatapultGoal *catapult_goal = nullptr;
   double roller_speed_compensated_front = 0.0;
   double roller_speed_compensated_back = 0.0;
   double transfer_roller_speed_front = 0.0;
@@ -84,6 +87,31 @@
 
     turret_goal =
         unsafe_goal->auto_aim() ? auto_aim_goal : unsafe_goal->turret();
+
+    catapult_goal = unsafe_goal->catapult();
+
+    constants::Values::ShotParams shot_params;
+    const double distance_to_goal = aimer_.DistanceToGoal();
+    if (unsafe_goal->auto_aim() && values_->shot_interpolation_table.GetInRange(
+                                       distance_to_goal, &shot_params)) {
+      std::optional<flatbuffers::Offset<
+          frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal>>
+          return_position_offset;
+      if (unsafe_goal != nullptr && unsafe_goal->has_catapult() &&
+          unsafe_goal->catapult()->has_return_position()) {
+        return_position_offset = {
+            aos::CopyFlatBuffer(unsafe_goal->catapult()->return_position(),
+                                catapult_goal_buffer.fbb())};
+      }
+      CatapultGoal::Builder builder(*catapult_goal_buffer.fbb());
+      builder.add_shot_position(shot_params.shot_angle);
+      builder.add_shot_velocity(shot_params.shot_velocity);
+      if (return_position_offset.has_value()) {
+        builder.add_return_position(return_position_offset.value());
+      }
+      catapult_goal_buffer.Finish(builder.Finish());
+      catapult_goal = &catapult_goal_buffer.message();
+    }
   }
 
   // Superstructure state machine:
@@ -395,7 +423,7 @@
   // flippers
   const flatbuffers::Offset<PotAndAbsoluteEncoderProfiledJointStatus>
       catapult_status_offset = catapult_.Iterate(
-          unsafe_goal, position, robot_state().voltage_battery(),
+          catapult_goal, position, robot_state().voltage_battery(),
           output != nullptr && !catapult_.estopped()
               ? &(output_struct.catapult_voltage)
               : nullptr,
@@ -459,6 +487,10 @@
   status_builder.add_solve_time(catapult_.solve_time());
   status_builder.add_shot_count(catapult_.shot_count());
   status_builder.add_mpc_active(catapult_.mpc_active());
+  if (catapult_goal != nullptr) {
+    status_builder.add_shot_position(catapult_goal->shot_position());
+    status_builder.add_shot_velocity(catapult_goal->shot_velocity());
+  }
 
   status_builder.add_flippers_open(flippers_open_);
   status_builder.add_reseating_in_catapult(reseating_in_catapult_);
diff --git a/y2022/control_loops/superstructure/superstructure_lib_test.cc b/y2022/control_loops/superstructure/superstructure_lib_test.cc
index 73ceee7..e6ff227 100644
--- a/y2022/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2022/control_loops/superstructure/superstructure_lib_test.cc
@@ -1254,6 +1254,42 @@
                   superstructure_status_fetcher_->aimer()->turret_velocity());
 }
 
+TEST_F(SuperstructureTest, InterpolationTableTest) {
+  SetEnabled(true);
+  WaitUntilZeroed();
+
+  constexpr double kDistance = 3.0;
+
+  SendDrivetrainStatus(0.0, {0.0, kDistance}, 0.0);
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_auto_aim(true);
+
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  // Give it time to stabilize.
+  RunFor(chrono::seconds(2));
+
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_NEAR(superstructure_status_fetcher_->aimer()->target_distance(),
+              kDistance, 0.01);
+
+  constants::Values::ShotParams shot_params;
+  EXPECT_TRUE(
+      values_->shot_interpolation_table.GetInRange(kDistance, &shot_params));
+
+  EXPECT_EQ(superstructure_status_fetcher_->shot_velocity(),
+            shot_params.shot_velocity);
+  EXPECT_EQ(superstructure_status_fetcher_->shot_position(),
+            shot_params.shot_angle);
+}
+
 }  // namespace testing
 }  // namespace superstructure
 }  // namespace control_loops
diff --git a/y2022/control_loops/superstructure/superstructure_status.fbs b/y2022/control_loops/superstructure/superstructure_status.fbs
index 4b215ac..d802f08 100644
--- a/y2022/control_loops/superstructure/superstructure_status.fbs
+++ b/y2022/control_loops/superstructure/superstructure_status.fbs
@@ -70,6 +70,8 @@
 
   solve_time:double (id: 7);
   mpc_active:bool (id: 8);
+  shot_position:double (id: 16);
+  shot_velocity:double (id: 17);
 
   // The number of shots we have taken.
   shot_count:int32 (id: 9);
diff --git a/y2022/vision/blob_detector.cc b/y2022/vision/blob_detector.cc
index aaa22f6..873cf43 100644
--- a/y2022/vision/blob_detector.cc
+++ b/y2022/vision/blob_detector.cc
@@ -13,7 +13,7 @@
 
 DEFINE_uint64(red_delta, 100,
               "Required difference between green pixels vs. red");
-DEFINE_uint64(blue_delta, 30,
+DEFINE_uint64(blue_delta, 1,
               "Required difference between green pixels vs. blue");
 
 DEFINE_bool(use_outdoors, false,
@@ -52,6 +52,14 @@
     }
   }
 
+  // Fill in the contours on the binarized image so that we don't detect
+  // multiple blobs in one
+  const auto blobs = FindBlobs(binarized_image);
+  for (auto it = blobs.begin(); it < blobs.end(); it++) {
+    cv::drawContours(binarized_image, blobs, it - blobs.begin(),
+                     cv::Scalar(255), cv::FILLED);
+  }
+
   return binarized_image;
 }
 
diff --git a/y2022/vision/viewer.cc b/y2022/vision/viewer.cc
index 24c8fc6..f847dbd 100644
--- a/y2022/vision/viewer.cc
+++ b/y2022/vision/viewer.cc
@@ -212,7 +212,7 @@
 
   TargetEstimator estimator(intrinsics, extrinsics);
 
-  for (auto it = file_list.begin() + FLAGS_skip; it != file_list.end(); it++) {
+  for (auto it = file_list.begin() + FLAGS_skip; it < file_list.end(); it++) {
     LOG(INFO) << "Reading file " << *it;
     cv::Mat image_mat = cv::imread(it->c_str());
     BlobDetector::BlobResult blob_result;