Merge "Allow using shortened channel names in foxglove_websocket"
diff --git a/aos/containers/BUILD b/aos/containers/BUILD
index 9fdc93f..ee1bf98 100644
--- a/aos/containers/BUILD
+++ b/aos/containers/BUILD
@@ -64,6 +64,30 @@
 )
 
 cc_library(
+    name = "error_list",
+    hdrs = [
+        "error_list.h",
+    ],
+    deps = [
+        ":sized_array",
+        "//aos:flatbuffers",
+    ],
+)
+
+cc_test(
+    name = "error_list_test",
+    srcs = [
+        "error_list_test.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":error_list",
+        "//aos:json_to_flatbuffer_fbs",
+        "//aos/testing:googletest",
+    ],
+)
+
+cc_library(
     name = "resizeable_buffer",
     hdrs = [
         "resizeable_buffer.h",
diff --git a/aos/containers/error_list.h b/aos/containers/error_list.h
new file mode 100644
index 0000000..2fbd39e
--- /dev/null
+++ b/aos/containers/error_list.h
@@ -0,0 +1,130 @@
+#ifndef AOS_CONTAINERS_ERROR_LIST_H_
+#define AOS_CONTAINERS_ERROR_LIST_H_
+
+#include <iostream>
+
+#include "aos/containers/sized_array.h"
+#include "flatbuffers/flatbuffers.h"
+
+namespace aos {
+
+// A de-duplicated sorted array based on SizedArray
+// For keeping a list of errors that a subsystem has thrown
+// to publish them in a Status message.
+// It is designed to use flatbuffer enums, and use the reserved fields MAX and
+// MIN to automatically determine how much capacity it needs to have.
+template <typename T>
+class ErrorList {
+ private:
+  using array = SizedArray<T, static_cast<size_t>(T::MAX) -
+                                  static_cast<size_t>(T::MIN) + 1>;
+  array array_;
+
+ public:
+  using value_type = typename array::value_type;
+  using size_type = typename array::size_type;
+  using difference_type = typename array::difference_type;
+  using reference = typename array::reference;
+  using const_reference = typename array::const_reference;
+  using pointer = typename array::pointer;
+  using const_pointer = typename array::const_pointer;
+  using iterator = typename array::iterator;
+  using const_iterator = typename array::const_iterator;
+  using reverse_iterator = typename array::reverse_iterator;
+  using const_reverse_iterator = typename array::const_reverse_iterator;
+
+  constexpr ErrorList() = default;
+  ErrorList(const ErrorList &) = default;
+  ErrorList(ErrorList &&) = default;
+  ErrorList(const flatbuffers::Vector<T> &array) : array_() {
+    for (auto it = array.begin(); it < array.end(); it++) {
+      array_.push_back(*it);
+    }
+    std::sort(array_.begin(), array_.end());
+  };
+
+  ErrorList &operator=(const ErrorList &) = default;
+  ErrorList &operator=(ErrorList &&) = default;
+
+  bool operator==(const ErrorList &other) const {
+    if (other.size() != size()) {
+      return false;
+    }
+    for (size_t i = 0; i < size(); ++i) {
+      if (other[i] != (*this)[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+  bool operator!=(const ErrorList &other) const { return !(*this == other); }
+
+  reference at(size_t i) { return array_.at(i); }
+  const_reference at(size_t i) const { return array_.at(i); }
+
+  reference operator[](size_t i) { return array_[i]; }
+  const_reference operator[](size_t i) const { return array_[i]; }
+
+  reference front() { return array_.front(); }
+  const_reference front() const { return array_.front(); }
+
+  reference back() { return array_.back(); }
+  const_reference back() const { return array_.back(); }
+
+  T *data() { return array_.data(); }
+  const T *data() const { return array_.data(); }
+
+  iterator begin() { return array_.begin(); }
+  const_iterator begin() const { return array_.begin(); }
+  const_iterator cbegin() const { return array_.cbegin(); }
+
+  iterator end() { return array_.end(); }
+  const_iterator end() const { return array_.end(); }
+  const_iterator cend() const { return array_.cend(); }
+
+  reverse_iterator rbegin() { return array_.rbegin(); }
+  const_reverse_iterator rbegin() const { return array_.rbegin(); }
+  const_reverse_iterator crbegin() const { return array_.crbegin(); }
+
+  reverse_iterator rend() { return array_.rend(); }
+  const_reverse_iterator rend() const { return array_.rend(); }
+  const_reverse_iterator crend() const { return array_.crend(); }
+
+  bool empty() const { return array_.empty(); }
+  bool full() const { return array_.full(); }
+
+  size_t size() const { return array_.size(); }
+  constexpr size_t max_size() const { return array_.max_size(); }
+
+  void Clear(const T t) {
+    iterator index = std::find(array_.begin(), array_.end(), t);
+    if (index != array_.end()) {
+      array_.erase(index);
+    }
+  }
+
+  void Set(const T t) {
+    iterator position = std::lower_bound(array_.begin(), array_.end(), t);
+
+    // if it found something, and that something is the same, just leave it
+    if (position != array_.end() && *position == t) {
+      return;
+    }
+
+    // key doesn't already exist
+    array_.insert(position, t);
+  }
+
+  bool Has(const T t) {
+    return std::binary_search(array_.begin(), array_.end(), t);
+  }
+
+  flatbuffers::Offset<flatbuffers::Vector<T>> ToFlatbuffer(
+      flatbuffers::FlatBufferBuilder *fbb) const {
+    return fbb->CreateVector(array_.data(), array_.size());
+  }
+};  // namespace aos
+
+}  // namespace aos
+
+#endif  // AOS_CONTAINERS_ERROR_LIST_H_
diff --git a/aos/containers/error_list_test.cc b/aos/containers/error_list_test.cc
new file mode 100644
index 0000000..3ce23c4
--- /dev/null
+++ b/aos/containers/error_list_test.cc
@@ -0,0 +1,115 @@
+#include "aos/containers/error_list.h"
+
+#include "aos/json_to_flatbuffer_generated.h"
+#include "gtest/gtest.h"
+
+namespace aos {
+namespace testing {
+
+enum class TestEnum : int8_t {
+  FOO = 0,
+  BAR = 1,
+  BAZ = 2,
+  VWEEP = 3,
+  MIN = FOO,
+  MAX = VWEEP
+};
+
+// Tests that setting works and allows no duplicates
+TEST(ErrorListTest, NoDuplicates) {
+  ErrorList<TestEnum> a;
+  EXPECT_EQ(a.size(), 0);
+  a.Set(TestEnum::BAZ);
+  EXPECT_EQ(a.at(0), TestEnum::BAZ);
+  EXPECT_EQ(a.size(), 1);
+  a.Set(TestEnum::BAZ);
+  EXPECT_EQ(a.at(0), TestEnum::BAZ);
+  EXPECT_EQ(a.size(), 1);
+  a.Set(TestEnum::VWEEP);
+  EXPECT_EQ(a.at(0), TestEnum::BAZ);
+  EXPECT_EQ(a.at(1), TestEnum::VWEEP);
+  EXPECT_EQ(a.size(), 2);
+  a.Set(TestEnum::FOO);
+  EXPECT_EQ(a.at(0), TestEnum::FOO);
+  EXPECT_EQ(a.at(1), TestEnum::BAZ);
+  EXPECT_EQ(a.at(2), TestEnum::VWEEP);
+  EXPECT_EQ(a.size(), 3);
+}
+
+// Tests that clearing works
+TEST(ErrorListTest, Clearing) {
+  ErrorList<TestEnum> a;
+  a.Set(TestEnum::FOO);
+  a.Set(TestEnum::BAZ);
+  a.Set(TestEnum::VWEEP);
+  EXPECT_EQ(a.at(0), TestEnum::FOO);
+  EXPECT_EQ(a.at(1), TestEnum::BAZ);
+  EXPECT_EQ(a.at(2), TestEnum::VWEEP);
+  EXPECT_EQ(a.size(), 3);
+
+  a.Clear(TestEnum::BAR);
+  EXPECT_EQ(a.at(0), TestEnum::FOO);
+  EXPECT_EQ(a.at(1), TestEnum::BAZ);
+  EXPECT_EQ(a.at(2), TestEnum::VWEEP);
+  EXPECT_EQ(a.size(), 3);
+
+  a.Clear(TestEnum::BAZ);
+  EXPECT_EQ(a.at(0), TestEnum::FOO);
+  EXPECT_EQ(a.at(1), TestEnum::VWEEP);
+  EXPECT_EQ(a.size(), 2);
+}
+
+// Tests that checking for a value works
+TEST(ErrorListTest, Has) {
+  ErrorList<TestEnum> a;
+  a.Set(TestEnum::FOO);
+  a.Set(TestEnum::BAZ);
+  a.Set(TestEnum::VWEEP);
+  EXPECT_EQ(a.at(0), TestEnum::FOO);
+  EXPECT_EQ(a.at(1), TestEnum::BAZ);
+  EXPECT_EQ(a.at(2), TestEnum::VWEEP);
+  EXPECT_EQ(a.size(), 3);
+
+  EXPECT_TRUE(a.Has(TestEnum::FOO));
+  EXPECT_TRUE(a.Has(TestEnum::VWEEP));
+  EXPECT_TRUE(a.Has(TestEnum::BAZ));
+  EXPECT_FALSE(a.Has(TestEnum::BAR));
+}
+
+// Tests serializing and deserializing to/from flatbuffers.
+TEST(ErrorListTest, Flatbuffers) {
+  ErrorList<BaseType> a;
+  a.Set(BaseType::Bool);
+  a.Set(BaseType::Float);
+  a.Set(BaseType::Short);
+  EXPECT_TRUE(a.Has(BaseType::Bool));
+  EXPECT_TRUE(a.Has(BaseType::Short));
+  EXPECT_TRUE(a.Has(BaseType::Float));
+  EXPECT_EQ(a.at(0), BaseType::Bool);
+  EXPECT_EQ(a.at(1), BaseType::Short);
+  EXPECT_EQ(a.at(2), BaseType::Float);
+  EXPECT_EQ(a.size(), 3);
+
+  flatbuffers::FlatBufferBuilder fbb(1024);
+  flatbuffers::Offset<flatbuffers::Vector<BaseType>> vector =
+      a.ToFlatbuffer(&fbb);
+
+  ConfigurationBuilder builder(fbb);
+  builder.add_vector_foo_enum(vector);
+
+  fbb.Finish(builder.Finish());
+  const Configuration *config =
+      flatbuffers::GetRoot<Configuration>(fbb.GetBufferPointer());
+
+  ErrorList<BaseType> b(*config->vector_foo_enum());
+  EXPECT_TRUE(b.Has(BaseType::Bool));
+  EXPECT_TRUE(b.Has(BaseType::Short));
+  EXPECT_TRUE(b.Has(BaseType::Float));
+  EXPECT_EQ(b.at(0), BaseType::Bool);
+  EXPECT_EQ(b.at(1), BaseType::Short);
+  EXPECT_EQ(b.at(2), BaseType::Float);
+  EXPECT_EQ(b.size(), 3);
+}
+
+}  // namespace testing
+}  // namespace aos
diff --git a/aos/containers/sized_array_test.cc b/aos/containers/sized_array_test.cc
index ae732bc..d055f40 100644
--- a/aos/containers/sized_array_test.cc
+++ b/aos/containers/sized_array_test.cc
@@ -175,5 +175,70 @@
   EXPECT_DEATH(a.emplace_back(5), "Aborted at");
 }
 
+// Tests inserting at various positions in the array.
+TEST(SizedArrayTest, Inserting) {
+  SizedArray<int, 5> a;
+  a.insert(a.begin(), 2);
+  EXPECT_EQ(a.at(0), 2);
+  EXPECT_EQ(a.size(), 1);
+
+  a.emplace_back(3);
+  EXPECT_EQ(a.at(0), 2);
+  EXPECT_EQ(a.at(1), 3);
+  EXPECT_EQ(a.size(), 2);
+
+  a.insert(a.begin(), 0);
+  EXPECT_EQ(a.at(0), 0);
+  EXPECT_EQ(a.at(1), 2);
+  EXPECT_EQ(a.at(2), 3);
+  EXPECT_EQ(a.size(), 3);
+
+  a.insert(a.begin() + 1, 1);
+  EXPECT_EQ(a.at(0), 0);
+  EXPECT_EQ(a.at(1), 1);
+  EXPECT_EQ(a.at(2), 2);
+  EXPECT_EQ(a.at(3), 3);
+  EXPECT_EQ(a.size(), 4);
+
+  a.insert(a.begin() + 1, 0);
+  EXPECT_EQ(a.at(0), 0);
+  EXPECT_EQ(a.at(1), 0);
+  EXPECT_EQ(a.at(2), 1);
+  EXPECT_EQ(a.at(3), 2);
+  EXPECT_EQ(a.at(4), 3);
+  EXPECT_EQ(a.size(), 5);
+}
+
+// Tests erasing things from the array
+TEST(SizedArrayTest, Erasing) {
+  SizedArray<int, 5> a;
+  a.push_back(8);
+  a.push_back(9);
+  a.push_back(7);
+  a.push_back(1);
+  a.push_back(5);
+  EXPECT_EQ(a.at(0), 8);
+  EXPECT_EQ(a.at(1), 9);
+  EXPECT_EQ(a.at(2), 7);
+  EXPECT_EQ(a.at(3), 1);
+  EXPECT_EQ(a.at(4), 5);
+  EXPECT_EQ(a.size(), 5);
+
+  a.erase(a.begin() + 1, a.begin() + 3);
+  EXPECT_EQ(a.at(0), 8);
+  EXPECT_EQ(a.at(1), 1);
+  EXPECT_EQ(a.at(2), 5);
+  EXPECT_EQ(a.size(), 3);
+
+  a.erase(a.begin());
+  EXPECT_EQ(a.at(0), 1);
+  EXPECT_EQ(a.at(1), 5);
+  EXPECT_EQ(a.size(), 2);
+
+  a.erase(a.end() - 1);
+  EXPECT_EQ(a.at(0), 1);
+  EXPECT_EQ(a.size(), 1);
+}
+
 }  // namespace testing
 }  // namespace aos
diff --git a/frc971/rockpi/build_rootfs.sh b/frc971/rockpi/build_rootfs.sh
index bb8b84a..78ae6ac 100755
--- a/frc971/rockpi/build_rootfs.sh
+++ b/frc971/rockpi/build_rootfs.sh
@@ -168,13 +168,13 @@
 cat << __EOF__ | sudo tee "${PARTITION}/boot/sdcard_extlinux.conf"
 label Linux ${KERNEL_VERSION}
     kernel /vmlinuz-${KERNEL_VERSION}
-    append earlycon=uart8250,mmio32,0xff1a0000 earlyprintk console=ttyS2,1500000n8 root=/dev/mmcblk0p2 ro rootfstype=ext4 rootwait
+    append earlycon=uart8250,mmio32,0xff1a0000 earlyprintk console=ttyS2,1500000n8 root=/dev/mmcblk0p2 ro rootfstype=ext4 rootflags=data=journal rootwait
     fdtdir /dtbs/${KERNEL_VERSION}/
 __EOF__
 cat << __EOF__ | sudo tee "${PARTITION}/boot/emmc_extlinux.conf"
 label Linux ${KERNEL_VERSION}
     kernel /vmlinuz-${KERNEL_VERSION}
-    append earlycon=uart8250,mmio32,0xff1a0000 earlyprintk console=ttyS2,1500000n8 root=/dev/mmcblk1p2 ro rootfstype=ext4 rootwait
+    append earlycon=uart8250,mmio32,0xff1a0000 earlyprintk console=ttyS2,1500000n8 root=/dev/mmcblk1p2 ro rootfstype=ext4 rootflags=data=journal rootwait
     fdtdir /dtbs/${KERNEL_VERSION}/
 __EOF__
 
diff --git a/frc971/rockpi/contents/etc/fstab b/frc971/rockpi/contents/etc/fstab
index c148c2d..0074a98 100644
--- a/frc971/rockpi/contents/etc/fstab
+++ b/frc971/rockpi/contents/etc/fstab
@@ -1,2 +1,2 @@
-/dev/mmcblk0p2  /  auto  errors=remount-ro  0  0
+/dev/mmcblk0p2 / ext4 rw,relatime,sync,dirsync,nodelalloc,errors=remount-ro 0  0
 tmpfs /dev/shm tmpfs rw,nosuid,nodev,size=90% 0 0
diff --git a/frc971/vision/calibration_accumulator.h b/frc971/vision/calibration_accumulator.h
index 4b59406..132bb4b 100644
--- a/frc971/vision/calibration_accumulator.h
+++ b/frc971/vision/calibration_accumulator.h
@@ -89,7 +89,8 @@
                      std::vector<std::vector<cv::Point2f>> charuco_corners) {
     auto builder = annotations_sender_.MakeBuilder();
     builder.CheckOk(builder.Send(
-        BuildAnnotations(eof, charuco_corners, 2.0, builder.fbb())));
+        BuildAnnotations(builder.fbb(), eof, charuco_corners,
+                         std::vector<double>{0.0, 1.0, 0.0, 1.0}, 2.0)));
   }
 
  private:
