Merge "Stop intaking when transfer beam break triggered"
diff --git a/.bazelignore b/.bazelignore
index 0b2e110..9166728 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,11 +1,12 @@
 external
 node_modules
+scouting/webserver/requests/messages/node_modules
 scouting/www/node_modules
-scouting/www/counter_button/node_modules
 scouting/www/driver_ranking/node_modules
 scouting/www/entry/node_modules
 scouting/www/match_list/node_modules
 scouting/www/notes/node_modules
+scouting/www/pipes/node_modules
 scouting/www/rpc/node_modules
 scouting/www/shift_schedule/node_modules
 scouting/www/view/node_modules
diff --git a/.bazelrc b/.bazelrc
index a69ccad..3a3c729 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -22,7 +22,6 @@
 
 # Shortcuts for selecting the target platform.
 build:k8 --platforms=//tools/platforms:linux_x86
-build:k8_legacy_python --platforms=//tools/platforms:linux_x86_legacy_python --host_platform=//tools/platforms:linux_x86_legacy_python
 build:roborio --platforms=//tools/platforms:linux_roborio
 build:roborio --platform_suffix=-roborio
 build:arm64 --platforms=//tools/platforms:linux_arm64
diff --git a/WORKSPACE b/WORKSPACE
index 1aabe54..f68d1b0 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -912,12 +912,13 @@
     data = [
         "@//:package.json",
         "@//:pnpm-workspace.yaml",
+        "@//scouting/webserver/requests/messages:package.json",
         "@//scouting/www:package.json",
-        "@//scouting/www/counter_button:package.json",
         "@//scouting/www/driver_ranking:package.json",
         "@//scouting/www/entry:package.json",
         "@//scouting/www/match_list:package.json",
         "@//scouting/www/notes:package.json",
+        "@//scouting/www/pipes:package.json",
         "@//scouting/www/rpc:package.json",
         "@//scouting/www/scan:package.json",
         "@//scouting/www/shift_schedule:package.json",
diff --git a/frc971/analysis/foxglove.md b/frc971/analysis/foxglove.md
index 8276584..d241ec0 100644
--- a/frc971/analysis/foxglove.md
+++ b/frc971/analysis/foxglove.md
@@ -8,7 +8,7 @@
    is convenient; it won't work when you do not have Internet access, and
    has some limitations when it comes to accessing unsecured websockets.
 2. Download the foxglove desktop application.
-3. Run our local copy by running `bazel run //frc971/analysis:local_foxglove`
+3. Run our local copy by running `bazel run //aos/analysis:local_foxglove`
    This will work offline, and serves foxglove at http://localhost:8000 by
    default.
 
@@ -20,6 +20,9 @@
 This will create an MCAP file at the specified path, which you can then open
 in any of the various foxglove options.
 
+Troubleshooting:
+* If you get the error `Check failed: output_`: Check whether `/tmp/log.mcap` already exists under another owner. If so, use a different filename, e.g. `/tmp/<your_name>_log.mcap`
+
 # Live Visualization
 
 On the pis, we run a `foxglove_websocket` application by default. This exposes
diff --git a/frc971/control_loops/drivetrain/drivetrain_config.fbs b/frc971/control_loops/drivetrain/drivetrain_config.fbs
index 645eeac..0947063 100644
--- a/frc971/control_loops/drivetrain/drivetrain_config.fbs
+++ b/frc971/control_loops/drivetrain/drivetrain_config.fbs
@@ -98,6 +98,15 @@
   max_controllable_offset:double = 0.1 (id: 2);
 }
 
+table SplineFollowerConfig {
+  // Q should be a 5x5 positive-definite matrix; it is used as the state cost
+  // of the LQR controller for the spline-following mode.
+  q:frc971.fbs.Matrix (id: 0);
+  // R should be a 2x2 positive-definite matrix; it is used as the input cost
+  // of the LQR controller for the spline-following mode.
+  r:frc971.fbs.Matrix (id: 1);
+}
+
 // These constants are all specified by the drivetrain python code, and
 // so are separated out for easy codegen.
 table DrivetrainLoopConfig {
@@ -135,6 +144,7 @@
   is_simulated:bool = false (id: 14);
   down_estimator_config:DownEstimatorConfig (id: 15);
   line_follow_config:LineFollowConfig (id: 16);
+  spline_follower_config:SplineFollowerConfig (id: 20);
   top_button_use:PistolTopButtonUse = kShift (id: 17);
   second_button_use:PistolSecondButtonUse = kShiftLow (id: 18);
   bottom_button_use:PistolBottomButtonUse = kSlowDown (id: 19);
diff --git a/frc971/control_loops/drivetrain/drivetrain_config.h b/frc971/control_loops/drivetrain/drivetrain_config.h
index 8e0a58d..3f56082 100644
--- a/frc971/control_loops/drivetrain/drivetrain_config.h
+++ b/frc971/control_loops/drivetrain/drivetrain_config.h
@@ -50,6 +50,33 @@
   }
 };
 
+struct SplineFollowerConfig {
+  // The line-following uses an LQR controller with states of
+  // [longitudinal_position, lateral_positoin, theta, left_velocity,
+  // right_velocity] and inputs of [left_voltage, right_voltage]. These Q and R
+  // matrices are the costs for state and input respectively.
+  Eigen::Matrix<double, 5, 5> Q = Eigen::Matrix<double, 5, 5>(
+      (::Eigen::DiagonalMatrix<double, 5>().diagonal() << ::std::pow(60.0, 2.0),
+       ::std::pow(60.0, 2.0), ::std::pow(40.0, 2.0), ::std::pow(30.0, 2.0),
+       ::std::pow(30.0, 2.0))
+          .finished()
+          .asDiagonal());
+  Eigen::Matrix2d R = Eigen::Matrix2d(
+      (::Eigen::DiagonalMatrix<double, 2>().diagonal() << 5.0, 5.0)
+          .finished()
+          .asDiagonal());
+
+  static SplineFollowerConfig FromFlatbuffer(
+      const fbs::SplineFollowerConfig *fbs) {
+    if (fbs == nullptr) {
+      return {};
+    }
+    return SplineFollowerConfig{
+        .Q = ToEigenOrDie<5, 5>(*CHECK_NOTNULL(fbs->q())),
+        .R = ToEigenOrDie<2, 2>(*CHECK_NOTNULL(fbs->r()))};
+  }
+};
+
 template <typename Scalar = double>
 struct DrivetrainConfig {
   // Shifting method we are using.
@@ -125,6 +152,8 @@
   PistolSecondButtonUse second_button_use = PistolSecondButtonUse::kShiftLow;
   PistolBottomButtonUse bottom_button_use = PistolBottomButtonUse::kSlowDown;
 
+  SplineFollowerConfig spline_follower_config{};
+
   // Converts the robot state to a linear distance position, velocity.
   static Eigen::Matrix<Scalar, 2, 1> LeftRightToLinear(
       const Eigen::Matrix<Scalar, 7, 1> &left_right) {
@@ -229,7 +258,9 @@
           .line_follow_config =
               LineFollowConfig::FromFlatbuffer(fbs.line_follow_config()),
           ASSIGN(top_button_use), ASSIGN(second_button_use),
-          ASSIGN(bottom_button_use)
+          ASSIGN(bottom_button_use),
+          .spline_follower_config = SplineFollowerConfig::FromFlatbuffer(
+              fbs.spline_follower_config()),
 #undef ASSIGN
     };
   }
diff --git a/frc971/control_loops/drivetrain/trajectory.cc b/frc971/control_loops/drivetrain/trajectory.cc
index 163311a..cf9929f 100644
--- a/frc971/control_loops/drivetrain/trajectory.cc
+++ b/frc971/control_loops/drivetrain/trajectory.cc
@@ -770,17 +770,8 @@
 
   // Set up reasonable gain matrices. Current choices of gains are arbitrary
   // and just setup to work well enough for the simulation tests.
-  // TODO(james): Tune this on a real robot.
-  // TODO(james): Pull these out into a config.
-  Eigen::Matrix<double, 5, 5> Q;
-  Q.setIdentity();
-  Q.diagonal() << 30.0, 30.0, 20.0, 15.0, 15.0;
-  Q *= 2.0;
-  Q = (Q * Q).eval();
-
-  Eigen::Matrix<double, 2, 2> R;
-  R.setIdentity();
-  R *= 5.0;
+  Eigen::Matrix<double, 5, 5> Q = config_->spline_follower_config.Q;
+  Eigen::Matrix<double, 2, 2> R = config_->spline_follower_config.R;
 
   Eigen::Matrix<double, 5, 5> P = Q;
 
@@ -822,6 +813,7 @@
     const Eigen::Matrix<double, 2, 5> K = RBPBinv * APB.transpose();
     plan_gains_[i].second = K.cast<float>();
     P = AP * A_discrete - APB * K + Q;
+    CHECK_LT(P.norm(), 1e30) << "LQR calculations became unstable.";
   }
 }
 
diff --git a/frc971/orin/hardware_monitor.cc b/frc971/orin/hardware_monitor.cc
index f09d0e6..e1c785b 100644
--- a/frc971/orin/hardware_monitor.cc
+++ b/frc971/orin/hardware_monitor.cc
@@ -10,6 +10,7 @@
 #include "frc971/orin/hardware_stats_generated.h"
 
 DEFINE_string(config, "aos_config.json", "File path of aos configuration");
+DEFINE_bool(log_voltages, false, "If true, log voltages too.");
 
 namespace frc971::orin {
 namespace {
@@ -68,14 +69,19 @@
     // Iterate through all thermal zones
     std::vector<flatbuffers::Offset<ThermalZone>> thermal_zones;
     for (int zone_id = 0; zone_id < 9; zone_id++) {
+      std::optional<std::string> zone_name = ReadFileFirstLine(absl::StrFormat(
+          "/sys/devices/virtual/thermal/thermal_zone%d/type", zone_id));
+      flatbuffers::Offset<flatbuffers::String> name_offset;
+      if (zone_name) {
+        name_offset = builder.fbb()->CreateString(*zone_name);
+      }
+
       ThermalZone::Builder thermal_zone_builder =
           builder.MakeBuilder<ThermalZone>();
       thermal_zone_builder.add_id(zone_id);
 
-      std::optional<std::string> zone_name = ReadFileFirstLine(absl::StrFormat(
-          "/sys/devices/virtual/thermal/thermal_zone%d/type", zone_id));
-      if (zone_name) {
-        thermal_zone_builder.add_name(builder.fbb()->CreateString(*zone_name));
+      if (!name_offset.IsNull()) {
+        thermal_zone_builder.add_name(name_offset);
       }
 
       std::optional<std::string> temperature_str =
@@ -93,54 +99,68 @@
     std::optional<std::string> fan_speed_str = ReadFileFirstLine(
         absl::StrFormat("/sys/class/hwmon/%s/rpm", fan_hwmon_));
 
-    // Iterate through INA3221 electrical reading channels
-    std::vector<flatbuffers::Offset<ElectricalReading>> electrical_readings;
-    for (int channel = 1; channel <= 3; channel++) {
-      ElectricalReading::Builder electrical_reading_builder =
-          builder.MakeBuilder<ElectricalReading>();
-      electrical_reading_builder.add_channel(channel);
+    flatbuffers::Offset<
+        flatbuffers::Vector<flatbuffers::Offset<ElectricalReading>>>
+        electrical_readings_offset;
+    if (FLAGS_log_voltages) {
+      std::vector<flatbuffers::Offset<ElectricalReading>> electrical_readings;
+      // Iterate through INA3221 electrical reading channels
+      for (int channel = 1; channel <= 3; channel++) {
+        std::optional<std::string> label = ReadFileFirstLine(absl::StrFormat(
+            "/sys/class/hwmon/%s/in%d_label", electrical_hwmon_, channel));
 
-      std::optional<std::string> label = ReadFileFirstLine(absl::StrFormat(
-          "/sys/class/hwmon/%s/in%d_label", electrical_hwmon_, channel));
-      if (label) {
-        electrical_reading_builder.add_label(
-            builder.fbb()->CreateString(*label));
+        flatbuffers::Offset<flatbuffers::String> label_offset;
+        if (label) {
+          label_offset = builder.fbb()->CreateString(*label);
+        }
+
+        ElectricalReading::Builder electrical_reading_builder =
+            builder.MakeBuilder<ElectricalReading>();
+        electrical_reading_builder.add_channel(channel);
+
+        if (!label_offset.IsNull()) {
+          electrical_reading_builder.add_label(label_offset);
+        }
+
+        std::optional<std::string> voltage_str =
+            ReadFileFirstLine(absl::StrFormat("/sys/class/hwmon/%s/in%d_input",
+                                              electrical_hwmon_, channel));
+        uint64_t voltage = 0;
+        if (voltage_str && absl::SimpleAtoi(*voltage_str, &voltage)) {
+          electrical_reading_builder.add_voltage(voltage);
+        }
+
+        std::optional<std::string> current_str = ReadFileFirstLine(
+            absl::StrFormat("/sys/class/hwmon/%s/curr%d_input",
+                            electrical_hwmon_, channel));
+        uint64_t current = 0;
+        if (current_str && absl::SimpleAtoi(*current_str, &current)) {
+          electrical_reading_builder.add_current(current);
+        }
+
+        uint64_t power = voltage * current / 1000;
+        if (power != 0) {
+          electrical_reading_builder.add_power(power);
+        }
+
+        electrical_readings.emplace_back(electrical_reading_builder.Finish());
       }
-
-      std::optional<std::string> voltage_str =
-          ReadFileFirstLine(absl::StrFormat("/sys/class/hwmon/%s/in%d_input",
-                                            electrical_hwmon_, channel));
-      uint64_t voltage = 0;
-      if (voltage_str && absl::SimpleAtoi(*voltage_str, &voltage)) {
-        electrical_reading_builder.add_voltage(voltage);
-      }
-
-      std::optional<std::string> current_str =
-          ReadFileFirstLine(absl::StrFormat("/sys/class/hwmon/%s/curr%d_input",
-                                            electrical_hwmon_, channel));
-      uint64_t current = 0;
-      if (current_str && absl::SimpleAtoi(*current_str, &current)) {
-        electrical_reading_builder.add_current(current);
-      }
-
-      uint64_t power = voltage * current / 1000;
-      if (power != 0) {
-        electrical_reading_builder.add_power(power);
-      }
-
-      electrical_readings.emplace_back(electrical_reading_builder.Finish());
+      electrical_readings_offset =
+          builder.fbb()->CreateVector(electrical_readings);
     }
 
+    auto thermal_zone_offset = builder.fbb()->CreateVector(thermal_zones);
     HardwareStats::Builder hardware_stats_builder =
         builder.MakeBuilder<HardwareStats>();
-    hardware_stats_builder.add_thermal_zones(
-        builder.fbb()->CreateVector(thermal_zones));
+    hardware_stats_builder.add_thermal_zones(thermal_zone_offset);
     uint64_t fan_speed = 0;
     if (fan_speed_str && absl::SimpleAtoi(*fan_speed_str, &fan_speed)) {
       hardware_stats_builder.add_fan_speed(fan_speed);
     }
-    hardware_stats_builder.add_electrical_readings(
-        builder.fbb()->CreateVector(electrical_readings));
+    if (!electrical_readings_offset.IsNull()) {
+      hardware_stats_builder.add_electrical_readings(
+          electrical_readings_offset);
+    }
 
     builder.CheckOk(builder.Send(hardware_stats_builder.Finish()));
   }
diff --git a/frc971/orin/set_orin_clock.sh b/frc971/orin/set_orin_clock.sh
index 32edb86..bc9dd8f 100755
--- a/frc971/orin/set_orin_clock.sh
+++ b/frc971/orin/set_orin_clock.sh
@@ -6,13 +6,13 @@
 
 ROBOT_PREFIX="9" #71  (Should be one of 79, 89, 99, or 9)
 
-ORIN_LIST="1"
+ORIN_LIST="1 2"
 
 echo "Setting hwclock on Orins"
 
 for orin in $ORIN_LIST; do
     echo "========================================================"
-    echo "Setting clock for ${ROBOT_PREFIX}71.1${orin}"
+    echo "Setting clock for ${ROBOT_PREFIX}71.10${orin}"
     echo "========================================================"
     current_time=`sudo hwclock`
     IFS="."
diff --git a/frc971/vision/intrinsics_calibration.cc b/frc971/vision/intrinsics_calibration.cc
index 16a53a7..d3ddba7 100644
--- a/frc971/vision/intrinsics_calibration.cc
+++ b/frc971/vision/intrinsics_calibration.cc
@@ -43,7 +43,19 @@
       << "Need a base intrinsics json to use to auto-capture images when the "
          "camera moves.";
   std::unique_ptr<aos::ExitHandle> exit_handle = event_loop.MakeExitHandle();
-  IntrinsicsCalibration extractor(&event_loop, hostname, FLAGS_channel,
+
+  std::string camera_name = absl::StrCat(
+      "/", aos::network::ParsePiOrOrin(hostname).value(),
+      std::to_string(aos::network::ParsePiOrOrinNumber(hostname).value()),
+      FLAGS_channel);
+  // THIS IS A HACK FOR 2024, since we call Orin2 "Imu"
+  if (aos::network::ParsePiOrOrin(hostname).value() == "orin" &&
+      aos::network::ParsePiOrOrinNumber(hostname).value() == 2) {
+    LOG(INFO) << "\nHACK for 2024: Renaming orin2 to imu\n";
+    camera_name = absl::StrCat("/imu", FLAGS_channel);
+  }
+
+  IntrinsicsCalibration extractor(&event_loop, hostname, camera_name,
                                   FLAGS_camera_id, FLAGS_base_intrinsics,
                                   FLAGS_display_undistorted,
                                   FLAGS_calibration_folder, exit_handle.get());
diff --git a/frc971/vision/intrinsics_calibration_lib.cc b/frc971/vision/intrinsics_calibration_lib.cc
index 59a45ac..5584ed7 100644
--- a/frc971/vision/intrinsics_calibration_lib.cc
+++ b/frc971/vision/intrinsics_calibration_lib.cc
@@ -36,17 +36,27 @@
                           rvecs_eigen, tvecs_eigen);
           }),
       image_callback_(
-          event_loop,
-          absl::StrCat("/", aos::network::ParsePiOrOrin(hostname_).value(),
-                       std::to_string(cpu_number_.value()), camera_channel_),
+          event_loop, camera_channel_,
           [this](cv::Mat rgb_image,
                  const aos::monotonic_clock::time_point eof) {
+            if (exit_collection_) {
+              return;
+            }
             charuco_extractor_.HandleImage(rgb_image, eof);
           },
           kMaxImageAge),
       display_undistorted_(display_undistorted),
       calibration_folder_(calibration_folder),
-      exit_handle_(exit_handle) {
+      exit_handle_(exit_handle),
+      exit_collection_(false) {
+  if (!FLAGS_visualize) {
+    // The only way to exit into the calibration routines is by hitting "q"
+    // while visualization is running.  The event_loop doesn't pause enough
+    // to handle ctrl-c exit requests
+    LOG(INFO) << "Setting visualize to true, since currently the intrinsics "
+                 "only works this way";
+    FLAGS_visualize = true;
+  }
   LOG(INFO) << "Hostname is: " << hostname_ << " and camera channel is "
             << camera_channel_;
 
@@ -81,7 +91,11 @@
   }
 
   int keystroke = cv::waitKey(1);
-
+  if ((keystroke & 0xFF) == static_cast<int>('q')) {
+    LOG(INFO) << "Going to exit";
+    exit_collection_ = true;
+    exit_handle_->Exit();
+  }
   // If we haven't got a valid pose estimate, don't use these points
   if (!valid) {
     LOG(INFO) << "Skipping because pose is not valid";
@@ -161,9 +175,6 @@
                   << kDeltaTThreshold;
       }
     }
-
-  } else if ((keystroke & 0xFF) == static_cast<int>('q')) {
-    exit_handle_->Exit();
   }
 }
 
@@ -175,8 +186,14 @@
     std::string_view camera_id, uint16_t team_number,
     double reprojection_error) {
   flatbuffers::FlatBufferBuilder fbb;
+  // THIS IS A HACK FOR 2024, since we call Orin2 "Imu"
+  std::string cpu_name = absl::StrFormat("%s%d", cpu_type, cpu_number);
+  if (cpu_type == "orin" && cpu_number == 2) {
+    LOG(INFO) << "Renaming orin2 to imu";
+    cpu_name = "imu";
+  }
   flatbuffers::Offset<flatbuffers::String> name_offset =
-      fbb.CreateString(absl::StrFormat("%s%d", cpu_type, cpu_number));
+      fbb.CreateString(cpu_name.c_str());
   flatbuffers::Offset<flatbuffers::String> camera_id_offset =
       fbb.CreateString(camera_id);
   flatbuffers::Offset<flatbuffers::Vector<float>> intrinsics_offset =
diff --git a/frc971/vision/intrinsics_calibration_lib.h b/frc971/vision/intrinsics_calibration_lib.h
index 605741f..faa82e9 100644
--- a/frc971/vision/intrinsics_calibration_lib.h
+++ b/frc971/vision/intrinsics_calibration_lib.h
@@ -77,6 +77,8 @@
   const bool display_undistorted_;
   const std::string calibration_folder_;
   aos::ExitHandle *exit_handle_;
+
+  bool exit_collection_;
 };
 
 }  // namespace frc971::vision
