Merge "Stop armv7 ci builds"
diff --git a/aos/configuration.cc b/aos/configuration.cc
index 1d5e60a..ad0a489 100644
--- a/aos/configuration.cc
+++ b/aos/configuration.cc
@@ -1355,8 +1355,10 @@
     case LoggerConfig::LOCAL_LOGGER:
       return channel->source_node()->string_view() == node_name;
     case LoggerConfig::LOCAL_AND_REMOTE_LOGGER:
-      CHECK(channel->has_logger_nodes());
-      CHECK_GT(channel->logger_nodes()->size(), 0u);
+      CHECK(channel->has_logger_nodes())
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
+      CHECK_GT(channel->logger_nodes()->size(), 0u)
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
 
       if (channel->source_node()->string_view() == node_name) {
         return true;
@@ -1364,8 +1366,10 @@
 
       [[fallthrough]];
     case LoggerConfig::REMOTE_LOGGER:
-      CHECK(channel->has_logger_nodes());
-      CHECK_GT(channel->logger_nodes()->size(), 0u);
+      CHECK(channel->has_logger_nodes())
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
+      CHECK_GT(channel->logger_nodes()->size(), 0u)
+          << "Missing logger nodes on " << StrippedChannelToString(channel);
       for (const flatbuffers::String *logger_node : *channel->logger_nodes()) {
         if (logger_node->string_view() == node_name) {
           return true;
diff --git a/aos/starter/BUILD b/aos/starter/BUILD
index 9b03907..8a30408 100644
--- a/aos/starter/BUILD
+++ b/aos/starter/BUILD
@@ -228,9 +228,6 @@
     srcs = [
         "irq_affinity.cc",
     ],
-    data = [
-        "//aos/starter:rockpi_config.json",
-    ],
     visibility = ["//visibility:public"],
     deps = [
         ":irq_affinity_lib",
diff --git a/aos/util/error_counter.h b/aos/util/error_counter.h
index cf6cb7f..3e69a71 100644
--- a/aos/util/error_counter.h
+++ b/aos/util/error_counter.h
@@ -67,5 +67,40 @@
  private:
   flatbuffers::Vector<flatbuffers::Offset<Count>> *vector_ = nullptr;
 };
+
+// The ArrayErrorCounter serves the same purpose as the ErrorCounter class,
+// except that:
+// (a) It owns its own memory, rather than modifying a flatbuffer in-place.
+// (b) Because of this, the user has greater flexibility in choosing when to
+//     reset the error counters.
+template <typename Error, typename Count>
+class ArrayErrorCounter {
+ public:
+  static constexpr size_t kNumErrors = ErrorCounter<Error, Count>::kNumErrors;
+  ArrayErrorCounter() { ResetCounts(); }
+
+  flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Count>>>
+  PopulateCounts(flatbuffers::FlatBufferBuilder *fbb) const {
+    const flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Count>>>
+        offset = ErrorCounter<Error, Count>::Initialize(fbb);
+    flatbuffers::Vector<flatbuffers::Offset<Count>> *vector =
+        flatbuffers::GetMutableTemporaryPointer(*fbb, offset);
+    for (size_t ii = 0; ii < kNumErrors; ++ii) {
+      vector->GetMutableObject(ii)->mutate_count(error_counts_.at(ii));
+    }
+    return offset;
+  }
+
+  void IncrementError(Error error) {
+    DCHECK_LT(static_cast<size_t>(error), error_counts_.size());
+    error_counts_.at(static_cast<size_t>(error))++;
+  }
+
+  // Sets all the error counts to zero.
+  void ResetCounts() { error_counts_.fill(0); }
+
+ private:
+  std::array<size_t, kNumErrors> error_counts_;
+};
 }  // namespace aos::util
 #endif  // AOS_UTIL_ERROR_COUNTER_H_
diff --git a/aos/util/error_counter_test.cc b/aos/util/error_counter_test.cc
index 2166cea..567d71d 100644
--- a/aos/util/error_counter_test.cc
+++ b/aos/util/error_counter_test.cc
@@ -34,4 +34,45 @@
   EXPECT_EQ(0u, message.message().error_counts()->Get(0)->count());
   EXPECT_EQ(0u, message.message().error_counts()->Get(1)->count());
 }
+
+// Tests the ArrayErrorCounter
+TEST(ErrorCounterTest, ARrayErrorCounter) {
+  ArrayErrorCounter<aos::timing::SendError, aos::timing::SendErrorCount>
+      counter;
+  flatbuffers::FlatBufferBuilder fbb;
+  fbb.ForceDefaults(true);
+  counter.IncrementError(aos::timing::SendError::MESSAGE_SENT_TOO_FAST);
+  counter.IncrementError(aos::timing::SendError::MESSAGE_SENT_TOO_FAST);
+  counter.IncrementError(aos::timing::SendError::INVALID_REDZONE);
+  {
+    const flatbuffers::Offset<
+        flatbuffers::Vector<flatbuffers::Offset<aos::timing::SendErrorCount>>>
+        counts_offset = counter.PopulateCounts(&fbb);
+    aos::timing::Sender::Builder builder(fbb);
+    builder.add_error_counts(counts_offset);
+    fbb.Finish(builder.Finish());
+    aos::FlatbufferDetachedBuffer<aos::timing::Sender> message = fbb.Release();
+    ASSERT_EQ(2u, message.message().error_counts()->size());
+    EXPECT_EQ(aos::timing::SendError::MESSAGE_SENT_TOO_FAST,
+              message.message().error_counts()->Get(0)->error());
+    EXPECT_EQ(2u, message.message().error_counts()->Get(0)->count());
+    EXPECT_EQ(aos::timing::SendError::INVALID_REDZONE,
+              message.message().error_counts()->Get(1)->error());
+    EXPECT_EQ(1u, message.message().error_counts()->Get(1)->count());
+  }
+
+  counter.ResetCounts();
+  {
+    const flatbuffers::Offset<
+        flatbuffers::Vector<flatbuffers::Offset<aos::timing::SendErrorCount>>>
+        counts_offset = counter.PopulateCounts(&fbb);
+    aos::timing::Sender::Builder builder(fbb);
+    builder.add_error_counts(counts_offset);
+    fbb.Finish(builder.Finish());
+    aos::FlatbufferDetachedBuffer<aos::timing::Sender> message = fbb.Release();
+    ASSERT_EQ(2u, message.message().error_counts()->size());
+    EXPECT_EQ(0u, message.message().error_counts()->Get(0)->count());
+    EXPECT_EQ(0u, message.message().error_counts()->Get(1)->count());
+  }
+}
 }  // namespace aos::util::testing
diff --git a/frc971/analysis/foxglove.md b/frc971/analysis/foxglove.md
new file mode 100644
index 0000000..8276584
--- /dev/null
+++ b/frc971/analysis/foxglove.md
@@ -0,0 +1,36 @@
+We have some support for using [Foxglove Studio](https://studio.foxglove.dev)
+for visualizing robot data.
+
+# Accessing Foxglove
+
+You have three main options for using foxglove studio:
+1. Go to https://studio.foxglove.dev and use the most up-to-date studio. This
+   is convenient; it won't work when you do not have Internet access, and
+   has some limitations when it comes to accessing unsecured websockets.
+2. Download the foxglove desktop application.
+3. Run our local copy by running `bazel run //frc971/analysis:local_foxglove`
+   This will work offline, and serves foxglove at http://localhost:8000 by
+   default.
+
+# Log Visualization
+
+If looking at data from a log, you will first need to convert one of our AOS
+logs to MCAP so that it can be viewed in foxglove. In order to do so,
+run `bazel run -c opt //aos/util:log_to_mcap -- /path/to/log --output_path /tmp/log.mcap`.
+This will create an MCAP file at the specified path, which you can then open
+in any of the various foxglove options.
+
+# Live Visualization
+
+On the pis, we run a `foxglove_websocket` application by default. This exposes
+a websocket on the 8765 port. How you connect to this varies depending on
+what method you are using to create a foxglove instance.
+
+If using https://studio.foxglove.dev, you cannot directly access
+ws://10.9.71.10X:8765 due to security constraints. Instead, you will have to
+port forward by doing something like `ssh -L 8765:localhost:8765 pi@10.9.71.101`
+to expose the port locally, and then use the `ws://localhost:8765` websocket.
+
+If using the local foxglove, you can just use the pi IP address directly.
+
+I have not tried using the desktop Foxglove application for this.
diff --git a/frc971/control_loops/drivetrain/BUILD b/frc971/control_loops/drivetrain/BUILD
index 7e0500e..86c1ec0 100644
--- a/frc971/control_loops/drivetrain/BUILD
+++ b/frc971/control_loops/drivetrain/BUILD
@@ -831,17 +831,3 @@
         "//aos/network/www:proxy",
     ],
 )
-
-cc_library(
-    name = "localization_utils",
-    srcs = ["localization_utils.cc"],
-    hdrs = ["localization_utils.h"],
-    deps = [
-        ":drivetrain_output_fbs",
-        "//aos/events:event_loop",
-        "//aos/network:message_bridge_server_fbs",
-        "//frc971/input:joystick_state_fbs",
-        "//frc971/vision:calibration_fbs",
-        "@org_tuxfamily_eigen//:eigen",
-    ],
-)
diff --git a/frc971/control_loops/drivetrain/hybrid_ekf.h b/frc971/control_loops/drivetrain/hybrid_ekf.h
index 89b2182..ce4e1b9 100644
--- a/frc971/control_loops/drivetrain/hybrid_ekf.h
+++ b/frc971/control_loops/drivetrain/hybrid_ekf.h
@@ -322,8 +322,6 @@
 
   // Set the initial guess of the state. Can only be called once, and before
   // any measurement updates have occured.