diff --git a/frc971/vision/charuco_lib.cc b/frc971/vision/charuco_lib.cc
index 2f9e81a..ac708f6 100644
--- a/frc971/vision/charuco_lib.cc
+++ b/frc971/vision/charuco_lib.cc
@@ -456,35 +456,15 @@
 }
 
 flatbuffers::Offset<foxglove::ImageAnnotations> BuildAnnotations(
+    flatbuffers::FlatBufferBuilder *fbb,
     const aos::monotonic_clock::time_point monotonic_now,
-    const std::vector<std::vector<cv::Point2f>> &corners, double thickness,
-    flatbuffers::FlatBufferBuilder *fbb) {
+    const std::vector<std::vector<cv::Point2f>> &corners,
+    const std::vector<double> rgba_color, const double thickness,
+    const foxglove::PointsAnnotationType line_type) {
   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, 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) {
-      points_offsets.push_back(foxglove::CreatePoint2(*fbb, point.x, point.y));
-    }
-    const flatbuffers::Offset<
-        flatbuffers::Vector<flatbuffers::Offset<foxglove::Point2>>>
-        points_offset = fbb->CreateVector(points_offsets);
-    std::vector<flatbuffers::Offset<foxglove::Color>> color_offsets(
-        points_offsets.size(), color_offset);
-    auto colors_offset = fbb->CreateVector(color_offsets);
-    foxglove::PointsAnnotation::Builder points_builder(*fbb);
-    points_builder.add_timestamp(&time);
-    points_builder.add_type(foxglove::PointsAnnotationType::POINTS);
-    points_builder.add_points(points_offset);
-    points_builder.add_outline_color(color_offset);
-    points_builder.add_outline_colors(colors_offset);
-    points_builder.add_thickness(thickness);
-    rectangles.push_back(points_builder.Finish());
+    rectangles.push_back(BuildPointsAnnotation(
+        fbb, monotonic_now, rectangle, rgba_color, thickness, line_type));
   }
 
   const auto rectangles_offset = fbb->CreateVector(rectangles);
@@ -493,6 +473,39 @@
   return annotation_builder.Finish();
 }
 
+flatbuffers::Offset<foxglove::PointsAnnotation> BuildPointsAnnotation(
+    flatbuffers::FlatBufferBuilder *fbb,
+    const aos::monotonic_clock::time_point monotonic_now,
+    const std::vector<cv::Point2f> &corners,
+    const std::vector<double> rgba_color, const double thickness,
+    const foxglove::PointsAnnotationType line_type) {
+  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)};
+  const flatbuffers::Offset<foxglove::Color> color_offset =
+      foxglove::CreateColor(*fbb, rgba_color[0], rgba_color[1], rgba_color[2],
+                            rgba_color[3]);
+  std::vector<flatbuffers::Offset<foxglove::Point2>> points_offsets;
+  for (const cv::Point2f &point : corners) {
+    points_offsets.push_back(foxglove::CreatePoint2(*fbb, point.x, point.y));
+  }
+  const flatbuffers::Offset<
+      flatbuffers::Vector<flatbuffers::Offset<foxglove::Point2>>>
+      points_offset = fbb->CreateVector(points_offsets);
+  std::vector<flatbuffers::Offset<foxglove::Color>> color_offsets(
+      points_offsets.size(), color_offset);
+  auto colors_offset = fbb->CreateVector(color_offsets);
+  foxglove::PointsAnnotation::Builder points_builder(*fbb);
+  points_builder.add_timestamp(&time);
+  points_builder.add_type(line_type);
+  points_builder.add_points(points_offset);
+  points_builder.add_outline_color(color_offset);
+  points_builder.add_outline_colors(colors_offset);
+  points_builder.add_thickness(thickness);
+
+  return points_builder.Finish();
+}
+
 TargetType TargetTypeFromString(std::string_view str) {
   if (str == "aruco") {
     return TargetType::kAruco;
diff --git a/frc971/vision/charuco_lib.h b/frc971/vision/charuco_lib.h
index f1846b5..2c35a0b 100644
--- a/frc971/vision/charuco_lib.h
+++ b/frc971/vision/charuco_lib.h
@@ -174,9 +174,25 @@
 // Puts the provided charuco corners into a foxglove ImageAnnotation type for
 // visualization purposes.
 flatbuffers::Offset<foxglove::ImageAnnotations> BuildAnnotations(
+    flatbuffers::FlatBufferBuilder *fbb,
     const aos::monotonic_clock::time_point monotonic_now,
-    const std::vector<std::vector<cv::Point2f>> &corners, double thickness,
-    flatbuffers::FlatBufferBuilder *fbb);
+    const std::vector<std::vector<cv::Point2f>> &corners,
+    const std::vector<double> rgba_color = std::vector<double>{0.0, 1.0, 0.0,
+                                                               1.0},
+    const double thickness = 5,
+    const foxglove::PointsAnnotationType line_type =
+        foxglove::PointsAnnotationType::POINTS);
+
+// Creates a PointsAnnotation to build up ImageAnnotations with different types
+flatbuffers::Offset<foxglove::PointsAnnotation> BuildPointsAnnotation(
+    flatbuffers::FlatBufferBuilder *fbb,
+    const aos::monotonic_clock::time_point monotonic_now,
+    const std::vector<cv::Point2f> &corners,
+    const std::vector<double> rgba_color = std::vector<double>{0.0, 1.0, 0.0,
+                                                               1.0},
+    const double thickness = 5,
+    const foxglove::PointsAnnotationType line_type =
+        foxglove::PointsAnnotationType::POINTS);
 
 }  // namespace vision
 }  // namespace frc971
diff --git a/scouting/BUILD b/scouting/BUILD
index 1d6ac5d..c98c741 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -1,5 +1,5 @@
-load("@aspect_rules_cypress//cypress:defs.bzl", "cypress_module_test")
 load("//tools/build_rules:apache.bzl", "apache_wrapper")
+load("//tools/build_rules:js.bzl", "cypress_test")
 
 sh_binary(
     name = "scouting",
@@ -16,23 +16,13 @@
     ],
 )
 