diff --git a/package.json b/package.json
index 6853a14..f37a2b6 100644
--- a/package.json
+++ b/package.json
@@ -34,5 +34,14 @@
     "typescript": "5.1.6",
     "terser": "5.16.4",
     "zone.js": "^0.13.0"
+  },
+  "pnpm": {
+    "packageExtensions": {
+      "angularx-qrcode": {
+        "peerDependencies": {
+          "@angular/platform-browser": "*"
+        }
+      }
+    }
   }
-}
\ No newline at end of file
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bf6a429..f718ebc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,8 @@
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
+packageExtensionsChecksum: 4cac6673474a9277f5f94a4599fee092
+
 importers:
 
   .:
@@ -64,7 +66,7 @@
         version: 2.0.3
       angularx-qrcode:
         specifier: ^16.0.2
-        version: 16.0.2(@angular/core@16.2.12)
+        version: 16.0.2(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)
       cypress:
         specifier: 13.3.1
         version: 13.3.1
@@ -99,27 +101,76 @@
         specifier: ^0.13.0
         version: 0.13.3
 
-  scouting/www: {}
+  scouting/webserver/requests/messages: {}
 
-  scouting/www/counter_button: {}
+  scouting/www:
+    dependencies:
+      '@angular/animations':
+        specifier: v16-lts
+        version: 16.2.12(@angular/core@16.2.12)
+      '@angular/service-worker':
+        specifier: v16-lts
+        version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)
+      '@org_frc971/scouting/www/driver_ranking':
+        specifier: workspace:*
+        version: link:driver_ranking
+      '@org_frc971/scouting/www/entry':
+        specifier: workspace:*
+        version: link:entry
+      '@org_frc971/scouting/www/match_list':
+        specifier: workspace:*
+        version: link:match_list
+      '@org_frc971/scouting/www/notes':
+        specifier: workspace:*
+        version: link:notes
+      '@org_frc971/scouting/www/pipes':
+        specifier: workspace:*
+        version: link:pipes
+      '@org_frc971/scouting/www/pit_scouting':
+        specifier: workspace:*
+        version: link:pit_scouting
+      '@org_frc971/scouting/www/scan':
+        specifier: workspace:*
+        version: link:scan
+      '@org_frc971/scouting/www/shift_schedule':
+        specifier: workspace:*
+        version: link:shift_schedule
+      '@org_frc971/scouting/www/view':
+        specifier: workspace:*
+        version: link:view
 
   scouting/www/driver_ranking:
     dependencies:
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
 
   scouting/www/entry:
     dependencies:
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
-      '@org_frc971/scouting/www/counter_button':
+      '@angular/platform-browser':
+        specifier: v16-lts
+        version: 16.2.12(@angular/animations@16.2.12)(@angular/common@16.2.12)(@angular/core@16.2.12)
+      '@org_frc971/scouting/webserver/requests/messages':
         specifier: workspace:*
-        version: link:../counter_button
+        version: link:../../webserver/requests/messages
+      '@org_frc971/scouting/www/pipes':
+        specifier: workspace:*
+        version: link:../pipes
+      '@org_frc971/scouting/www/rpc':
+        specifier: workspace:*
+        version: link:../rpc
       '@types/pako':
         specifier: 2.0.3
         version: 2.0.3
+      angularx-qrcode:
+        specifier: ^16.0.2
+        version: 16.0.2(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)
       pako:
         specifier: 2.1.0
         version: 2.1.0
@@ -129,6 +180,9 @@
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
       '@org_frc971/scouting/www/rpc':
         specifier: workspace:*
         version: link:../rpc
@@ -138,20 +192,38 @@
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
+
+  scouting/www/pipes: {}
 
   scouting/www/pit_scouting:
     dependencies:
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
 
-  scouting/www/rpc: {}
+  scouting/www/rpc:
+    dependencies:
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
+      dexie:
+        specifier: ^3.2.5
+        version: 3.2.5(karma@6.4.3)
 
   scouting/www/scan:
     dependencies:
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
       '@types/pako':
         specifier: 2.0.3
         version: 2.0.3
@@ -164,12 +236,21 @@
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
 
   scouting/www/view:
     dependencies:
       '@angular/forms':
         specifier: v16-lts
         version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+      '@org_frc971/scouting/webserver/requests/messages':
+        specifier: workspace:*
+        version: link:../../webserver/requests/messages
+      '@org_frc971/scouting/www/rpc':
+        specifier: workspace:*
+        version: link:../rpc
 
 packages:
 
@@ -358,7 +439,6 @@
       '@angular/common': 16.2.12(@angular/core@16.2.12)(rxjs@7.5.7)
       '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
       tslib: 2.6.0
-    dev: true
 
   /@babel/cli@7.23.9(@babel/core@7.23.9):
     resolution: {integrity: sha512-vB1UXmGDNEhcf1jNAHKT9IlYk1R+hehVTLFlCLHBi8gfuHQGP6uRjgXVYU0EVlI/qwAWpstqkBdf2aez3/z/5Q==}
@@ -612,7 +692,6 @@
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     engines: {node: '>=0.1.90'}
     requiresBuild: true
-    dev: true
 
   /@cypress/request@3.0.1:
     resolution: {integrity: sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==}
@@ -972,7 +1051,6 @@
 
   /@socket.io/component-emitter@3.1.0:
     resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
-    dev: true
 
   /@tootallnate/once@2.0.0:
     resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
@@ -1023,13 +1101,11 @@
 
   /@types/cookie@0.4.1:
     resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
-    dev: true
 
   /@types/cors@2.8.17:
     resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
     dependencies:
       '@types/node': 20.11.19
-    dev: true
 
   /@types/estree@1.0.5:
     resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -1053,7 +1129,6 @@
     resolution: {integrity: sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==}
     dependencies:
       undici-types: 5.26.5
-    dev: true
 
   /@types/pako@2.0.3:
     resolution: {integrity: sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==}
@@ -1092,7 +1167,6 @@
     dependencies:
       mime-types: 2.1.35
       negotiator: 0.6.3
-    dev: true
 
   /acorn@8.9.0:
     resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==}
@@ -1144,15 +1218,16 @@
       uri-js: 4.4.1
     dev: true
 
-  /angularx-qrcode@16.0.2(@angular/core@16.2.12):
+  /angularx-qrcode@16.0.2(@angular/core@16.2.12)(@angular/platform-browser@16.2.12):
     resolution: {integrity: sha512-FztOM7vjNu88sGxUU5jG2I+A9TxZBXXYBWINjpwIBbTL+COMgrtzXnScG7TyQeNknv5w3WFJWn59PcngRRYVXA==}
     peerDependencies:
       '@angular/core': ^16.0.0
+      '@angular/platform-browser': '*'
     dependencies:
       '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
+      '@angular/platform-browser': 16.2.12(@angular/animations@16.2.12)(@angular/common@16.2.12)(@angular/core@16.2.12)
       qrcode: 1.5.3
       tslib: 2.6.0
-    dev: true
 
   /ansi-colors@4.1.3:
     resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
@@ -1169,7 +1244,6 @@
   /ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
     engines: {node: '>=8'}
-    dev: true
 
   /ansi-regex@6.0.1:
     resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==}
@@ -1188,7 +1262,6 @@
     engines: {node: '>=8'}
     dependencies:
       color-convert: 2.0.1
-    dev: true
 
   /ansi-styles@6.2.1:
     resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
@@ -1201,7 +1274,6 @@
     dependencies:
       normalize-path: 3.0.0
       picomatch: 2.3.1
-    dev: true
 
   /aproba@2.0.0:
     resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
@@ -1258,7 +1330,6 @@
 
   /balanced-match@1.0.2:
     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
-    dev: true
 
   /base64-js@1.5.1:
     resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -1267,7 +1338,6 @@
   /base64id@2.0.0:
     resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
     engines: {node: ^4.5.0 || >= 5.9}
-    dev: true
 
   /bcrypt-pbkdf@1.0.2:
     resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
@@ -1278,7 +1348,6 @@
   /binary-extensions@2.2.0:
     resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
     engines: {node: '>=8'}
-    dev: true
 
   /bl@4.1.0:
     resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -1314,14 +1383,12 @@
       unpipe: 1.0.0
     transitivePeerDependencies:
       - supports-color
-    dev: true
 
   /brace-expansion@1.1.11:
     resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
     dependencies:
       balanced-match: 1.0.2
       concat-map: 0.0.1
-    dev: true
 
   /brace-expansion@2.0.1:
     resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
@@ -1334,7 +1401,6 @@
     engines: {node: '>=8'}
     dependencies:
       fill-range: 7.0.1
-    dev: true
 
   /browserslist@4.23.0:
     resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==}
@@ -1376,7 +1442,6 @@
   /bytes@3.1.2:
     resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /cacache@16.1.3:
     resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
@@ -1432,12 +1497,10 @@
     dependencies:
       function-bind: 1.1.2
       get-intrinsic: 1.2.1
-    dev: true
 
   /camelcase@5.3.1:
     resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
     engines: {node: '>=6'}
-    dev: true
 
   /caniuse-lite@1.0.30001588:
     resolution: {integrity: sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ==}
@@ -1486,7 +1549,6 @@
       readdirp: 3.6.0
     optionalDependencies:
       fsevents: 2.3.2
-    dev: true
 
   /chownr@2.0.0:
     resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
@@ -1543,7 +1605,6 @@
       string-width: 4.2.3
       strip-ansi: 6.0.1
       wrap-ansi: 6.2.0
-    dev: true
 
   /cliui@7.0.4:
     resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -1551,7 +1612,6 @@
       string-width: 4.2.3
       strip-ansi: 6.0.1
       wrap-ansi: 7.0.0
-    dev: true
 
   /cliui@8.0.1:
     resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
@@ -1578,7 +1638,6 @@
     engines: {node: '>=7.0.0'}
     dependencies:
       color-name: 1.1.4
-    dev: true
 
   /color-name@1.1.3:
     resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
@@ -1586,7 +1645,6 @@
 
   /color-name@1.1.4:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
-    dev: true
 
   /color-support@1.1.3:
     resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
@@ -1625,7 +1683,6 @@
 
   /concat-map@0.0.1:
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
-    dev: true
 
   /connect@3.7.0:
     resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
@@ -1637,7 +1694,6 @@
       utils-merge: 1.0.1
     transitivePeerDependencies:
       - supports-color
-    dev: true
 
   /console-control-strings@1.1.0:
     resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
@@ -1646,7 +1702,6 @@
   /content-type@1.0.5:
     resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /convert-source-map@1.9.0:
     resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
@@ -1659,7 +1714,6 @@
   /cookie@0.4.2:
     resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /core-util-is@1.0.2:
     resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -1671,7 +1725,6 @@
     dependencies:
       object-assign: 4.1.1
       vary: 1.1.2
-    dev: true
 
   /cross-spawn@7.0.3:
     resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
@@ -1684,7 +1737,6 @@
 
   /custom-event@1.0.1:
     resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==}
-    dev: true
 
   /cypress@13.3.1:
     resolution: {integrity: sha512-g4mJLZxYN+UAF2LMy3Znd4LBnUmS59Vynd81VES59RdW48Yt+QtR2cush3melOoVNz0PPbADpWr8DcUx6mif8Q==}
@@ -1747,7 +1799,6 @@
   /date-format@4.0.14:
     resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
     engines: {node: '>=4.0'}
-    dev: true
 
   /dayjs@1.11.9:
     resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==}
@@ -1762,7 +1813,6 @@
         optional: true
     dependencies:
       ms: 2.0.0
-    dev: true
 
   /debug@3.2.7(supports-color@8.1.1):
     resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
@@ -1787,12 +1837,10 @@
     dependencies:
       ms: 2.1.2
       supports-color: 8.1.1
-    dev: true
 
   /decamelize@1.2.0:
     resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /deepmerge@4.3.1:
     resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
@@ -1822,12 +1870,10 @@
   /depd@2.0.0:
     resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /destroy@1.2.0:
     resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
-    dev: true
 
   /dexie@3.2.5(karma@6.4.3):
     resolution: {integrity: sha512-MA7vYQvXxWN2+G50D0GLS4FqdYUyRYQsN0FikZIVebOmRoNCSCL9+eUbIF80dqrfns3kmY+83+hE2GN9CnAGyA==}
@@ -1836,15 +1882,12 @@
       karma-safari-launcher: 1.0.0(karma@6.4.3)
     transitivePeerDependencies:
       - karma
-    dev: true
 
   /di@0.0.1:
     resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==}
-    dev: true
 
   /dijkstrajs@1.0.3:
     resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
-    dev: true
 
   /dom-serialize@2.2.1:
     resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==}
@@ -1853,7 +1896,6 @@
       ent: 2.2.0
       extend: 3.0.2
       void-elements: 2.0.1
-    dev: true
 
   /eastasianwidth@0.2.0:
     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -1868,7 +1910,6 @@
 
   /ee-first@1.1.1:
     resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
-    dev: true
 
   /electron-to-chromium@1.4.679:
     resolution: {integrity: sha512-NhQMsz5k0d6m9z3qAxnsOR/ebal4NAGsrNVRwcDo4Kc/zQ7KdsTKZUxZoygHcVRb0QDW3waEDIcE3isZ79RP6g==}
@@ -1876,7 +1917,6 @@
 
   /emoji-regex@8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
-    dev: true
 
   /emoji-regex@9.2.2:
     resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1884,12 +1924,10 @@
 
   /encode-utf8@1.0.3:
     resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
-    dev: true
 
   /encodeurl@1.0.2:
     resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /encoding@0.1.13:
     resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
@@ -1908,7 +1946,6 @@
   /engine.io-parser@5.2.2:
     resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==}
     engines: {node: '>=10.0.0'}
-    dev: true
 
   /engine.io@6.5.4:
     resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==}
@@ -1928,7 +1965,6 @@
       - bufferutil
       - supports-color
       - utf-8-validate
-    dev: true
 
   /enquirer@2.3.6:
     resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
@@ -1939,7 +1975,6 @@
 
   /ent@2.2.0:
     resolution: {integrity: sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==}
-    dev: true
 
   /env-paths@2.2.1:
     resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
@@ -1953,11 +1988,9 @@
   /escalade@3.1.1:
     resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
     engines: {node: '>=6'}
-    dev: true
 
   /escape-html@1.0.3:
     resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
-    dev: true
 
   /escape-string-regexp@1.0.5:
     resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
@@ -1974,7 +2007,6 @@
 
   /eventemitter3@4.0.7:
     resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
-    dev: true
 
   /execa@4.1.0:
     resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
@@ -2004,7 +2036,6 @@
 
   /extend@3.0.2:
     resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
-    dev: true
 
   /external-editor@3.1.0:
     resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
@@ -2056,7 +2087,6 @@
     engines: {node: '>=8'}
     dependencies:
       to-regex-range: 5.0.1
-    dev: true
 
   /finalhandler@1.1.2:
     resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
@@ -2071,7 +2101,6 @@
       unpipe: 1.0.0
     transitivePeerDependencies:
       - supports-color
-    dev: true
 
   /find-up@4.1.0:
     resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
@@ -2079,11 +2108,9 @@
     dependencies:
       locate-path: 5.0.0
       path-exists: 4.0.0
-    dev: true
 
   /flatted@3.3.1:
     resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
-    dev: true
 
   /follow-redirects@1.15.5:
     resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==}
@@ -2093,7 +2120,6 @@
     peerDependenciesMeta:
       debug:
         optional: true
-    dev: true
 
   /foreground-child@3.1.1:
     resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
@@ -2123,7 +2149,6 @@
       graceful-fs: 4.2.11
       jsonfile: 4.0.0
       universalify: 0.1.2
-    dev: true
 
   /fs-extra@9.1.0:
     resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
@@ -2155,23 +2180,19 @@
 
   /fs.realpath@1.0.0:
     resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
-    dev: true
 
   /fsevents@2.3.2:
     resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
     os: [darwin]
     requiresBuild: true
-    dev: true
     optional: true
 
   /function-bind@1.1.1:
     resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
-    dev: true
 
   /function-bind@1.1.2:
     resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
-    dev: true
 
   /gauge@4.0.4:
     resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
@@ -2195,7 +2216,6 @@
   /get-caller-file@2.0.5:
     resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
     engines: {node: 6.* || 8.* || >= 10.*}
-    dev: true
 
   /get-intrinsic@1.2.1:
     resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==}
@@ -2204,7 +2224,6 @@
       has: 1.0.3
       has-proto: 1.0.1
       has-symbols: 1.0.3
-    dev: true
 
   /get-stream@5.2.0:
     resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
@@ -2230,7 +2249,6 @@
     engines: {node: '>= 6'}
     dependencies:
       is-glob: 4.0.3
-    dev: true
 
   /glob@10.3.10:
     resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
@@ -2253,7 +2271,6 @@
       minimatch: 3.1.2
       once: 1.4.0
       path-is-absolute: 1.0.1
-    dev: true
 
   /glob@8.1.0:
     resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
@@ -2280,7 +2297,6 @@
 
   /graceful-fs@4.2.11:
     resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-    dev: true
 
   /has-flag@3.0.0:
     resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
@@ -2290,17 +2306,14 @@
   /has-flag@4.0.0:
     resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
     engines: {node: '>=8'}
-    dev: true
 
   /has-proto@1.0.1:
     resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
     engines: {node: '>= 0.4'}
-    dev: true
 
   /has-symbols@1.0.3:
     resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
     engines: {node: '>= 0.4'}
-    dev: true
 
   /has-unicode@2.0.1:
     resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
@@ -2311,7 +2324,6 @@
     engines: {node: '>= 0.4.0'}
     dependencies:
       function-bind: 1.1.1
-    dev: true
 
   /hasown@2.0.1:
     resolution: {integrity: sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==}
@@ -2348,7 +2360,6 @@
       setprototypeof: 1.2.0
       statuses: 2.0.1
       toidentifier: 1.0.1
-    dev: true
 
   /http-proxy-agent@5.0.0:
     resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
@@ -2370,7 +2381,6 @@
       requires-port: 1.0.0
     transitivePeerDependencies:
       - debug
-    dev: true
 
   /http-signature@1.3.6:
     resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==}
@@ -2407,7 +2417,6 @@
     engines: {node: '>=0.10.0'}
     dependencies:
       safer-buffer: 2.1.2
-    dev: true
 
   /iconv-lite@0.6.3:
     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
@@ -2448,11 +2457,9 @@
     dependencies:
       once: 1.4.0
       wrappy: 1.0.2
-    dev: true
 
   /inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
-    dev: true
 
   /ini@2.0.0:
     resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==}
@@ -2494,7 +2501,6 @@
     engines: {node: '>=8'}
     dependencies:
       binary-extensions: 2.2.0
-    dev: true
 
   /is-builtin-module@3.2.1:
     resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==}
@@ -2531,19 +2537,16 @@
   /is-extglob@2.1.1:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /is-fullwidth-code-point@3.0.0:
     resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
     engines: {node: '>=8'}
-    dev: true
 
   /is-glob@4.0.3:
     resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
     engines: {node: '>=0.10.0'}
     dependencies:
       is-extglob: 2.1.1
-    dev: true
 
   /is-installed-globally@0.4.0:
     resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==}
@@ -2569,7 +2572,6 @@
   /is-number@7.0.0:
     resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
     engines: {node: '>=0.12.0'}
-    dev: true
 
   /is-path-inside@3.0.3:
     resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