-  // TODO(james): We may want to actually re-initialize and reset things on
-  // the field. Create some sort of Reset() function.
   void ResetInitialState(::aos::monotonic_clock::time_point t,
                          const State &state, const StateSquare &P) {
     observations_.clear();
diff --git a/frc971/control_loops/drivetrain/localization/BUILD b/frc971/control_loops/drivetrain/localization/BUILD
new file mode 100644
index 0000000..06c8d57
--- /dev/null
+++ b/frc971/control_loops/drivetrain/localization/BUILD
@@ -0,0 +1,66 @@
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
+
+cc_library(
+    name = "utils",
+    srcs = ["utils.cc"],
+    hdrs = ["utils.h"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//aos/events:event_loop",
+        "//aos/network:message_bridge_server_fbs",
+        "//frc971/control_loops/drivetrain:drivetrain_output_fbs",
+        "//frc971/input:joystick_state_fbs",
+        "//frc971/vision:calibration_fbs",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_library(
+    name = "puppet_localizer",
+    srcs = ["puppet_localizer.cc"],
+    hdrs = ["puppet_localizer.h"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//aos/events:event_loop",
+        "//aos/network:message_bridge_server_fbs",
+        "//frc971/control_loops/drivetrain:hybrid_ekf",
+        "//frc971/control_loops/drivetrain:localizer",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+    ],
+)
+
+cc_test(
+    name = "puppet_localizer_test",
+    srcs = ["puppet_localizer_test.cc"],
+    data = ["//y2022/control_loops/drivetrain:simulation_config"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":puppet_localizer",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_writer",
+        "//aos/network:team_number",
+        "//frc971/control_loops:control_loop_test",
+        "//frc971/control_loops:team_number_test_environment",
+        "//frc971/control_loops/drivetrain:drivetrain_lib",
+        "//frc971/control_loops/drivetrain:drivetrain_test_lib",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//y2022/control_loops/drivetrain:drivetrain_base",
+    ],
+)
+
+flatbuffer_cc_library(
+    name = "localizer_output_fbs",
+    srcs = [
+        "localizer_output.fbs",
+    ],
+    gen_reflections = True,
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+)
+
+flatbuffer_ts_library(
+    name = "localizer_output_ts_fbs",
+    srcs = ["localizer_output.fbs"],
+    visibility = ["//visibility:public"],
+)
diff --git a/y2022/localizer/localizer_output.fbs b/frc971/control_loops/drivetrain/localization/localizer_output.fbs
similarity index 100%
rename from y2022/localizer/localizer_output.fbs
rename to frc971/control_loops/drivetrain/localization/localizer_output.fbs
diff --git a/y2022/control_loops/drivetrain/localizer.cc b/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
similarity index 82%
rename from y2022/control_loops/drivetrain/localizer.cc
rename to frc971/control_loops/drivetrain/localization/puppet_localizer.cc
index 0aa4456..3125793 100644
--- a/y2022/control_loops/drivetrain/localizer.cc
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer.cc
@@ -1,10 +1,10 @@
-#include "y2022/control_loops/drivetrain/localizer.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 
-namespace y2022 {
+namespace frc971 {
 namespace control_loops {
 namespace drivetrain {
 
-Localizer::Localizer(
+PuppetLocalizer::PuppetLocalizer(
     aos::EventLoop *event_loop,
     const frc971::control_loops::drivetrain::DrivetrainConfig<double>
         &dt_config)
@@ -15,8 +15,6 @@
       localizer_output_fetcher_(
           event_loop_->MakeFetcher<frc971::controls::LocalizerOutput>(
               "/localizer")),
-      joystick_state_fetcher_(
-          event_loop_->MakeFetcher<aos::JoystickState>("/aos")),
       clock_offset_fetcher_(
           event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
               "/aos")) {
@@ -30,7 +28,7 @@
   target_selector_.set_has_target(false);
 }
 
-void Localizer::Reset(
+void PuppetLocalizer::Reset(
     aos::monotonic_clock::time_point t,
     const frc971::control_loops::drivetrain::HybridEkf<double>::State &state) {
   // Go through and clear out all of the fetchers so that we don't get behind.
@@ -38,19 +36,12 @@
   ekf_.ResetInitialState(t, state.cast<float>(), ekf_.P());
 }
 
-void Localizer::Update(const Eigen::Matrix<double, 2, 1> &U,
+void PuppetLocalizer::Update(const Eigen::Matrix<double, 2, 1> &U,
                        aos::monotonic_clock::time_point now,
                        double left_encoder, double right_encoder,
                        double gyro_rate, const Eigen::Vector3d &accel) {
   ekf_.UpdateEncodersAndGyro(left_encoder, right_encoder, gyro_rate,
                              U.cast<float>(), accel.cast<float>(), now);
-  joystick_state_fetcher_.Fetch();
-  if (joystick_state_fetcher_.get() != nullptr &&
-      joystick_state_fetcher_->autonomous()) {
-    // TODO(james): This is an inelegant way to avoid having the localizer mess
-    // up splines. Do better.
-    // return;
-  }
   if (localizer_output_fetcher_.Fetch()) {
     clock_offset_fetcher_.Fetch();
     bool message_bridge_connected = true;
@@ -79,8 +70,6 @@
         std::chrono::nanoseconds(
             localizer_output_fetcher_->monotonic_timestamp_ns()) -
         monotonic_offset);
-    // TODO: Finish implementing simple x/y/theta updater with state_at_capture.
-    // TODO: Implement turret/camera processing logic on pi side.
     std::optional<State> state_at_capture =
         ekf_.LastStateBeforeTime(capture_time);
     if (!state_at_capture.has_value()) {
@@ -104,4 +93,4 @@
 
 }  // namespace drivetrain
 }  // namespace control_loops
-}  // namespace y2022
+}  // namespace frc971
diff --git a/y2022/control_loops/drivetrain/localizer.h b/frc971/control_loops/drivetrain/localization/puppet_localizer.h
similarity index 78%
rename from y2022/control_loops/drivetrain/localizer.h
rename to frc971/control_loops/drivetrain/localization/puppet_localizer.h
index 77b29eb..4f8f4f3 100644
--- a/y2022/control_loops/drivetrain/localizer.h
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer.h
@@ -1,5 +1,5 @@
-#ifndef Y2022_CONTROL_LOOPS_DRIVETRAIN_LOCALIZER_H_
-#define Y2022_CONTROL_LOOPS_DRIVETRAIN_LOCALIZER_H_
+#ifndef FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_PUPPET_LOCALIZER_H_
+#define FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_PUPPET_LOCALIZER_H_
 
 #include <string_view>
 
@@ -7,20 +7,20 @@
 #include "aos/network/message_bridge_server_generated.h"
 #include "frc971/control_loops/drivetrain/hybrid_ekf.h"
 #include "frc971/control_loops/drivetrain/localizer.h"
-#include "frc971/input/joystick_state_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
 
-namespace y2022 {
+namespace frc971 {
 namespace control_loops {
 namespace drivetrain {
 
-// This class handles the localization for the 2022 robot. Rather than actually
-// doing any work on the roborio, we farm all the localization out to a
+// This class handles the localization for the 2022/2023 robots. Rather than
+// actually doing any work on the roborio, we farm all the localization out to a
 // raspberry pi and it then sends out LocalizerOutput messages that we treat as
-// measurement updates. See //y2022/localizer.
-// TODO(james): Needs tests. Should refactor out some of the code from the 2020
-// localizer test.
-class Localizer : public frc971::control_loops::drivetrain::LocalizerInterface {
+// measurement updates. See //y202*/localizer.
+// TODO(james): Needs more tests. Should refactor out some of the code from the
+// 2020 localizer test.
+class PuppetLocalizer
+    : public frc971::control_loops::drivetrain::LocalizerInterface {
  public:
   typedef frc971::control_loops::TypedPose<float> Pose;
   typedef frc971::control_loops::drivetrain::HybridEkf<float> HybridEkf;
@@ -29,9 +29,10 @@
   typedef typename HybridEkf::StateSquare StateSquare;
   typedef typename HybridEkf::Input Input;
   typedef typename HybridEkf::Output Output;
-  Localizer(aos::EventLoop *event_loop,
-            const frc971::control_loops::drivetrain::DrivetrainConfig<double>
-                &dt_config);
+  PuppetLocalizer(
+      aos::EventLoop *event_loop,
+      const frc971::control_loops::drivetrain::DrivetrainConfig<double>
+          &dt_config);
   frc971::control_loops::drivetrain::HybridEkf<double>::State Xhat()
       const override {
     return ekf_.X_hat().cast<double>();
@@ -93,7 +94,6 @@
   HybridEkf::ExpectedObservationAllocator<Corrector> observations_;
 
   aos::Fetcher<frc971::controls::LocalizerOutput> localizer_output_fetcher_;
-  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
   aos::Fetcher<aos::message_bridge::ServerStatistics> clock_offset_fetcher_;
 
   // Target selector to allow us to satisfy the LocalizerInterface requirements.
@@ -102,6 +102,6 @@
 
 }  // namespace drivetrain
 }  // namespace control_loops
-}  // namespace y2022
+}  // namespace frc971
 
-#endif  // Y2022_CONTROL_LOOPS_DRIVETRAIN_LOCALIZER_H_
+#endif  // FRC971_CONTROL_LOOPS_DRIVETRAIN_LOCALIZATION_PUPPET_LOCALIZER_H_
diff --git a/y2022/control_loops/drivetrain/localizer_test.cc b/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
similarity index 92%
rename from y2022/control_loops/drivetrain/localizer_test.cc
rename to frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
index 77f3988..d64c419 100644
--- a/y2022/control_loops/drivetrain/localizer_test.cc
+++ b/frc971/control_loops/drivetrain/localization/puppet_localizer_test.cc
@@ -1,4 +1,4 @@
-#include "y2022/control_loops/drivetrain/localizer.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 
 #include <queue>
 
@@ -8,17 +8,17 @@
 #include "aos/network/testing_time_converter.h"
 #include "frc971/control_loops/control_loop_test.h"
 #include "frc971/control_loops/drivetrain/drivetrain.h"
-#include "frc971/control_loops/drivetrain/drivetrain_test_lib.h"
 #include "frc971/control_loops/team_number_test_environment.h"
 #include "gtest/gtest.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/drivetrain_test_lib.h"
 #include "y2022/control_loops/drivetrain/drivetrain_base.h"
-#include "y2022/localizer/localizer_output_generated.h"
 
 DEFINE_string(output_folder, "",
               "If set, logs all channels to the provided logfile.");
 DECLARE_bool(die_on_malloc);
 
-namespace y2022 {
+namespace frc971 {
 namespace control_loops {
 namespace drivetrain {
 namespace testing {
@@ -29,7 +29,8 @@
 
 namespace {
 DrivetrainConfig<double> GetTest2022DrivetrainConfig() {
-  DrivetrainConfig<double> config = GetDrivetrainConfig();
+  DrivetrainConfig<double> config =
+      y2022::control_loops::drivetrain::GetDrivetrainConfig();
   return config;
 }
 }  // namespace
@@ -39,11 +40,13 @@
 using frc971::control_loops::drivetrain::DrivetrainLoop;
 using frc971::control_loops::drivetrain::testing::DrivetrainSimulation;
 
+
 // TODO(james): Make it so this actually tests the full system of the localizer.
 class LocalizedDrivetrainTest : public frc971::testing::ControlLoopTest {
  protected:
-  // We must use the 2022 drivetrain config so that we don't have to deal
-  // with shifting:
+  // We must use the 2022 drivetrain config so that we actually have a multi-nde
+  // config with a LocalizerOutput message.
+  // TODO(james): Refactor this test to be year-agnostic.
   LocalizedDrivetrainTest()
       : frc971::testing::ControlLoopTest(
             aos::configuration::ReadConfig(
@@ -108,7 +111,8 @@
   void SetStartingPosition(const Eigen::Matrix<double, 3, 1> &xytheta) {
     *drivetrain_plant_.mutable_state() << xytheta.x(), xytheta.y(),
         xytheta(2, 0), 0.0, 0.0;
-    Eigen::Matrix<double, Localizer::HybridEkf::kNStates, 1> localizer_state;
+    Eigen::Matrix<double, PuppetLocalizer::HybridEkf::kNStates, 1>
+        localizer_state;
     localizer_state.setZero();
     localizer_state.block<3, 1>(0, 0) = xytheta;
     localizer_.Reset(monotonic_now(), localizer_state);
@@ -169,7 +173,7 @@
   std::unique_ptr<aos::EventLoop> drivetrain_event_loop_;
   const frc971::control_loops::drivetrain::DrivetrainConfig<double> dt_config_;
 
-  Localizer localizer_;
+  PuppetLocalizer localizer_;
   DrivetrainLoop drivetrain_;
 
   std::unique_ptr<aos::EventLoop> drivetrain_plant_event_loop_;
@@ -208,4 +212,4 @@
 }  // namespace testing
 }  // namespace drivetrain
 }  // namespace control_loops
-}  // namespace y2022
+}  // namespace frc971
diff --git a/frc971/control_loops/drivetrain/localization_utils.cc b/frc971/control_loops/drivetrain/localization/utils.cc
similarity index 97%
rename from frc971/control_loops/drivetrain/localization_utils.cc
rename to frc971/control_loops/drivetrain/localization/utils.cc
index c7968e1..ff027d0 100644
--- a/frc971/control_loops/drivetrain/localization_utils.cc
+++ b/frc971/control_loops/drivetrain/localization/utils.cc
@@ -1,4 +1,4 @@
-#include "frc971/control_loops/drivetrain/localization_utils.h"
+#include "frc971/control_loops/drivetrain/localization/utils.h"
 
 namespace frc971::control_loops::drivetrain {
 
diff --git a/frc971/control_loops/drivetrain/localization_utils.h b/frc971/control_loops/drivetrain/localization/utils.h
similarity index 100%
rename from frc971/control_loops/drivetrain/localization_utils.h
rename to frc971/control_loops/drivetrain/localization/utils.h
diff --git a/frc971/imu_reader/imu_watcher.cc b/frc971/imu_reader/imu_watcher.cc
index 0e0d656..ed9c65f 100644
--- a/frc971/imu_reader/imu_watcher.cc
+++ b/frc971/imu_reader/imu_watcher.cc
@@ -28,8 +28,7 @@
       right_encoder_(
           -EncoderWrapDistance(drivetrain_distance_per_encoder_tick) / 2.0,
           EncoderWrapDistance(drivetrain_distance_per_encoder_tick)) {
-  event_loop->MakeWatcher("/localizer", [this, event_loop](
-                                            const IMUValuesBatch &values) {
+  event_loop->MakeWatcher("/localizer", [this](const IMUValuesBatch &values) {
     CHECK(values.has_readings());
     for (const IMUValues *value : *values.readings()) {
       zeroer_.InsertAndProcessMeasurement(*value);
@@ -79,11 +78,13 @@
                        : aos::monotonic_clock::epoch())
                 : aos::monotonic_clock::time_point(
                       std::chrono::microseconds(value->pico_timestamp_us()));
+        const aos::monotonic_clock::time_point pi_read_timestamp =
+            aos::monotonic_clock::time_point(
+                std::chrono::nanoseconds(value->monotonic_timestamp_ns()));
         // TODO(james): If we get large enough drift off of the pico,
         // actually do something about it.
         if (!pico_offset_.has_value()) {
-          pico_offset_ =
-              event_loop->context().monotonic_event_time - pico_timestamp;
+          pico_offset_ = pi_read_timestamp - pico_timestamp;
           last_pico_timestamp_ = pico_timestamp;
         }
         if (pico_timestamp < last_pico_timestamp_) {
@@ -91,17 +92,14 @@
         }
         const aos::monotonic_clock::time_point sample_timestamp =
             pico_offset_.value() + pico_timestamp;
-        pico_offset_error_ =
-            event_loop->context().monotonic_event_time - sample_timestamp;
+        pico_offset_error_ = pi_read_timestamp - sample_timestamp;
         const bool zeroed = zeroer_.Zeroed();
 
         // When not zeroed, we aim to approximate zero acceleration by doing a
         // zero-order hold on the gyro and setting the accelerometer readings to
         // gravity.
-        callback_(sample_timestamp,
-                  aos::monotonic_clock::time_point(std::chrono::nanoseconds(
-                      value->monotonic_timestamp_ns())),
-                  encoders, zeroed ? zeroer_.ZeroedGyro().value() : last_gyro_,
+        callback_(sample_timestamp, pi_read_timestamp, encoders,
+                  zeroed ? zeroer_.ZeroedGyro().value() : last_gyro_,
                   zeroed ? zeroer_.ZeroedAccel().value()
                          : dt_config_.imu_transform.transpose() *
                                Eigen::Vector3d::UnitZ());
diff --git a/frc971/rockpi/BUILD b/frc971/rockpi/BUILD
new file mode 100644
index 0000000..f6d1840
--- /dev/null
+++ b/frc971/rockpi/BUILD
@@ -0,0 +1 @@
+exports_files(["rockpi_config.json"])
diff --git a/frc971/rockpi/contents/root/bin/change_hostname.sh b/frc971/rockpi/contents/root/bin/change_hostname.sh
index a784bb1..43e6528 100755
--- a/frc971/rockpi/contents/root/bin/change_hostname.sh
+++ b/frc971/rockpi/contents/root/bin/change_hostname.sh
@@ -40,23 +40,10 @@
   done
 fi
 
-# Put corret team number in roborio's address, or add it if missing
+# Put correct team number in roborio's address, or add it if missing
 if grep '^10\.[0-9]*\.[0-9]*\.2\s*roborio$' /etc/hosts >/dev/null;
 then
   sed -i "s/^10\.[0-9]*\.[0-9]*\(\.2\s*roborio\)$/${IP_BASE}\1/" /etc/hosts
 else
   echo -e "${IP_BASE}.2\troborio" >> /etc/hosts
 fi
-
-# Put corret team number in imu's address, or add it if missing
-if grep '^10\.[0-9]*\.[0-9]*\.105\s.*\s*imu$' /etc/hosts >/dev/null;
-then
-  sed -i "s/^10\.[0-9]*\.[0-9]*\(\.[0-9]*\s*pi-\)[0-9]*\(-[0-9] pi5 imu\)$/${IP_BASE}\1${TEAM_NUMBER}\2/" /etc/hosts
-else
-  if grep '^10\.[0-9]*\.[0-9]*\.105\s*pi-[0-9]*-[0-9]*\s*pi5$' /etc/hosts
-  then
-    sed -i "s/^10\.[0-9]*\.[0-9]*\(\.[0-9]*\s*pi-\)[0-9]*\(-[0-9] pi5\)$/${IP_BASE}\1${TEAM_NUMBER}\2 imu/" /etc/hosts
-  else
-    echo -e "${IP_BASE}.105\tpi-${TEAM_NUMBER}-5 pi5 imu" >> /etc/hosts
-  fi
-fi
diff --git a/aos/starter/rockpi_config.json b/frc971/rockpi/rockpi_config.json
similarity index 100%
rename from aos/starter/rockpi_config.json
rename to frc971/rockpi/rockpi_config.json
diff --git a/frc971/vision/calibration_accumulator.cc b/frc971/vision/calibration_accumulator.cc
index 711ed7d..f01af3c 100644
--- a/frc971/vision/calibration_accumulator.cc
+++ b/frc971/vision/calibration_accumulator.cc
@@ -8,11 +8,11 @@
 #include "aos/events/simulated_event_loop.h"
 #include "aos/network/team_number.h"
 #include "aos/time/time.h"
+#include "external/com_github_foxglove_schemas/CompressedImage_schema.h"
+#include "external/com_github_foxglove_schemas/ImageAnnotations_schema.h"
 #include "frc971/control_loops/quaternion_utils.h"
 #include "frc971/vision/charuco_lib.h"
 #include "frc971/wpilib/imu_batch_generated.h"
-#include "external/com_github_foxglove_schemas/ImageAnnotations_schema.h"
-#include "external/com_github_foxglove_schemas/CompressedImage_schema.h"
 
 DEFINE_bool(display_undistorted, false,
             "If true, display the undistorted image.");
@@ -116,10 +116,10 @@
 CalibrationFoxgloveVisualizer::CalibrationFoxgloveVisualizer(
     aos::EventLoop *event_loop)
     : event_loop_(event_loop),
-      image_converter_(event_loop_, "/camera", "/visualization",
+      image_converter_(event_loop_, "/camera", "/camera",
                        ImageCompression::kJpeg),
       annotations_sender_(
-          event_loop_->MakeSender<foxglove::ImageAnnotations>("/visualization")) {}
+          event_loop_->MakeSender<foxglove::ImageAnnotations>("/camera")) {}
 
 aos::FlatbufferDetachedBuffer<aos::Configuration>
 CalibrationFoxgloveVisualizer::AddVisualizationChannels(
diff --git a/frc971/vision/calibration_accumulator.h b/frc971/vision/calibration_accumulator.h
index 93d68a5..4b59406 100644
--- a/frc971/vision/calibration_accumulator.h
+++ b/frc971/vision/calibration_accumulator.h
@@ -88,8 +88,8 @@
   void HandleCharuco(const aos::monotonic_clock::time_point eof,
                      std::vector<std::vector<cv::Point2f>> charuco_corners) {
     auto builder = annotations_sender_.MakeBuilder();
-    builder.CheckOk(
-        builder.Send(BuildAnnotations(eof, charuco_corners, builder.fbb())));
+    builder.CheckOk(builder.Send(
+        BuildAnnotations(eof, charuco_corners, 2.0, builder.fbb())));
   }
 
  private:
diff --git a/frc971/vision/charuco_lib.cc b/frc971/vision/charuco_lib.cc
index dbff4db..2f9e81a 100644
--- a/frc971/vision/charuco_lib.cc
+++ b/frc971/vision/charuco_lib.cc
@@ -457,14 +457,15 @@
 
 flatbuffers::Offset<foxglove::ImageAnnotations> BuildAnnotations(
     const aos::monotonic_clock::time_point monotonic_now,
-    const std::vector<std::vector<cv::Point2f>> &corners,
+    const std::vector<std::vector<cv::Point2f>> &corners, double thickness,
     flatbuffers::FlatBufferBuilder *fbb) {
   std::vector<flatbuffers::Offset<foxglove::PointsAnnotation>> rectangles;
   const struct timespec now_t = aos::time::to_timespec(monotonic_now);
   foxglove::Time time{static_cast<uint32_t>(now_t.tv_sec),
                       static_cast<uint32_t>(now_t.tv_nsec)};
+  // Draw the points in pink
   const flatbuffers::Offset<foxglove::Color> color_offset =
-      foxglove::CreateColor(*fbb, 0.0, 1.0, 0.0, 1.0);
+      foxglove::CreateColor(*fbb, 1.0, 0.75, 0.8, 1.0);
   for (const std::vector<cv::Point2f> &rectangle : corners) {
     std::vector<flatbuffers::Offset<foxglove::Point2>> points_offsets;
     for (const cv::Point2f &point : rectangle) {
@@ -482,7 +483,7 @@
     points_builder.add_points(points_offset);
     points_builder.add_outline_color(color_offset);
     points_builder.add_outline_colors(colors_offset);
-    points_builder.add_thickness(2.0);
+    points_builder.add_thickness(thickness);
     rectangles.push_back(points_builder.Finish());
   }
 
diff --git a/frc971/vision/charuco_lib.h b/frc971/vision/charuco_lib.h
index 40b1601..f1846b5 100644
--- a/frc971/vision/charuco_lib.h
+++ b/frc971/vision/charuco_lib.h
@@ -175,7 +175,7 @@
 // visualization purposes.
 flatbuffers::Offset<foxglove::ImageAnnotations> BuildAnnotations(
     const aos::monotonic_clock::time_point monotonic_now,
-    const std::vector<std::vector<cv::Point2f>> &corners,
+    const std::vector<std::vector<cv::Point2f>> &corners, double thickness,
     flatbuffers::FlatBufferBuilder *fbb);
 
 }  // namespace vision
diff --git a/frc971/vision/extrinsics_calibration.cc b/frc971/vision/extrinsics_calibration.cc
index d563fb0..daa371e 100644
--- a/frc971/vision/extrinsics_calibration.cc
+++ b/frc971/vision/extrinsics_calibration.cc
@@ -488,38 +488,6 @@
     vis_robot.SetCameraParameters(camera_mat);
     vis_robot.SetDistortionCoefficients(dist_coeffs);
 
-    /*
-    // Draw an initial visualization
-    Eigen::Vector3d T_world_imu_vec =
-        calibration_parameters.initial_state.block<3, 1>(0, 0);
-    Eigen::Translation3d T_world_imu(T_world_imu_vec);
-    Eigen::Affine3d H_world_imu =
-        T_world_imu * calibration_parameters.initial_orientation;
-
-    vis_robot.DrawFrameAxes(H_world_imu, "imu");
-
-    Eigen::Quaterniond R_imu_pivot(calibration_parameters.pivot_to_imu);
-    Eigen::Translation3d T_imu_pivot(
-        calibration_parameters.pivot_to_imu_translation);
-    Eigen::Affine3d H_imu_pivot = T_imu_pivot * R_imu_pivot;
-    Eigen::Affine3d H_world_pivot = H_world_imu * H_imu_pivot;
-    vis_robot.DrawFrameAxes(H_world_pivot, "pivot");
-
-    Eigen::Affine3d H_imupivot_camerapivot(
-        Eigen::AngleAxisd(1.5, Eigen::Vector3d::UnitZ()));
-    Eigen::Quaterniond R_camera_pivot(calibration_parameters.pivot_to_camera);
-    Eigen::Translation3d T_camera_pivot(
-        calibration_parameters.pivot_to_camera_translation);
-    Eigen::Affine3d H_camera_pivot = T_camera_pivot * R_camera_pivot;
-    Eigen::Affine3d H_world_camera = H_world_imu * H_imu_pivot *
-                                     H_imupivot_camerapivot *
-                                     H_camera_pivot.inverse();
-    vis_robot.DrawFrameAxes(H_world_camera, "camera");
-
-    cv::imshow("Original poses", image_mat);
-    cv::waitKey();
-    */
-
     uint current_state_index = 0;
     uint current_turret_index = 0;
     for (uint i = 0; i < camera_times_.size() - 1; i++) {
@@ -623,11 +591,6 @@
 
       cv::imshow("Live", image_mat);
       cv::waitKey(50);
-
-      if (i % 200 == 0) {
-        LOG(INFO) << "Pausing at step " << i;
-        cv::waitKey();
-      }
     }
     LOG(INFO) << "Finished visualizing robot.  Press any key to continue";
     cv::waitKey();
@@ -849,11 +812,11 @@
           trans_error_scale * filter.errorpz(i);
     }
 
-    LOG(INFO) << "Cost function calc took "
-              << chrono::duration<double>(aos::monotonic_clock::now() -
-                                          start_time)
-                     .count()
-              << " seconds";
+    VLOG(2) << "Cost function calc took "
+            << chrono::duration<double>(aos::monotonic_clock::now() -
+                                        start_time)
+                   .count()
+            << " seconds";
 
     return true;
   }
@@ -898,7 +861,7 @@
         calibration_parameters->accelerometer_bias.data());
   }
 
-  {
+  if (calibration_parameters->has_pivot) {
     // The turret's Z rotation is redundant with the camera's mounting z
     // rotation since it's along the rotation axis.
     ceres::CostFunction *turret_z_cost_function =
@@ -922,6 +885,17 @@
         calibration_parameters->pivot_to_imu_translation.data());
   }
 
+  {
+    // The board rotation in z is a bit arbitrary, so hoping to limit it to
+    // increase repeatability
+    ceres::CostFunction *board_z_cost_function =
+        new ceres::AutoDiffCostFunction<PenalizeQuaternionZ, 1, 4>(
+            new PenalizeQuaternionZ());
+    problem.AddResidualBlock(
+        board_z_cost_function, nullptr,
+        calibration_parameters->board_to_world.coeffs().data());
+  }
+
   problem.SetParameterization(
       calibration_parameters->initial_orientation.coeffs().data(),
       quaternion_local_parameterization);
@@ -952,9 +926,9 @@
   // Run the solver!
   ceres::Solver::Options options;
   options.minimizer_progress_to_stdout = true;
-  options.gradient_tolerance = 1e-12;
+  options.gradient_tolerance = 1e-6;
   options.function_tolerance = 1e-6;
-  options.parameter_tolerance = 1e-12;
+  options.parameter_tolerance = 1e-6;
   ceres::Solver::Summary summary;
   Solve(options, &problem, &summary);
   LOG(INFO) << summary.FullReport();
diff --git a/frc971/vision/target_map.json b/frc971/vision/target_map.json
index 3f6eb54..8d971f3 100644
--- a/frc971/vision/target_map.json
+++ b/frc971/vision/target_map.json
@@ -7,12 +7,11 @@
                 "y": -2.938,
                 "z": 0.463
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -22,12 +21,11 @@
                 "y": -1.262,
                 "z": 0.463
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -37,12 +35,11 @@
                 "y": 0.414,
                 "z": 0.463
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -52,12 +49,11 @@
                 "y": 2.740,
                 "z": 0.695
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -68,11 +64,10 @@
                 "z": 0.695
             },
             "orientation": {
-            /* yaw of 0 */
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -82,12 +77,11 @@
                 "y": 0.414,
                 "z": 0.463
             },
-            /* yaw of 0 */
             "orientation": {
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -97,12 +91,11 @@
                 "y": -1.262,
                 "z": 0.463
             },
-            /* yaw of 0 */
             "orientation": {
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -112,12 +105,11 @@
                 "y": -2.938,
                 "z": 0.463
             },
-            /* yaw of 0 */
             "orientation": {
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         }
     ]
diff --git a/y2022/BUILD b/y2022/BUILD
index e978f7a..1a04c2e 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -102,7 +102,7 @@
             "//aos/network:timestamp_fbs",
             "//aos/network:remote_message_fbs",
             "//frc971/vision:vision_fbs",
-            "//y2022/localizer:localizer_output_fbs",
+            "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
             "//frc971/vision:calibration_fbs",
             "//y2022/vision:target_estimate_fbs",
             "//y2022/vision:ball_color_fbs",
@@ -132,7 +132,7 @@
         "//aos/network:timestamp_fbs",
         "//aos/network:remote_message_fbs",
         "//y2022/localizer:localizer_status_fbs",
-        "//y2022/localizer:localizer_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//y2022/localizer:localizer_visualization_fbs",
     ],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2022/control_loops/drivetrain/BUILD b/y2022/control_loops/drivetrain/BUILD
index 18855a6..148a998 100644
--- a/y2022/control_loops/drivetrain/BUILD
+++ b/y2022/control_loops/drivetrain/BUILD
@@ -71,19 +71,6 @@
     ],
 )
 
-cc_library(
-    name = "localizer",
-    srcs = ["localizer.cc"],
-    hdrs = ["localizer.h"],
-    deps = [
-        "//aos/events:event_loop",
-        "//aos/network:message_bridge_server_fbs",
-        "//frc971/control_loops/drivetrain:hybrid_ekf",
-        "//frc971/control_loops/drivetrain:localizer",
-        "//y2022/localizer:localizer_output_fbs",
-    ],
-)
-
 cc_binary(
     name = "drivetrain",
     srcs = [
@@ -93,10 +80,10 @@
     visibility = ["//visibility:public"],
     deps = [
         ":drivetrain_base",
-        ":localizer",
         "//aos:init",
         "//aos/events:shm_event_loop",
         "//frc971/control_loops/drivetrain:drivetrain_lib",
+        "//frc971/control_loops/drivetrain/localization:puppet_localizer",
     ],
 )
 
@@ -111,25 +98,6 @@
     ],
 )
 
-cc_test(
-    name = "localizer_test",
-    srcs = ["localizer_test.cc"],
-    data = [":simulation_config"],
-    target_compatible_with = ["@platforms//os:linux"],
-    deps = [
-        ":drivetrain_base",
-        ":localizer",
-        "//aos/events:simulated_event_loop",
-        "//aos/events/logging:log_writer",
-        "//aos/network:team_number",
-        "//frc971/control_loops:control_loop_test",
-        "//frc971/control_loops:team_number_test_environment",
-        "//frc971/control_loops/drivetrain:drivetrain_lib",
-        "//frc971/control_loops/drivetrain:drivetrain_test_lib",
-        "//y2022/localizer:localizer_output_fbs",
-    ],
-)
-
 cc_binary(
     name = "trajectory_generator",
     srcs = [
diff --git a/y2022/control_loops/drivetrain/drivetrain_main.cc b/y2022/control_loops/drivetrain/drivetrain_main.cc
index a422eaa..6e02cc7 100644
--- a/y2022/control_loops/drivetrain/drivetrain_main.cc
+++ b/y2022/control_loops/drivetrain/drivetrain_main.cc
@@ -3,8 +3,8 @@
 #include "aos/events/shm_event_loop.h"
 #include "aos/init.h"
 #include "frc971/control_loops/drivetrain/drivetrain.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 #include "y2022/control_loops/drivetrain/drivetrain_base.h"
-#include "y2022/control_loops/drivetrain/localizer.h"
 
 using ::frc971::control_loops::drivetrain::DrivetrainLoop;
 
@@ -15,10 +15,11 @@
       aos::configuration::ReadConfig("aos_config.json");
 
   aos::ShmEventLoop event_loop(&config.message());
-  std::unique_ptr<::y2022::control_loops::drivetrain::Localizer> localizer =
-      std::make_unique<y2022::control_loops::drivetrain::Localizer>(
-          &event_loop,
-          y2022::control_loops::drivetrain::GetDrivetrainConfig());
+  std::unique_ptr<::frc971::control_loops::drivetrain::PuppetLocalizer>
+      localizer =
+          std::make_unique<frc971::control_loops::drivetrain::PuppetLocalizer>(
+              &event_loop,
+              y2022::control_loops::drivetrain::GetDrivetrainConfig());
   std::unique_ptr<DrivetrainLoop> drivetrain = std::make_unique<DrivetrainLoop>(
       y2022::control_loops::drivetrain::GetDrivetrainConfig(), &event_loop,
       localizer.get());
diff --git a/y2022/control_loops/superstructure/BUILD b/y2022/control_loops/superstructure/BUILD
index a085049..e0db64c 100644
--- a/y2022/control_loops/superstructure/BUILD
+++ b/y2022/control_loops/superstructure/BUILD
@@ -193,10 +193,10 @@
         "//frc971/control_loops:control_loops_fbs",
         "//frc971/control_loops:profiled_subsystem_fbs",
         "//frc971/control_loops/drivetrain:drivetrain_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//frc971/queues:gyro_fbs",
         "//third_party:phoenix",
         "//third_party:wpilib",
-        "//y2022/localizer:localizer_output_fbs",
     ],
 )
 
diff --git a/y2022/control_loops/superstructure/led_indicator.h b/y2022/control_loops/superstructure/led_indicator.h
index c058254..71bc73b 100644
--- a/y2022/control_loops/superstructure/led_indicator.h
+++ b/y2022/control_loops/superstructure/led_indicator.h
@@ -8,11 +8,11 @@
 #include "frc971/control_loops/control_loop.h"
 #include "frc971/control_loops/control_loops_generated.h"
 #include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
 #include "frc971/control_loops/profiled_subsystem_generated.h"
 #include "frc971/queues/gyro_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_status_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
 
 namespace y2022::control_loops::superstructure {
 
diff --git a/y2022/localizer/BUILD b/y2022/localizer/BUILD
index a3b1774..dd0fb67 100644
--- a/y2022/localizer/BUILD
+++ b/y2022/localizer/BUILD
@@ -17,22 +17,6 @@
 )
 
 flatbuffer_cc_library(
-    name = "localizer_output_fbs",
-    srcs = [
-        "localizer_output.fbs",
-    ],
-    gen_reflections = True,
-    target_compatible_with = ["@platforms//os:linux"],
-    visibility = ["//visibility:public"],
-)
-
-flatbuffer_ts_library(
-    name = "localizer_output_ts_fbs",
-    srcs = ["localizer_output.fbs"],
-    visibility = ["//visibility:public"],
-)
-
-flatbuffer_cc_library(
     name = "localizer_status_fbs",
     srcs = [
         "localizer_status.fbs",
@@ -94,7 +78,6 @@
     hdrs = ["localizer.h"],
     visibility = ["//visibility:public"],
     deps = [
-        ":localizer_output_fbs",
         ":localizer_status_fbs",
         ":localizer_visualization_fbs",
         "//aos/containers:ring_buffer",
@@ -107,8 +90,9 @@
         "//frc971/control_loops/drivetrain:drivetrain_output_fbs",
         "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
         "//frc971/control_loops/drivetrain:improved_down_estimator",
-        "//frc971/control_loops/drivetrain:localization_utils",
         "//frc971/control_loops/drivetrain:localizer_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:utils",
         "//frc971/imu_reader:imu_watcher",
         "//frc971/input:joystick_state_fbs",
         "//frc971/vision:calibration_fbs",
diff --git a/y2022/localizer/localizer.cc b/y2022/localizer/localizer.cc
index 46eff94..ec81573 100644
--- a/y2022/localizer/localizer.cc
+++ b/y2022/localizer/localizer.cc
@@ -479,21 +479,6 @@
 }
 
 namespace {
-// Converts a flatbuffer TransformationMatrix to an Eigen matrix. Technically,
-// this should be able to do a single memcpy, but the extra verbosity here seems
-// appropriate.
-Eigen::Matrix<double, 4, 4> FlatbufferToTransformationMatrix(
-    const frc971::vision::calibration::TransformationMatrix &flatbuffer) {
-  CHECK_EQ(16u, CHECK_NOTNULL(flatbuffer.data())->size());
-  Eigen::Matrix<double, 4, 4> result;
-  result.setIdentity();
-  for (int row = 0; row < 4; ++row) {
-    for (int col = 0; col < 4; ++col) {
-      result(row, col) = (*flatbuffer.data())[row * 4 + col];
-    }
-  }
-  return result;
-}
 
 // Node names of the pis to listen for cameras from.
 constexpr std::array<std::string_view, ModelBasedLocalizer::kNumPis> kPisToUse{
@@ -521,7 +506,8 @@
   }
   CHECK(calibration->has_fixed_extrinsics());
   const Eigen::Matrix<double, 4, 4> fixed_extrinsics =
-      FlatbufferToTransformationMatrix(*calibration->fixed_extrinsics());
+      control_loops::drivetrain::FlatbufferToTransformationMatrix(
+          *calibration->fixed_extrinsics());
 
   // Calculate the pose of the camera relative to the robot origin.
   Eigen::Matrix<double, 4, 4> H_robot_camera = fixed_extrinsics;
@@ -530,7 +516,8 @@
         H_robot_camera *
         frc971::control_loops::TransformationMatrixForYaw<double>(
             state.turret_position) *
-        FlatbufferToTransformationMatrix(*calibration->turret_extrinsics());
+        control_loops::drivetrain::FlatbufferToTransformationMatrix(
+            *calibration->turret_extrinsics());
   }
   return H_robot_camera;
 }
diff --git a/y2022/localizer/localizer.h b/y2022/localizer/localizer.h
index a403ca8..59ad75c 100644
--- a/y2022/localizer/localizer.h
+++ b/y2022/localizer/localizer.h
@@ -9,18 +9,18 @@
 #include "aos/network/message_bridge_server_generated.h"
 #include "aos/time/time.h"
 #include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
-#include "frc971/input/joystick_state_generated.h"
 #include "frc971/control_loops/drivetrain/improved_down_estimator.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/utils.h"
 #include "frc971/control_loops/drivetrain/localizer_generated.h"
-#include "frc971/control_loops/drivetrain/localization_utils.h"
+#include "frc971/imu_reader/imu_watcher.h"
+#include "frc971/input/joystick_state_generated.h"
 #include "frc971/zeroing/imu_zeroer.h"
 #include "frc971/zeroing/wrap.h"
 #include "y2022/control_loops/superstructure/superstructure_status_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
 #include "y2022/localizer/localizer_status_generated.h"
 #include "y2022/localizer/localizer_visualization_generated.h"
 #include "y2022/vision/target_estimate_generated.h"
-#include "frc971/imu_reader/imu_watcher.h"
 
 namespace frc971::controls {
 
diff --git a/y2022/vision/BUILD b/y2022/vision/BUILD
index 8d8a43c..99fb95d 100644
--- a/y2022/vision/BUILD
+++ b/y2022/vision/BUILD
@@ -98,11 +98,11 @@
         "//aos/events:event_loop",
         "//aos/events:shm_event_loop",
         "//aos/network:team_number",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
         "//frc971/vision:calibration_fbs",
         "//frc971/vision:v4l2_reader",
         "//frc971/vision:vision_fbs",
         "//third_party:opencv",
-        "//y2022/localizer:localizer_output_fbs",
     ],
 )
 
diff --git a/y2022/vision/camera_reader.h b/y2022/vision/camera_reader.h
index c2d4f5f..7f07704 100644
--- a/y2022/vision/camera_reader.h
+++ b/y2022/vision/camera_reader.h
@@ -11,10 +11,10 @@
 #include "aos/events/shm_event_loop.h"
 #include "aos/flatbuffer_merge.h"
 #include "aos/network/team_number.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
 #include "frc971/vision/calibration_generated.h"
 #include "frc971/vision/v4l2_reader.h"
 #include "frc971/vision/vision_generated.h"
-#include "y2022/localizer/localizer_output_generated.h"
 #include "y2022/vision/calibration_data.h"
 #include "y2022/vision/gpio.h"
 #include "y2022/vision/target_estimate_generated.h"
diff --git a/y2022/www/BUILD b/y2022/www/BUILD
index 386ca90..e806404 100644
--- a/y2022/www/BUILD
+++ b/y2022/www/BUILD
@@ -24,8 +24,8 @@
         "//aos/network:web_proxy_ts_fbs",
         "//aos/network/www:proxy",
         "//frc971/control_loops/drivetrain:drivetrain_status_ts_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_ts_fbs",
         "//y2022/control_loops/superstructure:superstructure_status_ts_fbs",
-        "//y2022/localizer:localizer_output_ts_fbs",
         "//y2022/localizer:localizer_status_ts_fbs",
         "//y2022/localizer:localizer_visualization_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
diff --git a/y2022/www/field_handler.ts b/y2022/www/field_handler.ts
index ec25607..d20637f 100644
--- a/y2022/www/field_handler.ts
+++ b/y2022/www/field_handler.ts
@@ -1,7 +1,7 @@
 import {ByteBuffer} from 'flatbuffers';
 import {Connection} from '../../aos/network/www/proxy';
 import {IntakeState, Status as SuperstructureStatus, SuperstructureState} from '../control_loops/superstructure/superstructure_status_generated'
-import {LocalizerOutput} from '../localizer/localizer_output_generated';
+import {LocalizerOutput} from '../../frc971/control_loops/drivetrain/localization/localizer_output_generated';
 import {RejectionReason} from '../localizer/localizer_status_generated';
 import {Status as DrivetrainStatus} from '../../frc971/control_loops/drivetrain/drivetrain_status_generated';
 import {LocalizerVisualization, TargetEstimateDebug} from '../localizer/localizer_visualization_generated';
diff --git a/y2023/BUILD b/y2023/BUILD
index 16273e5..dc5e708 100644
--- a/y2023/BUILD
+++ b/y2023/BUILD
@@ -6,6 +6,7 @@
     binaries = [
         "//aos/network:web_proxy_main",
         "//aos/events/logging:log_cat",
+        "//aos/events:aos_timing_report_streamer",
     ],
     data = [
         ":aos_config",
@@ -41,20 +42,24 @@
         "//aos/util:foxglove_websocket",
         "//y2023/vision:viewer",
         "//y2023/vision:aprilrobotics",
-        "//y2022/localizer:localizer_main",
+        "//y2023/localizer:localizer_main",
         "//y2023/constants:constants_sender",
         "//y2023/vision:foxglove_image_converter",
         "//aos/network:web_proxy_main",
         "//aos/events/logging:log_cat",
         "//y2023/rockpi:imu_main",
+        "//frc971/image_streamer:image_streamer",
     ],
     data = [
         ":aos_config",
+        "//frc971/rockpi:rockpi_config.json",
         "//y2023/constants:constants.json",
+        "//y2023/vision:image_streamer_start",
         "//y2023/www:www_files",
     ],
     dirs = [
         "//y2023/www:www_files",
+        "//frc971/image_streamer/www:www_files",
     ],
     start_binaries = [
         "//aos/network:message_bridge_client",
@@ -102,11 +107,11 @@
             "//aos/network:timestamp_fbs",
             "//aos/network:remote_message_fbs",
             "//y2023/constants:constants_fbs",
-            "//y2022/localizer:localizer_output_fbs",
+            "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
             "//frc971/vision:calibration_fbs",
             "//frc971/vision:target_map_fbs",
             "//frc971/vision:vision_fbs",
-            "//y2023/vision:april_debug_fbs",
+            "//y2023/localizer:visualization_fbs",
             "@com_github_foxglove_schemas//:schemas",
         ],
         target_compatible_with = ["@platforms//os:linux"],
@@ -134,9 +139,9 @@
         "//y2023/constants:constants_fbs",
         "//aos/network:timestamp_fbs",
         "//aos/network:remote_message_fbs",
-        "//y2022/localizer:localizer_status_fbs",
-        "//y2022/localizer:localizer_output_fbs",
-        "//y2022/localizer:localizer_visualization_fbs",
+        "//y2023/localizer:status_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//y2023/localizer:visualization_fbs",
         "//frc971/vision:target_map_fbs",
     ],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2023/constants.cc b/y2023/constants.cc
index e2e8522..dbe8025 100644
--- a/y2023/constants.cc
+++ b/y2023/constants.cc
@@ -47,20 +47,22 @@
   roll_joint->zeroing.moving_buffer_size = 20;
   roll_joint->zeroing.allowable_encoder_error = 0.9;
 
-  wrist->zeroing_voltage = 3.0;
-  wrist->operating_voltage = 12.0;
-  wrist->zeroing_profile_params = {0.5, 3.0};
-  wrist->default_profile_params = {6.0, 30.0};
-  wrist->range = Values::kWristRange();
-  wrist->make_integral_loop =
+  wrist->subsystem_params.zeroing_voltage = 3.0;
+  wrist->subsystem_params.operating_voltage = 12.0;
+  wrist->subsystem_params.zeroing_profile_params = {0.5, 3.0};
+  wrist->subsystem_params.default_profile_params = {6.0, 30.0};
+  wrist->subsystem_params.range = Values::kWristRange();
+  wrist->subsystem_params.make_integral_loop =
       control_loops::superstructure::wrist::MakeIntegralWristLoop;
-  wrist->zeroing_constants.average_filter_size = Values::kZeroingSampleSize;
-  wrist->zeroing_constants.one_revolution_distance =
+  wrist->subsystem_params.zeroing_constants.average_filter_size =
+      Values::kZeroingSampleSize;
+  wrist->subsystem_params.zeroing_constants.one_revolution_distance =
       M_PI * 2.0 * constants::Values::kWristEncoderRatio();
-  wrist->zeroing_constants.zeroing_threshold = 0.0005;
-  wrist->zeroing_constants.moving_buffer_size = 20;
-  wrist->zeroing_constants.allowable_encoder_error = 0.9;
-  wrist->zeroing_constants.middle_position = Values::kWristRange().middle();
+  wrist->subsystem_params.zeroing_constants.zeroing_threshold = 0.0005;
+  wrist->subsystem_params.zeroing_constants.moving_buffer_size = 20;
+  wrist->subsystem_params.zeroing_constants.allowable_encoder_error = 0.9;
+  wrist->subsystem_params.zeroing_constants.middle_position =
+      Values::kWristRange().middle();
 
   switch (team) {
     // A set of constants for tests.
@@ -74,26 +76,32 @@
       roll_joint->zeroing.measured_absolute_position = 0.0;
       roll_joint->potentiometer_offset = 0.0;
 
-      wrist->zeroing_constants.measured_absolute_position = 0.0;
+      wrist->subsystem_params.zeroing_constants.measured_absolute_position =
+          0.0;
 
       break;
 
     case kCompTeamNumber:
-      arm_proximal->zeroing.measured_absolute_position = 0.908708932890508;
-      arm_proximal->potentiometer_offset = 0.931355973012855 + 8.6743197253382;
+      arm_proximal->zeroing.measured_absolute_position = 0.0910237406998185;
+      arm_proximal->potentiometer_offset = 0.931355973012855 + 8.6743197253382 -
+                                           0.101200335326309 -
+                                           0.0820901660993467;
 
-      arm_distal->zeroing.measured_absolute_position = 0.560320674747198;
-      arm_distal->potentiometer_offset = 0.436664933370656 + 0.49457213779426 +
-                                         6.78213223139724 - 0.0220711555235029 -
-                                         0.0162945074111813;
+      arm_distal->zeroing.measured_absolute_position = 0.556077643172765;
+      arm_distal->potentiometer_offset =
+          0.436664933370656 + 0.49457213779426 + 6.78213223139724 -
+          0.0220711555235029 - 0.0162945074111813 + 0.00630344935527365 -
+          0.0164398318919943;
 
-      roll_joint->zeroing.measured_absolute_position = 0.779367181558787;
+      roll_joint->zeroing.measured_absolute_position = 1.10682573591996;
       roll_joint->potentiometer_offset =
           3.87038557084874 - 0.0241774522172967 + 0.0711345168020632 -
           0.866186131631967 - 0.0256788357596952 + 0.18101759154572017 -
-          0.0208958996127179;
+          0.0208958996127179 - 0.186395903925026 + 0.45801689548395 -
+          0.5935210745062;
 
-      wrist->zeroing_constants.measured_absolute_position = 0.0;
+      wrist->subsystem_params.zeroing_constants.measured_absolute_position =
+          0.0;
 
       break;
 
@@ -107,7 +115,8 @@
       roll_joint->zeroing.measured_absolute_position = 0.0;
       roll_joint->potentiometer_offset = 0.0;
 
-      wrist->zeroing_constants.measured_absolute_position = 0.0;
+      wrist->subsystem_params.zeroing_constants.measured_absolute_position =
+          0.0;
 
       break;
 
@@ -121,7 +130,8 @@
       roll_joint->zeroing.measured_absolute_position = 0.0;
       roll_joint->potentiometer_offset = 0.0;
 
-      wrist->zeroing_constants.measured_absolute_position = 0.0;
+      wrist->subsystem_params.zeroing_constants.measured_absolute_position =
+          0.0;
 
       break;
 
diff --git a/y2023/constants.h b/y2023/constants.h
index 598a7d3..816de02 100644
--- a/y2023/constants.h
+++ b/y2023/constants.h
@@ -40,7 +40,7 @@
   }
 
   static constexpr double kDrivetrainSupplyCurrentLimit() { return 35.0; }
-  static constexpr double kDrivetrainStatorCurrentLimit() { return 40.0; }
+  static constexpr double kDrivetrainStatorCurrentLimit() { return 60.0; }
 
   static double DrivetrainEncoderToMeters(int32_t in) {
     return ((static_cast<double>(in) /
@@ -142,6 +142,17 @@
   // Rollers
   static constexpr double kRollerSupplyCurrentLimit() { return 30.0; }
   static constexpr double kRollerStatorCurrentLimit() { return 60.0; }
+  static constexpr double kRollerVoltage() { return 12.0; }
+
+  // Game object is fed into end effector for at least this time
+  static constexpr std::chrono::milliseconds kExtraIntakingTime() {
+    return std::chrono::seconds(2);
+  }
+
+  // Game object is spit from end effector for at least this time
+  static constexpr std::chrono::milliseconds kExtraSpittingTime() {
+    return std::chrono::seconds(2);
+  }
 
   struct PotConstants {
     ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemParams<
@@ -157,6 +168,12 @@
     double potentiometer_offset;
   };
 
+  struct AbsEncoderConstants {
+    ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemParams<
+        ::frc971::zeroing::AbsoluteEncoderZeroingEstimator>
+        subsystem_params;
+  };
+
   struct ArmJointConstants {
     ::frc971::constants::PotAndAbsoluteEncoderZeroingConstants zeroing;
     double potentiometer_offset;
@@ -166,9 +183,7 @@
   ArmJointConstants arm_distal;
   ArmJointConstants roll_joint;
 
-  ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemParams<
-      ::frc971::zeroing::AbsoluteEncoderZeroingEstimator>
-      wrist;
+  AbsEncoderConstants wrist;
 };
 
 // Creates and returns a Values instance for the constants.
diff --git a/y2023/constants/971.json b/y2023/constants/971.json
index f00cc1c..9ac8c8b 100644
--- a/y2023/constants/971.json
+++ b/y2023/constants/971.json
@@ -1,16 +1,17 @@
 {
   "cameras": [
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_2013-01-18_09-38-22.915096335.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json' %}
     },
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_2013-01-18_09-32-06.409252911.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json' %}
     },
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_2013-01-18_09-11-56.768663953.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json' %}
     },
     {
-      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_2013-01-18_09-27-45.150551614.json' %}
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json' %}
     }
-  ]
+  ],
+  "target_map": {% include 'y2023/vision/maps/target_map.json' %}
 }
diff --git a/y2023/constants/9971.json b/y2023/constants/9971.json
new file mode 100644
index 0000000..6d5cbf9
--- /dev/null
+++ b/y2023/constants/9971.json
@@ -0,0 +1,17 @@
+{
+  "cameras": [
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_2013-01-18_09-27-45.150551614.json' %}
+    }
+  ],
+  "target_map": {% include 'y2023/vision/maps/target_map.json' %}
+}
diff --git a/y2023/constants/BUILD b/y2023/constants/BUILD
index 9f8d1a4..344fa75 100644
--- a/y2023/constants/BUILD
+++ b/y2023/constants/BUILD
@@ -4,8 +4,10 @@
 cc_library(
     name = "simulated_constants_sender",
     testonly = True,
-    srcs = ["simulated_constants_sender.h"],
-    hdrs = ["simulated_constants_sender.cc"],
+    srcs = ["simulated_constants_sender.cc"],
+    hdrs = ["simulated_constants_sender.h"],
+    data = [":test_constants.json"],
+    visibility = ["//y2023:__subpackages__"],
     deps = [
         ":constants_fbs",
         ":constants_list_fbs",
@@ -16,11 +18,21 @@
 )
 
 jinja2_template(
+    name = "test_constants.json",
+    testonly = True,
+    src = "test_constants.jinja2.json",
+    includes = glob(["test_data/*.json"]),
+    parameters = {},
+    visibility = ["//visibility:public"],
+)
+
+jinja2_template(
     name = "constants.json",
     src = "constants.jinja2.json",
     includes = [
         "7971.json",
         "971.json",
+        "9971.json",
         "//y2023/vision/calib_files",
         "//y2023/vision/maps",
     ],
diff --git a/y2023/constants/constants.jinja2.json b/y2023/constants/constants.jinja2.json
index 2c8fce9..4fb9df7 100644
--- a/y2023/constants/constants.jinja2.json
+++ b/y2023/constants/constants.jinja2.json
@@ -10,7 +10,7 @@
     },
     {
       "team": 9971,
-      "data": {}
+      "data": {% include 'y2023/constants/9971.json' %}
     }
   ]
 }
diff --git a/y2023/constants/simulated_constants_sender.cc b/y2023/constants/simulated_constants_sender.cc
index 3086e99..f18c3c1 100644
--- a/y2023/constants/simulated_constants_sender.cc
+++ b/y2023/constants/simulated_constants_sender.cc
@@ -2,6 +2,7 @@
 #include "y2023/constants/constants_list_generated.h"
 #include "aos/events/simulated_event_loop.h"
 #include "aos/testing/path.h"
+#include "frc971/constants/constants_sender_lib.h"
 
 namespace y2023 {
 void SendSimulationConstants(aos::SimulatedEventLoopFactory *factory, int team,
@@ -14,5 +15,3 @@
   }
 }
 }  // namespace y2023
-
-#endif  // Y2023_CONFIGURATION_SIMULATED_CONFIG_SENDER_H_
diff --git a/y2023/constants/simulated_constants_sender.h b/y2023/constants/simulated_constants_sender.h
index 10ea793..44a868c 100644
--- a/y2023/constants/simulated_constants_sender.h
+++ b/y2023/constants/simulated_constants_sender.h
@@ -5,9 +5,10 @@
 #include "aos/testing/path.h"
 
 namespace y2023 {
-void SendSimulationConstants(aos::SimulatedEventLoopFactory *factory, int team,
-                             std::string constants_path = testing::ArtifactPath(
-                                 "y2023/constants/constants.json"));
+void SendSimulationConstants(
+    aos::SimulatedEventLoopFactory *factory, int team,
+    std::string constants_path =
+        aos::testing::ArtifactPath("y2023/constants/test_constants.json"));
 }  // namespace y2023
 
 #endif  // Y2023_CONSTANTS_SIMULATED_CONFIG_SENDER_H_
diff --git a/y2023/constants/test_constants.jinja2.json b/y2023/constants/test_constants.jinja2.json
new file mode 100644
index 0000000..86bd834
--- /dev/null
+++ b/y2023/constants/test_constants.jinja2.json
@@ -0,0 +1,8 @@
+{
+  "constants": [
+    {
+      "team": 7971,
+      "data": {% include 'y2023/constants/test_data/test_team.json' %}
+    }
+  ]
+}
diff --git a/y2023/constants/test_data/calibration_pi-1.json b/y2023/constants/test_data/calibration_pi-1.json
new file mode 100644
index 0000000..1902740
--- /dev/null
+++ b/y2023/constants/test_data/calibration_pi-1.json
@@ -0,0 +1,45 @@
+{
+  "node_name": "pi1",
+  "team_number": 7971,
+  "intrinsics": [
+    893.759521,
+    0,
+    645.470764,
+    0,
+    893.222351,
+    388.150269,
+    0,
+    0,
+    1
+  ],
+  "fixed_extrinsics": {
+    "data": [
+      0.0,
+      0.0,
+      1.0,
+      1.0,
+
+      -1.0,
+      0.0,
+      0.0,
+      0.0,
+
+      0.0,
+      -1.0,
+      0.0,
+      0.0,
+
+      0.0,
+      0.0,
+      0.0,
+      1.0
+    ]
+  },
+  "dist_coeffs": [
+    -0.44902,
+    0.248409,
+    -0.000537,
+    -0.000112,
+    -0.076989
+  ]
+}
diff --git a/y2023/constants/test_data/calibration_pi-2.json b/y2023/constants/test_data/calibration_pi-2.json
new file mode 100644
index 0000000..289337d
--- /dev/null
+++ b/y2023/constants/test_data/calibration_pi-2.json
@@ -0,0 +1,45 @@
+{
+  "node_name": "pi2",
+  "team_number": 7971,
+  "intrinsics": [
+    893.759521,
+    0,
+    645.470764,
+    0,
+    893.222351,
+    388.150269,
+    0,
+    0,
+    1
+  ],
+  "fixed_extrinsics": {
+    "data": [
+      1.0,
+      0.0,
+      0.0,
+      1.0,
+
+      0.0,
+      0.0,
+      -1.0,
+      0.0,
+
+      0.0,
+      1.0,
+      0.0,
+      0.0,
+
+      0.0,
+      0.0,
+      0.0,
+      1.0
+    ]
+  },
+  "dist_coeffs": [
+    -0.44902,
+    0.248409,
+    -0.000537,
+    -0.000112,
+    -0.076989
+  ]
+}
diff --git a/y2023/constants/test_data/calibration_pi-3.json b/y2023/constants/test_data/calibration_pi-3.json
new file mode 100644
index 0000000..073a88a
--- /dev/null
+++ b/y2023/constants/test_data/calibration_pi-3.json
@@ -0,0 +1,45 @@
+{
+  "node_name": "pi3",
+  "team_number": 7971,
+  "intrinsics": [
+    893.759521,
+    0,
+    645.470764,
+    0,
+    893.222351,
+    388.150269,
+    0,
+    0,
+    1
+  ],
+  "fixed_extrinsics": {
+    "data": [
+      0.0,
+      1.0,
+      0.0,
+      1.0,
+
+      0.0,
+      0.0,
+      -1.0,
+      0.0,
+
+      -1.0,
+      0.0,
+      0.0,
+      0.0,
+
+      0.0,
+      0.0,
+      0.0,
+      1.0
+    ]
+  },
+  "dist_coeffs": [
+    -0.44902,
+    0.248409,
+    -0.000537,
+    -0.000112,
+    -0.076989
+  ]
+}
diff --git a/y2023/constants/test_data/calibration_pi-4.json b/y2023/constants/test_data/calibration_pi-4.json
new file mode 100644
index 0000000..0097bdb
--- /dev/null
+++ b/y2023/constants/test_data/calibration_pi-4.json
@@ -0,0 +1,45 @@
+{
+  "node_name": "pi4",
+  "team_number": 7971,
+  "intrinsics": [
+    893.759521,
+    0,
+    645.470764,
+    0,
+    893.222351,
+    388.150269,
+    0,
+    0,
+    1
+  ],
+  "fixed_extrinsics": {
+    "data": [
+      -1.0,
+      0.0,
+      0.0,
+      1.0,
+
+      0.0,
+      0.0,
+      -1.0,
+      0.0,
+
+      0.0,
+      -1.0,
+      0.0,
+      0.0,
+
+      0.0,
+      0.0,
+      0.0,
+      1.0
+    ]
+  },
+  "dist_coeffs": [
+    -0.44902,
+    0.248409,
+    -0.000537,
+    -0.000112,
+    -0.076989
+  ]
+}
diff --git a/y2023/constants/test_data/target_map.json b/y2023/constants/test_data/target_map.json
new file mode 100644
index 0000000..6bd2947
--- /dev/null
+++ b/y2023/constants/test_data/target_map.json
@@ -0,0 +1,18 @@
+{
+    "target_poses": [
+        {
+            "id": 1,
+            "position": {
+                "x": 10.0,
+                "y": 10.0,
+                "z": 10.0
+            },
+            "orientation": {
+                "w": 0.0,
+                "x": 0.0,
+                "y": 0.0,
+                "z": 1.0
+            }
+        }
+    ]
+}
diff --git a/y2023/constants/test_data/test_team.json b/y2023/constants/test_data/test_team.json
new file mode 100644
index 0000000..adc9eae
--- /dev/null
+++ b/y2023/constants/test_data/test_team.json
@@ -0,0 +1,17 @@
+{
+  "cameras": [
+    {
+      "calibration": {% include 'y2023/constants/test_data/calibration_pi-1.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/constants/test_data/calibration_pi-2.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/constants/test_data/calibration_pi-3.json' %}
+    },
+    {
+      "calibration": {% include 'y2023/constants/test_data/calibration_pi-4.json' %}
+    }
+  ],
+  "target_map": {% include 'y2023/constants/test_data/target_map.json' %}
+}
diff --git a/y2023/control_loops/drivetrain/BUILD b/y2023/control_loops/drivetrain/BUILD
index ac00e2b..09e5137 100644
--- a/y2023/control_loops/drivetrain/BUILD
+++ b/y2023/control_loops/drivetrain/BUILD
@@ -84,6 +84,7 @@
         "//aos:init",
         "//aos/events:shm_event_loop",
         "//frc971/control_loops/drivetrain:drivetrain_lib",
+        "//frc971/control_loops/drivetrain/localization:puppet_localizer",
     ],
 )
 
diff --git a/y2023/control_loops/drivetrain/drivetrain_main.cc b/y2023/control_loops/drivetrain/drivetrain_main.cc
index 0817b3d..32b2455 100644
--- a/y2023/control_loops/drivetrain/drivetrain_main.cc
+++ b/y2023/control_loops/drivetrain/drivetrain_main.cc
@@ -3,6 +3,7 @@
 #include "aos/events/shm_event_loop.h"
 #include "aos/init.h"
 #include "frc971/control_loops/drivetrain/drivetrain.h"
+#include "frc971/control_loops/drivetrain/localization/puppet_localizer.h"
 #include "y2023/control_loops/drivetrain/drivetrain_base.h"
 
 using ::frc971::control_loops::drivetrain::DrivetrainLoop;
@@ -14,11 +15,11 @@
       aos::configuration::ReadConfig("aos_config.json");
 
   aos::ShmEventLoop event_loop(&config.message());
-  std::unique_ptr<::frc971::control_loops::drivetrain::DeadReckonEkf>
-      localizer =
-          std::make_unique<::frc971::control_loops::drivetrain::DeadReckonEkf>(
-              &event_loop,
-              ::y2023::control_loops::drivetrain::GetDrivetrainConfig());
+  std::unique_ptr<::frc971::control_loops::drivetrain::PuppetLocalizer>
+      localizer = std::make_unique<
+          ::frc971::control_loops::drivetrain::PuppetLocalizer>(
+          &event_loop,
+          ::y2023::control_loops::drivetrain::GetDrivetrainConfig());
   std::unique_ptr<DrivetrainLoop> drivetrain = std::make_unique<DrivetrainLoop>(
       y2023::control_loops::drivetrain::GetDrivetrainConfig(), &event_loop,
       localizer.get());
diff --git a/y2023/control_loops/python/BUILD b/y2023/control_loops/python/BUILD
index 9a5a156..561bb77 100644
--- a/y2023/control_loops/python/BUILD
+++ b/y2023/control_loops/python/BUILD
@@ -124,3 +124,19 @@
         "@pip//python_gflags",
     ],
 )
+
+py_binary(
+    name = "turret",
+    srcs = [
+        "turret.py",
+    ],
+    legacy_create_init = False,
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        ":python_init",
+        "//frc971/control_loops/python:angular_system",
+        "//frc971/control_loops/python:controls",
+        "@pip//glog",
+        "@pip//python_gflags",
+    ],
+)
diff --git a/y2023/control_loops/python/drivetrain.py b/y2023/control_loops/python/drivetrain.py
index 863684c..05739fc 100644
--- a/y2023/control_loops/python/drivetrain.py
+++ b/y2023/control_loops/python/drivetrain.py
@@ -13,7 +13,7 @@
 gflags.DEFINE_bool('plot', False, 'If true, plot the loop response.')
 
 kDrivetrain = drivetrain.DrivetrainParams(
-    J=6.0,
+    J=6.5,
     mass=58.0,
     # TODO(austin): Measure radius a bit better.
     robot_radius=0.39,
@@ -23,7 +23,7 @@
     G=(14.0 / 54.0) * (22.0 / 56.0),
     q_pos=0.24,
     q_vel=2.5,
-    efficiency=0.80,
+    efficiency=0.75,
     has_imu=True,
     force=True,
     kf_q_voltage=1.0,
diff --git a/y2023/control_loops/python/graph_codegen.py b/y2023/control_loops/python/graph_codegen.py
index 69e2497..054a32d 100644
--- a/y2023/control_loops/python/graph_codegen.py
+++ b/y2023/control_loops/python/graph_codegen.py
@@ -13,6 +13,8 @@
 
 
 def add_edge(cc_file, name, segment, index, reverse):
+    segment.VerifyPoints()
+
     cc_file.append("  // Adding edge %d" % index)
     vmax = "vmax"
     if segment.vmax:
@@ -71,7 +73,6 @@
 
 def main(argv):
     cc_file = []
-    cc_file.append("")
     cc_file.append("#include <memory>")
     cc_file.append("")
     cc_file.append(
@@ -84,6 +85,7 @@
     cc_file.append(
         "#include \"y2023/control_loops/superstructure/roll/integral_hybrid_roll_plant.h\""
     )
+    cc_file.append("")
 
     cc_file.append("using frc971::control_loops::arm::SearchGraph;")
     cc_file.append(
@@ -187,28 +189,24 @@
                       path_function_name(name))
         cc_file.append("::std::unique_ptr<Path> %s() {" %
                        path_function_name(name))
-        cc_file.append(
-            "  return ::std::unique_ptr<Path>(new Path(CosSpline{NSpline<4, 2>((Eigen::Matrix<double, 2, 4>() << "
-        )
+        cc_file.append("  return ::std::unique_ptr<Path>(new Path(CosSpline{")
+        cc_file.append("      NSpline<4, 2>((Eigen::Matrix<double, 2, 4>() <<")
         points = [
             segment.start, segment.control1, segment.control2, segment.end
         ]
-        for i in range(len(points)):
-            cc_file.append("%.12f," % (points[i][0]))
-        for i in range(len(points)):
-            cc_file.append(
-                "%.12f%s" %
-                (points[i][1], ", " if i != len(points) - 1 else ""))
+        cc_file.append("             " +
+                       " ".join(["%.12f," % (p[0]) for p in points]))
+        cc_file.append("             " +
+                       ", ".join(["%.12f" % (p[1]) for p in points]))
 
-        cc_file.append(").finished()), {")
+        cc_file.append("      ).finished()), {")
         for alpha, roll in segment.alpha_rolls:
             cc_file.append(
-                "CosSpline::AlphaTheta{.alpha = %.12f, .theta = %.12f}" %
-                (alpha, roll))
-            if alpha != segment.alpha_rolls[-1][0]:
-                cc_file.append(", ")
+                "       CosSpline::AlphaTheta{.alpha = %.12f, .theta = %.12f},"
+                % (alpha, roll))
         cc_file.append("  }}));")
         cc_file.append("}")
+        cc_file.append("")
 
     # Matrix of nodes
     h_file.append("::std::vector<::Eigen::Matrix<double, 3, 1>> PointList();")
@@ -229,22 +227,25 @@
     h_file.append("SearchGraph MakeSearchGraph("
                   "const frc971::control_loops::arm::Dynamics *dynamics, "
                   "::std::vector<TrajectoryAndParams> *trajectories,")
-    h_file.append("                            "
-                  "const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,")
-    h_file.append("                            "
-                  "double vmax,")
+    h_file.append(
+        "                            const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,"
+    )
+    h_file.append("                            double vmax,")
     h_file.append(
         "const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>, HybridKalman<3, 1, 1>> *hybrid_roll_joint_loop);"
     )
-    cc_file.append("SearchGraph MakeSearchGraph("
-                   "const frc971::control_loops::arm::Dynamics *dynamics, "
-                   "::std::vector<TrajectoryAndParams> *trajectories,")
-    cc_file.append("                            "
-                   "const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer,")
-    cc_file.append("                            "
-                   "double vmax,")
+    cc_file.append("")
+    cc_file.append("SearchGraph MakeSearchGraph(")
+    cc_file.append("    const frc971::control_loops::arm::Dynamics *dynamics,")
+    cc_file.append("    std::vector<TrajectoryAndParams> *trajectories,")
     cc_file.append(
-        "const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>, HybridKalman<3, 1, 1>> *hybrid_roll_joint_loop) {"
+        "    const ::Eigen::Matrix<double, 3, 3> &alpha_unitizer, double vmax,"
+    )
+    cc_file.append(
+        "    const StateFeedbackLoop<3, 1, 1, double, StateFeedbackHybridPlant<3, 1, 1>,"
+    )
+    cc_file.append(
+        "                            HybridKalman<3, 1, 1>> *hybrid_roll_joint_loop) {"
     )
     cc_file.append("  ::std::vector<SearchGraph::Edge> edges;")
 
diff --git a/y2023/control_loops/python/graph_edit.py b/y2023/control_loops/python/graph_edit.py
index b9e29d0..689401a 100644
--- a/y2023/control_loops/python/graph_edit.py
+++ b/y2023/control_loops/python/graph_edit.py
@@ -162,6 +162,8 @@
         self.ax = self.fig.add_subplot(111)
         plt.show(block=False)
 
+        self.index = 0
+
     def do_key_press(self, event):
         pass
 
@@ -330,8 +332,7 @@
 
         set_color(cr, Color(0.0, 0.5, 1.0))
         for segment in self.segments:
-            color = [0, random.random(), 1]
-            random.shuffle(color)
+            color = [0.2, random.random(), random.random()]
             set_color(cr, Color(color[0], color[1], color[2]))
             segment.DrawTo(cr, self.theta_version)
             with px(cr):
@@ -411,13 +412,16 @@
         elif keyval == Gdk.KEY_r:
             self.prev_segment_pt = self.now_segment_pt
 
+        elif keyval == Gdk.KEY_o:
+            # Only prints current segment
+            print(repr(self.segments[self.index]))
         elif keyval == Gdk.KEY_p:
             # Print out the segments.
             print(repr(self.segments))
         elif keyval == Gdk.KEY_g:
             # Generate theta points.
             if self.segments:
-                print(repr(self.segments[0].ToThetaPoints()))
+                print(repr(self.segments[self.index].ToThetaPoints()))
         elif keyval == Gdk.KEY_e:
             best_pt = self.now_segment_pt
             best_dist = 1e10
@@ -432,6 +436,10 @@
                     best_dist = d
             self.now_segment_pt = best_pt
 
+        elif keyval == Gdk.KEY_k:
+            self.index += 1
+            self.index = self.index % len(self.segments)
+
         elif keyval == Gdk.KEY_t:
             # Toggle between theta and xy renderings
             if self.theta_version:
@@ -449,9 +457,9 @@
         elif keyval == Gdk.KEY_z:
             self.edit_control1 = not self.edit_control1
             if self.edit_control1:
-                self.now_segment_pt = self.segments[0].control1
+                self.now_segment_pt = self.segments[self.index].control1
             else:
-                self.now_segment_pt = self.segments[0].control2
+                self.now_segment_pt = self.segments[self.index].control2
             if not self.theta_version:
                 data = to_xy(self.now_segment_pt[0], self.now_segment_pt[1])
                 self.last_pos = (data[0], data[1])
@@ -474,9 +482,9 @@
         self.now_segment_pt = np.array(shift_angles(pt_theta))
 
         if self.edit_control1:
-            self.segments[0].control1 = self.now_segment_pt
+            self.segments[self.index].control1 = self.now_segment_pt
         else:
-            self.segments[0].control2 = self.now_segment_pt
+            self.segments[self.index].control2 = self.now_segment_pt
 
         print('Clicked at theta: %s' % (repr(self.now_segment_pt, )))
         if not self.theta_version:
@@ -485,9 +493,11 @@
                    self.circular_index_select))
 
         print('c1: np.array([%f, %f])' %
-              (self.segments[0].control1[0], self.segments[0].control1[1]))
+              (self.segments[self.index].control1[0],
+               self.segments[self.index].control1[1]))
         print('c2: np.array([%f, %f])' %
-              (self.segments[0].control2[0], self.segments[0].control2[1]))
+              (self.segments[self.index].control2[0],
+               self.segments[self.index].control2[1]))
 
         self.redraw()
 