-cypress_module_test(
+cypress_test(
     name = "scouting_test",
-    args = [
-        "run",
-        "--config-file=cypress.config.js",
-        "--browser=../../chrome_linux/chrome",
-    ],
-    browsers = ["@chrome_linux//:all"],
-    copy_data_to_bin = False,
-    cypress = "//:node_modules/cypress",
     data = [
-        "cypress.config.js",
         "scouting_test.cy.js",
         "//scouting/testing:scouting_test_servers",
-        "@xvfb_amd64//:wrapped_bin/Xvfb",
     ],
-    runner = "cypress_runner.js",
+    runner = "scouting_test_runner.js",
 )
 
 apache_wrapper(
diff --git a/scouting/README.md b/scouting/README.md
index 6e31b5b..7e1a58a 100644
--- a/scouting/README.md
+++ b/scouting/README.md
@@ -124,3 +124,8 @@
 where `<year>` is the year of the event and `<event_code>` is the short code
 for the event. A list of event codes is available
 [here](http://frclinks.com/#eventcodes).
+
+
+Debugging Cypress tests
+--------------------------------------------------------------------------------
+See the [dedicated section](../tools/js#debugging-cypress-tests) for this.
diff --git a/scouting/deploy/BUILD b/scouting/deploy/BUILD
index ed4b9cd..2bf4b4e 100644
--- a/scouting/deploy/BUILD
+++ b/scouting/deploy/BUILD
@@ -17,6 +17,15 @@
     include_runfiles = True,
     package_dir = "opt/frc971/scouting_server",
     strip_prefix = ".",
+    # The "include_runfiles" attribute creates a runfiles tree as seen from
+    # within the workspace directory. But what we really want is the runfiles
+    # tree as seen from the root of the runfiles tree (i.e. one directory up).
+    # So we work around it by manually adding some symlinks that let us pretend
+    # that we're at the root of the runfiles tree.
+    symlinks = {
+        "opt/frc971/scouting_server/org_frc971": ".",
+        "opt/frc971/scouting_server/bazel_tools": "external/bazel_tools",
+    },
 )
 
 pkg_tar(
diff --git a/scouting/deploy/scouting.service b/scouting/deploy/scouting.service
index b22a57d..5aa64b0 100644
--- a/scouting/deploy/scouting.service
+++ b/scouting/deploy/scouting.service
@@ -7,6 +7,7 @@
 Group=www-data
 Type=simple
 WorkingDirectory=/opt/frc971/scouting_server
+Environment=RUNFILES_DIR=/opt/frc971/scouting_server
 ExecStart=/opt/frc971/scouting_server/scouting/scouting \
     -port 8080 \
     -db_config /var/frc971/scouting/db_config.json \
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index e27fb54..a2dfa83 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -115,6 +115,8 @@
     cy.get('#comp_level').should('have.value', '3: sf');
   });
 
+  //TODO(FILIP): Rewrite tests for the new scouting interface.
+  /*
   it('should: error on unknown match.', () => {
     switchToTab('Data Entry');
     headerShouldBe('Team Selection');
@@ -167,6 +169,7 @@
       }
     }
   });
+  
 
   it('should: review and submit correct data.', () => {
     switchToTab('Data Entry');
@@ -234,25 +237,7 @@
     headerShouldBe('Success');
   });
 
-  it('should: load all images successfully.', () => {
-    switchToTab('Data Entry');
-
-    // Get to the Auto display with the field pictures.
-    headerShouldBe('Team Selection');
-    clickButton('Next');
-    headerShouldBe('Auto');
-
-    // We expect 2 fully loaded images for each of the orientations.
-    // 2 images for the original orientation and 2 images for the flipped orientation.
-    for (let i = 0; i < 2; i++) {
-      cy.get('img').should(($imgs) => {
-        for (const $img of $imgs) {
-          expect($img.naturalWidth).to.be.greaterThan(0);
-        }
-      });
-      clickButton('Flip');
-    }
-  });
+  */
 
   it('should: submit note scouting for multiple teams', () => {
     // Navigate to Notes Page.
diff --git a/scouting/cypress_runner.js b/scouting/scouting_test_runner.js
similarity index 82%
rename from scouting/cypress_runner.js
rename to scouting/scouting_test_runner.js
index 6c63adb..3106ee7 100644
--- a/scouting/cypress_runner.js
+++ b/scouting/scouting_test_runner.js
@@ -57,17 +57,21 @@
 
 // Wait for the server to be ready, run the tests, then shut down the server.
 (async () => {
+  // Parse command line options.
+  let runOptions = await cypress.cli.parseRunArguments(process.argv.slice(2));
+
   await serverStartup;
-  const result = await cypress.run({
-    headless: true,
-    config: {
-      baseUrl: 'http://localhost:8000',
-      screenshotsFolder:
-        process.env.TEST_UNDECLARED_OUTPUTS_DIR + '/screenshots',
-      video: false,
-      videosFolder: process.env.TEST_UNDECLARED_OUTPUTS_DIR + '/videos',
-    },
-  });
+  const result = await cypress.run(
+    Object.assign(runOptions, {
+      config: {
+        baseUrl: 'http://localhost:8000',
+        screenshotsFolder:
+          process.env.TEST_UNDECLARED_OUTPUTS_DIR + '/screenshots',
+        video: false,
+        videosFolder: process.env.TEST_UNDECLARED_OUTPUTS_DIR + '/videos',
+      },
+    })
+  );
   await servers.kill();
   await serverShutdown;
 
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 37b7fe6..07c1d79 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -11,9 +11,7 @@
     deps = [
         ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_data_scouting_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_data_scouting_ts_fbs",
-        "//scouting/www/counter_button:_lib",
+        "//scouting/webserver/requests/messages:submit_actions_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
     ],
 )
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 6209669..9aae0d1 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -11,17 +11,25 @@
 import {Builder, ByteBuffer} from 'flatbuffers';
 import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
 import {
-  ClimbLevel,
-  SubmitDataScouting,
-} from '../../webserver/requests/messages/submit_data_scouting_generated';
-import {SubmitDataScoutingResponse} from '../../webserver/requests/messages/submit_data_scouting_response_generated';
+  ObjectType,
+  ScoreLevel,
+  SubmitActions,
+  StartMatchAction,
+  PickupObjectAction,
+  PlaceObjectAction,
+  RobotDeathAction,
+  EndMatchAction,
+  ActionType,
+  Action,
+} from '../../webserver/requests/messages/submit_actions_generated';
 
 type Section =
   | 'Team Selection'
-  | 'Auto'
-  | 'TeleOp'
-  | 'Climb'
-  | 'Other'
+  | 'Init'
+  | 'Pickup'
+  | 'Place'
+  | 'Endgame'
+  | 'Dead'
   | 'Review and Submit'
   | 'Success';
 
@@ -38,22 +46,42 @@
   f: 'Finals',
 };
 
-const IMAGES_ARRAY = [
-  {
-    id: 'field_quadrants_image',
-    original_image:
-      '/sha256/cbb99a057a2504e80af526dae7a0a04121aed84c56a6f4889e9576fe1c20c61e/pictures/field/quadrants.jpeg',
-    reversed_image:
-      '/sha256/ee4d24cf6b850158aa64e2b301c31411cb28f88a247a8916abb97214bb251eb5/pictures/field/reversed_quadrants.jpeg',
-  },
-  {
-    id: 'field_balls_image',
-    original_image:
-      '/sha256/e095cc8a75d804b0e2070e0a941fab37154176756d4c1a775e53cc48c3a732b9/pictures/field/balls.jpeg',
-    reversed_image:
-      '/sha256/fe4a4605c03598611c583d4dcdf28e06a056a17302ae91f5c527568966d95f3a/pictures/field/reversed_balls.jpeg',
-  },
-];
+type ActionT =
+  | {
+      type: 'startMatchAction';
+      timestamp?: number;
+      position: number;
+    }
+  | {
+      type: 'pickupObjectAction';
+      timestamp?: number;
+      objectType: ObjectType;
+      auto?: boolean;
+    }
+  | {
+      type: 'placeObjectAction';
+      timestamp?: number;
+      objectType?: ObjectType;
+      scoreLevel: ScoreLevel;
+      auto?: boolean;
+    }
+  | {
+      type: 'robotDeathAction';
+      timestamp?: number;
+      robotOn: boolean;
+    }
+  | {
+      type: 'endMatchAction';
+      docked: boolean;
+      engaged: boolean;
+      timestamp?: number;
+    }
+  | {
+      // This is not a action that is submitted,
+      // It is used for undoing purposes.
+      type: 'endAutoPhase';
+      timestamp?: number;
+    };
 
 @Component({
   selector: 'app-entry',
@@ -63,9 +91,10 @@
 export class EntryComponent {
   // Re-export the type here so that we can use it in the `[value]` attribute
   // of radio buttons.
-  readonly ClimbLevel = ClimbLevel;
   readonly COMP_LEVELS = COMP_LEVELS;
   readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
+  readonly ObjectType = ObjectType;
+  readonly ScoreLevel = ScoreLevel;
 
   section: Section = 'Team Selection';
   @Output() switchTabsEvent = new EventEmitter<string>();
@@ -73,118 +102,169 @@
   @Input() teamNumber: number = 1;
   @Input() setNumber: number = 1;
   @Input() compLevel: CompLevel = 'qm';
-  autoUpperShotsMade: number = 0;
-  autoLowerShotsMade: number = 0;
-  autoShotsMissed: number = 0;
-  teleUpperShotsMade: number = 0;
-  teleLowerShotsMade: number = 0;
-  teleShotsMissed: number = 0;
-  defensePlayedOnScore: number = 0;
-  defensePlayedScore: number = 0;
-  level: ClimbLevel = ClimbLevel.NoAttempt;
-  ball1: boolean = false;
-  ball2: boolean = false;
-  ball3: boolean = false;
-  ball4: boolean = false;
-  ball5: boolean = false;
-  quadrant: number = 1;
+
+  actionList: ActionT[] = [];
   errorMessage: string = '';
-  noShow: boolean = false;
-  neverMoved: boolean = false;
-  batteryDied: boolean = false;
-  mechanicallyBroke: boolean = false;
-  lostComs: boolean = false;
-  comment: string = '';
+  autoPhase: boolean = true;
+  lastObject: ObjectType = null;
+
+  matchStartTimestamp: number = 0;
+
+  addAction(action: ActionT): void {
+    action.timestamp = Math.floor(Date.now() / 1000);
+    if (action.type == 'startMatchAction') {
+      // Unix nanosecond timestamp.
+      this.matchStartTimestamp = Date.now() * 1e6;
+      action.timestamp = 0;
+    } else {
+      // Unix nanosecond timestamp relative to match start.
+      action.timestamp = Date.now() * 1e6 - this.matchStartTimestamp;
+    }
+
+    if (
+      action.type == 'pickupObjectAction' ||
+      action.type == 'placeObjectAction'
+    ) {
+      action.auto = this.autoPhase;
+      if (action.type == 'pickupObjectAction') {
+        this.lastObject = action.objectType;
+      } else if (action.type == 'placeObjectAction') {
+        action.objectType = this.lastObject;
+      }
+    }
+    this.actionList.push(action);
+  }
+
+  undoLastAction() {
+    if (this.actionList.length > 0) {
+      let lastAction = this.actionList.pop();
+      switch (lastAction?.type) {
+        case 'endAutoPhase':
+          this.autoPhase = true;
+        case 'pickupObjectAction':
+          this.section = 'Pickup';
+          break;
+        case 'placeObjectAction':
+          this.section = 'Place';
+          break;
+        case 'endMatchAction':
+          this.section = 'Pickup';
+          break;
+        default:
+          break;
+      }
+    }
+  }
+
+  changeSectionTo(target: Section) {
+    this.section = target;
+  }
 
   @ViewChild('header') header: ElementRef;
 
-  nextSection() {
-    if (this.section === 'Team Selection') {
-      this.section = 'Auto';
-    } else if (this.section === 'Auto') {
-      this.section = 'TeleOp';
-    } else if (this.section === 'TeleOp') {
-      this.section = 'Climb';
-    } else if (this.section === 'Climb') {
-      this.section = 'Other';
-    } else if (this.section === 'Other') {
-      this.section = 'Review and Submit';
-    } else if (this.section === 'Review and Submit') {
-      this.submitDataScouting();
-      return;
-    } else if (this.section === 'Success') {
-      this.switchTabsEvent.emit('MatchList');
-      return;
-    }
-    // Scroll back to the top so that we can be sure the user sees the
-    // entire next screen. Otherwise it's easy to overlook input fields.
-    this.scrollToTop();
-  }
-
-  prevSection() {
-    if (this.section === 'Auto') {
-      this.section = 'Team Selection';
-    } else if (this.section === 'TeleOp') {
-      this.section = 'Auto';
-    } else if (this.section === 'Climb') {
-      this.section = 'TeleOp';
-    } else if (this.section === 'Other') {
-      this.section = 'Climb';
-    } else if (this.section === 'Review and Submit') {
-      this.section = 'Other';
-    }
-    // Scroll back to the top so that we can be sure the user sees the
-    // entire previous screen. Otherwise it's easy to overlook input
-    // fields.
-    this.scrollToTop();
-  }
-
-  flipImages() {
-    for (let obj of IMAGES_ARRAY) {
-      let img = document.getElementById(obj.id) as HTMLImageElement;
-      img.src = img.src.endsWith(obj.original_image)
-        ? obj.reversed_image
-        : obj.original_image;
-    }
-  }
   private scrollToTop() {
     this.header.nativeElement.scrollIntoView();
   }
 
-  async submitDataScouting() {
-    this.errorMessage = '';
-
+  async submitActions() {
     const builder = new Builder();
-    const compLevel = builder.createString(this.compLevel);
-    const comment = builder.createString(this.comment);
-    SubmitDataScouting.startSubmitDataScouting(builder);
-    SubmitDataScouting.addTeam(builder, this.teamNumber);
-    SubmitDataScouting.addMatch(builder, this.matchNumber);
-    SubmitDataScouting.addSetNumber(builder, this.setNumber);
-    SubmitDataScouting.addCompLevel(builder, compLevel);
-    SubmitDataScouting.addMissedShotsAuto(builder, this.autoShotsMissed);
-    SubmitDataScouting.addUpperGoalAuto(builder, this.autoUpperShotsMade);
-    SubmitDataScouting.addLowerGoalAuto(builder, this.autoLowerShotsMade);
-    SubmitDataScouting.addMissedShotsTele(builder, this.teleShotsMissed);
-    SubmitDataScouting.addUpperGoalTele(builder, this.teleUpperShotsMade);
-    SubmitDataScouting.addLowerGoalTele(builder, this.teleLowerShotsMade);
-    SubmitDataScouting.addDefenseRating(builder, this.defensePlayedScore);
-    SubmitDataScouting.addDefenseReceivedRating(
+    const actionOffsets: number[] = [];
+
+    for (const action of this.actionList) {
+      let actionOffset: number | undefined;
+      console.log(action.type);
+
+      switch (action.type) {
+        case 'startMatchAction':
+          const startMatchActionOffset =
+            StartMatchAction.createStartMatchAction(builder, action.position);
+          actionOffset = Action.createAction(
+            builder,
+            action.timestamp || 0,
+            ActionType.StartMatchAction,
+            startMatchActionOffset
+          );
+          break;
+
+        case 'pickupObjectAction':
+          const pickupObjectActionOffset =
+            PickupObjectAction.createPickupObjectAction(
+              builder,
+              action.objectType,
+              action.auto || false
+            );
+          actionOffset = Action.createAction(
+            builder,
+            action.timestamp || 0,
+            ActionType.PickupObjectAction,
+            pickupObjectActionOffset
+          );
+          break;
+
+        case 'placeObjectAction':
+          const placeObjectActionOffset =
+            PlaceObjectAction.createPlaceObjectAction(
+              builder,
+              action.objectType,
+              action.scoreLevel,
+              action.auto || false
+            );
+          actionOffset = Action.createAction(
+            builder,
+            action.timestamp || 0,
+            ActionType.PlaceObjectAction,
+            placeObjectActionOffset
+          );
+          break;
+
+        case 'robotDeathAction':
+          const robotDeathActionOffset =
+            RobotDeathAction.createRobotDeathAction(builder, action.robotOn);
+          actionOffset = Action.createAction(
+            builder,
+            action.timestamp || 0,
+            ActionType.RobotDeathAction,
+            robotDeathActionOffset
+          );
+          break;
+
+        case 'endMatchAction':
+          const endMatchActionOffset = EndMatchAction.createEndMatchAction(
+            builder,
+            action.docked,
+            action.engaged
+          );
+          actionOffset = Action.createAction(
+            builder,
+            action.timestamp || 0,
+            ActionType.EndMatchAction,
+            endMatchActionOffset
+          );
+          break;
+
+        case 'endAutoPhase':
+          // Not important action.
+          break;
+
+        default:
+          throw new Error(`Unknown action type`);
+      }
+
+      if (actionOffset !== undefined) {
+        actionOffsets.push(actionOffset);
+      }
+    }
+
+    const actionsVector = SubmitActions.createActionsListVector(
       builder,
-      this.defensePlayedOnScore
+      actionOffsets
     );
-    SubmitDataScouting.addAutoBall1(builder, this.ball1);
-    SubmitDataScouting.addAutoBall2(builder, this.ball2);
-    SubmitDataScouting.addAutoBall3(builder, this.ball3);
-    SubmitDataScouting.addAutoBall4(builder, this.ball4);
-    SubmitDataScouting.addAutoBall5(builder, this.ball5);
-    SubmitDataScouting.addStartingQuadrant(builder, this.quadrant);
-    SubmitDataScouting.addClimbLevel(builder, this.level);
-    SubmitDataScouting.addComment(builder, comment);
-    builder.finish(SubmitDataScouting.endSubmitDataScouting(builder));
+    SubmitActions.startSubmitActions(builder);
+    SubmitActions.addActionsList(builder, actionsVector);
+    builder.finish(SubmitActions.endSubmitActions(builder));
 
     const buffer = builder.asUint8Array();
-    const res = await fetch('/requests/submit/data_scouting', {
+    const res = await fetch('/requests/submit/actions', {
       method: 'POST',
       body: buffer,
     });
@@ -192,6 +272,7 @@
     if (res.ok) {
       // We successfully submitted the data. Report success.
       this.section = 'Success';
+      this.actionList = [];
     } else {
       const resBuffer = await res.arrayBuffer();
       const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index a2aa7bb..c74d4d0 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -1,22 +1,11 @@
 import {NgModule, Pipe, PipeTransform} from '@angular/core';
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
-
-import {CounterButtonModule} from '@org_frc971/scouting/www/counter_button';
 import {EntryComponent} from './entry.component';
 
-import {ClimbLevel} from '../../webserver/requests/messages/submit_data_scouting_generated';
-
-@Pipe({name: 'levelToString'})
-export class LevelToStringPipe implements PipeTransform {
-  transform(level: ClimbLevel): string {
-    return ClimbLevel[level];
-  }
-}
-
 @NgModule({
-  declarations: [EntryComponent, LevelToStringPipe],
+  declarations: [EntryComponent],
   exports: [EntryComponent],
-  imports: [CommonModule, FormsModule, CounterButtonModule],
+  imports: [CommonModule, FormsModule],
 })
 export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index e73cfb0..ec95f39 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -49,386 +49,133 @@
     <div class="buttons">
       <!-- hack to right align the next button -->
       <div></div>
-      <button class="btn btn-primary" (click)="nextSection()">Next</button>
+      <button class="btn btn-primary" (click)="changeSectionTo('Init');">
+        Next
+      </button>
     </div>
   </div>
 
-  <div *ngSwitchCase="'Auto'" id="auto" class="container-fluid">
-    <button class="buttons" id="switch_field_button" (click)="flipImages()">
-      Flip
-    </button>
-    <div class="row">
-      <img
-        id="field_quadrants_image"
-        src="/sha256/cbb99a057a2504e80af526dae7a0a04121aed84c56a6f4889e9576fe1c20c61e/pictures/field/quadrants.jpeg"
-        alt="Quadrants Image"
-      />
-      <form>
+  <div *ngSwitchCase="'Init'" id="init" class="container-fluid">
+    <h2>Select Starting Position</h2>
+    <div *ngFor="let i of [1, 2, 3, 4]">
+      <label>
         <input
           type="radio"
-          [(ngModel)]="quadrant"
-          name="quadrant"
-          id="quadrant1"
-          [value]="1"
+          name="radio-group"
+          [value]="i"
+          (change)="selectedValue = $event.target.value"
         />
-        <label for="quadrant1">Quadrant 1</label>
-        <input
-          type="radio"
-          [(ngModel)]="quadrant"
-          name="quadrant"
-          id="quadrant2"
-          [value]="2"
-        />
-        <label for="quadrant2">Quadrant 2</label>
-        <br />
-        <input
-          type="radio"
-          [(ngModel)]="quadrant"
-          name="quadrant"
-          id="quadrant3"
-          [value]="3"
-        />
-        <label for="quadrant3">Quadrant 3</label>
-        <input
-          type="radio"
-          [(ngModel)]="quadrant"
-          name="quadrant"
-          id="quadrant4"
-          [value]="4"
-        />
-        <label for="quadrant4">Quadrant 4</label>
-      </form>
-    </div>
-    <div class="row">
-      <img
-        id="field_balls_image"
-        src="/sha256/e095cc8a75d804b0e2070e0a941fab37154176756d4c1a775e53cc48c3a732b9/pictures/field/balls.jpeg"
-        alt="Balls Image"
-      />
-      <form>
-        <!--Choice for each ball location-->
-        <input
-          [(ngModel)]="ball1"
-          type="checkbox"
-          name="1ball"
-          value="1"
-          id="ball-1"
-        />
-        <label for="ball-1">Ball 1</label>
-        <input
-          [(ngModel)]="ball2"
-          type="checkbox"
-          name="2ball"
-          value="2"
-          id="ball-2"
-        />
-        <label for="ball-2">Ball 2</label>
-        <br />
-        <input
-          [(ngModel)]="ball3"
-          type="checkbox"
-          name="3ball"
-          value="3"
-          id="ball-3"
-        />
-        <label for="ball-3">Ball 3</label>
-        <input
-          [(ngModel)]="ball4"
-          type="checkbox"
-          name="4ball"
-          value="4"
-          id="ball-4"
-        />
-        <label for="ball-4">Ball 4</label>
-        <br />
-        <input
-          [(ngModel)]="ball5"
-          type="checkbox"
-          name="5ball"
-          value="5"
-          id="ball-5"
-        />
-        <label for="ball-5">Ball 5</label>
-      </form>
-    </div>
-    <div class="row justify-content-center">
-      <frc971-counter-button class="col-4" [(value)]="autoUpperShotsMade">
-        Upper
-      </frc971-counter-button>
-      <frc971-counter-button class="col-4" [(value)]="autoLowerShotsMade">
-        Lower
-      </frc971-counter-button>
-      <frc971-counter-button class="col-4" [(value)]="autoShotsMissed">
-        Missed
-      </frc971-counter-button>
-    </div>
-    <div class="buttons">
-      <button class="btn btn-primary" (click)="prevSection()">Back</button>
-      <button class="btn btn-primary" (click)="nextSection()">Next</button>
-    </div>
-  </div>
-
-  <div *ngSwitchCase="'TeleOp'" id="teleop" class="container-fluid">
-    <div class="row justify-content-center">
-      <frc971-counter-button class="col-4" [(value)]="teleUpperShotsMade">
-        Upper
-      </frc971-counter-button>
-      <frc971-counter-button class="col-4" [(value)]="teleLowerShotsMade">
-        Lower
-      </frc971-counter-button>
-      <frc971-counter-button class="col-4" [(value)]="teleShotsMissed">
-        Missed
-      </frc971-counter-button>
-    </div>
-    <div class="buttons">
-      <button class="btn btn-primary" (click)="prevSection()">Back</button>
-      <button class="btn btn-primary" (click)="nextSection()">Next</button>
-    </div>
-  </div>
-
-  <div *ngSwitchCase="'Climb'" id="climb" class="container-fluid">
-    <form>
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="no_attempt"
-        [value]="ClimbLevel.NoAttempt"
-      />
-      <label for="no_attempt">No climbing attempt</label>
-      <br />
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="low"
-        [value]="ClimbLevel.Low"
-      />
-      <label for="low">Low</label>
-      <br />
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="medium"
-        [value]="ClimbLevel.Medium"
-      />
-      <label for="medium">Medium</label>
-      <br />
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="high"
-        [value]="ClimbLevel.High"
-      />
-      <label for="high">High</label>
-      <br />
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="traversal"
-        [value]="ClimbLevel.Traversal"
-      />
-      <label for="traversal">Traversal</label>
-      <br />
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="failed"
-        [value]="ClimbLevel.Failed"
-      />
-      <label for="failed">Failed</label>
-      <br />
-      <input
-        [(ngModel)]="level"
-        type="radio"
-        name="level"
-        id="failed_with_plenty_of_time"
-        [value]="ClimbLevel.FailedWithPlentyOfTime"
-      />
-      <label for="failed_with_plenty_of_time">
-        Failed (attempted with more than 10 seconds left)
+        {{ i }}
       </label>
-      <br />
-    </form>
+    </div>
     <div class="buttons">
-      <button class="btn btn-primary" (click)="prevSection()">Back</button>
-      <button class="btn btn-primary" (click)="nextSection()">Next</button>
+      <button
+        class="btn btn-primary"
+        [disabled]="!selectedValue"
+        (click)="changeSectionTo('Pickup'); addAction({type: 'startMatchAction', position: selectedValue});"
+      >
+        Start Match
+      </button>
     </div>
   </div>
 
-  <div *ngSwitchCase="'Other'" id="defense" class="container-fluid">
-    <h4 class="text-center">
-      How much did other robots play defense against this robot?
-    </h4>
-    0 - No defense played against this robot
-    <br />
-    1 - Minimal defense
-    <br />
-    2 - Some defense
-    <br />
-    3 - About half the match was played against defense
-    <br />
-    4 - Good amount of defense
-    <br />
-    5 - Constant defense
-    <div class="row" style="min-height: 50px">
-      <div class="col-10">
-        <input
-          type="range"
-          min="0"
-          max="5"
-          [(ngModel)]="defensePlayedOnScore"
-        />
-      </div>
-      <div class="col">
-        <h6>{{defensePlayedOnScore}}</h6>
-      </div>
-    </div>
-
-    <h4 class="text-center">
-      How much did this robot play defense against other robots?
-    </h4>
-    0 - This robot did not play defense
-    <br />
-    1 - Minimal defense
-    <br />
-    2 - Some defense
-    <br />
-    3 - Defense was played for about half the match
-    <br />
-    4 - Good amount of defense
-    <br />
-    5 - Constant defense
-    <div class="row">
-      <div class="col-10">
-        <input type="range" min="0" max="5" [(ngModel)]="defensePlayedScore" />
-      </div>
-      <div class="col">
-        <h6>{{defensePlayedScore}}</h6>
-      </div>
-    </div>
-
-    <div class="row">
-      <form>
-        <input
-          type="checkbox"
-          [(ngModel)]="noShow"
-          name="no_show"
-          id="no_show"
-        />
-        <label for="no_show">No show</label>
-        <br />
-        <input
-          type="checkbox"
-          [(ngModel)]="neverMoved"
-          name="never_moved"
-          id="never_moved"
-        />
-        <label for="never_moved">Never moved</label>
-        <br />
-        <input
-          type="checkbox"
-          [(ngModel)]="batteryDied"
-          name="battery_died"
-          id="battery_died"
-        />
-        <label for="battery_died">Battery died</label>
-        <br />
-        <input
-          type="checkbox"
-          [(ngModel)]="mechanicallyBroke"
-          name="mechanically_broke"
-          id="mechanically_broke"
-        />
-        <label for="mechanically_broke">Broke (mechanically)</label>
-        <br />
-        <input
-          type="checkbox"
-          [(ngModel)]="lostComs"
-          name="lost_coms"
-          id="lost_coms"
-        />
-        <label for="lost_coms">Lost coms</label>
-      </form>
-    </div>
-
-    <div class="row">
-      <h4>General Comments About Match</h4>
-      <textarea
-        [(ngModel)]="comment"
-        id="comment"
-        placeholder="optional"
-      ></textarea>
-    </div>
-
-    <div class="buttons">
-      <button class="btn btn-primary" (click)="prevSection()">Back</button>
-      <button class="btn btn-primary" (click)="nextSection()">Next</button>
-    </div>
+  <div *ngSwitchCase="'Pickup'" id="PickUp" class="container-fluid">
+    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+    <button
+      class="btn btn-warning"
+      (click)="changeSectionTo('Place'); addAction({type: 'pickupObjectAction', objectType: ObjectType.kCone});"
+    >
+      CONE
+    </button>
+    <button
+      class="btn btn-primary"
+      (click)="changeSectionTo('Place'); addAction({type: 'pickupObjectAction', objectType: ObjectType.kCube});"
+    >
+      CUBE
+    </button>
+    <button
+      *ngIf="autoPhase"
+      class="btn btn-info"
+      (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+    >
+      Start Teleop
+    </button>
+    <button
+      *ngIf="!autoPhase"
+      class="btn btn-info"
+      (click)="changeSectionTo('Endgame')"
+    >
+      Endgame
+    </button>
   </div>
 
-  <div *ngSwitchCase="'Review and Submit'" id="review" class="container-fluid">
-    <h4>Team Selection</h4>
-    <ul>
-      <li>Match number: {{matchNumber}}</li>
-      <li>Team number: {{teamNumber}}</li>
-      <li>SetNumber: {{setNumber}}</li>
-      <li>Comp Level: {{COMP_LEVEL_LABELS[compLevel]}}</li>
-    </ul>
-
-    <h4>Auto</h4>
-    <ul>
-      <li>Quadrant: {{quadrant}}</li>
-      <li>Collected Ball 1: {{ball1}}</li>
-      <li>Collected Ball 2: {{ball2}}</li>
-      <li>Collected Ball 3: {{ball3}}</li>
-      <li>Collected Ball 4: {{ball4}}</li>
-      <li>Collected Ball 5: {{ball5}}</li>
-      <li>Upper Shots Made: {{autoUpperShotsMade}}</li>
-      <li>Lower Shots Made: {{autoLowerShotsMade}}</li>
-      <li>Missed Shots: {{autoShotsMissed}}</li>
-    </ul>
-
-    <h4>TeleOp</h4>
-    <ul>
-      <li>Upper Shots Made: {{teleUpperShotsMade}}</li>
-      <li>Lower Shots Made: {{teleLowerShotsMade}}</li>
-      <li>Missed Shots: {{teleShotsMissed}}</li>
-    </ul>
-
-    <h4>Climb</h4>
-    <ul>
-      <li>Climb Level: {{level | levelToString}}</li>
-    </ul>
-
-    <h4>Other</h4>
-    <ul>
-      <li>Defense Played On Rating: {{defensePlayedOnScore}}</li>
-      <li>Defense Played Rating: {{defensePlayedScore}}</li>
-      <li>No show: {{noShow}}</li>
-      <li>Never moved: {{neverMoved}}</li>
-      <li>Battery died: {{batteryDied}}</li>
-      <li>Broke (mechanically): {{mechanicallyBroke}}</li>
-      <li>Lost coms: {{lostComs}}</li>
-      <li>Comments: {{comment}}</li>
-    </ul>
-
-    <span class="error_message">{{ errorMessage }}</span>
-
-    <div class="buttons">
-      <button class="btn btn-primary" (click)="prevSection()">Back</button>
-      <button class="btn btn-primary" (click)="nextSection()">Submit</button>
-    </div>
+  <div *ngSwitchCase="'Place'" id="Place" class="container-fluid">
+    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+    <button
+      class="btn btn-success"
+      (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kHigh});"
+    >
+      HIGH
+    </button>
+    <button
+      class="btn btn-warning"
+      (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kMiddle});"
+    >
+      MID
+    </button>
+    <button
+      class="btn btn-danger"
+      (click)="changeSectionTo('Pickup'); addAction({type: 'placeObjectAction', scoreLevel: ScoreLevel.kLow});"
+    >
+      LOW
+    </button>
+    <button
+      *ngIf="autoPhase"
+      class="btn btn-info"
+      (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+    >
+      Start Teleop
+    </button>
+    <button
+      *ngIf="!autoPhase"
+      class="btn btn-info"
+      (click)="changeSectionTo('Endgame')"
+    >
+      Endgame
+    </button>
   </div>
 
-  <div *ngSwitchCase="'Success'" id="success" class="container-fluid">
-    <span>Successfully submitted scouting data.</span>
-    <div class="buttons justify-content-end">
-      <button class="btn btn-primary" (click)="nextSection()">Continue</button>
-    </div>
+  <div *ngSwitchCase="'Endgame'" id="Endgame" class="container-fluid">
+    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+    <label>
+      <input type="checkbox" (change)="dockedValue = $event.target.value" />
+      Docked
+    </label>
+    <label>
+      <input type="checkbox" (change)="engagedValue = $event.target.value" />
+      Engaged
+    </label>
+    <button
+      *ngIf="!autoPhase"
+      class="btn btn-info"
+      (click)="changeSectionTo('Review and Submit'); addAction({type: 'endMatchAction', docked: dockedValue, engaged: engagedValue});"
+    >
+      End Match
+    </button>
+  </div>
+
+  <div *ngSwitchCase="'Review and Submit'" id="Review" class="container-fluid">
+    <button class="btn btn-danger" (click)="undoLastAction()">UNDO</button>
+    <button
+      *ngIf="!autoPhase"
+      class="btn btn-warning"
+      (click)="submitActions();"
+    >
+      Submit
+    </button>
+  </div>
+
+  <div *ngSwitchCase="'Success'" id="Success" class="container-fluid">
+    <h2>Successfully submitted data.</h2>
   </div>
 </ng-container>
diff --git a/scouting/www/index.html b/scouting/www/index.html
index 208141a..afc589e 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -5,16 +5,16 @@
     <title>FRC971 Scouting Application</title>
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <link
-      href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
       rel="stylesheet"
-      integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
+      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
+      integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD"
       crossorigin="anonymous"
     />
     <link
       rel="stylesheet"
-      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css"
+      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.c"
     />
-    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
   </head>
   <body>
     <my-app></my-app>
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index f66d9c4..ceb67aa 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -12,6 +12,7 @@
 load("//tools/build_rules/js:ts.bzl", _ts_project = "ts_project")
 load("@aspect_rules_rollup//rollup:defs.bzl", upstream_rollup_bundle = "rollup_bundle")
 load("@aspect_rules_terser//terser:defs.bzl", "terser_minified")
+load("@aspect_rules_cypress//cypress:defs.bzl", "cypress_module_test")
 
 ts_project = _ts_project
 
@@ -359,3 +360,42 @@
         "suffix": attr.string(mandatory = True),
     },
 )
+
+def cypress_test(runner, data = None, **kwargs):
+    """Runs a cypress test with the specified runner.
+
+    Args:
+        runner: The runner that starts up any necessary servers and then
+            invokes Cypress itself. See the Module API documentation for more
+            information: https://docs.cypress.io/guides/guides/module-api
+        data: The spec files (*.cy.js) and the servers under test. Also any
+            other files needed at runtime.
+        kwargs: Arguments forwarded to the upstream cypress_module_test().
+    """
+
+    # Figure out how many directories deep this package is relative to the
+    # workspace root.
+    package_depth = len(native.package_name().split("/"))
+
+    # Chrome is located at the runfiles root. So we need to go up one more
+    # directory than the workspace root.
+    chrome_location = "../" * (package_depth + 1) + "chrome_linux/chrome"
+    config_location = "../" * package_depth + "tools/build_rules/js/cypress.config.js"
+
+    data = data or []
+    data.append("//tools/build_rules/js:cypress.config.js")
+    data.append("@xvfb_amd64//:wrapped_bin/Xvfb")
+
+    cypress_module_test(
+        args = [
+            "run",
+            "--config-file=" + config_location,
+            "--browser=" + chrome_location,
+        ],
+        browsers = ["@chrome_linux//:all"],
+        copy_data_to_bin = False,
+        cypress = "//:node_modules/cypress",
+        data = data,
+        runner = runner,
+        **kwargs
+    )
diff --git a/tools/build_rules/js/BUILD b/tools/build_rules/js/BUILD
index 2381fcb..1bbe769 100644
--- a/tools/build_rules/js/BUILD
+++ b/tools/build_rules/js/BUILD
@@ -1,6 +1,10 @@
 load("@npm//:@angular/compiler-cli/package_json.bzl", angular_compiler_cli = "bin")
 load(":ts.bzl", "ts_project")
 
+exports_files([
+    "cypress.config.js",
+])
+
 # Define the @angular/compiler-cli ngc bin binary as a target
 angular_compiler_cli.ngc_binary(
     name = "ngc",
diff --git a/scouting/cypress.config.js b/tools/build_rules/js/cypress.config.js
similarity index 80%
rename from scouting/cypress.config.js
rename to tools/build_rules/js/cypress.config.js
index 4eb1a82..c8d6988 100644
--- a/scouting/cypress.config.js
+++ b/tools/build_rules/js/cypress.config.js
@@ -7,6 +7,9 @@
     setupNodeEvents(on, config) {
       on('before:browser:launch', (browser = {}, launchOptions) => {
         launchOptions.args.push('--disable-gpu-shader-disk-cache');
+        launchOptions.args.push('--enable-logging');
+        launchOptions.args.push('--v=stderr');
+        return launchOptions;
       });
 
       // Lets users print to the console:
diff --git a/tools/js/README.md b/tools/js/README.md
new file mode 100644
index 0000000..68f2264
--- /dev/null
+++ b/tools/js/README.md
@@ -0,0 +1,28 @@
+Javascript-related Information
+================================================================================
+
+Debugging Cypress tests
+--------------------------------------------------------------------------------
+You can run Cypress tests interactively. I.e. the browser will open up and you
+can interact with it. Use the `tools/js/run_cypress_test_interactively.sh`
+script for this.
+
+```console
+$ ./tools/js/run_cypress_test_interactively.sh //path/to:test
+(opens Chrome and runs tests)
+```
+
+All arguments to the script are passed to `bazel test`. So you can specify `-c
+opt` and other bazel options.
+
+### Pausing execution
+Use [`cy.pause()`](https://docs.cypress.io/api/commands/pause) to pause
+execution. Resume by hitting the green "Play" icon near the top left.
+
+Pausing can be helpful when you're trying to understand more about the state of
+the web page in the middle of a test.
+
+### Getting `console.log()` output
+Add `--test_env=DEBUG=cypress:launcher:browsers` to your test execution.
+Cypress will then log Chrome's output. Among which will be all `console.log()`
+statements.
diff --git a/tools/js/run_cypress_test_interactively.sh b/tools/js/run_cypress_test_interactively.sh
new file mode 100755
index 0000000..8869cc0
--- /dev/null
+++ b/tools/js/run_cypress_test_interactively.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+bazel test \
+  --test_env=DISPLAY="${DISPLAY}" \
+  --strategy=TestRunner=processwrapper-sandbox \
+  --test_output=streamed \
+  --test_arg=--headed \
+  --test_timeout=9999 \
+  "$@"
diff --git a/y2023/BUILD b/y2023/BUILD
index 309ada4..bdeaea9 100644
--- a/y2023/BUILD
+++ b/y2023/BUILD
@@ -6,11 +6,13 @@
     binaries = [
         "//aos/network:web_proxy_main",
         "//aos/events/logging:log_cat",
+        "//y2023/constants:constants_sender",
         "//aos/events:aos_timing_report_streamer",
     ],
     data = [
         ":aos_config",
         "//aos/starter:roborio_irq_config.json",
+        "//y2023/constants:constants.json",
         "@ctre_phoenix_api_cpp_athena//:shared_libraries",
         "@ctre_phoenix_cci_athena//:shared_libraries",
         "@ctre_phoenixpro_api_cpp_athena//:shared_libraries",
diff --git a/y2023/constants.cc b/y2023/constants.cc
index b07240f..8908811 100644
--- a/y2023/constants.cc
+++ b/y2023/constants.cc
@@ -102,22 +102,26 @@
           0.11972765117321;
 
       wrist->subsystem_params.zeroing_constants.measured_absolute_position =
-          0.10640608532802;
+          0.420104471500763;
 
       break;
 
     case kPracticeTeamNumber:
-      arm_proximal->zeroing.measured_absolute_position = 0.0;
-      arm_proximal->potentiometer_offset = 0.0;
+      arm_proximal->zeroing.measured_absolute_position = 0.254437958024658;
+      arm_proximal->potentiometer_offset =
+          10.5178592988554 + 0.0944609125285876;
 
-      arm_distal->zeroing.measured_absolute_position = 0.0;
-      arm_distal->potentiometer_offset = 0.0;
+      arm_distal->zeroing.measured_absolute_position = 0.51986178669514;
+      arm_distal->potentiometer_offset = 7.673132586937 - 0.0799284644472573 -
+                                         0.0323574039310657 +
+                                         0.0143810684138064;
 
-      roll_joint->zeroing.measured_absolute_position = 0.0;
-      roll_joint->potentiometer_offset = 0.0;
+      roll_joint->zeroing.measured_absolute_position = 1.86685853969852;
+      roll_joint->potentiometer_offset =
+          0.624713611895747 + 3.10458504917251 - 0.0966407797407789;
 
       wrist->subsystem_params.zeroing_constants.measured_absolute_position =
-          0.0;
+          -0.607792293122026;
 
       break;
 
diff --git a/y2023/control_loops/python/graph_codegen.py b/y2023/control_loops/python/graph_codegen.py
index 054a32d..6f3bc0d 100644
--- a/y2023/control_loops/python/graph_codegen.py
+++ b/y2023/control_loops/python/graph_codegen.py
@@ -46,13 +46,14 @@
 
     start_index = None
     end_index = None
-    for point, name in graph_paths.points:
+    for key in sorted(graph_paths.points.keys()):
+        point = graph_paths.points[key]
         if (point[:2] == segment.start
             ).all() and point[2] == segment.alpha_rolls[0][1]:
-            start_index = name
+            start_index = key
         if (point[:2] == segment.end
             ).all() and point[2] == segment.alpha_rolls[-1][1]:
-            end_index = name
+            end_index = key
 
     if reverse:
         start_index, end_index = end_index, start_index
@@ -146,16 +147,17 @@
     h_file.append("")
 
     # Now dump out the vertices and associated constexpr vertex name functions.
-    for index, point in enumerate(graph_paths.points):
+    for index, key in enumerate(sorted(graph_paths.points.keys())):
+        point = graph_paths.points[key]
         h_file.append("")
         h_file.append("constexpr uint32_t %s() { return %d; }" %
-                      (index_function_name(point[1]), index))
-        h_file.append("inline ::Eigen::Matrix<double, 3, 1> %sPoint() {" %
-                      point[1])
-        h_file.append(
+                      (index_function_name(key), index))
+        h_file.append("::Eigen::Matrix<double, 3, 1> %sPoint();" % key)
+        cc_file.append("::Eigen::Matrix<double, 3, 1> %sPoint() {" % key)
+        cc_file.append(
             "  return (::Eigen::Matrix<double, 3, 1>() << %f, %f, %f).finished();"
-            % (point[0][0], point[0][1], point[0][2]))
-        h_file.append("}")
+            % (point[0], point[1], point[2]))
+        cc_file.append("}")
 
     front_points = [
         index_function_name(point[1]) + "()"
@@ -214,10 +216,11 @@
     cc_file.append(
         "::std::vector<::Eigen::Matrix<double, 3, 1>> PointList() {")
     cc_file.append("  ::std::vector<::Eigen::Matrix<double, 3, 1>> points;")
-    for point in graph_paths.points:
+    for key in sorted(graph_paths.points.keys()):
+        point = graph_paths.points[key]
         cc_file.append(
             "  points.push_back((::Eigen::Matrix<double, 3, 1>() << %.12s, %.12s, %.12s).finished());"
-            % (point[0][0], point[0][1], point[0][2]))
+            % (point[0], point[1], point[2]))
     cc_file.append("  return points;")
     cc_file.append("}")
 
diff --git a/y2023/control_loops/python/graph_edit.py b/y2023/control_loops/python/graph_edit.py
index d841509..a976807 100644
--- a/y2023/control_loops/python/graph_edit.py
+++ b/y2023/control_loops/python/graph_edit.py
@@ -217,7 +217,7 @@
         self.theta_version = False
         self.reinit_extents()
 
-        self.last_pos = to_xy(*graph_paths.neutral[:2])
+        self.last_pos = to_xy(*graph_paths.points['Neutral'][:2])
         self.circular_index_select = 1
 
         # Extra stuff for drawing lines.
@@ -494,11 +494,13 @@
             else:
                 self.index = len(self.segments) - 1
             print("Switched to segment:", self.segments[self.index].name)
+            self.segments[self.index].Print(graph_paths.points)
 
         elif keyval == Gdk.KEY_n:
             self.index += 1
             self.index = self.index % len(self.segments)
             print("Switched to segment:", self.segments[self.index].name)
+            self.segments[self.index].Print(graph_paths.points)
 
         elif keyval == Gdk.KEY_t:
             # Toggle between theta and xy renderings
@@ -546,18 +548,15 @@
         else:
             self.segments[self.index].control2 = self.now_segment_pt
 
-        print('Clicked at theta: %s' % (repr(self.now_segment_pt, )))
+        print('Clicked at theta: np.array([%s, %s])' %
+              (self.now_segment_pt[0], self.now_segment_pt[1]))
         if not self.theta_version:
-            print('Clicked at xy, circular index: (%f, %f, %f)' %
-                  (self.last_pos[0], self.last_pos[1],
+            print(
+                'Clicked at to_theta_with_circular_index(%.3f, %.3f, circular_index=%d)'
+                % (self.last_pos[0], self.last_pos[1],
                    self.circular_index_select))
 
-        print('c1: np.array([%f, %f])' %
-              (self.segments[self.index].control1[0],
-               self.segments[self.index].control1[1]))
-        print('c2: np.array([%f, %f])' %
-              (self.segments[self.index].control2[0],
-               self.segments[self.index].control2[1]))
+        self.segments[self.index].Print(graph_paths.points)
 
         self.redraw()
 
@@ -565,4 +564,5 @@
 arm_ui = ArmUi()
 arm_ui.segments = graph_paths.segments
 print('Starting with segment: ', arm_ui.segments[arm_ui.index].name)
+arm_ui.segments[arm_ui.index].Print(graph_paths.points)
 basic_window.RunApp()
diff --git a/y2023/control_loops/python/graph_paths.py b/y2023/control_loops/python/graph_paths.py
index 66ca158..cde885c 100644
--- a/y2023/control_loops/python/graph_paths.py
+++ b/y2023/control_loops/python/graph_paths.py
@@ -2,186 +2,224 @@
 
 from y2023.control_loops.python.graph_tools import *
 
-neutral = np.array((np.pi, 0.0, 0.0))
+named_segments = []
+points = {}
 
-# NeutralToGroundPickupBackConeUp
-neutral_to_cone_up_1 = np.array([3.170156, -0.561227])
-neutral_to_cone_up_2 = np.array([2.972776, -1.026820])
-ground_pickup_back_cone_up = to_theta_with_circular_index_and_roll(
+points['Neutral'] = np.array((np.pi, 0.0, 0.0))
+
+points['GroundPickupBackConeUp'] = to_theta_with_circular_index_and_roll(
     -1.07774334, 0.36308701, np.pi / 2.0, circular_index=1)
 
-# NeutralToGroundPickupBackConeDown
-neutral_to_ground_pickup_back_cone_down_1 = np.array([3.170156, -0.561227])
-neutral_to_ground_pickup_back_cone_down_2 = np.array([2.972776, -1.026820])
-ground_pickup_back_cone_down = to_theta_with_circular_index_and_roll(
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToGroundPickupBackConeUp",
+        start=points['Neutral'],
+        control1=np.array([3.170156, -0.561227]),
+        control2=np.array([2.972776, -1.026820]),
+        end=points['GroundPickupBackConeUp'],
+        control_alpha_rolls=[(0.30, 0.0), (.95, np.pi / 2.0)],
+    ))
+
+points['GroundPickupBackConeDown'] = to_theta_with_circular_index_and_roll(
     -1.11487594, 0.23140145, np.pi / 2.0, circular_index=1)
 
-# NeutralToGroundPickupBackCube
-neutral_to_ground_pickup_back_cube_1 = np.array([3.153228, -0.497009])
-neutral_to_ground_pickup_back_cube_2 = np.array([2.972776, -1.026820])
-neutral_to_hp_pickup_back_cube_alpha_rolls = [
-    (0.7, 0.0),
-    (.9, -np.pi / 2.0),
-]
-ground_pickup_back_cube = to_theta_with_circular_index_and_roll(
-    -1.102, 0.224, -np.pi / 2.0, circular_index=1)
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToGroundPickupBackConeDown",
+        start=points['Neutral'],
+        control1=np.array([3.170156, -0.561227]),
+        control2=np.array([2.972776, -1.026820]),
+        end=points['GroundPickupBackConeDown'],
+        control_alpha_rolls=[(0.30, 0.0), (.95, np.pi / 2.0)],
+    ))
 
-# NeutralToBackMidConeUpScore
-neutral_to_score_back_mid_cone_up_1 = np.array([0.994244, -1.417442])
-neutral_to_score_back_mid_cone_up_2 = np.array([1.711325, -0.679748])
-score_back_mid_cone_up_pos = to_theta_with_circular_index_and_roll(
+points['GroundPickupBackCube'] = to_theta_with_circular_index_and_roll(
+    -1.102, 0.25, -np.pi / 2.0, circular_index=1)
+
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToGroundPickupBackCube",
+        start=points['Neutral'],
+        control1=np.array([3.153228, -0.497009]),
+        control2=np.array([2.972776, -1.026820]),
+        end=points['GroundPickupBackCube'],
+        control_alpha_rolls=[(0.7, 0.0), (.9, -np.pi / 2.0)],
+    ))
+
+points['GroundPickupFrontCube'] = to_theta_with_circular_index_and_roll(
+    0.325603, 0.255189, np.pi / 2.0, circular_index=0)
+
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToGroundPickupFrontCube",
+        start=points['Neutral'],
+        control1=np.array([3.338852196583635, 0.34968650009090885]),
+        control2=np.array([4.28246270189025, 1.492916470137478]),
+        end=points['GroundPickupFrontCube'],
+        control_alpha_rolls=[(0.4, 0.0), (.9, np.pi / 2.0)],
+    ))
+
+points['ScoreBackMidConeUpPos'] = to_theta_with_circular_index_and_roll(
     -1.41871454, 1.07476162, np.pi / 2.0, circular_index=0)
 
-# NeutralToMidConeDownScore
-neutral_to_score_mid_cone_down_1 = np.array([3.394572, -0.239378])
-neutral_to_score_mid_cone_down_2 = np.array([3.654854, -0.626835])
-score_mid_cone_down_pos = to_theta_with_circular_index_and_roll(
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToBackMidConeUpScore",
+        start=points['Neutral'],
+        control1=np.array([0.994244, -1.417442]),
+        control2=np.array([1.711325, -0.679748]),
+        end=points['ScoreBackMidConeUpPos'],
+        control_alpha_rolls=[(0.40, 0.0), (.95, np.pi / 2.0)],
+    ))
+
+points['ScoreBackMidConeDownPos'] = to_theta_with_circular_index_and_roll(
     -1.37792406, 0.81332449, np.pi / 2.0, circular_index=1)
 
-# NeutralToMidConeDownScore
-neutral_to_hp_pickup_back_cone_up_1 = np.array([2.0, -0.239378])
-neutral_to_hp_pickup_back_cone_up_2 = np.array([1.6, -0.626835])
-neutral_to_hp_pickup_back_cone_up_alpha_rolls = [
-    (0.7, 0.0),
-    (.9, np.pi / 2.0),
-]
-hp_pickup_back_cone_up = to_theta_with_circular_index_and_roll(
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToMidConeDownScore",
+        start=points['Neutral'],
+        control1=np.array([3.394572, -0.239378]),
+        control2=np.array([3.654854, -0.626835]),
+        end=points['ScoreBackMidConeDownPos'],
+        control_alpha_rolls=[(0.40, 0.0), (.95, np.pi / 2.0)],
+    ))
+
+points['HPPickupBackConeUp'] = to_theta_with_circular_index_and_roll(
     -1.1050539, 1.31390128, np.pi / 2.0, circular_index=0)
 
-# NeutralToFrontHighConeUpScore
-neutral_to_score_front_high_cone_up_1 = np.array([2.594244, 0.417442])
-neutral_to_score_front_high_cone_up_2 = np.array([1.51325, 0.679748])
-score_front_high_cone_up_pos = to_theta_with_circular_index_and_roll(
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToHPPickupBackConeUp",
+        start=points['Neutral'],
+        control1=np.array([2.0, -0.239378]),
+        control2=np.array([1.6, -0.626835]),
+        end=points['HPPickupBackConeUp'],
+        control_alpha_rolls=[(0.7, 0.0), (.9, np.pi / 2.0)],
+    ))
+
+points['ScoreFrontHighConeUpPos'] = to_theta_with_circular_index_and_roll(
     0.98810344, 1.37536719, -np.pi / 2.0, circular_index=0)
 
-# NeutralToFrontMidConeUpScore
-neutral_to_score_front_mid_cone_up_1 = np.array([3.0, 0.317442])
-neutral_to_score_front_mid_cone_up_2 = np.array([2.9, 0.479748])
-score_front_mid_cone_up_pos = to_theta_with_circular_index_and_roll(
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToFrontHighConeUpScore",
+        start=points['Neutral'],
+        control1=np.array([2.594244, 0.417442]),
+        control2=np.array([1.51325, 0.679748]),
+        end=points['ScoreFrontHighConeUpPos'],
+        control_alpha_rolls=[(0.40, 0.0), (.95, -np.pi / 2.0)],
+    ))
+
+points['ScoreFrontMidConeUpPos'] = to_theta_with_circular_index_and_roll(
     0.43740453, 1.06330555, -np.pi / 2.0, circular_index=0)
 
-neutral_to_cone_down_1 = np.array([2.396694, 0.508020])
-neutral_to_cone_down_2 = np.array([2.874513, 0.933160])
-cone_down_pos = to_theta_with_circular_index_and_roll(0.7,
-                                                      0.11,
-                                                      np.pi / 2.0,
-                                                      circular_index=0)
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToFrontMidConeUpScore",
+        start=points['Neutral'],
+        control1=np.array([3.0, 0.317442]),
+        control2=np.array([2.9, 0.479748]),
+        end=points['ScoreFrontMidConeUpPos'],
+        control_alpha_rolls=[(0.40, 0.0), (.95, -np.pi / 2.0)],
+    ))
 
-neutral_to_cube_1 = np.array([2.396694, 0.508020])
-neutral_to_cube_2 = np.array([2.874513, 0.933160])
+points['ScoreFrontLowCube'] = to_theta_with_circular_index_and_roll(
+    0.325603, 0.30, np.pi / 2.0, circular_index=0)
 
-cube_pos = to_theta_with_circular_index_and_roll(0.7,
-                                                 0.24,
-                                                 np.pi / 2.0,
-                                                 circular_index=0)
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToScoreFrontLowCube",
+        start=points['Neutral'],
+        control1=np.array([3.338852196583635, 0.34968650009090885]),
+        control2=np.array([4.28246270189025, 1.492916470137478]),
+        end=points['ScoreFrontLowCube'],
+        control_alpha_rolls=[(0.4, 0.0), (.9, np.pi / 2.0)],
+    ))
 
-neutral_to_pickup_control_alpha_rolls = [
-    (0.30, 0.0),
-    (.95, np.pi / 2.0),
-]
+points['ScoreBackLowCube'] = to_theta_with_circular_index_and_roll(
+    -1.102, 0.30, -np.pi / 2.0, circular_index=1)
 
-neutral_to_score_1 = np.array([0.994244, -1.417442])
-neutral_to_score_2 = np.array([1.711325, -0.679748])
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToScoreLowBackCube",
+        start=points['Neutral'],
+        control1=np.array([3.153228, -0.497009]),
+        control2=np.array([2.972776, -1.026820]),
+        end=points['ScoreBackLowCube'],
+        control_alpha_rolls=[(0.7, 0.0), (.9, -np.pi / 2.0)],
+    ))
 