@@ -2600,7 +2602,6 @@
   /isbinaryfile@4.0.10:
     resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
     engines: {node: '>= 8.0.0'}
-    dev: true
 
   /isexe@2.0.0:
     resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -2664,7 +2665,6 @@
     resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
     optionalDependencies:
       graceful-fs: 4.2.11
-    dev: true
 
   /jsonfile@6.1.0:
     resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
@@ -2695,7 +2695,6 @@
       karma: '>=0.9'
     dependencies:
       karma: 6.4.3
-    dev: true
 
   /karma@6.4.3:
     resolution: {integrity: sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==}
@@ -2731,7 +2730,6 @@
       - debug
       - supports-color
       - utf-8-validate
-    dev: true
 
   /lazy-ass@1.6.0:
     resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
@@ -2763,7 +2761,6 @@
     engines: {node: '>=8'}
     dependencies:
       p-locate: 4.1.0
-    dev: true
 
   /lodash.once@4.1.1:
     resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
@@ -2771,7 +2768,6 @@
 
   /lodash@4.17.21:
     resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
-    dev: true
 
   /log-symbols@4.1.0:
     resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
@@ -2802,7 +2798,6 @@
       streamroller: 3.1.5
     transitivePeerDependencies:
       - supports-color
-    dev: true
 
   /lru-cache@10.2.0:
     resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==}
@@ -2893,7 +2888,6 @@
   /media-typer@0.3.0:
     resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /merge-stream@2.0.0:
     resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -2902,20 +2896,17 @@
   /mime-db@1.52.0:
     resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /mime-types@2.1.35:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}
     dependencies:
       mime-db: 1.52.0
-    dev: true
 
   /mime@2.6.0:
     resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
     engines: {node: '>=4.0.0'}
     hasBin: true
-    dev: true
 
   /mimic-fn@2.1.0:
     resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
@@ -2926,7 +2917,6 @@
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
     dependencies:
       brace-expansion: 1.1.11
-    dev: true
 
   /minimatch@5.1.6:
     resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
@@ -2944,7 +2934,6 @@
 
   /minimist@1.2.8:
     resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
-    dev: true
 
   /minipass-collect@1.0.2:
     resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
@@ -3033,7 +3022,6 @@
     hasBin: true
     dependencies:
       minimist: 1.2.8
-    dev: true
 
   /mkdirp@1.0.4:
     resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
@@ -3043,11 +3031,9 @@
 
   /ms@2.0.0:
     resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
-    dev: true
 
   /ms@2.1.2:
     resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
-    dev: true
 
   /ms@2.1.3:
     resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3060,7 +3046,6 @@
   /negotiator@0.6.3:
     resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /node-gyp@9.4.1:
     resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
@@ -3108,7 +3093,6 @@
   /normalize-path@3.0.0:
     resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /npm-bundled@3.0.0:
     resolution: {integrity: sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==}
@@ -3191,31 +3175,26 @@
   /object-assign@4.1.1:
     resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /object-inspect@1.12.3:
     resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
-    dev: true
 
   /on-finished@2.3.0:
     resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
     engines: {node: '>= 0.8'}
     dependencies:
       ee-first: 1.1.1
-    dev: true
 
   /on-finished@2.4.1:
     resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
     engines: {node: '>= 0.8'}
     dependencies:
       ee-first: 1.1.1
-    dev: true
 
   /once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
     dependencies:
       wrappy: 1.0.2
-    dev: true
 
   /onetime@5.1.2:
     resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
@@ -3262,14 +3241,12 @@
     engines: {node: '>=6'}
     dependencies:
       p-try: 2.2.0
-    dev: true
 
   /p-locate@4.1.0:
     resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
     engines: {node: '>=8'}
     dependencies:
       p-limit: 2.3.0
-    dev: true
 
   /p-map@4.0.0:
     resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
@@ -3281,7 +3258,6 @@
   /p-try@2.2.0:
     resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
     engines: {node: '>=6'}
-    dev: true
 
   /pacote@15.2.0:
     resolution: {integrity: sha512-rJVZeIwHTUta23sIZgEIM62WYwbmGbThdbnkt81ravBplQv+HjyroqnLRNH2+sLJHcGZmLRmhPwACqhfTcOmnA==}
@@ -3321,17 +3297,14 @@
   /parseurl@1.3.3:
     resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /path-exists@4.0.0:
     resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
     engines: {node: '>=8'}
-    dev: true
 
   /path-is-absolute@1.0.1:
     resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /path-key@3.1.1:
     resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
@@ -3365,7 +3338,6 @@
   /picomatch@2.3.1:
     resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
     engines: {node: '>=8.6'}
-    dev: true
 
   /pify@2.3.0:
     resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
@@ -3380,7 +3352,6 @@
   /pngjs@5.0.0:
     resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
     engines: {node: '>=10.13.0'}
-    dev: true
 
   /prettier@2.6.1:
     resolution: {integrity: sha512-8UVbTBYGwN37Bs9LERmxCPjdvPxlEowx2urIL6urHzdb3SDq4B/Z6xLFCblrSnE4iKWcS6ziJ3aOYrc1kz/E2A==}
@@ -3443,7 +3414,6 @@
   /qjobs@1.2.0:
     resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==}
     engines: {node: '>=0.9'}
-    dev: true
 
   /qrcode@1.5.3:
     resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
@@ -3454,7 +3424,6 @@
       encode-utf8: 1.0.3
       pngjs: 5.0.0
       yargs: 15.4.1
-    dev: true
 
   /qs@6.10.4:
     resolution: {integrity: sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==}
@@ -3468,7 +3437,6 @@
     engines: {node: '>=0.6'}
     dependencies:
       side-channel: 1.0.4
-    dev: true
 
   /querystringify@2.2.0:
     resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -3477,7 +3445,6 @@
   /range-parser@1.2.1:
     resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /raw-body@2.5.2:
     resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
@@ -3487,7 +3454,6 @@
       http-errors: 2.0.0
       iconv-lite: 0.4.24
       unpipe: 1.0.0
-    dev: true
 
   /read-package-json-fast@3.0.2:
     resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==}
@@ -3521,7 +3487,6 @@
     engines: {node: '>=8.10.0'}
     dependencies:
       picomatch: 2.3.1
-    dev: true
 
   /reflect-metadata@0.1.14:
     resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
@@ -3536,7 +3501,6 @@
   /require-directory@2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /require-from-string@2.0.2:
     resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
@@ -3545,7 +3509,6 @@
 
   /require-main-filename@2.0.0:
     resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
-    dev: true
 
   /requirejs@2.3.6:
     resolution: {integrity: sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==}
@@ -3555,7 +3518,6 @@
 
   /requires-port@1.0.0:
     resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
-    dev: true
 
   /resolve@1.22.2:
     resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==}
@@ -3581,14 +3543,12 @@
 
   /rfdc@1.3.0:
     resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}
-    dev: true
 
   /rimraf@3.0.2:
     resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
     hasBin: true
     dependencies:
       glob: 7.2.3
-    dev: true
 
   /rollup@4.12.0:
     resolution: {integrity: sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==}
@@ -3635,7 +3595,6 @@
 
   /safer-buffer@2.1.2:
     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
-    dev: true
 
   /semver@5.7.1:
     resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
@@ -3665,11 +3624,9 @@
 
   /set-blocking@2.0.0:
     resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
-    dev: true
 
   /setprototypeof@1.2.0:
     resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
-    dev: true
 
   /shebang-command@2.0.0:
     resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -3689,7 +3646,6 @@
       call-bind: 1.0.2
       get-intrinsic: 1.2.1
       object-inspect: 1.12.3
-    dev: true
 
   /signal-exit@3.0.7:
     resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -3751,7 +3707,6 @@
       - bufferutil
       - supports-color
       - utf-8-validate
-    dev: true
 
   /socket.io-parser@4.2.4:
     resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
@@ -3761,7 +3716,6 @@
       debug: 4.3.4(supports-color@8.1.1)
     transitivePeerDependencies:
       - supports-color
-    dev: true
 
   /socket.io@4.7.4:
     resolution: {integrity: sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==}
@@ -3778,7 +3732,6 @@
       - bufferutil
       - supports-color
       - utf-8-validate
-    dev: true
 
   /socks-proxy-agent@7.0.0:
     resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
@@ -3809,7 +3762,6 @@
   /source-map@0.6.1:
     resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /source-map@0.7.4:
     resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
@@ -3871,12 +3823,10 @@
   /statuses@1.5.0:
     resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /statuses@2.0.1:
     resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /streamroller@3.1.5:
     resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==}
@@ -3887,7 +3837,6 @@
       fs-extra: 8.1.0
     transitivePeerDependencies:
       - supports-color
-    dev: true
 
   /string-width@4.2.3:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
@@ -3896,7 +3845,6 @@
       emoji-regex: 8.0.0
       is-fullwidth-code-point: 3.0.0
       strip-ansi: 6.0.1
-    dev: true
 
   /string-width@5.1.2:
     resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
@@ -3918,7 +3866,6 @@
     engines: {node: '>=8'}
     dependencies:
       ansi-regex: 5.0.1
-    dev: true
 
   /strip-ansi@7.1.0:
     resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
@@ -3951,7 +3898,6 @@
     engines: {node: '>=10'}
     dependencies:
       has-flag: 4.0.0
-    dev: true
 
   /supports-preserve-symlinks-flag@1.0.0:
     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
@@ -4006,7 +3952,6 @@
     engines: {node: '>=8.17.0'}
     dependencies:
       rimraf: 3.0.2
-    dev: true
 
   /to-fast-properties@2.0.0:
     resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
@@ -4018,12 +3963,10 @@
     engines: {node: '>=8.0'}
     dependencies:
       is-number: 7.0.0
-    dev: true
 
   /toidentifier@1.0.1:
     resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
     engines: {node: '>=0.6'}
-    dev: true
 
   /tough-cookie@4.1.3:
     resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==}
@@ -4070,7 +4013,6 @@
     dependencies:
       media-typer: 0.3.0
       mime-types: 2.1.35
-    dev: true
 
   /typescript@5.1.6:
     resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
@@ -4080,11 +4022,9 @@
 
   /ua-parser-js@0.7.37:
     resolution: {integrity: sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==}
-    dev: true
 
   /undici-types@5.26.5:
     resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
-    dev: true
 
   /unique-filename@2.0.1:
     resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==}
@@ -4117,7 +4057,6 @@
   /universalify@0.1.2:
     resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
     engines: {node: '>= 4.0.0'}
-    dev: true
 
   /universalify@0.2.0:
     resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
@@ -4132,7 +4071,6 @@
   /unpipe@1.0.0:
     resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /untildify@4.0.0:
     resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
@@ -4170,7 +4108,6 @@
   /utils-merge@1.0.1:
     resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
     engines: {node: '>= 0.4.0'}
-    dev: true
 
   /uuid@8.3.2:
     resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
@@ -4194,7 +4131,6 @@
   /vary@1.1.2:
     resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
     engines: {node: '>= 0.8'}
-    dev: true
 
   /verror@1.10.0:
     resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
@@ -4208,7 +4144,6 @@
   /void-elements@2.0.1:
     resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /wcwidth@1.0.1:
     resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@@ -4218,7 +4153,6 @@
 
   /which-module@2.0.1:
     resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
-    dev: true
 
   /which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
@@ -4249,7 +4183,6 @@
       ansi-styles: 4.3.0
       string-width: 4.2.3
       strip-ansi: 6.0.1
-    dev: true
 
   /wrap-ansi@7.0.0:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
@@ -4258,7 +4191,6 @@
       ansi-styles: 4.3.0
       string-width: 4.2.3
       strip-ansi: 6.0.1
-    dev: true
 
   /wrap-ansi@8.1.0:
     resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
@@ -4271,7 +4203,6 @@
 
   /wrappy@1.0.2:
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
-    dev: true
 
   /ws@8.11.0:
     resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
@@ -4284,16 +4215,13 @@
         optional: true
       utf-8-validate:
         optional: true
-    dev: true
 
   /y18n@4.0.3:
     resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
-    dev: true
 
   /y18n@5.0.8:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}
-    dev: true
 
   /yallist@3.1.1:
     resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4309,12 +4237,10 @@
     dependencies:
       camelcase: 5.3.1
       decamelize: 1.2.0
-    dev: true
 
   /yargs-parser@20.2.9:
     resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
     engines: {node: '>=10'}
-    dev: true
 
   /yargs-parser@21.1.1:
     resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
@@ -4336,7 +4262,6 @@
       which-module: 2.0.1
       y18n: 4.0.3
       yargs-parser: 18.1.3
-    dev: true
 
   /yargs@16.2.0:
     resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
@@ -4349,7 +4274,6 @@
       string-width: 4.2.3
       y18n: 5.0.8
       yargs-parser: 20.2.9
-    dev: true
 
   /yargs@17.7.2:
     resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 36eb141..cf4c338 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,4 @@
 packages:
+  - 'scouting/webserver/requests/messages'
   - 'scouting/www'
   - 'scouting/www/*'
diff --git a/scouting/scouting_qrcode_test.cy.js b/scouting/scouting_qrcode_test.cy.js
index 668cba8..559481e 100644
--- a/scouting/scouting_qrcode_test.cy.js
+++ b/scouting/scouting_qrcode_test.cy.js
@@ -86,7 +86,7 @@
   cy.get('#review_data li')
     .eq(0)
     .should('have.text', ' Started match at position 1 ');
-  cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+  cy.get('#review_data li').eq(1).should('have.text', ' Picked up Note ');
   cy.get('#review_data li')
     .last()
     .should(
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index d15cdfc..2de6879 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -106,7 +106,7 @@
   cy.get('#review_data li')
     .eq(0)
     .should('have.text', ' Started match at position 1 ');
-  cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+  cy.get('#review_data li').eq(1).should('have.text', ' Picked up Note ');
   cy.get('#review_data li')
     .last()
     .should(
diff --git a/scouting/webserver/requests/messages/BUILD b/scouting/webserver/requests/messages/BUILD
index d0534a51..6dee54d 100644
--- a/scouting/webserver/requests/messages/BUILD
+++ b/scouting/webserver/requests/messages/BUILD
@@ -1,3 +1,4 @@
+load("@aspect_rules_js//npm:defs.bzl", "npm_package")
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_go_library")
 load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
 
@@ -60,3 +61,14 @@
         visibility = ["//visibility:public"],
     ),
 ) for name in FILE_NAMES]
+
+npm_package(
+    name = "messages",
+    srcs = [
+        ":package.json",
+    ] + [
+        ":{}_ts_fbs_ts".format(lib)
+        for lib in FILE_NAMES
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/scouting/webserver/requests/messages/package.json b/scouting/webserver/requests/messages/package.json
new file mode 100644
index 0000000..323b3c4
--- /dev/null
+++ b/scouting/webserver/requests/messages/package.json
@@ -0,0 +1,6 @@
+{
+    "name": "@org_frc971/scouting/webserver/requests/messages",
+    "private": true,
+    "dependencies": {
+    }
+}
diff --git a/scouting/webserver/requests/messages/submit_2024_actions.fbs b/scouting/webserver/requests/messages/submit_2024_actions.fbs
index 9462fbe..e927b67 100644
--- a/scouting/webserver/requests/messages/submit_2024_actions.fbs
+++ b/scouting/webserver/requests/messages/submit_2024_actions.fbs
@@ -46,13 +46,21 @@
     spotlight:bool (id:2);
 }
 
+table EndAutoPhaseAction {
+}
+
+table EndTeleopPhaseAction {
+}
+
 union ActionType {
     MobilityAction,
     StartMatchAction,
+    EndAutoPhaseAction,
     PickupNoteAction,
     PlaceNoteAction,
     PenaltyAction,
     RobotDeathAction,
+    EndTeleopPhaseAction,
     EndMatchAction
 }
 
@@ -72,4 +80,4 @@
     // submission. I.e. checking that the match information exists in the match
     // list should be skipped.
     pre_scouting:bool (id: 5);
-}
\ No newline at end of file
+}
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index a6ca0a1..46742e3 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -36,16 +36,7 @@
         "assets/971_144.png",
     ],
     deps = [
-        "//:node_modules/@angular/animations",
-        "//:node_modules/@angular/service-worker",
-        "//scouting/www/driver_ranking",
-        "//scouting/www/entry",
-        "//scouting/www/match_list",
-        "//scouting/www/notes",
-        "//scouting/www/pit_scouting",
-        "//scouting/www/scan",
-        "//scouting/www/shift_schedule",
-        "//scouting/www/view",
+        ":node_modules",
     ],
 )
 
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index decd1f3..e23c2f3 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -4,14 +4,15 @@
 import {ServiceWorkerModule} from '@angular/service-worker';
 
 import {App} from './app';
