Merge "Configure rockpi to flush dirty pages faster"
diff --git a/.bazelignore b/.bazelignore
index 784e4c1..2409a99 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -4,7 +4,6 @@
 scouting/www/counter_button/node_modules
 scouting/www/driver_ranking/node_modules
 scouting/www/entry/node_modules
-scouting/www/import_match_list/node_modules
 scouting/www/match_list/node_modules
 scouting/www/notes/node_modules
 scouting/www/rpc/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index 817e5ba..a7630d1 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -959,7 +959,6 @@
         "@//scouting/www/counter_button:package.json",
         "@//scouting/www/driver_ranking:package.json",
         "@//scouting/www/entry:package.json",
-        "@//scouting/www/import_match_list:package.json",
         "@//scouting/www/match_list:package.json",
         "@//scouting/www/notes:package.json",
         "@//scouting/www/rpc:package.json",
diff --git a/aos/util/config_validator.cc b/aos/util/config_validator.cc
index 78bd0b1..d5bd6ba 100644
--- a/aos/util/config_validator.cc
+++ b/aos/util/config_validator.cc
@@ -1,4 +1,5 @@
 #include <chrono>
+
 #include "aos/configuration.h"
 #include "aos/events/logging/log_reader.h"
 #include "aos/events/logging/log_writer.h"
@@ -9,8 +10,7 @@
 #include "gflags/gflags.h"
 #include "gtest/gtest.h"
 
-DEFINE_string(config, "",
-              "Name of the config file to replay using.");
+DEFINE_string(config, "", "Name of the config file to replay using.");
 /* This binary is used to validate that all of the
    needed remote timestamps channels are in the config
    to log the timestamps.
@@ -18,8 +18,8 @@
    to confirm that the timestamps in the config are able to
    replay all of the data in the log
    This can be done by getting a list of all of the nodes and
-   iterating through it with a for loop creating a logger for 
-   each one 
+   iterating through it with a for loop creating a logger for
+   each one
    Reference superstructure_lib_test.cc*/
 TEST(ConfigValidatorTest, ReadConfig) {
   ASSERT_TRUE(!FLAGS_config.empty());
@@ -30,3 +30,7 @@
 
   factory.RunFor(std::chrono::seconds(1));
 }
+
+// TODO(milind): add more tests, the above one doesn't
+// catch an error like forgetting to add a forwarded message to
+// the destination node's config.
diff --git a/aos/util/config_validator_macro.bzl b/aos/util/config_validator_macro.bzl
index 47ab240..453c5c2 100644
--- a/aos/util/config_validator_macro.bzl
+++ b/aos/util/config_validator_macro.bzl
@@ -1,4 +1,4 @@
-def config_validator_rule(name, config, visibility = None):
+def config_validator_rule(name, config, extension = ".bfbs", visibility = None):
     '''
     Macro to take a config and pass it to the config validator to validate that it will work on a real system.
 
@@ -8,12 +8,12 @@
         name: name that the config validator uses, e.g. "test_config",
         config: config rule that needs to be validated, e.g. "//aos/events:pingpong_config",
     '''
-    config_bfbs = config + ".bfbs"
+    config_file = config + extension
     native.genrule(
         name = name,
         outs = [name + ".txt"],
-        cmd = "$(location //aos/util:config_validator) --config $(location %s) > $@" % config_bfbs,
-        srcs = [config_bfbs],
+        cmd = "$(location //aos/util:config_validator) --config $(location %s) > $@" % config_file,
+        srcs = [config_file],
         tools = ["//aos/util:config_validator"],
         testonly = True,
         visibility = visibility,
diff --git a/frc971/control_loops/control_loops.fbs b/frc971/control_loops/control_loops.fbs
index 243e2cb..da2dc05 100644
--- a/frc971/control_loops/control_loops.fbs
+++ b/frc971/control_loops/control_loops.fbs
@@ -86,6 +86,13 @@
   encoder:double (id: 0);
 }
 
+// An enum to represent the different types of errors
+// a zeroing estimator could have.
+enum ZeroingError : short {
+  OFFSET_MOVED_TOO_FAR,
+  LOST_ABSOLUTE_ENCODER
+}
+
 // The internal state of a zeroing estimator.
 table EstimatorState {
   // If true, there has been a fatal error for the estimator.
@@ -114,6 +121,9 @@
   // The estimated absolute position of the encoder.  This is filtered, so it
   // can be easily used when zeroing.
   absolute_position:double (id: 4);
+
+  // If errored, this contains the causes for the error.
+  errors: [ZeroingError] (id: 5);
 }
 
 // The internal state of a zeroing estimator.
@@ -128,6 +138,9 @@
   // The estimated absolute position of the encoder.  This is filtered, so it
   // can be easily used when zeroing.
   absolute_position:double (id: 3);
+
+  // If errored, this contains the causes for the error.
+  errors: [ZeroingError] (id: 4);
 }
 
 // The internal state of a zeroing estimator.
@@ -145,8 +158,12 @@
 
   // Estimated absolute position of the single turn absolute encoder.
   single_turn_absolute_position:double (id: 4);
+
+  // If errored, this contains the causes for the error.
+  errors: [ZeroingError] (id: 5);
 }
 
+
 table RelativeEncoderEstimatorState {
   // If true, there has been a fatal error for the estimator.
   error:bool (id: 0);
diff --git a/frc971/control_loops/drivetrain/drivetrain_config.h b/frc971/control_loops/drivetrain/drivetrain_config.h
index 9150e8a..51f92b1 100644
--- a/frc971/control_loops/drivetrain/drivetrain_config.h
+++ b/frc971/control_loops/drivetrain/drivetrain_config.h
@@ -49,6 +49,31 @@
   int do_accel_corrections = 50;
 };
 
+// Configuration for line-following mode.
+struct LineFollowConfig {
+  // The line-following uses an LQR controller with states of [theta,
+  // linear_velocity, angular_velocity] and inputs of [left_voltage,
+  // right_voltage].
+  // These Q and R matrices are the costs for state and input respectively.
+  Eigen::Matrix3d Q =
+      Eigen::Matrix3d((::Eigen::DiagonalMatrix<double, 3>().diagonal()
+                           << 1.0 / ::std::pow(0.1, 2),
+                       1.0 / ::std::pow(1.0, 2), 1.0 / ::std::pow(1.0, 2))
+                          .finished()
+                          .asDiagonal());
+  Eigen::Matrix2d R =
+      Eigen::Matrix2d((::Eigen::DiagonalMatrix<double, 2>().diagonal()
+                           << 1.0 / ::std::pow(12.0, 2),
+                       1.0 / ::std::pow(12.0, 2))
+                          .finished()
+                          .asDiagonal());
+
+  // The driver can use their steering controller to adjust where we attempt to
+  // place things laterally. This specifies how much range on either side of
+  // zero we allow them, in meters.
+  double max_controllable_offset = 0.1;
+};
+
 template <typename Scalar = double>
 struct DrivetrainConfig {
   // Shifting method we are using.
@@ -118,6 +143,8 @@
 
   DownEstimatorConfig down_estimator_config{};
 
+  LineFollowConfig line_follow_config{};
+
   // Converts the robot state to a linear distance position, velocity.
   static Eigen::Matrix<Scalar, 2, 1> LeftRightToLinear(
       const Eigen::Matrix<Scalar, 7, 1> &left_right) {
diff --git a/frc971/control_loops/drivetrain/line_follow_drivetrain.cc b/frc971/control_loops/drivetrain/line_follow_drivetrain.cc
index a53538b..95f14a3 100644
--- a/frc971/control_loops/drivetrain/line_follow_drivetrain.cc
+++ b/frc971/control_loops/drivetrain/line_follow_drivetrain.cc
@@ -95,6 +95,8 @@
     const DrivetrainConfig<double> &dt_config,
     TargetSelectorInterface *target_selector)
     : dt_config_(dt_config),
+      Q_(dt_config_.line_follow_config.Q),
+      R_(dt_config_.line_follow_config.R),
       A_d_(ADiscrete(dt_config_)),
       B_d_(BDiscrete(dt_config_)),
       K_(CalcK(A_d_, B_d_, Q_, R_)),
@@ -131,7 +133,8 @@
   }
   // Set an adjustment that lets the driver tweak the offset for where we place
   // the target left/right.
-  side_adjust_ = -goal->wheel() * 0.1;
+  side_adjust_ =
+      -goal->wheel() * dt_config_.line_follow_config.max_controllable_offset;
 }
 
 bool LineFollowDrivetrain::SetOutput(
diff --git a/frc971/control_loops/drivetrain/line_follow_drivetrain.h b/frc971/control_loops/drivetrain/line_follow_drivetrain.h
index 24f8cd4..c5b03f0 100644
--- a/frc971/control_loops/drivetrain/line_follow_drivetrain.h
+++ b/frc971/control_loops/drivetrain/line_follow_drivetrain.h
@@ -1,7 +1,6 @@
 #ifndef FRC971_CONTROL_LOOPS_DRIVETRAIN_LINE_FOLLOW_DRIVETRAIN_H_
 #define FRC971_CONTROL_LOOPS_DRIVETRAIN_LINE_FOLLOW_DRIVETRAIN_H_
 #include "Eigen/Dense"
-
 #include "frc971/control_loops/control_loops_generated.h"
 #include "frc971/control_loops/drivetrain/drivetrain_config.h"
 #include "frc971/control_loops/drivetrain/drivetrain_goal_generated.h"
@@ -56,21 +55,10 @@
                    double relative_y_offset, double velocity_sign);
 
   const DrivetrainConfig<double> dt_config_;
-  // TODO(james): These should probably be factored out somewhere.
   // TODO(james): This controller is not actually asymptotically stable, due to
   // the varying goal theta.
-  const ::Eigen::DiagonalMatrix<double, 3> Q_ =
-      (::Eigen::DiagonalMatrix<double, 3>().diagonal()
-           << 1.0 / ::std::pow(0.1, 2),
-       1.0 / ::std::pow(1.0, 2), 1.0 / ::std::pow(1.0, 2))
-          .finished()
-          .asDiagonal();
-  const ::Eigen::DiagonalMatrix<double, 2> R_ =
-      (::Eigen::DiagonalMatrix<double, 2>().diagonal()
-           << 1.0 / ::std::pow(12.0, 2),
-       1.0 / ::std::pow(12.0, 2))
-          .finished()
-          .asDiagonal();
+  const ::Eigen::Matrix<double, 3, 3> Q_;
+  const ::Eigen::Matrix<double, 2, 2> R_;
   // The matrices we use for the linear controller.
   // State for these is [theta, linear_velocity, angular_velocity]
   const ::Eigen::Matrix<double, 3, 3> A_d_;
diff --git a/frc971/control_loops/drivetrain/localization/puppet_localizer.cc b/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
index 3125793..33095f2 100644
--- a/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
@@ -7,7 +7,9 @@
 PuppetLocalizer::PuppetLocalizer(
     aos::EventLoop *event_loop,
     const frc971::control_loops::drivetrain::DrivetrainConfig<double>
-        &dt_config)
+        &dt_config,
+    std::unique_ptr<frc971::control_loops::drivetrain::TargetSelectorInterface>
+        target_selector)
     : event_loop_(event_loop),
       dt_config_(dt_config),
       ekf_(dt_config),
@@ -17,7 +19,8 @@
               "/localizer")),
       clock_offset_fetcher_(
           event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
-              "/aos")) {
+              "/aos")),
+      target_selector_(std::move(target_selector)) {
   ekf_.set_ignore_accel(true);
 
   event_loop->OnRun([this, event_loop]() {
@@ -25,7 +28,11 @@
                            HybridEkf::State::Zero(), ekf_.P());
   });
 
-  target_selector_.set_has_target(false);
+  if (!target_selector_) {
+    auto selector = std::make_unique<TrivialTargetSelector>();
+    selector->set_has_target(false);
+    target_selector_ = std::move(selector);
+  }
 }
 
 void PuppetLocalizer::Reset(
diff --git a/frc971/control_loops/drivetrain/localization/puppet_localizer.h b/frc971/control_loops/drivetrain/localization/puppet_localizer.h
index 4f8f4f3..24e2f88 100644
--- a/frc971/control_loops/drivetrain/localization/puppet_localizer.h
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer.h
@@ -32,14 +32,17 @@
   PuppetLocalizer(
       aos::EventLoop *event_loop,
       const frc971::control_loops::drivetrain::DrivetrainConfig<double>
-          &dt_config);
+          &dt_config,
+      std::unique_ptr<
+          frc971::control_loops::drivetrain::TargetSelectorInterface>
+          target_selector = {});
   frc971::control_loops::drivetrain::HybridEkf<double>::State Xhat()
       const override {
     return ekf_.X_hat().cast<double>();
   }
-  frc971::control_loops::drivetrain::TrivialTargetSelector *target_selector()
+  frc971::control_loops::drivetrain::TargetSelectorInterface *target_selector()
       override {
-    return &target_selector_;
+    return target_selector_.get();
   }
 
   void Update(const ::Eigen::Matrix<double, 2, 1> &U,
@@ -97,7 +100,8 @@
   aos::Fetcher<aos::message_bridge::ServerStatistics> clock_offset_fetcher_;
 
   // Target selector to allow us to satisfy the LocalizerInterface requirements.
-  frc971::control_loops::drivetrain::TrivialTargetSelector target_selector_;
+  std::unique_ptr<frc971::control_loops::drivetrain::TargetSelectorInterface>
+      target_selector_;
 };
 
 }  // namespace drivetrain
diff --git a/frc971/control_loops/drivetrain/localizer.h b/frc971/control_loops/drivetrain/localizer.h
index 67d681e..d590f1d 100644
--- a/frc971/control_loops/drivetrain/localizer.h
+++ b/frc971/control_loops/drivetrain/localizer.h
@@ -15,6 +15,7 @@
 // state updates and then determine what poes we should be driving to.
 class TargetSelectorInterface {
  public:
+  virtual ~TargetSelectorInterface() {}
   // Take the state as [x, y, theta, left_vel, right_vel]
   // If unable to determine what target to go for, returns false. If a viable
   // target is selected, then returns true and sets target_pose.
@@ -108,6 +109,7 @@
 // manually set the target selector state.
 class TrivialTargetSelector : public TargetSelectorInterface {
  public:
+  virtual ~TrivialTargetSelector() {}
   bool UpdateSelection(const ::Eigen::Matrix<double, 5, 1> &, double) override {
     return has_target_;
   }
diff --git a/frc971/zeroing/BUILD b/frc971/zeroing/BUILD
index 9ec2772..d50f181 100644
--- a/frc971/zeroing/BUILD
+++ b/frc971/zeroing/BUILD
@@ -80,6 +80,7 @@
     target_compatible_with = ["@platforms//os:linux"],
     deps = [
         ":wrap",
+        "//aos/containers:error_list",
         "//aos/logging",
         "//frc971:constants",
         "//frc971/control_loops:control_loops_fbs",
diff --git a/frc971/zeroing/absolute_and_absolute_encoder.cc b/frc971/zeroing/absolute_and_absolute_encoder.cc
index 40b0519..03ef3f1 100644
--- a/frc971/zeroing/absolute_and_absolute_encoder.cc
+++ b/frc971/zeroing/absolute_and_absolute_encoder.cc
@@ -71,6 +71,7 @@
     if (zeroed_) {
       VLOG(1) << "NAN on one of the absolute encoders.";
       error_ = true;
+      errors_.Set(ZeroingError::LOST_ABSOLUTE_ENCODER);
     } else {
       ++nan_samples_;
       VLOG(1) << "NAN on one of the absolute encoders while zeroing"
@@ -78,6 +79,7 @@
       if (nan_samples_ >= constants_.average_filter_size) {
         error_ = true;
         zeroed_ = true;
+        errors_.Set(ZeroingError::LOST_ABSOLUTE_ENCODER);
       }
     }
     // Throw some dummy values in for now.
@@ -189,11 +191,6 @@
          (-constants_.single_turn_measured_absolute_position +
           what_Unwrap_added));
 
-    /*
-    filtered_single_turn_absolute_encoder_ =
-        sample.encoder - single_turn_to_relative_encoder_offset_;
-    */
-
     if (!zeroed_) {
       first_offset_ = offset_;
     }
@@ -209,6 +206,7 @@
                 constants_.allowable_encoder_error *
                     constants_.one_revolution_distance);
         error_ = true;
+        errors_.Set(ZeroingError::OFFSET_MOVED_TOO_FAR);
       }
 
       zeroed_ = true;