diff --git a/y2023/control_loops/python/graph_paths.py b/y2023/control_loops/python/graph_paths.py
index b21f548..87ced83 100644
--- a/y2023/control_loops/python/graph_paths.py
+++ b/y2023/control_loops/python/graph_paths.py
@@ -6,22 +6,23 @@
                                                 joint_center[1] + l2 - l1,
                                                 0.0,
                                                 circular_index=1)
-
-neutral_to_pickup_1 = to_theta_with_circular_index(0.3, 0.6, circular_index=1)
-neutral_to_pickup_2 = to_theta_with_circular_index(0.3, 0.4, circular_index=1)
+neutral_to_pickup_1 = np.array([2.396694, 0.508020])
+neutral_to_pickup_2 = np.array([2.874513, 0.933160])
 pickup_pos = to_theta_with_circular_index_and_roll(0.6,
-                                                   0.1,
-                                                   0.0,
-                                                   circular_index=1)
-neutral_to_pickup_control_alpha_rolls = [(0.33, 0.0), (.67, 0.0)]
+                                                   0.4,
+                                                   np.pi / 2.0,
+                                                   circular_index=0)
 
-neutral_to_score_1 = to_theta_with_circular_index(-0.4, 1.2, circular_index=1)
-neutral_to_score_2 = to_theta_with_circular_index(-0.7, 1.2, circular_index=1)
+neutral_to_pickup_control_alpha_rolls = [(0.33, 0.0), (.95, np.pi / 2.0)]
+
+neutral_to_score_1 = np.array([0.994244, -1.417442])
+neutral_to_score_2 = np.array([1.711325, -0.679748])
+
 score_pos = to_theta_with_circular_index_and_roll(-1.0,
                                                   1.2,
-                                                  0.0,
-                                                  circular_index=1)
-neutral_to_score_control_alpha_rolls = [(0.33, 0.0), (.67, 0.0)]
+                                                  np.pi / 2.0,
+                                                  circular_index=0)
+neutral_to_score_control_alpha_rolls = [(0.40, 0.0), (.95, np.pi / 2.0)]
 
 # TODO(Max): Add real paths for arm.
 points = [(neutral, "NeutralPos"), (pickup_pos, "PickupPos"),
diff --git a/y2023/control_loops/python/graph_tools.py b/y2023/control_loops/python/graph_tools.py
index cf4fd7b..fef1083 100644
--- a/y2023/control_loops/python/graph_tools.py
+++ b/y2023/control_loops/python/graph_tools.py
@@ -409,12 +409,11 @@
 
         self.DoDrawTo(cr, theta_version)
 
-    def ToThetaPoints(self):
+    def VerifyPoints(self):
         points = self.DoToThetaPoints()
         if self.roll_joint_collision(points, verbose=True) or \
            self.arm_past_limit(points, verbose=True):
             sys.exit(1)
-        return points
 
 
 class SplineSegmentBase(Path):
diff --git a/y2023/control_loops/python/turret.py b/y2023/control_loops/python/turret.py
new file mode 100644
index 0000000..4e8f117
--- /dev/null
+++ b/y2023/control_loops/python/turret.py
@@ -0,0 +1,54 @@
+#!/usr/bin/python3
+
+from aos.util.trapezoid_profile import TrapezoidProfile
+from frc971.control_loops.python import control_loop
+from frc971.control_loops.python import angular_system
+from frc971.control_loops.python import controls
+import numpy
+import sys
+from matplotlib import pylab
+import gflags
+import glog
+
+FLAGS = gflags.FLAGS
+
+try:
+    gflags.DEFINE_bool('plot', False, 'If true, plot the loop response.')
+except gflags.DuplicateFlagError:
+    pass
+
+kTurret = angular_system.AngularSystemParams(name='Turret',
+                                             motor=control_loop.Falcon(),
+                                             G=0.01,
+                                             J=3.1,
+                                             q_pos=0.40,
+                                             q_vel=20.0,
+                                             kalman_q_pos=0.12,
+                                             kalman_q_vel=2.0,
+                                             kalman_q_voltage=4.0,
+                                             kalman_r_position=0.05,
+                                             radius=25 * 0.0254)
+
+
+def main(argv):
+    if FLAGS.plot:
+        R = numpy.matrix([[numpy.pi / 2.0], [0.0]])
+        angular_system.PlotKick(kTurret, R)
+        angular_system.PlotMotion(kTurret, R)
+        return
+
+    # Write the generated constants out to a file.
+    if len(argv) != 5:
+        glog.fatal(
+            'Expected .h file name and .cc file name for the wrist and integral wrist.'
+        )
+    else:
+        namespaces = ['y2023', 'control_loops', 'superstructure', 'turret']
+        angular_system.WriteAngularSystem(kTurret, argv[1:3], argv[3:5],
+                                          namespaces)
+
+
+if __name__ == '__main__':
+    argv = FLAGS(sys.argv)
+    glog.init()
+    sys.exit(main(argv))
diff --git a/y2023/control_loops/superstructure/BUILD b/y2023/control_loops/superstructure/BUILD
index 52254e4..d00489a 100644
--- a/y2023/control_loops/superstructure/BUILD
+++ b/y2023/control_loops/superstructure/BUILD
@@ -60,6 +60,25 @@
 )
 
 cc_library(
+    name = "end_effector",
+    srcs = [
+        "end_effector.cc",
+    ],
+    hdrs = [
+        "end_effector.h",
+    ],
+    deps = [
+        ":superstructure_goal_fbs",
+        ":superstructure_position_fbs",
+        ":superstructure_status_fbs",
+        "//aos/events:event_loop",
+        "//aos/time",
+        "//frc971/control_loops:control_loop",
+        "//y2023:constants",
+    ],
+)
+
+cc_library(
     name = "superstructure_lib",
     srcs = [
         "superstructure.cc",
@@ -68,6 +87,7 @@
         "superstructure.h",
     ],
     deps = [
+        ":end_effector",
         ":superstructure_goal_fbs",
         ":superstructure_output_fbs",
         ":superstructure_position_fbs",
diff --git a/y2023/control_loops/superstructure/arm/arm.cc b/y2023/control_loops/superstructure/arm/arm.cc
index 5417dc8..07d54ec 100644
--- a/y2023/control_loops/superstructure/arm/arm.cc
+++ b/y2023/control_loops/superstructure/arm/arm.cc
@@ -55,8 +55,8 @@
     const ::aos::monotonic_clock::time_point /*monotonic_now*/,
     const uint32_t *unsafe_goal, const superstructure::ArmPosition *position,
     bool trajectory_override, double *proximal_output, double *distal_output,
-    double *roll_joint_output, bool /*intake*/, bool /*spit*/,
-    flatbuffers::FlatBufferBuilder *fbb) {
+    double *roll_joint_output, flatbuffers::FlatBufferBuilder *fbb) {
+  ::Eigen::Matrix<double, 2, 1> Y;
   const bool outputs_disabled =
       ((proximal_output == nullptr) || (distal_output == nullptr) ||
        (roll_joint_output == nullptr));
diff --git a/y2023/control_loops/superstructure/arm/arm.h b/y2023/control_loops/superstructure/arm/arm.h
index 9cda7fc..6e4cc29 100644
--- a/y2023/control_loops/superstructure/arm/arm.h
+++ b/y2023/control_loops/superstructure/arm/arm.h
@@ -47,8 +47,7 @@
       const ::aos::monotonic_clock::time_point /*monotonic_now*/,
       const uint32_t *unsafe_goal, const superstructure::ArmPosition *position,
       bool trajectory_override, double *proximal_output, double *distal_output,
-      double *roll_joint_output, bool /*intake*/, bool /*spit*/,
-      flatbuffers::FlatBufferBuilder *fbb);
+      double *roll_joint_output, flatbuffers::FlatBufferBuilder *fbb);
 
   void Reset();
 