-import {EntryModule} from '../entry';
-import {MatchListModule} from '../match_list';
-import {NotesModule} from '../notes';
-import {ShiftScheduleModule} from '../shift_schedule';
-import {ViewModule} from '../view';
-import {DriverRankingModule} from '../driver_ranking';
-import {PitScoutingModule} from '../pit_scouting';
-import {ScanModule} from '../scan';
+import {PipeModule} from '@org_frc971/scouting/www/pipes';
+import {EntryModule} from '@org_frc971/scouting/www/entry';
+import {MatchListModule} from '@org_frc971/scouting/www/match_list';
+import {NotesModule} from '@org_frc971/scouting/www/notes';
+import {ShiftScheduleModule} from '@org_frc971/scouting/www/shift_schedule';
+import {ViewModule} from '@org_frc971/scouting/www/view';
+import {DriverRankingModule} from '@org_frc971/scouting/www/driver_ranking';
+import {PitScoutingModule} from '@org_frc971/scouting/www/pit_scouting';
+import {ScanModule} from '@org_frc971/scouting/www/scan';
 
 @NgModule({
   declarations: [App],
@@ -27,6 +28,7 @@
     EntryModule,
     NotesModule,
     MatchListModule,
+    PipeModule,
     ShiftScheduleModule,
     DriverRankingModule,
     ViewModule,
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index 1a70b6f..645c224 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -1,5 +1,7 @@
 import {Component, ElementRef, ViewChild, isDevMode} from '@angular/core';
 
+import {CompLevel} from '@org_frc971/scouting/www/entry';
+
 type Tab =
   | 'MatchList'
   | 'Notes'
@@ -17,7 +19,7 @@
   teamNumber: string;
   matchNumber: number;
   setNumber: number;
-  compLevel: string;
+  compLevel: CompLevel;
 };
 
 @Component({
diff --git a/scouting/www/counter_button/BUILD b/scouting/www/counter_button/BUILD
deleted file mode 100644
index d081f9d..0000000
--- a/scouting/www/counter_button/BUILD
+++ /dev/null
@@ -1,8 +0,0 @@
-load("@npm//:defs.bzl", "npm_link_all_packages")
-load("//tools/build_rules:js.bzl", "ng_pkg")
-
-npm_link_all_packages(name = "node_modules")
-
-ng_pkg(
-    name = "counter_button",
-)
diff --git a/scouting/www/counter_button/counter_button.component.css b/scouting/www/counter_button/counter_button.component.css
deleted file mode 100644
index df95f65..0000000
--- a/scouting/www/counter_button/counter_button.component.css
+++ /dev/null
@@ -1,14 +0,0 @@
-:host {
-  display: flex;
-  align-items: stretch;
-  flex-direction: column;
-  text-align: center;
-}
-
-* {
-  padding: 10px;
-}
-
-.no-touch-action {
-  touch-action: manipulation;
-}
diff --git a/scouting/www/counter_button/counter_button.component.ts b/scouting/www/counter_button/counter_button.component.ts
deleted file mode 100644
index fa84dc6..0000000
--- a/scouting/www/counter_button/counter_button.component.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import {Component, Input, Output, EventEmitter} from '@angular/core';
-
-@Component({
-  selector: 'frc971-counter-button',
-  templateUrl: './counter_button.ng.html',
-  styleUrls: ['./counter_button.component.css'],
-})
-export class CounterButton {
-  @Input() value: number = 0;
-  @Output() valueChange = new EventEmitter<number>();
-
-  update(delta: number) {
-    this.value = Math.max(this.value + delta, 0);
-
-    this.valueChange.emit(this.value);
-  }
-}
diff --git a/scouting/www/counter_button/counter_button.module.ts b/scouting/www/counter_button/counter_button.module.ts
deleted file mode 100644
index 719ce7c..0000000
--- a/scouting/www/counter_button/counter_button.module.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import {NgModule} from '@angular/core';
-
-import {CounterButton} from './counter_button.component';
-
-@NgModule({
-  declarations: [CounterButton],
-  exports: [CounterButton],
-})
-export class CounterButtonModule {}
diff --git a/scouting/www/counter_button/counter_button.ng.html b/scouting/www/counter_button/counter_button.ng.html
deleted file mode 100644
index 3300ff2..0000000
--- a/scouting/www/counter_button/counter_button.ng.html
+++ /dev/null
@@ -1,11 +0,0 @@
-<h4><ng-content></ng-content></h4>
-<button (click)="update(1)" class="btn btn-secondary btn-block no-touch-action">
-  +
-</button>
-<h3>{{value}}</h3>
-<button
-  (click)="update(-1)"
-  class="btn btn-secondary btn-block no-touch-action"
->
-  -
-</button>
diff --git a/scouting/www/counter_button/package.json b/scouting/www/counter_button/package.json
deleted file mode 100644
index 61eb83b..0000000
--- a/scouting/www/counter_button/package.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-    "name": "@org_frc971/scouting/www/counter_button",
-    "private": true
-}
diff --git a/scouting/www/driver_ranking/BUILD b/scouting/www/driver_ranking/BUILD
index a934ffe..1565dad 100644
--- a/scouting/www/driver_ranking/BUILD
+++ b/scouting/www/driver_ranking/BUILD
@@ -9,9 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_driver_ranking_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/driver_ranking/driver_ranking.component.ts b/scouting/www/driver_ranking/driver_ranking.component.ts
index 09f62c2..2148b63 100644
--- a/scouting/www/driver_ranking/driver_ranking.component.ts
+++ b/scouting/www/driver_ranking/driver_ranking.component.ts
@@ -1,7 +1,7 @@
 import {Component, OnInit} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {SubmitDriverRanking} from '../../webserver/requests/messages/submit_driver_ranking_generated';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {SubmitDriverRanking} from '@org_frc971/scouting/webserver/requests/messages/submit_driver_ranking_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
 
 // TeamSelection: Display form to input which
 // teams to rank and the match number.
diff --git a/scouting/www/driver_ranking/package.json b/scouting/www/driver_ranking/package.json
index 38d9358..73b6f6b 100644
--- a/scouting/www/driver_ranking/package.json
+++ b/scouting/www/driver_ranking/package.json
@@ -2,6 +2,7 @@
     "name": "@org_frc971/scouting/www/driver_ranking",
     "private": true,
     "dependencies": {
-        "@angular/forms": "v16-lts"
+        "@angular/forms": "v16-lts",
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*"
     }
 }
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 48732f9..24da904 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -1,23 +1,38 @@
 load("@npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/build_rules:js.bzl", "ng_pkg")
+load("//tools/build_rules:template.bzl", "jinja2_template")
 
 npm_link_all_packages(name = "node_modules")
 
 ng_pkg(
     name = "entry",
     extra_srcs = [
+        ":action_helper.ts",
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//:node_modules/@angular/platform-browser",
-        "//:node_modules/@types/pako",
-        "//:node_modules/angularx-qrcode",
-        "//:node_modules/pako",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_2024_actions_ts_fbs",
-        "//scouting/www/rpc",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
+
+jinja2_template(
+    name = "action_helper.ts",
+    src = "action_helper.jinja2.ts",
+    list_parameters = {
+        # Is there a way to auto-generate the list of actions here? Would be
+        # nice not to have a duplicate list here when they're already known in
+        # the .fbs file.
+        "ACTIONS": [
+            "EndMatchAction",
+            "MobilityAction",
+            "PenaltyAction",
+            "PickupNoteAction",
+            "PlaceNoteAction",
+            "RobotDeathAction",
+            "StartMatchAction",
+            "EndAutoPhaseAction",
+            "EndTeleopPhaseAction",
+        ],
+    },
+)
diff --git a/scouting/www/entry/action_helper.jinja2.ts b/scouting/www/entry/action_helper.jinja2.ts
new file mode 100644
index 0000000..bdce4d3
--- /dev/null
+++ b/scouting/www/entry/action_helper.jinja2.ts
@@ -0,0 +1,32 @@
+import {
+  ActionT,
+  ActionType,
+{% for action in ACTIONS %}
+  {{ action }}T,
+{% endfor %}
+} from '@org_frc971/scouting/webserver/requests/messages/submit_2024_actions_generated';
+
+export type ConcreteAction =
+{% for action in ACTIONS %}
+  {{ action }}T {% if not loop.last %} | {% endif %}
+{% endfor %};
+
+export class ActionHelper {
+  constructor(
+    private addAction: (actionType: ActionType, action: ConcreteAction) => void
+  ){}
+
+  {% for action in ACTIONS %}
+  // Calls `addAction` in entry.component.ts with the proper arguments. This
+  // also forces users to specify all the attributes in the `action` object.
+  public add{{ action}}(action: NonFunctionProperties<{{ action }}T>): void {
+    this.addAction(ActionType.{{ action }}, Object.assign(new {{ action }}T(), action));
+  }
+  {% endfor %}
+}
+
+type NonFunctionPropertyNames<T> = {
+  [K in keyof T]: T[K] extends Function ? never : K
+}[keyof T];
+
+type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 21fd474..cd59884 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -9,23 +9,32 @@
 } from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
 import {
   StartMatchAction,
+  StartMatchActionT,
   ScoreType,
   StageType,
   Submit2024Actions,
   MobilityAction,
+  MobilityActionT,
   PenaltyAction,
+  PenaltyActionT,
   PickupNoteAction,
+  PickupNoteActionT,
   PlaceNoteAction,
+  PlaceNoteActionT,
   RobotDeathAction,
+  RobotDeathActionT,
   EndMatchAction,
+  EndMatchActionT,
   ActionType,
   Action,
-} from '../../webserver/requests/messages/submit_2024_actions_generated';
-import {Match} from '../../webserver/requests/messages/request_all_matches_response_generated';
-import {MatchListRequestor} from '../rpc';
+  ActionT,
+} from '@org_frc971/scouting/webserver/requests/messages/submit_2024_actions_generated';
+import {Match} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
+import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
+import {ActionHelper, ConcreteAction} from './action_helper';
 import * as pako from 'pako';
 
 type Section =
@@ -41,7 +50,7 @@
 
 // TODO(phil): Deduplicate with match_list.component.ts.
 const COMP_LEVELS = ['qm', 'ef', 'qf', 'sf', 'f'] as const;
-type CompLevel = typeof COMP_LEVELS[number];
+export type CompLevel = typeof COMP_LEVELS[number];
 
 // TODO(phil): Deduplicate with match_list.component.ts.
 const COMP_LEVEL_LABELS: Record<CompLevel, string> = {
@@ -59,57 +68,12 @@
 // The default index into QR_CODE_PIECE_SIZES.
 const DEFAULT_QR_CODE_PIECE_SIZE_INDEX = QR_CODE_PIECE_SIZES.indexOf(750);
 
-type ActionT =
-  | {
-      type: 'startMatchAction';
-      timestamp?: number;
-      position: number;
-    }
-  | {
-      type: 'mobilityAction';
-      timestamp?: number;
-      mobility: boolean;
-    }
-  | {
-      type: 'pickupNoteAction';
-      timestamp?: number;
-      auto?: boolean;
-    }
-  | {
-      type: 'placeNoteAction';
-      timestamp?: number;
-      scoreType: ScoreType;
-      auto?: boolean;
-    }
-  | {
-      type: 'robotDeathAction';
-      timestamp?: number;
-      robotDead: boolean;
-    }
-  | {
-      type: 'penaltyAction';
-      timestamp?: number;
-      penalties: number;
-    }
-  | {
-      type: 'endMatchAction';
-      stageType: StageType;
-      trapNote: boolean;
-      spotlight: boolean;
-      timestamp?: number;
-    }
-  | {
-      // This is not a action that is submitted,
-      // It is used for undoing purposes.
-      type: 'endAutoPhase';
-      timestamp?: number;
-    }
-  | {
-      // This is not a action that is submitted,
-      // It is used for undoing purposes.
-      type: 'endTeleopPhase';
-      timestamp?: number;
-    };
+// The actions that are purely used for tracking state. They don't actually
+// have any permanent meaning and will not be saved in the database.
+const STATE_ACTIONS: ActionType[] = [
+  ActionType.EndAutoPhaseAction,
+  ActionType.EndTeleopPhaseAction,
+];
 
 @Component({
   selector: 'app-entry',
@@ -124,6 +88,15 @@
   readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
   readonly ScoreType = ScoreType;
   readonly StageType = StageType;
+  readonly ActionT = ActionT;
+  readonly ActionType = ActionType;
+  readonly StartMatchActionT = StartMatchActionT;
+  readonly MobilityActionT = MobilityActionT;
+  readonly PickupNoteActionT = PickupNoteActionT;
+  readonly PlaceNoteActionT = PlaceNoteActionT;
+  readonly RobotDeathActionT = RobotDeathActionT;
+  readonly PenaltyActionT = PenaltyActionT;
+  readonly EndMatchActionT = EndMatchActionT;
 
   section: Section = 'Team Selection';
   @Input() matchNumber: number = 1;
@@ -136,12 +109,18 @@
 
   matchList: Match[] = [];
 
+  actionHelper: ActionHelper;
   actionList: ActionT[] = [];
   progressMessage: string = '';
   errorMessage: string = '';
   autoPhase: boolean = true;
   mobilityCompleted: boolean = false;
+  // TODO(phil): Come up with a better name here.
   selectedValue = 0;
+  endGameAction: StageType = StageType.kMISSING;
+  noteIsTrapped: boolean = false;
+  endGameSpotlight: boolean = false;
+
   nextTeamNumber = '';
 
   preScouting: boolean = false;
@@ -163,6 +142,12 @@
   constructor(private readonly matchListRequestor: MatchListRequestor) {}
 
   ngOnInit() {
+    this.actionHelper = new ActionHelper(
+      (actionType: ActionType, action: ConcreteAction) => {
+        this.addAction(actionType, action);
+      }
+    );
+
     // When the user navigated from the match list, we can skip the team
     // selection. I.e. we trust that the user clicked the correct button.
     this.section = this.skipTeamSelection ? 'Init' : 'Team Selection';
@@ -231,60 +216,58 @@
   }
 
   addPenalties(): void {
-    this.addAction({type: 'penaltyAction', penalties: this.penalties});
+    this.actionHelper.addPenaltyAction({penalties: this.penalties});
   }
 
-  addAction(action: ActionT): void {
-    if (action.type == 'startMatchAction') {
+  addAction(actionType: ActionType, action: ConcreteAction): void {
+    let timestamp: number = 0;
+
+    if (actionType == ActionType.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;
+      timestamp = Date.now() * 1e6 - this.matchStartTimestamp;
     }
 
-    if (action.type == 'endMatchAction') {
+    if (actionType == ActionType.EndMatchAction) {
       // endMatchAction occurs at the same time as penaltyAction so add to its
       // timestamp to make it unique.
-      action.timestamp += 1;
+      timestamp += 1;
     }
 
-    if (action.type == 'mobilityAction') {
+    if (actionType == ActionType.MobilityAction) {
       this.mobilityCompleted = true;
     }
 
-    if (action.type == 'pickupNoteAction' || action.type == 'placeNoteAction') {
-      action.auto = this.autoPhase;
-    }
-    this.actionList.push(action);
+    this.actionList.push(new ActionT(BigInt(timestamp), actionType, action));
   }
 
   undoLastAction() {
     if (this.actionList.length > 0) {
       let lastAction = this.actionList.pop();
-      switch (lastAction?.type) {
-        case 'endAutoPhase':
+      switch (lastAction?.actionTakenType) {
+        case ActionType.EndAutoPhaseAction:
           this.autoPhase = true;
           this.section = 'Pickup';
-        case 'pickupNoteAction':
+        case ActionType.PickupNoteAction:
           this.section = 'Pickup';
           break;
-        case 'endTeleopPhase':
+        case ActionType.EndTeleopPhaseAction:
           this.section = 'Pickup';
           break;
-        case 'placeNoteAction':
+        case ActionType.PlaceNoteAction:
           this.section = 'Place';
           break;
-        case 'endMatchAction':
+        case ActionType.EndMatchAction:
           this.section = 'Endgame';
-        case 'mobilityAction':
+        case ActionType.MobilityAction:
           this.mobilityCompleted = false;
           break;
-        case 'startMatchAction':
+        case ActionType.StartMatchAction:
           this.section = 'Init';
           break;
-        case 'robotDeathAction':
+        case ActionType.RobotDeathAction:
           // TODO(FILIP): Return user to the screen they
           // clicked dead robot on. Pickup is fine for now but
           // might cause confusion.
@@ -326,111 +309,11 @@
     const actionOffsets: number[] = [];
 
     for (const action of this.actionList) {
-      let actionOffset: number | undefined;
-
-      switch (action.type) {
-        case 'startMatchAction':
-          const startMatchActionOffset =
-            StartMatchAction.createStartMatchAction(builder, action.position);
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.StartMatchAction,
-            startMatchActionOffset
-          );
-          break;
-        case 'mobilityAction':
-          const mobilityActionOffset = MobilityAction.createMobilityAction(
-            builder,
-            action.mobility
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.MobilityAction,
-            mobilityActionOffset
-          );
-          break;
-        case 'penaltyAction':
-          const penaltyActionOffset = PenaltyAction.createPenaltyAction(
-            builder,
-            action.penalties
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.PenaltyAction,
-            penaltyActionOffset
-          );
-          break;
-        case 'pickupNoteAction':
-          const pickupNoteActionOffset =
-            PickupNoteAction.createPickupNoteAction(
-              builder,
-              action.auto || false
-            );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.PickupNoteAction,
-            pickupNoteActionOffset
-          );
-          break;
-        case 'placeNoteAction':
-          const placeNoteActionOffset = PlaceNoteAction.createPlaceNoteAction(
-            builder,
-            action.scoreType,
-            action.auto || false
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.PlaceNoteAction,
-            placeNoteActionOffset
-          );
-          break;
-
-        case 'robotDeathAction':
-          const robotDeathActionOffset =
-            RobotDeathAction.createRobotDeathAction(builder, action.robotDead);
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.RobotDeathAction,
-            robotDeathActionOffset
-          );
-          break;
-
-        case 'endMatchAction':
-          const endMatchActionOffset = EndMatchAction.createEndMatchAction(
-            builder,
-            action.stageType,
-            action.trapNote,
-            action.spotlight
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.EndMatchAction,
-            endMatchActionOffset
-          );
-          break;
-
-        case 'endAutoPhase':
-          // Not important action.
-          break;
-
-        case 'endTeleopPhase':
-          // Not important action.
-          break;
-
-        default:
-          throw new Error(`Unknown action type`);
+      if (STATE_ACTIONS.includes(action.actionTakenType)) {
+        // Actions only used for undo purposes are not submitted.
+        continue;
       }
-
-      if (actionOffset !== undefined) {
-        actionOffsets.push(actionOffset);
-      }
+      actionOffsets.push(action.pack(builder));
     }
     const teamNumberFb = builder.createString(this.teamNumber);
     const compLevelFb = builder.createString(this.compLevel);
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index bcba4ee..8757ced 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -1,12 +1,14 @@
-import {NgModule, Pipe, PipeTransform} from '@angular/core';
+import {NgModule} from '@angular/core';
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
 import {EntryComponent} from './entry.component';
 import {QRCodeModule} from 'angularx-qrcode';
 
+import {PipeModule} from '@org_frc971/scouting/www/pipes';
+
 @NgModule({
   declarations: [EntryComponent],
   exports: [EntryComponent],
-  imports: [CommonModule, FormsModule, QRCodeModule],
+  imports: [PipeModule, CommonModule, FormsModule, QRCodeModule],
 })
 export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 553fa1e..d95bcec 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -91,7 +91,7 @@
           type="radio"
           name="radio-group"
           [value]="i"
-          (change)="selectedValue = $event.target.value"
+          [(ngModel)]="selectedValue"
         />
         {{ i }}
       </label>
@@ -102,7 +102,7 @@
         <button
           class="btn btn-primary"
           [disabled]="!selectedValue"
-          (click)="changeSectionTo('Pickup'); addAction({type: 'startMatchAction', position: selectedValue});"
+          (click)="changeSectionTo('Pickup'); actionHelper.addStartMatchAction({position: selectedValue});"
         >
           Start Match
         </button>
@@ -111,7 +111,8 @@
   </div>
   <div *ngSwitchCase="'Pickup'" id="PickUp" class="container-fluid">
     <h6 class="text-muted">
-      Last Action: {{actionList[actionList.length - 1].type}}
+      Last Action: {{ActionType[actionList[actionList.length -
+      1].actionTakenType]}}
     </h6>
     <!--
       Decrease distance between buttons during auto to make space for auto balancing
@@ -123,20 +124,20 @@
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
+        (click)="changeSectionTo('Dead'); actionHelper.addRobotDeathAction({robotDead: true});"
       >
         DEAD
       </button>
       <button
         class="btn btn-warning"
-        (click)="changeSectionTo('Place'); addAction({type: 'pickupNoteAction'});"
+        (click)="changeSectionTo('Place'); actionHelper.addPickupNoteAction({auto: autoPhase});"
       >
         NOTE
       </button>
       <button
         *ngIf="autoPhase && !mobilityCompleted"
         class="btn btn-light"
-        (click)="addAction({type: 'mobilityAction', mobility: true});"
+        (click)="actionHelper.addMobilityAction({mobility: true});"
       >
         Mobility
       </button>
@@ -161,14 +162,14 @@
       <button
         *ngIf="autoPhase"
         class="btn btn-dark"
-        (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+        (click)="autoPhase = false; actionHelper.addEndAutoPhaseAction({});"
       >
         Start Teleop
       </button>
       <button
         *ngIf="!autoPhase"
         class="btn btn-info"
-        (click)="changeSectionTo('Endgame'); addAction({type: 'endTeleopPhase'});"
+        (click)="changeSectionTo('Endgame'); actionHelper.addEndTeleopPhaseAction({});"
       >
         Endgame
       </button>
@@ -176,7 +177,8 @@
   </div>
   <div *ngSwitchCase="'Place'" id="Place" class="container-fluid">
     <h6 class="text-muted">
-      Last Action: {{actionList[actionList.length - 1].type}}
+      Last Action: {{ActionType[actionList[actionList.length -
+      1].actionTakenType]}}
     </h6>
     <!--
       Decrease distance between buttons during auto to make space for auto balancing
@@ -188,13 +190,13 @@
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
+        (click)="changeSectionTo('Dead'); actionHelper.addRobotDeathAction({robotDead: true});"
       >
         DEAD
       </button>
       <button
         class="btn btn-info"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kDROPPED});"
+        (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kDROPPED});"
       >
         Dropped
       </button>
@@ -211,7 +213,7 @@
         >
           <button
             class="btn btn-success"
-            (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kAMP});"
+            (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kAMP});"
             style="width: 48%; height: 12vh; margin: 0px 10px 10px 0px"
           >
             AMP
@@ -219,21 +221,21 @@
 
           <button
             class="btn btn-warning"
-            (click)="changeSectionTo('Pickup');  addAction({type: 'placeNoteAction', scoreType: ScoreType.kAMP_AMPLIFIED});"
+            (click)="changeSectionTo('Pickup');  actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kAMP_AMPLIFIED});"
             style="width: 48%; height: 12vh; margin: 0px 0px 10px 0px"
           >
             AMP AMPLIFIED
           </button>
           <button
             class="btn btn-success"
-            (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kSPEAKER});"
+            (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kSPEAKER});"
             style="width: 48%; height: 12vh; margin: 0px 10px 0px 0px"
           >
             SPEAKER
           </button>
           <button
             class="btn btn-warning"
-            (click)="changeSectionTo('Pickup');  addAction({type: 'placeNoteAction', scoreType: ScoreType.kSPEAKER_AMPLIFIED});"
+            (click)="changeSectionTo('Pickup');  actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kSPEAKER_AMPLIFIED});"
             style="width: 48%; height: 12vh; margin: 0px 0px 0px 0px"
           >
             SPEAKER AMPLIFIED
@@ -244,21 +246,21 @@
       <button
         *ngIf="autoPhase"
         class="btn btn-success"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kAMP});"
+        (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kAMP});"
       >
         AMP
       </button>
       <button
         *ngIf="autoPhase"
         class="btn btn-warning"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kSPEAKER});"