-score_low_pos = to_theta_with_circular_index_and_roll(-(0.41 / 2 + 0.49),
-                                                      0 + 0.05,
-                                                      np.pi / 2.0,
-                                                      circular_index=1)
+points['ScoreFrontMidCube'] = to_theta_with_circular_index_and_roll(
+    0.517846, 0.87, np.pi / 2.0, circular_index=0)
 
-neutral_to_score_low_2 = np.array([3.37926599, -0.73664663])
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToScoreFrontMidCube",
+        start=points["Neutral"],
+        control1=np.array([3.1310824883477952, 0.23591705727105095]),
+        control2=np.array([3.0320025094685965, 0.43674789928668933]),
+        end=points["ScoreFrontMidCube"],
+        control_alpha_rolls=[(0.4, np.pi * 0.0), (0.95, np.pi * 0.5)],
+    ))
 
-score_mid_cube_pos = to_theta_with_circular_index_and_roll(-(0.58 + 0.49),
-                                                           0.6 + 0.05,
-                                                           np.pi / 2.0,
-                                                           circular_index=0)
+points['ScoreFrontHighCube'] = to_theta_with_circular_index_and_roll(
+    0.901437, 1.16, np.pi / 2.0, circular_index=0)
 
-score_high_cone_pos = to_theta_with_circular_index_and_roll(-1.01,
-                                                            1.17 + 0.05,
-                                                            np.pi / 2.0,
-                                                            circular_index=0)
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToScoreFrontHighCube",
+        start=points["Neutral"],
+        control1=np.array([2.537484161662287, 0.059700523547219]),
+        control2=np.array([2.449391812539668, 0.4141564369176016]),
+        end=points["ScoreFrontHighCube"],
+        control_alpha_rolls=[(0.4, np.pi * 0.0), (0.95, np.pi * 0.5)],
+    ))
 