diff --git a/y2023/control_loops/superstructure/end_effector.cc b/y2023/control_loops/superstructure/end_effector.cc
new file mode 100644
index 0000000..658eb10
--- /dev/null
+++ b/y2023/control_loops/superstructure/end_effector.cc
@@ -0,0 +1,81 @@
+#include "y2023/control_loops/superstructure/end_effector.h"
+
+#include "aos/events/event_loop.h"
+#include "aos/time/time.h"
+#include "frc971/control_loops/control_loop.h"
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+
+using ::aos::monotonic_clock;
+
+EndEffector::EndEffector()
+    : state_(EndEffectorState::IDLE), beambreak_(false) {}
+
+EndEffectorState EndEffector::RunIteration(
+    const ::aos::monotonic_clock::time_point timestamp, RollerGoal roller_goal,
+    bool cone_beambreak, bool cube_beambreak, double *roller_voltage) {
+  bool beambreak = cone_beambreak || cube_beambreak;
+
+  *roller_voltage = 0.0;
+
+  switch (state_) {
+    case EndEffectorState::IDLE:
+      // If idle and intake requested, intake
+      if (roller_goal == RollerGoal::INTAKE) {
+        state_ = EndEffectorState::INTAKING;
+        timer_ = timestamp;
+      }
+      break;
+    case EndEffectorState::INTAKING:
+      // If intaking and beam break is not triggered, keep intaking
+      if (beambreak) {
+        // Beam has been broken, switch to loaded.
+        state_ = EndEffectorState::LOADED;
+        break;
+      } else if (timestamp > timer_ + constants::Values::kExtraIntakingTime()) {
+        // Intaking failed, waited 2 seconds with no beambreak
+        state_ = EndEffectorState::IDLE;
+        break;
+      }
+
+      *roller_voltage = constants::Values::kRollerVoltage();
+
+      break;
+    case EndEffectorState::LOADED:
+      // If loaded and beam break not triggered, intake
+      if (!beambreak) {
+        state_ = EndEffectorState::INTAKING;
+        timer_ = timestamp;
+      }
+      // If loaded and spit requested, spit
+      else if (roller_goal == RollerGoal::SPIT) {
+        state_ = EndEffectorState::SPITTING;
+      }
+      break;
+    case EndEffectorState::SPITTING:
+      // If spit requested, spit
+      *roller_voltage = -constants::Values::kRollerVoltage();
+      if (beambreak_) {
+        if (!beambreak) {
+          timer_ = timestamp;
+        }
+      } else if (timestamp > timer_ + constants::Values::kExtraSpittingTime()) {
+        // Finished spitting
+        state_ = EndEffectorState::IDLE;
+      }
+
+      break;
+  }
+
+  beambreak_ = beambreak;
+
+  return state_;
+}
+
+void EndEffector::Reset() { state_ = EndEffectorState::IDLE; }
+
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
diff --git a/y2023/control_loops/superstructure/end_effector.h b/y2023/control_loops/superstructure/end_effector.h
new file mode 100644
index 0000000..16007de
--- /dev/null
+++ b/y2023/control_loops/superstructure/end_effector.h
@@ -0,0 +1,37 @@
+#ifndef Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_END_EFFECTOR_H_
+#define Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_END_EFFECTOR_H_
+
+#include "aos/events/event_loop.h"
+#include "aos/time/time.h"
+#include "frc971/control_loops/control_loop.h"
+#include "y2023/constants.h"
+#include "y2023/control_loops/superstructure/superstructure_goal_generated.h"
+#include "y2023/control_loops/superstructure/superstructure_position_generated.h"
+#include "y2023/control_loops/superstructure/superstructure_status_generated.h"
+
+namespace y2023 {
+namespace control_loops {
+namespace superstructure {
+
+class EndEffector {
+ public:
+  EndEffector();
+  EndEffectorState RunIteration(
+      const ::aos::monotonic_clock::time_point timestamp,
+      RollerGoal roller_goal, bool cone_beambreak, bool cube_beambreak,
+      double *intake_roller_voltage);
+  EndEffectorState state() const { return state_; }
+  void Reset();
+
+ private:
+  EndEffectorState state_ = EndEffectorState::IDLE;
+  aos::monotonic_clock::time_point timer_ = aos::monotonic_clock::min_time;
+
+  bool beambreak_;
+};
+
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2023
+
+#endif  // Y2023_CONTROL_LOOPS_SUPERSTRUCTURE_END_EFFECTOR_H_
diff --git a/y2023/control_loops/superstructure/superstructure.cc b/y2023/control_loops/superstructure/superstructure.cc
index 1ad2625..3b4d2b0 100644
--- a/y2023/control_loops/superstructure/superstructure.cc
+++ b/y2023/control_loops/superstructure/superstructure.cc
@@ -29,7 +29,9 @@
               "/drivetrain")),
       joystick_state_fetcher_(
           event_loop->MakeFetcher<aos::JoystickState>("/aos")),
-      arm_(values_) {}
+      arm_(values_),
+      end_effector_(),
+      wrist_(values->wrist.subsystem_params) {}
 
 void Superstructure::RunIteration(const Goal *unsafe_goal,
                                   const Position *position,
@@ -41,6 +43,7 @@
   if (WasReset()) {
     AOS_LOG(ERROR, "WPILib reset, restarting\n");
     arm_.Reset();
+    end_effector_.Reset();
   }
 
   OutputT output_struct;
@@ -61,11 +64,20 @@
           output != nullptr ? &output_struct.proximal_voltage : nullptr,
           output != nullptr ? &output_struct.distal_voltage : nullptr,
           output != nullptr ? &output_struct.roll_joint_voltage : nullptr,
-          unsafe_goal != nullptr ? unsafe_goal->intake() : false,
-          unsafe_goal != nullptr ? unsafe_goal->spit() : false,
-
           status->fbb());
 
+  flatbuffers::Offset<AbsoluteEncoderProfiledJointStatus> wrist_offset =
+      wrist_.Iterate(unsafe_goal != nullptr ? unsafe_goal->wrist() : nullptr,
+                     position->wrist(),
+                     output != nullptr ? &output_struct.wrist_voltage : nullptr,
+                     status->fbb());
+
+  EndEffectorState end_effector_state = end_effector_.RunIteration(
+      timestamp,
+      unsafe_goal != nullptr ? unsafe_goal->roller_goal() : RollerGoal::IDLE,
+      position->end_effector_cone_beam_break(),
+      position->end_effector_cube_beam_break(), &output_struct.roller_voltage);
+
   if (output) {
     output->CheckOk(output->Send(Output::Pack(*output->fbb(), &output_struct)));
   }
@@ -74,6 +86,8 @@
   status_builder.add_zeroed(true);
   status_builder.add_estopped(false);
   status_builder.add_arm(arm_status_offset);
+  status_builder.add_wrist(wrist_offset);
+  status_builder.add_end_effector_state(end_effector_state);
 
   (void)status->Send(status_builder.Finish());
 }
diff --git a/y2023/control_loops/superstructure/superstructure.h b/y2023/control_loops/superstructure/superstructure.h
index 2d0fc43..827cc40 100644
--- a/y2023/control_loops/superstructure/superstructure.h
+++ b/y2023/control_loops/superstructure/superstructure.h
@@ -6,6 +6,7 @@
 #include "frc971/control_loops/drivetrain/drivetrain_status_generated.h"
 #include "y2023/constants.h"
 #include "y2023/control_loops/superstructure/arm/arm.h"
+#include "y2023/control_loops/superstructure/end_effector.h"
 #include "y2023/control_loops/superstructure/superstructure_goal_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2023/control_loops/superstructure/superstructure_position_generated.h"
@@ -28,12 +29,21 @@
           ::frc971::zeroing::PotAndAbsoluteEncoderZeroingEstimator,
           ::frc971::control_loops::PotAndAbsoluteEncoderProfiledJointStatus>;
 
+  using AbsoluteEncoderSubsystem =
+      ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystem<
+          ::frc971::zeroing::AbsoluteEncoderZeroingEstimator,
+          ::frc971::control_loops::AbsoluteEncoderProfiledJointStatus>;
+
   explicit Superstructure(::aos::EventLoop *event_loop,
                           std::shared_ptr<const constants::Values> values,
                           const ::std::string &name = "/superstructure");
 
   double robot_velocity() const;
 
+  inline const arm::Arm &arm() const { return arm_; }
+  inline const EndEffector &end_effector() const { return end_effector_; }
+  inline const AbsoluteEncoderSubsystem &wrist() const { return wrist_; }
+
  protected:
   virtual void RunIteration(const Goal *unsafe_goal, const Position *position,
                             aos::Sender<Output>::Builder *output,
@@ -47,6 +57,8 @@
   aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
 
   arm::Arm arm_;
+  EndEffector end_effector_;
+  AbsoluteEncoderSubsystem wrist_;
 
   aos::Alliance alliance_ = aos::Alliance::kInvalid;
 
diff --git a/y2023/control_loops/superstructure/superstructure_goal.fbs b/y2023/control_loops/superstructure/superstructure_goal.fbs
index 936cbee..8f2d5ab 100644
--- a/y2023/control_loops/superstructure/superstructure_goal.fbs
+++ b/y2023/control_loops/superstructure/superstructure_goal.fbs
@@ -2,6 +2,11 @@
 
 namespace y2023.control_loops.superstructure;
 
+enum RollerGoal: ubyte {
+    IDLE = 0,
+    INTAKE = 1,
+    SPIT = 2,
+}
 
 table Goal {
     // Used to identify a position in the planned set of positions on the arm.
@@ -13,11 +18,7 @@
 
     wrist:frc971.control_loops.StaticZeroingSingleDOFProfiledSubsystemGoal (id: 2);
 
-    // If this is true, the rollers should intake.
-    intake:bool (id: 3);
-
-    // If this is true, the rollers should spit.
-    spit:bool (id: 4);
+    roller_goal:RollerGoal (id: 3);
 }
 
 
diff --git a/y2023/control_loops/superstructure/superstructure_lib_test.cc b/y2023/control_loops/superstructure/superstructure_lib_test.cc
index f3f0471..adfa2a5 100644
--- a/y2023/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2023/control_loops/superstructure/superstructure_lib_test.cc
@@ -43,6 +43,20 @@
 using RelativeEncoderSimulator = frc971::control_loops::SubsystemSimulator<
     frc971::control_loops::RelativeEncoderProfiledJointStatus,
     RelativeEncoderSubsystem::State, constants::Values::PotConstants>;
+using AbsoluteEncoderSubsystem =
+    ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystem<
+        ::frc971::zeroing::AbsoluteEncoderZeroingEstimator,
+        ::frc971::control_loops::AbsoluteEncoderProfiledJointStatus>;
+
+using AbsoluteEncoderSimulator = ::frc971::control_loops::SubsystemSimulator<
+    ::frc971::control_loops::AbsoluteEncoderProfiledJointStatus,
+    Superstructure::AbsoluteEncoderSubsystem::State,
+    constants::Values::AbsEncoderConstants>;
+
+enum GamePiece {
+  kCone,
+  kCube,
+};
 
 class ArmSimulation {
  public:
@@ -169,6 +183,14 @@
         dt_(dt),
         arm_(values->arm_proximal.zeroing, values->arm_distal.zeroing,
              values->roll_joint.zeroing, dt_),
+        wrist_(new CappedTestPlant(wrist::MakeWristPlant()),
+               PositionSensorSimulator(
+                   values->wrist.subsystem_params.zeroing_constants
+                       .one_revolution_distance),
+               values->wrist, constants::Values::kWristRange(),
+               values->wrist.subsystem_params.zeroing_constants
+                   .measured_absolute_position,
+               dt_),
         superstructure_position_sender_(
             event_loop_->MakeSender<Position>("/superstructure")),
         superstructure_status_fetcher_(
@@ -189,6 +211,9 @@
                  superstructure_output_fetcher_->distal_voltage(),
                  superstructure_output_fetcher_->roll_joint_voltage())
                     .finished());
+
+            wrist_.Simulate(superstructure_output_fetcher_->wrist_voltage(),
+                            superstructure_status_fetcher_->wrist());
           }
           first_ = false;
           SendPositionMessage();
@@ -200,7 +225,8 @@
     arm_.InitializePosition(position);
   }
 
-  const ArmSimulation &arm() const { return arm_; }
+  ArmSimulation *arm() { return &arm_; }
+  AbsoluteEncoderSimulator *wrist() { return &wrist_; }
 
   // Sends a queue message with the position of the superstructure.
   void SendPositionMessage() {
@@ -210,18 +236,40 @@
     flatbuffers::Offset<ArmPosition> arm_offset =
         arm_.GetSensorValues(builder.fbb());
 
+    frc971::AbsolutePosition::Builder wrist_builder =
+        builder.MakeBuilder<frc971::AbsolutePosition>();
+    flatbuffers::Offset<frc971::AbsolutePosition> wrist_offset =
+        wrist_.encoder()->GetSensorValues(&wrist_builder);
+
     Position::Builder position_builder = builder.MakeBuilder<Position>();
     position_builder.add_arm(arm_offset);
+    position_builder.add_wrist(wrist_offset);
+    position_builder.add_end_effector_cone_beam_break(
+        end_effector_cone_beam_break_);
+    position_builder.add_end_effector_cube_beam_break(
+        end_effector_cube_beam_break_);
     CHECK_EQ(builder.Send(position_builder.Finish()),
              aos::RawSender::Error::kOk);
   }
 
+  void set_end_effector_cone_beam_break(bool triggered) {
+    end_effector_cone_beam_break_ = triggered;
+  }
+
+  void set_end_effector_cube_beam_break(bool triggered) {
+    end_effector_cube_beam_break_ = triggered;
+  }
+
  private:
   ::aos::EventLoop *event_loop_;
   const chrono::nanoseconds dt_;
   ::aos::PhasedLoopHandler *phased_loop_handle_ = nullptr;
 
   ArmSimulation arm_;
+  AbsoluteEncoderSimulator wrist_;
+
+  bool end_effector_cone_beam_break_;
+  bool end_effector_cube_beam_break_;
 
   ::aos::Sender<Position> superstructure_position_sender_;
   ::aos::Fetcher<Status> superstructure_status_fetcher_;
@@ -309,18 +357,23 @@
                 superstructure_status_fetcher_->arm()->omega2(), kEpsOmega);
 
     // Check that our simulator matches the status
-    EXPECT_NEAR(superstructure_plant_.arm().proximal_position(),
+    EXPECT_NEAR(superstructure_plant_.arm()->proximal_position(),
                 superstructure_status_fetcher_->arm()->theta0(), kEpsTheta);
-    EXPECT_NEAR(superstructure_plant_.arm().distal_position(),
+    EXPECT_NEAR(superstructure_plant_.arm()->distal_position(),
                 superstructure_status_fetcher_->arm()->theta1(), kEpsTheta);
-    EXPECT_NEAR(superstructure_plant_.arm().roll_joint_position(),
+    EXPECT_NEAR(superstructure_plant_.arm()->roll_joint_position(),
                 superstructure_status_fetcher_->arm()->theta2(), kEpsTheta);
-    EXPECT_NEAR(superstructure_plant_.arm().proximal_velocity(),
+    EXPECT_NEAR(superstructure_plant_.arm()->proximal_velocity(),
                 superstructure_status_fetcher_->arm()->omega0(), kEpsOmega);
-    EXPECT_NEAR(superstructure_plant_.arm().distal_velocity(),
+    EXPECT_NEAR(superstructure_plant_.arm()->distal_velocity(),
                 superstructure_status_fetcher_->arm()->omega1(), kEpsOmega);
-    EXPECT_NEAR(superstructure_plant_.arm().roll_joint_velocity(),
+    EXPECT_NEAR(superstructure_plant_.arm()->roll_joint_velocity(),
                 superstructure_status_fetcher_->arm()->omega2(), kEpsOmega);
+
+    if (superstructure_goal_fetcher_->has_wrist()) {
+      EXPECT_NEAR(superstructure_goal_fetcher_->wrist()->unsafe_goal(),
+                  superstructure_status_fetcher_->wrist()->position(), 0.001);
+    }
   }
 
   void CheckIfZeroed() {
@@ -392,13 +445,20 @@
 // still.
 TEST_F(SuperstructureTest, DoesNothing) {
   SetEnabled(true);
-
   WaitUntilZeroed();
 
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
 
+    flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
+        wrist_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
+            *builder.fbb(), constants::Values::kWristRange().middle());
+
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_trajectory_override(false);
+    goal_builder.add_wrist(wrist_offset);
+    goal_builder.add_roller_goal(RollerGoal::IDLE);
 
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
@@ -411,13 +471,20 @@
 // Tests that loops can reach a goal.
 TEST_F(SuperstructureTest, ReachesGoal) {
   SetEnabled(true);
-
   WaitUntilZeroed();
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
+        wrist_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
+            *builder.fbb(), constants::Values::kWristRange().upper);
+
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
     goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_trajectory_override(false);
+    goal_builder.add_wrist(wrist_offset);
+    goal_builder.add_roller_goal(RollerGoal::IDLE);
 
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
@@ -439,8 +506,14 @@
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
 
+    flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
+        wrist_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
+            *builder.fbb(), constants::Values::kWristRange().upper);
+
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
+    goal_builder.add_wrist(wrist_offset);
+
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
   RunFor(chrono::seconds(20));
@@ -451,11 +524,21 @@
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
 
+    flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
+        wrist_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
+            *builder.fbb(), constants::Values::kWristRange().lower,
+            CreateProfileParameters(*builder.fbb(), 20.0, 0.1));
+
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
+    goal_builder.add_wrist(wrist_offset);
+
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
 
+  superstructure_plant_.wrist()->set_peak_velocity(23.0);
+  superstructure_plant_.wrist()->set_peak_acceleration(0.2);
+
   // TODO(Milo): Make this a sane time
   RunFor(chrono::seconds(20));
   VerifyNearGoal();
@@ -466,6 +549,10 @@
   SetEnabled(true);
   WaitUntilZeroed();
   RunFor(chrono::seconds(2));
+
+  EXPECT_EQ(ArmState::RUNNING, superstructure_.arm().state());
+  EXPECT_EQ(Superstructure::AbsoluteEncoderSubsystem::State::RUNNING,
+            superstructure_.wrist().state());
 }
 
 // Tests that running disabled works
@@ -474,6 +561,178 @@
   CheckIfZeroed();
 }
 
+class SuperstructureBeambreakTest
+    : public SuperstructureTest,
+      public ::testing::WithParamInterface<GamePiece> {
+ public:
+  void SetBeambreak(GamePiece game_piece, bool status) {
+    if (game_piece == GamePiece::kCone) {
+      superstructure_plant_.set_end_effector_cone_beam_break(status);
+    } else {
+      superstructure_plant_.set_end_effector_cube_beam_break(status);
+    }
+  }
+};
+
+TEST_P(SuperstructureBeambreakTest, EndEffectorGoal) {
+  SetEnabled(true);
+  WaitUntilZeroed();
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_trajectory_override(false);
+    goal_builder.add_roller_goal(RollerGoal::INTAKE);
+
+    builder.CheckOk(builder.Send(goal_builder.Finish()));
+  }
+
+  // This makes sure that we intake as normal when
+  // requesting intake.
+  RunFor(constants::Values::kExtraIntakingTime());
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
+            constants::Values::kRollerVoltage());
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::INTAKING);
+
+  SetBeambreak(GetParam(), true);
+
+  // Checking that after the beambreak is set once intaking that the
+  // state changes to LOADED.
+  RunFor(dt());
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(), 0.0);
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::LOADED);
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_trajectory_override(false);
+    goal_builder.add_roller_goal(RollerGoal::IDLE);
+
+    builder.CheckOk(builder.Send(goal_builder.Finish()));
+  }
+
+  SetBeambreak(GetParam(), false);
+  // Checking that it's going back to intaking because we lost the
+  // beambreak sensor.
+  RunFor(dt() * 2);
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
+            constants::Values::kRollerVoltage());
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::INTAKING);
+
+  // Checking that we go back to idle after beambreak is lost and we
+  // set our goal to idle.
+  RunFor(dt() * 2 + constants::Values::kExtraIntakingTime());
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(), 0.0);
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::IDLE);
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_trajectory_override(false);
+    goal_builder.add_roller_goal(RollerGoal::INTAKE);
+
+    builder.CheckOk(builder.Send(goal_builder.Finish()));
+  }
+
+  // Going through intake -> loaded -> spitting
+  // Making sure that it's intaking normally.
+  RunFor(constants::Values::kExtraIntakingTime());
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
+            constants::Values::kRollerVoltage());
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::INTAKING);
+
+  SetBeambreak(GetParam(), true);
+
+  // Checking that it's loaded once beambreak is sensing something.
+  RunFor(dt());
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(), 0.0);
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::LOADED);
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_trajectory_override(false);
+    goal_builder.add_roller_goal(RollerGoal::SPIT);
+
+    builder.CheckOk(builder.Send(goal_builder.Finish()));
+  }
+
+  // Checking that it stays spitting until 2 seconds after the
+  // beambreak is lost.
+  RunFor(dt() * 10);
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
+            -constants::Values::kRollerVoltage());
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::SPITTING);
+
+  SetBeambreak(GetParam(), false);
+
+  RunFor(constants::Values::kExtraSpittingTime());
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
+            -constants::Values::kRollerVoltage());
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::SPITTING);
+
+  // Checking that it goes to idle after it's given time to stop spitting.
+  RunFor(dt() * 3);
+
+  ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
+  ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
+
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(), 0.0);
+  EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
+            EndEffectorState::IDLE);
+}
+
 // Tests that we don't freak out without a goal.
 TEST_F(SuperstructureTest, ArmSimpleGoal) {
   SetEnabled(true);
@@ -543,6 +802,9 @@
   VerifyNearGoal();
 }
 
+INSTANTIATE_TEST_SUITE_P(EndEffectorGoal, SuperstructureBeambreakTest,
+                         ::testing::Values(GamePiece::kCone, GamePiece::kCube));
+
 }  // namespace testing
 }  // namespace superstructure
 }  // namespace control_loops
diff --git a/y2023/control_loops/superstructure/superstructure_position.fbs b/y2023/control_loops/superstructure/superstructure_position.fbs
index bd3d607..927fe10 100644
--- a/y2023/control_loops/superstructure/superstructure_position.fbs
+++ b/y2023/control_loops/superstructure/superstructure_position.fbs
@@ -24,6 +24,12 @@
     // Zero for wrist is facing staright outward.
     // Positive position would be upwards
     wrist:frc971.AbsolutePosition (id: 1);
+
+    // If this is true, the cone beam break is triggered.
+    end_effector_cone_beam_break:bool (id: 2);
+
+    // If this is true, the cube beam break is triggered.
+    end_effector_cube_beam_break:bool (id: 3);
 }
 
 root_type Position;
diff --git a/y2023/control_loops/superstructure/superstructure_status.fbs b/y2023/control_loops/superstructure/superstructure_status.fbs
index db86687..2555134 100644
--- a/y2023/control_loops/superstructure/superstructure_status.fbs
+++ b/y2023/control_loops/superstructure/superstructure_status.fbs
@@ -57,6 +57,19 @@
   failed_solutions:uint32 (id: 17);
 }
 
+// State of the superstructure state machine
+enum EndEffectorState : ubyte {
+  // Not doing anything
+  IDLE = 0,
+  // Intaking the game object into the end effector
+  INTAKING = 1,
+  // The game object is loaded into the end effector
+  LOADED = 2,
+  // Waiting for the arm to be at shooting goal and then telling the
+  // end effector to spit
+  SPITTING = 3,
+}
+
 table Status {
   // All subsystems know their location.
   zeroed:bool (id: 0);
@@ -67,6 +80,8 @@
   arm:ArmStatus (id: 2);
 
   wrist:frc971.control_loops.AbsoluteEncoderProfiledJointStatus (id: 3);
+
+  end_effector_state:EndEffectorState (id: 4);
 }
 
 root_type Status;
diff --git a/y2023/joystick_reader.cc b/y2023/joystick_reader.cc
index 3caaa41..adeda2b 100644
--- a/y2023/joystick_reader.cc
+++ b/y2023/joystick_reader.cc
@@ -30,6 +30,7 @@
 using frc971::input::driver_station::ControlBit;
 using frc971::input::driver_station::JoystickAxis;
 using frc971::input::driver_station::POVLocation;