+        (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kSPEAKER});"
       >
         SPEAKER
       </button>
       <button
         *ngIf="autoPhase && !mobilityCompleted"
         class="btn btn-light"
-        (click)="addAction({type: 'mobilityAction', mobility: true});"
+        (click)="actionHelper.addMobilityAction({mobility: true});"
       >
         Mobility
       </button>
@@ -283,14 +285,14 @@
       <button
         class="btn btn-dark"
         *ngIf="autoPhase"
-        (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+        (click)="autoPhase = false; actionHelper.addEndAutoPhaseAction({});"
       >
         Start Teleop
       </button>
       <button
         *ngIf="!autoPhase"
         class="btn btn-info"
-        (click)="changeSectionTo('Endgame'); addAction({type: 'endTeleopPhase'});"
+        (click)="changeSectionTo('Endgame'); actionHelper.addEndTeleopPhaseAction({});"
       >
         Endgame
       </button>
@@ -298,34 +300,33 @@
   </div>
   <div *ngSwitchCase="'Endgame'" id="Endgame" class="container-fluid">
     <h6 class="text-muted">
-      Last Action: {{actionList[actionList.length - 1].type}}
+      Last Action: {{ActionType[actionList[actionList.length -
+      1].actionTakenType]}}
     </h6>
     <div class="d-grid gap-2">
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
+        (click)="changeSectionTo('Dead'); actionHelper.addRobotDeathAction({robotDead: true});"
       >
         DEAD
       </button>
       <div class="button_row">
         <label>
           <input
-            #park
             type="radio"
-            id="option1"
             name="endgameaction"
-            value="park"
+            [value]="StageType.kPARK"
+            [(ngModel)]="endGameAction"
           />
           Park
         </label>
         <label>
           <input
-            #onStage
             type="radio"
-            id="option2"
             name="endgameaction"
-            value="onStage"
+            [value]="StageType.kON_STAGE"
+            [(ngModel)]="endGameAction"
           />
           On Stage
         </label>
@@ -333,42 +334,32 @@
       <div class="button_row">
         <label>
           <input
-            #harmony
             type="radio"
-            id="option3"
             name="endgameaction"
-            value="harmony"
+            [value]="StageType.kHARMONY"
+            [(ngModel)]="endGameAction"
           />
           Harmony
         </label>
         <label>
           <input
-            #na
             type="radio"
-            id="option2"
             name="endgameaction"
-            value="na"
+            [value]="StageType.kMISSING"
+            [(ngModel)]="endGameAction"
           />
           N/A
         </label>
       </div>
       <label>
-        <input
-          #trapNote
-          type="checkbox"
-          id="trapnote"
-          name="trapnote"
-          value="trapNote"
-        />
+        <input type="checkbox" name="trapnote" [(ngModel)]="noteIsTrapped" />
         Trap Note
       </label>
       <label>
         <input
-          #spotlight
           type="checkbox"
-          id="spotlight"
           name="spotlight"
-          value="spotlight"
+          [(ngModel)]="endGameSpotlight"
         />
         Spotlight
       </label>
@@ -394,7 +385,7 @@
       <button
         *ngIf="!autoPhase"
         class="btn btn-info"
-        (click)="changeSectionTo('Review and Submit');  addPenalties(); addAction({type: 'endMatchAction', stageType: (park.checked ? StageType.kPARK : onStage.checked ? StageType.kON_STAGE : harmony.checked ? StageType.kHARMONY : StageType.kMISSING), trapNote: trapNote.checked, spotlight: spotlight.checked});"
+        (click)="changeSectionTo('Review and Submit');  addPenalties(); actionHelper.addEndMatchAction({stageType: endGameAction, trapNote: noteIsTrapped, spotlight: endGameSpotlight});"
       >
         End Match
       </button>
@@ -424,13 +415,13 @@
       </div>
       <button
         class="btn btn-success"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'robotDeathAction', robotDead: false}); "
+        (click)="changeSectionTo('Pickup'); actionHelper.addRobotDeathAction({robotDead: false}); "
       >
         Revive
       </button>
       <button
         class="btn btn-info"
-        (click)="changeSectionTo('Review and Submit');  addPenalties(); addAction({type: 'endMatchAction', stageType: (park.checked ? StageType.kPARK : onStage.checked ? StageType.kON_STAGE : harmony.checked ? StageType.kHARMONY : StageType.kMISSING), trapNote: trapNote.checked, spotlight: spotlight.checked});"
+        (click)="changeSectionTo('Review and Submit');  addPenalties(); actionHelper.addEndMatchAction({stageType: endGameAction, trapNote: noteIsTrapped, spotlight: endGameSpotlight});"
       >
         End Match
       </button>
@@ -439,35 +430,41 @@
   <div *ngSwitchCase="'Review and Submit'" id="Review" class="container-fluid">
     <div class="row">
       <ul id="review_data">
-        <li
-          *ngFor="let action of actionList"
-          [ngValue]="action"
-          style="display: flex"
-        >
-          <div [ngSwitch]="action.type" style="padding: 0px">
-            <span *ngSwitchCase="'startMatchAction'">
-              Started match at position {{action.position}}
+        <li *ngFor="let action of actionList" style="display: flex">
+          <div [ngSwitch]="action.actionTakenType" style="padding: 0px">
+            <span *ngSwitchCase="ActionType.StartMatchAction">
+              Started match at position {{(action.actionTaken | cast:
+              StartMatchActionT).position}}
             </span>
-            <span *ngSwitchCase="'pickupNoteAction'">Picked up Note</span>
-            <span *ngSwitchCase="'placeNoteAction'">
-              Placed at {{stringifyScoreType(action.scoreType)}}
+            <span *ngSwitchCase="ActionType.PickupNoteAction">
+              Picked up Note
             </span>
-            <span *ngSwitchCase="'endAutoPhase'">Ended auto phase</span>
-            <span *ngSwitchCase="'endMatchAction'">
-              Ended Match; stageType: {{(action.stageType === 0 ? "kON_STAGE" :
-              action.stageType === 1 ? "kPARK" : action.stageType === 2 ?
-              "kHARMONY" : "kMISSING")}}, trapNote: {{action.trapNote}},
-              spotlight: {{action.spotlight}}
+            <span *ngSwitchCase="ActionType.PlaceNoteAction">
+              Placed at {{stringifyScoreType((action.actionTaken | cast:
+              PlaceNoteActionT).scoreType)}}
             </span>
-            <span *ngSwitchCase="'robotDeathAction'">
-              Robot dead: {{action.robotDead}}
+            <span *ngSwitchCase="ActionType.EndAutoPhaseAction">
+              Ended auto phase
             </span>
-            <span *ngSwitchCase="'mobilityAction'">
-              Mobility: {{action.mobility}}
+            <span *ngSwitchCase="ActionType.EndMatchAction">
+              Ended Match; stageType: {{stringifyStageType((action.actionTaken |
+              cast: EndMatchActionT).stageType)}}, trapNote:
+              {{(action.actionTaken | cast: EndMatchActionT).trapNote}},
+              spotlight: {{(action.actionTaken | cast:
+              EndMatchActionT).spotlight}}
             </span>
-            <span *ngSwitchDefault>{{action.type}}</span>
-            <span *ngSwitchCase="'penaltyAction'">
-              Penalties: {{action.penalties}}
+            <span *ngSwitchCase="ActionType.RobotDeathAction">
+              Robot dead: {{(action.actionTaken | cast:
+              RobotDeathActionT).robotDead}}
+            </span>
+            <span *ngSwitchCase="ActionType.MobilityAction">
+              Mobility: {{(action.actionTaken | cast:
+              MobilityActionT).mobility}}
+            </span>
+            <span *ngSwitchDefault>{{action.actionTakenType}}</span>
+            <span *ngSwitchCase="ActionType.PenaltyAction">
+              Penalties: {{(action.actionTaken | cast:
+              PenaltyActionT).penalties}}
             </span>
           </div>
         </li>
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index a02c0a6..e97d92c 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -2,9 +2,13 @@
     "name": "@org_frc971/scouting/www/entry",
     "private": true,
     "dependencies": {
+        "angularx-qrcode": "^16.0.2",
         "pako": "2.1.0",
+        "@angular/forms": "v16-lts",
+        "@angular/platform-browser": "v16-lts",
         "@types/pako": "2.0.3",
-        "@org_frc971/scouting/www/counter_button": "workspace:*",
-        "@angular/forms": "v16-lts"
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
+        "@org_frc971/scouting/www/rpc": "workspace:*",
+        "@org_frc971/scouting/www/pipes": "workspace:*"
     }
 }
diff --git a/scouting/www/index.html b/scouting/www/index.html
index 821acf2..ee2bcb4 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -7,17 +7,20 @@
     <meta name="theme-color" content="#000000" />
     <link rel="manifest" href="/manifest.json" />
     <link
+      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
       rel="stylesheet"
-      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
-      integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD"
+      integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
       crossorigin="anonymous"
     />
     <link
       rel="stylesheet"
-      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
-      integrity="d8824f7067cdfea38afec7e9ffaf072125266824206d69ef1f112d72153a505e"
+      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
     />
-    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
+    <script
+      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+      integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+      crossorigin="anonymous"
+    ></script>
     <script>
       // In order to hook into WASM's "finished loading" event, we interact
       // with the global Module variable.  Maybe some day there'll be a better
diff --git a/scouting/www/match_list/BUILD b/scouting/www/match_list/BUILD
index b2128db..2ff4fee 100644
--- a/scouting/www/match_list/BUILD
+++ b/scouting/www/match_list/BUILD
@@ -9,11 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
-        "//scouting/www/rpc",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
index 8fafdce..08120b3 100644
--- a/scouting/www/match_list/match_list.component.ts
+++ b/scouting/www/match_list/match_list.component.ts
@@ -1,19 +1,23 @@
 import {Component, EventEmitter, OnInit, Output} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
-import {RequestAllMatches} from '../../webserver/requests/messages/request_all_matches_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {RequestAllMatches} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
 import {
   Match,
   RequestAllMatchesResponse,
-} from '../../webserver/requests/messages/request_all_matches_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
 
-import {MatchListRequestor} from '../rpc';
+import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
+
+// TODO(phil): Deduplicate with entry.component.ts.
+const COMP_LEVELS = ['qm', 'ef', 'qf', 'sf', 'f'] as const;
+export type CompLevel = typeof COMP_LEVELS[number];
 
 type TeamInMatch = {
   teamNumber: string;
   matchNumber: number;
   setNumber: number;
-  compLevel: string;
+  compLevel: CompLevel;
 };
 
 @Component({
@@ -23,6 +27,7 @@
 })
 export class MatchListComponent implements OnInit {
   @Output() selectedTeamEvent = new EventEmitter<TeamInMatch>();
+
   progressMessage: string = '';
   errorMessage: string = '';
   matchList: Match[] = [];
@@ -30,6 +35,14 @@
 
   constructor(private readonly matchListRequestor: MatchListRequestor) {}
 
+  // Validates that the specified string is a proper comp level.
+  validateCompLevel(compLevel: string): CompLevel {
+    if (COMP_LEVELS.indexOf(compLevel as any) !== -1) {
+      return compLevel as CompLevel;
+    }
+    throw new Error(`Could not parse "${compLevel}" as a valid comp level.`);
+  }
+
   // Returns true if the match is fully scouted. Returns false otherwise.
   matchIsFullyScouted(match: Match): boolean {
     const scouted = match.dataScouted();
diff --git a/scouting/www/match_list/match_list.ng.html b/scouting/www/match_list/match_list.ng.html
index 0ebbe4c..fe26d09 100644
--- a/scouting/www/match_list/match_list.ng.html
+++ b/scouting/www/match_list/match_list.ng.html
@@ -22,7 +22,7 @@
             teamNumber: team.teamNumber,
             matchNumber: match.matchNumber(),
             setNumber: match.setNumber(),
-            compLevel: match.compLevel()
+            compLevel: validateCompLevel(match.compLevel()),
             })"
         class="match-item"
         [ngClass]="team.color"
diff --git a/scouting/www/match_list/package.json b/scouting/www/match_list/package.json
index 00977c5..77d2dc5 100644
--- a/scouting/www/match_list/package.json
+++ b/scouting/www/match_list/package.json
@@ -2,6 +2,7 @@
     "name": "@org_frc971/scouting/www/match_list",
     "private": true,
     "dependencies": {
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
         "@org_frc971/scouting/www/rpc": "workspace:*",
         "@angular/forms": "v16-lts"
     }
diff --git a/scouting/www/notes/BUILD b/scouting/www/notes/BUILD
index 64e4ae7..59c508e 100644
--- a/scouting/www/notes/BUILD
+++ b/scouting/www/notes/BUILD
@@ -9,12 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_notes_for_team_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_notes_for_team_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_notes_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_notes_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index 431ec71..6263d37 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -1,13 +1,13 @@
 import {Component, HostListener} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
-import {RequestNotesForTeam} from '../../webserver/requests/messages/request_notes_for_team_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {RequestNotesForTeam} from '@org_frc971/scouting/webserver/requests/messages/request_notes_for_team_generated';
 import {
   Note as NoteFb,
   RequestNotesForTeamResponse,
-} from '../../webserver/requests/messages/request_notes_for_team_response_generated';
-import {SubmitNotes} from '../../webserver/requests/messages/submit_notes_generated';
-import {SubmitNotesResponse} from '../../webserver/requests/messages/submit_notes_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_notes_for_team_response_generated';
+import {SubmitNotes} from '@org_frc971/scouting/webserver/requests/messages/submit_notes_generated';
+import {SubmitNotesResponse} from '@org_frc971/scouting/webserver/requests/messages/submit_notes_response_generated';
 
 /*
 For new games, the keywords being used will likely need to be updated.
diff --git a/scouting/www/notes/package.json b/scouting/www/notes/package.json
index f1ad3ae..5961469 100644
--- a/scouting/www/notes/package.json
+++ b/scouting/www/notes/package.json
@@ -2,6 +2,7 @@
     "name": "@org_frc971/scouting/www/notes",
     "private": true,
     "dependencies": {
-        "@angular/forms": "v16-lts"
+        "@angular/forms": "v16-lts",
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*"
     }
 }
diff --git a/scouting/www/package.json b/scouting/www/package.json
index 3ab0035..da5f279 100644
--- a/scouting/www/package.json
+++ b/scouting/www/package.json
@@ -1,4 +1,16 @@
 {
     "private": true,
-    "dependencies": {}
+    "dependencies": {
+        "@angular/animations": "v16-lts",
+        "@angular/service-worker": "v16-lts",
+        "@org_frc971/scouting/www/driver_ranking": "workspace:*",
+        "@org_frc971/scouting/www/entry": "workspace:*",
+        "@org_frc971/scouting/www/match_list": "workspace:*",
+        "@org_frc971/scouting/www/notes": "workspace:*",
+        "@org_frc971/scouting/www/pipes": "workspace:*",
+        "@org_frc971/scouting/www/pit_scouting": "workspace:*",
+        "@org_frc971/scouting/www/scan": "workspace:*",
+        "@org_frc971/scouting/www/shift_schedule": "workspace:*",
+        "@org_frc971/scouting/www/view": "workspace:*"
+    }
 }
diff --git a/scouting/www/pipes/BUILD b/scouting/www/pipes/BUILD
new file mode 100644
index 0000000..680eb09
--- /dev/null
+++ b/scouting/www/pipes/BUILD
@@ -0,0 +1,12 @@
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
+    name = "pipes",
+    extra_srcs = [
+        "public-api.ts",
+    ],
+    generate_public_api = False,
+)
diff --git a/scouting/www/pipes/cast.ts b/scouting/www/pipes/cast.ts
new file mode 100644
index 0000000..1bb24e8
--- /dev/null
+++ b/scouting/www/pipes/cast.ts
@@ -0,0 +1,15 @@
+import {Pipe, PipeTransform} from '@angular/core';
+
+@Pipe({name: 'cast'})
+export class CastPipe implements PipeTransform {
+  /**
+   * Cast (S: SuperType) into (T: Type) using @Generics.
+   * @param value (S: SuperType) obtained from input type.
+   * @optional @param type (T CastingType)
+   * type?: { new (): T }
+   * type?: new () => T
+   */
+  transform<S, T extends S>(value: S, type?: new () => T): T {
+    return <T>value;
+  }
+}
diff --git a/scouting/www/pipes/package.json b/scouting/www/pipes/package.json
new file mode 100644
index 0000000..b4ce582
--- /dev/null
+++ b/scouting/www/pipes/package.json
@@ -0,0 +1,4 @@
+{
+    "name": "@org_frc971/scouting/www/pipes",
+    "private": true
+}
diff --git a/scouting/www/pipes/pipes.module.ts b/scouting/www/pipes/pipes.module.ts
new file mode 100644
index 0000000..b7dd4c4
--- /dev/null
+++ b/scouting/www/pipes/pipes.module.ts
@@ -0,0 +1,12 @@
+import {NgModule} from '@angular/core';
+import {CastPipe} from './cast';
+
+// Export types needed for the public API.
+export {CastPipe};
+
+@NgModule({
+  declarations: [CastPipe],
+  exports: [CastPipe],
+  imports: [],
+})
+export class PipeModule {}
diff --git a/scouting/www/pipes/public-api.ts b/scouting/www/pipes/public-api.ts
new file mode 100644
index 0000000..77a5641
--- /dev/null
+++ b/scouting/www/pipes/public-api.ts
@@ -0,0 +1 @@
+export * from './pipes.module';
diff --git a/scouting/www/pit_scouting/BUILD b/scouting/www/pit_scouting/BUILD
index 740dee1..cfaf5d4 100644
--- a/scouting/www/pit_scouting/BUILD
+++ b/scouting/www/pit_scouting/BUILD
@@ -9,10 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_pit_image_response_ts_fbs",
-        "//scouting/webserver/requests/messages:submit_pit_image_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/pit_scouting/package.json b/scouting/www/pit_scouting/package.json
index f41150f..2eb2783 100644
--- a/scouting/www/pit_scouting/package.json
+++ b/scouting/www/pit_scouting/package.json
@@ -2,6 +2,7 @@
 	"name": "@org_frc971/scouting/www/pit_scouting",
 	"private": true,
 	"dependencies": {
-			"@angular/forms": "v16-lts"
+			"@angular/forms": "v16-lts",
+			"@org_frc971/scouting/webserver/requests/messages": "workspace:*"
 	}
 }
diff --git a/scouting/www/pit_scouting/pit_scouting.component.ts b/scouting/www/pit_scouting/pit_scouting.component.ts
index 7bb884c..294eca2 100644
--- a/scouting/www/pit_scouting/pit_scouting.component.ts
+++ b/scouting/www/pit_scouting/pit_scouting.component.ts
@@ -6,8 +6,8 @@
   ViewChildren,
 } from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
-import {SubmitPitImage} from '../../webserver/requests/messages/submit_pit_image_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {SubmitPitImage} from '@org_frc971/scouting/webserver/requests/messages/submit_pit_image_generated';
 
 type Section = 'TeamSelection' | 'Data';
 