-score_high_cube_pos = to_theta_with_circular_index_and_roll(-1.01,
-                                                            0.90 + 0.05,
-                                                            np.pi / 2.0,
-                                                            circular_index=0)
+points['ScoreBackMidCube'] = to_theta_with_circular_index_and_roll(
+    -1.27896, 0.84, -np.pi / 2.0, circular_index=1)
 
-neutral_to_back_score_control_alpha_rolls = [(0.40, 0.0), (.95, np.pi / 2.0)]
-neutral_to_front_score_control_alpha_rolls = [(0.40, 0.0), (.95, -np.pi / 2.0)]
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToScoreBackMidCube",
+        start=points["Neutral"],
+        control1=np.array([3.3485646154655404, -0.4369603013926491]),
+        control2=np.array([3.2653593368256995, -0.789587049476034]),
+        end=points["ScoreBackMidCube"],
+        control_alpha_rolls=[(0.3, -np.pi * 0.0), (0.95, -np.pi * 0.5)],
+    ))
 
-points = [(neutral, "NeutralPos"),
-          (ground_pickup_back_cone_up, "GroundPickupBackConeUp"),
-          (ground_pickup_back_cone_down, "GroundPickupBackConeDown"),
-          (ground_pickup_back_cube, "GroundPickupBackCube"),
-          (hp_pickup_back_cone_up, "HPPickupBackConeUp"),
-          (cone_down_pos, "ConeDownPos"), (score_low_pos, "ScoreLowPos"),
-          (score_back_mid_cone_up_pos, "ScoreBackMidConeUpPos"),
-          (score_front_high_cone_up_pos, "ScoreFrontHighConeUpPos"),
-          (score_front_mid_cone_up_pos, "ScoreFrontMidConeUpPos"),
-          (score_mid_cone_down_pos, "ScoreBackMidConeDownPos"),
-          (score_mid_cube_pos, "ScoreMidCubePos"),
-          (score_high_cone_pos, "ScoreHighConePos"),
-          (score_high_cube_pos, "ScoreHighCubePos"), (cube_pos, "CubePos")]
+# TODO(austin): This doesn't produce the next line...
+#points['ScoreBackHighCube'] = to_theta_with_circular_index_and_roll(
+#    -1.60932, 1.16839, np.pi / 2.0, circular_index=0)
+points['ScoreBackHighCube'] = np.array(
+    (4.77284735761704, -1.19952193130714, -np.pi / 2.0))
+
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToScoreBackHighCube",
+        start=points["Neutral"],
+        control1=np.array([3.6804854484103684, -0.3494541095053125]),
+        control2=np.array([3.9889380578509517, -0.6637934755748516]),
+        end=points["ScoreBackHighCube"],
+        control_alpha_rolls=[(0.3, -np.pi * 0.0), (0.95, -np.pi * 0.5)],
+    ))
+
+points['ConeDownPos'] = to_theta_with_circular_index_and_roll(0.7,
+                                                              0.11,
+                                                              np.pi / 2.0,
+                                                              circular_index=0)
+named_segments.append(
+    ThetaSplineSegment(
+        name="NeutralToConeDown",
+        start=points['Neutral'],
+        control1=np.array([2.396694, 0.508020]),
+        control2=np.array([2.874513, 0.933160]),
+        end=points['ConeDownPos'],
+        control_alpha_rolls=[(0.30, 0.0), (.95, np.pi / 2.0)],
+    ))
+
 front_points = []
 back_points = []
 unnamed_segments = []