@@ -222,8 +220,12 @@
 flatbuffers::Offset<AbsoluteAndAbsoluteEncoderZeroingEstimator::State>
 AbsoluteAndAbsoluteEncoderZeroingEstimator::GetEstimatorState(
     flatbuffers::FlatBufferBuilder *fbb) const {
+  flatbuffers::Offset<flatbuffers::Vector<ZeroingError>> errors_offset =
+      errors_.ToFlatbuffer(fbb);
+
   State::Builder builder(*fbb);
   builder.add_error(error_);
+  builder.add_errors(errors_offset);
   builder.add_zeroed(zeroed_);
   builder.add_position(position_);
   builder.add_absolute_position(filtered_absolute_encoder_);
diff --git a/frc971/zeroing/absolute_and_absolute_encoder.h b/frc971/zeroing/absolute_and_absolute_encoder.h
index 499f7d1..509d5c5 100644
--- a/frc971/zeroing/absolute_and_absolute_encoder.h
+++ b/frc971/zeroing/absolute_and_absolute_encoder.h
@@ -5,6 +5,7 @@
 
 #include "flatbuffers/flatbuffers.h"
 
+#include "aos/containers/error_list.h"
 #include "frc971/zeroing/zeroing.h"
 
 namespace frc971 {
@@ -73,6 +74,8 @@
   bool zeroed_;
   // Marker to track whether an error has occurred.
   bool error_;
+  // Marker to track what kind of error has occured.
+  aos::ErrorList<ZeroingError> errors_;
   // The first valid offset we recorded. This is only set after zeroed_ first
   // changes to true.
   double first_offset_;
diff --git a/frc971/zeroing/absolute_and_absolute_encoder_test.cc b/frc971/zeroing/absolute_and_absolute_encoder_test.cc
index cc1872d..59b421a 100644
--- a/frc971/zeroing/absolute_and_absolute_encoder_test.cc
+++ b/frc971/zeroing/absolute_and_absolute_encoder_test.cc
@@ -1,15 +1,15 @@
 #include "frc971/zeroing/absolute_and_absolute_encoder.h"
 
-#include "gtest/gtest.h"
-
 #include "frc971/zeroing/zeroing_test.h"
+#include "glog/logging.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
 
 namespace frc971 {
 namespace zeroing {
 namespace testing {
 
 using constants::AbsoluteAndAbsoluteEncoderZeroingConstants;
-using EstimatorState = AbsoluteAndAbsoluteEncoderZeroingEstimator::State;
 
 class AbsoluteAndAbsoluteEncoderZeroingTest : public ZeroingTest {
  protected:
@@ -17,7 +17,7 @@
               AbsoluteAndAbsoluteEncoderZeroingEstimator *estimator,
               double new_position) {
     simulator->MoveTo(new_position);
-    FBB fbb;
+    flatbuffers::FlatBufferBuilder fbb;
     estimator->UpdateEstimate(
         *simulator->FillSensorValues<AbsoluteAndAbsolutePosition>(&fbb));
   }
@@ -107,7 +107,7 @@
   AbsoluteAndAbsoluteEncoderZeroingEstimator estimator(constants);
 
   // We tolerate a couple NANs before we start.
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
   fbb.Finish(CreateAbsoluteAndAbsolutePosition(
       fbb, 0.0, ::std::numeric_limits<double>::quiet_NaN(), 0.0));
   for (size_t i = 0; i < kSampleSize - 1; ++i) {
@@ -180,7 +180,7 @@
 
   AbsoluteAndAbsoluteEncoderZeroingEstimator estimator(constants);
 
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
   fbb.Finish(CreateAbsoluteAndAbsolutePosition(
       fbb, 0.0, ::std::numeric_limits<double>::quiet_NaN(), 0.0));
   const auto sensor_values =
@@ -192,6 +192,14 @@
 
   estimator.UpdateEstimate(*sensor_values);
   ASSERT_TRUE(estimator.error());
+
+  fbb.Finish(estimator.GetEstimatorState(&fbb));
+  const AbsoluteAndAbsoluteEncoderEstimatorState *state =
+      flatbuffers::GetRoot<AbsoluteAndAbsoluteEncoderEstimatorState>(
+          fbb.GetBufferPointer());
+
+  ASSERT_GT(state->errors()->size(), 0);
+  EXPECT_EQ(state->errors()->Get(0), ZeroingError::LOST_ABSOLUTE_ENCODER);
 }
 
 TEST_F(AbsoluteAndAbsoluteEncoderZeroingTest,
@@ -234,12 +242,13 @@
   ASSERT_TRUE(estimator.zeroed());
   EXPECT_DOUBLE_EQ(start_pos, estimator.offset());
 
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
 
   fbb.Finish(estimator.GetEstimatorState(&fbb));
 
-  const EstimatorState *state =
-      flatbuffers::GetRoot<EstimatorState>(fbb.GetBufferPointer());
+  const AbsoluteAndAbsoluteEncoderEstimatorState *state =
+      flatbuffers::GetRoot<AbsoluteAndAbsoluteEncoderEstimatorState>(
+          fbb.GetBufferPointer());
 
   EXPECT_NEAR(state->position(), position, 1e-10);
 
@@ -253,6 +262,74 @@
   EXPECT_NEAR(state->single_turn_absolute_position(), 0.3, 1e-10);
 }
 
+// Tests that errors() adds the OFFSET_MOVED_TOO_FAR error when we move too far.
+TEST_F(AbsoluteAndAbsoluteEncoderZeroingTest,
+       TestAbsoluteAndAbsoluteEncoderZeroingStateErrors) {
+  const double full_range = 4.0;
+  const double distance_per_revolution = 1.0;
+  const double single_turn_distance_per_revolution = full_range;
+  const double start_pos = 2.1;
+  const double single_turn_middle_position = full_range * 0.5;
+  const double measured_absolute_position = 0.3 * distance_per_revolution;
+  const double single_turn_measured_absolute_position =
+      0.4 * single_turn_distance_per_revolution;
+
+  AbsoluteAndAbsoluteEncoderZeroingConstants constants{
+      kSampleSize,
+      distance_per_revolution,
+      measured_absolute_position,
+      single_turn_distance_per_revolution,
+      single_turn_measured_absolute_position,
+      single_turn_middle_position,
+      0.1,
+      kMovingBufferSize,
+      kIndexErrorFraction};
+
+  PositionSensorSimulator sim(distance_per_revolution,
+                              single_turn_distance_per_revolution);
+  sim.Initialize(start_pos, distance_per_revolution / 3.0, 0.0,
+                 measured_absolute_position,
+                 single_turn_measured_absolute_position);
+
+  AbsoluteAndAbsoluteEncoderZeroingEstimator estimator(constants);
+
+  const double position = 2.7;
+
+  for (size_t i = 0; i < kSampleSize + kMovingBufferSize - 1; ++i) {
+    MoveTo(&sim, &estimator, position);
+    ASSERT_FALSE(estimator.zeroed());
+  }
+  MoveTo(&sim, &estimator, position);
+  ASSERT_TRUE(estimator.zeroed());
+  EXPECT_DOUBLE_EQ(start_pos, estimator.offset());
+
+  // If the ratios suddenly get very messed up
+  flatbuffers::FlatBufferBuilder fbb;
+  fbb.Finish(CreateAbsoluteAndAbsolutePosition(fbb, 0.0, 0.0, 3.0));
+
+  const auto sensor_values =
+      flatbuffers::GetRoot<AbsoluteAndAbsolutePosition>(fbb.GetBufferPointer());
+
+  ASSERT_FALSE(estimator.error());
+
+  for (size_t i = 0; i < kSampleSize + kMovingBufferSize - 1; ++i) {
+    estimator.UpdateEstimate(*sensor_values);
+  }
+  ASSERT_TRUE(estimator.error());
+
+  flatbuffers::FlatBufferBuilder fbb2;
+  fbb2.Finish(estimator.GetEstimatorState(&fbb2));
+  const AbsoluteAndAbsoluteEncoderEstimatorState *state =
+      flatbuffers::GetRoot<AbsoluteAndAbsoluteEncoderEstimatorState>(
+          fbb2.GetBufferPointer());
+
+  for (ZeroingError err : *state->errors()) {
+    LOG(INFO) << "error: " << EnumNameZeroingError(err);
+  }
+  EXPECT_THAT(*state->errors(),
+              ::testing::ElementsAre(ZeroingError::OFFSET_MOVED_TOO_FAR));
+}
+
 }  // namespace testing
 }  // namespace zeroing
 }  // namespace frc971
diff --git a/frc971/zeroing/absolute_encoder.cc b/frc971/zeroing/absolute_encoder.cc
index ffdf9da..d0cd0d9 100644
--- a/frc971/zeroing/absolute_encoder.cc
+++ b/frc971/zeroing/absolute_encoder.cc
@@ -3,9 +3,9 @@
 #include <cmath>
 #include <numeric>
 
-#include "glog/logging.h"
-
+#include "aos/containers/error_list.h"
 #include "frc971/zeroing/wrap.h"
+#include "glog/logging.h"
 
 namespace frc971 {
 namespace zeroing {
@@ -29,7 +29,6 @@
   move_detector_.Reset();
 }
 
-
 // The math here is a bit backwards, but I think it'll be less error prone that
 // way and more similar to the version with a pot as well.
 //
@@ -49,11 +48,13 @@
   if (::std::isnan(info.absolute_encoder())) {
     if (zeroed_) {
       VLOG(1) << "NAN on absolute encoder.";
+      errors_.Set(ZeroingError::LOST_ABSOLUTE_ENCODER);
       error_ = true;
     } else {
       ++nan_samples_;
       VLOG(1) << "NAN on absolute encoder while zeroing " << nan_samples_;
       if (nan_samples_ >= constants_.average_filter_size) {
+        errors_.Set(ZeroingError::LOST_ABSOLUTE_ENCODER);
         error_ = true;
         zeroed_ = true;
       }
@@ -152,6 +153,7 @@
                 << ", current " << offset_ << ", allowable change: "
                 << constants_.allowable_encoder_error *
                        constants_.one_revolution_distance;
+        errors_.Set(ZeroingError::OFFSET_MOVED_TOO_FAR);
         error_ = true;
       }
 
@@ -166,11 +168,15 @@
 flatbuffers::Offset<AbsoluteEncoderZeroingEstimator::State>
 AbsoluteEncoderZeroingEstimator::GetEstimatorState(
     flatbuffers::FlatBufferBuilder *fbb) const {
+  flatbuffers::Offset<flatbuffers::Vector<ZeroingError>> errors_offset =
+      errors_.ToFlatbuffer(fbb);
+
   State::Builder builder(*fbb);
   builder.add_error(error_);
   builder.add_zeroed(zeroed_);
   builder.add_position(position_);
   builder.add_absolute_position(filtered_absolute_encoder_);
+  builder.add_errors(errors_offset);
   return builder.Finish();
 }
 
diff --git a/frc971/zeroing/absolute_encoder.h b/frc971/zeroing/absolute_encoder.h
index 0021e13..df40ec3 100644
--- a/frc971/zeroing/absolute_encoder.h
+++ b/frc971/zeroing/absolute_encoder.h
@@ -85,6 +85,9 @@
 
   // The filtered position.
   double position_ = 0.0;
+
+  // Marker to track what kind of error has occured.
+  aos::ErrorList<ZeroingError> errors_;
 };
 
 }  // namespace zeroing
diff --git a/frc971/zeroing/absolute_encoder_test.cc b/frc971/zeroing/absolute_encoder_test.cc
index 38ce069..ce485eb 100644
--- a/frc971/zeroing/absolute_encoder_test.cc
+++ b/frc971/zeroing/absolute_encoder_test.cc
@@ -1,8 +1,8 @@
 #include "frc971/zeroing/absolute_encoder.h"
 
-#include "gtest/gtest.h"
-
 #include "frc971/zeroing/zeroing_test.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
 
 namespace frc971 {
 namespace zeroing {
@@ -15,7 +15,7 @@
   void MoveTo(PositionSensorSimulator *simulator,
               AbsoluteEncoderZeroingEstimator *estimator, double new_position) {
     simulator->MoveTo(new_position);
-    FBB fbb;
+    flatbuffers::FlatBufferBuilder fbb;
     estimator->UpdateEstimate(
         *simulator->FillSensorValues<AbsolutePosition>(&fbb));
   }
@@ -71,7 +71,7 @@
   AbsoluteEncoderZeroingEstimator estimator(constants);
 
   // We tolerate a couple NANs before we start.
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
   fbb.Finish(CreateAbsolutePosition(
       fbb, 0.0, ::std::numeric_limits<double>::quiet_NaN()));
   const auto sensor_values =
@@ -126,7 +126,7 @@
 
   AbsoluteEncoderZeroingEstimator estimator(constants);
 
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
   fbb.Finish(CreateAbsolutePosition(
       fbb, 0.0, ::std::numeric_limits<double>::quiet_NaN()));
   const auto sensor_values =
@@ -138,6 +138,16 @@
 
   estimator.UpdateEstimate(*sensor_values);
   ASSERT_TRUE(estimator.error());
+
+  flatbuffers::FlatBufferBuilder fbb2;
+  fbb2.Finish(estimator.GetEstimatorState(&fbb2));
+
+  const AbsoluteEncoderEstimatorState *state =
+      flatbuffers::GetRoot<AbsoluteEncoderEstimatorState>(
+          fbb2.GetBufferPointer());
+
+  EXPECT_THAT(*state->errors(),
+              ::testing::ElementsAre(ZeroingError::LOST_ABSOLUTE_ENCODER));
 }
 
 }  // namespace testing
diff --git a/frc971/zeroing/pot_and_absolute_encoder.cc b/frc971/zeroing/pot_and_absolute_encoder.cc
index 200f399..32c4f60 100644
--- a/frc971/zeroing/pot_and_absolute_encoder.cc
+++ b/frc971/zeroing/pot_and_absolute_encoder.cc
@@ -3,9 +3,9 @@
 #include <cmath>
 #include <numeric>
 
-#include "glog/logging.h"
-
+#include "aos/containers/error_list.h"
 #include "frc971/zeroing/wrap.h"
+#include "glog/logging.h"
 
 namespace frc971 {
 namespace zeroing {
@@ -57,11 +57,13 @@
   if (::std::isnan(info.absolute_encoder())) {
     if (zeroed_) {
       VLOG(1) << "NAN on absolute encoder.";
+      errors_.Set(ZeroingError::LOST_ABSOLUTE_ENCODER);
       error_ = true;
     } else {
       ++nan_samples_;
-      VLOG(1) << "NAN on absolute encoder while zeroing" << nan_samples_;
+      VLOG(1) << "NAN on absolute encoder while zeroing " << nan_samples_;
       if (nan_samples_ >= constants_.average_filter_size) {
+        errors_.Set(ZeroingError::LOST_ABSOLUTE_ENCODER);
         error_ = true;
         zeroed_ = true;
       }
@@ -168,6 +170,7 @@
                 << ", current " << offset_ << ", allowable change: "
                 << constants_.allowable_encoder_error *
                        constants_.one_revolution_distance;
+        errors_.Set(ZeroingError::OFFSET_MOVED_TOO_FAR);
         error_ = true;
       }
 
@@ -183,12 +186,16 @@
 flatbuffers::Offset<PotAndAbsoluteEncoderZeroingEstimator::State>
 PotAndAbsoluteEncoderZeroingEstimator::GetEstimatorState(
     flatbuffers::FlatBufferBuilder *fbb) const {
+  flatbuffers::Offset<flatbuffers::Vector<ZeroingError>> errors_offset =
+      errors_.ToFlatbuffer(fbb);
+
   State::Builder builder(*fbb);
   builder.add_error(error_);
   builder.add_zeroed(zeroed_);
   builder.add_position(position_);
   builder.add_pot_position(filtered_position_);
   builder.add_absolute_position(filtered_absolute_encoder_);
+  builder.add_errors(errors_offset);
   return builder.Finish();
 }
 
diff --git a/frc971/zeroing/pot_and_absolute_encoder.h b/frc971/zeroing/pot_and_absolute_encoder.h
index 133eaf5..2ff141f 100644
--- a/frc971/zeroing/pot_and_absolute_encoder.h
+++ b/frc971/zeroing/pot_and_absolute_encoder.h
@@ -3,8 +3,8 @@
 
 #include <vector>
 
+#include "aos/containers/error_list.h"
 #include "flatbuffers/flatbuffers.h"
-
 #include "frc971/zeroing/zeroing.h"
 
 namespace frc971 {
@@ -92,6 +92,9 @@
   double filtered_position_ = 0.0;
   // The filtered position.
   double position_ = 0.0;
+
+  // Marker to track what kind of error has occured.
+  aos::ErrorList<ZeroingError> errors_;
 };
 
 }  // namespace zeroing
diff --git a/frc971/zeroing/pot_and_absolute_encoder_test.cc b/frc971/zeroing/pot_and_absolute_encoder_test.cc
index ba89834..1784fed 100644
--- a/frc971/zeroing/pot_and_absolute_encoder_test.cc
+++ b/frc971/zeroing/pot_and_absolute_encoder_test.cc
@@ -1,8 +1,8 @@
 #include "frc971/zeroing/pot_and_absolute_encoder.h"
 
-#include "gtest/gtest.h"
-
 #include "frc971/zeroing/zeroing_test.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
 
 namespace frc971 {
 namespace zeroing {
@@ -16,7 +16,7 @@
               PotAndAbsoluteEncoderZeroingEstimator *estimator,
               double new_position) {
     simulator->MoveTo(new_position);
-    FBB fbb;
+    flatbuffers::FlatBufferBuilder fbb;
     estimator->UpdateEstimate(
         *simulator->FillSensorValues<PotAndAbsolutePosition>(&fbb));
   }
@@ -70,7 +70,7 @@
   PotAndAbsoluteEncoderZeroingEstimator estimator(constants);
 
   // We tolerate a couple NANs before we start.
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
   fbb.Finish(CreatePotAndAbsolutePosition(
       fbb, 0.0, ::std::numeric_limits<double>::quiet_NaN(), 0.0));
   for (size_t i = 0; i < kSampleSize - 1; ++i) {
@@ -124,7 +124,7 @@
 
   PotAndAbsoluteEncoderZeroingEstimator estimator(constants);
 
-  FBB fbb;
+  flatbuffers::FlatBufferBuilder fbb;
   fbb.Finish(CreatePotAndAbsolutePosition(
       fbb, 0.0, ::std::numeric_limits<double>::quiet_NaN(), 0.0));
   const auto sensor_values =
@@ -136,6 +136,16 @@
 
   estimator.UpdateEstimate(*sensor_values);
   ASSERT_TRUE(estimator.error());
+
+  flatbuffers::FlatBufferBuilder fbb2;
+  fbb2.Finish(estimator.GetEstimatorState(&fbb2));
+
+  const PotAndAbsoluteEncoderEstimatorState *state =
+      flatbuffers::GetRoot<PotAndAbsoluteEncoderEstimatorState>(
+          fbb2.GetBufferPointer());
+
+  EXPECT_THAT(*state->errors(),
+              ::testing::ElementsAre(ZeroingError::LOST_ABSOLUTE_ENCODER));
 }
 
 }  // namespace testing
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 01c9f01..10c6288 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,12 +72,6 @@
       '@angular/forms': 15.1.5
       '@org_frc971/scouting/www/counter_button': link:../counter_button
 
-  scouting/www/import_match_list:
-    specifiers:
-      '@angular/forms': 15.1.5
-    dependencies:
-      '@angular/forms': 15.1.5
-
   scouting/www/match_list:
     specifiers:
       '@angular/forms': 15.1.5
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 940d96e..162f973 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -232,6 +232,14 @@
 	return result.Error
 }
 
+func (database *Database) DeleteFromStats(compLevel_ string, matchNumber_ int32, setNumber_ int32, teamNumber_ string) error {
+	var stats2023 []Stats2023
+	result := database.
+		Where("comp_level = ? AND match_number = ? AND set_number = ? AND team_number = ?", compLevel_, matchNumber_, setNumber_, teamNumber_).
+		Delete(&stats2023)
+	return result.Error
+}
+
 func (database *Database) AddOrUpdateRankings(r Ranking) error {
 	result := database.Clauses(clause.OnConflict{
 		UpdateAll: true,
@@ -297,6 +305,12 @@
 	return stats, result.Error
 }
 
+func (database *Database) ReturnStats2023() ([]Stats2023, error) {
+	var stats2023 []Stats2023
+	result := database.Find(&stats2023)
+	return stats2023, result.Error
+}
+
 func (database *Database) ReturnRankings() ([]Ranking, error) {
 	var rankins []Ranking
 	result := database.Find(&rankins)
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index ec5e776..ec7a1e7 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -6,6 +6,7 @@
 	"os"
 	"os/exec"
 	"reflect"
+	"strconv"
 	"strings"
 	"testing"
 	"time"
@@ -281,6 +282,133 @@
 	}
 }
 
+func TestDeleteFromStats(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	startingStats := []Stats2023{
+		Stats2023{
+			TeamNumber: "1111", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 2, LowCubesAuto: 2,
+			MiddleCubesAuto: 0, HighCubesAuto: 1, CubesDroppedAuto: 1,
+			LowConesAuto: 0, MiddleConesAuto: 2, HighConesAuto: 0,
+			ConesDroppedAuto: 1, LowCubes: 0, MiddleCubes: 1,
+			HighCubes: 2, CubesDropped: 1, LowCones: 1,
+			MiddleCones: 0, HighCones: 1, ConesDropped: 2,
+			AvgCycle: 58, CollectedBy: "unknown",
+		},
+		Stats2023{
+			TeamNumber: "2314", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 3, LowCubesAuto: 1,
+			MiddleCubesAuto: 0, HighCubesAuto: 1, CubesDroppedAuto: 1,
+			LowConesAuto: 0, MiddleConesAuto: 1, HighConesAuto: 0,
+			ConesDroppedAuto: 0, LowCubes: 2, MiddleCubes: 0,
+			HighCubes: 1, CubesDropped: 0, LowCones: 0,
+			MiddleCones: 2, HighCones: 1, ConesDropped: 0,
+			AvgCycle: 34, CollectedBy: "simon",
+		},
+		Stats2023{
+			TeamNumber: "3242", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 1, LowCubesAuto: 0,
+			MiddleCubesAuto: 2, HighCubesAuto: 0, CubesDroppedAuto: 1,
+			LowConesAuto: 1, MiddleConesAuto: 0, HighConesAuto: 0,
+			ConesDroppedAuto: 1, LowCubes: 0, MiddleCubes: 2,
+			HighCubes: 0, CubesDropped: 0, LowCones: 2,
+			MiddleCones: 0, HighCones: 1, ConesDropped: 1,
+			AvgCycle: 50, CollectedBy: "eliza",
+		},
+		Stats2023{
+			TeamNumber: "1742", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 4, LowCubesAuto: 1,
+			MiddleCubesAuto: 1, HighCubesAuto: 0, CubesDroppedAuto: 0,
+			LowConesAuto: 0, MiddleConesAuto: 2, HighConesAuto: 0,
+			ConesDroppedAuto: 1, LowCubes: 0, MiddleCubes: 1,
+			HighCubes: 2, CubesDropped: 1, LowCones: 0,
+			MiddleCones: 2, HighCones: 1, ConesDropped: 1,
+			AvgCycle: 49, CollectedBy: "isaac",
+		},
+		Stats2023{
+			TeamNumber: "2454", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 1, LowCubesAuto: 0,
+			MiddleCubesAuto: 0, HighCubesAuto: 0, CubesDroppedAuto: 0,
+			LowConesAuto: 1, MiddleConesAuto: 1, HighConesAuto: 0,
+			ConesDroppedAuto: 1, LowCubes: 1, MiddleCubes: 2,
+			HighCubes: 0, CubesDropped: 0, LowCones: 1,
+			MiddleCones: 1, HighCones: 1, ConesDropped: 0,
+			AvgCycle: 70, CollectedBy: "sam",
+		},
+	}
+
+	correct := []Stats2023{
+		Stats2023{
+			TeamNumber: "3242", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 1, LowCubesAuto: 0,
+			MiddleCubesAuto: 2, HighCubesAuto: 0, CubesDroppedAuto: 1,
+			LowConesAuto: 1, MiddleConesAuto: 0, HighConesAuto: 0,
+			ConesDroppedAuto: 1, LowCubes: 0, MiddleCubes: 2,
+			HighCubes: 0, CubesDropped: 0, LowCones: 2,
+			MiddleCones: 0, HighCones: 1, ConesDropped: 1,
+			AvgCycle: 50, CollectedBy: "eliza",
+		},
+		Stats2023{
+			TeamNumber: "2454", MatchNumber: 5, SetNumber: 1,
+			CompLevel: "quals", StartingQuadrant: 1, LowCubesAuto: 0,
+			MiddleCubesAuto: 0, HighCubesAuto: 0, CubesDroppedAuto: 0,
+			LowConesAuto: 1, MiddleConesAuto: 1, HighConesAuto: 0,
+			ConesDroppedAuto: 1, LowCubes: 1, MiddleCubes: 2,
+			HighCubes: 0, CubesDropped: 0, LowCones: 1,
+			MiddleCones: 1, HighCones: 1, ConesDropped: 0,
+			AvgCycle: 70, CollectedBy: "sam",
+		},
+	}
+
+	originalMatches := []TeamMatch{
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 1, TeamNumber: 1111},
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 1, TeamNumber: 2314},
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			Alliance: "R", AlliancePosition: 3, TeamNumber: 1742},
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 2, TeamNumber: 2454},
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			Alliance: "B", AlliancePosition: 3, TeamNumber: 3242},
+	}
+
+	// Matches for which we want to delete the stats.
+	matches := []TeamMatch{
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			TeamNumber: 1111},
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			TeamNumber: 2314},
+		TeamMatch{MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
+			TeamNumber: 1742},
+	}
+
+	for _, match := range originalMatches {
+		err := fixture.db.AddToMatch(match)
+		check(t, err, "Failed to add match")
+		fmt.Println("Match has been added : ", match.TeamNumber)
+	}
+
+	for _, stat := range startingStats {
+		err := fixture.db.AddToStats2023(stat)
+		check(t, err, "Failed to add stat")
+	}
+
+	for _, match := range matches {
+		err := fixture.db.DeleteFromStats(match.CompLevel, match.MatchNumber, match.SetNumber, strconv.Itoa(int(match.TeamNumber)))
+		check(t, err, "Failed to delete stat")
+	}
+
+	got, err := fixture.db.ReturnStats2023()
+	check(t, err, "Failed ReturnStats()")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestQueryShiftDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
diff --git a/scouting/deploy/README.md b/scouting/deploy/README.md
index 6d223da..2c6ebdb 100644
--- a/scouting/deploy/README.md
+++ b/scouting/deploy/README.md
@@ -35,3 +35,10 @@
 $ sudo systemctl start scouting.service
 $ sudo systemctl restart scouting.service
 ```
+
+Incompatible database changes
+--------------------------------------------------------------------------------
+When deploying a new scouting application that has incompatible changes, you
+may want to clear the existing database. This can be done by also specifying
+the `--clear-db` option when deploying. This option will cause all tables to be
+dropped before the scouting app is deployed.
diff --git a/scouting/deploy/deploy.py b/scouting/deploy/deploy.py
index 5967956..f947398 100644
--- a/scouting/deploy/deploy.py
+++ b/scouting/deploy/deploy.py
@@ -19,9 +19,40 @@
         default="scouting.frc971.org",
         help="The SSH host to install the scouting web server to.",
     )
+    parser.add_argument(
+        "--clear-db",
+        action="store_true",
+        help=("If set, will stop the existing scouting server and clear the "
+              "database before deploying the new one."),
+    )
     args = parser.parse_args(argv[1:])
     deb = Path(args.deb)
 
+    if args.clear_db:
+        print("Stopping the scouting app.")
+        subprocess.run(
+            f"ssh -tt {args.host} sudo systemctl stop scouting.service",
+            shell=True,
+            # In case the scouting app isn't installed, ignore the error here.
+            check=False,
+            stdin=sys.stdin)
+        print("Clearing the database.")
+        subprocess.run(
+            " ".join([
+                f"ssh -tt {args.host}",
+                "\"sudo -u postgres psql",
+                # Drop all tables in the same schema.
+                "-c 'drop schema public cascade;'",
+                # Create an empty schema for the scouting app to use.
+                "-c 'create schema public;'",
+                # List all tables as a sanity check.
+                "-c '\dt'",
+                "postgres\"",
+            ]),
+            shell=True,
+            check=True,
+            stdin=sys.stdin)
+
     # Copy the .deb to the scouting server, install it, and delete it again.
     subprocess.run(["rsync", "-L", args.deb, f"{args.host}:/tmp/{deb.name}"],
                    check=True,
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index a2dfa83..105cf43 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -62,17 +62,6 @@
   cy.visit('/');
   disableAlerts();
   cy.title().should('eq', 'FRC971 Scouting Application');
-
-  // Import the match list before running any tests. Ideally this should be
-  // run in beforeEach(), but it's not worth doing that at this time. Our
-  // tests are basic enough not to require this.
-  switchToTab('Import Match List');
-  headerShouldBe('Import Match List');
-  setInputTo('#year', '2016');
-  setInputTo('#event_code', 'nytr');
-  clickButton('Import');
-
-  cy.get('.progress_message').contains('Successfully imported match list.');
 });
 
 beforeEach(() => {
diff --git a/scouting/scraping/background/BUILD b/scouting/scraping/background/BUILD
new file mode 100644
index 0000000..9aa92c9
--- /dev/null
+++ b/scouting/scraping/background/BUILD
@@ -0,0 +1,9 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "background",
+    srcs = ["background.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/scraping/background",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:public"],
+)
diff --git a/scouting/scraping/background/background.go b/scouting/scraping/background/background.go
new file mode 100644
index 0000000..5af8c3e
--- /dev/null
+++ b/scouting/scraping/background/background.go
@@ -0,0 +1,52 @@
+package background
+
+import (
+	"time"
+)
+
+// A helper to run a function in the background every ~10 minutes. Technically
+// can be used for a lot of different things, but is primarily geared towards
+// scraping thebluealliance.com.
+type BackgroundScraper struct {
+	doneChan     chan<- bool
+	checkStopped chan<- bool
+}
+
+func (scraper *BackgroundScraper) Start(scrape func()) {
+	scraper.doneChan = make(chan bool, 1)
+	scraper.checkStopped = make(chan bool, 1)
+
+	go func() {
+		// Setting start time to 11 minutes prior so getRankings called instantly when Start() called
+		startTime := time.Now().Add(-11 * time.Minute)
+		for {
+			curTime := time.Now()
+			diff := curTime.Sub(startTime)
+
+			if diff.Minutes() > 10 {
+				scrape()
+				startTime = curTime
+			}
+
+			if len(scraper.doneChan) != 0 {
+				break
+			}
+
+			time.Sleep(time.Second)
+		}
+
+		scraper.checkStopped <- true
+	}()
+}
+
+func (scraper *BackgroundScraper) Stop() {
+	scraper.doneChan <- true
+
+	for {
+		if len(scraper.checkStopped) != 0 {
+			close(scraper.doneChan)
+			close(scraper.checkStopped)
+			break
+		}
+	}
+}
diff --git a/scouting/scraping/scrape.go b/scouting/scraping/scrape.go
index 625157a..b905465 100644
--- a/scouting/scraping/scrape.go
+++ b/scouting/scraping/scrape.go
@@ -89,36 +89,17 @@
 	return bodyBytes, nil
 }
 
-// Return all matches in event according to TBA
-func AllMatches(year int32, eventCode, configPath string) ([]Match, error) {
-	bodyBytes, err := getJson(year, eventCode, configPath, "matches")
-
+func GetAllData[T interface{}](year int32, eventCode, configPath string, category string) (T, error) {
+	var result T
+	bodyBytes, err := getJson(year, eventCode, configPath, category)
 	if err != nil {
-		return nil, err
+		return result, err
 	}
 
-	var matches []Match
-	// Unmarshal json into go usable format.
-	if err := json.Unmarshal([]byte(bodyBytes), &matches); err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to parse JSON received from TBA: ", err))
+	// Unmarshal the JSON data into the in-memory format.
+	if err = json.Unmarshal([]byte(bodyBytes), &result); err != nil {
+		return result, errors.New(fmt.Sprint("Failed to parse ", category, " JSON received from TBA: ", err))
 	}
 
-	return matches, nil
-}
-
-// Return event rankings according to TBA
-func AllRankings(year int32, eventCode, configPath string) (EventRanking, error) {
-	bodyBytes, err := getJson(year, eventCode, configPath, "rankings")
-
-	if err != nil {
-		return EventRanking{}, err
-	}
-
-	var rankings EventRanking
-	// Unmarshal json into go usable format.
-	if err := json.Unmarshal([]byte(bodyBytes), &rankings); err != nil {
-		return EventRanking{}, errors.New(fmt.Sprint("Failed to parse JSON received from TBA: ", err))
-	}
-
-	return rankings, nil
+	return result, nil
 }
diff --git a/scouting/scraping/scraping_demo.go b/scouting/scraping/scraping_demo.go
index 69cdbff..0c58612 100644
--- a/scouting/scraping/scraping_demo.go
+++ b/scouting/scraping/scraping_demo.go
@@ -11,6 +11,25 @@
 	"github.com/frc971/971-Robot-Code/scouting/scraping"
 )
 
+func dumpData[T interface{}](jsonPtr *bool, category string) {
+	// Get all the data.
+	data, err := scraping.GetAllData[T](2016, "nytr", "", category)
+	if err != nil {
+		log.Fatal("Failed to scrape ", category, " data: ", err)
+	}
+
+	// Dump the data.
+	if *jsonPtr {
+		jsonData, err := json.MarshalIndent(data, "", "  ")
+		if err != nil {
+			log.Fatal("Failed to turn ranking list into JSON: ", err)
+		}
+		fmt.Println(string(jsonData))
+	} else {
+		spew.Dump(data)
+	}
+}
+
 func main() {
 	jsonPtr := flag.Bool("json", false, "If set, dump as JSON, rather than Go debug output.")
 	demoCategory := flag.String("category", "matches", "Decide whether to demo matches or rankings.")
@@ -18,38 +37,8 @@
 	flag.Parse()
 
 	if *demoCategory == "rankings" {
-		// Get all the rankings.
-		rankings, err := scraping.AllRankings(2016, "nytr", "")
-		if err != nil {
-			log.Fatal("Failed to scrape ranking list: ", err)
-		}
-
-		// Dump the rankings.
-		if *jsonPtr {
-			jsonData, err := json.MarshalIndent(rankings, "", "  ")
-			if err != nil {
-				log.Fatal("Failed to turn ranking list into JSON: ", err)
-			}
-			fmt.Println(string(jsonData))
-		} else {
-			spew.Dump(rankings)
-		}
+		dumpData[scraping.EventRanking](jsonPtr, "rankings")
 	} else if *demoCategory == "matches" {
-		// Get all the matches.
-		matches, err := scraping.AllMatches(2016, "nytr", "")
-		if err != nil {
-			log.Fatal("Failed to scrape match list: ", err)
-		}
-
-		// Dump the matches.
-		if *jsonPtr {
-			jsonData, err := json.MarshalIndent(matches, "", "  ")
-			if err != nil {
-				log.Fatal("Failed to turn match list into JSON: ", err)
-			}
-			fmt.Println(string(jsonData))
-		} else {
-			spew.Dump(matches)
-		}
+		dumpData[[]scraping.Match](jsonPtr, "matches")
 	}
 }
diff --git a/scouting/testing/scouting_test_servers.py b/scouting/testing/scouting_test_servers.py
index d1e4e32..40412c0 100644
--- a/scouting/testing/scouting_test_servers.py
+++ b/scouting/testing/scouting_test_servers.py
@@ -47,14 +47,15 @@
     return config
 
 
-def create_tba_config(tmpdir: Path) -> Path:
-    # Configure the scouting webserver to scrape data from our fake TBA
-    # server.
+def create_tba_config(tmpdir: Path, year: int, event_code: str) -> 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",
+            "year": year,
+            "event_code": event_code,
         }))
     return config
 
@@ -72,7 +73,11 @@
 class Runner:
     """Helps manage the services we need for testing the scouting app."""
 
-    def start(self, port: int, notify_fd: int = 0):
+    def start(self,
+              port: int,
+              notify_fd: int = 0,
+              year: int = 2016,
+              event_code: str = "nytr"):
         """Starts the services needed for testing the scouting app.
 
         if notify_fd is set to a non-zero value, the string "READY" is written
@@ -83,7 +88,9 @@
         self.tmpdir.mkdir(exist_ok=True)
 
         db_config = create_db_config(self.tmpdir)
-        tba_config = create_tba_config(self.tmpdir)
+        tba_config = create_tba_config(self.tmpdir,
+                                       year=year,
+                                       event_code=event_code)
 
         # The database needs to be running and addressable before the scouting
         # webserver can start.
@@ -102,8 +109,7 @@
         ])
 
         # 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")
+        set_up_tba_api_dir(self.tmpdir, year, event_code)
         self.fake_tba_api = subprocess.Popen(
             ["python3", "-m", "http.server", "7000"],
             cwd=self.tmpdir,
diff --git a/scouting/webserver/BUILD b/scouting/webserver/BUILD
index 3df423e..934aff1 100644
--- a/scouting/webserver/BUILD
+++ b/scouting/webserver/BUILD
@@ -8,7 +8,8 @@
     visibility = ["//visibility:private"],
     deps = [
         "//scouting/db",
-        "//scouting/scraping",
+        "//scouting/scraping/background",
+        "//scouting/webserver/match_list",
         "//scouting/webserver/rankings",
         "//scouting/webserver/requests",
         "//scouting/webserver/server",
diff --git a/scouting/webserver/main.go b/scouting/webserver/main.go
index d2fbdfe..1811b7f 100644
--- a/scouting/webserver/main.go
+++ b/scouting/webserver/main.go
@@ -14,7 +14,8 @@
 	"time"
 
 	"github.com/frc971/971-Robot-Code/scouting/db"
-	"github.com/frc971/971-Robot-Code/scouting/scraping"
+	"github.com/frc971/971-Robot-Code/scouting/scraping/background"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/match_list"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/rankings"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
@@ -119,21 +120,24 @@
 	}
 	defer database.Delete()
 
-	scrapeMatchList := func(year int32, eventCode string) ([]scraping.Match, error) {
-		if *blueAllianceConfigPtr == "" {
-			return nil, errors.New("Cannot scrape TBA's match list without a config file.")
-		}
-		return scraping.AllMatches(year, eventCode, *blueAllianceConfigPtr)
-	}
-
 	scoutingServer := server.NewScoutingServer()
 	static.ServePages(scoutingServer, *dirPtr)
-	requests.HandleRequests(database, scrapeMatchList, scoutingServer)
+	requests.HandleRequests(database, scoutingServer)
 	scoutingServer.Start(*portPtr)
 	fmt.Println("Serving", *dirPtr, "on port", *portPtr)
 
-	scraper := rankings.RankingScraper{}
-	scraper.Start(database, 0, "", *blueAllianceConfigPtr)
+	// Since Go doesn't support default arguments, we use 0 and "" to
+	// indicate that we want to source the values from the config.
+
+	matchListScraper := background.BackgroundScraper{}
+	matchListScraper.Start(func() {
+		match_list.GetMatchList(database, 0, "", *blueAllianceConfigPtr)
+	})
+
+	rankingsScraper := background.BackgroundScraper{}
+	rankingsScraper.Start(func() {
+		rankings.GetRankings(database, 0, "", *blueAllianceConfigPtr)
+	})
 
 	// Block until the user hits Ctrl-C.
 	sigint := make(chan os.Signal, 1)
@@ -144,6 +148,7 @@
 
 	fmt.Println("Shutting down.")
 	scoutingServer.Stop()
-	scraper.Stop()
+	rankingsScraper.Stop()
+	matchListScraper.Stop()
 	fmt.Println("Successfully shut down.")
 }
diff --git a/scouting/webserver/match_list/BUILD b/scouting/webserver/match_list/BUILD
new file mode 100644
index 0000000..e32b93c
--- /dev/null
+++ b/scouting/webserver/match_list/BUILD
@@ -0,0 +1,12 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "match_list",
+    srcs = ["match_list.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/match_list",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//scouting/db",
+        "//scouting/scraping",
+    ],
+)
diff --git a/scouting/webserver/match_list/match_list.go b/scouting/webserver/match_list/match_list.go
new file mode 100644
index 0000000..9029438
--- /dev/null
+++ b/scouting/webserver/match_list/match_list.go
@@ -0,0 +1,138 @@
+package match_list
+
+import (
+	"errors"
+	"fmt"
+	"github.com/frc971/971-Robot-Code/scouting/db"
+	"github.com/frc971/971-Robot-Code/scouting/scraping"
+	"log"
+	"strconv"
+	"strings"
+)
+
+type Database interface {
+	AddToMatch(db.TeamMatch) error
+}
+
+func parseTeamKey(teamKey string) (int, error) {
+	// TBA prefixes teams with "frc". Not sure why. Get rid of that.
+	teamKey = strings.TrimPrefix(teamKey, "frc")
+	magnitude := 0
+	if strings.HasSuffix(teamKey, "A") {
+		magnitude = 0
+		teamKey = strings.TrimSuffix(teamKey, "A")
+	} else if strings.HasSuffix(teamKey, "B") {
+		magnitude = 9
+		teamKey = strings.TrimSuffix(teamKey, "B")
+	} else if strings.HasSuffix(teamKey, "C") {
+		magnitude = 8
+		teamKey = strings.TrimSuffix(teamKey, "C")
+	} else if strings.HasSuffix(teamKey, "D") {
+		magnitude = 7
+		teamKey = strings.TrimSuffix(teamKey, "D")
+	} else if strings.HasSuffix(teamKey, "E") {
+		magnitude = 6
+		teamKey = strings.TrimSuffix(teamKey, "E")
+	} else if strings.HasSuffix(teamKey, "F") {
+		magnitude = 5
+		teamKey = strings.TrimSuffix(teamKey, "F")
+	}
+
+	if magnitude != 0 {
+		teamKey = strconv.Itoa(magnitude) + teamKey
+	}
+
+	result, err := strconv.Atoi(teamKey)
+	return result, err
+}
+
+// Parses the alliance data from the specified match and returns the three red
+// teams and the three blue teams.
+func parseTeamKeys(match *scraping.Match) ([3]int32, [3]int32, error) {
+	redKeys := match.Alliances.Red.TeamKeys
+	blueKeys := match.Alliances.Blue.TeamKeys
+
+	if len(redKeys) != 3 || len(blueKeys) != 3 {
+		return [3]int32{}, [3]int32{}, errors.New(fmt.Sprintf(
+			"Found %d red teams and %d blue teams.", len(redKeys), len(blueKeys)))
+	}
+
+	var red [3]int32
+	for i, key := range redKeys {
+		team, err := parseTeamKey(key)
+		if err != nil {
+			return [3]int32{}, [3]int32{}, errors.New(fmt.Sprintf(
+				"Failed to parse red %d team '%s' as integer: %v", i+1, key, err))
+		}
+		red[i] = int32(team)
+	}
+	var blue [3]int32
+	for i, key := range blueKeys {
+		team, err := parseTeamKey(key)
+		if err != nil {
+			return [3]int32{}, [3]int32{}, errors.New(fmt.Sprintf(
+				"Failed to parse blue %d team '%s' as integer: %v", i+1, key, err))
+		}
+		blue[i] = int32(team)
+	}
+	return red, blue, nil
+}
+
+func GetMatchList(database Database, year int32, eventCode string, blueAllianceConfig string) {
+	matches, err := scraping.GetAllData[[]scraping.Match](year, eventCode, blueAllianceConfig, "matches")
+	if err != nil {
+		log.Println("Failed to scrape match list: ", err)
+		return
+	}
+
+	for _, match := range matches {
+		// Make sure the data is valid.
+		red, blue, err := parseTeamKeys(&match)
+		if err != nil {
+			log.Println("TheBlueAlliance data for match %d is malformed: %v", match.MatchNumber, err)
+			return
+		}
+
+		team_matches := []db.TeamMatch{
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "R", AlliancePosition: 1, TeamNumber: red[0],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "R", AlliancePosition: 2, TeamNumber: red[1],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "R", AlliancePosition: 3, TeamNumber: red[2],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "B", AlliancePosition: 1, TeamNumber: blue[0],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "B", AlliancePosition: 2, TeamNumber: blue[1],
+			},
+			{
+				MatchNumber: int32(match.MatchNumber),
+				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
+				Alliance: "B", AlliancePosition: 3, TeamNumber: blue[2],
+			},
+		}
+
+		for _, match := range team_matches {
+			// Iterate through matches to check they can be added to database.
+			err = database.AddToMatch(match)
+			if err != nil {
+				log.Println("Failed to add team %d from match %d to the database: %v", match.TeamNumber, match.MatchNumber, err)
+				return
+			}
+		}
+	}
+}
diff --git a/scouting/webserver/rankings/BUILD b/scouting/webserver/rankings/BUILD
index c74f88f..4696d26 100644
--- a/scouting/webserver/rankings/BUILD
+++ b/scouting/webserver/rankings/BUILD
@@ -21,6 +21,7 @@
     embed = [":rankings"],
     deps = [
         "//scouting/db",
+        "//scouting/scraping/background",
         "//scouting/webserver/server",
     ],
 )
diff --git a/scouting/webserver/rankings/rankings.go b/scouting/webserver/rankings/rankings.go
index 6e63c0a..0d20b54 100644
--- a/scouting/webserver/rankings/rankings.go
+++ b/scouting/webserver/rankings/rankings.go
@@ -6,14 +6,8 @@
 	"log"
 	"strconv"
 	"strings"
-	"time"
 )
 
-type RankingScraper struct {
-	doneChan     chan<- bool
-	checkStopped chan<- bool
-}
-
 type Database interface {
 	AddOrUpdateRankings(db.Ranking) error
 }
@@ -24,8 +18,8 @@
 	return strconv.Atoi(teamKey)
 }
 
-func getRankings(database Database, year int32, eventCode string, blueAllianceConfig string) {
-	rankings, err := scraping.AllRankings(year, eventCode, blueAllianceConfig)
+func GetRankings(database Database, year int32, eventCode string, blueAllianceConfig string) {
+	rankings, err := scraping.GetAllData[scraping.EventRanking](year, eventCode, blueAllianceConfig, "rankings")
 	if err != nil {
 		log.Println("Failed to scrape ranking list: ", err)
 		return
@@ -51,42 +45,3 @@
 		}
 	}
 }
-
-func (scraper *RankingScraper) Start(database Database, year int32, eventCode string, blueAllianceConfig string) {
-	scraper.doneChan = make(chan bool, 1)
-	scraper.checkStopped = make(chan bool, 1)
-
-	go func(database Database, year int32, eventCode string) {
-		// Setting start time to 11 minutes prior so getRankings called instantly when Start() called
-		startTime := time.Now().Add(-11 * time.Minute)
-		for {
-			curTime := time.Now()
-			diff := curTime.Sub(startTime)
-
-			if diff.Minutes() > 10 {
-				getRankings(database, year, eventCode, blueAllianceConfig)
-				startTime = curTime
-			}
-
-			if len(scraper.doneChan) != 0 {
-				break
-			}
-
-			time.Sleep(time.Second)
-		}
-
-		scraper.checkStopped <- true
-	}(database, year, eventCode)
-}
-
-func (scraper *RankingScraper) Stop() {
-	scraper.doneChan <- true
-
-	for {
-		if len(scraper.checkStopped) != 0 {
-			close(scraper.doneChan)
-			close(scraper.checkStopped)
-			break
-		}
-	}
-}
diff --git a/scouting/webserver/rankings/rankings_test.go b/scouting/webserver/rankings/rankings_test.go
index aa23c76..6f8af3b 100644
--- a/scouting/webserver/rankings/rankings_test.go
+++ b/scouting/webserver/rankings/rankings_test.go
@@ -2,9 +2,11 @@
 
 import (
 	"github.com/frc971/971-Robot-Code/scouting/db"
+	"github.com/frc971/971-Robot-Code/scouting/scraping/background"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 	"net/http"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 )
@@ -18,8 +20,13 @@
 	return nil
 }
 
-func ServeRankings(h http.Handler) http.Handler {
+func ServeRankings(t *testing.T, h http.Handler) http.Handler {
 	fn := func(w http.ResponseWriter, r *http.Request) {
+		// Make sure that the rankings are requested properly.
+		if !strings.HasSuffix(r.URL.Path, "/2016nytr/rankings") {
+			t.Error("Got unexpected URL: ", r.URL.Path)
+		}
+
 		r.URL.Path = "scraping/test_data/2016_nytr_rankings.json"
 
 		h.ServeHTTP(w, r)
@@ -30,13 +37,15 @@
 
 func TestGetRankings(t *testing.T) {
 	database := MockDatabase{}
-	scraper := RankingScraper{}
+	scraper := background.BackgroundScraper{}
 	tbaServer := server.NewScoutingServer()
-	tbaServer.Handle("/", ServeRankings(http.FileServer(http.Dir("../../"))))
+	tbaServer.Handle("/", ServeRankings(t, http.FileServer(http.Dir("../../"))))
 	tbaServer.Start(8000)
 	defer tbaServer.Stop()
 
-	scraper.Start(&database, 0, "", "scouting_test_config.json")
+	scraper.Start(func() {
+		GetRankings(&database, 0, "", "scouting_test_config.json")
+	})
 	defer scraper.Stop()
 
 	for {
diff --git a/scouting/webserver/rankings/scouting_test_config.json b/scouting/webserver/rankings/scouting_test_config.json
index 40a7747..6bc4fec 100644
--- a/scouting/webserver/rankings/scouting_test_config.json
+++ b/scouting/webserver/rankings/scouting_test_config.json
@@ -1,6 +1,6 @@
 {
      "api_key": "dummy_key_that_is_not_actually_used_in_this_test",
      "base_url": "http://localhost:8000",
-     "year": 2022,
-     "event_code": "CMPTX"
-}
\ No newline at end of file
+     "year": 2016,
+     "event_code": "nytr"
+}
diff --git a/scouting/webserver/requests/BUILD b/scouting/webserver/requests/BUILD
index 9eb207d..e0d3e87 100644
--- a/scouting/webserver/requests/BUILD
+++ b/scouting/webserver/requests/BUILD
@@ -8,10 +8,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//scouting/db",
-        "//scouting/scraping",
         "//scouting/webserver/requests/messages:error_response_go_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_go_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_go_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_go_fbs",
@@ -46,11 +43,8 @@
     target_compatible_with = ["@platforms//cpu:x86_64"],
     deps = [
         "//scouting/db",
-        "//scouting/scraping",
         "//scouting/webserver/requests/debug",
         "//scouting/webserver/requests/messages:error_response_go_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_go_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_go_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_go_fbs",
diff --git a/scouting/webserver/requests/debug/BUILD b/scouting/webserver/requests/debug/BUILD
index 650ba82..1473b3c 100644
--- a/scouting/webserver/requests/debug/BUILD
+++ b/scouting/webserver/requests/debug/BUILD
@@ -8,7 +8,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//scouting/webserver/requests/messages:error_response_go_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_go_fbs",
         "//scouting/webserver/requests/messages:request_all_notes_response_go_fbs",
diff --git a/scouting/webserver/requests/debug/cli/cli_test.py b/scouting/webserver/requests/debug/cli/cli_test.py
index 6d33082..4e47b2d 100644
--- a/scouting/webserver/requests/debug/cli/cli_test.py
+++ b/scouting/webserver/requests/debug/cli/cli_test.py
@@ -13,8 +13,19 @@
 from typing import Any, Dict, List
 import unittest
 
+from rules_python.python.runfiles import runfiles
+
 import scouting.testing.scouting_test_servers
 
+RUNFILES = runfiles.Create()
+
+# This regex finds the number of matches that the web server has imported. This
+# is intended to parse the output of the debug cli's `-requestAllMatches`
+# option.
+MATCH_LIST_LENGTH_EXTRACTION_RE = re.compile(
+    r"MatchList: \(\[\]\*request_all_matches_response.MatchT\) \(len=(\d+) cap=.*\) \{"
+)
+
 
 def write_json_request(content: Dict[str, Any]):
     """Writes a JSON file with the specified dict content."""
@@ -37,29 +48,52 @@
     )
 
 
+def find_num_matches_for_event(year, event_code):
+    with open(
+            RUNFILES.Rlocation(
+                f"org_frc971/scouting/scraping/test_data/{year}_{event_code}.json"
+            ), "r") as file:
+        raw_match_list = json.load(file)
+    return len(raw_match_list)
+
+
 class TestDebugCli(unittest.TestCase):
 
     def setUp(self):
         self.servers = scouting.testing.scouting_test_servers.Runner()
-        self.servers.start(8080)
 
     def tearDown(self):
         self.servers.stop()
 
-    def refresh_match_list(self, year=2016, event_code="nytr"):
-        """Triggers the webserver to fetch the match list."""
-        json_path = write_json_request({
-            "year": year,
-            "event_code": event_code,
-        })
-        exit_code, stdout, stderr = run_debug_cli(
-            ["-refreshMatchList", json_path])
-        self.assertEqual(exit_code, 0, f"{year}{event_code}: {stderr}")
-        self.assertIn(
-            "(refresh_match_list_response.RefreshMatchListResponseT)", stdout)
+    def start_servers(self, year=2016, event_code="nytr"):
+        self.servers.start(8080, year=year, event_code=event_code)
+
+        expected_num_matches = find_num_matches_for_event(year, event_code)
+        json_path = write_json_request({})
+
+        # Wait until the match list is imported. This is done automatically
+        # when the web server starts up.
+        sys.stderr.write("Waiting for match list to be imported.\n")
+        while True:
+            exit_code, stdout, stderr = run_debug_cli(
+                ["-requestAllMatches", json_path])
+            self.assertEqual(exit_code, 0, stderr)
+
+            match = MATCH_LIST_LENGTH_EXTRACTION_RE.search(stdout)
+            if match:
+                num_matches_imported = int(match.group(1))
+
+                if num_matches_imported == expected_num_matches:
+                    break
+                else:
+                    sys.stderr.write(
+                        f"Waiting until {expected_num_matches} are imported. "
+                        f"Currently at {num_matches_imported}.\n")
+
+            time.sleep(0.25)
 
     def test_submit_and_request_data_scouting(self):
-        self.refresh_match_list(year=2020, event_code="fake")
+        self.start_servers(year=2020, event_code="fake")
 
         # First submit some data to be added to the database.
         json_path = write_json_request({
@@ -142,7 +176,7 @@
             }"""), stdout)
 
     def test_submit_and_request_notes(self):
-        self.refresh_match_list(year=2020, event_code="fake")
+        self.start_servers(year=2020, event_code="fake")
 
         # First submit some data to be added to the database.
         json_path = write_json_request({
@@ -179,7 +213,7 @@
             }"""), stdout)
 
     def test_submit_and_request_driver_ranking(self):
-        self.refresh_match_list(year=2020, event_code="fake")
+        self.start_servers(year=2020, event_code="fake")
 
         # First submit some data to be added to the database.
         json_path = write_json_request({
@@ -209,7 +243,7 @@
             }"""), stdout)
 
     def test_request_all_matches(self):
-        self.refresh_match_list()
+        self.start_servers()
 
         # RequestAllMatches has no fields.
         json_path = write_json_request({})
@@ -222,24 +256,6 @@
             stdout)
         self.assertEqual(stdout.count("MatchNumber:"), 90)
 
-    def test_request_all_matches(self):
-        """Makes sure that we can import the match list multiple times without problems."""
-        request_all_matches_outputs = []
-        for _ in range(2):
-            self.refresh_match_list()
-
-            # RequestAllMatches has no fields.
-            json_path = write_json_request({})
-            exit_code, stdout, stderr = run_debug_cli(
-                ["-requestAllMatches", json_path])
-
-            self.assertEqual(exit_code, 0, stderr)
-            request_all_matches_outputs.append(stdout)
-
-        self.maxDiff = None
-        self.assertEqual(request_all_matches_outputs[0],
-                         request_all_matches_outputs[1])
-
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/scouting/webserver/requests/debug/cli/main.go b/scouting/webserver/requests/debug/cli/main.go
index 066dec5..9279dba 100644
--- a/scouting/webserver/requests/debug/cli/main.go
+++ b/scouting/webserver/requests/debug/cli/main.go
@@ -97,8 +97,6 @@
 		"If specified, parse the file as a requestAllDriverRankings JSON request.")
 	requestAllNotesPtr := flag.String("requestAllNotes", "",
 		"If specified, parse the file as a requestAllNotes JSON request.")
-	refreshMatchListPtr := flag.String("refreshMatchList", "",
-		"If specified, parse the file as a RefreshMatchList JSON request.")
 	flag.Parse()
 
 	spew.Config.Indent = *indentPtr
@@ -155,11 +153,4 @@
 		*requestAllNotesPtr,
 		*addressPtr,
 		debug.RequestAllNotes)
-
-	maybePerformRequest(
-		"RefreshMatchList",
-		"scouting/webserver/requests/messages/refresh_match_list.fbs",
-		*refreshMatchListPtr,
-		*addressPtr,
-		debug.RefreshMatchList)
 }
diff --git a/scouting/webserver/requests/debug/debug.go b/scouting/webserver/requests/debug/debug.go
index 130850c..1f215f7 100644
--- a/scouting/webserver/requests/debug/debug.go
+++ b/scouting/webserver/requests/debug/debug.go
@@ -10,7 +10,6 @@
 	"net/http"
 
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/error_response"
-	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/refresh_match_list_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_driver_rankings_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_matches_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_notes_response"
@@ -130,12 +129,6 @@
 		request_data_scouting_response.GetRootAsRequestDataScoutingResponse)
 }
 
-func RefreshMatchList(server string, requestBytes []byte) (*refresh_match_list_response.RefreshMatchListResponseT, error) {
-	return sendMessage[refresh_match_list_response.RefreshMatchListResponseT](
-		server+"/requests/refresh_match_list", requestBytes,
-		refresh_match_list_response.GetRootAsRefreshMatchListResponse)
-}
-
 func SubmitNotes(server string, requestBytes []byte) (*submit_notes_response.SubmitNotesResponseT, error) {
 	return sendMessage[submit_notes_response.SubmitNotesResponseT](
 		server+"/requests/submit/submit_notes", requestBytes,
diff --git a/scouting/webserver/requests/messages/BUILD b/scouting/webserver/requests/messages/BUILD
index 2e118d5..124613c 100644
--- a/scouting/webserver/requests/messages/BUILD
+++ b/scouting/webserver/requests/messages/BUILD
@@ -13,8 +13,6 @@
     "request_all_notes_response",
     "request_data_scouting",
     "request_data_scouting_response",
-    "refresh_match_list",
-    "refresh_match_list_response",
     "submit_notes",
     "submit_notes_response",
     "request_notes_for_team",
diff --git a/scouting/webserver/requests/messages/refresh_match_list.fbs b/scouting/webserver/requests/messages/refresh_match_list.fbs
deleted file mode 100644
index c4384c7..0000000
--- a/scouting/webserver/requests/messages/refresh_match_list.fbs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace scouting.webserver.requests;
-
-table RefreshMatchList {
-    year: int (id: 0);
-    event_code: string (id: 1);
-}
-
-root_type RefreshMatchList;
diff --git a/scouting/webserver/requests/messages/refresh_match_list_response.fbs b/scouting/webserver/requests/messages/refresh_match_list_response.fbs
deleted file mode 100644
index ba80272..0000000
--- a/scouting/webserver/requests/messages/refresh_match_list_response.fbs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace scouting.webserver.requests;
-
-table RefreshMatchListResponse {
-}
-
-root_type RefreshMatchListResponse;
diff --git a/scouting/webserver/requests/messages/submit_actions.fbs b/scouting/webserver/requests/messages/submit_actions.fbs
index 863e2fc..dfb980f 100644
--- a/scouting/webserver/requests/messages/submit_actions.fbs
+++ b/scouting/webserver/requests/messages/submit_actions.fbs
@@ -15,6 +15,11 @@
     kHigh
 }
 
+table AutoBalanceAction {
+    docked:bool (id:0);
+    engaged:bool (id:1);
+}
+
 table PickupObjectAction {
     object_type:ObjectType (id:0);
     auto:bool (id:1);
@@ -36,6 +41,7 @@
 }
 
 union ActionType {
+    AutoBalanceAction,
     StartMatchAction,
     PickupObjectAction,
     PlaceObjectAction,
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index d56f380..fab725c 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -12,10 +12,7 @@
 	"strings"
 
 	"github.com/frc971/971-Robot-Code/scouting/db"
-	"github.com/frc971/971-Robot-Code/scouting/scraping"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/error_response"
-	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/refresh_match_list"
-	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/refresh_match_list_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_driver_rankings"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_driver_rankings_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_matches"
@@ -52,8 +49,6 @@
 type RequestAllNotesResponseT = request_all_notes_response.RequestAllNotesResponseT
 type RequestDataScouting = request_data_scouting.RequestDataScouting
 type RequestDataScoutingResponseT = request_data_scouting_response.RequestDataScoutingResponseT
-type RefreshMatchList = refresh_match_list.RefreshMatchList
-type RefreshMatchListResponseT = refresh_match_list_response.RefreshMatchListResponseT
 type SubmitNotes = submit_notes.SubmitNotes
 type SubmitNotesResponseT = submit_notes_response.SubmitNotesResponseT
 type RequestNotesForTeam = request_notes_for_team.RequestNotesForTeam
@@ -85,8 +80,6 @@
 	AddDriverRanking(db.DriverRankingData) error
 }
 
-type ScrapeMatchList func(int32, string) ([]scraping.Match, error)
-
 // Handles unknown requests. Just returns a 404.
 func unknown(w http.ResponseWriter, req *http.Request) {
 	w.WriteHeader(http.StatusNotFound)
@@ -417,152 +410,6 @@
 	w.Write(builder.FinishedBytes())
 }
 
-func parseTeamKey(teamKey string) (int, error) {
-	// TBA prefixes teams with "frc". Not sure why. Get rid of that.
-	teamKey = strings.TrimPrefix(teamKey, "frc")
-	magnitude := 0
-	if strings.HasSuffix(teamKey, "A") {
-		magnitude = 0
-		teamKey = strings.TrimSuffix(teamKey, "A")
-	} else if strings.HasSuffix(teamKey, "B") {
-		magnitude = 9
-		teamKey = strings.TrimSuffix(teamKey, "B")
-	} else if strings.HasSuffix(teamKey, "C") {
-		magnitude = 8
-		teamKey = strings.TrimSuffix(teamKey, "C")
-	} else if strings.HasSuffix(teamKey, "D") {
-		magnitude = 7
-		teamKey = strings.TrimSuffix(teamKey, "D")
-	} else if strings.HasSuffix(teamKey, "E") {
-		magnitude = 6
-		teamKey = strings.TrimSuffix(teamKey, "E")
-	} else if strings.HasSuffix(teamKey, "F") {
-		magnitude = 5
-		teamKey = strings.TrimSuffix(teamKey, "F")
-	}
-
-	if magnitude != 0 {
-		teamKey = strconv.Itoa(magnitude) + teamKey
-	}
-
-	result, err := strconv.Atoi(teamKey)
-	return result, err
-}
-
-// Parses the alliance data from the specified match and returns the three red
-// teams and the three blue teams.
-func parseTeamKeys(match *scraping.Match) ([3]int32, [3]int32, error) {
-	redKeys := match.Alliances.Red.TeamKeys
-	blueKeys := match.Alliances.Blue.TeamKeys
-
-	if len(redKeys) != 3 || len(blueKeys) != 3 {
-		return [3]int32{}, [3]int32{}, errors.New(fmt.Sprintf(
-			"Found %d red teams and %d blue teams.", len(redKeys), len(blueKeys)))
-	}
-
-	var red [3]int32
-	for i, key := range redKeys {
-		team, err := parseTeamKey(key)
-		if err != nil {
-			return [3]int32{}, [3]int32{}, errors.New(fmt.Sprintf(
-				"Failed to parse red %d team '%s' as integer: %v", i+1, key, err))
-		}
-		red[i] = int32(team)
-	}
-	var blue [3]int32
-	for i, key := range blueKeys {
-		team, err := parseTeamKey(key)
-		if err != nil {
-			return [3]int32{}, [3]int32{}, errors.New(fmt.Sprintf(
-				"Failed to parse blue %d team '%s' as integer: %v", i+1, key, err))
-		}
-		blue[i] = int32(team)
-	}
-	return red, blue, nil
-}
-
-type refreshMatchListHandler struct {
-	db     Database
-	scrape ScrapeMatchList
-}
-
-func (handler refreshMatchListHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
-	requestBytes, err := io.ReadAll(req.Body)
-	if err != nil {
-		respondWithError(w, http.StatusBadRequest, fmt.Sprint("Failed to read request bytes:", err))
-		return
-	}
-
-	request, success := parseRequest(w, requestBytes, "RefreshMatchList", refresh_match_list.GetRootAsRefreshMatchList)
-	if !success {
-		return
-	}
-
-	matches, err := handler.scrape(request.Year(), string(request.EventCode()))
-	if err != nil {
-		respondWithError(w, http.StatusInternalServerError, fmt.Sprint("Faled to scrape match list: ", err))
-		return
-	}
-
-	for _, match := range matches {
-		// Make sure the data is valid.
-		red, blue, err := parseTeamKeys(&match)
-		if err != nil {
-			respondWithError(w, http.StatusInternalServerError, fmt.Sprintf(
-				"TheBlueAlliance data for match %d is malformed: %v", match.MatchNumber, err))
-			return
-		}
-
-		team_matches := []db.TeamMatch{
-			{
-				MatchNumber: int32(match.MatchNumber),
-				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
-				Alliance: "R", AlliancePosition: 1, TeamNumber: red[0],
-			},
-			{
-				MatchNumber: int32(match.MatchNumber),
-				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
-				Alliance: "R", AlliancePosition: 2, TeamNumber: red[1],
-			},
-			{
-				MatchNumber: int32(match.MatchNumber),
-				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
-				Alliance: "R", AlliancePosition: 3, TeamNumber: red[2],
-			},
-			{
-				MatchNumber: int32(match.MatchNumber),
-				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
-				Alliance: "B", AlliancePosition: 1, TeamNumber: blue[0],
-			},
-			{
-				MatchNumber: int32(match.MatchNumber),
-				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
-				Alliance: "B", AlliancePosition: 2, TeamNumber: blue[1],
-			},
-			{
-				MatchNumber: int32(match.MatchNumber),
-				SetNumber:   int32(match.SetNumber), CompLevel: match.CompLevel,
-				Alliance: "B", AlliancePosition: 3, TeamNumber: blue[2],
-			},
-		}
-
-		for _, match := range team_matches {
-			// Iterate through matches to check they can be added to database.
-			err = handler.db.AddToMatch(match)
-			if err != nil {
-				respondWithError(w, http.StatusInternalServerError, fmt.Sprintf(
-					"Failed to add team %d from match %d to the database: %v", match.TeamNumber, match.MatchNumber, err))
-				return
-			}
-		}
-	}
-
-	var response RefreshMatchListResponseT
-	builder := flatbuffers.NewBuilder(1024)
-	builder.Finish((&response).Pack(builder))
-	w.Write(builder.FinishedBytes())
-}
-
 type submitNoteScoutingHandler struct {
 	db Database
 }
@@ -829,14 +676,13 @@
 	w.Write(builder.FinishedBytes())
 }
 
-func HandleRequests(db Database, scrape ScrapeMatchList, scoutingServer server.ScoutingServer) {
+func HandleRequests(db Database, scoutingServer server.ScoutingServer) {
 	scoutingServer.HandleFunc("/requests", unknown)
 	scoutingServer.Handle("/requests/submit/data_scouting", submitDataScoutingHandler{db})
 	scoutingServer.Handle("/requests/request/all_matches", requestAllMatchesHandler{db})
 	scoutingServer.Handle("/requests/request/all_notes", requestAllNotesHandler{db})
 	scoutingServer.Handle("/requests/request/all_driver_rankings", requestAllDriverRankingsHandler{db})
 	scoutingServer.Handle("/requests/request/data_scouting", requestDataScoutingHandler{db})
-	scoutingServer.Handle("/requests/refresh_match_list", refreshMatchListHandler{db, scrape})
 	scoutingServer.Handle("/requests/submit/submit_notes", submitNoteScoutingHandler{db})
 	scoutingServer.Handle("/requests/request/notes_for_team", requestNotesForTeamHandler{db})
 	scoutingServer.Handle("/requests/submit/shift_schedule", submitShiftScheduleHandler{db})
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index 27a45e6..ee22022 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -8,11 +8,8 @@
 	"testing"
 
 	"github.com/frc971/971-Robot-Code/scouting/db"
-	"github.com/frc971/971-Robot-Code/scouting/scraping"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/debug"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/error_response"
-	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/refresh_match_list"
-	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/refresh_match_list_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_driver_rankings"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_driver_rankings_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_all_matches"
@@ -37,7 +34,7 @@
 func Test404(t *testing.T) {
 	db := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -54,7 +51,7 @@
 func TestSubmitDataScoutingError(t *testing.T) {
 	db := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -82,7 +79,7 @@
 func TestSubmitDataScouting(t *testing.T) {
 	db := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -201,7 +198,7 @@
 		},
 	}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -267,7 +264,7 @@
 		},
 	}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -322,7 +319,7 @@
 func TestSubmitNotes(t *testing.T) {
 	database := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&database, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&database, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -375,7 +372,7 @@
 		}},
 	}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&database, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&database, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -417,7 +414,7 @@
 		},
 	}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -464,7 +461,7 @@
 func TestSubmitShiftSchedule(t *testing.T) {
 	database := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&database, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&database, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -500,98 +497,10 @@
 	}
 }
 
-// Validates that we can download the schedule from The Blue Alliance.
-func TestRefreshMatchList(t *testing.T) {
-	scrapeMockSchedule := func(int32, string) ([]scraping.Match, error) {
-		return []scraping.Match{
-			{
-				CompLevel:   "qual",
-				MatchNumber: 1,
-				SetNumber:   2,
-				Alliances: scraping.Alliances{
-					Red: scraping.Alliance{
-						TeamKeys: []string{
-							"100",
-							"200",
-							"300",
-						},
-					},
-					Blue: scraping.Alliance{
-						TeamKeys: []string{
-							"101",
-							"201",
-							"301",
-						},
-					},
-				},
-				WinningAlliance: "",
-				EventKey:        "",
-				Time:            0,
-				PredictedTime:   0,
-				ActualTime:      0,
-				PostResultTime:  0,
-				ScoreBreakdowns: scraping.ScoreBreakdowns{},
-			},
-		}, nil
-	}
-
-	database := MockDatabase{}
-	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&database, scrapeMockSchedule, scoutingServer)
-	scoutingServer.Start(8080)
-	defer scoutingServer.Stop()
-
-	builder := flatbuffers.NewBuilder(1024)
-	builder.Finish((&refresh_match_list.RefreshMatchListT{}).Pack(builder))
-
-	response, err := debug.RefreshMatchList("http://localhost:8080", builder.FinishedBytes())
-	if err != nil {
-		t.Fatal("Failed to request all matches: ", err)
-	}
-
-	// Validate the response.
-	expected := refresh_match_list_response.RefreshMatchListResponseT{}
-	if !reflect.DeepEqual(expected, *response) {
-		t.Fatal("Expected ", expected, ", but got ", *response)
-	}
-
-	// Make sure that the data made it into the database.
-	expectedMatches := []db.TeamMatch{
-		{
-			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
-			Alliance: "R", AlliancePosition: 1, TeamNumber: 100,
-		},
-		{
-			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
-			Alliance: "R", AlliancePosition: 2, TeamNumber: 200,
-		},
-		{
-			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
-			Alliance: "R", AlliancePosition: 3, TeamNumber: 300,
-		},
-		{
-			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
-			Alliance: "B", AlliancePosition: 1, TeamNumber: 101,
-		},
-		{
-			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
-			Alliance: "B", AlliancePosition: 2, TeamNumber: 201,
-		},
-		{
-			MatchNumber: 1, SetNumber: 2, CompLevel: "qual",
-			Alliance: "B", AlliancePosition: 3, TeamNumber: 301,
-		},
-	}
-
-	if !reflect.DeepEqual(expectedMatches, database.matches) {
-		t.Fatal("Expected ", expectedMatches, ", but got ", database.matches)
-	}
-}
-
 func TestSubmitDriverRanking(t *testing.T) {
 	database := MockDatabase{}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&database, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&database, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -636,7 +545,7 @@
 		},
 	}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -701,7 +610,7 @@
 		},
 	}
 	scoutingServer := server.NewScoutingServer()
-	HandleRequests(&db, scrapeEmtpyMatchList, scoutingServer)
+	HandleRequests(&db, scoutingServer)
 	scoutingServer.Start(8080)
 	defer scoutingServer.Stop()
 
@@ -820,8 +729,3 @@
 func (database *MockDatabase) ReturnAllDriverRankings() ([]db.DriverRankingData, error) {
 	return database.driver_ranking, nil
 }
-
-// Returns an empty match list from the fake The Blue Alliance scraping.
-func scrapeEmtpyMatchList(int32, string) ([]scraping.Match, error) {
-	return nil, nil
-}
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index cbcecc3..93bcbb2 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -17,7 +17,6 @@
         "//:node_modules/@angular/animations",
         "//scouting/www/driver_ranking:_lib",
         "//scouting/www/entry:_lib",
-        "//scouting/www/import_match_list:_lib",
         "//scouting/www/match_list:_lib",
         "//scouting/www/notes:_lib",
         "//scouting/www/shift_schedule:_lib",
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index ead8b37..7b200d0 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -4,7 +4,6 @@
 
 import {App} from './app';
 import {EntryModule} from '../entry';
-import {ImportMatchListModule} from '../import_match_list';
 import {MatchListModule} from '../match_list';
 import {NotesModule} from '../notes';
 import {ShiftScheduleModule} from '../shift_schedule';
@@ -18,7 +17,6 @@
     BrowserAnimationsModule,
     EntryModule,
     NotesModule,
-    ImportMatchListModule,
     MatchListModule,
     ShiftScheduleModule,
     DriverRankingModule,
diff --git a/scouting/www/app/app.ng.html b/scouting/www/app/app.ng.html
index d9dbead..526cd61 100644
--- a/scouting/www/app/app.ng.html
+++ b/scouting/www/app/app.ng.html
@@ -49,15 +49,6 @@
   <li class="nav-item">
     <a
       class="nav-link"
-      [class.active]="tabIs('ImportMatchList')"
-      (click)="switchTabToGuarded('ImportMatchList')"
-    >
-      Import Match List
-    </a>
-  </li>
-  <li class="nav-item">
-    <a
-      class="nav-link"
       [class.active]="tabIs('ShiftSchedule')"
       (click)="switchTabToGuarded('ShiftSchedule')"
     >
@@ -90,9 +81,6 @@
   ></app-entry>
   <frc971-notes *ngSwitchCase="'Notes'"></frc971-notes>
   <app-driver-ranking *ngSwitchCase="'DriverRanking'"></app-driver-ranking>
-  <app-import-match-list
-    *ngSwitchCase="'ImportMatchList'"
-  ></app-import-match-list>
   <shift-schedule *ngSwitchCase="'ShiftSchedule'"></shift-schedule>
   <app-view
     (switchTabsEvent)="switchTabTo($event)"
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index 7e81d84..f7d2770 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -5,12 +5,11 @@
   | 'Notes'
   | 'Entry'
   | 'DriverRanking'
-  | 'ImportMatchList'
   | 'ShiftSchedule'
   | 'View';
 
 // Ignore the guard for tabs that don't require the user to enter any data.
-const unguardedTabs: Tab[] = ['MatchList', 'ImportMatchList'];
+const unguardedTabs: Tab[] = ['MatchList'];
 
 type TeamInMatch = {
   teamNumber: number;
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index a78a00a..b41cb22 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -2,11 +2,6 @@
   padding: 10px;
 }
 
-.buttons {
-  display: flex;
-  justify-content: space-between;
-}
-
 textarea {
   width: 350px;
   height: 180px;
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 9aae0d1..6ecc95d 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -15,6 +15,7 @@
   ScoreLevel,
   SubmitActions,
   StartMatchAction,
+  AutoBalanceAction,
   PickupObjectAction,
   PlaceObjectAction,
   RobotDeathAction,
@@ -53,6 +54,12 @@
       position: number;
     }
   | {
+      type: 'autoBalanceAction';
+      timestamp?: number;
+      docked: boolean;
+      engaged: boolean;
+    }
+  | {
       type: 'pickupObjectAction';
       timestamp?: number;
       objectType: ObjectType;
@@ -201,6 +208,21 @@
           );
           break;
 
+        case 'autoBalanceAction':
+          const autoBalanceActionOffset =
+            AutoBalanceAction.createAutoBalanceAction(
+              builder,
+              action.docked,
+              action.engaged
+            );
+          actionOffset = Action.createAction(
+            builder,
+            action.timestamp || 0,
+            ActionType.AutoBalanceAction,
+            autoBalanceActionOffset
+          );
+          break;
+
         case 'placeObjectAction':
           const placeObjectActionOffset =
             PlaceObjectAction.createPlaceObjectAction(
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index ec95f39..72fc140 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -69,110 +69,163 @@
       </label>
     </div>
     <div class="buttons">
-      <button
-        class="btn btn-primary"
-        [disabled]="!selectedValue"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'startMatchAction', position: selectedValue});"
-      >
-        Start Match
-      </button>
+      <!-- Creates a responsive stack of full-width, "block buttons". -->
+      <div class="d-grid gap-5">
+        <button
+          class="btn btn-primary"
+          [disabled]="!selectedValue"
+          (click)="changeSectionTo('Pickup'); addAction({type: 'startMatchAction', position: selectedValue});"
+        >
+          Start Match
+        </button>
+      </div>
     </div>
   </div>
 
   <div *ngSwitchCase="'Pickup'" id="PickUp" class="container-fluid">
-    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
-    <button
-      class="btn btn-warning"
-      (click)="changeSectionTo('Place'); addAction({type: 'pickupObjectAction', objectType: ObjectType.kCone});"
-    >
-      CONE
-    </button>
-    <button
-      class="btn btn-primary"
-      (click)="changeSectionTo('Place'); addAction({type: 'pickupObjectAction', objectType: ObjectType.kCube});"
-    >
-      CUBE
-    </button>
-    <button
-      *ngIf="autoPhase"
-      class="btn btn-info"
-      (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
-    >
-      Start Teleop
-    </button>
-    <button
-      *ngIf="!autoPhase"
-      class="btn btn-info"
-      (click)="changeSectionTo('Endgame')"
-    >
-      Endgame
-    </button>
+    <div class="d-grid gap-5">
+      <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+      <button
+        class="btn btn-warning"
+        (click)="changeSectionTo('Place'); addAction({type: 'pickupObjectAction', objectType: ObjectType.kCone});"
+      >
+        CONE
+      </button>
+      <button
+        class="btn btn-primary"
+        (click)="changeSectionTo('Place'); addAction({type: 'pickupObjectAction', objectType: ObjectType.kCube});"
+      >
+        CUBE
+      </button>
+      <!-- 'Balancing' during auto. -->
+      <div *ngIf="autoPhase" class="d-grid gap-2">
+        <label>
+          <input type="checkbox" (change)="dockedValue = $event.target.value" />
+          Docked
+        </label>
+        <label>
+          <input
+            type="checkbox"
+            (change)="engagedValue = $event.target.value"
+          />
+          Engaged
+        </label>
+        <br />
+        <button
+          class="btn btn-info"
+          (click)="addAction({type: 'autoBalanceAction', docked: dockedValue, engaged: engagedValue});"
+        >
+          Submit Balancing
+        </button>
+      </div>
+      <button
+        *ngIf="autoPhase"
+        class="btn btn-info"
+        (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+      >
+        Start Teleop
+      </button>
+      <button
+        *ngIf="!autoPhase"
+        class="btn btn-info"
+        (click)="changeSectionTo('Endgame')"
+      >
+        Endgame
+      </button>
+    </div>
   </div>
 
   <div *ngSwitchCase="'Place'" id="Place" class="container-fluid">
-    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
-    <button
-      class="btn btn-success"
-      (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kHigh});"
-    >
-      HIGH
-    </button>
-    <button
-      class="btn btn-warning"
-      (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kMiddle});"
-    >
-      MID
-    </button>
-    <button
-      class="btn btn-danger"
-      (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kLow});"
-    >
-      LOW
-    </button>
-    <button
-      *ngIf="autoPhase"
-      class="btn btn-info"
-      (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
-    >
-      Start Teleop
-    </button>
-    <button
-      *ngIf="!autoPhase"
-      class="btn btn-info"
-      (click)="changeSectionTo('Endgame')"
-    >
-      Endgame
-    </button>
+    <div class="d-grid gap-5">
+      <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+      <button
+        class="btn btn-success"
+        (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kHigh});"
+      >
+        HIGH
+      </button>
+      <button
+        class="btn btn-warning"
+        (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kMiddle});"
+      >
+        MID
+      </button>
+      <button
+        class="btn btn-danger"
+        (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kLow});"
+      >
+        LOW
+      </button>
+      <!-- 'Balancing' during auto. -->
+      <div *ngIf="autoPhase" class="d-grid gap-2">
+        <label>
+          <input type="checkbox" (change)="dockedValue = $event.target.value" />
+          Docked
+        </label>
+        <label>
+          <input
+            type="checkbox"
+            (change)="engagedValue = $event.target.value"
+          />
+          Engaged
+        </label>
+        <br />
+        <button
+          class="btn btn-info"
+          (click)="addAction({type: 'autoBalanceAction', docked: dockedValue, engaged: engagedValue});"
+        >
+          Submit Balancing
+        </button>
+      </div>
+      <button
+        *ngIf="autoPhase"
+        class="btn btn-info"
+        (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+      >
+        Start Teleop
+      </button>
+      <button
+        *ngIf="!autoPhase"
+        class="btn btn-info"
+        (click)="changeSectionTo('Endgame')"
+      >
+        Endgame
+      </button>
+    </div>
   </div>
 
   <div *ngSwitchCase="'Endgame'" id="Endgame" class="container-fluid">
-    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
-    <label>
-      <input type="checkbox" (change)="dockedValue = $event.target.value" />
-      Docked
-    </label>
-    <label>
-      <input type="checkbox" (change)="engagedValue = $event.target.value" />
-      Engaged
-    </label>
-    <button
-      *ngIf="!autoPhase"
-      class="btn btn-info"
-      (click)="changeSectionTo('Review and Submit'); addAction({type: 'endMatchAction', docked: dockedValue, engaged: engagedValue});"
-    >
-      End Match
-    </button>
+    <div class="d-grid gap-5">
+      <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+      <label>
+        <input type="checkbox" (change)="dockedValue = $event.target.value" />
+        Docked
+      </label>
+      <label>
+        <input type="checkbox" (change)="engagedValue = $event.target.value" />
+        Engaged
+      </label>
+      <button
+        *ngIf="!autoPhase"
+        class="btn btn-info"
+        (click)="changeSectionTo('Review and Submit'); addAction({type: 'endMatchAction', docked: dockedValue, engaged: engagedValue});"
+      >
+        End Match
+      </button>
+    </div>
   </div>
 
   <div *ngSwitchCase="'Review and Submit'" id="Review" class="container-fluid">
-    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
-    <button
-      *ngIf="!autoPhase"
-      class="btn btn-warning"
-      (click)="submitActions();"
-    >
-      Submit
-    </button>
+    <div class="d-grid gap-5">
+      <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+      <button
+        *ngIf="!autoPhase"
+        class="btn btn-warning"
+        (click)="submitActions();"
+      >
+        Submit
+      </button>
+    </div>
   </div>
 
   <div *ngSwitchCase="'Success'" id="Success" class="container-fluid">
diff --git a/scouting/www/import_match_list/BUILD b/scouting/www/import_match_list/BUILD
deleted file mode 100644
index bc1d5d5..0000000
--- a/scouting/www/import_match_list/BUILD
+++ /dev/null
@@ -1,18 +0,0 @@
-load("@npm//:defs.bzl", "npm_link_all_packages")
-load("//tools/build_rules:js.bzl", "ng_pkg")
-
-npm_link_all_packages(name = "node_modules")
-
-ng_pkg(
-    name = "import_match_list",
-    extra_srcs = [
-        "//scouting/www:app_common_css",
-    ],
-    deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_response_ts_fbs",
-        "//scouting/webserver/requests/messages:refresh_match_list_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-    ],
-)
diff --git a/scouting/www/import_match_list/import_match_list.component.css b/scouting/www/import_match_list/import_match_list.component.css
deleted file mode 100644
index e220645..0000000
--- a/scouting/www/import_match_list/import_match_list.component.css
+++ /dev/null
@@ -1,3 +0,0 @@
-* {
-  padding: 10px;
-}
diff --git a/scouting/www/import_match_list/import_match_list.component.ts b/scouting/www/import_match_list/import_match_list.component.ts
deleted file mode 100644
index 526a636..0000000
--- a/scouting/www/import_match_list/import_match_list.component.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import {Component, OnInit} from '@angular/core';
-
-import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
-import {RefreshMatchListResponse} from '../../webserver/requests/messages/refresh_match_list_response_generated';
-import {RefreshMatchList} from '../../webserver/requests/messages/refresh_match_list_generated';
-
-@Component({
-  selector: 'app-import-match-list',
-  templateUrl: './import_match_list.ng.html',
-  styleUrls: ['../app/common.css', './import_match_list.component.css'],
-})
-export class ImportMatchListComponent {
-  year: number = new Date().getFullYear();
-  eventCode: string = '';
-  progressMessage: string = '';
-  errorMessage: string = '';
-
-  async importMatchList() {
-    const block_alerts = document.getElementById(
-      'block_alerts'
-    ) as HTMLInputElement;
-    console.log(block_alerts.checked);
-    if (!block_alerts.checked) {
-      if (!window.confirm('Actually import new matches?')) {
-        return;
-      }
-    }
-
-    this.errorMessage = '';
-
-    const builder = new Builder();
-    const eventCode = builder.createString(this.eventCode);
-    RefreshMatchList.startRefreshMatchList(builder);
-    RefreshMatchList.addYear(builder, this.year);
-    RefreshMatchList.addEventCode(builder, eventCode);
-    builder.finish(RefreshMatchList.endRefreshMatchList(builder));
-
-    this.progressMessage = 'Importing match list. Please be patient.';
-
-    const buffer = builder.asUint8Array();
-    const res = await fetch('/requests/refresh_match_list', {
-      method: 'POST',
-      body: buffer,
-    });
-
-    if (res.ok) {
-      // We successfully submitted the data.
-      this.progressMessage = 'Successfully imported match list.';
-    } else {
-      this.progressMessage = '';
-      const resBuffer = await res.arrayBuffer();
-      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
-
-      const errorMessage = parsedResponse.errorMessage();
-      this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
-    }
-  }
-}
diff --git a/scouting/www/import_match_list/import_match_list.module.ts b/scouting/www/import_match_list/import_match_list.module.ts
deleted file mode 100644
index cdb6fea..0000000
--- a/scouting/www/import_match_list/import_match_list.module.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {NgModule} from '@angular/core';
-import {CommonModule} from '@angular/common';
-import {ImportMatchListComponent} from './import_match_list.component';
-import {FormsModule} from '@angular/forms';
-
-@NgModule({
-  declarations: [ImportMatchListComponent],
-  exports: [ImportMatchListComponent],
-  imports: [CommonModule, FormsModule],
-})
-export class ImportMatchListModule {}
diff --git a/scouting/www/import_match_list/import_match_list.ng.html b/scouting/www/import_match_list/import_match_list.ng.html
deleted file mode 100644
index fdd7687..0000000
--- a/scouting/www/import_match_list/import_match_list.ng.html
+++ /dev/null
@@ -1,20 +0,0 @@
-<div class="header">
-  <h2>Import Match List</h2>
-</div>
-
-<div class="container-fluid">
-  <div class="row">
-    <label for="year">Year</label>
-    <input [(ngModel)]="year" type="number" id="year" min="1970" max="2500" />
-  </div>
-  <div class="row">
-    <label for="event_code">Event Code</label>
-    <input [(ngModel)]="eventCode" type="text" id="event_code" />
-  </div>
-
-  <span class="progress_message">{{ progressMessage }}</span>
-  <span class="error_message">{{ errorMessage }}</span>
-  <div class="text-right">
-    <button class="btn btn-primary" (click)="importMatchList()">Import</button>
-  </div>
-</div>
diff --git a/scouting/www/import_match_list/package.json b/scouting/www/import_match_list/package.json
deleted file mode 100644
index 05aa790..0000000
--- a/scouting/www/import_match_list/package.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-    "name": "@org_frc971/scouting/www/import_match_list",
-    "private": true,
-    "dependencies": {
-        "@angular/forms": "15.1.5"
-    }
-}
diff --git a/y2019/control_loops/drivetrain/target_selector.h b/y2019/control_loops/drivetrain/target_selector.h
index fdc183f..19ac52e 100644
--- a/y2019/control_loops/drivetrain/target_selector.h
+++ b/y2019/control_loops/drivetrain/target_selector.h
@@ -32,6 +32,7 @@
       /*num_obstacles=*/0, double> FakeCamera;
 
   TargetSelector(::aos::EventLoop *event_loop);
+  virtual ~TargetSelector() {}
 
   bool UpdateSelection(const ::Eigen::Matrix<double, 5, 1> &state,
                        double command_speed) override;
diff --git a/y2023/BUILD b/y2023/BUILD
index f055fe4..fa53462 100644
--- a/y2023/BUILD
+++ b/y2023/BUILD
@@ -2,6 +2,12 @@
 load("//aos:config.bzl", "aos_config")
 load("//tools/build_rules:template.bzl", "jinja2_template")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("//aos/util:config_validator_macro.bzl", "config_validator_rule")
+
+config_validator_rule(
+    name = "config_validator_test",
+    config = "//y2023:aos_config",
+)
 
 robot_downloader(
     binaries = [
@@ -308,6 +314,7 @@
         "//frc971/input:drivetrain_input",
         "//frc971/input:joystick_input",
         "//y2023/control_loops/drivetrain:drivetrain_base",
+        "//y2023/control_loops/drivetrain:target_selector_hint_fbs",
         "//y2023/control_loops/superstructure:superstructure_goal_fbs",
         "//y2023/control_loops/superstructure:superstructure_status_fbs",
         "//y2023/control_loops/superstructure/arm:generated_graph",
diff --git a/y2023/constants.cc b/y2023/constants.cc
index 8908811..7e30898 100644
--- a/y2023/constants.cc
+++ b/y2023/constants.cc
@@ -87,41 +87,43 @@
           0.931355973012855 + 8.6743197253382 - 0.101200335326309 -
           0.0820901660993467 - 0.0703733798337964;
 
-      arm_distal->zeroing.measured_absolute_position = 0.617279923658987;
+      arm_distal->zeroing.measured_absolute_position = 0.58478872393942;
       arm_distal->potentiometer_offset =
           0.436664933370656 + 0.49457213779426 + 6.78213223139724 -
           0.0220711555235029 - 0.0162945074111813 + 0.00630344935527365 -
-          0.0164398318919943 - 0.145833494945215;
+          0.0164398318919943 - 0.145833494945215 + 0.234878799868491;
 
-      roll_joint->zeroing.measured_absolute_position = 1.12525305971909;
+      roll_joint->zeroing.measured_absolute_position = 1.7229611246564;
       roll_joint->potentiometer_offset =
-          3.87038557084874 - 0.0241774522172967 + 0.0711345168020632 -
-          0.866186131631967 - 0.0256788357596952 + 0.18101759154572017 -
-          0.0208958996127179 - 0.186395903925026 + 0.45801689548395 -
-          0.5935210745062 + 0.166256655718334 - 0.12591438680483 +
-          0.11972765117321;
+          -(3.87038557084874 - 0.0241774522172967 + 0.0711345168020632 -
+            0.866186131631967 - 0.0256788357596952 + 0.18101759154572017 -
+            0.0208958996127179 - 0.186395903925026 + 0.45801689548395 -
+            0.5935210745062 + 0.166256655718334 - 0.12591438680483 +
+            0.11972765117321 - 0.318724743041507) +
+          0.0201047336425017;
 
       wrist->subsystem_params.zeroing_constants.measured_absolute_position =
-          0.420104471500763;
+          0.85247856535298;
 
       break;
 
     case kPracticeTeamNumber:
-      arm_proximal->zeroing.measured_absolute_position = 0.254437958024658;
+      arm_proximal->zeroing.measured_absolute_position = 0.261970010788946;
       arm_proximal->potentiometer_offset =
-          10.5178592988554 + 0.0944609125285876;
+          10.5178592988554 + 0.0944609125285876 - 0.00826532984625095;
 
-      arm_distal->zeroing.measured_absolute_position = 0.51986178669514;
-      arm_distal->potentiometer_offset = 7.673132586937 - 0.0799284644472573 -
-                                         0.0323574039310657 +
-                                         0.0143810684138064;
+      arm_distal->zeroing.measured_absolute_position = 0.507166003869875;
+      arm_distal->potentiometer_offset =
+          7.673132586937 - 0.0799284644472573 - 0.0323574039310657 +
+          0.0143810684138064 + 0.00945555248207735;
 
-      roll_joint->zeroing.measured_absolute_position = 1.86685853969852;
+      roll_joint->zeroing.measured_absolute_position = 1.88759815823151;
       roll_joint->potentiometer_offset =
-          0.624713611895747 + 3.10458504917251 - 0.0966407797407789;
+          0.624713611895747 + 3.10458504917251 - 0.0966407797407789 +
+          0.0257708772364788 - 0.0395076737853459;
 
       wrist->subsystem_params.zeroing_constants.measured_absolute_position =
-          -0.607792293122026;
+          0.627578012126286;
 
       break;
 
diff --git a/y2023/constants/test_data/scoring_map.json b/y2023/constants/test_data/scoring_map.json
new file mode 100644
index 0000000..ebb9b38
--- /dev/null
+++ b/y2023/constants/test_data/scoring_map.json
@@ -0,0 +1,348 @@
+{
+ "red": {
+  "substation": {
+   "left": {
+    "x": -8.068,
+    "y": 1.74,
+    "z": 0.95
+   },
+   "right": {
+    "x": -8.068,
+    "y": 3.74,
+    "z": 0.95
+   }
+  },
+  "left_grid": {
+   "bottom": {
+    "left_cone": {
+     "x": 6.99,
+     "y": 0.973,
+     "z": 0.0
+    },
+    "cube": {
+     "x": 6.99,
+     "y": 0.414,
+     "z": 0.0
+    },
+    "right_cone": {
+     "x": 6.99,
+     "y": -0.145,
+     "z": 0.0
+    }
+   },
+   "middle": {
+    "left_cone": {
+     "x": 7.461,
+     "y": 0.973,
+     "z": 0.87
+    },
+    "cube": {
+     "x": 7.461,
+     "y": 0.414,
+     "z": 0.87
+    },
+    "right_cone": {
+     "x": 7.461,
+     "y": -0.145,
+     "z": 0.87
+    }
+   },
+   "top": {
+    "left_cone": {
+     "x": 7.891,
+     "y": 0.973,
+     "z": 1.17
+    },
+    "cube": {
+     "x": 7.891,
+     "y": 0.414,
+     "z": 1.17
+    },
+    "right_cone": {
+     "x": 7.891,
+     "y": -0.145,
+     "z": 1.17
+    }
+   }
+  },
+  "middle_grid": {
+   "bottom": {
+    "left_cone": {
+     "x": 6.99,
+     "y": -0.703,
+     "z": 0.0
+    },
+    "cube": {
+     "x": 6.99,
+     "y": -1.262,
+     "z": 0.0
+    },
+    "right_cone": {
+     "x": 6.99,
+     "y": -1.821,
+     "z": 0.0
+    }
+   },
+   "middle": {
+    "left_cone": {
+     "x": 7.461,
+     "y": -0.703,
+     "z": 0.87
+    },
+    "cube": {
+     "x": 7.461,
+     "y": -1.262,
+     "z": 0.87
+    },
+    "right_cone": {
+     "x": 7.461,
+     "y": -1.821,
+     "z": 0.87
+    }
+   },
+   "top": {
+    "left_cone": {
+     "x": 7.891,
+     "y": -0.703,
+     "z": 1.17
+    },
+    "cube": {
+     "x": 7.891,
+     "y": -1.262,
+     "z": 1.17
+    },
+    "right_cone": {
+     "x": 7.891,
+     "y": -1.821,
+     "z": 1.17
+    }
+   }
+  },
+  "right_grid": {
+   "bottom": {
+    "left_cone": {
+     "x": 6.99,
+     "y": -2.379,
+     "z": 0.0
+    },
+    "cube": {
+     "x": 6.99,
+     "y": -2.938,
+     "z": 0.0
+    },
+    "right_cone": {
+     "x": 6.99,
+     "y": -3.497,
+     "z": 0.0
+    }
+   },
+   "middle": {
+    "left_cone": {
+     "x": 7.461,
+     "y": -2.379,
+     "z": 0.87
+    },
+    "cube": {
+     "x": 7.461,
+     "y": -2.938,
+     "z": 0.87
+    },
+    "right_cone": {
+     "x": 7.461,
+     "y": -3.497,
+     "z": 0.87
+    }
+   },
+   "top": {
+    "left_cone": {
+     "x": 7.891,
+     "y": -2.379,
+     "z": 1.17
+    },
+    "cube": {
+     "x": 7.891,
+     "y": -2.938,
+     "z": 1.17
+    },
+    "right_cone": {
+     "x": 7.891,
+     "y": -3.497,
+     "z": 1.17
+    }
+   }
+  }
+ },
+ "blue": {
+  "substation": {
+   "left": {
+    "x": 8.069,
+    "y": 3.74,
+    "z": 0.95
+   },
+   "right": {
+    "x": 8.069,
+    "y": 1.74,
+    "z": 0.95
+   }
+  },
+  "left_grid": {
+   "bottom": {
+    "left_cone": {
+     "x": -6.989,
+     "y": -0.145,
+     "z": 0.0
+    },
+    "cube": {
+     "x": -6.989,
+     "y": 0.414,
+     "z": 0.0
+    },
+    "right_cone": {
+     "x": -6.989,
+     "y": 0.973,
+     "z": 0.0
+    }
+   },
+   "middle": {
+    "left_cone": {
+     "x": -7.46,
+     "y": -0.145,
+     "z": 0.87
+    },
+    "cube": {
+     "x": -7.46,
+     "y": 0.414,
+     "z": 0.87
+    },
+    "right_cone": {
+     "x": -7.46,
+     "y": 0.973,
+     "z": 0.87
+    }
+   },
+   "top": {
+    "left_cone": {
+     "x": -7.89,
+     "y": -0.145,
+     "z": 1.17
+    },
+    "cube": {
+     "x": -7.89,
+     "y": 0.414,
+     "z": 1.17
+    },
+    "right_cone": {
+     "x": -7.89,
+     "y": 0.973,
+     "z": 1.17
+    }
+   }
+  },
+  "middle_grid": {
+   "bottom": {
+    "left_cone": {
+     "x": -6.989,
+     "y": -1.821,
+     "z": 0.0
+    },
+    "cube": {
+     "x": -6.989,
+     "y": -1.262,
+     "z": 0.0
+    },
+    "right_cone": {
+     "x": -6.989,
+     "y": -0.703,
+     "z": 0.0
+    }
+   },
+   "middle": {
+    "left_cone": {
+     "x": -7.46,
+     "y": -1.821,
+     "z": 0.87
+    },
+    "cube": {
+     "x": -7.46,
+     "y": -1.262,
+     "z": 0.87
+    },
+    "right_cone": {
+     "x": -7.46,
+     "y": -0.703,
+     "z": 0.87
+    }
+   },
+   "top": {
+    "left_cone": {
+     "x": -7.89,
+     "y": -1.821,
+     "z": 1.17
+    },
+    "cube": {
+     "x": -7.89,
+     "y": -1.262,
+     "z": 1.17
+    },
+    "right_cone": {
+     "x": -7.89,
+     "y": -0.703,
+     "z": 1.17
+    }
+   }
+  },
+  "right_grid": {
+   "bottom": {
+    "left_cone": {
+     "x": -6.989,
+     "y": -3.497,
+     "z": 0.0
+    },
+    "cube": {
+     "x": -6.989,
+     "y": -2.938,
+     "z": 0.0
+    },
+    "right_cone": {
+     "x": -6.989,
+     "y": -2.379,
+     "z": 0.0
+    }
+   },
+   "middle": {
+    "left_cone": {
+     "x": -7.46,
+     "y": -3.497,
+     "z": 0.87
+    },
+    "cube": {
+     "x": -7.46,
+     "y": -2.938,
+     "z": 0.87
+    },
+    "right_cone": {
+     "x": -7.46,
+     "y": -2.379,
+     "z": 0.87
+    }
+   },
+   "top": {
+    "left_cone": {
+     "x": -7.89,
+     "y": -3.497,
+     "z": 1.17
+    },
+    "cube": {
+     "x": -7.89,
+     "y": -2.938,
+     "z": 1.17
+    },
+    "right_cone": {
+     "x": -7.89,
+     "y": -2.379,
+     "z": 1.17
+    }
+   }
+  }
+ }
+}
\ No newline at end of file
diff --git a/y2023/constants/test_data/test_team.json b/y2023/constants/test_data/test_team.json
index adc9eae..f09b23e 100644
--- a/y2023/constants/test_data/test_team.json
+++ b/y2023/constants/test_data/test_team.json
@@ -13,5 +13,6 @@
       "calibration": {% include 'y2023/constants/test_data/calibration_pi-4.json' %}
     }
   ],
-  "target_map": {% include 'y2023/constants/test_data/target_map.json' %}
+  "target_map": {% include 'y2023/constants/test_data/target_map.json' %},
+  "scoring_map": {% include 'y2023/constants/test_data/scoring_map.json' %}
 }
diff --git a/y2023/control_loops/drivetrain/BUILD b/y2023/control_loops/drivetrain/BUILD
index 416d6d9..32b8c15 100644
--- a/y2023/control_loops/drivetrain/BUILD
+++ b/y2023/control_loops/drivetrain/BUILD
@@ -81,6 +81,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":drivetrain_base",
+        ":target_selector",
         "//aos:init",
         "//aos/events:shm_event_loop",
         "//frc971/control_loops/drivetrain:drivetrain_lib",
@@ -131,3 +132,32 @@
     gen_reflections = 1,
     visibility = ["//visibility:public"],
 )
+
+cc_library(
+    name = "target_selector",
+    srcs = ["target_selector.cc"],
+    hdrs = ["target_selector.h"],
+    deps = [
+        ":target_selector_hint_fbs",
+        "//aos/containers:sized_array",
+        "//aos/events:event_loop",
+        "//frc971/constants:constants_sender_lib",
+        "//frc971/control_loops:pose",
+        "//frc971/control_loops/drivetrain:localizer",
+        "//frc971/input:joystick_state_fbs",
+        "//y2023/constants:constants_fbs",
+    ],
+)
+
+cc_test(
+    name = "target_selector_test",
+    srcs = ["target_selector_test.cc"],
+    data = ["//y2023:aos_config"],
+    deps = [
+        ":target_selector",
+        "//aos/events:simulated_event_loop",
+        "//aos/testing:googletest",
+        "//frc971/input:joystick_state_fbs",
+        "//y2023/constants:simulated_constants_sender",
+    ],
+)
diff --git a/y2023/control_loops/drivetrain/drivetrain_base.cc b/y2023/control_loops/drivetrain/drivetrain_base.cc
index cdb5a61..e8fdb3c 100644
--- a/y2023/control_loops/drivetrain/drivetrain_base.cc
+++ b/y2023/control_loops/drivetrain/drivetrain_base.cc
@@ -11,6 +11,7 @@
 
 using ::frc971::control_loops::drivetrain::DownEstimatorConfig;
 using ::frc971::control_loops::drivetrain::DrivetrainConfig;
+using ::frc971::control_loops::drivetrain::LineFollowConfig;
 
 namespace chrono = ::std::chrono;
 
@@ -61,7 +62,20 @@
           .finished(),
       false /*is_simulated*/,
       DownEstimatorConfig{.gravity_threshold = 0.015,
-                          .do_accel_corrections = 1000}};
+                          .do_accel_corrections = 1000},
+      LineFollowConfig{
+          .Q = Eigen::Matrix3d((::Eigen::DiagonalMatrix<double, 3>().diagonal()
+                                    << 1.0 / ::std::pow(0.1, 2),
+                                1.0 / ::std::pow(1.0, 2),
+                                1.0 / ::std::pow(1.0, 2))
+                                   .finished()
+                                   .asDiagonal()),
+          .R = Eigen::Matrix2d((::Eigen::DiagonalMatrix<double, 2>().diagonal()
+                                    << 10.0 / ::std::pow(12.0, 2),
+                                10.0 / ::std::pow(12.0, 2))
+                                   .finished()
+                                   .asDiagonal()),
+          .max_controllable_offset = 0.5}};
 
   return kDrivetrainConfig;
 };
diff --git a/y2023/control_loops/drivetrain/drivetrain_main.cc b/y2023/control_loops/drivetrain/drivetrain_main.cc
index 32b2455..b13e76f 100644
--- a/y2023/control_loops/drivetrain/drivetrain_main.cc
+++ b/y2023/control_loops/drivetrain/drivetrain_main.cc
@@ -5,6 +5,7 @@
 #include "frc971/control_loops/drivetrain/drivetrain.h"
 #include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 #include "y2023/control_loops/drivetrain/drivetrain_base.h"
+#include "y2023/control_loops/drivetrain/target_selector.h"
 
 using ::frc971::control_loops::drivetrain::DrivetrainLoop;
 
@@ -19,7 +20,9 @@
       localizer = std::make_unique<
           ::frc971::control_loops::drivetrain::PuppetLocalizer>(
           &event_loop,
-          ::y2023::control_loops::drivetrain::GetDrivetrainConfig());
+          ::y2023::control_loops::drivetrain::GetDrivetrainConfig(),
+          std::make_unique<::y2023::control_loops::drivetrain::TargetSelector>(
+              &event_loop));
   std::unique_ptr<DrivetrainLoop> drivetrain = std::make_unique<DrivetrainLoop>(
       y2023::control_loops::drivetrain::GetDrivetrainConfig(), &event_loop,
       localizer.get());
diff --git a/y2023/control_loops/drivetrain/target_selector.cc b/y2023/control_loops/drivetrain/target_selector.cc
new file mode 100644
index 0000000..99805cb
--- /dev/null
+++ b/y2023/control_loops/drivetrain/target_selector.cc
@@ -0,0 +1,120 @@
+#include "y2023/control_loops/drivetrain/target_selector.h"
+
+#include "aos/containers/sized_array.h"
+
+namespace y2023::control_loops::drivetrain {
+TargetSelector::TargetSelector(aos::EventLoop *event_loop)
+    : joystick_state_fetcher_(
+          event_loop->MakeFetcher<aos::JoystickState>("/aos")),
+      hint_fetcher_(event_loop->MakeFetcher<TargetSelectorHint>("/drivetrain")),
+      constants_fetcher_(event_loop) {
+  CHECK(constants_fetcher_.constants().has_scoring_map());
+  CHECK(constants_fetcher_.constants().scoring_map()->has_red());
+  CHECK(constants_fetcher_.constants().scoring_map()->has_blue());
+}
+
+void TargetSelector::UpdateAlliance() {
+  joystick_state_fetcher_.Fetch();
+  if (joystick_state_fetcher_.get() != nullptr &&
+      joystick_state_fetcher_->has_alliance()) {
+    switch (joystick_state_fetcher_->alliance()) {
+      case aos::Alliance::kRed:
+        scoring_map_ = constants_fetcher_.constants().scoring_map()->red();
+        break;
+      case aos::Alliance::kBlue:
+        scoring_map_ = constants_fetcher_.constants().scoring_map()->blue();
+        break;
+      case aos::Alliance::kInvalid:
+        // Do nothing.
+        break;
+    }
+  }
+}
+
+bool TargetSelector::UpdateSelection(const ::Eigen::Matrix<double, 5, 1> &state,
+                                     double /*command_speed*/) {
+  UpdateAlliance();
+  if (scoring_map_ == nullptr) {
+    // We don't know which alliance we are on yet; wait on a JoystickState
+    // message.
+    return false;
+  }
+  hint_fetcher_.Fetch();
+  if (hint_fetcher_.get() == nullptr) {
+    // We don't know where to go, wait on a hint.
+    return false;
+  }
+  aos::SizedArray<const localizer::ScoringGrid *, 3> possible_grids;
+  if (hint_fetcher_->has_grid()) {
+    possible_grids = {[this]() -> const localizer::ScoringGrid * {
+      switch (hint_fetcher_->grid()) {
+        case GridSelectionHint::LEFT:
+          return scoring_map_->left_grid();
+        case GridSelectionHint::MIDDLE:
+          return scoring_map_->middle_grid();
+        case GridSelectionHint::RIGHT:
+          return scoring_map_->right_grid();
+      }
+      // Make roborio compiler happy...
+      return nullptr;
+    }()};
+  } else {
+    possible_grids = {scoring_map_->left_grid(), scoring_map_->middle_grid(),
+                      scoring_map_->right_grid()};
+  }
+
+  aos::SizedArray<const localizer::ScoringRow *, 3> possible_rows =
+      [this, possible_grids]() {
+        aos::SizedArray<const localizer::ScoringRow *, 3> rows;
+        for (const localizer::ScoringGrid *grid : possible_grids) {
+          CHECK_NOTNULL(grid);
+          switch (hint_fetcher_->row()) {
+            case RowSelectionHint::BOTTOM:
+              rows.push_back(grid->bottom());
+              break;
+            case RowSelectionHint::MIDDLE:
+              rows.push_back(grid->middle());
+              break;
+            case RowSelectionHint::TOP:
+              rows.push_back(grid->top());
+              break;
+          }
+        }
+        return rows;
+      }();
+  aos::SizedArray<const frc971::vision::Position *, 3> possible_positions =
+      [this, possible_rows]() {
+        aos::SizedArray<const frc971::vision::Position *, 3> positions;
+        for (const localizer::ScoringRow *row : possible_rows) {
+          CHECK_NOTNULL(row);
+          switch (hint_fetcher_->spot()) {
+            case SpotSelectionHint::LEFT:
+              positions.push_back(row->left_cone());
+              break;
+            case SpotSelectionHint::MIDDLE:
+              positions.push_back(row->cube());
+              break;
+            case SpotSelectionHint::RIGHT:
+              positions.push_back(row->right_cone());
+              break;
+          }
+        }
+        return positions;
+      }();
+  CHECK_LT(0u, possible_positions.size());
+  std::optional<double> closest_distance;
+  std::optional<Eigen::Vector3d> closest_position;
+  const Eigen::Vector3d robot_position(state.x(), state.y(), 0.0);
+  for (const frc971::vision::Position *position : possible_positions) {
+    const Eigen::Vector3d target(position->x(), position->y(), position->z());
+    double distance = (target - robot_position).norm();
+    if (!closest_distance.has_value() || distance < closest_distance.value()) {
+      closest_distance = distance;
+      closest_position = target;
+    }
+  }
+  CHECK(closest_position.has_value());
+  target_pose_ = Pose(closest_position.value(), /*theta=*/0.0);
+  return true;
+}
+}  // namespace y2023::control_loops::drivetrain
diff --git a/y2023/control_loops/drivetrain/target_selector.h b/y2023/control_loops/drivetrain/target_selector.h
new file mode 100644
index 0000000..0290425
--- /dev/null
+++ b/y2023/control_loops/drivetrain/target_selector.h
@@ -0,0 +1,49 @@
+#ifndef Y2023_CONTROL_LOOPS_DRIVETRAIN_TARGET_SELECTOR_H_
+#define Y2023_CONTROL_LOOPS_DRIVETRAIN_TARGET_SELECTOR_H_
+#include "frc971/constants/constants_sender_lib.h"
+#include "frc971/control_loops/drivetrain/localizer.h"
+#include "frc971/control_loops/pose.h"
+#include "frc971/input/joystick_state_generated.h"
+#include "y2023/constants/constants_generated.h"
+#include "y2023/control_loops/drivetrain/target_selector_hint_generated.h"
+
+namespace y2023::control_loops::drivetrain {
+// This target selector provides the logic to choose which position to try to
+// guide the robot to (primarily for game piece placement; but also for game
+// piece pickup).
+// Currently, this works by:
+// 1. Relying on the constants + JoystickState message to figure out which set
+//    of targets are relevant to us given the alliance that we are on).
+// 2. If the TargetSelectorHint message fully specifies where to score the game
+//    piece, go there.
+// 3. If the exact grid to score in is unpopulated, score in the closest grid.
+//    In the future, the code could readily be expanded to score in the nearest
+//    valid position or resolve any other set of extra ambiguity.
+class TargetSelector
+    : public frc971::control_loops::drivetrain::TargetSelectorInterface {
+ public:
+  typedef frc971::control_loops::TypedPose<double> Pose;
+
+  TargetSelector(aos::EventLoop *event_loop);
+
+  bool UpdateSelection(const ::Eigen::Matrix<double, 5, 1> &state,
+                       double command_speed) override;
+
+  Pose TargetPose() const override {
+    CHECK(target_pose_.has_value())
+        << "Did you check the return value of UpdateSelection()?";
+    return target_pose_.value();
+  }
+
+  double TargetRadius() const override { return 0.0; }
+
+ private:
+  void UpdateAlliance();
+  std::optional<Pose> target_pose_;
+  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
+  aos::Fetcher<TargetSelectorHint> hint_fetcher_;
+  frc971::constants::ConstantsFetcher<Constants> constants_fetcher_;
+  const localizer::HalfField *scoring_map_ = nullptr;
+};
+}  // namespace y2023::control_loops::drivetrain
+#endif  // Y2023_CONTROL_LOOPS_DRIVETRAIN_TARGET_SELECTOR_H_
diff --git a/y2023/control_loops/drivetrain/target_selector_hint.fbs b/y2023/control_loops/drivetrain/target_selector_hint.fbs
index 0a16a59..518d302 100644
--- a/y2023/control_loops/drivetrain/target_selector_hint.fbs
+++ b/y2023/control_loops/drivetrain/target_selector_hint.fbs
@@ -28,6 +28,8 @@
   grid:GridSelectionHint (id: 0);
   row:RowSelectionHint (id: 1);
   spot:SpotSelectionHint (id: 2);
+  // TODO: support human player pickup auto-align?
+  // TODO: add flag for forwards vs. backwards.
 }
 
 root_type TargetSelectorHint;
diff --git a/y2023/control_loops/drivetrain/target_selector_test.cc b/y2023/control_loops/drivetrain/target_selector_test.cc
new file mode 100644
index 0000000..fdbd904
--- /dev/null
+++ b/y2023/control_loops/drivetrain/target_selector_test.cc
@@ -0,0 +1,137 @@
+#include "y2023/control_loops/drivetrain/target_selector.h"
+
+#include "frc971/input/joystick_state_generated.h"
+#include "gtest/gtest.h"
+#include "y2023/constants/simulated_constants_sender.h"
+
+namespace y2023::control_loops::drivetrain {
+class TargetSelectorTest : public ::testing::Test {
+ protected:
+  TargetSelectorTest()
+      : configuration_(aos::configuration::ReadConfig("y2023/aos_config.json")),
+        event_loop_factory_(&configuration_.message()),
+        roborio_node_([this]() {
+          // Get the constants sent before anything else happens.
+          // It has nothing to do with the roborio node.
+          SendSimulationConstants(&event_loop_factory_, 7971,
+                                  "y2023/constants/test_constants.json");
+          return aos::configuration::GetNode(&configuration_.message(),
+                                             "roborio");
+        }()),
+        selector_event_loop_(
+            event_loop_factory_.MakeEventLoop("drivetrain", roborio_node_)),
+        target_selector_(selector_event_loop_.get()),
+        test_event_loop_(
+            event_loop_factory_.MakeEventLoop("test", roborio_node_)),
+        constants_fetcher_(test_event_loop_.get()),
+        joystick_state_sender_(
+            test_event_loop_->MakeSender<aos::JoystickState>("/aos")),
+        hint_sender_(
+            test_event_loop_->MakeSender<TargetSelectorHint>("/drivetrain")) {}
+
+  void SendJoystickState() {
+    auto builder = joystick_state_sender_.MakeBuilder();
+    aos::JoystickState::Builder state_builder =
+        builder.MakeBuilder<aos::JoystickState>();
+    state_builder.add_alliance(aos::Alliance::kRed);
+    builder.CheckOk(builder.Send(state_builder.Finish()));
+  }
+
+  void SendHint(GridSelectionHint grid, RowSelectionHint row,
+                SpotSelectionHint spot) {
+    auto builder = hint_sender_.MakeBuilder();
+    builder.CheckOk(builder.Send(
+        CreateTargetSelectorHint(*builder.fbb(), grid, row, spot)));
+  }
+  void SendHint(RowSelectionHint row, SpotSelectionHint spot) {
+    auto builder = hint_sender_.MakeBuilder();
+    TargetSelectorHint::Builder hint_builder =
+        builder.MakeBuilder<TargetSelectorHint>();
+    hint_builder.add_row(row);
+    hint_builder.add_spot(spot);
+    builder.CheckOk(builder.Send(hint_builder.Finish()));
+  }
+
+  const localizer::HalfField *scoring_map() const {
+    return constants_fetcher_.constants().scoring_map()->red();
+  }
+
+  aos::FlatbufferDetachedBuffer<aos::Configuration> configuration_;
+  aos::SimulatedEventLoopFactory event_loop_factory_;
+  const aos::Node *const roborio_node_;
+  std::unique_ptr<aos::EventLoop> selector_event_loop_;
+  TargetSelector target_selector_;
+  std::unique_ptr<aos::EventLoop> test_event_loop_;
+  frc971::constants::ConstantsFetcher<Constants> constants_fetcher_;
+  aos::Sender<aos::JoystickState> joystick_state_sender_;
+  aos::Sender<TargetSelectorHint> hint_sender_;
+};
+
+// Tests that no target is available if no input messages have been sent.
+TEST_F(TargetSelectorTest, NoTargetWithoutInputs) {
+  EXPECT_FALSE(target_selector_.UpdateSelection(
+      Eigen::Matrix<double, 5, 1>::Zero(), 0.0));
+  EXPECT_DEATH(target_selector_.TargetPose(), "Did you check the return value");
+  EXPECT_EQ(0.0, target_selector_.TargetRadius());
+}
+
+// Tests that if we fully specify which target to go to that we always will do
+// so.
+TEST_F(TargetSelectorTest, FullySpecifiedTarget) {
+  SendJoystickState();
+  // Iterate over every available target.
+  for (const auto &[grid, grid_hint] : std::vector<
+           std::pair<const localizer::ScoringGrid *, GridSelectionHint>>{
+           {scoring_map()->left_grid(), GridSelectionHint::LEFT},
+           {scoring_map()->middle_grid(), GridSelectionHint::MIDDLE},
+           {scoring_map()->right_grid(), GridSelectionHint::RIGHT}}) {
+    for (const auto &[row, row_hint] : std::vector<
+             std::pair<const localizer::ScoringRow *, RowSelectionHint>>{
+             {grid->bottom(), RowSelectionHint::BOTTOM},
+             {grid->middle(), RowSelectionHint::MIDDLE},
+             {grid->top(), RowSelectionHint::TOP}}) {
+      for (const auto &[spot, spot_hint] : std::vector<
+               std::pair<const frc971::vision::Position *, SpotSelectionHint>>{
+               {row->left_cone(), SpotSelectionHint::LEFT},
+               {row->cube(), SpotSelectionHint::MIDDLE},
+               {row->right_cone(), SpotSelectionHint::RIGHT}}) {
+        SendHint(grid_hint, row_hint, spot_hint);
+        EXPECT_TRUE(target_selector_.UpdateSelection(
+            Eigen::Matrix<double, 5, 1>::Zero(), 0.0));
+        EXPECT_EQ(0.0, target_selector_.TargetRadius());
+        EXPECT_EQ(spot->x(), target_selector_.TargetPose().abs_pos().x());
+        EXPECT_EQ(spot->y(), target_selector_.TargetPose().abs_pos().y());
+        EXPECT_EQ(spot->z(), target_selector_.TargetPose().abs_pos().z());
+      }
+    }
+  }
+}
+
+// Tests that if we leave the grid setting ambiguous that we select the
+// nearest possible target given the other settings.
+TEST_F(TargetSelectorTest, NoGridSpecified) {
+  SendJoystickState();
+  // We will leave the robot at (0, 0). This means that if we are going for the
+  // left cone we should go for the middle grid, and if we are going for the
+  // cube (middle) or right cone positions we should prefer the left grid.
+  // Note that the grids are not centered on the field (hence the middle isn't
+  // always preferred when at (0, 0)).
+
+  for (const auto &[spot, spot_hint] : std::vector<
+           std::pair<const frc971::vision::Position *, SpotSelectionHint>>{
+           {scoring_map()->middle_grid()->bottom()->left_cone(),
+            SpotSelectionHint::LEFT},
+           {scoring_map()->left_grid()->bottom()->cube(),
+            SpotSelectionHint::MIDDLE},
+           {scoring_map()->left_grid()->bottom()->right_cone(),
+            SpotSelectionHint::RIGHT}}) {
+    SendHint(RowSelectionHint::BOTTOM, spot_hint);
+    EXPECT_TRUE(target_selector_.UpdateSelection(
+        Eigen::Matrix<double, 5, 1>::Zero(), 0.0));
+    EXPECT_EQ(spot->x(), target_selector_.TargetPose().abs_pos().x());
+    EXPECT_EQ(spot->y(), target_selector_.TargetPose().abs_pos().y());
+    EXPECT_EQ(spot->z(), target_selector_.TargetPose().abs_pos().z());
+  }
+}
+
+}  // namespace y2023::control_loops::drivetrain
diff --git a/y2023/joystick_reader.cc b/y2023/joystick_reader.cc
index 489934b..a7fed8f 100644
--- a/y2023/joystick_reader.cc
+++ b/y2023/joystick_reader.cc
@@ -19,6 +19,7 @@
 #include "frc971/zeroing/wrap.h"
 #include "y2023/constants.h"
 #include "y2023/control_loops/drivetrain/drivetrain_base.h"
+#include "y2023/control_loops/drivetrain/target_selector_hint_generated.h"
 #include "y2023/control_loops/superstructure/arm/generated_graph.h"
 #include "y2023/control_loops/superstructure/superstructure_goal_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_status_generated.h"
@@ -31,6 +32,10 @@
 using frc971::input::driver_station::JoystickAxis;
 using frc971::input::driver_station::POVLocation;
 using y2023::control_loops::superstructure::RollerGoal;
+using y2023::control_loops::drivetrain::RowSelectionHint;
+using y2023::control_loops::drivetrain::GridSelectionHint;
+using y2023::control_loops::drivetrain::SpotSelectionHint;
+using y2023::control_loops::drivetrain::TargetSelectorHint;
 
 namespace y2023 {
 namespace input {
@@ -81,6 +86,8 @@
   GamePiece game_piece;
   std::vector<ButtonLocation> buttons;
   Side side;
+  std::optional<RowSelectionHint> row_hint = std::nullopt;
+  std::optional<SpotSelectionHint> spot_hint = std::nullopt;
 };
 
 const std::vector<ArmSetpoint> setpoints = {
@@ -104,6 +111,8 @@
         .game_piece = GamePiece::CONE_UP,
         .buttons = {kMidConeScoreRight},
         .side = Side::BACK,
+        .row_hint = RowSelectionHint::MIDDLE,
+        .spot_hint = SpotSelectionHint::RIGHT,
     },
     {
         .index = arm::ScoreBackMidConeDownPosIndex(),
@@ -112,6 +121,8 @@
         .game_piece = GamePiece::CONE_DOWN,
         .buttons = {kMidConeScoreRight},
         .side = Side::BACK,
+        .row_hint = RowSelectionHint::MIDDLE,
+        .spot_hint = SpotSelectionHint::RIGHT,
     },
     {
         .index = arm::HPPickupBackConeUpIndex(),
@@ -126,6 +137,8 @@
         .game_piece = GamePiece::CONE_UP,
         .buttons = {kHighConeScoreLeft, kHighConeScoreRight},
         .side = Side::FRONT,
+        .row_hint = RowSelectionHint::TOP,
+        .spot_hint = SpotSelectionHint::LEFT,
     },
     {
         .index = arm::ScoreFrontMidConeUpPosIndex(),
@@ -133,6 +146,8 @@
         .game_piece = GamePiece::CONE_UP,
         .buttons = {kMidConeScoreLeft, kMidConeScoreRight},
         .side = Side::FRONT,
+        .row_hint = RowSelectionHint::MIDDLE,
+        .spot_hint = SpotSelectionHint::LEFT,
     },
     {
         .index = arm::GroundPickupBackCubeIndex(),
@@ -147,6 +162,8 @@
         .game_piece = GamePiece::CUBE,
         .buttons = {kMidCube},
         .side = Side::FRONT,
+        .row_hint = RowSelectionHint::MIDDLE,
+        .spot_hint = SpotSelectionHint::MIDDLE,
     },
     {
         .index = arm::ScoreBackMidCubeIndex(),
@@ -155,6 +172,8 @@
         .game_piece = GamePiece::CUBE,
         .buttons = {kMidCube},
         .side = Side::BACK,
+        .row_hint = RowSelectionHint::MIDDLE,
+        .spot_hint = SpotSelectionHint::MIDDLE,
     },
     {
         .index = arm::ScoreFrontLowCubeIndex(),
@@ -162,6 +181,8 @@
         .game_piece = GamePiece::CUBE,
         .buttons = {kLowCube},
         .side = Side::FRONT,
+        .row_hint = RowSelectionHint::BOTTOM,
+        .spot_hint = SpotSelectionHint::MIDDLE,
     },
     {
         .index = arm::ScoreBackLowCubeIndex(),
@@ -169,6 +190,8 @@
         .game_piece = GamePiece::CUBE,
         .buttons = {kLowCube},
         .side = Side::BACK,
+        .row_hint = RowSelectionHint::BOTTOM,
+        .spot_hint = SpotSelectionHint::MIDDLE,
     },
     {
         .index = arm::ScoreFrontHighCubeIndex(),
@@ -176,6 +199,8 @@
         .game_piece = GamePiece::CUBE,
         .buttons = {kHighCube},
         .side = Side::FRONT,
+        .row_hint = RowSelectionHint::TOP,
+        .spot_hint = SpotSelectionHint::MIDDLE,
     },
     {
         .index = arm::ScoreBackHighCubeIndex(),
@@ -184,6 +209,8 @@
         .game_piece = GamePiece::CUBE,
         .buttons = {kHighCube},
         .side = Side::BACK,
+        .row_hint = RowSelectionHint::TOP,
+        .spot_hint = SpotSelectionHint::MIDDLE,
     },
     {
         .index = arm::GroundPickupFrontCubeIndex(),
@@ -203,6 +230,8 @@
             ::frc971::input::DrivetrainInputReader::InputType::kPistol, {}),
         superstructure_goal_sender_(
             event_loop->MakeSender<superstructure::Goal>("/superstructure")),
+        target_selector_hint_sender_(
+            event_loop->MakeSender<TargetSelectorHint>("/drivetrain")),
         superstructure_status_fetcher_(
             event_loop->MakeFetcher<superstructure::Status>(
                 "/superstructure")) {}
@@ -245,6 +274,8 @@
     }
 
     const Side current_side = data.IsPressed(kBack) ? Side::BACK : Side::FRONT;
+    std::optional<RowSelectionHint> placing_row;
+    std::optional<SpotSelectionHint> placing_spot;
 
     // Search for the active setpoint.
     for (const ArmSetpoint &setpoint : setpoints) {
@@ -255,11 +286,14 @@
             wrist_goal = setpoint.wrist_goal;
             arm_goal_position_ = setpoint.index;
             score_wrist_goal = setpoint.score_wrist_goal;
+            placing_row = setpoint.row_hint;
+            placing_spot = setpoint.spot_hint;
             break;
           }
         }
       }
     }
+    CHECK_EQ(placing_row.has_value(), placing_spot.has_value());
 
     if (data.IsPressed(kSuck)) {
       roller_goal = RollerGoal::INTAKE_LAST;
@@ -297,10 +331,22 @@
         AOS_LOG(ERROR, "Sending superstructure goal failed.\n");
       }
     }
+    if (placing_row.has_value()) {
+      auto builder = target_selector_hint_sender_.MakeBuilder();
+      auto hint_builder = builder.MakeBuilder<TargetSelectorHint>();
+      hint_builder.add_row(placing_row.value());
+      hint_builder.add_spot(placing_spot.value());
+      // TODO: Add field to TargetSelector hint for forwards vs. backwards
+      // placement.
+      if (builder.Send(hint_builder.Finish()) != aos::RawSender::Error::kOk) {
+        AOS_LOG(ERROR, "Sending target selector hint failed.\n");
+      }
+    }
   }
 
  private:
   ::aos::Sender<superstructure::Goal> superstructure_goal_sender_;
+  ::aos::Sender<TargetSelectorHint> target_selector_hint_sender_;
 
   ::aos::Fetcher<superstructure::Status> superstructure_status_fetcher_;
 
diff --git a/y2023/wpilib_interface.cc b/y2023/wpilib_interface.cc
index a1a60ba..12a1779 100644
--- a/y2023/wpilib_interface.cc
+++ b/y2023/wpilib_interface.cc
@@ -463,7 +463,7 @@
       CopyPosition(roll_joint_encoder_, &roll_joint,
                    Values::kRollJointEncoderCountsPerRevolution(),
                    Values::kRollJointEncoderRatio(), roll_joint_pot_translate,
-                   true, values_->roll_joint.potentiometer_offset);
+                   false, values_->roll_joint.potentiometer_offset);
       frc971::AbsolutePositionT wrist;
       CopyPosition(wrist_encoder_, &wrist,
                    Values::kWristEncoderCountsPerRevolution(),
@@ -786,7 +786,9 @@
 
   void Stop() override {
     AOS_LOG(WARNING, "Superstructure CAN output too old.\n");
-    ctre::phoenixpro::controls::NeutralOut stop_command;
+    ctre::phoenixpro::controls::DutyCycleOut stop_command(0.0);
+    stop_command.UpdateFreqHz = 0_Hz;
+    stop_command.EnableFOC = true;
 
     roller_falcon_->talon()->SetControl(stop_command);
   }
@@ -893,7 +895,10 @@
 
   void Stop() override {
     AOS_LOG(WARNING, "drivetrain output too old\n");
-    ctre::phoenixpro::controls::NeutralOut stop_command;
+    ctre::phoenixpro::controls::DutyCycleOut stop_command(0.0);
+    stop_command.UpdateFreqHz = 0_Hz;
+    stop_command.EnableFOC = true;
+
     for (auto falcon :
          {right_front_.get(), right_back_.get(), right_under_.get(),
           left_front_.get(), left_back_.get(), left_under_.get()}) {
diff --git a/y2023/y2023_logger.json b/y2023/y2023_logger.json
index 387915d..641b1da 100644
--- a/y2023/y2023_logger.json
+++ b/y2023/y2023_logger.json
@@ -493,7 +493,11 @@
       "name": "logger_camera_reader",
       "executable_name": "camera_reader",
       "user": "pi",
-      "args": ["--enable_ftrace", "--send_downsized_images"],
+      "args": [
+        "--enable_ftrace",
+        "--send_downsized_images",
+        "--exposure=650"
+      ],
       "nodes": [
         "logger"
       ]