diff --git a/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
index 592735c..66cba8d 100644
--- a/scouting/www/rpc/BUILD
+++ b/scouting/www/rpc/BUILD
@@ -10,18 +10,7 @@
     ],
     generate_public_api = False,
     deps = [
-        "//:node_modules/dexie",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_2024_data_scouting_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_2024_data_scouting_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_driver_rankings_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_driver_rankings_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_notes_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_notes_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_pit_images_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_pit_images_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index 0a812ce..97ed0b0 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -1,11 +1,11 @@
 import {Injectable} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
-import {RequestAllMatches} from '../../webserver/requests/messages/request_all_matches_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {RequestAllMatches} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
 import {
   Match,
   RequestAllMatchesResponse,
-} from '../../webserver/requests/messages/request_all_matches_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
 import {db, MatchListData} from './db';
 const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
 @Injectable({providedIn: 'root'})
diff --git a/scouting/www/rpc/package.json b/scouting/www/rpc/package.json
index 6d1369a..bc9a662 100644
--- a/scouting/www/rpc/package.json
+++ b/scouting/www/rpc/package.json
@@ -1,4 +1,8 @@
 {
     "name": "@org_frc971/scouting/www/rpc",
-    "private": true
+    "private": true,
+    "dependencies": {
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
+        "dexie": "^3.2.5"
+    }
 }
diff --git a/scouting/www/rpc/view_data_requestor.ts b/scouting/www/rpc/view_data_requestor.ts
index 74fc212..606eef1 100644
--- a/scouting/www/rpc/view_data_requestor.ts
+++ b/scouting/www/rpc/view_data_requestor.ts
@@ -1,26 +1,26 @@
 import {Injectable} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
-import {RequestAllNotes} from '../../webserver/requests/messages/request_all_notes_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {RequestAllNotes} from '@org_frc971/scouting/webserver/requests/messages/request_all_notes_generated';
 import {
   Note,
   RequestAllNotesResponse,
-} from '../../webserver/requests/messages/request_all_notes_response_generated';
-import {RequestAllDriverRankings} from '../../webserver/requests/messages/request_all_driver_rankings_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_notes_response_generated';
+import {RequestAllDriverRankings} from '@org_frc971/scouting/webserver/requests/messages/request_all_driver_rankings_generated';
 import {
   Ranking,
   RequestAllDriverRankingsResponse,
-} from '../../webserver/requests/messages/request_all_driver_rankings_response_generated';
-import {Request2024DataScouting} from '../../webserver/requests/messages/request_2024_data_scouting_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_driver_rankings_response_generated';
+import {Request2024DataScouting} from '@org_frc971/scouting/webserver/requests/messages/request_2024_data_scouting_generated';
 import {
   PitImage,
   RequestAllPitImagesResponse,
-} from '../../webserver/requests/messages/request_all_pit_images_response_generated';
-import {RequestAllPitImages} from '../../webserver/requests/messages/request_all_pit_images_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_pit_images_response_generated';
+import {RequestAllPitImages} from '@org_frc971/scouting/webserver/requests/messages/request_all_pit_images_generated';
 import {
   Stats2024,
   Request2024DataScoutingResponse,
-} from '../../webserver/requests/messages/request_2024_data_scouting_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_2024_data_scouting_response_generated';
 
 @Injectable({providedIn: 'root'})
 export class ViewDataRequestor {
diff --git a/scouting/www/scan/BUILD b/scouting/www/scan/BUILD
index 88b2822..ee44b1f 100644
--- a/scouting/www/scan/BUILD
+++ b/scouting/www/scan/BUILD
@@ -9,10 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        ":node_modules/@types/pako",
-        ":node_modules/pako",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/scan/package.json b/scouting/www/scan/package.json
index a5950c3..7fa3990 100644
--- a/scouting/www/scan/package.json
+++ b/scouting/www/scan/package.json
@@ -4,6 +4,7 @@
     "dependencies": {
         "pako": "2.1.0",
         "@types/pako": "2.0.3",
-        "@angular/forms": "v16-lts"
+        "@angular/forms": "v16-lts",
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*"
     }
 }
diff --git a/scouting/www/scan/scan.component.ts b/scouting/www/scan/scan.component.ts
index 98a7b61..4a1c9c8 100644
--- a/scouting/www/scan/scan.component.ts
+++ b/scouting/www/scan/scan.component.ts
@@ -1,5 +1,5 @@
 import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
 import {Builder, ByteBuffer} from 'flatbuffers';
 import * as pako from 'pako';
 
diff --git a/scouting/www/shift_schedule/BUILD b/scouting/www/shift_schedule/BUILD
index 3afb557..747ff98 100644
--- a/scouting/www/shift_schedule/BUILD
+++ b/scouting/www/shift_schedule/BUILD
@@ -9,10 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/shift_schedule/package.json b/scouting/www/shift_schedule/package.json
index f2d6d7e..a2ce384 100644
--- a/scouting/www/shift_schedule/package.json
+++ b/scouting/www/shift_schedule/package.json
@@ -2,6 +2,7 @@
     "name": "@org_frc971/scouting/www/shift_schedule",
     "private": true,
     "dependencies": {
-        "@angular/forms": "v16-lts"
+        "@angular/forms": "v16-lts",
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*"
     }
 }
diff --git a/scouting/www/shift_schedule/shift_schedule.component.ts b/scouting/www/shift_schedule/shift_schedule.component.ts
index de0b2e1..316830c 100644
--- a/scouting/www/shift_schedule/shift_schedule.component.ts
+++ b/scouting/www/shift_schedule/shift_schedule.component.ts
@@ -1,6 +1,6 @@
 import {Component, OnInit} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
 
 @Component({
   selector: 'shift-schedule',
diff --git a/scouting/www/view/BUILD b/scouting/www/view/BUILD
index 738ea00..d967882 100644
--- a/scouting/www/view/BUILD
+++ b/scouting/www/view/BUILD
@@ -9,17 +9,7 @@
         "//scouting/www:app_common_css",
     ],
     deps = [
-        ":node_modules/@angular/forms",
-        "//scouting/webserver/requests/messages:delete_2024_data_scouting_response_ts_fbs",
-        "//scouting/webserver/requests/messages:delete_2024_data_scouting_ts_fbs",
-        "//scouting/webserver/requests/messages:error_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_2024_data_scouting_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_2024_data_scouting_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_driver_rankings_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_driver_rankings_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_notes_response_ts_fbs",
-        "//scouting/webserver/requests/messages:request_all_notes_ts_fbs",
-        "//scouting/www/rpc",
-        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        ":node_modules",
+        "//:node_modules/flatbuffers",
     ],
 )
diff --git a/scouting/www/view/package.json b/scouting/www/view/package.json
index d214f6f..efcbd8a 100644
--- a/scouting/www/view/package.json
+++ b/scouting/www/view/package.json
@@ -2,6 +2,8 @@
     "name": "@org_frc971/scouting/www/view",
     "private": true,
     "dependencies": {
-      "@angular/forms": "v16-lts"
+      "@angular/forms": "v16-lts",
+      "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
+      "@org_frc971/scouting/www/rpc": "workspace:*"
     }
 }
diff --git a/scouting/www/view/view.component.ts b/scouting/www/view/view.component.ts
index ea9a61f..8aa3784 100644
--- a/scouting/www/view/view.component.ts
+++ b/scouting/www/view/view.component.ts
@@ -1,28 +1,31 @@
 import {Component, OnInit} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
-import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
 import {
   Ranking,
   RequestAllDriverRankingsResponse,
-} from '../../webserver/requests/messages/request_all_driver_rankings_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_driver_rankings_response_generated';
 import {
   Stats2024,
   Request2024DataScoutingResponse,
-} from '../../webserver/requests/messages/request_2024_data_scouting_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_2024_data_scouting_response_generated';
 
 import {
   PitImage,
   RequestAllPitImagesResponse,
-} from '../../webserver/requests/messages/request_all_pit_images_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_pit_images_response_generated';
 
 import {
   Note,
   RequestAllNotesResponse,
-} from '../../webserver/requests/messages/request_all_notes_response_generated';
-import {Delete2024DataScouting} from '../../webserver/requests/messages/delete_2024_data_scouting_generated';
-import {Delete2024DataScoutingResponse} from '../../webserver/requests/messages/delete_2024_data_scouting_response_generated';
+} from '@org_frc971/scouting/webserver/requests/messages/request_all_notes_response_generated';
+import {Delete2024DataScouting} from '@org_frc971/scouting/webserver/requests/messages/delete_2024_data_scouting_generated';
+import {Delete2024DataScoutingResponse} from '@org_frc971/scouting/webserver/requests/messages/delete_2024_data_scouting_response_generated';
 
-import {ViewDataRequestor} from '../rpc';
+import {
+  MatchListRequestor,
+  ViewDataRequestor,
+} from '@org_frc971/scouting/www/rpc';
 
 type Source = 'Notes' | 'Stats2024' | 'PitImages' | 'DriverRanking';
 
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index c2468f8..bb15c2c 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -1,4 +1,5 @@
 load("@aspect_rules_js//js:providers.bzl", "JsInfo")
+load("@aspect_rules_js//npm:defs.bzl", "npm_package")
 load("@bazel_skylib//rules:write_file.bzl", "write_file")
 load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory")
 load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
@@ -251,6 +252,7 @@
     srcs = native.glob(
         ["**/*.ts", "**/*.css", "**/*.html"],
         exclude = test_spec_srcs + [
+            "**/*.jinja2.*",
             "public-api.ts",
         ],
     ) + extra_srcs
@@ -276,13 +278,20 @@
         srcs.append(":_public_api")
 
     ng_project(
-        name = name,
+        name = "_lib",
         srcs = srcs + [":_index"],
         deps = deps + PACKAGE_DEPS,
-        visibility = visibility,
+        visibility = ["//visibility:private"],
         **kwargs
     )
 