-named_segments = [
-    ThetaSplineSegment("NeutralToGroundPickupBackConeUp", neutral,
-                       neutral_to_cone_up_1, neutral_to_cone_up_2,
-                       ground_pickup_back_cone_up,
-                       neutral_to_pickup_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToGroundPickupBackConeDown", neutral,
-                       neutral_to_ground_pickup_back_cone_down_1,
-                       neutral_to_ground_pickup_back_cone_down_2,
-                       ground_pickup_back_cone_down,
-                       neutral_to_pickup_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToGroundPickupBackCube", neutral,
-                       neutral_to_ground_pickup_back_cube_1,
-                       neutral_to_ground_pickup_back_cube_2,
-                       ground_pickup_back_cube,
-                       neutral_to_hp_pickup_back_cube_alpha_rolls),
-    ThetaSplineSegment("NeutralToHPPickupBackConeUp", neutral,
-                       neutral_to_hp_pickup_back_cone_up_1,
-                       neutral_to_hp_pickup_back_cone_up_2,
-                       hp_pickup_back_cone_up,
-                       neutral_to_hp_pickup_back_cone_up_alpha_rolls),
-    ThetaSplineSegment("NeutralToFrontHighConeUpScore", neutral,
-                       neutral_to_score_front_high_cone_up_1,
-                       neutral_to_score_front_high_cone_up_2,
-                       score_front_high_cone_up_pos,
-                       neutral_to_front_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToFrontMidConeUpScore", neutral,
-                       neutral_to_score_front_mid_cone_up_1,
-                       neutral_to_score_front_mid_cone_up_2,
-                       score_front_mid_cone_up_pos,
-                       neutral_to_front_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToConeDown", neutral, neutral_to_cone_down_1,
-                       neutral_to_cone_down_2, cone_down_pos,
-                       neutral_to_pickup_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToCube", neutral, neutral_to_cube_1,
-                       neutral_to_cube_2, cube_pos,
-                       neutral_to_pickup_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToLowScore", neutral, neutral_to_score_1,
-                       neutral_to_score_low_2, score_low_pos,
-                       neutral_to_back_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToBackMidConeUpScore", neutral,
-                       neutral_to_score_back_mid_cone_up_1,
-                       neutral_to_score_back_mid_cone_up_2,
-                       score_back_mid_cone_up_pos,
-                       neutral_to_back_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToMidConeDownScore", neutral,
-                       neutral_to_score_mid_cone_down_1,
-                       neutral_to_score_mid_cone_down_2,
-                       score_mid_cone_down_pos,
-                       neutral_to_back_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToMidCubeScore", neutral, neutral_to_score_1,
-                       neutral_to_score_2, score_mid_cube_pos,
-                       neutral_to_back_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToHighConeScore", neutral, neutral_to_score_1,
-                       neutral_to_score_2, score_high_cone_pos,
-                       neutral_to_back_score_control_alpha_rolls),
-    ThetaSplineSegment("NeutralToHighCubeScore", neutral, neutral_to_score_1,
-                       neutral_to_score_2, score_high_cube_pos,
-                       neutral_to_back_score_control_alpha_rolls),
-]
-
 segments = named_segments + unnamed_segments