+using y2023::control_loops::superstructure::RollerGoal;
 
 namespace y2023 {
 namespace input {
@@ -66,16 +67,15 @@
       return;
     }
 
-    bool intake = false;
-    bool spit = false;
+    RollerGoal roller_goal = RollerGoal::IDLE;
 
     // TODO(milind): add more actions and paths
     if (data.IsPressed(kIntake)) {
-      intake = true;
-      arm_goal_position_ = arm::PickupPosIndex();
+      roller_goal = RollerGoal::INTAKE;
+      arm_goal_position_ = arm::ScorePosIndex();
     } else if (data.IsPressed(kSpit)) {
-      spit = true;
-      arm_goal_position_ = arm::PickupPosIndex();
+      roller_goal = RollerGoal::SPIT;
+      arm_goal_position_ = arm::ScorePosIndex();
     } else if (data.IsPressed(kScore)) {
       arm_goal_position_ = arm::ScorePosIndex();
     }
@@ -86,8 +86,7 @@
       superstructure::Goal::Builder superstructure_goal_builder =
           builder.MakeBuilder<superstructure::Goal>();
       superstructure_goal_builder.add_arm_goal_position(arm_goal_position_);
-      superstructure_goal_builder.add_intake(intake);
-      superstructure_goal_builder.add_spit(spit);
+      superstructure_goal_builder.add_roller_goal(roller_goal);
       if (builder.Send(superstructure_goal_builder.Finish()) !=
           aos::RawSender::Error::kOk) {
         AOS_LOG(ERROR, "Sending superstructure goal failed.\n");
diff --git a/y2023/localizer/BUILD b/y2023/localizer/BUILD
new file mode 100644
index 0000000..8f1237c
--- /dev/null
+++ b/y2023/localizer/BUILD
@@ -0,0 +1,101 @@
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
+
+flatbuffer_cc_library(
+    name = "status_fbs",
+    srcs = [
+        "status.fbs",
+    ],
+    gen_reflections = True,
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
+        "//frc971/imu_reader:imu_failures_fbs",
+    ],
+)
+
+flatbuffer_ts_library(
+    name = "status_ts_fbs",
+    srcs = ["status.fbs"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//frc971/control_loops/drivetrain:drivetrain_status_ts_fbs",
+        "//frc971/imu_reader:imu_failures_ts_fbs",
+    ],
+)
+
+flatbuffer_cc_library(
+    name = "visualization_fbs",
+    srcs = [
+        "visualization.fbs",
+    ],
+    gen_reflections = True,
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":status_fbs",
+    ],
+)
+
+flatbuffer_ts_library(
+    name = "visualization_ts_fbs",
+    srcs = ["visualization.fbs"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":status_ts_fbs",
+    ],
+)
+
+cc_library(
+    name = "localizer",
+    srcs = ["localizer.cc"],
+    hdrs = ["localizer.h"],
+    deps = [
+        ":status_fbs",
+        ":visualization_fbs",
+        "//aos/containers:sized_array",
+        "//aos/events:event_loop",
+        "//frc971/constants:constants_sender_lib",
+        "//frc971/control_loops:pose",
+        "//frc971/control_loops/drivetrain:hybrid_ekf",
+        "//frc971/control_loops/drivetrain:improved_down_estimator",
+        "//frc971/control_loops/drivetrain:localizer_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//frc971/control_loops/drivetrain/localization:utils",
+        "//frc971/imu_reader:imu_watcher",
+        "//frc971/vision:target_map_fbs",
+        "//y2023:constants",
+        "//y2023/constants:constants_fbs",
+    ],
+)
+
+cc_test(
+    name = "localizer_test",
+    srcs = ["localizer_test.cc"],
+    data = ["//y2023:aos_config"],
+    deps = [
+        ":localizer",
+        ":status_fbs",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_writer",
+        "//aos/testing:googletest",
+        "//frc971/control_loops/drivetrain:drivetrain_test_lib",
+        "//frc971/control_loops/drivetrain:localizer_fbs",
+        "//y2023/constants:simulated_constants_sender",
+        "//y2023/control_loops/drivetrain:drivetrain_base",
+    ],
+)
+
+cc_binary(
+    name = "localizer_main",
+    srcs = ["localizer_main.cc"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":localizer",
+        "//aos:init",
+        "//aos/events:shm_event_loop",
+        "//frc971/constants:constants_sender_lib",
+        "//y2023/control_loops/drivetrain:drivetrain_base",
+    ],
+)
diff --git a/y2023/localizer/localizer.cc b/y2023/localizer/localizer.cc
new file mode 100644
index 0000000..1d78528
--- /dev/null
+++ b/y2023/localizer/localizer.cc
@@ -0,0 +1,429 @@
+#include "y2023/localizer/localizer.h"
+
+#include "aos/containers/sized_array.h"
+#include "frc971/control_loops/drivetrain/localizer_generated.h"
+#include "frc971/control_loops/pose.h"
+#include "y2023/constants.h"
+
+namespace y2023::localizer {
+namespace {
+constexpr std::array<std::string_view, Localizer::kNumCameras> kPisToUse{
+    "pi1", "pi2", "pi3", "pi4"};
+
+size_t CameraIndexForName(std::string_view name) {
+  for (size_t index = 0; index < kPisToUse.size(); ++index) {
+    if (name == kPisToUse.at(index)) {
+      return index;
+    }
+  }
+  LOG(FATAL) << "No camera named " << name;
+}
+
+std::map<uint64_t, Localizer::Transform> GetTargetLocations(
+    const Constants &constants) {
+  CHECK(constants.has_target_map());
+  CHECK(constants.target_map()->has_target_poses());
+  std::map<uint64_t, Localizer::Transform> transforms;
+  for (const frc971::vision::TargetPoseFbs *target :
+       *constants.target_map()->target_poses()) {
+    CHECK(target->has_id());
+    CHECK(target->has_position());
+    CHECK(target->has_orientation());
+    CHECK_EQ(0u, transforms.count(target->id()));
+    transforms[target->id()] = PoseToTransform(target);
+  }
+  return transforms;
+}
+}  // namespace
+
+Localizer::Transform PoseToTransform(
+    const frc971::vision::TargetPoseFbs *pose) {
+  const frc971::vision::Position *position = pose->position();
+  const frc971::vision::Quaternion *quaternion = pose->orientation();
+  return (Eigen::Translation3d(
+              Eigen::Vector3d(position->x(), position->y(), position->z())) *
+          Eigen::Quaterniond(quaternion->w(), quaternion->x(), quaternion->y(),
+                             quaternion->z()))
+      .matrix();
+}
+
+std::array<Localizer::CameraState, Localizer::kNumCameras>
+Localizer::MakeCameras(const Constants &constants, aos::EventLoop *event_loop) {
+  CHECK(constants.has_cameras());
+  std::array<Localizer::CameraState, Localizer::kNumCameras> cameras;
+  for (const CameraConfiguration *camera : *constants.cameras()) {
+    CHECK(camera->has_calibration());
+    const frc971::vision::calibration::CameraCalibration *calibration =
+        camera->calibration();
+    CHECK(!calibration->has_turret_extrinsics())
+        << "The 2023 robot does not have a turret.";
+    CHECK(calibration->has_node_name());
+    const size_t index =
+        CameraIndexForName(calibration->node_name()->string_view());
+    // We default-construct the extrinsics matrix to all-zeros; use that to
+    // sanity-check whether we have populated the matrix yet or not.
+    CHECK(cameras.at(index).extrinsics.norm() == 0)
+        << "Got multiple calibrations for "
+        << calibration->node_name()->string_view();
+    CHECK(calibration->has_fixed_extrinsics());
+    cameras.at(index).extrinsics =
+        frc971::control_loops::drivetrain::FlatbufferToTransformationMatrix(
+            *calibration->fixed_extrinsics());
+    cameras.at(index).debug_sender = event_loop->MakeSender<Visualization>(
+        absl::StrCat("/", calibration->node_name()->string_view(), "/camera"));
+  }
+  for (const CameraState &camera : cameras) {
+    CHECK(camera.extrinsics.norm() != 0) << "Missing a camera calibration.";
+  }
+  return cameras;
+}
+
+Localizer::Localizer(
+    aos::EventLoop *event_loop,
+    const frc971::control_loops::drivetrain::DrivetrainConfig<double> dt_config)
+    : event_loop_(event_loop),
+      dt_config_(dt_config),
+      constants_fetcher_(event_loop),
+      cameras_(MakeCameras(constants_fetcher_.constants(), event_loop)),
+      target_poses_(GetTargetLocations(constants_fetcher_.constants())),
+      down_estimator_(dt_config),
+      ekf_(dt_config),
+      observations_(&ekf_),
+      imu_watcher_(event_loop, dt_config,
+                   y2023::constants::Values::DrivetrainEncoderToMeters(1),
+                   std::bind(&Localizer::HandleImu, this, std::placeholders::_1,
+                             std::placeholders::_2, std::placeholders::_3,
+                             std::placeholders::_4, std::placeholders::_5)),
+      utils_(event_loop),
+      status_sender_(event_loop->MakeSender<Status>("/localizer")),
+      output_sender_(event_loop->MakeSender<frc971::controls::LocalizerOutput>(
+          "/localizer")) {
+  if (dt_config_.is_simulated) {
+    down_estimator_.assume_perfect_gravity();
+  }
+
+  for (size_t camera_index = 0; camera_index < kNumCameras; ++camera_index) {
+    const std::string_view pi_name = kPisToUse.at(camera_index);
+    event_loop->MakeWatcher(
+        absl::StrCat("/", pi_name, "/camera"),
+        [this, pi_name,
+         camera_index](const frc971::vision::TargetMap &targets) {
+          CHECK(targets.has_target_poses());
+          CHECK(targets.has_monotonic_timestamp_ns());
+          const std::optional<aos::monotonic_clock::duration> clock_offset =
+              utils_.ClockOffset(pi_name);
+          if (!clock_offset.has_value()) {
+            VLOG(1) << "Rejecting image due to disconnected message bridge at "
+                    << event_loop_->monotonic_now();
+            cameras_.at(camera_index)
+                .rejection_counter.IncrementError(
+                    RejectionReason::MESSAGE_BRIDGE_DISCONNECTED);
+            return;
+          }
+          const aos::monotonic_clock::time_point pi_capture_time(
+              std::chrono::nanoseconds(targets.monotonic_timestamp_ns()) -
+              clock_offset.value());
+          const aos::monotonic_clock::time_point capture_time =
+              pi_capture_time - imu_watcher_.pico_offset_error();
+          VLOG(2) << "Capture time of "
+                  << targets.monotonic_timestamp_ns() * 1e-9
+                  << " clock offset of "
+                  << aos::time::DurationInSeconds(clock_offset.value())
+                  << " pico error "
+                  << aos::time::DurationInSeconds(
+                         imu_watcher_.pico_offset_error());
+          if (pi_capture_time > event_loop_->context().monotonic_event_time) {
+            VLOG(1) << "Rejecting image due to being from future at "
+                    << event_loop_->monotonic_now() << " with timestamp of "
+                    << pi_capture_time << " and event time pf "
+                    << event_loop_->context().monotonic_event_time;
+            cameras_.at(camera_index)
+                .rejection_counter.IncrementError(
+                    RejectionReason::IMAGE_FROM_FUTURE);
+            return;
+          }
+          auto builder = cameras_.at(camera_index).debug_sender.MakeBuilder();
+          aos::SizedArray<flatbuffers::Offset<TargetEstimateDebug>, 20>
+              debug_offsets;
+          for (const frc971::vision::TargetPoseFbs *target :
+               *targets.target_poses()) {
+            VLOG(1) << "Handling target from " << camera_index;
+            auto offset = HandleTarget(camera_index, capture_time, *target,
+                                       builder.fbb());
+            if (debug_offsets.size() < debug_offsets.capacity()) {
+              debug_offsets.push_back(offset);
+            } else {
+              AOS_LOG(ERROR, "Dropped message from debug vector.");
+            }
+          }
+          auto vector_offset = builder.fbb()->CreateVector(
+              debug_offsets.data(), debug_offsets.size());
+          auto stats_offset =
+              StatisticsForCamera(cameras_.at(camera_index), builder.fbb());
+          Visualization::Builder visualize_builder(*builder.fbb());
+          visualize_builder.add_targets(vector_offset);
+          visualize_builder.add_statistics(stats_offset);
+          builder.CheckOk(builder.Send(visualize_builder.Finish()));
+          SendStatus();
+        });
+  }
+
+  event_loop_->AddPhasedLoop([this](int) { SendOutput(); },
+                             std::chrono::milliseconds(5));
+
+  event_loop_->MakeWatcher(
+      "/drivetrain",
+      [this](
+          const frc971::control_loops::drivetrain::LocalizerControl &control) {
+        const double theta = control.keep_current_theta()
+                                 ? ekf_.X_hat(StateIdx::kTheta)
+                                 : control.theta();
+        const double left_encoder = ekf_.X_hat(StateIdx::kLeftEncoder);
+        const double right_encoder = ekf_.X_hat(StateIdx::kRightEncoder);
+        ekf_.ResetInitialState(
+            t_,
+            (HybridEkf::State() << control.x(), control.y(), theta,
+             left_encoder, 0, right_encoder, 0, 0, 0, 0, 0, 0)
+                .finished(),
+            ekf_.P());
+      });
+
+  ekf_.set_ignore_accel(true);
+  // Priority should be lower than the imu reading process, but non-zero.
+  event_loop->SetRuntimeRealtimePriority(10);
+  event_loop->OnRun([this, event_loop]() {
+    ekf_.ResetInitialState(event_loop->monotonic_now(),
+                           HybridEkf::State::Zero(), ekf_.P());
+  });
+}
+
+void Localizer::HandleImu(aos::monotonic_clock::time_point sample_time_pico,
+                          aos::monotonic_clock::time_point sample_time_pi,
+                          std::optional<Eigen::Vector2d> encoders,
+                          Eigen::Vector3d gyro, Eigen::Vector3d accel) {
+  last_encoder_readings_ = encoders;
+  // Ignore ivnalid readings; the HybridEkf will handle it reasonably.
+  if (!encoders.has_value()) {
+    return;
+  }
+  if (t_ == aos::monotonic_clock::min_time) {
+    t_ = sample_time_pico;
+  }
+  if (t_ + 10 * frc971::controls::ImuWatcher::kNominalDt < sample_time_pico) {
+    t_ = sample_time_pico;
+    ++clock_resets_;
+  }
+  const aos::monotonic_clock::duration dt = sample_time_pico - t_;
+  t_ = sample_time_pico;
+  // We don't actually use the down estimator currently, but it's really
+  // convenient for debugging.
+  down_estimator_.Predict(gyro, accel, dt);
+  const double yaw_rate = (dt_config_.imu_transform * gyro)(2);
+  ekf_.UpdateEncodersAndGyro(encoders.value()(0), encoders.value()(1), yaw_rate,
+                             utils_.VoltageOrZero(sample_time_pi), accel, t_);
+  SendStatus();
+}
+
+flatbuffers::Offset<TargetEstimateDebug> Localizer::RejectImage(
+    int camera_index, RejectionReason reason,
+    TargetEstimateDebug::Builder *builder) {
+  builder->add_accepted(false);
+  builder->add_rejection_reason(reason);
+  cameras_.at(camera_index).rejection_counter.IncrementError(reason);
+  return builder->Finish();
+}
+
+flatbuffers::Offset<TargetEstimateDebug> Localizer::HandleTarget(
+    int camera_index, const aos::monotonic_clock::time_point capture_time,
+    const frc971::vision::TargetPoseFbs &target,
+    flatbuffers::FlatBufferBuilder *debug_fbb) {
+  ++total_candidate_targets_;
+  ++cameras_.at(camera_index).total_candidate_targets;
+
+  TargetEstimateDebug::Builder builder(*debug_fbb);
+  builder.add_camera(camera_index);
+  builder.add_image_age_sec(aos::time::DurationInSeconds(
+      event_loop_->monotonic_now() - capture_time));
+
+  const uint64_t target_id = target.id();
+  VLOG(2) << aos::FlatbufferToJson(&target);
+  if (target_poses_.count(target_id) == 0) {
+    VLOG(1) << "Rejecting target due to invalid ID " << target_id;
+    return RejectImage(camera_index, RejectionReason::NO_SUCH_TARGET, &builder);
+  }
+
+  const Transform &H_field_target = target_poses_.at(target_id);
+  const Transform &H_robot_camera = cameras_.at(camera_index).extrinsics;
+
+  const Transform H_camera_target = PoseToTransform(&target);
+
+  const Transform H_field_camera = H_field_target * H_camera_target.inverse();
+  // Back out the robot position that is implied by the current camera
+  // reading. Note that the Pose object ignores any roll/pitch components, so
+  // if the camera's extrinsics for pitch/roll are off, this should just
+  // ignore it.
+  const frc971::control_loops::Pose measured_camera_pose(H_field_camera);
+  builder.add_camera_x(measured_camera_pose.rel_pos().x());
+  builder.add_camera_y(measured_camera_pose.rel_pos().y());
+  // Because the camera uses Z as forwards rather than X, just calculate the
+  // debugging theta value using the transformation matrix directly.
+  builder.add_camera_theta(
+      std::atan2(H_field_camera(1, 2), H_field_camera(0, 2)));
+  // Calculate the camera-to-robot transformation matrix ignoring the
+  // pitch/roll of the camera.
+  const Transform H_camera_robot_stripped =
+      frc971::control_loops::Pose(H_robot_camera)
+          .AsTransformationMatrix()
+          .inverse();
+  const frc971::control_loops::Pose measured_pose(
+      measured_camera_pose.AsTransformationMatrix() * H_camera_robot_stripped);
+  // This "Z" is the robot pose directly implied by the camera results.
+  const Eigen::Matrix<double, 3, 1> Z(measured_pose.rel_pos().x(),
+                                      measured_pose.rel_pos().y(),
+                                      measured_pose.rel_theta());
+  builder.add_implied_robot_x(Z(Corrector::kX));
+  builder.add_implied_robot_y(Z(Corrector::kY));
+  builder.add_implied_robot_theta(Z(Corrector::kTheta));
+
+  // TODO(james): Tune this. Also, gain schedule for auto mode?
+  Eigen::Matrix<double, 3, 1> noises(1.0, 1.0, 0.5);
+
+  Eigen::Matrix3d R = Eigen::Matrix3d::Zero();
+  R.diagonal() = noises.cwiseAbs2();
+  // In order to do the EKF correction, we determine the expected state based
+  // on the state at the time the image was captured; however, we insert the
+  // correction update itself at the current time. This is technically not
+  // quite correct, but saves substantial CPU usage & code complexity by
+  // making
+  // it so that we don't have to constantly rewind the entire EKF history.
+  const std::optional<State> state_at_capture =
+      ekf_.LastStateBeforeTime(capture_time);
+
+  if (!state_at_capture.has_value()) {
+    VLOG(1) << "Rejecting image due to being too old.";
+    return RejectImage(camera_index, RejectionReason::IMAGE_TOO_OLD, &builder);
+  }
+
+  const Input U = ekf_.MostRecentInput();
+  VLOG(1) << "previous state " << ekf_.X_hat().topRows<3>().transpose();
+  // For the correction step, instead of passing in the measurement directly,
+  // we pass in (0, 0, 0) as the measurement and then for the expected
+  // measurement (Zhat) we calculate the error between the pose implied by
+  // the camera measurement and the current estimate of the
+  // pose. This doesn't affect any of the math, it just makes the code a bit
+  // more convenient to write given the Correct() interface we already have.
+  observations_.CorrectKnownH(Eigen::Vector3d::Zero(), &U,
+                              Corrector(state_at_capture.value(), Z), R, t_);
+  ++total_accepted_targets_;
+  ++cameras_.at(camera_index).total_accepted_targets;
+  VLOG(1) << "new state " << ekf_.X_hat().topRows<3>().transpose();
+  return builder.Finish();
+}
+
+Localizer::Output Localizer::Corrector::H(const State &, const Input &) {
+  CHECK(Z_.allFinite());
+  Eigen::Vector3d Zhat = H_ * state_at_capture_ - Z_;
+  // Rewrap angle difference to put it back in range.
+  Zhat(2) = aos::math::NormalizeAngle(Zhat(2));
+  VLOG(1) << "Zhat " << Zhat.transpose() << " Z_ " << Z_.transpose()
+          << " state " << (H_ * state_at_capture_).transpose();
+  return Zhat;
+}
+
+void Localizer::SendOutput() {
+  auto builder = output_sender_.MakeBuilder();
+  frc971::controls::LocalizerOutput::Builder output_builder =
+      builder.MakeBuilder<frc971::controls::LocalizerOutput>();
+  output_builder.add_monotonic_timestamp_ns(
+      std::chrono::duration_cast<std::chrono::nanoseconds>(
+          event_loop_->context().monotonic_event_time.time_since_epoch())
+          .count());
+  output_builder.add_x(ekf_.X_hat(StateIdx::kX));
+  output_builder.add_y(ekf_.X_hat(StateIdx::kY));
+  output_builder.add_theta(ekf_.X_hat(StateIdx::kTheta));
+  output_builder.add_zeroed(imu_watcher_.zeroer().Zeroed());
+  output_builder.add_image_accepted_count(total_accepted_targets_);
+  const Eigen::Quaterniond &orientation =
+      Eigen::AngleAxis<double>(ekf_.X_hat(StateIdx::kTheta),
+                               Eigen::Vector3d::UnitZ()) *
+      down_estimator_.X_hat();
+  frc971::controls::Quaternion quaternion;
+  quaternion.mutate_x(orientation.x());
+  quaternion.mutate_y(orientation.y());
+  quaternion.mutate_z(orientation.z());
+  quaternion.mutate_w(orientation.w());
+  output_builder.add_orientation(&quaternion);
+  builder.CheckOk(builder.Send(output_builder.Finish()));
+}
+
+flatbuffers::Offset<frc971::control_loops::drivetrain::LocalizerState>
+Localizer::PopulateState(flatbuffers::FlatBufferBuilder *fbb) const {
+  frc971::control_loops::drivetrain::LocalizerState::Builder builder(*fbb);
+  builder.add_x(ekf_.X_hat(StateIdx::kX));
+  builder.add_y(ekf_.X_hat(StateIdx::kY));
+  builder.add_theta(ekf_.X_hat(StateIdx::kTheta));
+  builder.add_left_velocity(ekf_.X_hat(StateIdx::kLeftVelocity));
+  builder.add_right_velocity(ekf_.X_hat(StateIdx::kRightVelocity));
+  builder.add_left_encoder(ekf_.X_hat(StateIdx::kLeftEncoder));
+  builder.add_right_encoder(ekf_.X_hat(StateIdx::kRightEncoder));
+  builder.add_left_voltage_error(ekf_.X_hat(StateIdx::kLeftVoltageError));
+  builder.add_right_voltage_error(ekf_.X_hat(StateIdx::kRightVoltageError));
+  builder.add_angular_error(ekf_.X_hat(StateIdx::kAngularError));
+  builder.add_longitudinal_velocity_offset(
+      ekf_.X_hat(StateIdx::kLongitudinalVelocityOffset));
+  builder.add_lateral_velocity(ekf_.X_hat(StateIdx::kLateralVelocity));
+  return builder.Finish();
+}
+
+flatbuffers::Offset<ImuStatus> Localizer::PopulateImu(
+    flatbuffers::FlatBufferBuilder *fbb) const {
+  const auto zeroer_offset = imu_watcher_.zeroer().PopulateStatus(fbb);
+  const auto failures_offset = imu_watcher_.PopulateImuFailures(fbb);
+  ImuStatus::Builder builder(*fbb);
+  builder.add_zeroed(imu_watcher_.zeroer().Zeroed());
+  builder.add_faulted_zero(imu_watcher_.zeroer().Faulted());
+  builder.add_zeroing(zeroer_offset);
+  if (imu_watcher_.pico_offset().has_value()) {
+    builder.add_pico_offset_ns(imu_watcher_.pico_offset().value().count());
+    builder.add_pico_offset_error_ns(imu_watcher_.pico_offset_error().count());
+  }
+  if (last_encoder_readings_.has_value()) {
+    builder.add_left_encoder(last_encoder_readings_.value()(0));
+    builder.add_right_encoder(last_encoder_readings_.value()(1));
+  }
+  builder.add_imu_failures(failures_offset);
+  return builder.Finish();
+}
+
+flatbuffers::Offset<CumulativeStatistics> Localizer::StatisticsForCamera(
+    const CameraState &camera, flatbuffers::FlatBufferBuilder *fbb) {
+  const auto counts_offset = camera.rejection_counter.PopulateCounts(fbb);
+  CumulativeStatistics::Builder stats_builder(*fbb);
+  stats_builder.add_total_accepted(camera.total_accepted_targets);
+  stats_builder.add_total_candidates(camera.total_candidate_targets);
+  stats_builder.add_rejection_reasons(counts_offset);
+  return stats_builder.Finish();
+}
+
+void Localizer::SendStatus() {
+  auto builder = status_sender_.MakeBuilder();
+  std::array<flatbuffers::Offset<CumulativeStatistics>, kNumCameras>
+      stats_offsets;
+  for (size_t ii = 0; ii < kNumCameras; ++ii) {
+    stats_offsets.at(ii) = StatisticsForCamera(cameras_.at(ii), builder.fbb());
+  }
+  auto stats_offset =
+      builder.fbb()->CreateVector(stats_offsets.data(), stats_offsets.size());
+  auto down_estimator_offset =
+      down_estimator_.PopulateStatus(builder.fbb(), t_);
+  auto imu_offset = PopulateImu(builder.fbb());
+  auto state_offset = PopulateState(builder.fbb());
+  Status::Builder status_builder = builder.MakeBuilder<Status>();
+  status_builder.add_state(state_offset);
+  status_builder.add_down_estimator(down_estimator_offset);
+  status_builder.add_imu(imu_offset);
+  status_builder.add_statistics(stats_offset);
+  builder.CheckOk(builder.Send(status_builder.Finish()));
+}
+
+}  // namespace y2023::localizer
diff --git a/y2023/localizer/localizer.h b/y2023/localizer/localizer.h
new file mode 100644
index 0000000..8c53467
--- /dev/null
+++ b/y2023/localizer/localizer.h
@@ -0,0 +1,123 @@
+#ifndef Y2023_LOCALIZER_LOCALIZER_H_
+#define Y2023_LOCALIZER_LOCALIZER_H_
+
+#include <array>
+#include <map>
+
+#include "frc971/constants/constants_sender_lib.h"
+#include "frc971/control_loops/drivetrain/hybrid_ekf.h"
+#include "frc971/control_loops/drivetrain/improved_down_estimator.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
+#include "frc971/control_loops/drivetrain/localization/utils.h"
+#include "frc971/imu_reader/imu_watcher.h"
+#include "frc971/vision/target_map_generated.h"
+#include "y2023/constants/constants_generated.h"
+#include "y2023/localizer/status_generated.h"
+#include "y2023/localizer/visualization_generated.h"
+
+namespace y2023::localizer {
+
+class Localizer {
+ public:
+  static constexpr size_t kNumCameras = 4;
+  typedef Eigen::Matrix<double, 4, 4> Transform;
+  typedef frc971::control_loops::drivetrain::HybridEkf<double> HybridEkf;
+  typedef HybridEkf::State State;
+  typedef HybridEkf::Output Output;
+  typedef HybridEkf::Input Input;
+  typedef HybridEkf::StateIdx StateIdx;
+  Localizer(aos::EventLoop *event_loop,
+            const frc971::control_loops::drivetrain::DrivetrainConfig<double>
+                dt_config);
+
+ private:
+  class Corrector : public HybridEkf::ExpectedObservationFunctor {
+   public:
+    // Indices used for each of the members of the output vector for this
+    // Corrector.
+    enum OutputIdx {
+      kX = 0,
+      kY = 1,
+      kTheta = 2,
+    };
+    Corrector(const State &state_at_capture, const Eigen::Vector3d &Z)
+        : state_at_capture_(state_at_capture), Z_(Z) {
+      H_.setZero();
+      H_(kX, StateIdx::kX) = 1;
+      H_(kY, StateIdx::kY) = 1;
+      H_(kTheta, StateIdx::kTheta) = 1;
+    }
+    Output H(const State &, const Input &) final;
+    Eigen::Matrix<double, HybridEkf::kNOutputs, HybridEkf::kNStates> DHDX(
+        const State &) final {
+      return H_;
+    }
+
+   private:
+    Eigen::Matrix<double, HybridEkf::kNOutputs, HybridEkf::kNStates> H_;
+    const State state_at_capture_;
+    const Eigen::Vector3d &Z_;
+  };
+
+  struct CameraState {
+    aos::Sender<Visualization> debug_sender;
+    Transform extrinsics = Transform::Zero();
+    aos::util::ArrayErrorCounter<RejectionReason, RejectionCount>
+        rejection_counter;
+    size_t total_candidate_targets = 0;
+    size_t total_accepted_targets = 0;
+  };
+
+  static std::array<CameraState, kNumCameras> MakeCameras(
+      const Constants &constants, aos::EventLoop *event_loop);
+  flatbuffers::Offset<TargetEstimateDebug> HandleTarget(
+      int camera_index, const aos::monotonic_clock::time_point capture_time,
+      const frc971::vision::TargetPoseFbs &target,
+      flatbuffers::FlatBufferBuilder *debug_fbb);
+  void HandleImu(aos::monotonic_clock::time_point sample_time_pico,
+                 aos::monotonic_clock::time_point sample_time_pi,
+                 std::optional<Eigen::Vector2d> encoders, Eigen::Vector3d gyro,
+                 Eigen::Vector3d accel);
+  flatbuffers::Offset<TargetEstimateDebug> RejectImage(
+      int camera_index, RejectionReason reason,
+      TargetEstimateDebug::Builder *builder);
+
+  void SendOutput();
+  flatbuffers::Offset<frc971::control_loops::drivetrain::LocalizerState>
+  PopulateState(flatbuffers::FlatBufferBuilder *fbb) const;
+  flatbuffers::Offset<ImuStatus> PopulateImu(
+      flatbuffers::FlatBufferBuilder *fbb) const;
+  void SendStatus();
+  static flatbuffers::Offset<CumulativeStatistics> StatisticsForCamera(
+      const CameraState &camera, flatbuffers::FlatBufferBuilder *fbb);
+
+  aos::EventLoop *const event_loop_;
+  const frc971::control_loops::drivetrain::DrivetrainConfig<double> dt_config_;
+  frc971::constants::ConstantsFetcher<Constants> constants_fetcher_;
+  std::array<CameraState, kNumCameras> cameras_;
+  const std::array<Transform, kNumCameras> camera_extrinsics_;
+  const std::map<uint64_t, Transform> target_poses_;
+
+  frc971::control_loops::drivetrain::DrivetrainUkf down_estimator_;
+  HybridEkf ekf_;
+  HybridEkf::ExpectedObservationAllocator<Corrector> observations_;
+
+  frc971::controls::ImuWatcher imu_watcher_;
+  frc971::control_loops::drivetrain::LocalizationUtils utils_;
+
+  aos::Sender<Status> status_sender_;
+  aos::Sender<frc971::controls::LocalizerOutput> output_sender_;
+  aos::monotonic_clock::time_point t_ = aos::monotonic_clock::min_time;
+  size_t clock_resets_ = 0;
+
+  size_t total_candidate_targets_ = 0;
+  size_t total_accepted_targets_ = 0;
+
+  // For the status message.
+  std::optional<Eigen::Vector2d> last_encoder_readings_;
+};
+
+// Converts a TargetPoseFbs into a transformation matrix.
+Localizer::Transform PoseToTransform(const frc971::vision::TargetPoseFbs *pose);
+}  // namespace y2023::localizer
+#endif  // Y2023_LOCALIZER_LOCALIZER_H_
diff --git a/y2023/localizer/localizer_main.cc b/y2023/localizer/localizer_main.cc
new file mode 100644
index 0000000..98f0e81
--- /dev/null
+++ b/y2023/localizer/localizer_main.cc
@@ -0,0 +1,24 @@
+#include "aos/events/shm_event_loop.h"
+#include "aos/init.h"
+#include "frc971/constants/constants_sender_lib.h"
+#include "y2023/control_loops/drivetrain/drivetrain_base.h"
+#include "y2023/localizer/localizer.h"
+
+DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
+
+int main(int argc, char *argv[]) {
+  aos::InitGoogle(&argc, &argv);
+
+  aos::FlatbufferDetachedBuffer<aos::Configuration> config =
+      aos::configuration::ReadConfig(FLAGS_config);
+
+  frc971::constants::WaitForConstants<y2023::Constants>(&config.message());
+
+  aos::ShmEventLoop event_loop(&config.message());
+  y2023::localizer::Localizer localizer(
+      &event_loop, ::y2023::control_loops::drivetrain::GetDrivetrainConfig());
+
+  event_loop.Run();
+
+  return 0;
+}
diff --git a/y2023/localizer/localizer_test.cc b/y2023/localizer/localizer_test.cc
new file mode 100644
index 0000000..77af69b
--- /dev/null
+++ b/y2023/localizer/localizer_test.cc
@@ -0,0 +1,459 @@
+#include "y2023/localizer/localizer.h"
+
+#include "aos/events/logging/log_writer.h"
+#include "aos/events/simulated_event_loop.h"
+#include "frc971/control_loops/drivetrain/drivetrain_test_lib.h"
+#include "frc971/control_loops/drivetrain/localizer_generated.h"
+#include "frc971/control_loops/pose.h"
+#include "frc971/vision/target_map_generated.h"
+#include "gtest/gtest.h"
+#include "y2023/constants/simulated_constants_sender.h"
+#include "y2023/control_loops/drivetrain/drivetrain_base.h"
+#include "y2023/localizer/status_generated.h"
+
+DEFINE_string(output_folder, "",
+              "If set, logs all channels to the provided logfile.");
+DECLARE_bool(die_on_malloc);
+
+namespace y2023::localizer::testing {
+
+using frc971::control_loops::drivetrain::Output;
+
+class LocalizerTest : public ::testing::Test {
+ protected:
+  static constexpr uint64_t kTargetId = 1;
+  LocalizerTest()
+      : 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");
+        }()),
+        imu_node_(
+            aos::configuration::GetNode(&configuration_.message(), "imu")),
+        camera_node_(
+            aos::configuration::GetNode(&configuration_.message(), "pi1")),
+        dt_config_(frc971::control_loops::drivetrain::testing::
+                       GetTestDrivetrainConfig()),
+        localizer_event_loop_(
+            event_loop_factory_.MakeEventLoop("localizer", imu_node_)),
+        localizer_(localizer_event_loop_.get(), dt_config_),
+        drivetrain_plant_event_loop_(event_loop_factory_.MakeEventLoop(
+            "drivetrain_plant", roborio_node_)),
+        drivetrain_plant_imu_event_loop_(
+            event_loop_factory_.MakeEventLoop("drivetrain_plant", imu_node_)),
+        drivetrain_plant_(drivetrain_plant_event_loop_.get(),
+                          drivetrain_plant_imu_event_loop_.get(), dt_config_,
+                          std::chrono::microseconds(500)),
+        roborio_test_event_loop_(
+            event_loop_factory_.MakeEventLoop("test", roborio_node_)),
+        imu_test_event_loop_(
+            event_loop_factory_.MakeEventLoop("test", imu_node_)),
+        camera_test_event_loop_(
+            event_loop_factory_.MakeEventLoop("test", camera_node_)),
+        logger_test_event_loop_(
+            event_loop_factory_.GetNodeEventLoopFactory("logger")
+                ->MakeEventLoop("test")),
+        constants_fetcher_(imu_test_event_loop_.get()),
+        output_sender_(
+            roborio_test_event_loop_->MakeSender<Output>("/drivetrain")),
+        target_sender_(
+            camera_test_event_loop_->MakeSender<frc971::vision::TargetMap>(
+                "/camera")),
+        control_sender_(roborio_test_event_loop_->MakeSender<
+                        frc971::control_loops::drivetrain::LocalizerControl>(
+            "/drivetrain")),
+        output_fetcher_(
+            roborio_test_event_loop_
+                ->MakeFetcher<frc971::controls::LocalizerOutput>("/localizer")),
+        status_fetcher_(
+            imu_test_event_loop_->MakeFetcher<Status>("/localizer")) {
+    FLAGS_die_on_malloc = true;
+    {
+      aos::TimerHandler *timer = roborio_test_event_loop_->AddTimer([this]() {
+        {
+          auto builder = output_sender_.MakeBuilder();
+          auto output_builder = builder.MakeBuilder<Output>();
+          output_builder.add_left_voltage(output_voltages_(0));
+          output_builder.add_right_voltage(output_voltages_(1));
+          builder.CheckOk(builder.Send(output_builder.Finish()));
+        }
+      });
+      roborio_test_event_loop_->OnRun([timer, this]() {
+        timer->Setup(roborio_test_event_loop_->monotonic_now(),
+                     std::chrono::milliseconds(5));
+      });
+    }
+    {
+      // Sanity check that the test calibration files look like what we
+      // expect.
+      CHECK_EQ("pi1", constants_fetcher_.constants()
+                          .cameras()
+                          ->Get(0)
+                          ->calibration()
+                          ->node_name()
+                          ->string_view());
+      const Eigen::Matrix<double, 4, 4> H_robot_camera =
+          frc971::control_loops::drivetrain::FlatbufferToTransformationMatrix(
+              *constants_fetcher_.constants()
+                   .cameras()
+                   ->Get(0)
+                   ->calibration()
+                   ->fixed_extrinsics());
+
+      CHECK_EQ(kTargetId, constants_fetcher_.constants()
+                              .target_map()
+                              ->target_poses()
+                              ->Get(0)
+                              ->id());
+      const Eigen::Matrix<double, 4, 4> H_field_target = PoseToTransform(
+          constants_fetcher_.constants().target_map()->target_poses()->Get(0));
+      // For reference, the camera should pointed straight forwards on the
+      // robot, offset by 1 meter.
+      aos::TimerHandler *timer = camera_test_event_loop_->AddTimer(
+          [this, H_robot_camera, H_field_target]() {
+            if (!send_targets_) {
+              return;
+            }
+            const frc971::control_loops::Pose robot_pose(
+                {drivetrain_plant_.GetPosition().x(),
+                 drivetrain_plant_.GetPosition().y(), 0.0},
+                drivetrain_plant_.state()(2, 0));
+
+            const Eigen::Matrix<double, 4, 4> H_field_camera =
+                robot_pose.AsTransformationMatrix() * H_robot_camera;
+            const Eigen::Matrix<double, 4, 4> H_camera_target =
+                H_field_camera.inverse() * H_field_target;
+
+            const Eigen::Quaterniond quat(H_camera_target.block<3, 3>(0, 0));
+            const Eigen::Vector3d translation(
+                H_camera_target.block<3, 1>(0, 3));
+
+            auto builder = target_sender_.MakeBuilder();
+            frc971::vision::Quaternion::Builder quat_builder(*builder.fbb());
+            quat_builder.add_w(quat.w());
+            quat_builder.add_x(quat.x());
+            quat_builder.add_y(quat.y());
+            quat_builder.add_z(quat.z());
+            auto quat_offset = quat_builder.Finish();
+            frc971::vision::Position::Builder position_builder(*builder.fbb());
+            position_builder.add_x(translation.x());
+            position_builder.add_y(translation.y());
+            position_builder.add_z(translation.z());
+            auto position_offset = position_builder.Finish();
+
+            frc971::vision::TargetPoseFbs::Builder target_builder(
+                *builder.fbb());
+            target_builder.add_id(send_target_id_);
+            target_builder.add_position(position_offset);
+            target_builder.add_orientation(quat_offset);
+            auto target_offset = target_builder.Finish();
+
+            auto targets_offset = builder.fbb()->CreateVector({target_offset});
+            frc971::vision::TargetMap::Builder map_builder(*builder.fbb());
+            map_builder.add_target_poses(targets_offset);
+            map_builder.add_monotonic_timestamp_ns(
+                std::chrono::duration_cast<std::chrono::nanoseconds>(
+                    camera_test_event_loop_->monotonic_now().time_since_epoch())
+                    .count());
+
+            builder.CheckOk(builder.Send(map_builder.Finish()));
+          });
+      camera_test_event_loop_->OnRun([timer, this]() {
+        timer->Setup(camera_test_event_loop_->monotonic_now(),
+                     std::chrono::milliseconds(50));
+      });
+    }
+
+    localizer_control_send_timer_ =
+        roborio_test_event_loop_->AddTimer([this]() {
+          auto builder = control_sender_.MakeBuilder();
+          auto control_builder = builder.MakeBuilder<
+              frc971::control_loops::drivetrain::LocalizerControl>();
+          control_builder.add_x(localizer_control_x_);
+          control_builder.add_y(localizer_control_y_);
+          control_builder.add_theta(localizer_control_theta_);
+          control_builder.add_theta_uncertainty(0.01);
+          control_builder.add_keep_current_theta(false);
+          builder.CheckOk(builder.Send(control_builder.Finish()));
+        });
+
+    // Get things zeroed.
+    event_loop_factory_.RunFor(std::chrono::seconds(10));
+    CHECK(status_fetcher_.Fetch());
+    CHECK(status_fetcher_->imu()->zeroed());
+
+    if (!FLAGS_output_folder.empty()) {
+      logger_event_loop_ =
+          event_loop_factory_.MakeEventLoop("logger", imu_node_);
+      logger_ = std::make_unique<aos::logger::Logger>(logger_event_loop_.get());
+      logger_->StartLoggingOnRun(FLAGS_output_folder);
+    }
+  }
+
+  void SendLocalizerControl(double x, double y, double theta) {
+    localizer_control_x_ = x;
+    localizer_control_y_ = y;
+    localizer_control_theta_ = theta;
+    localizer_control_send_timer_->Setup(
+        roborio_test_event_loop_->monotonic_now());
+  }
+  ::testing::AssertionResult IsNear(double expected, double actual,
+                                    double epsilon) {
+    if (std::abs(expected - actual) < epsilon) {
+      return ::testing::AssertionSuccess();
+    } else {
+      return ::testing::AssertionFailure()
+             << "Expected " << expected << " but got " << actual
+             << " with a max difference of " << epsilon
+             << " and an actual difference of " << std::abs(expected - actual);
+    }
+  }
+  ::testing::AssertionResult VerifyEstimatorAccurate(double eps) {
+    const Eigen::Matrix<double, 5, 1> true_state = drivetrain_plant_.state();
+    ::testing::AssertionResult result(true);
+    status_fetcher_.Fetch();
+    if (!(result = IsNear(status_fetcher_->state()->x(), true_state(0), eps))) {
+      return result;
+    }
+    if (!(result = IsNear(status_fetcher_->state()->y(), true_state(1), eps))) {
+      return result;
+    }
+    if (!(result =
+              IsNear(status_fetcher_->state()->theta(), true_state(2), eps))) {
+      return result;
+    }
+    return result;
+  }
+
+  aos::FlatbufferDetachedBuffer<aos::Configuration> configuration_;
+  aos::SimulatedEventLoopFactory event_loop_factory_;
+  const aos::Node *const roborio_node_;
+  const aos::Node *const imu_node_;
+  const aos::Node *const camera_node_;
+  const frc971::control_loops::drivetrain::DrivetrainConfig<double> dt_config_;
+  std::unique_ptr<aos::EventLoop> localizer_event_loop_;
+  Localizer localizer_;
+
+  std::unique_ptr<aos::EventLoop> drivetrain_plant_event_loop_;
+  std::unique_ptr<aos::EventLoop> drivetrain_plant_imu_event_loop_;
+  frc971::control_loops::drivetrain::testing::DrivetrainSimulation
+      drivetrain_plant_;
+
+  std::unique_ptr<aos::EventLoop> roborio_test_event_loop_;
+  std::unique_ptr<aos::EventLoop> imu_test_event_loop_;
+  std::unique_ptr<aos::EventLoop> camera_test_event_loop_;
+  std::unique_ptr<aos::EventLoop> logger_test_event_loop_;
+
+  frc971::constants::ConstantsFetcher<Constants> constants_fetcher_;
+
+  aos::Sender<Output> output_sender_;
+  aos::Sender<frc971::vision::TargetMap> target_sender_;
+  aos::Sender<frc971::control_loops::drivetrain::LocalizerControl>
+      control_sender_;
+  aos::Fetcher<frc971::controls::LocalizerOutput> output_fetcher_;
+  aos::Fetcher<Status> status_fetcher_;
+
+  Eigen::Vector2d output_voltages_ = Eigen::Vector2d::Zero();
+
+  aos::TimerHandler *localizer_control_send_timer_;
+
+  bool send_targets_ = false;
+
+  double localizer_control_x_ = 0.0;
+  double localizer_control_y_ = 0.0;
+  double localizer_control_theta_ = 0.0;
+
+  std::unique_ptr<aos::EventLoop> logger_event_loop_;
+  std::unique_ptr<aos::logger::Logger> logger_;
+
+  uint64_t send_target_id_ = kTargetId;
+
+  gflags::FlagSaver flag_saver_;
+};
+
+// Test a simple scenario with no errors where the robot should just drive
+// straight forwards.
+TEST_F(LocalizerTest, Nominal) {
+  output_voltages_ << 1.0, 1.0;
+  event_loop_factory_.RunFor(std::chrono::seconds(2));
+  CHECK(output_fetcher_.Fetch());
+  CHECK(status_fetcher_.Fetch());
+  // The two can be different because they may've been sent at different
+  // times.
+  EXPECT_NEAR(output_fetcher_->x(), status_fetcher_->state()->x(), 1e-2);
+  EXPECT_NEAR(output_fetcher_->y(), status_fetcher_->state()->y(), 1e-6);
+  EXPECT_NEAR(output_fetcher_->theta(), status_fetcher_->state()->theta(),
+              1e-6);
+  // Confirm that we did indeed drive forwards (and straight), as expected.
+  EXPECT_LT(0.1, output_fetcher_->x());
+  EXPECT_NEAR(0.0, output_fetcher_->y(), 1e-10);
+  EXPECT_NEAR(0.0, output_fetcher_->theta(), 1e-10);
+  EXPECT_NEAR(0.0, status_fetcher_->state()->left_voltage_error(), 1e-1);
+  EXPECT_NEAR(0.0, status_fetcher_->state()->right_voltage_error(), 1e-1);
+
+  // And check that we actually think that we are near where the simulator
+  // says we are.
+  EXPECT_TRUE(VerifyEstimatorAccurate(1e-2));
+}
+
+// Confirm that when the robot drives backwards that we localize correctly.
+TEST_F(LocalizerTest, NominalReverse) {
+  output_voltages_ << -1.0, -1.0;
+  event_loop_factory_.RunFor(std::chrono::seconds(2));
+  CHECK(output_fetcher_.Fetch());
+  CHECK(status_fetcher_.Fetch());
+  // The two can be different because they may've been sent at different
+  // times.
+  EXPECT_NEAR(output_fetcher_->x(), status_fetcher_->state()->x(), 1e-2);
+  EXPECT_NEAR(output_fetcher_->y(), status_fetcher_->state()->y(), 1e-6);
+  EXPECT_NEAR(output_fetcher_->theta(), status_fetcher_->state()->theta(),
+              1e-6);
+  // Confirm that we did indeed drive backwards (and straight), as expected.
+  EXPECT_GT(-0.1, output_fetcher_->x());
+  EXPECT_NEAR(0.0, output_fetcher_->y(), 1e-10);
+  EXPECT_NEAR(0.0, output_fetcher_->theta(), 1e-10);
+  EXPECT_NEAR(0.0, status_fetcher_->state()->left_voltage_error(), 1e-1);
+  EXPECT_NEAR(0.0, status_fetcher_->state()->right_voltage_error(), 1e-1);
+
+  // And check that we actually think that we are near where the simulator
+  // says we are.
+  EXPECT_TRUE(VerifyEstimatorAccurate(1e-2));
+}
+
+// Confirm that when the robot turns counter-clockwise that we localize
+// correctly.
+TEST_F(LocalizerTest, NominalSpinInPlace) {
+  output_voltages_ << -1.0, 1.0;
+  event_loop_factory_.RunFor(std::chrono::seconds(2));
+  CHECK(output_fetcher_.Fetch());
+  CHECK(status_fetcher_.Fetch());
+  // The two can be different because they may've been sent at different
+  // times.
+  EXPECT_NEAR(output_fetcher_->x(), status_fetcher_->state()->x(), 1e-6);
+  EXPECT_NEAR(output_fetcher_->y(), status_fetcher_->state()->y(), 1e-6);
+  EXPECT_NEAR(output_fetcher_->theta(), status_fetcher_->state()->theta(),
+              1e-2);
+  // Confirm that we did indeed turn counter-clockwise.
+  EXPECT_NEAR(0.0, output_fetcher_->x(), 1e-10);
+  EXPECT_NEAR(0.0, output_fetcher_->y(), 1e-10);
+  EXPECT_LT(0.1, output_fetcher_->theta());
+  EXPECT_NEAR(0.0, status_fetcher_->state()->left_voltage_error(), 1e-1);
+  EXPECT_NEAR(0.0, status_fetcher_->state()->right_voltage_error(), 1e-1);
+
+  // And check that we actually think that we are near where the simulator
+  // says we are.
+  EXPECT_TRUE(VerifyEstimatorAccurate(1e-2));
+}
+
+// Confirm that when the robot drives in a curve that we localize
+// successfully.
+TEST_F(LocalizerTest, NominalCurve) {
+  output_voltages_ << 2.0, 3.0;
+  event_loop_factory_.RunFor(std::chrono::seconds(4));
+  CHECK(output_fetcher_.Fetch());
+  CHECK(status_fetcher_.Fetch());
+  // The two can be different because they may've been sent at different
+  // times.
+  EXPECT_NEAR(output_fetcher_->x(), status_fetcher_->state()->x(), 1e-2);
+  EXPECT_NEAR(output_fetcher_->y(), status_fetcher_->state()->y(), 1e-2);
+  EXPECT_NEAR(output_fetcher_->theta(), status_fetcher_->state()->theta(),
+              1e-2);
+  // Confirm that we did indeed drive in a rough, counter-clockwise, curve.
+  EXPECT_LT(0.1, output_fetcher_->x());
+  EXPECT_LT(0.1, output_fetcher_->y());
+  EXPECT_LT(0.1, output_fetcher_->theta());
+
+  // And check that we actually think that we are near where the simulator
+  // says we are.
+  EXPECT_TRUE(VerifyEstimatorAccurate(1e-2));
+}
+
+// Tests that, in the presence of a non-zero voltage error, that we correct
+// for it.
+TEST_F(LocalizerTest, VoltageErrorDisabled) {
+  output_voltages_ << 0.0, 0.0;
+  drivetrain_plant_.set_left_voltage_offset(2.0);
+  drivetrain_plant_.set_right_voltage_offset(2.0);
+
+  event_loop_factory_.RunFor(std::chrono::seconds(2));
+  CHECK(output_fetcher_.Fetch());
+  CHECK(status_fetcher_.Fetch());
+  // We should've just ended up driving straight forwards.
+  EXPECT_LT(0.1, output_fetcher_->x());
+  EXPECT_NEAR(0.0, output_fetcher_->y(), 1e-10);
+  EXPECT_NEAR(0.0, output_fetcher_->theta(), 1e-10);
+  EXPECT_NEAR(2.0, status_fetcher_->state()->left_voltage_error(), 1.0);
+  EXPECT_NEAR(2.0, status_fetcher_->state()->right_voltage_error(), 1.0);
+
+  // And check that we actually think that we are near where the simulator
+  // says we are.
+  EXPECT_TRUE(VerifyEstimatorAccurate(0.05));
+}
+
+// Tests that image corrections in the nominal case (no errors) causes no
+// issues.
+TEST_F(LocalizerTest, NominalImageCorrections) {
+  output_voltages_ << 3.0, 2.0;
+  send_targets_ = true;
+
+  event_loop_factory_.RunFor(std::chrono::seconds(4));
+  CHECK(status_fetcher_.Fetch());
+  EXPECT_TRUE(VerifyEstimatorAccurate(1e-2));
+  ASSERT_TRUE(status_fetcher_->has_statistics());
+  ASSERT_EQ(4u /* number of cameras */, status_fetcher_->statistics()->size());
+  ASSERT_EQ(status_fetcher_->statistics()->Get(0)->total_candidates(),
+            status_fetcher_->statistics()->Get(0)->total_accepted());
+  ASSERT_LT(10, status_fetcher_->statistics()->Get(0)->total_candidates());
+}
+
+// Tests that image corrections when there is an error at the start results
+// in us actually getting corrected over time.
+TEST_F(LocalizerTest, ImageCorrections) {
+  output_voltages_ << 0.0, 0.0;
+  drivetrain_plant_.mutable_state()->x() = 2.0;
+  drivetrain_plant_.mutable_state()->y() = 2.0;
+  SendLocalizerControl(5.0, 3.0, 0.0);
+  event_loop_factory_.RunFor(std::chrono::seconds(4));
+  CHECK(output_fetcher_.Fetch());
+  ASSERT_NEAR(5.0, output_fetcher_->x(), 1e-5);
+  ASSERT_NEAR(3.0, output_fetcher_->y(), 1e-5);
+  ASSERT_NEAR(0.0, output_fetcher_->theta(), 1e-5);
+
+  send_targets_ = true;
+
+  event_loop_factory_.RunFor(std::chrono::seconds(4));
+  CHECK(status_fetcher_.Fetch());
+  EXPECT_TRUE(VerifyEstimatorAccurate(0.1));
+  ASSERT_TRUE(status_fetcher_->has_statistics());
+  ASSERT_EQ(4u /* number of cameras */, status_fetcher_->statistics()->size());
+  ASSERT_EQ(status_fetcher_->statistics()->Get(0)->total_candidates(),
+            status_fetcher_->statistics()->Get(0)->total_accepted());
+  ASSERT_LT(10, status_fetcher_->statistics()->Get(0)->total_candidates());
+}
+
+// Tests that we correctly reject an invalid target.
+TEST_F(LocalizerTest, InvalidTargetId) {
+  output_voltages_ << 0.0, 0.0;
+  send_targets_ = true;
+  send_target_id_ = 100;
+
+  event_loop_factory_.RunFor(std::chrono::seconds(4));
+  CHECK(status_fetcher_.Fetch());
+  ASSERT_TRUE(status_fetcher_->has_statistics());
+  ASSERT_EQ(4u /* number of cameras */, status_fetcher_->statistics()->size());
+  ASSERT_EQ(0, status_fetcher_->statistics()->Get(0)->total_accepted());
+  ASSERT_LT(10, status_fetcher_->statistics()->Get(0)->total_candidates());
+  ASSERT_EQ(status_fetcher_->statistics()
+                ->Get(0)
+                ->rejection_reasons()
+                ->Get(static_cast<size_t>(RejectionReason::NO_SUCH_TARGET))
+                ->count(),
+            status_fetcher_->statistics()->Get(0)->total_candidates());
+}
+
+}  // namespace y2023::localizer::testing
diff --git a/y2023/localizer/status.fbs b/y2023/localizer/status.fbs
new file mode 100644
index 0000000..4d0a9c1
--- /dev/null
+++ b/y2023/localizer/status.fbs
@@ -0,0 +1,59 @@
+include "frc971/control_loops/drivetrain/drivetrain_status.fbs";
+include "frc971/imu_reader/imu_failures.fbs";
+
+namespace y2023.localizer;
+
+enum RejectionReason : uint8 {
+  // For some reason, the image timestamp indicates that the image was taken
+  // in the future.
+  IMAGE_FROM_FUTURE = 0,
+  // The image was too old for the buffer of old state estimates that we
+  // maintain.
+  IMAGE_TOO_OLD = 1,
+  // Message bridge is not yet connected, and so we can't get accurate
+  // time offsets betwee nnodes.
+  MESSAGE_BRIDGE_DISCONNECTED = 2,
+  // The target ID does not exist.
+  NO_SUCH_TARGET = 3,
+}
+
+table RejectionCount {
+  error:RejectionReason (id: 0);
+  count:uint (id: 1);
+}
+
+table CumulativeStatistics {
+  total_accepted:int (id: 0);
+  total_candidates:int (id: 1);
+  rejection_reasons:[RejectionCount] (id: 2);
+}
+
+table ImuStatus {
+  // Whether the IMU is zeroed or not.
+  zeroed:bool (id: 0);
+  // Whether the IMU zeroing is faulted or not.
+  faulted_zero:bool (id: 1);
+  zeroing:frc971.control_loops.drivetrain.ImuZeroerState (id: 2);
+  // Offset between the pico clock and the pi clock, such that
+  // pico_timestamp + pico_offset_ns = pi_timestamp
+  pico_offset_ns:int64 (id: 3);
+  // Error in the offset, if we assume that the pi/pico clocks are identical and
+  // that there is a perfectly consistent latency between the two. Will be zero
+  // for the very first cycle, and then referenced off of the initial offset
+  // thereafter. If greater than zero, implies that the pico is "behind",
+  // whether due to unusually large latency or due to clock drift.
+  pico_offset_error_ns:int64 (id: 4);
+  left_encoder:double (id: 5);
+  right_encoder:double (id: 6);
+  imu_failures:frc971.controls.ImuFailures (id: 7);
+}
+
+table Status {
+  state: frc971.control_loops.drivetrain.LocalizerState (id: 0);
+  down_estimator:frc971.control_loops.drivetrain.DownEstimatorState (id: 1);
+  imu:ImuStatus (id: 2);
+  // Statistics are per-camera, by camera index.
+  statistics:[CumulativeStatistics] (id: 3);
+}
+
+root_type Status;
diff --git a/y2023/localizer/visualization.fbs b/y2023/localizer/visualization.fbs
new file mode 100644
index 0000000..2785872
--- /dev/null
+++ b/y2023/localizer/visualization.fbs
@@ -0,0 +1,25 @@
+include "y2023/localizer/status.fbs";
+
+namespace y2023.localizer;
+
+table TargetEstimateDebug {
+  camera:uint8 (id: 0);
+  camera_x:double (id: 1);
+  camera_y:double (id: 2);
+  camera_theta:double (id: 3);
+  implied_robot_x:double (id: 4);
+  implied_robot_y:double (id: 5);
+  implied_robot_theta:double (id: 6);
+  accepted:bool (id: 7);
+  rejection_reason:RejectionReason  (id: 8);
+  // Image age (more human-readable than trying to interpret raw nanosecond
+  // values).
+  image_age_sec:double (id: 9);
+}
+
+table Visualization {
+  targets:[TargetEstimateDebug] (id: 0);
+  statistics:CumulativeStatistics (id: 1);
+}
+
+root_type Visualization;
diff --git a/y2023/rockpi/BUILD b/y2023/rockpi/BUILD
index 91e8729..701383b 100644
--- a/y2023/rockpi/BUILD
+++ b/y2023/rockpi/BUILD
@@ -7,5 +7,6 @@
         "//aos:init",
         "//aos/events:shm_event_loop",
         "//frc971/imu_reader:imu",