+    npm_package(
+        name = name,
+        srcs = ["package.json", ":_lib"],
+        include_runfiles = False,
+        visibility = visibility,
+    )
+
 def rollup_bundle(name, entry_point, node_modules = "//:node_modules", deps = [], visibility = None, **kwargs):
     """Calls the upstream rollup_bundle() and exposes a .min.js file.
 
diff --git a/tools/lint/prettier.sh b/tools/lint/prettier.sh
index 0198a71..ef4f5ca 100755
--- a/tools/lint/prettier.sh
+++ b/tools/lint/prettier.sh
@@ -28,6 +28,7 @@
 # TODO(phil): Support more than just //scouting.
 web_files=($(git ls-tree --name-only --full-tree -r @ \
     | grep '^scouting/' \
+    | grep -v '\.jinja2\.' \
     | (grep \
         -e '\.ts$' \
         -e '\.js$' \
diff --git a/tsconfig.json b/tsconfig.json
index bd23965..3999b5c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,5 +17,10 @@
   },
   "bazelOptions": {
     "workspaceName": "971-Robot-Code"
+  },
+  "angularCompilerOptions": {
+    "strictInjectionParameters": true,
+    "strictInputAccessModifiers": true,
+    "strictTemplates": true
   }
 }
diff --git a/y2024/BUILD b/y2024/BUILD
index 7bbf64f..7568050 100644
--- a/y2024/BUILD
+++ b/y2024/BUILD
@@ -152,7 +152,7 @@
         "//aos/network:message_bridge_client_fbs",
         "//aos/network:message_bridge_server_fbs",
         "//frc971/wpilib:pdp_values_fbs",
-        #y2019 stuff shouldn't be here (e.g. target selector)
+        # y2019 stuff shouldn't be here (e.g. target selector)
         "//y2024/constants:constants_fbs",
         "//aos/network:timestamp_fbs",
         "//y2024/control_loops/superstructure:superstructure_goal_fbs",
@@ -277,6 +277,7 @@
         "//third_party:phoenix6",
         "//third_party:wpilib",
         "//y2024/constants:constants_fbs",
+        "//y2024/control_loops/superstructure:led_indicator_lib",
         "//y2024/control_loops/superstructure:superstructure_can_position_fbs",
         "//y2024/control_loops/superstructure:superstructure_output_fbs",
         "//y2024/control_loops/superstructure:superstructure_position_fbs",
diff --git a/y2024/constants/7971.json b/y2024/constants/7971.json
index 3beaae0..2fe58cf 100644
--- a/y2024/constants/7971.json
+++ b/y2024/constants/7971.json
@@ -71,7 +71,9 @@
       ) %}
       "zeroing_constants": {{ extend_zero | tojson(indent=2)}},
       "potentiometer_offset": 0.0
-    }
+    },
+    "disable_extend": false,
+    "disable_climber": false
   },
   {% include 'y2024/constants/common.json' %}
 }
diff --git a/y2024/constants/971.json b/y2024/constants/971.json
index 974fb60..c146417 100644
--- a/y2024/constants/971.json
+++ b/y2024/constants/971.json
@@ -23,7 +23,7 @@
   "robot": {
     {% set _ = intake_pivot_zero.update(
       {
-          "measured_absolute_position" : 3.49222521810232
+          "measured_absolute_position" : 3.229
       }
     ) %}
     "intake_constants":  {{ intake_pivot_zero | tojson(indent=2)}},
@@ -43,12 +43,12 @@
           }
       ) %}
       "zeroing_constants": {{ catapult_zero | tojson(indent=2)}},
-      "potentiometer_offset": {{ 9.41595277209342 }}
+      "potentiometer_offset": {{ 9.41595277209342 - 1.59041961316453 + 0.478015209219659 }}
     },
     "altitude_constants": {
       {% set _ = altitude_zero.update(
           {
-              "measured_absolute_position" : 0.1877
+              "measured_absolute_position" : 0.2135
           }
       ) %}
       "zeroing_constants": {{ altitude_zero | tojson(indent=2)}},
@@ -57,11 +57,11 @@
     "turret_constants": {
       {% set _ = turret_zero.update(
           {
-              "measured_absolute_position" : 0.961143535321169
+              "measured_absolute_position" : 0.2077
           }
       ) %}
       "zeroing_constants": {{ turret_zero | tojson(indent=2)}},
-      "potentiometer_offset": {{ -6.47164779835404 - 0.0711209027239817 + 1.0576004531907 }}
+      "potentiometer_offset": {{ -6.47164779835404 - 0.0711209027239817 + 1.0576004531907 - 0.343 }}
     },
     "extend_constants": {
       {% set _ = extend_zero.update(
@@ -71,7 +71,9 @@
       ) %}
       "zeroing_constants": {{ extend_zero | tojson(indent=2)}},
       "potentiometer_offset": {{ -0.2574404033256 + 0.0170793439542 - 0.177097393974999 + 0.3473623911879  - 0.1577}}
-    }
+    },
+    "disable_extend": false,
+    "disable_climber": true
   },
   {% include 'y2024/constants/common.json' %}
 }
diff --git a/y2024/constants/9971.json b/y2024/constants/9971.json
index ec59033..23cad58 100644
--- a/y2024/constants/9971.json
+++ b/y2024/constants/9971.json
@@ -62,7 +62,9 @@
       ) %}
       "zeroing_constants": {{ extend_zero | tojson(indent=2)}},
       "potentiometer_offset": 0.0
-    }
+    },
+    "disable_extend": false,
+    "disable_climber": false
   },
   {% include 'y2024/constants/common.json' %}
 }
diff --git a/y2024/constants/common.json b/y2024/constants/common.json
index 65fe20e..52fdb74 100644
--- a/y2024/constants/common.json
+++ b/y2024/constants/common.json
@@ -7,43 +7,50 @@
         "distance_from_goal": 0.7,
         "shot_params": {
             "shot_altitude_angle": 0.85,
-            "shot_speed_over_ground": 8.0
+            "shot_speed_over_ground": 16.0
         }
     },
     {
       "distance_from_goal": 1.24,
       "shot_params": {
           "shot_altitude_angle": 0.85,
-          "shot_speed_over_ground": 8.0
+          "shot_speed_over_ground": 16.0
       }
     },
     {
       "distance_from_goal": 1.904,
       "shot_params": {
           "shot_altitude_angle": 0.73,
-          "shot_speed_over_ground": 8.0
+          "shot_speed_over_ground": 16.0
       }
     },
     // 2.2 -> high.
     {
       "distance_from_goal": 2.744,
       "shot_params": {
-          "shot_altitude_angle": 0.62,
-          "shot_speed_over_ground": 8.0
+          "shot_altitude_angle": 0.61,
+          "shot_speed_over_ground": 16.0
       }
     },
     {
       "distance_from_goal": 3.274,
       "shot_params": {
-          "shot_altitude_angle": 0.58,
-          "shot_speed_over_ground": 8.0
+          "shot_altitude_angle": 0.55,
+          "shot_speed_over_ground": 16.0
       }
     },
     {
       "distance_from_goal": 4.00,
       "shot_params": {
-          "shot_altitude_angle": 0.54,
-          "shot_speed_over_ground": 8.0
+          "shot_altitude_angle": 0.515,
+          "shot_speed_over_ground": 16.0
+      }
+    },
+    {
+      "distance_from_goal": 4.68,
+      "shot_params": {
+          "shot_altitude_angle": 0.51,
+          "shot_speed_over_ground": 16.0
       }
     }
   ],
@@ -82,7 +89,7 @@
     "intake_pivot_stator_current_limit": 100,
     "intake_roller_supply_current_limit": 20,
     "intake_roller_stator_current_limit": 100,
-    "transfer_roller_supply_current_limit": 20,
+    "transfer_roller_supply_current_limit": 40,
     "transfer_roller_stator_current_limit": 50,
     "drivetrain_supply_current_limit": 50,
     "drivetrain_stator_current_limit": 200,
@@ -104,7 +111,7 @@
     "retention_roller_supply_current_limit": 10
   },
   "transfer_roller_voltages": {
-    "transfer_in": 9.0,
+    "transfer_in": 11.0,
     "transfer_out": -4.0,
     "extend_moving": 4.0
   },
@@ -248,7 +255,7 @@
   },
   // TODO(Filip): Update the speaker and amp shooter setpoints
   "shooter_speaker_set_point": {
-    "turret_position": 0.22,
+    "turret_position": 0.13,
     "altitude_position": 0.85,
     "shot_velocity": 0.0
   },
@@ -269,6 +276,5 @@
   "ignore_targets": {
     "red": [1, 2, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16],
     "blue": [1, 2, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16]
-  },
-  "disable_extend": false
+  }
 }
diff --git a/y2024/constants/constants.fbs b/y2024/constants/constants.fbs
index 1554f4c..99d49a4 100644
--- a/y2024/constants/constants.fbs
+++ b/y2024/constants/constants.fbs
@@ -132,6 +132,8 @@
   altitude_constants:PotAndAbsEncoderConstants (id: 3);
   turret_constants:PotAndAbsEncoderConstants (id: 4);
   extend_constants:PotAndAbsEncoderConstants (id: 5);
+  disable_extend:bool (id: 6);
+  disable_climber:bool (id: 7);
 }
 
 table ShooterSetPoint {
@@ -197,7 +199,6 @@
   altitude_avoid_extend_collision_position: double (id: 28);
   autonomous_mode:AutonomousMode (id: 26);
   ignore_targets:IgnoreTargets (id: 27);
-  disable_extend:bool (id: 29);
 }
 
 table Constants {
diff --git a/y2024/constants/test_data/test_team.json b/y2024/constants/test_data/test_team.json
index b717224..37d9de1 100644
--- a/y2024/constants/test_data/test_team.json
+++ b/y2024/constants/test_data/test_team.json
@@ -71,7 +71,9 @@
       ) %}
       "zeroing_constants": {{ extend_zero | tojson(indent=2)}},
       "potentiometer_offset": 0.0
-    }
+    },
+    "disable_extend": false,
+    "disable_climber": false
   },
   {% include 'y2024/constants/common.json' %}
 }
diff --git a/y2024/control_loops/drivetrain/drivetrain_config.jinja2.json b/y2024/control_loops/drivetrain/drivetrain_config.jinja2.json
index 194b123..1127d6a 100644
--- a/y2024/control_loops/drivetrain/drivetrain_config.jinja2.json
+++ b/y2024/control_loops/drivetrain/drivetrain_config.jinja2.json
@@ -10,7 +10,7 @@
   "quickturn_wheel_multiplier": 1.2,
   "wheel_multiplier": 1.2,
   "pistol_grip_shift_enables_line_follow": false,
-  "imu_transform":{
+  "imu_transform": {
     "rows": 3,
     "cols": 3,
     "data": [1, 0, 0, 0, 1, 0, 0, 0, 1]
@@ -22,5 +22,21 @@
   },
   "top_button_use": "kNone",
   "second_button_use": "kTurn1",
-  "bottom_button_use": "kControlLoopDriving"
+  "bottom_button_use": "kControlLoopDriving",
+  "spline_follower_config": {
+    "q": {
+      "rows": 5,
+      "cols": 5,
+      "data": [3600, 0,    0,    0,  0,
+               0,    3600, 0,    0,  0,
+               0,    0,    1600, 0,  0,
+               0,    0,    0,    16, 0,
+               0,    0,    0,    0,  16]
+    },
+    "r": {
+      "rows": 2,
+      "cols": 2,
+      "data": [5, 0, 0, 5]
+    }
+  }
 }
diff --git a/y2024/control_loops/python/catapult.py b/y2024/control_loops/python/catapult.py
index 406f56e..6f2e9a5 100644
--- a/y2024/control_loops/python/catapult.py
+++ b/y2024/control_loops/python/catapult.py
@@ -25,14 +25,14 @@
     return motor
 
 
-kCatapultWithGamePiece = angular_system.AngularSystemParams(
+kCatapultWithoutGamePiece = angular_system.AngularSystemParams(
     name='Catapult',
     # Add the battery series resistance to make it better match.
     motor=AddResistance(control_loop.NMotor(control_loop.KrakenFOC(), 2),
                         0.00),
     G=(14.0 / 60.0) * (12.0 / 24.0),
-    # 208.7328 in^2 lb
-    J=0.065 + 0.04,
+    # 135.2928 in^2 lb
+    J=0.06,
     q_pos=0.80,
     q_vel=15.0,
     kalman_q_pos=0.12,
@@ -43,14 +43,32 @@
     delayed_u=1,
     dt=0.005)
 
-kCatapultWithoutGamePiece = angular_system.AngularSystemParams(
-    name='Catapult',
+kCatapultWithGamePiece = angular_system.AngularSystemParams(
+    name='CatapultWithPiece',
+    # Add the battery series resistance to make it better match.
+    motor=AddResistance(control_loop.NMotor(control_loop.KrakenFOC(), 2),
+                        0.00),
+    G=(14.0 / 60.0) * (12.0 / 24.0),
+    # 208.7328 in^2 lb
+    J=0.065 + 0.06,
+    q_pos=0.80,
+    q_vel=15.0,
+    kalman_q_pos=0.12,
+    kalman_q_vel=2.0,
+    kalman_q_voltage=0.7,
+    kalman_r_position=0.05,
+    radius=12 * 0.0254,
+    delayed_u=1,
+    dt=0.005)
+
+kCatapultWithoutGamePieceDecel = angular_system.AngularSystemParams(
+    name='CatapultWithoutPieceDecel',
     # Add the battery series resistance to make it better match.
     motor=AddResistance(control_loop.NMotor(control_loop.KrakenFOC(), 2),
                         0.00),
     G=(14.0 / 60.0) * (12.0 / 24.0),
     # 135.2928 in^2 lb
-    J=0.06,
+    J=0.04,
     q_pos=0.80,
     q_vel=15.0,
     kalman_q_pos=0.12,
@@ -76,9 +94,10 @@
         )
     else:
         namespaces = ['y2024', 'control_loops', 'superstructure', 'catapult']
-        angular_system.WriteAngularSystem(
-            [kCatapultWithoutGamePiece, kCatapultWithGamePiece], argv[1:4],
-            argv[4:7], namespaces)
+        angular_system.WriteAngularSystem([
+            kCatapultWithoutGamePiece, kCatapultWithGamePiece,
+            kCatapultWithoutGamePieceDecel
+        ], argv[1:4], argv[4:7], namespaces)
 
 
 if __name__ == '__main__':
diff --git a/y2024/control_loops/superstructure/BUILD b/y2024/control_loops/superstructure/BUILD
index 6150caa..946579b 100644
--- a/y2024/control_loops/superstructure/BUILD
+++ b/y2024/control_loops/superstructure/BUILD
@@ -78,8 +78,7 @@
     hdrs = [
         "superstructure.h",
     ],
-    data = [
-    ],
+    data = [],
     deps = [
         ":collision_avoidance_lib",
         ":shooter",
@@ -242,3 +241,31 @@
         "//aos/network/www:proxy",
     ],
 )
+
+cc_library(
+    name = "led_indicator_lib",
+    srcs = ["led_indicator.cc"],
+    hdrs = ["led_indicator.h"],
+    data = [
+        "@ctre_phoenix_api_cpp_athena//:shared_libraries",
+        "@ctre_phoenix_cci_athena//:shared_libraries",
+    ],
+    target_compatible_with = ["//tools/platforms/hardware:roborio"],
+    deps = [
+        ":superstructure_output_fbs",
+        ":superstructure_position_fbs",
+        ":superstructure_status_fbs",
+        "//aos/events:event_loop",
+        "//aos/network:message_bridge_client_fbs",
+        "//aos/network:message_bridge_server_fbs",
+        "//frc971/control_loops:control_loop",
+        "//frc971/control_loops:control_loops_fbs",
+        "//frc971/control_loops:profiled_subsystem_fbs",
+        "//frc971/control_loops/drivetrain:drivetrain_output_fbs",
+        "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
+        "//frc971/control_loops/drivetrain/localization:localizer_output_fbs",
+        "//frc971/queues:gyro_fbs",
+        "//third_party:phoenix",
+        "//third_party:wpilib",
+    ],
+)
diff --git a/y2024/control_loops/superstructure/aiming.cc b/y2024/control_loops/superstructure/aiming.cc
index a21c09d..bf68527 100644
--- a/y2024/control_loops/superstructure/aiming.cc
+++ b/y2024/control_loops/superstructure/aiming.cc
@@ -8,9 +8,6 @@
 using frc971::control_loops::aiming::ShotMode;
 using y2024::control_loops::superstructure::Aimer;
 
-// When the turret is at 0 the note will be leaving the robot at PI.
-static constexpr double kTurretZeroOffset = M_PI - 0.22;
-
 Aimer::Aimer(aos::EventLoop *event_loop,
              const y2024::Constants *robot_constants)
     : event_loop_(event_loop),
@@ -76,7 +73,7 @@
                      robot_constants_->common()->turret()->range()),
                  interpolation_table_.Get(current_goal_.target_distance)
                      .shot_speed_over_ground,
-                 /*wrap_mode=*/0.15, kTurretZeroOffset},
+                 /*wrap_mode=*/0.15, M_PI - kTurretZeroOffset},
       RobotState{
           robot_pose, {xdot, ydot}, linear_angular(1), current_goal_.position});
 
diff --git a/y2024/control_loops/superstructure/aiming.h b/y2024/control_loops/superstructure/aiming.h
index 9bec187..97e319d 100644
--- a/y2024/control_loops/superstructure/aiming.h
+++ b/y2024/control_loops/superstructure/aiming.h
@@ -17,6 +17,9 @@
 
 class Aimer {
  public:
+  // When the turret is at 0 the note will be leaving the robot at PI.
+  static constexpr double kTurretZeroOffset = 0.13;
+
   Aimer(aos::EventLoop *event_loop, const Constants *robot_constants);
 
   void Update(
diff --git a/y2024/control_loops/superstructure/led_indicator.cc b/y2024/control_loops/superstructure/led_indicator.cc
new file mode 100644
index 0000000..3927548
--- /dev/null
+++ b/y2024/control_loops/superstructure/led_indicator.cc
@@ -0,0 +1,165 @@
+#include "y2024/control_loops/superstructure/led_indicator.h"
+
+namespace led = ctre::phoenix::led;
+namespace chrono = std::chrono;
+
+namespace y2024::control_loops::superstructure {
+
+LedIndicator::LedIndicator(aos::EventLoop *event_loop)
+    : event_loop_(event_loop),
+      drivetrain_output_fetcher_(
+          event_loop_->MakeFetcher<frc971::control_loops::drivetrain::Output>(
+              "/drivetrain")),
+      superstructure_status_fetcher_(
+          event_loop_->MakeFetcher<Status>("/superstructure")),
+      server_statistics_fetcher_(
+          event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
+              "/roborio/aos")),
+      client_statistics_fetcher_(
+          event_loop_->MakeFetcher<aos::message_bridge::ClientStatistics>(
+              "/roborio/aos")),
+      localizer_output_fetcher_(
+          event_loop_->MakeFetcher<frc971::controls::LocalizerOutput>(
+              "/localizer")),
+      gyro_reading_fetcher_(
+          event_loop_->MakeFetcher<frc971::sensors::GyroReading>(
+              "/drivetrain")),
+      drivetrain_status_fetcher_(
+          event_loop_->MakeFetcher<frc971::control_loops::drivetrain::Status>(
+              "/drivetrain")) {
+  led::CANdleConfiguration config;
+  config.statusLedOffWhenActive = true;
+  config.disableWhenLOS = false;
+  config.brightnessScalar = 1.0;
+  candle_.ConfigAllSettings(config, 0);
+
+  event_loop_->AddPhasedLoop([this](int) { DecideColor(); },
+                             chrono::milliseconds(20));
+  event_loop_->OnRun(
+      [this]() { startup_time_ = event_loop_->monotonic_now(); });
+}
+
+// This method will be called once per scheduler run
+void LedIndicator::DisplayLed(uint8_t r, uint8_t g, uint8_t b) {
+  candle_.SetLEDs(static_cast<int>(r), static_cast<int>(g),
+                  static_cast<int>(b));
+}
+
+bool DisconnectedIMUPiServer(
+    const aos::message_bridge::ServerStatistics &server_statistics) {
+  for (const auto *node_status : *server_statistics.connections()) {
+    if (node_status->state() == aos::message_bridge::State::DISCONNECTED) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+bool DisconnectedIMUPiClient(
+    const aos::message_bridge::ClientStatistics &client_statistics) {
+  for (const auto *node_status : *client_statistics.connections()) {
+    if (node_status->state() == aos::message_bridge::State::DISCONNECTED) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+bool OrinsDisconnected(
+    const frc971::controls::LocalizerOutput &localizer_output) {
+  if (!localizer_output.all_pis_connected()) {
+    return true;
+  } else {
+    return false;
+  }
+}
+
+void LedIndicator::DecideColor() {
+  superstructure_status_fetcher_.Fetch();
+  server_statistics_fetcher_.Fetch();
+  drivetrain_output_fetcher_.Fetch();
+  drivetrain_status_fetcher_.Fetch();
+  client_statistics_fetcher_.Fetch();
+  gyro_reading_fetcher_.Fetch();
+  localizer_output_fetcher_.Fetch();
+
+  if (localizer_output_fetcher_.get()) {
+    if (localizer_output_fetcher_->image_accepted_count() !=
+        last_accepted_count_) {
+      last_accepted_count_ = localizer_output_fetcher_->image_accepted_count();
+      last_accepted_time_ = event_loop_->monotonic_now();
+    }
+  }
+
+  // Estopped: Red
+  if (superstructure_status_fetcher_.get() &&
+      superstructure_status_fetcher_->estopped()) {
+    DisplayLed(255, 0, 0);
+    return;
+  }
+
+  // If the imu gyro readings are not being sent/updated recently.  Only do this
+  // after we've been on for a bit.
+  if (event_loop_->context().monotonic_event_time >
+          startup_time_ + chrono::seconds(5) &&
+      (!gyro_reading_fetcher_.get() ||
+       gyro_reading_fetcher_.context().monotonic_event_time +
+               frc971::controls::kLoopFrequency * 10 <
+           event_loop_->monotonic_now() ||
+       !gyro_reading_fetcher_->has_velocity())) {
+    // Flash red/white
+    if (flash_counter_.Flash()) {
+      DisplayLed(255, 0, 0);
+    } else {
+      DisplayLed(255, 255, 255);
+    }
+    return;
+  }
+
+  if (localizer_output_fetcher_.get() == nullptr ||
+      server_statistics_fetcher_.get() == nullptr ||
+      client_statistics_fetcher_.get() == nullptr ||
+      OrinsDisconnected(*localizer_output_fetcher_) ||
+      DisconnectedIMUPiServer(*server_statistics_fetcher_) ||
+      DisconnectedIMUPiClient(*client_statistics_fetcher_)) {
+    // Flash red/green
+    if (flash_counter_.Flash()) {
+      DisplayLed(255, 0, 0);
+    } else {
+      DisplayLed(0, 255, 0);
+    }
+
+    return;
+  }
+
+  // Not zeroed: Yellow
+  if ((superstructure_status_fetcher_.get() &&
+       !superstructure_status_fetcher_->zeroed()) ||
+      (drivetrain_status_fetcher_.get() &&
+       !drivetrain_status_fetcher_->filters_ready())) {
+    DisplayLed(255, 255, 0);
+    return;
+  }
+
+  // Want to know when we have a note.
+  //
+  if (superstructure_status_fetcher_.get()) {
+    // Check if there is a target that is in sight
+    if (event_loop_->monotonic_now() <
+        last_accepted_time_ + chrono::milliseconds(100)) {
+      if (superstructure_status_fetcher_.get() != nullptr &&
+          superstructure_status_fetcher_->shooter()->auto_aiming()) {
+        DisplayLed(0, 0, 255);
+        return;
+      } else {
+        DisplayLed(0, 255, 0);
+        return;
+      }
+    }
+  }
+  DisplayLed(0, 0, 0);
+}
+
+}  // namespace y2024::control_loops::superstructure
diff --git a/y2024/control_loops/superstructure/led_indicator.h b/y2024/control_loops/superstructure/led_indicator.h
new file mode 100644
index 0000000..a7d891a
--- /dev/null
+++ b/y2024/control_loops/superstructure/led_indicator.h
@@ -0,0 +1,96 @@
+#ifndef Y2024_CONTROL_LOOPS_SUPERSTRUCTURE_LED_INDICATOR_H_
+#define Y2024_CONTROL_LOOPS_SUPERSTRUCTURE_LED_INDICATOR_H_
+
+#include "ctre/phoenix/led/CANdle.h"
+
+#include "aos/events/event_loop.h"
+#include "aos/network/message_bridge_client_generated.h"
+#include "aos/network/message_bridge_server_generated.h"
+#include "frc971/control_loops/control_loop.h"
+#include "frc971/control_loops/control_loops_generated.h"
+#include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
+#include "frc971/control_loops/drivetrain/drivetrain_status_generated.h"
+#include "frc971/control_loops/drivetrain/localization/localizer_output_generated.h"
+#include "frc971/control_loops/profiled_subsystem_generated.h"
+#include "frc971/queues/gyro_generated.h"
+#include "y2024/control_loops/superstructure/superstructure_output_generated.h"
+#include "y2024/control_loops/superstructure/superstructure_position_generated.h"
+#include "y2024/control_loops/superstructure/superstructure_status_generated.h"
+
+namespace y2024::control_loops::superstructure {
+
+class FlashCounter {
+ public:
+  FlashCounter(size_t flash_iterations) : flash_iterations_(flash_iterations) {}
+
+  bool Flash() {
+    if (counter_ % flash_iterations_ == 0) {
+      flash_ = !flash_;
+    }
+    counter_++;
+    return flash_;
+  }
+
+ private:
+  size_t flash_iterations_;
+  size_t counter_ = 0;
+  bool flash_ = false;
+};
+
+class LedIndicator {
+ public:
+  LedIndicator(aos::EventLoop *event_loop);
+
+  // Colors in order of priority:
+  //
+  // Red: estopped
+  // Flash red/white: imu disconnected
+  // Flash red/green: any orin disconnected
+  // Pink: not zeroed
+  //
+  // State machine:
+  //    INTAKING: Flash Orange/Off
+  //    LOADED: Yellow
+  //    MOVING: Flash Yellow/Off
+  //    LOADING_CATAPULT: Flash Purple/Off
+  //    READY: Green
+  //    FIRING: Purple
+  //
+  // HAS A TARGET: Blue
+  // VISION: Flash Blue/Off
+
+  void DecideColor();
+
+ private:
+  static constexpr size_t kFlashIterations = 5;
+
+  void DisplayLed(uint8_t r, uint8_t g, uint8_t b);
+
+  ctre::phoenix::led::CANdle candle_{8, "rio"};
+
+  aos::EventLoop *event_loop_;
+  aos::Fetcher<frc971::control_loops::drivetrain::Output>
+      drivetrain_output_fetcher_;
+  aos::Fetcher<Status> superstructure_status_fetcher_;
+  aos::Fetcher<aos::message_bridge::ServerStatistics>
+      server_statistics_fetcher_;
+  aos::Fetcher<aos::message_bridge::ClientStatistics>
+      client_statistics_fetcher_;
+  aos::Fetcher<frc971::controls::LocalizerOutput> localizer_output_fetcher_;
+  aos::Fetcher<frc971::sensors::GyroReading> gyro_reading_fetcher_;
+  aos::Fetcher<frc971::control_loops::drivetrain::Status>
+      drivetrain_status_fetcher_;
+
+  size_t last_accepted_count_ = 0;
+  aos::monotonic_clock::time_point last_accepted_time_ =
+      aos::monotonic_clock::min_time;
+
+  aos::monotonic_clock::time_point startup_time_ =
+      aos::monotonic_clock::min_time;
+
+  FlashCounter flash_counter_{kFlashIterations};
+};
+
+}  // namespace y2024::control_loops::superstructure
+
+#endif  // Y2024_CONTROL_LOOPS_SUPERSTRUCTURE_LED_INDICATOR_H_
diff --git a/y2024/control_loops/superstructure/shooter.cc b/y2024/control_loops/superstructure/shooter.cc
index 79da980..7002657 100644
--- a/y2024/control_loops/superstructure/shooter.cc
+++ b/y2024/control_loops/superstructure/shooter.cc
@@ -186,10 +186,10 @@
       {.intake_pivot_position = intake_pivot_position,
        .turret_position = turret_.estimated_position(),
        .extend_position =
-           ((!robot_constants_->common()->disable_extend()) ? extend_position
-                                                            : 0.0)},
+           ((!robot_constants_->robot()->disable_extend()) ? extend_position
+                                                           : 0.0)},
       turret_goal->unsafe_goal(),
-      ((!robot_constants_->common()->disable_extend()) ? extend_goal : 0.0));
+      ((!robot_constants_->robot()->disable_extend()) ? extend_goal : 0.0));
 
   if (!CatapultRetracted()) {
     altitude_.set_min_position(
@@ -248,7 +248,7 @@
     //
     // accel = v^2 / (2 * x)
     catapult_.mutable_profile()->set_maximum_velocity(
-        catapult::kFreeSpeed * catapult::kOutputRatio * 4.0 / 12.0);
+        catapult::kFreeSpeed * catapult::kOutputRatio * 5.5 / 12.0);
 
     if (disabled) {
       state_ = CatapultState::RETRACTING;
@@ -272,6 +272,7 @@
         if (subsystems_in_range && shooter_goal != nullptr && fire &&
             catapult_close && piece_loaded) {
           state_ = CatapultState::FIRING;
+          max_catapult_goal_velocity_ = catapult_.goal(1);
         } else {
           catapult_.set_controller_index(0);
           catapult_.mutable_profile()->set_maximum_acceleration(
@@ -294,10 +295,17 @@
             robot_constants_->common()
                 ->current_limits()
                 ->shooting_retention_roller_stator_current_limit();
-        catapult_.set_controller_index(1);
+        max_catapult_goal_velocity_ =
+            std::max(max_catapult_goal_velocity_, catapult_.goal(1));
+
+        if (max_catapult_goal_velocity_ > catapult_.goal(1) + 0.1) {
+          catapult_.set_controller_index(2);
+        } else {
+          catapult_.set_controller_index(1);
+        }
         catapult_.mutable_profile()->set_maximum_acceleration(400.0);
-        catapult_.mutable_profile()->set_maximum_deceleration(500.0);
-        catapult_.set_unprofiled_goal(2.0, 0.0);
+        catapult_.mutable_profile()->set_maximum_deceleration(1000.0);
+        catapult_.set_unprofiled_goal(2.45, 0.0);
         if (CatapultClose()) {
           state_ = CatapultState::RETRACTING;
           ++shot_count_;
diff --git a/y2024/control_loops/superstructure/shooter.h b/y2024/control_loops/superstructure/shooter.h
index 5363288..e873629 100644
--- a/y2024/control_loops/superstructure/shooter.h
+++ b/y2024/control_loops/superstructure/shooter.h
@@ -138,6 +138,10 @@
 
   CatapultSubsystem catapult_;
 
+  // Max speed we have seen during this shot.  This is used to figure out when
+  // we start decelerating and switch controllers.
+  double max_catapult_goal_velocity_ = 0.0;
+
   PotAndAbsoluteEncoderSubsystem turret_;
   PotAndAbsoluteEncoderSubsystem altitude_;
 
diff --git a/y2024/control_loops/superstructure/superstructure.cc b/y2024/control_loops/superstructure/superstructure.cc
index af3f9de..9edc626 100644
--- a/y2024/control_loops/superstructure/superstructure.cc
+++ b/y2024/control_loops/superstructure/superstructure.cc
@@ -167,7 +167,7 @@
   // considered ready to accept note from the transfer rollers. If disable
   // extend is triggered, this will autoatically be false.
   const bool extend_at_retracted =
-      (!robot_constants_->common()->disable_extend() &&
+      (!robot_constants_->robot()->disable_extend() &&
        PositionNear(extend_.position(), extend_set_points->retracted(),
                     kExtendThreshold));
 
@@ -549,7 +549,7 @@
   const bool collided = collision_avoidance_.IsCollided({
       .intake_pivot_position = intake_pivot_.estimated_position(),
       .turret_position = shooter_.turret().estimated_position(),
-      .extend_position = ((!robot_constants_->common()->disable_extend())
+      .extend_position = ((!robot_constants_->robot()->disable_extend())
                               ? extend_.estimated_position()
                               : 0.0),
   });
@@ -659,10 +659,15 @@
           status->fbb());
 
   // Zero out extend voltage if "disable_extend" is true
-  if (robot_constants_->common()->disable_extend()) {
+  if (robot_constants_->robot()->disable_extend()) {
     output_struct.extend_voltage = 0.0;
   }
 
+  // Zero out climber voltage if "disable_climber" is true
+  if (robot_constants_->robot()->disable_climber()) {
+    output_struct.climber_voltage = 0.0;
+  }
+
   if (output) {
     output->CheckOk(output->Send(Output::Pack(*output->fbb(), &output_struct)));
   }
diff --git a/y2024/control_loops/superstructure/superstructure_lib_test.cc b/y2024/control_loops/superstructure/superstructure_lib_test.cc
index e3368e9..9e8b3a5 100644
--- a/y2024/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2024/control_loops/superstructure/superstructure_lib_test.cc
@@ -1450,11 +1450,11 @@
   EXPECT_NEAR(
       -M_PI_2,
       superstructure_status_fetcher_->shooter()->aimer()->turret_position() -
-          M_PI - 0.22,
+          M_PI - Aimer::kTurretZeroOffset,
       5e-4);
   EXPECT_NEAR(-M_PI_2,
               superstructure_status_fetcher_->shooter()->turret()->position() -
-                  M_PI - 0.22,
+                  M_PI - Aimer::kTurretZeroOffset,
               5e-4);
 
   EXPECT_EQ(
@@ -1496,11 +1496,11 @@
   EXPECT_NEAR(
       M_PI_2,
       superstructure_status_fetcher_->shooter()->aimer()->turret_position() +
-          M_PI - 0.22,
+          M_PI - Aimer::kTurretZeroOffset,
       5e-4);
   EXPECT_NEAR(M_PI_2,
               superstructure_status_fetcher_->shooter()->turret()->position() +
-                  M_PI - 0.22,
+                  M_PI - Aimer::kTurretZeroOffset,
               5e-4);
   EXPECT_EQ(
       kDistanceFromSpeaker,
diff --git a/y2024/joystick_reader.cc b/y2024/joystick_reader.cc
index 4a8d414..293e57e 100644
--- a/y2024/joystick_reader.cc
+++ b/y2024/joystick_reader.cc
@@ -51,6 +51,7 @@
 const ButtonLocation kCatapultLoad(2, 1);
 const ButtonLocation kAmp(2, 4);
 const ButtonLocation kFire(2, 8);
+const ButtonLocation kDriverFire(1, 1);
 const ButtonLocation kTrap(2, 6);
 const ButtonLocation kAutoAim(1, 8);
 const ButtonLocation kAimSpeaker(2, 11);
@@ -159,7 +160,8 @@
                                                    ->shooter_speaker_set_point()
                                                    ->turret_position());
     }
-    superstructure_goal_builder->set_fire(data.IsPressed(kFire));
+    superstructure_goal_builder->set_fire(data.IsPressed(kFire) ||
+                                          data.IsPressed(kDriverFire));
 
     if (data.IsPressed(kRetractClimber)) {
       superstructure_goal_builder->set_climber_goal(
diff --git a/y2024/localizer/localizer.cc b/y2024/localizer/localizer.cc
index b8e982e..daef22c 100644
--- a/y2024/localizer/localizer.cc
+++ b/y2024/localizer/localizer.cc
@@ -11,7 +11,7 @@
 
 DEFINE_double(max_pose_error, 1e-5,
               "Throw out target poses with a higher pose error than this");
-DEFINE_double(max_distortion, 0.1, "");
+DEFINE_double(max_distortion, 1000.0, "");
 DEFINE_double(
     max_pose_error_ratio, 0.4,
     "Throw out target poses with a higher pose error ratio than this");
diff --git a/y2024/vision/BUILD b/y2024/vision/BUILD
index 4904554..bd4fe76 100644
--- a/y2024/vision/BUILD
+++ b/y2024/vision/BUILD
@@ -167,3 +167,20 @@
         "@org_tuxfamily_eigen//:eigen",
     ],
 )
+
+cc_binary(
+    name = "image_replay",
+    srcs = [
+        "image_replay.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//y2024:__subpackages__"],
+    deps = [
+        "//aos:configuration",
+        "//aos:init",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_reader",
+        "//frc971/vision:vision_fbs",
+        "//third_party:opencv",
+    ],
+)
diff --git a/y2024/vision/image_replay.cc b/y2024/vision/image_replay.cc
new file mode 100644
index 0000000..f03bcf1
--- /dev/null
+++ b/y2024/vision/image_replay.cc
@@ -0,0 +1,47 @@
+#include "gflags/gflags.h"
+#include "opencv2/imgproc.hpp"
+#include <opencv2/highgui.hpp>
+
+#include "aos/events/logging/log_reader.h"
+#include "aos/events/logging/log_writer.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/init.h"
+#include "aos/json_to_flatbuffer.h"
+#include "aos/logging/log_message_generated.h"
+#include "frc971/vision/vision_generated.h"
+
+DEFINE_string(node, "orin1", "The node to view the log from");
+DEFINE_string(channel, "/camera0", "The channel to view the log from");
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  // open logfiles
+  aos::logger::LogReader reader(
+      aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
+
+  aos::SimulatedEventLoopFactory factory(reader.configuration());
+  reader.Register(&factory);
+
+  aos::NodeEventLoopFactory *node = factory.GetNodeEventLoopFactory(FLAGS_node);
+
+  std::unique_ptr<aos::EventLoop> image_loop = node->MakeEventLoop("image");
+  image_loop->MakeWatcher(
+      "/" + FLAGS_node + "/" + FLAGS_channel,
+      [](const frc971::vision::CameraImage &msg) {
+        cv::Mat color_image(cv::Size(msg.cols(), msg.rows()), CV_8UC2,
+                            (void *)msg.data()->data());
+
+        cv::Mat bgr(color_image.size(), CV_8UC3);
+        cv::cvtColor(color_image, bgr, cv::COLOR_YUV2BGR_YUYV);
+
+        cv::imshow("Replay", bgr);
+        cv::waitKey(1);
+      });
+
+  factory.Run();
+
+  reader.Deregister();
+
+  return 0;
+}
diff --git a/y2024/vision/target_mapping.cc b/y2024/vision/target_mapping.cc
index d2fb26e..fac7e81 100644
--- a/y2024/vision/target_mapping.cc
+++ b/y2024/vision/target_mapping.cc
@@ -32,7 +32,7 @@
               "Write the target constraints to this path");
 DEFINE_string(dump_stats_to, "/tmp/mapping_stats.txt",
               "Write the mapping stats to this path");
-DEFINE_string(field_name, "charged_up",
+DEFINE_string(field_name, "crescendo",
               "Field name, for the output json filename and flatbuffer field");
 DEFINE_string(json_path, "y2024/vision/maps/target_map.json",
               "Specify path for json with initial pose guesses.");
@@ -52,6 +52,8 @@
 DEFINE_uint64(skip_to, 1,
               "Start at combined image of this number (1 is the first image)");
 DEFINE_bool(solve, true, "Whether to solve for the field's target map.");
+DEFINE_bool(split_field, false,
+            "Whether to break solve into two sides of field");
 DEFINE_int32(team_number, 0,
              "Required: Use the calibration for a node with this team number");
 DEFINE_uint64(wait_key, 1,
@@ -89,6 +91,9 @@
   // Contains fixed target poses without solving, for use with visualization
   static const TargetMapper kFixedTargetMapper;
 
+  // Map of TargetId to alliance "color" for splitting field
+  static std::map<uint, std::string> kIdAllianceMap;
+
   // Change reference frame from camera to robot
   static Eigen::Affine3d CameraToRobotDetection(Eigen::Affine3d H_camera_target,
                                                 Eigen::Affine3d extrinsics);
@@ -110,8 +115,8 @@
   std::vector<DataAdapter::TimestampedDetection> timestamped_target_detections_;
 
   VisualizeRobot vis_robot_;
-  // Set of node names which are currently drawn on the display
-  std::set<std::string> drawn_nodes_;
+  // Set of camera names which are currently drawn on the display
+  std::set<std::string> drawn_cameras_;
   // Number of frames displayed
   size_t display_count_;
   // Last time we drew onto the display image.
@@ -124,6 +129,9 @@
   // used to determine if we need to pause for the user to see this frame
   // clearly
   double max_delta_T_world_robot_;
+  double ignore_count_;
+
+  std::map<std::string, int> camera_numbering_map_;
 
   std::vector<std::unique_ptr<aos::EventLoop>> mapping_event_loops_;
 
@@ -138,6 +146,12 @@
     {"/imu/camera1", cv::Scalar(255, 165, 0)},
 };
 
+std::map<uint, std::string> TargetMapperReplay::kIdAllianceMap = {
+    {1, "red"},  {2, "red"},   {3, "red"},   {4, "red"},
+    {5, "red"},  {6, "blue"},  {7, "blue"},  {8, "blue"},
+    {9, "blue"}, {10, "blue"}, {11, "red"},  {12, "red"},
+    {13, "red"}, {14, "blue"}, {15, "blue"}, {16, "blue"}};
+
 const auto TargetMapperReplay::kFixedTargetMapper =
     TargetMapper(FLAGS_json_path, ceres::examples::VectorOfConstraints{});
 
@@ -152,13 +166,17 @@
     : reader_(reader),
       timestamped_target_detections_(),
       vis_robot_(cv::Size(1280, 1000)),
-      drawn_nodes_(),
+      drawn_cameras_(),
       display_count_(0),
       last_draw_time_(aos::distributed_clock::min_time),
       last_H_world_robot_(Eigen::Matrix4d::Identity()),
       max_delta_T_world_robot_(0.0) {
   reader_->RemapLoggedChannel("/orin1/constants", "y2024.Constants");
   reader_->RemapLoggedChannel("/imu/constants", "y2024.Constants");
+  // If it's Box of Orins, don't remap roborio constants
+  if (FLAGS_team_number == 7971) {
+    reader_->RemapLoggedChannel("/roborio/constants", "y2024.Constants");
+  }
   reader_->Register();
 
   SendSimulationConstants(reader_->event_loop_factory(), FLAGS_team_number,
@@ -168,6 +186,7 @@
   node_list.push_back("imu");
   node_list.push_back("orin1");
 
+  int camera_count = 0;
   for (std::string node : node_list) {
     const aos::Node *pi =
         aos::configuration::GetNode(reader->configuration(), node);
@@ -186,6 +205,10 @@
     HandleNodeCaptures(
         mapping_event_loops_[mapping_event_loops_.size() - 1].get(),
         &constants_fetcher, 1);
+    std::string camera0_name = "/" + node + "/camera0";
+    camera_numbering_map_[camera0_name] = camera_count++;
+    std::string camera1_name = "/" + node + "/camera1";
+    camera_numbering_map_[camera1_name] = camera_count++;
   }
 
   if (FLAGS_visualize_solver) {
@@ -205,6 +228,11 @@
   std::stringstream label;
   label << camera_name << " - ";
 
+  if (map.target_poses()->size() == 0) {
+    VLOG(2) << "Got 0 AprilTags for camera " << camera_name;
+    return;
+  }
+
   for (const auto *target_pose_fbs : *map.target_poses()) {
     // Skip detections with invalid ids
     if (static_cast<TargetMapper::TargetId>(target_pose_fbs->id()) <
@@ -242,13 +270,18 @@
     double distance_from_camera = target_pose_camera.p.norm();
     double distortion_factor = target_pose_fbs->distortion_factor();
 
-    if (distance_from_camera > 5.0) {
+    double distance_threshold = 5.0;
+    if (distance_from_camera > distance_threshold) {
+      ignore_count_++;
+      LOG(INFO) << "Ignored " << ignore_count_ << " AprilTags with distance "
+                << distance_from_camera << " > " << distance_threshold;
       continue;
     }
 
     CHECK(map.has_monotonic_timestamp_ns())
         << "Need detection timestamps for mapping";
 
+    // Detection is usable, so store it
     timestamped_target_detections_.emplace_back(
         DataAdapter::TimestampedDetection{
             .time = node_distributed_time,
@@ -260,7 +293,7 @@
     if (FLAGS_visualize_solver) {
       // If we've already drawn this camera_name in the current image,
       // display the image before clearing and adding the new poses
-      if (drawn_nodes_.count(camera_name) != 0) {
+      if (drawn_cameras_.count(camera_name) != 0) {
         display_count_++;
         cv::putText(vis_robot_.image_,
                     "Poses #" + std::to_string(display_count_),
@@ -268,7 +301,7 @@
                     cv::Scalar(255, 255, 255));
 
         if (display_count_ >= FLAGS_skip_to) {
-          VLOG(1) << "Showing image for node " << camera_name
+          VLOG(1) << "Showing image for camera " << camera_name
                   << " since we've drawn it already";
           cv::imshow("View", vis_robot_.image_);
           // Pause if delta_T is too large, but only after first image (to make
@@ -284,10 +317,10 @@
           }
           max_delta_T_world_robot_ = 0.0;
         } else {
-          VLOG(1) << "At poses #" << std::to_string(display_count_);
+          VLOG(2) << "At poses #" << std::to_string(display_count_);
         }
         vis_robot_.ClearImage();
-        drawn_nodes_.clear();
+        drawn_cameras_.clear();
       }
 
       Eigen::Affine3d H_world_target = PoseUtils::Pose3dToAffine3d(
@@ -316,7 +349,7 @@
       max_delta_T_world_robot_ =
           std::max(delta_T_world_robot, max_delta_T_world_robot_);
 
-      VLOG(1) << "Drew in info for robot " << camera_name << " and target #"
+      VLOG(1) << "Drew in info for camera " << camera_name << " and target #"
               << target_pose_fbs->id();
       drew = true;
       last_draw_time_ = node_distributed_time;
@@ -325,26 +358,30 @@
   }
   if (FLAGS_visualize_solver) {
     if (drew) {
-      // Collect all the labels from a given node, and add the text
-      size_t pi_number =
-          static_cast<size_t>(camera_name[camera_name.size() - 1] - '0');
+      // Collect all the labels from a given camera, and add the text
+      // TODO: Need to fix this one
+      int position_number = camera_numbering_map_[camera_name];
       cv::putText(vis_robot_.image_, label.str(),
-                  cv::Point(10, 10 + 20 * pi_number), cv::FONT_HERSHEY_PLAIN,
-                  1.0, kOrinColors.at(camera_name));
+                  cv::Point(10, 10 + 20 * position_number),
+                  cv::FONT_HERSHEY_PLAIN, 1.0, kOrinColors.at(camera_name));
 
-      drawn_nodes_.emplace(camera_name);
+      drawn_cameras_.emplace(camera_name);
     } else if (node_distributed_time - last_draw_time_ >
                    std::chrono::milliseconds(30) &&
-               display_count_ >= FLAGS_skip_to) {
-      cv::putText(vis_robot_.image_, "No detections", cv::Point(10, 0),
-                  cv::FONT_HERSHEY_PLAIN, 1.0, kOrinColors.at(camera_name));
+               display_count_ >= FLAGS_skip_to && drew) {
+      // TODO: Check on 30ms value-- does this make sense?
+      double delta_t = (node_distributed_time - last_draw_time_).count() / 1e6;
+      VLOG(1) << "Last result was " << delta_t << "ms ago";
+      cv::putText(vis_robot_.image_, "No detections in last 30ms",
+                  cv::Point(10, 0), cv::FONT_HERSHEY_PLAIN, 1.0,
+                  kOrinColors.at(camera_name));
       // Display and clear the image if we haven't draw in a while
       VLOG(1) << "Displaying image due to time lapse";
       cv::imshow("View", vis_robot_.image_);
       cv::waitKey(FLAGS_wait_key);
       vis_robot_.ClearImage();
       max_delta_T_world_robot_ = 0.0;
-      drawn_nodes_.clear();
+      drawn_cameras_.clear();
     }
   }
 }
@@ -383,21 +420,20 @@
     auto target_constraints =
         DataAdapter::MatchTargetDetections(timestamped_target_detections_);
 
-    // Remove constraints between the two sides of the field - these are
-    // basically garbage because of how far the camera is. We will use seeding
-    // below to connect the two sides
-    target_constraints.erase(
-        std::remove_if(target_constraints.begin(), target_constraints.end(),
-                       [](const auto &constraint) {
-                         // TODO(james): This no longer makes sense.
-                         constexpr TargetMapper::TargetId kMaxRedId = 4;
-                         TargetMapper::TargetId min_id =
-                             std::min(constraint.id_begin, constraint.id_end);
-                         TargetMapper::TargetId max_id =
-                             std::max(constraint.id_begin, constraint.id_end);
-                         return (min_id <= kMaxRedId && max_id > kMaxRedId);
-                       }),
-        target_constraints.end());
+    if (FLAGS_split_field) {
+      // Remove constraints between the two sides of the field - these are
+      // basically garbage because of how far the camera is. We will use seeding
+      // below to connect the two sides
+      target_constraints.erase(
+          std::remove_if(
+              target_constraints.begin(), target_constraints.end(),
+              [](const auto &constraint) {
+                return (
+                    kIdAllianceMap[static_cast<uint>(constraint.id_begin)] !=
+                    kIdAllianceMap[static_cast<uint>(constraint.id_end)]);
+              }),
+          target_constraints.end());
+    }
 
     LOG(INFO) << "Solving for locations of tags with "
               << target_constraints.size() << " constraints";
diff --git a/y2024/wpilib_interface.cc b/y2024/wpilib_interface.cc
index 6b2acc4..301ab79 100644
--- a/y2024/wpilib_interface.cc
+++ b/y2024/wpilib_interface.cc
@@ -53,6 +53,7 @@
 #include "frc971/wpilib/wpilib_robot_base.h"
 #include "y2024/constants.h"
 #include "y2024/constants/constants_generated.h"
+#include "y2024/control_loops/superstructure/led_indicator.h"
 #include "y2024/control_loops/superstructure/superstructure_can_position_static.h"
 #include "y2024/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2024/control_loops/superstructure/superstructure_position_generated.h"
@@ -493,7 +494,7 @@
         current_limits->climber_stator_current_limit(),
         current_limits->climber_supply_current_limit());
     std::shared_ptr<TalonFX> extend =
-        (robot_constants->common()->disable_extend())
+        (robot_constants->robot()->disable_extend())
             ? nullptr
             : std::make_shared<TalonFX>(
                   12, false, "Drivetrain Bus", &canivore_signal_registry,
@@ -714,7 +715,7 @@
     can_superstructure_writer.add_talonfx("catapult_two", catapult_two);
     can_superstructure_writer.add_talonfx("turret", turret);
     can_superstructure_writer.add_talonfx("climber", climber);
-    if (!robot_constants->common()->disable_extend()) {
+    if (!robot_constants->robot()->disable_extend()) {
       can_superstructure_writer.add_talonfx("extend", extend);
     }
     can_superstructure_writer.add_talonfx("intake_roller", intake_roller);
@@ -731,6 +732,14 @@
 
     AddLoop(&can_output_event_loop);
 
+    // Thread 6
+    // Setup led_indicator
+    ::aos::ShmEventLoop led_indicator_event_loop(&config.message());
+    led_indicator_event_loop.set_name("LedIndicator");
+    control_loops::superstructure::LedIndicator led_indicator(
+        &led_indicator_event_loop);
+    AddLoop(&led_indicator_event_loop);
+
     RunLoops();
   }
 };
diff --git a/y2024/y2024_imu.json b/y2024/y2024_imu.json
index ed43e10..2680856 100644
--- a/y2024/y2024_imu.json
+++ b/y2024/y2024_imu.json
@@ -163,7 +163,7 @@
       "type": "aos.message_bridge.RemoteMessage",
       "source_node": "imu",
       "logger": "NOT_LOGGED",
-      "frequency": 52,
+      "frequency": 100,
       "num_senders": 2,
       "max_size": 200
     },