diff --git a/y2023/control_loops/python/graph_tools.py b/y2023/control_loops/python/graph_tools.py
index 8751935..ac926aa 100644
--- a/y2023/control_loops/python/graph_tools.py
+++ b/y2023/control_loops/python/graph_tools.py
@@ -180,11 +180,11 @@
 # The limit for the proximal and distal is relative,
 # so define constraints for this delta.
 UPPER_DELTA_LIMIT = 0.0
-LOWER_DELTA_LIMIT = -1.9 * np.pi
+LOWER_DELTA_LIMIT = -1.98 * np.pi
 
 # TODO(milind): put actual proximal limits
-UPPER_PROXIMAL_LIMIT = np.pi * 1.5
-LOWER_PROXIMAL_LIMIT = -np.pi
+UPPER_PROXIMAL_LIMIT = np.pi * 2.0
+LOWER_PROXIMAL_LIMIT = -np.pi * 2.0
 
 UPPER_DISTAL_LIMIT = 0.75 * np.pi
 LOWER_DISTAL_LIMIT = -0.75 * np.pi
@@ -202,7 +202,8 @@
 
 
 def get_circular_index(theta):
-    return int(np.floor((theta[1] - theta[0]) / np.pi))
+    return int(
+        np.floor((shift_angle(theta[1]) - shift_angle(theta[0])) / np.pi))
 
 
 def get_xy(theta):
@@ -447,6 +448,52 @@
         self.alpha_unitizer = alpha_unitizer
         self.vmax = vmax
 
+    def Print(self, points):
+        # Find the name of the start end end points.
+        start_name = None
+        end_name = None
+        for name in points:
+            point = points[name]
+            if (self.start == point[:2]).all():
+                start_name = name
+            elif (self.end == point[:2]).all():
+                end_name = name
+
+        alpha_points = '[' + ', '.join([
+            f"({alpha}, np.pi * {theta / np.pi})"
+            for alpha, theta in self.alpha_rolls[1:-1]
+        ]) + ']'
+
+        def FormatToTheta(point):
+            x, y = get_xy(point)
+            circular_index = get_circular_index(point)
+            return "to_theta_with_circular_index(%.3f, %.3f, circular_index=%d)" % (
+                x, y, circular_index)
+
+        def FormatToThetaRoll(point, roll):
+            x, y = get_xy(point)
+            circular_index = get_circular_index(point)
+            return "to_theta_with_circular_index_and_roll(%.3f, %.3f, np.pi * %.2f, circular_index=%d)" % (
+                x, y, roll / np.pi, circular_index)
+
+        print('named_segments.append(')
+        print('    ThetaSplineSegment(')
+        print(f'        name="{self.name}",')
+        print(
+            f'        start=points["{start_name}"], # {FormatToThetaRoll(self.start, self.alpha_rolls[0][1])}'
+        )
+        print(
+            f'        control1=np.array([{self.control1[0]}, {self.control1[1]}]), # {FormatToTheta(self.control1)}'
+        )
+        print(
+            f'        control2=np.array([{self.control2[0]}, {self.control2[1]}]), # {FormatToTheta(self.control2)}'
+        )
+        print(
+            f'        end=points["{end_name}"], # {FormatToThetaRoll(self.end, self.alpha_rolls[-1][1])}'
+        )
+        print(f'        control_alpha_rolls={alpha_points},')
+        print(f'))')
+
     def __repr__(self):
         return "ThetaSplineSegment(%s, %s, %s, %s)" % (repr(
             self.start), repr(self.control1), repr(
diff --git a/y2023/control_loops/superstructure/arm/arm.cc b/y2023/control_loops/superstructure/arm/arm.cc
index fd265c4..03a1b21 100644
--- a/y2023/control_loops/superstructure/arm/arm.cc
+++ b/y2023/control_loops/superstructure/arm/arm.cc
@@ -38,7 +38,7 @@
       search_graph_(MakeSearchGraph(&dynamics_, &trajectories_, alpha_unitizer_,
                                     kVMax(), &hybrid_roll_joint_loop_)),
       // Go to the start of the first trajectory.
-      follower_(&dynamics_, &hybrid_roll_joint_loop_, NeutralPosPoint()),
+      follower_(&dynamics_, &hybrid_roll_joint_loop_, NeutralPoint()),
       points_(PointList()),
       current_node_(0) {
   int i = 0;
@@ -92,7 +92,7 @@
   }
 
   // TODO(milind): should we default to the closest position?
-  uint32_t filtered_goal = arm::NeutralPosIndex();
+  uint32_t filtered_goal = arm::NeutralIndex();
   if (unsafe_goal != nullptr) {
     filtered_goal = *unsafe_goal;
   }
diff --git a/y2023/control_loops/superstructure/end_effector.cc b/y2023/control_loops/superstructure/end_effector.cc
index 874cfad..287f0e7 100644
--- a/y2023/control_loops/superstructure/end_effector.cc
+++ b/y2023/control_loops/superstructure/end_effector.cc
@@ -35,9 +35,6 @@
     game_piece_ = GamePiece::CUBE;
   }
 
-  // Cube voltage is flipped
-  double voltage_sign = (game_piece_ == GamePiece::CUBE ? -1.0 : 1.0);
-
   // Go into spitting if we were told to, no matter where we are
   if (roller_goal == RollerGoal::SPIT && state_ != EndEffectorState::SPITTING) {
     state_ = EndEffectorState::SPITTING;
@@ -59,6 +56,12 @@
       break;
     case EndEffectorState::INTAKING:
       // If intaking and beam break is not triggered, keep intaking
+      if (roller_goal == RollerGoal::INTAKE_CONE ||
+          roller_goal == RollerGoal::INTAKE_CUBE ||
+          roller_goal == RollerGoal::INTAKE_LAST) {
+        timer_ = timestamp;
+      }
+
       if (beambreak_status) {
         // Beam has been broken, switch to loaded.
         state_ = EndEffectorState::LOADED;
@@ -77,15 +80,19 @@
 
       break;
     case EndEffectorState::LOADED:
+      timer_ = timestamp;
       // If loaded and beam break not triggered, intake
       if (!beambreak_status) {
         state_ = EndEffectorState::INTAKING;
-        timer_ = timestamp;
       }
       break;
     case EndEffectorState::SPITTING:
       // If spit requested, spit
-      *roller_voltage = voltage_sign * kRollerSpitVoltage();
+      if (game_piece_ == GamePiece::CUBE) {
+        *roller_voltage = kRollerCubeSpitVoltage();
+      } else {
+        *roller_voltage = kRollerConeSpitVoltage();
+      }
       if (beambreak_) {
         if (!beambreak_status) {
           timer_ = timestamp;
diff --git a/y2023/control_loops/superstructure/end_effector.h b/y2023/control_loops/superstructure/end_effector.h
index d93d868..14245c8 100644
--- a/y2023/control_loops/superstructure/end_effector.h
+++ b/y2023/control_loops/superstructure/end_effector.h
@@ -15,8 +15,10 @@
 class EndEffector {
  public:
   static constexpr double kRollerConeSuckVoltage() { return 12.0; }
+  static constexpr double kRollerConeSpitVoltage() { return -9.0; }
+
   static constexpr double kRollerCubeSuckVoltage() { return -5.0; }
-  static constexpr double kRollerSpitVoltage() { return -9.0; }
+  static constexpr double kRollerCubeSpitVoltage() { return 3.0; }
 
   EndEffector();
   void RunIteration(const ::aos::monotonic_clock::time_point timestamp,
diff --git a/y2023/control_loops/superstructure/superstructure_lib_test.cc b/y2023/control_loops/superstructure/superstructure_lib_test.cc
index 0ecc5cf..220f3d8 100644
--- a/y2023/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2023/control_loops/superstructure/superstructure_lib_test.cc
@@ -192,7 +192,7 @@
             event_loop_->MakeFetcher<Status>("/superstructure")),
         superstructure_output_fetcher_(
             event_loop_->MakeFetcher<Output>("/superstructure")) {
-    InitializeArmPosition(arm::NeutralPosPoint());
+    InitializeArmPosition(arm::NeutralPoint());
     phased_loop_handle_ = event_loop_->AddPhasedLoop(
         [this](int) {
           // Skip this the first time.
@@ -447,7 +447,7 @@
             *builder.fbb(), constants::Values::kWristRange().middle());
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_wrist(wrist_offset);
     goal_builder.add_roller_goal(RollerGoal::IDLE);
@@ -473,7 +473,7 @@
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_wrist(wrist_offset);
     goal_builder.add_roller_goal(RollerGoal::IDLE);
@@ -505,7 +505,7 @@
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
     goal_builder.add_wrist(wrist_offset);
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
 
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
@@ -526,7 +526,7 @@
 
     goal_builder.add_wrist(wrist_offset);
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
 
@@ -572,7 +572,9 @@
   SetEnabled(true);
   WaitUntilZeroed();
 
-  double voltage_sign = (GetParam() == GamePiece::CUBE ? -1.0 : 1.0);
+  double spit_voltage =
+      (GetParam() == GamePiece::CUBE ? EndEffector::kRollerCubeSpitVoltage()
+                                     : EndEffector::kRollerConeSpitVoltage());
   double suck_voltage =
       (GetParam() == GamePiece::CUBE ? EndEffector::kRollerCubeSuckVoltage()
                                      : EndEffector::kRollerConeSuckVoltage());
@@ -582,7 +584,7 @@
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_roller_goal(GetParam() == GamePiece::CONE
                                      ? RollerGoal::INTAKE_CONE
@@ -623,7 +625,7 @@
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_roller_goal(RollerGoal::IDLE);
 
@@ -659,7 +661,7 @@
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_roller_goal(GetParam() == GamePiece::CONE
                                      ? RollerGoal::INTAKE_CONE
@@ -698,7 +700,7 @@
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_roller_goal(RollerGoal::SPIT);
 
@@ -712,8 +714,7 @@
   ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
   ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
 
-  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
-            voltage_sign * EndEffector::kRollerSpitVoltage());
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(), spit_voltage);
   EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
             EndEffectorState::SPITTING);
   EXPECT_EQ(superstructure_status_fetcher_->game_piece(), GetParam());
@@ -725,8 +726,7 @@
   ASSERT_TRUE(superstructure_output_fetcher_.Fetch());
   ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
 
-  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(),
-            voltage_sign * EndEffector::kRollerSpitVoltage());
+  EXPECT_EQ(superstructure_output_fetcher_->roller_voltage(), spit_voltage);
   EXPECT_EQ(superstructure_status_fetcher_->end_effector_state(),
             EndEffectorState::SPITTING);
   EXPECT_EQ(superstructure_status_fetcher_->game_piece(), GetParam());
@@ -736,7 +736,7 @@
 
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
 
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     goal_builder.add_trajectory_override(false);
     goal_builder.add_roller_goal(RollerGoal::IDLE);
 
@@ -780,7 +780,7 @@
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
-    goal_builder.add_arm_goal_position(arm::NeutralPosIndex());
+    goal_builder.add_arm_goal_position(arm::NeutralIndex());
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
 
@@ -803,7 +803,7 @@
 TEST_F(SuperstructureTest, ArmMultistepMove) {
   SetEnabled(true);
   WaitUntilZeroed();
-  superstructure_plant_.InitializeArmPosition(arm::NeutralPosPoint());
+  superstructure_plant_.InitializeArmPosition(arm::NeutralPoint());
 
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
@@ -819,7 +819,7 @@
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
-    goal_builder.add_arm_goal_position(arm::ScoreLowPosIndex());
+    goal_builder.add_arm_goal_position(arm::ConeDownPosIndex());
     ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
   }
 
diff --git a/y2023/joystick_reader.cc b/y2023/joystick_reader.cc
index 5cb148c..489934b 100644
--- a/y2023/joystick_reader.cc
+++ b/y2023/joystick_reader.cc
@@ -37,20 +37,26 @@
 namespace joysticks {
 
 // TODO(milind): add correct locations
-const ButtonLocation kIntake(4, 5);
 const ButtonLocation kScore(4, 4);
 const ButtonLocation kSpit(4, 13);
 
-const ButtonLocation kMidBackTipConeScoreLeft(4, 15);
-const ButtonLocation kHighBackTipConeScoreLeft(4, 14);
-const ButtonLocation kMidBackTipConeScoreRight(3, 2);
+const ButtonLocation kHighConeScoreLeft(4, 14);
+const ButtonLocation kHighConeScoreRight(3, 1);
+
+const ButtonLocation kMidConeScoreLeft(4, 15);
+const ButtonLocation kMidConeScoreRight(3, 2);
+
+const ButtonLocation kHighCube(4, 1);
+const ButtonLocation kMidCube(4, 2);
+const ButtonLocation kLowCube(4, 3);
 
 const ButtonLocation kGroundPickupConeUp(4, 7);
 const ButtonLocation kGroundPickupConeDown(4, 8);
 const ButtonLocation kGroundPickupCube(4, 10);
 const ButtonLocation kHPConePickup(4, 6);
 
-const ButtonLocation kSuck(4, 12);
+const ButtonLocation kSuck(4, 11);
+const ButtonLocation kBack(4, 12);
 
 const ButtonLocation kWrist(4, 10);
 
@@ -63,12 +69,18 @@
   CUBE = 2,
 };
 
+enum class Side {
+  FRONT = 0,
+  BACK = 1,
+};
+
 struct ArmSetpoint {
   uint32_t index;
   double wrist_goal;
   std::optional<double> score_wrist_goal = std::nullopt;
   GamePiece game_piece;
-  ButtonLocation button;
+  std::vector<ButtonLocation> buttons;
+  Side side;
 };
 
 const std::vector<ArmSetpoint> setpoints = {
@@ -76,50 +88,109 @@
         .index = arm::GroundPickupBackConeUpIndex(),
         .wrist_goal = 0.0,
         .game_piece = GamePiece::CONE_UP,
-        .button = kGroundPickupConeUp,
+        .buttons = {kGroundPickupConeUp},
+        .side = Side::BACK,
     },
     {
         .index = arm::GroundPickupBackConeDownIndex(),
         .wrist_goal = 0.0,
         .game_piece = GamePiece::CONE_DOWN,
-        .button = kGroundPickupConeDown,
+        .buttons = {kGroundPickupConeDown},
+        .side = Side::BACK,
     },
     {
         .index = arm::ScoreBackMidConeUpPosIndex(),
         .wrist_goal = 0.55,
         .game_piece = GamePiece::CONE_UP,
-        .button = kMidBackTipConeScoreRight,
+        .buttons = {kMidConeScoreRight},
+        .side = Side::BACK,
     },
     {
         .index = arm::ScoreBackMidConeDownPosIndex(),
         .wrist_goal = 2.2,
         .score_wrist_goal = 0.0,
         .game_piece = GamePiece::CONE_DOWN,
-        .button = kMidBackTipConeScoreRight,
+        .buttons = {kMidConeScoreRight},
+        .side = Side::BACK,
     },
     {
         .index = arm::HPPickupBackConeUpIndex(),
         .wrist_goal = 0.2,
         .game_piece = GamePiece::CONE_UP,
-        .button = kHPConePickup,
+        .buttons = {kHPConePickup},
+        .side = Side::BACK,
     },
     {
         .index = arm::ScoreFrontHighConeUpPosIndex(),
         .wrist_goal = 0.05,
         .game_piece = GamePiece::CONE_UP,
-        .button = kHighBackTipConeScoreLeft,
+        .buttons = {kHighConeScoreLeft, kHighConeScoreRight},
+        .side = Side::FRONT,
     },
     {
         .index = arm::ScoreFrontMidConeUpPosIndex(),
         .wrist_goal = 0.05,
         .game_piece = GamePiece::CONE_UP,
-        .button = kMidBackTipConeScoreLeft,
+        .buttons = {kMidConeScoreLeft, kMidConeScoreRight},
+        .side = Side::FRONT,
     },
     {
         .index = arm::GroundPickupBackCubeIndex(),
         .wrist_goal = 0.6,
         .game_piece = GamePiece::CUBE,
-        .button = kGroundPickupCube,
+        .buttons = {kGroundPickupCube},
+        .side = Side::BACK,
+    },
+    {
+        .index = arm::ScoreFrontMidCubeIndex(),
+        .wrist_goal = 0.6,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kMidCube},
+        .side = Side::FRONT,
+    },
+    {
+        .index = arm::ScoreBackMidCubeIndex(),
+        .wrist_goal = 0.6,
+        .score_wrist_goal = 0.0,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kMidCube},
+        .side = Side::BACK,
+    },
+    {
+        .index = arm::ScoreFrontLowCubeIndex(),
+        .wrist_goal = 0.6,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kLowCube},
+        .side = Side::FRONT,
+    },
+    {
+        .index = arm::ScoreBackLowCubeIndex(),
+        .wrist_goal = 0.6,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kLowCube},
+        .side = Side::BACK,
+    },
+    {
+        .index = arm::ScoreFrontHighCubeIndex(),
+        .wrist_goal = 0.6,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kHighCube},
+        .side = Side::FRONT,
+    },
+    {
+        .index = arm::ScoreBackHighCubeIndex(),
+        .wrist_goal = 0.6,
+        .score_wrist_goal = 0.0,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kHighCube},
+        .side = Side::BACK,
+    },
+    {
+        .index = arm::GroundPickupFrontCubeIndex(),
+        .wrist_goal = 0.6,
+        .game_piece = GamePiece::CUBE,
+        .buttons = {kGroundPickupCube},
+        .side = Side::FRONT,
     },
 };
 