+        "//y2023:constants",
     ],
 )
diff --git a/y2023/rockpi/imu_main.cc b/y2023/rockpi/imu_main.cc
index ac0c141..d53b3fb 100644
--- a/y2023/rockpi/imu_main.cc
+++ b/y2023/rockpi/imu_main.cc
@@ -2,6 +2,7 @@
 #include "aos/init.h"
 #include "aos/realtime.h"
 #include "frc971/imu_reader/imu.h"
+#include "y2023/constants.h"
 
 DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
 
@@ -12,8 +13,8 @@
       aos::configuration::ReadConfig(FLAGS_config);
 
   aos::ShmEventLoop event_loop(&config.message());
-  // TODO(austin): Set the ratio...
-  frc971::imu::Imu imu(&event_loop, 1.0);
+  frc971::imu::Imu imu(&event_loop,
+                       y2023::constants::Values::DrivetrainEncoderToMeters(1));
 
   event_loop.SetRuntimeAffinity(aos::MakeCpusetFromCpus({0}));
   event_loop.SetRuntimeRealtimePriority(55);
diff --git a/y2023/vision/BUILD b/y2023/vision/BUILD
index 839f11e..a0b0ce5 100644
--- a/y2023/vision/BUILD
+++ b/y2023/vision/BUILD
@@ -1,5 +1,3 @@
-load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
-
 cc_binary(
     name = "camera_reader",
     srcs = [
@@ -33,7 +31,6 @@
         "//frc971/constants:constants_sender_lib",
         "//frc971/vision:vision_fbs",
         "//third_party:opencv",
-        "//y2023/vision:april_debug_fbs",
         "//y2023/vision:vision_util",
         "@com_google_absl//absl/strings",
     ],
@@ -75,14 +72,6 @@
     ],
 )
 
-flatbuffer_cc_library(
-    name = "april_debug_fbs",
-    srcs = ["april_debug.fbs"],
-    gen_reflections = 1,
-    target_compatible_with = ["@platforms//os:linux"],
-    visibility = ["//visibility:public"],
-)
-
 cc_library(
     name = "aprilrobotics_lib",
     srcs = [
@@ -92,7 +81,6 @@
     target_compatible_with = ["@platforms//os:linux"],
     visibility = ["//y2023:__subpackages__"],
     deps = [
-        ":april_debug_fbs",
         ":vision_util",
         "//aos:init",
         "//aos/events:shm_event_loop",
@@ -122,6 +110,12 @@
     ],
 )
 
+filegroup(
+    name = "image_streamer_start",
+    srcs = ["image_streamer_start.sh"],
+    visibility = ["//visibility:public"],
+)
+
 cc_binary(
     name = "foxglove_image_converter",
     srcs = ["foxglove_image_converter.cc"],
diff --git a/y2023/vision/april_debug.fbs b/y2023/vision/april_debug.fbs
deleted file mode 100644
index 4442448..0000000
--- a/y2023/vision/april_debug.fbs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace y2023.vision;
-
-// Stores image xy pixel coordinates of apriltag corners
-struct Point {
-     x:double (id: 0);
-     y:double (id: 1);
-}
-
-// Corner locations for each apriltag
-table AprilCorners {
-  id:uint64 (id: 0);
-  // Will always have 4 values, one for each corner
-  points: [Point] (id: 1);
-}
-
-// List of positions of all apriltags detected in current frame
-table AprilDebug {
-  corners: [AprilCorners] (id: 0);
-}
-
-root_type AprilDebug;
diff --git a/y2023/vision/aprilrobotics.cc b/y2023/vision/aprilrobotics.cc
index 07eee64..b132f85 100644
--- a/y2023/vision/aprilrobotics.cc
+++ b/y2023/vision/aprilrobotics.cc
@@ -27,8 +27,8 @@
           chrono::milliseconds(5)),
       target_map_sender_(
           event_loop->MakeSender<frc971::vision::TargetMap>("/camera")),
-      april_debug_sender_(
-          event_loop->MakeSender<y2023::vision::AprilDebug>("/camera")) {
+      image_annotations_sender_(
+          event_loop->MakeSender<foxglove::ImageAnnotations>("/camera")) {
   tag_family_ = tag16h5_create();
   tag_detector_ = apriltag_detector_create();
 
@@ -80,7 +80,7 @@
 void AprilRoboticsDetector::HandleImage(cv::Mat image_grayscale,
                                         aos::monotonic_clock::time_point eof) {
   std::vector<std::pair<apriltag_detection_t, apriltag_pose_t>> detections =
-      DetectTags(image_grayscale);
+      DetectTags(image_grayscale, eof);
 
   auto builder = target_map_sender_.MakeBuilder();
   std::vector<flatbuffers::Offset<frc971::vision::TargetPoseFbs>> target_poses;
@@ -136,7 +136,8 @@
 }
 
 std::vector<std::pair<apriltag_detection_t, apriltag_pose_t>>
-AprilRoboticsDetector::DetectTags(cv::Mat image) {
+AprilRoboticsDetector::DetectTags(cv::Mat image,
+                                  aos::monotonic_clock::time_point eof) {
   const aos::monotonic_clock::time_point start_time =
       aos::monotonic_clock::now();
 
@@ -153,9 +154,9 @@
 
   std::vector<std::pair<apriltag_detection_t, apriltag_pose_t>> results;
 
-  std::vector<flatbuffers::Offset<AprilCorners>> corners_vector;
+  std::vector<std::vector<cv::Point2f>> corners_vector;
 
-  auto builder = april_debug_sender_.MakeBuilder();
+  auto builder = image_annotations_sender_.MakeBuilder();
 
   for (int i = 0; i < zarray_size(detections); i++) {
     apriltag_detection_t *det;
@@ -195,33 +196,19 @@
                      .count()
               << " seconds for pose estimation";
 
-      std::vector<Point> corner_points;
-
+      std::vector<cv::Point2f> corner_points;
       corner_points.emplace_back(det->p[0][0], det->p[0][1]);
       corner_points.emplace_back(det->p[1][0], det->p[1][1]);
       corner_points.emplace_back(det->p[2][0], det->p[2][1]);
       corner_points.emplace_back(det->p[3][0], det->p[3][1]);
 
-      auto corner_points_fbs =
-          builder.fbb()->CreateVectorOfStructs(corner_points);
-
-      AprilCorners::Builder april_corners_builder =
-          builder.MakeBuilder<AprilCorners>();
-
-      april_corners_builder.add_id(det->id);
-      april_corners_builder.add_points(corner_points_fbs);
-
-      corners_vector.emplace_back(april_corners_builder.Finish());
+      corners_vector.emplace_back(corner_points);
     }
   }
 
-  auto corners_vector_fbs = builder.fbb()->CreateVector(corners_vector);
-
-  AprilDebug::Builder april_debug_builder = builder.MakeBuilder<AprilDebug>();
-
-  april_debug_builder.add_corners(corners_vector_fbs);
-
-  builder.CheckOk(builder.Send(april_debug_builder.Finish()));
+  const auto annotations_offset =
+      frc971::vision::BuildAnnotations(eof, corners_vector, 5.0, builder.fbb());
+  builder.CheckOk(builder.Send(annotations_offset));
 
   apriltag_detections_destroy(detections);
 
diff --git a/y2023/vision/aprilrobotics.h b/y2023/vision/aprilrobotics.h
index 4477856..bbc1661 100644
--- a/y2023/vision/aprilrobotics.h
+++ b/y2023/vision/aprilrobotics.h
@@ -17,7 +17,6 @@
 #include "third_party/apriltag/apriltag_pose.h"
 #include "third_party/apriltag/tag16h5.h"
 #include "y2023/constants/constants_generated.h"
-#include "y2023/vision/april_debug_generated.h"
 
 DECLARE_int32(team_number);
 
@@ -36,9 +35,9 @@
   void UndistortDetection(apriltag_detection_t *det) const;
 
   std::vector<std::pair<apriltag_detection_t, apriltag_pose_t>> DetectTags(
-      cv::Mat image);
+      cv::Mat image, aos::monotonic_clock::time_point eof);
 
-  const cv::Mat extrinsics() const { return extrinsics_; }
+  const std::optional<cv::Mat> extrinsics() const { return extrinsics_; }
   const cv::Mat intrinsics() const { return intrinsics_; }
   const cv::Mat dist_coeffs() const { return dist_coeffs_; }
 
@@ -57,14 +56,14 @@
   const frc971::vision::calibration::CameraCalibration *calibration_;
   cv::Mat intrinsics_;
   cv::Mat projection_matrix_;
-  cv::Mat extrinsics_;
+  std::optional<cv::Mat> extrinsics_;
   cv::Mat dist_coeffs_;
 
   aos::Ftrace ftrace_;
 
   frc971::vision::ImageCallback image_callback_;
   aos::Sender<frc971::vision::TargetMap> target_map_sender_;
-  aos::Sender<y2023::vision::AprilDebug> april_debug_sender_;
+  aos::Sender<foxglove::ImageAnnotations> image_annotations_sender_;
 };
 
 }  // namespace vision
diff --git a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json
index 1f30579..c9d960a 100755
--- a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json
+++ b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-01_2013-01-18_08-56-17.194305339.json
@@ -19,24 +19,6 @@
   0.008949,
   -0.079813
  ],
- "fixed_extrinsics": [
-  1.0,
-  0.0,
-  0.0,
-  0.0,
-  0.0,
-  1.0,
-  0.0,
-  0.0,
-  0.0,
-  0.0,
-  1.0,
-  0.0,
-  0.0,
-  0.0,
-  0.0,
-  1.0
- ],
  "calibration_timestamp": 1358499377194305339,
  "camera_id": "23-01"
 }
diff --git a/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json
new file mode 100644
index 0000000..b21da40
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-1_cam-23-05_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi1", "team_number": 971, "intrinsics": [ 890.980713, 0.0, 619.298645, 0.0, 890.668762, 364.009766, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ -0.487722, 0.222354, 0.844207, 0.025116, 0.864934, -0.008067, 0.501821, -0.246003, 0.118392, 0.974933, -0.188387, 0.532497, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.449172, 0.252318, 0.000881, -0.000615, -0.082208 ], "calibration_timestamp": 1358501902915096335, "camera_id": "23-05" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json
new file mode 100644
index 0000000..4759fda
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-06_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi2", "team_number": 971, "intrinsics": [ 893.242981, 0.0, 639.796692, 0.0, 892.498718, 354.109344, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ 0.852213, 0.227336, 0.471224, 0.220072, 0.485092, -0.005909, -0.874443, -0.175232, -0.196008, 0.973799, -0.115315, 0.61409, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.451751, 0.252422, 0.000531, 0.000079, -0.079369 ], "calibration_timestamp": 1358501526409252911, "camera_id": "23-06" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json
index 1aa8ce6..306bea1 100755
--- a/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json
+++ b/y2023/vision/calib_files/calibration_pi-971-2_cam-23-11_2013-01-18_10-01-30.177115986.json
@@ -1,5 +1,5 @@
 {
- "node_name": "pi2",
+ "node_name": "pi3",
  "team_number": 971,
  "intrinsics": [
   891.026001,
diff --git a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json
new file mode 100644
index 0000000..8c86d20
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-07_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi3", "team_number": 971, "intrinsics": [ 892.627869, 0.0, 629.289978, 0.0, 891.73761, 373.299896, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ 0.492232, -0.163335, -0.855002, 0.020122, -0.866067, 0.006706, -0.499883, -0.174518, 0.087382, 0.986548, -0.138158, 0.645307, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.454901, 0.266778, -0.000316, -0.000469, -0.091357 ], "calibration_timestamp": 1358500316768663953, "camera_id": "23-07" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json
index e7367cc..393beef 100755
--- a/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json
+++ b/y2023/vision/calib_files/calibration_pi-971-3_cam-23-10_2013-01-18_10-02-40.377380613.json
@@ -1,5 +1,5 @@
 {
- "node_name": "pi3",
+ "node_name": "pi2",
  "team_number": 971,
  "intrinsics": [
   894.002502,
@@ -21,4 +21,4 @@
  ],
  "calibration_timestamp": 1358503360377380613,
  "camera_id": "23-10"
-}
\ No newline at end of file
+}
diff --git a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json
new file mode 100644
index 0000000..92ab69c
--- /dev/null
+++ b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-08_ext_2023-02-22.json
@@ -0,0 +1 @@
+{ "node_name": "pi4", "team_number": 971, "intrinsics": [ 891.88385, 0.0, 642.268616, 0.0, 890.626465, 353.272919, 0.0, 0.0, 1.0 ], "fixed_extrinsics": { "data": [ -0.865915, -0.186983, -0.463928, -0.014873, -0.473362, 0.006652, 0.880843, -0.215738, -0.161617, 0.982341, -0.094271, 0.676433, 0.0, 0.0, 0.0, 1.0 ] }, "dist_coeffs": [ -0.448426, 0.246817, 0.000002, 0.000948, -0.076717 ], "calibration_timestamp": 1358501265150551614, "camera_id": "23-08" }
\ No newline at end of file
diff --git a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json
index 68c2b94..e119c43 100755
--- a/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json
+++ b/y2023/vision/calib_files/calibration_pi-971-4_cam-23-09_2013-01-18_09-02-59.650270322.json
@@ -1,5 +1,5 @@
 {
- "node_name": "pi4",
+ "node_name": "pi1",
  "team_number": 971,
  "intrinsics": [
   893.617798,
@@ -21,4 +21,4 @@
  ],
  "calibration_timestamp": 1358499779650270322,
  "camera_id": "23-09"
-}
\ No newline at end of file
+}
diff --git a/y2023/vision/calibrate_extrinsics.cc b/y2023/vision/calibrate_extrinsics.cc
index eab8724..10e1639 100644
--- a/y2023/vision/calibrate_extrinsics.cc
+++ b/y2023/vision/calibrate_extrinsics.cc
@@ -7,8 +7,8 @@
 #include "aos/network/team_number.h"
 #include "aos/time/time.h"
 #include "aos/util/file.h"
-#include "frc971/control_loops/quaternion_utils.h"
 #include "frc971/constants/constants_sender_lib.h"
+#include "frc971/control_loops/quaternion_utils.h"
 #include "frc971/vision/extrinsics_calibration.h"
 #include "frc971/vision/vision_generated.h"
 #include "frc971/wpilib/imu_batch_generated.h"
@@ -17,7 +17,7 @@
 
 DEFINE_string(pi, "pi-7971-2", "Pi name to calibrate.");
 DEFINE_bool(plot, false, "Whether to plot the resulting data.");
-DEFINE_string(target_type, "charuco",
+DEFINE_string(target_type, "charuco_diamond",
               "Type of target: aruco|charuco|charuco_diamond");
 DEFINE_string(image_channel, "/camera", "Channel to listen for images on");
 DEFINE_string(output_logs, "/tmp/calibration/",
@@ -51,16 +51,17 @@
       TargetType target_type = TargetTypeFromString(FLAGS_target_type);
       std::unique_ptr<aos::EventLoop> constants_event_loop =
           factory->MakeEventLoop("constants_fetcher", pi_event_loop->node());
-      frc971::constants::ConstantsFetcher<y2023::Constants> constants_fetcher(
-          constants_event_loop.get());
-      *intrinsics =
-          FLAGS_base_intrinsics.empty()
-              ? aos::RecursiveCopyFlatBuffer(
-                    y2023::vision::FindCameraCalibration(
-                        constants_fetcher.constants(),
-                        pi_event_loop->node()->name()->string_view()))
-              : aos::JsonFileToFlatbuffer<calibration::CameraCalibration>(
-                    FLAGS_base_intrinsics);
+      if (FLAGS_base_intrinsics.empty()) {
+        frc971::constants::ConstantsFetcher<y2023::Constants> constants_fetcher(
+            constants_event_loop.get());
+        *intrinsics =
+            aos::RecursiveCopyFlatBuffer(y2023::vision::FindCameraCalibration(
+                constants_fetcher.constants(),
+                pi_event_loop->node()->name()->string_view()));
+      } else {
+        *intrinsics = aos::JsonFileToFlatbuffer<calibration::CameraCalibration>(
+            FLAGS_base_intrinsics);
+      }
       extractor = std::make_unique<Calibration>(
           factory, pi_event_loop.get(), imu_event_loop.get(), FLAGS_pi,
           &intrinsics->message(), target_type, FLAGS_image_channel, data);
@@ -80,6 +81,14 @@
     // Now, accumulate all the data into the data object.
     aos::logger::LogReader reader(
         aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi1/camera");
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi2/camera");
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi3/camera");
+    reader.RemapLoggedChannel<foxglove::CompressedImage>("/pi4/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi1/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi2/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi3/camera");
+    reader.RemapLoggedChannel<foxglove::ImageAnnotations>("/pi4/camera");
 
     aos::SimulatedEventLoopFactory factory(reader.configuration());
     reader.RegisterWithoutStarting(&factory);
@@ -122,29 +131,40 @@
   CHECK(data.camera_samples_size() > 0) << "Didn't get any camera observations";
 
   // And now we have it, we can start processing it.
-  const Eigen::Quaternion<double> nominal_initial_orientation(
-      frc971::controls::ToQuaternionFromRotationVector(
-          Eigen::Vector3d(0.0, 0.0, M_PI)));
-  const Eigen::Quaternion<double> nominal_pivot_to_camera(
+  // NOTE: For y2023, with no turret, pivot == imu
+
+  // Define the mapping that takes imu frame (with z up) to camera frame (with z
+  // pointing out)
+  const Eigen::Quaterniond R_precam_cam(
+      Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()) *
+      Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitZ()));
+  // Set up initial conditions for the pis that are reasonable
+  Eigen::Quaternion<double> nominal_initial_orientation(
+      Eigen::AngleAxisd(0.0, Eigen::Vector3d::UnitZ()));
+  Eigen::Quaternion<double> nominal_pivot_to_camera(
+      Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()) *
+      Eigen::AngleAxisd(-0.75 * M_PI, Eigen::Vector3d::UnitY()));
+  Eigen::Vector3d nominal_pivot_to_camera_translation(8.0, 8.0, 0.0);
+  Eigen::Quaternion<double> nominal_board_to_world(
       Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()));
-  const Eigen::Quaternion<double> nominal_board_to_world(
-      Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()));
   Eigen::Matrix<double, 6, 1> nominal_initial_state =
       Eigen::Matrix<double, 6, 1>::Zero();
-  // Set x value to 0.5 m (center view on the board)
-  // nominal_initial_state(0, 0) = 0.5;
-  // Set y value to -1 m (approx distance from imu to board/world)
-  nominal_initial_state(1, 0) = -1.0;
+  // Set y value to -1 m (approx distance from imu to the board/world)
+  nominal_initial_state(1, 0) = 1.0;
 
   CalibrationParameters calibration_parameters;
   calibration_parameters.initial_orientation = nominal_initial_orientation;
   calibration_parameters.pivot_to_camera = nominal_pivot_to_camera;
+  calibration_parameters.pivot_to_camera_translation =
+      nominal_pivot_to_camera_translation;
+  // Board to world rotation
   calibration_parameters.board_to_world = nominal_board_to_world;
+  // Initial imu location (and velocity)
   calibration_parameters.initial_state = nominal_initial_state;
   calibration_parameters.has_pivot = false;
 
-  // Show the inverse of pivot_to_camera, since camera_to_pivot tells where the
-  // camera is with respect to the pivot frame
+  // Show the inverse of pivot_to_camera, since camera_to_pivot tells where
+  // the camera is with respect to the pivot frame
   const Eigen::Affine3d nominal_affine_pivot_to_camera =
       Eigen::Translation3d(calibration_parameters.pivot_to_camera_translation) *
       nominal_pivot_to_camera;
@@ -156,22 +176,20 @@
   LOG(INFO) << "Initial Conditions for solver.  Assumes:\n"
             << "1) board origin is same as world, but rotated pi/2 about "
                "x-axis, so z points out\n"
-            << "2) pivot origin matches imu origin\n"
+            << "2) pivot origin matches imu origin (since there's no turret)\n"
             << "3) camera is offset from pivot (depends on which camera)";
 
-  LOG(INFO)
-      << "Nominal initial_orientation of imu w.r.t. world (angle-axis vector): "
-      << frc971::controls::ToRotationVectorFromQuaternion(
-             nominal_initial_orientation)
-             .transpose();
+  LOG(INFO) << "Nominal initial_orientation of imu w.r.t. world "
+               "(angle-axis vector): "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   nominal_initial_orientation)
+                   .transpose();
   LOG(INFO) << "Nominal initial_state: \n"
             << "Position: "
             << nominal_initial_state.block<3, 1>(0, 0).transpose() << "\n"
             << "Velocity: "
             << nominal_initial_state.block<3, 1>(3, 0).transpose();
-  // TODO<Jim>: Might be nice to take out the rotation component that maps into
-  // camera image coordinates (with x right, y down, z forward)
-  LOG(INFO) << "Nominal camera_to_pivot (angle-axis vector): "
+  LOG(INFO) << "Nominal camera_to_pivot rotation (angle-axis vector): "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    nominal_camera_to_pivot_rotation)
                    .transpose();
@@ -188,17 +206,20 @@
 
   if (!FLAGS_output_calibration.empty()) {
     aos::WriteFlatbufferToJson(FLAGS_output_calibration, merged_calibration);
+  } else {
+    LOG(WARNING) << "Calibration filename not provided, so not saving it";
   }
 
   LOG(INFO) << "RESULTS OF CALIBRATION SOLVER:";
   std::cout << aos::FlatbufferToJson(&merged_calibration.message())
             << std::endl;
+
   LOG(INFO) << "initial_orientation of imu w.r.t. world (angle-axis vector): "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.initial_orientation)
                    .transpose();
   LOG(INFO)
-      << "initial_state: \n"
+      << "initial_state (imu): \n"
       << "Position: "
       << calibration_parameters.initial_state.block<3, 1>(0, 0).transpose()
       << "\n"
@@ -208,15 +229,17 @@
   const Eigen::Affine3d affine_pivot_to_camera =
       Eigen::Translation3d(calibration_parameters.pivot_to_camera_translation) *
       calibration_parameters.pivot_to_camera;
+  const Eigen::Affine3d affine_camera_to_pivot =
+      affine_pivot_to_camera.inverse();
   const Eigen::Quaterniond camera_to_pivot_rotation(
-      affine_pivot_to_camera.inverse().rotation());
+      affine_camera_to_pivot.rotation());
   const Eigen::Vector3d camera_to_pivot_translation(
-      affine_pivot_to_camera.inverse().translation());
-  LOG(INFO) << "camera to pivot (angle-axis vec): "
+      affine_camera_to_pivot.translation());
+  LOG(INFO) << "camera to pivot(imu) rotation (angle-axis vec): "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    camera_to_pivot_rotation)
                    .transpose();
-  LOG(INFO) << "camera to pivot translation: "
+  LOG(INFO) << "camera to pivot(imu) translation: "
             << camera_to_pivot_translation.transpose();
   LOG(INFO) << "board_to_world (rotation) "
             << frc971::controls::ToRotationVectorFromQuaternion(
@@ -227,12 +250,24 @@
   LOG(INFO) << "gyro_bias " << calibration_parameters.gyro_bias.transpose();
   LOG(INFO) << "gravity " << 9.81 * calibration_parameters.gravity_scalar;
 
-  LOG(INFO) << "pivot_to_camera change "
+  LOG(INFO) << "Checking delta from nominal (initial condition) to solved "
+               "values:";
+  LOG(INFO) << "nominal_pivot_to_camera rotation: "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   R_precam_cam * nominal_pivot_to_camera)
+                   .transpose();
+  LOG(INFO) << "solved pivot_to_camera rotation: "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   R_precam_cam * calibration_parameters.pivot_to_camera)
+                   .transpose();
+
+  LOG(INFO) << "pivot_to_camera rotation delta (zero if the IC's match the "
+               "solved value) "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.pivot_to_camera *
                    nominal_pivot_to_camera.inverse())
                    .transpose();
-  LOG(INFO) << "board_to_world delta "
+  LOG(INFO) << "board_to_world rotation change "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.board_to_world *
                    nominal_board_to_world.inverse())
diff --git a/y2023/vision/image_streamer_start.sh b/y2023/vision/image_streamer_start.sh
new file mode 100755
index 0000000..48d9da7
--- /dev/null
+++ b/y2023/vision/image_streamer_start.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+# Some configurations to avoid dropping frames
+# 640x480@30fps, 400x300@60fps.
+# Bitrate 500000-1500000
+WIDTH=640
+HEIGHT=480
+BITRATE=1500000
+LISTEN_ON="/camera/downsized"
+# Don't interfere with field webpage
+STREAMING_PORT=1181
+
+# Handle weirdness with openssl and gstreamer
+export OPENSSL_CONF=""
+
+# Enable for verbose logging
+#export GST_DEBUG=4
+
+export LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu/gstreamer-1.0
+
+exec ./image_streamer --width=$WIDTH --height=$HEIGHT --bitrate=$BITRATE --listen_on=$LISTEN_ON --config=aos_config.json --streaming_port=$STREAMING_PORT
+
diff --git a/y2023/vision/maps/target_map.json b/y2023/vision/maps/target_map.json
index 3f6eb54..2aa1995 100644
--- a/y2023/vision/maps/target_map.json
+++ b/y2023/vision/maps/target_map.json
@@ -1,3 +1,7 @@
+/* Targets have positive Z axis pointing into the board, positive X to the right
+   when looking at the board, and positive Y is down when looking at the board.
+   This means that you will get an identity rotation from the camera to target
+   frame when the target is upright, flat, and centered in the camera's view.*/
 {
     "target_poses": [
         {
@@ -7,12 +11,11 @@
                 "y": -2.938,
                 "z": 0.463
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -22,12 +25,11 @@
                 "y": -1.262,
                 "z": 0.463
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -37,12 +39,11 @@
                 "y": 0.414,
                 "z": 0.463
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -52,12 +53,11 @@
                 "y": 2.740,
                 "z": 0.695
             },
-            /* yaw of pi */
             "orientation": {
-                "w": 0.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 1.0
+                "w": -0.5,
+                "x": 0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -68,11 +68,10 @@
                 "z": 0.695
             },
             "orientation": {
-            /* yaw of 0 */
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -82,12 +81,11 @@
                 "y": 0.414,
                 "z": 0.463
             },
-            /* yaw of 0 */
             "orientation": {
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -97,12 +95,11 @@
                 "y": -1.262,
                 "z": 0.463
             },
-            /* yaw of 0 */
             "orientation": {
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         },
         {
@@ -112,12 +109,11 @@
                 "y": -2.938,
                 "z": 0.463
             },
-            /* yaw of 0 */
             "orientation": {
-                "w": 1.0,
-                "x": 0.0,
-                "y": 0.0,
-                "z": 0.0
+                "w": 0.5,
+                "x": -0.5,
+                "y": -0.5,
+                "z": 0.5
             }
         }
     ]
diff --git a/y2023/vision/target_mapping.cc b/y2023/vision/target_mapping.cc
index 921c172..b41d7d5 100644
--- a/y2023/vision/target_mapping.cc
+++ b/y2023/vision/target_mapping.cc
@@ -29,10 +29,10 @@
 namespace calibration = frc971::vision::calibration;
 
 // Change reference frame from camera to robot