@@ -155,7 +226,7 @@
 
     double wrist_goal = 0.0;
     RollerGoal roller_goal = RollerGoal::IDLE;
-    arm_goal_position_ = arm::NeutralPosIndex();
+    arm_goal_position_ = arm::NeutralIndex();
     std::optional<double> score_wrist_goal = std::nullopt;
 
     if (data.IsPressed(kGroundPickupConeUp) || data.IsPressed(kHPConePickup)) {
@@ -173,14 +244,19 @@
       wrist_goal = 0.6;
     }
 
+    const Side current_side = data.IsPressed(kBack) ? Side::BACK : Side::FRONT;
+
     // Search for the active setpoint.
     for (const ArmSetpoint &setpoint : setpoints) {
-      if (data.IsPressed(setpoint.button)) {
-        if (setpoint.game_piece == current_game_piece_) {
-          wrist_goal = setpoint.wrist_goal;
-          arm_goal_position_ = setpoint.index;
-          score_wrist_goal = setpoint.score_wrist_goal;
-          break;
+      for (const ButtonLocation &button : setpoint.buttons) {
+        if (data.IsPressed(button)) {
+          if (setpoint.game_piece == current_game_piece_ &&
+              setpoint.side == current_side) {
+            wrist_goal = setpoint.wrist_goal;
+            arm_goal_position_ = setpoint.index;
+            score_wrist_goal = setpoint.score_wrist_goal;
+            break;
+          }
         }
       }
     }
diff --git a/y2023/localizer/localizer.cc b/y2023/localizer/localizer.cc
index 4378f61..ace3663 100644
--- a/y2023/localizer/localizer.cc
+++ b/y2023/localizer/localizer.cc
@@ -3,9 +3,16 @@
 #include "aos/containers/sized_array.h"
 #include "frc971/control_loops/drivetrain/localizer_generated.h"
 #include "frc971/control_loops/pose.h"
+#include "gflags/gflags.h"
 #include "y2023/constants.h"
 #include "y2023/localizer/utils.h"
 
+DEFINE_double(max_pose_error, 1e-6,
+              "Throw out target poses with a higher pose error than this");
+DEFINE_double(distortion_noise_scalar, 1.0,
+              "Scale the target pose distortion factor by this when computing "
+              "the noise.");
+
 namespace y2023::localizer {
 namespace {
 constexpr std::array<std::string_view, Localizer::kNumCameras> kPisToUse{
@@ -37,7 +44,6 @@
 }
 }  // namespace
 
-
 std::array<Localizer::CameraState, Localizer::kNumCameras>
 Localizer::MakeCameras(const Constants &constants, aos::EventLoop *event_loop) {
   CHECK(constants.has_cameras());
@@ -278,6 +284,8 @@
 
   // TODO(james): Tune this. Also, gain schedule for auto mode?
   Eigen::Matrix<double, 3, 1> noises(1.0, 1.0, 0.5);
+  // Scale noise by the distortion factor for this detection
+  noises *= (1.0 + FLAGS_distortion_noise_scalar * target.distortion_factor());
 
   Eigen::Matrix3d R = Eigen::Matrix3d::Zero();
   R.diagonal() = noises.cwiseAbs2();
@@ -293,6 +301,11 @@
   if (!state_at_capture.has_value()) {
     VLOG(1) << "Rejecting image due to being too old.";
     return RejectImage(camera_index, RejectionReason::IMAGE_TOO_OLD, &builder);
+  } else if (target.pose_error() > FLAGS_max_pose_error) {
+    VLOG(1) << "Rejecting target due to high pose error "
+            << target.pose_error();
+    return RejectImage(camera_index, RejectionReason::HIGH_POSE_ERROR,
+                       &builder);
   }
 
   const Input U = ekf_.MostRecentInput();
diff --git a/y2023/localizer/localizer_test.cc b/y2023/localizer/localizer_test.cc
index 554ab5d..947771f 100644
--- a/y2023/localizer/localizer_test.cc
+++ b/y2023/localizer/localizer_test.cc
@@ -152,6 +152,7 @@
             target_builder.add_id(send_target_id_);
             target_builder.add_position(position_offset);
             target_builder.add_orientation(quat_offset);
+            target_builder.add_pose_error(pose_error_);
             auto target_offset = target_builder.Finish();
 
             auto targets_offset = builder.fbb()->CreateVector({target_offset});
@@ -273,6 +274,7 @@
   std::unique_ptr<aos::logger::Logger> logger_;
 
   uint64_t send_target_id_ = kTargetId;
+  double pose_error_ = 1e-7;
 
   gflags::FlagSaver flag_saver_;
 };
@@ -457,4 +459,26 @@
             status_fetcher_->statistics()->Get(0)->total_candidates());
 }
 
+// Tests that we correctly reject a detection with a high pose error.
+TEST_F(LocalizerTest, HighPoseError) {
+  output_voltages_ << 0.0, 0.0;
+  send_targets_ = true;
+  // Send the minimum pose error to be rejected
+  constexpr double kEps = 1e-9;
+  pose_error_ = 1e-6 + kEps;
+
+  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::HIGH_POSE_ERROR))
+                ->count(),
+            status_fetcher_->statistics()->Get(0)->total_candidates());
+}
+
 }  // namespace y2023::localizer::testing
diff --git a/y2023/localizer/status.fbs b/y2023/localizer/status.fbs
index 4d0a9c1..1b5c6f6 100644
--- a/y2023/localizer/status.fbs
+++ b/y2023/localizer/status.fbs
@@ -15,6 +15,8 @@
   MESSAGE_BRIDGE_DISCONNECTED = 2,
   // The target ID does not exist.
   NO_SUCH_TARGET = 3,
+  // Pose estimation error was higher than any normal detection.
+  HIGH_POSE_ERROR = 4,
 }
 
 table RejectionCount {
diff --git a/y2023/vision/aprilrobotics.cc b/y2023/vision/aprilrobotics.cc
index 0102538..70a3f30 100644
--- a/y2023/vision/aprilrobotics.cc
+++ b/y2023/vision/aprilrobotics.cc
@@ -26,13 +26,12 @@
     : calibration_data_(event_loop),
       image_size_(0, 0),
       ftrace_(),
-      image_callback_(
-          event_loop, channel_name,
-          [&](cv::Mat image_color_mat,
-              const aos::monotonic_clock::time_point eof) {
-            HandleImage(image_color_mat, eof);
-          },
-          chrono::milliseconds(5)),
+      image_callback_(event_loop, channel_name,
+                      [&](cv::Mat image_color_mat,
+                          const aos::monotonic_clock::time_point eof) {
+                        HandleImage(image_color_mat, eof);
+                      },
+                      chrono::milliseconds(5)),
       target_map_sender_(
           event_loop->MakeSender<frc971::vision::TargetMap>("/camera")),
       image_annotations_sender_(
@@ -166,6 +165,17 @@
   return std::min(distortion_factor / FLAGS_max_expected_distortion, 1.0);
 }
 
+std::vector<cv::Point2f> AprilRoboticsDetector::MakeCornerVector(
+    const apriltag_detection_t *det) {
+  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]);
+
+  return corner_points;
+}
+
 std::vector<AprilRoboticsDetector::Detection> AprilRoboticsDetector::DetectTags(
     cv::Mat image, aos::monotonic_clock::time_point eof) {
   const aos::monotonic_clock::time_point start_time =
@@ -186,21 +196,25 @@
 
   std::vector<Detection> results;
 
-  std::vector<std::vector<cv::Point2f>> orig_corners_vector;
-  std::vector<std::vector<cv::Point2f>> corners_vector;
-
   auto builder = image_annotations_sender_.MakeBuilder();
+  std::vector<flatbuffers::Offset<foxglove::PointsAnnotation>> foxglove_corners;
 
   for (int i = 0; i < zarray_size(detections); i++) {
     apriltag_detection_t *det;
     zarray_get(detections, i, &det);
 
     if (det->decision_margin > FLAGS_min_decision_margin) {
+      // TODO<jim>: Should we check for top/bottom of image?
       if (det->p[0][0] < min_x || det->p[0][0] > max_x ||
           det->p[1][0] < min_x || det->p[1][0] > max_x ||
           det->p[2][0] < min_x || det->p[2][0] > max_x ||
           det->p[3][0] < min_x || det->p[3][0] > max_x) {
         VLOG(1) << "Rejecting detection because corner is outside pixel border";
+        // Send rejected corner points in red
+        std::vector<cv::Point2f> rejected_corner_points = MakeCornerVector(det);
+        foxglove_corners.push_back(frc971::vision::BuildPointsAnnotation(
+            builder.fbb(), eof, rejected_corner_points,
+            std::vector<double>{1.0, 0.0, 0.0, 0.5}));
         continue;
       }
       VLOG(1) << "Found tag number " << det->id << " hamming: " << det->hamming
@@ -217,14 +231,11 @@
       info.cx = intrinsics_.at<double>(0, 2);
       info.cy = intrinsics_.at<double>(1, 2);
 
-      // Store out the original, pre-undistortion corner points for sending
-      std::vector<cv::Point2f> orig_corner_points;
-      orig_corner_points.emplace_back(det->p[0][0], det->p[0][1]);
-      orig_corner_points.emplace_back(det->p[1][0], det->p[1][1]);
-      orig_corner_points.emplace_back(det->p[2][0], det->p[2][1]);
-      orig_corner_points.emplace_back(det->p[3][0], det->p[3][1]);
-
-      orig_corners_vector.emplace_back(orig_corner_points);
+      // Send original corner points in green
+      std::vector<cv::Point2f> orig_corner_points = MakeCornerVector(det);
+      foxglove_corners.push_back(frc971::vision::BuildPointsAnnotation(
+          builder.fbb(), eof, orig_corner_points,
+          std::vector<double>{0.0, 1.0, 0.0, 0.5}));
 
       UndistortDetection(det);
 
@@ -243,13 +254,11 @@
               << " seconds for pose estimation";
       VLOG(1) << "Pose err: " << pose_error;
 
-      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]);
-
-      corners_vector.emplace_back(corner_points);
+      // Send undistorted corner points in pink
+      std::vector<cv::Point2f> corner_points = MakeCornerVector(det);
+      foxglove_corners.push_back(frc971::vision::BuildPointsAnnotation(
+          builder.fbb(), eof, corner_points,
+          std::vector<double>{1.0, 0.75, 0.8, 1.0}));
 
       double distortion_factor =
           ComputeDistortionFactor(orig_corner_points, corner_points);
@@ -261,9 +270,10 @@
     }
   }
 
-  const auto annotations_offset = frc971::vision::BuildAnnotations(
-      eof, orig_corners_vector, 5.0, builder.fbb());
-  builder.CheckOk(builder.Send(annotations_offset));
+  foxglove::ImageAnnotations::Builder annotation_builder(*builder.fbb());
+  const auto corners_offset = builder.fbb()->CreateVector(foxglove_corners);
+  annotation_builder.add_points(corners_offset);
+  builder.CheckOk(builder.Send(annotation_builder.Finish()));
 
   apriltag_detections_destroy(detections);
 
diff --git a/y2023/vision/aprilrobotics.h b/y2023/vision/aprilrobotics.h
index fd371c7..bf9265b 100644
--- a/y2023/vision/aprilrobotics.h
+++ b/y2023/vision/aprilrobotics.h
@@ -40,6 +40,9 @@
   // Undistorts the april tag corners using the camera calibration
   void UndistortDetection(apriltag_detection_t *det) const;
 
+  // Helper function to store detection points in vector of Point2f's
+  std::vector<cv::Point2f> MakeCornerVector(const apriltag_detection_t *det);
+
   std::vector<Detection> DetectTags(cv::Mat image,
                                     aos::monotonic_clock::time_point eof);
 
diff --git a/y2023/y2023_roborio.json b/y2023/y2023_roborio.json
index 8ef43d3..0ed62c7 100644
--- a/y2023/y2023_roborio.json
+++ b/y2023/y2023_roborio.json
@@ -561,6 +561,7 @@
     },
     {
       "name": "constants_sender_roborio",
+      "executable_name": "constants_sender",
       "autorestart": false,
       "nodes": [
         "roborio"