-Eigen::Affine3d CameraToRobotDetection(Eigen::Affine3d H_camrob_target,
+Eigen::Affine3d CameraToRobotDetection(Eigen::Affine3d H_camera_target,
                                        Eigen::Affine3d extrinsics) {
-  const Eigen::Affine3d H_robot_camrob = extrinsics;
-  const Eigen::Affine3d H_robot_target = H_robot_camrob * H_camrob_target;
+  const Eigen::Affine3d H_robot_camera = extrinsics;
+  const Eigen::Affine3d H_robot_target = H_robot_camera * H_camera_target;
   return H_robot_target;
 }
 
@@ -47,20 +47,13 @@
     const TargetMapper::TargetPose target_pose =
         PoseUtils::TargetPoseFromFbs(*target_pose_fbs);
 
-    Eigen::Affine3d H_camcv_target =
+    Eigen::Affine3d H_camera_target =
         Eigen::Translation3d(target_pose.pose.p) * target_pose.pose.q;
-    // With X, Y, Z being robot axes and x, y, z being camera axes,
-    // x = -Y, y = -Z, z = X
-    static const Eigen::Affine3d H_camcv_camrob =
-        Eigen::Affine3d((Eigen::Matrix4d() << 0.0, -1.0, 0.0, 0.0, 0.0, 0.0,
-                         -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
-                            .finished());
-    Eigen::Affine3d H_camrob_target = H_camcv_camrob.inverse() * H_camcv_target;
     Eigen::Affine3d H_robot_target =
-        CameraToRobotDetection(H_camrob_target, extrinsics);
+        CameraToRobotDetection(H_camera_target, extrinsics);
 
     ceres::examples::Pose3d target_pose_camera =
-        PoseUtils::Affine3dToPose3d(H_camrob_target);
+        PoseUtils::Affine3dToPose3d(H_camera_target);
     double distance_from_camera = target_pose_camera.p.norm();
 
     CHECK(map.has_monotonic_timestamp_ns())
@@ -86,7 +79,7 @@
   auto detector_ptr = std::make_unique<AprilRoboticsDetector>(
       detection_event_loop, kImageChannel);
   // Get the camera extrinsics
-  cv::Mat extrinsics_cv = detector_ptr->extrinsics();
+  cv::Mat extrinsics_cv = detector_ptr->extrinsics().value();
   Eigen::Matrix4d extrinsics_matrix;
   cv::cv2eigen(extrinsics_cv, extrinsics_matrix);
   const auto extrinsics = Eigen::Affine3d(extrinsics_matrix);
diff --git a/y2023/vision/viewer.cc b/y2023/vision/viewer.cc
index 7877a57..4fb96bc 100644
--- a/y2023/vision/viewer.cc
+++ b/y2023/vision/viewer.cc
@@ -10,7 +10,6 @@
 #include "frc971/vision/vision_generated.h"
 #include "opencv2/calib3d.hpp"
 #include "opencv2/imgproc.hpp"
-#include "y2023/vision/april_debug_generated.h"
 #include "y2023/vision/vision_util.h"
 
 DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
@@ -28,10 +27,8 @@
 using frc971::vision::CameraImage;
 
 bool DisplayLoop(const cv::Mat intrinsics, const cv::Mat dist_coeffs,
-                 aos::Fetcher<CameraImage> *image_fetcher,
-                 aos::Fetcher<AprilDebug> *april_debug_fetcher) {
+                 aos::Fetcher<CameraImage> *image_fetcher) {
   const CameraImage *image;
-  std::optional<const AprilDebug *> april_debug = std::nullopt;
 
   // Read next image
   if (!image_fetcher->Fetch()) {
@@ -41,12 +38,6 @@
   image = image_fetcher->get();
   CHECK(image != nullptr) << "Couldn't read image";
 
-  if (april_debug_fetcher->Fetch()) {
-    april_debug = april_debug_fetcher->get();
-  } else {
-    VLOG(2) << "Couldn't fetch next target map";
-  }
-
   // Create color image:
   cv::Mat image_color_mat(cv::Size(image->cols(), image->rows()), CV_8UC2,
                           (void *)image->data()->data());
@@ -66,17 +57,6 @@
 
   cv::Mat undistorted_image;
   cv::undistort(bgr_image, undistorted_image, intrinsics, dist_coeffs);
-
-  if (april_debug.has_value() && april_debug.value()->corners()->size() > 0) {
-    for (const auto *corners : *april_debug.value()->corners()) {
-      std::vector<cv::Point> points;
-      for (const auto *point_fbs : *corners->points()) {
-        points.emplace_back(point_fbs->x(), point_fbs->y());
-      }
-      cv::polylines(undistorted_image, points, true, cv::Scalar(255, 0, 0), 10);
-    }
-  }
-
   cv::imshow("Display", undistorted_image);
 
   int keystroke = cv::waitKey(1);
@@ -109,14 +89,11 @@
 
   aos::Fetcher<CameraImage> image_fetcher =
       event_loop.MakeFetcher<CameraImage>(FLAGS_channel);
-  aos::Fetcher<AprilDebug> april_debug_fetcher =
-      event_loop.MakeFetcher<AprilDebug>("/camera");
 
   // Run the display loop
   event_loop.AddPhasedLoop(
       [&](int) {
-        if (!DisplayLoop(intrinsics, dist_coeffs, &image_fetcher,
-                         &april_debug_fetcher)) {
+        if (!DisplayLoop(intrinsics, dist_coeffs, &image_fetcher)) {
           LOG(INFO) << "Calling event_loop Exit";
           event_loop.Exit();
         };
diff --git a/y2023/vision/vision_util.cc b/y2023/vision/vision_util.cc
index f4937e5..ca5ad89 100644
--- a/y2023/vision/vision_util.cc
+++ b/y2023/vision/vision_util.cc
@@ -18,11 +18,15 @@
   LOG(FATAL) << ": Failed to find camera calibration for " << node_name;
 }
 
-cv::Mat CameraExtrinsics(
+std::optional<cv::Mat> CameraExtrinsics(
     const frc971::vision::calibration::CameraCalibration *camera_calibration) {
   CHECK(!camera_calibration->has_turret_extrinsics())
       << "No turret on 2023 robot";
 
+  if (!camera_calibration->has_fixed_extrinsics()) {
+    return std::nullopt;
+  }
+  CHECK(camera_calibration->fixed_extrinsics()->has_data());
   cv::Mat result(4, 4, CV_32F,
                  const_cast<void *>(static_cast<const void *>(
                      camera_calibration->fixed_extrinsics()->data()->data())));
diff --git a/y2023/vision/vision_util.h b/y2023/vision/vision_util.h
index ce1a69d..79f5c92 100644
--- a/y2023/vision/vision_util.h
+++ b/y2023/vision/vision_util.h
@@ -10,7 +10,7 @@
 const frc971::vision::calibration::CameraCalibration *FindCameraCalibration(
     const y2023::Constants &calibration_data, std::string_view node_name);
 
-cv::Mat CameraExtrinsics(
+std::optional<cv::Mat> CameraExtrinsics(
     const frc971::vision::calibration::CameraCalibration *camera_calibration);
 
 cv::Mat CameraIntrinsics(
diff --git a/y2023/wpilib_interface.cc b/y2023/wpilib_interface.cc
index 3ccb036..9ffb783 100644
--- a/y2023/wpilib_interface.cc
+++ b/y2023/wpilib_interface.cc
@@ -197,6 +197,10 @@
 
       position_builder.add_arm(arm_offset);
       position_builder.add_wrist(wrist_offset);
+      position_builder.add_end_effector_cone_beam_break(
+          end_effector_cone_beam_break_->Get());
+      position_builder.add_end_effector_cube_beam_break(
+          end_effector_cube_beam_break_->Get());
       builder.CheckOk(builder.Send(position_builder.Finish()));
     }
 
@@ -338,6 +342,15 @@
     wrist_encoder_.set_absolute_pwm(::std::move(absolute_pwm));
   }
 
+  void set_end_effector_cone_beam_break(
+      ::std::unique_ptr<frc::DigitalInput> sensor) {
+    end_effector_cone_beam_break_ = ::std::move(sensor);
+  }
+  void set_end_effector_cube_beam_break(
+      ::std::unique_ptr<frc::DigitalInput> sensor) {
+    end_effector_cube_beam_break_ = ::std::move(sensor);
+  }
+
  private:
   std::shared_ptr<const Values> values_;
 
@@ -349,7 +362,8 @@
 
   std::array<std::unique_ptr<frc::DigitalInput>, 2> autonomous_modes_;
 
-  std::unique_ptr<frc::DigitalInput> imu_heading_input_, imu_yaw_rate_input_;
+  std::unique_ptr<frc::DigitalInput> imu_heading_input_, imu_yaw_rate_input_,
+      end_effector_cone_beam_break_, end_effector_cube_beam_break_;
 
   frc971::wpilib::DMAPulseWidthReader imu_heading_reader_, imu_yaw_rate_reader_;
 
@@ -817,6 +831,12 @@
     sensor_reader.set_wrist_encoder(make_encoder(4));
     sensor_reader.set_wrist_absolute_pwm(make_unique<frc::DigitalInput>(4));
 
+    // TODO(Max): Make the DigitalInput values the accurate robot values.
+    sensor_reader.set_end_effector_cone_beam_break(
+        make_unique<frc::DigitalInput>(6));
+    sensor_reader.set_end_effector_cube_beam_break(
+        make_unique<frc::DigitalInput>(7));
+
     AddLoop(&sensor_reader_event_loop);
 
     // Thread 4.
diff --git a/y2023/www/2022.png b/y2023/www/2022.png
deleted file mode 100644
index 68087bd..0000000
--- a/y2023/www/2022.png
+++ /dev/null
Binary files differ
diff --git a/y2023/www/2023.png b/y2023/www/2023.png
new file mode 100644
index 0000000..d3bffd1
--- /dev/null
+++ b/y2023/www/2023.png
Binary files differ
diff --git a/y2023/www/BUILD b/y2023/www/BUILD
index 539fa6d..f8b706c 100644
--- a/y2023/www/BUILD
+++ b/y2023/www/BUILD
@@ -24,7 +24,9 @@
         "//aos/network:web_proxy_ts_fbs",
         "//aos/network/www:proxy",
         "//frc971/control_loops/drivetrain:drivetrain_status_ts_fbs",
-        "//y2023/control_loops/superstructure:superstructure_status_ts_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_ts_fbs",
+        "//y2023/localizer:status_ts_fbs",
+        "//y2023/localizer:visualization_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
     ],
 )
diff --git a/y2023/www/field.html b/y2023/www/field.html
index f39c1a4..72d8f54 100644
--- a/y2023/www/field.html
+++ b/y2023/www/field.html
@@ -27,96 +27,12 @@
 
       <table>
         <tr>
-          <th colspan="2">Aiming</th>
-        </tr>
-        <tr>
-          <td>Shot distance</td>
-          <td id="shot_distance"> NA </td>
-        </tr>
-        <tr>
-          <td>Turret</td>
-          <td id="turret"> NA </td>
-        </tr>
-      </table>
-
-      <table>
-        <tr>
-          <th colspan="2">Catapult</th>
-        </tr>
-        <tr>
-          <td>Fire</td>
-          <td id="fire"> NA </td>
-        </tr>
-        <tr>
-          <td>Solve Time</td>
-          <td id="mpc_solve_time"> NA </td>
-        </tr>
-        <tr>
-          <td>MPC Active</td>
-          <td id="mpc_horizon"> NA </td>
-        </tr>
-        <tr>
-          <td>Shot Count</td>
-          <td id="shot_count"> NA </td>
-        </tr>
-        <tr>
-          <td>Position</td>
-          <td id="catapult"> NA </td>
-        </tr>
-      </table>
-
-      <table>
-        <tr>
-          <th colspan="2">Superstructure</th>
-        </tr>
-        <tr>
-          <td>State</td>
-          <td id="superstructure_state"> NA </td>
-        </tr>
-        <tr>
-          <td>Intake State</td>
-          <td id="intake_state"> NA </td>
-        </tr>
-        <tr>
-          <td>Reseating</td>
-          <td id="reseating_in_catapult"> NA </td>
-        </tr>
-        <tr>
-          <td>Flippers Open</td>
-          <td id="flippers_open"> NA </td>
-        </tr>
-        <tr>
-          <td>Climber</td>
-          <td id="climber"> NA </td>
-        </tr>
-      </table>
-
-      <table>
-        <tr>
-          <th colspan="2">Intakes</th>
-        </tr>
-        <tr>
-          <td>Front Intake</td>
-          <td id="front_intake"> NA </td>
-        </tr>
-        <tr>
-          <td>Back Intake</td>
-          <td id="back_intake"> NA </td>
-        </tr>
-      </table>
-
-      <table>
-        <tr>
           <th colspan="2">Images</th>
         </tr>
         <tr>
           <td> Images Accepted </td>
           <td id="images_accepted"> NA </td>
         </tr>
-        <tr>
-          <td> Images Rejected </td>
-          <td id="images_rejected"> NA </td>
-        </tr>
       </table>
     </div>
     <div id="vision_readouts">
diff --git a/y2023/www/field_handler.ts b/y2023/www/field_handler.ts
index 67566ea..0f5a975 100644
--- a/y2023/www/field_handler.ts
+++ b/y2023/www/field_handler.ts
@@ -1,7 +1,9 @@
 import {ByteBuffer} from 'flatbuffers';
 import {Connection} from '../../aos/network/www/proxy';
-import {Status as SuperstructureStatus} from '../control_loops/superstructure/superstructure_status_generated'
+import {LocalizerOutput} from '../../frc971/control_loops/drivetrain/localization/localizer_output_generated';
+import {RejectionReason} from '../localizer/status_generated';
 import {Status as DrivetrainStatus} from '../../frc971/control_loops/drivetrain/drivetrain_status_generated';
+import {Visualization, TargetEstimateDebug} from '../localizer/visualization_generated';
 
 import {FIELD_LENGTH, FIELD_WIDTH, FT_TO_M, IN_TO_M} from './constants';
 
@@ -9,34 +11,50 @@
 const FIELD_SIDE_Y = FIELD_WIDTH / 2;
 const FIELD_EDGE_X = FIELD_LENGTH / 2;
 
-const ROBOT_WIDTH = 34 * IN_TO_M;
-const ROBOT_LENGTH = 36 * IN_TO_M;
+const ROBOT_WIDTH = 25 * IN_TO_M;
+const ROBOT_LENGTH = 32 * IN_TO_M;
 
 const PI_COLORS = ['#ff00ff', '#ffff00', '#00ffff', '#ffa500'];
+const PIS = ['pi1', 'pi2', 'pi3', 'pi4'];
 
 export class FieldHandler {
   private canvas = document.createElement('canvas');
+  private localizerOutput: LocalizerOutput|null = null;
   private drivetrainStatus: DrivetrainStatus|null = null;
-  private superstructureStatus: SuperstructureStatus|null = null;
 
   // Image information indexed by timestamp (seconds since the epoch), so that
   // we can stop displaying images after a certain amount of time.
-    private x: HTMLElement = (document.getElementById('x') as HTMLElement);
+  private localizerImageMatches = new Map<number, Visualization>();
+  private x: HTMLElement = (document.getElementById('x') as HTMLElement);
   private y: HTMLElement = (document.getElementById('y') as HTMLElement);
   private theta: HTMLElement =
       (document.getElementById('theta') as HTMLElement);
-  private superstructureState: HTMLElement =
-      (document.getElementById('superstructure_state') as HTMLElement);
   private imagesAcceptedCounter: HTMLElement =
       (document.getElementById('images_accepted') as HTMLElement);
-  private imagesRejectedCounter: HTMLElement =
-      (document.getElementById('images_rejected') as HTMLElement);
+  private rejectionReasonCells: HTMLElement[] = [];
   private fieldImage: HTMLImageElement = new Image();
 
   constructor(private readonly connection: Connection) {
     (document.getElementById('field') as HTMLElement).appendChild(this.canvas);
 
-    this.fieldImage.src = "2022.png";
+    this.fieldImage.src = "2023.png";
+
+    for (const value in RejectionReason) {
+      // Typescript generates an iterator that produces both numbers and
+      // strings... don't do anything on the string iterations.
+      if (isNaN(Number(value))) {
+        continue;
+      }
+      const row = document.createElement('div');
+      const nameCell = document.createElement('div');
+      nameCell.innerHTML = RejectionReason[value];
+      row.appendChild(nameCell);
+      const valueCell = document.createElement('div');
+      valueCell.innerHTML = 'NA';
+      this.rejectionReasonCells.push(valueCell);
+      row.appendChild(valueCell);
+      document.getElementById('vision_readouts').appendChild(row);
+    }
 
     for (let ii = 0; ii < PI_COLORS.length; ++ii) {
       const legendEntry = document.createElement('div');
@@ -48,32 +66,61 @@
     this.connection.addConfigHandler(() => {
       // Visualization message is reliable so that we can see *all* the vision
       // matches.
+      for (const pi in PIS) {
+        this.connection.addReliableHandler(
+            '/' + PIS[pi] + '/camera', "y2023.localizer.Visualization",
+            (data) => {
+              this.handleLocalizerDebug(pi, data);
+            });
+      }
       this.connection.addHandler(
           '/drivetrain', "frc971.control_loops.drivetrain.Status", (data) => {
             this.handleDrivetrainStatus(data);
           });
       this.connection.addHandler(
-          '/superstructure', "y2023.control_loops.superstructure.Status",
-          (data) => {
-            this.handleSuperstructureStatus(data);
+               '/localizer', "frc971.controls.LocalizerOutput", (data) => {
+            this.handleLocalizerOutput(data);
           });
     });
   }
 
+  private handleLocalizerDebug(pi: string, data: Uint8Array): void {
+    const now = Date.now() / 1000.0;
+
+    const fbBuffer = new ByteBuffer(data);
+    this.localizerImageMatches.set(
+        now, Visualization.getRootAsVisualization(fbBuffer));
+
+    const debug = this.localizerImageMatches.get(now);
+
+    if (debug.statistics()) {
+      if (debug.statistics().rejectionReasonsLength() ==
+          this.rejectionReasonCells.length) {
+        for (let ii = 0; ii < debug.statistics().rejectionReasonsLength();
+             ++ii) {
+          this.rejectionReasonCells[ii].innerHTML =
+              debug.statistics().rejectionReasons(ii).count().toString();
+        }
+      } else {
+        console.error('Unexpected number of rejection reasons in counter.');
+      }
+    }
+  }
+
+  private handleLocalizerOutput(data: Uint8Array): void {
+    const fbBuffer = new ByteBuffer(data);
+    this.localizerOutput = LocalizerOutput.getRootAsLocalizerOutput(fbBuffer);
+  }
+
   private handleDrivetrainStatus(data: Uint8Array): void {
     const fbBuffer = new ByteBuffer(data);
     this.drivetrainStatus = DrivetrainStatus.getRootAsStatus(fbBuffer);
   }
 
-  private handleSuperstructureStatus(data: Uint8Array): void {
-    const fbBuffer = new ByteBuffer(data);
-    this.superstructureStatus = SuperstructureStatus.getRootAsStatus(fbBuffer);
-  }
-
   drawField(): void {
     const ctx = this.canvas.getContext('2d');
     ctx.save();
-    ctx.scale(-1.0, 1.0);
+    ctx.scale(1.0, -1.0);
     ctx.drawImage(
         this.fieldImage, 0, 0, this.fieldImage.width, this.fieldImage.height,
         -FIELD_EDGE_X, -FIELD_SIDE_Y, FIELD_LENGTH, FIELD_WIDTH);
@@ -81,8 +128,7 @@
   }
 
   drawCamera(
-      x: number, y: number, theta: number, color: string = 'blue',
-      extendLines: boolean = true): void {
+      x: number, y: number, theta: number, color: string = 'blue'): void {
     const ctx = this.canvas.getContext('2d');
     ctx.save();
     ctx.translate(x, y);
@@ -91,10 +137,6 @@
     ctx.beginPath();
     ctx.moveTo(0.5, 0.5);
     ctx.lineTo(0, 0);
-    if (extendLines) {
-      ctx.lineTo(100.0, 0);
-      ctx.lineTo(0, 0);
-    }
     ctx.lineTo(0.5, -0.5);
     ctx.stroke();
     ctx.beginPath();
@@ -104,9 +146,8 @@
   }
 
   drawRobot(
-      x: number, y: number, theta: number, turret: number|null,
-      color: string = 'blue', dashed: boolean = false,
-      extendLines: boolean = true): void {
+      x: number, y: number, theta: number,
+      color: string = 'blue', dashed: boolean = false): void {
     const ctx = this.canvas.getContext('2d');
     ctx.save();
     ctx.translate(x, y);
@@ -125,11 +166,7 @@
     // Draw line indicating which direction is forwards on the robot.
     ctx.beginPath();
     ctx.moveTo(0, 0);
-    if (extendLines) {
-      ctx.lineTo(1000.0, 0);
-    } else {
-      ctx.lineTo(ROBOT_LENGTH / 2.0, 0);
-    }
+    ctx.lineTo(ROBOT_LENGTH / 2.0, 0);
     ctx.stroke();
 
     ctx.restore();
@@ -142,25 +179,6 @@
     div.classList.remove('near');
   }
 
-  setEstopped(div: HTMLElement): void {
-    div.innerHTML = 'estopped';
-    div.classList.add('faulted');
-    div.classList.remove('zeroing');
-    div.classList.remove('near');
-  }
-
-  setTargetValue(
-      div: HTMLElement, target: number, val: number, tolerance: number): void {
-    div.innerHTML = val.toFixed(4);
-    div.classList.remove('faulted');
-    div.classList.remove('zeroing');
-    if (Math.abs(target - val) < tolerance) {
-      div.classList.add('near');
-    } else {
-      div.classList.remove('near');
-    }
-  }
-
   setValue(div: HTMLElement, val: number): void {
     div.innerHTML = val.toFixed(4);
     div.classList.remove('faulted');
@@ -174,15 +192,64 @@
 
     // Draw the matches with debugging information from the localizer.
     const now = Date.now() / 1000.0;
-    
+
     if (this.drivetrainStatus && this.drivetrainStatus.trajectoryLogging()) {
       this.drawRobot(
           this.drivetrainStatus.trajectoryLogging().x(),
           this.drivetrainStatus.trajectoryLogging().y(),
-          this.drivetrainStatus.trajectoryLogging().theta(), null, "#000000FF",
+          this.drivetrainStatus.trajectoryLogging().theta(), "#000000FF",
           false);
     }
 
+    if (this.localizerOutput) {
+      if (!this.localizerOutput.zeroed()) {
+        this.setZeroing(this.x);
+        this.setZeroing(this.y);
+        this.setZeroing(this.theta);
+      } else {
+        this.setValue(this.x, this.localizerOutput.x());
+        this.setValue(this.y, this.localizerOutput.y());
+        this.setValue(this.theta, this.localizerOutput.theta());
+      }
+
+      this.drawRobot(
+          this.localizerOutput.x(), this.localizerOutput.y(),
+          this.localizerOutput.theta());
+
+      this.imagesAcceptedCounter.innerHTML =
+          this.localizerOutput.imageAcceptedCount().toString();
+    }
+
+    for (const [time, value] of this.localizerImageMatches) {
+      const age = now - time;
+      const kRemovalAge = 1.0;
+      if (age > kRemovalAge) {
+        this.localizerImageMatches.delete(time);
+        continue;
+      }
+      const kMaxImageAlpha = 0.5;
+      const ageAlpha = kMaxImageAlpha * (kRemovalAge - age) / kRemovalAge
+      for (let i = 0; i < value.targetsLength(); i++) {
+        const imageDebug = value.targets(i);
+        const x = imageDebug.impliedRobotX();
+        const y = imageDebug.impliedRobotY();
+        const theta = imageDebug.impliedRobotTheta();
+        const cameraX = imageDebug.cameraX();
+        const cameraY = imageDebug.cameraY();
+        const cameraTheta = imageDebug.cameraTheta();
+        const accepted = imageDebug.accepted();
+        // Make camera readings fade over time.
+        const alpha = Math.round(255 * ageAlpha).toString(16).padStart(2, '0');
+        const dashed = false;
+        const acceptedRgb = accepted ? '#00FF00' : '#FF0000';
+        const acceptedRgba = acceptedRgb + alpha;
+        const cameraRgb = PI_COLORS[imageDebug.camera()];
+        const cameraRgba = cameraRgb + alpha;
+        this.drawRobot(x, y, theta, acceptedRgba, dashed);
+        this.drawCamera(cameraX, cameraY, cameraTheta, cameraRgba);
+      }
+    }
+
     window.requestAnimationFrame(() => this.draw());
   }
 
diff --git a/y2023/y2023_imu.json b/y2023/y2023_imu.json
index b521e07..68dc14a 100644
--- a/y2023/y2023_imu.json
+++ b/y2023/y2023_imu.json
@@ -22,6 +22,7 @@
       "logger": "LOCAL_AND_REMOTE_LOGGER",
       "frequency": 50,
       "num_senders": 20,
+      "max_size": 2048,
       "logger_nodes": [
         "roborio",
         "logger"
@@ -300,6 +301,44 @@
       "num_senders": 2
     },
     {
+      "name": "/localizer",
+      "type": "y2023.localizer.Status",
+      "source_node": "imu",
+      "frequency": 2200,
+      "max_size": 1000,
+      "num_senders": 2
+    },
+    {
+      "name": "/localizer",
+      "type": "frc971.controls.LocalizerOutput",
+      "source_node": "imu",
+      "frequency": 210,
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "roborio"
+      ],
+      "destination_nodes": [
+        {
+          "name": "roborio",
+          "priority": 5,
+          "time_to_live": 5000000,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": [
+            "imu"
+          ]
+        }
+      ]
+    },
+    {
+      "name": "/imu/aos/remote_timestamps/roborio/localizer/frc971-controls-LocalizerOutput",
+      "type": "aos.message_bridge.RemoteMessage",
+      "source_node": "imu",
+      "logger": "NOT_LOGGED",
+      "frequency": 210,
+      "num_senders": 2,
+      "max_size": 200
+    },
+    {
       "name": "/imu/constants",
       "type": "y2023.Constants",
       "source_node": "imu",
@@ -320,8 +359,6 @@
     {
       "name": "localizer",
       "executable_name": "localizer_main",
-      /* TODO(james): Remove this once confident in the accelerometer code. */
-      "args": ["--ignore_accelerometer"],
       "user": "pi",
       "nodes": [
         "imu"
@@ -361,6 +398,13 @@
       ]
     },
     {
+      "name": "foxglove_websocket",
+      "user": "pi",
+      "nodes": [
+        "imu"
+      ]
+    },
+    {
       "name": "constants_sender",
       "autorestart": false,
       "user": "pi",
diff --git a/y2023/y2023_logger.json b/y2023/y2023_logger.json
index e5d62b0..bb78bc5 100644
--- a/y2023/y2023_logger.json
+++ b/y2023/y2023_logger.json
@@ -373,7 +373,7 @@
       "max_size": 1843456,
       "num_readers": 4,
       "read_method": "PIN",
-      "num_senders": 1
+      "num_senders": 18
     },
     {
       "name": "/logger/camera/downsized",
@@ -442,24 +442,17 @@
   ],
   "applications": [
     {
-      "name": "logger_message_bridge_client",
-      "autostart": false,
-      "executable_name": "message_bridge_client.sh",
-      "args": [
-        "--rmem=8388608",
-        "--rt_priority=16"
-      ],
+      "name": "message_bridge_client",
+      "executable_name": "message_bridge_client",
+      "user": "pi",
       "nodes": [
         "logger"
       ]
     },
     {
-      "name": "logger_message_bridge_server",
+      "name": "message_bridge_server",
       "executable_name": "message_bridge_server",
-      "autostart": false,
-      "args": [
-        "--rt_priority=16"
-      ],
+      "user": "pi",
       "nodes": [
         "logger"
       ]
@@ -467,6 +460,7 @@
     {
       "name": "logger_camera_reader",
       "executable_name": "camera_reader",
+      "user": "pi",
       "args": ["--enable_ftrace", "--send_downsized_images"],
       "nodes": [
         "logger"
@@ -476,6 +470,7 @@
       "name": "image_logger",
       "executable_name": "logger_main",
       "autostart": false,
+      "user": "pi",
       "args": [
         "--logging_folder",
         "",
@@ -491,7 +486,8 @@
     {
       "name": "image_streamer",
       "executable_name": "image_streamer_start.sh",
-      "autostart": false,
+      "autostart": true,
+      "user": "pi",
       "nodes": [
         "logger"
       ]
@@ -499,6 +495,7 @@
     {
       "name": "constants_sender",
       "autorestart": false,
+      "user": "pi",
       "nodes": [
         "logger"
       ]
diff --git a/y2023/y2023_pi_template.json b/y2023/y2023_pi_template.json
index b6eaf04..e125ebe 100644
--- a/y2023/y2023_pi_template.json
+++ b/y2023/y2023_pi_template.json
@@ -217,15 +217,6 @@
       ]
     },
     {
-      "name": "/pi{{ NUM }}/camera",
-      "type": "y2023.vision.AprilDebug",
-      "source_node": "pi{{ NUM }}",
-      "frequency": 40,
-      "num_senders": 2,
-      "max_size": 40000,
-      "logger": "LOCAL_LOGGER"
-    },
-    {
       "name": "/pi{{ NUM }}/aos/remote_timestamps/imu/pi{{ NUM }}/camera/frc971-vision-TargetMap",
       "type": "aos.message_bridge.RemoteMessage",
       "frequency": 80,
@@ -346,6 +337,14 @@
       "frequency": 1,
       "num_senders": 2,
       "max_size": 4096
+    },
+    {
+      "name": "/pi{{ NUM }}/camera",
+      "type": "y2023.localizer.Visualization",
+      "source_node": "imu",
+      "frequency": 40,
+      "max_size": 1000,
+      "num_senders": 2
     }
   ],
   "applications": [
diff --git a/y2023/y2023_roborio.json b/y2023/y2023_roborio.json
index 09873dc..e2fa87b 100644
--- a/y2023/y2023_roborio.json
+++ b/y2023/y2023_roborio.json
@@ -457,6 +457,9 @@
     {
       "name": "drivetrain",
       "executable_name": "drivetrain",
+      "args": [
+        "--die_on_malloc"
+      ],
       "nodes": [
         "roborio"
       ]
@@ -464,6 +467,9 @@
     {
       "name": "trajectory_generator",
       "executable_name": "trajectory_generator",
+      "args": [
+        "--die_on_malloc"
+      ],
       "nodes": [
         "roborio"
       ]
@@ -471,6 +477,9 @@
     {
       "name": "superstructure",
       "executable_name": "superstructure",
+      "args": [
+        "--die_on_malloc"
+      ],
       "nodes": [
         "roborio"
       ]
@@ -488,6 +497,9 @@
     {
       "name": "joystick_reader",
       "executable_name": "joystick_reader",
+      "args": [
+        "--die_on_malloc"
+      ],
       "nodes": [
         "roborio"
       ]
@@ -502,6 +514,9 @@
     {
       "name": "autonomous_action",
       "executable_name": "autonomous_action",
+      "args": [
+        "--die_on_malloc"
+      ],
       "autostart": false,
       "nodes": [
         "roborio"