Merge "Undistort points before running pose estimation"
diff --git a/.bazelignore b/.bazelignore
index 3665125..0b2e110 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -10,3 +10,4 @@
 scouting/www/shift_schedule/node_modules
 scouting/www/view/node_modules
 scouting/www/pit_scouting/node_modules
+scouting/www/scan/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index 82a0dd4..1aabe54 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -919,6 +919,7 @@
         "@//scouting/www/match_list:package.json",
         "@//scouting/www/notes:package.json",
         "@//scouting/www/rpc:package.json",
+        "@//scouting/www/scan:package.json",
         "@//scouting/www/shift_schedule:package.json",
         "@//scouting/www/view:package.json",
     ],
@@ -988,6 +989,11 @@
 
 http_archive(
     name = "aspect_rules_cypress",
+    patch_args = ["-p1"],
+    patches = [
+        "//third_party:rules_cypress/0001-fix-incorrect-linux-checksums.patch",
+        "//third_party:rules_cypress/0002-Add-support-for-cypress-13.6.6.patch",
+    ],
     sha256 = "76947778d8e855eee3c15931e1fcdc1c2a25d56d6c0edd110b2227c05b794d08",
     strip_prefix = "rules_cypress-0.3.2",
     urls = [
@@ -1002,7 +1008,7 @@
 
 cypress_register_toolchains(
     name = "cypress",
-    cypress_version = "12.3.0",
+    cypress_version = "13.3.1",
 )
 
 # Copied from:
@@ -1013,7 +1019,7 @@
 # LASTCHANGE_URL="https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2FLAST_CHANGE?alt=media"
 # CHROME_REVISION=$(curl -s -S $LASTCHANGE_URL)
 # echo "latest CHROME_REVISION_LINUX is $CHROME_REVISION"
-CHROME_REVISION_LINUX = "1072361"
+CHROME_REVISION_LINUX = "1264932"
 
 http_archive(
     name = "chrome_linux",
@@ -1022,7 +1028,7 @@
 srcs = glob(["**"]),
 visibility = ["//visibility:public"],
 )""",
-    sha256 = "0df22f743facd1e090eff9b7f8d8bdc293fb4dc31ce9156d2ef19b515974a72b",
+    sha256 = "4de54f43b2fc4812b9fad4145e44df6ed3063969174a8883ea42ed4c1ee58301",
     strip_prefix = "chrome-linux",
     urls = [
         "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F" + CHROME_REVISION_LINUX + "%2Fchrome-linux.zip?alt=media",
@@ -1207,6 +1213,14 @@
     url = "https://software.frc971.org/Build-Dependencies/2021-10-03_superstructure_shoot_balls.tar.gz",
 )
 
+http_file(
+    name = "opencv_wasm",
+    sha256 = "447244d0e67e411f91e7c225c07f104437104e3e753085248a0c527a25bd8807",
+    urls = [
+        "https://docs.opencv.org/4.9.0/opencv.js",
+    ],
+)
+
 http_archive(
     name = "opencv_k8",
     build_file = "@//debian:opencv.BUILD",
diff --git a/aos/starter/irq_affinity.cc b/aos/starter/irq_affinity.cc
index 8b6bb2a..a188127 100644
--- a/aos/starter/irq_affinity.cc
+++ b/aos/starter/irq_affinity.cc
@@ -109,7 +109,7 @@
     }
   }
 
-  void ConfigurePid(pid_t pid) const {
+  void ConfigurePid(pid_t pid, std::string_view name) const {
     struct sched_param param;
     param.sched_priority = priority;
     int new_scheduler;
@@ -126,7 +126,11 @@
       default:
         LOG(FATAL) << "Unknown scheduler";
     }
-    PCHECK(sched_setscheduler(pid, new_scheduler, &param) == 0);
+    PCHECK(sched_setscheduler(pid, new_scheduler, &param) == 0)
+        << ", Failed to set " << name << "(" << pid << ") to "
+        << (new_scheduler == SCHED_OTHER
+                ? "SCHED_OTHER"
+                : (new_scheduler == SCHED_RR ? "SCHED_RR" : "SCHED_FIFO"));
 
     if (scheduler == starter::Scheduler::SCHEDULER_OTHER && nice.has_value()) {
       PCHECK(setpriority(PRIO_PROCESS, pid, *nice) == 0)
@@ -172,14 +176,14 @@
         if (reading.second.kthread) {
           for (const ParsedKThreadConfig &match : kthreads_) {
             if (match.Matches(reading.second.name)) {
-              match.ConfigurePid(reading.first);
+              match.ConfigurePid(reading.first, reading.second.name);
               break;
             }
           }
         } else {
           for (const ParsedKThreadConfig &match : threads_) {
             if (match.Matches(reading.second.name)) {
-              match.ConfigurePid(reading.first);
+              match.ConfigurePid(reading.first, reading.second.name);
               break;
             }
           }
diff --git a/aos/util/BUILD b/aos/util/BUILD
index 7bb4061..599d477 100644
--- a/aos/util/BUILD
+++ b/aos/util/BUILD
@@ -578,3 +578,25 @@
         "//aos/testing:path",
     ],
 )
+
+static_flatbuffer(
+    name = "filesystem_fbs",
+    srcs = ["filesystem.fbs"],
+)
+
+cc_static_flatbuffer(
+    name = "filesystem_schema",
+    function = "aos::util::FilesystemStatusSchema",
+    target = ":filesystem_fbs_reflection_out",
+)
+
+cc_binary(
+    name = "filesystem_monitor",
+    srcs = ["filesystem_monitor.cc"],
+    deps = [
+        ":filesystem_fbs",
+        "//aos:init",
+        "//aos/events:shm_event_loop",
+        "@com_google_absl//absl/strings",
+    ],
+)
diff --git a/aos/util/filesystem.fbs b/aos/util/filesystem.fbs
new file mode 100644
index 0000000..0571d15
--- /dev/null
+++ b/aos/util/filesystem.fbs
@@ -0,0 +1,23 @@
+namespace aos.util;
+
+table Filesystem {
+  // Mountpoint of the filesystem in question.
+  path: string (id: 0);
+  // Type (e.g., "ext4") of the filesystem.
+  type: string (id: 1);
+  // Total size of the filesystem, in bytes.
+  overall_space: uint64 (id: 2);
+  // Total free space on the filesystem, in bytes.
+  free_space: uint64 (id: 3);
+  // Total number of inodes on this filesystem.
+  overall_inodes: uint64 (id: 4);
+  // Total free inodes on this filesystem.
+  free_inodes: uint64 (id: 5);
+}
+
+// Table to track the current state of a compute platform's filesystem.
+table FilesystemStatus {
+  filesystems: [Filesystem] (id: 0);
+}
+
+root_type FilesystemStatus;
diff --git a/aos/util/filesystem_monitor.cc b/aos/util/filesystem_monitor.cc
new file mode 100644
index 0000000..4efb141
--- /dev/null
+++ b/aos/util/filesystem_monitor.cc
@@ -0,0 +1,140 @@
+#include <sys/statvfs.h>
+
+#include "absl/strings/str_split.h"
+#include "gflags/gflags.h"
+
+#include "aos/events/shm_event_loop.h"
+#include "aos/init.h"
+#include "aos/util/filesystem_generated.h"
+
+DEFINE_string(config, "aos_config.json", "File path of aos configuration");
+
+namespace aos::util {
+namespace {
+std::optional<std::string> ReadShortFile(std::string_view file_name) {
+  // Open as input and seek to end immediately.
+  std::ifstream file(std::string(file_name), std::ios_base::in);
+  if (!file.good()) {
+    VLOG(1) << "Can't read " << file_name;
+    return std::nullopt;
+  }
+  const size_t kMaxLineLength = 4096;
+  char buffer[kMaxLineLength];
+  file.read(buffer, kMaxLineLength);
+  if (!file.eof()) {
+    return std::nullopt;
+  }
+  return std::string(buffer, file.gcount());
+}
+}  // namespace
+
+// Periodically sends out the Filesystems message with filesystem utilization
+// info.
+class FilesystemMonitor {
+ public:
+  FilesystemMonitor(aos::EventLoop *event_loop)
+      : event_loop_(event_loop),
+        sender_(event_loop_->MakeSender<FilesystemStatus>("/aos")) {
+    periodic_timer_ =
+        event_loop_->AddTimer([this]() { PublishFilesystemStatus(); });
+    event_loop_->OnRun([this]() {
+      periodic_timer_->Schedule(event_loop_->monotonic_now(),
+                                std::chrono::seconds(5));
+    });
+  }
+
+ private:
+  void PublishFilesystemStatus() {
+    aos::Sender<FilesystemStatus>::Builder builder = sender_.MakeBuilder();
+
+    std::optional<std::string> contents = ReadShortFile("/proc/self/mountinfo");
+
+    CHECK(contents.has_value());
+
+    std::vector<flatbuffers::Offset<Filesystem>> filesystems;
+
+    // Iterate through /proc/self/mounts to find all the filesystems.
+    for (std::string_view line :
+         absl::StrSplit(std::string_view(contents->c_str(), contents->size()),
+                        '\n', absl::SkipWhitespace())) {
+      // See https://www.kernel.org/doc/Documentation/filesystems/proc.txt for
+      // the format.
+      std::vector<std::string_view> elements =
+          absl::StrSplit(line, ' ', absl::SkipWhitespace());
+
+      // First thing after - is the filesystem type.
+      size_t i = 6;
+      while (elements[i] != "-") {
+        ++i;
+        CHECK_LT(i + 1, elements.size());
+      }
+
+      // Mount point is the 4th element.
+      std::string mount_point(elements[4]);
+      std::string_view type = elements[i + 1];
+
+      // Ignore filesystems without reasonable types.
+      if (type != "ext2" && type != "xfs" && type != "vfat" && type != "ext3" &&
+          type != "ext4" && type != "tmpfs" && type != "devtmpfs") {
+        continue;
+      }
+      VLOG(1) << mount_point << ", type " << type;
+
+      struct statvfs info;
+
+      PCHECK(statvfs(mount_point.c_str(), &info) == 0);
+
+      VLOG(1) << "overall size: " << info.f_frsize * info.f_blocks << ", free "
+              << info.f_bfree * info.f_bsize << ", inodes " << info.f_files
+              << ", free " << info.f_ffree;
+
+      flatbuffers::Offset<flatbuffers::String> path_offset =
+          builder.fbb()->CreateString(mount_point);
+      flatbuffers::Offset<flatbuffers::String> type_offset =
+          builder.fbb()->CreateString(type);
+      Filesystem::Builder filesystem_builder =
+          builder.MakeBuilder<Filesystem>();
+      filesystem_builder.add_path(path_offset);
+      filesystem_builder.add_type(type_offset);
+      filesystem_builder.add_overall_space(info.f_frsize * info.f_blocks);
+      filesystem_builder.add_free_space(info.f_bfree * info.f_bsize);
+      filesystem_builder.add_overall_inodes(info.f_files);
+      filesystem_builder.add_free_inodes(info.f_ffree);
+
+      filesystems.emplace_back(filesystem_builder.Finish());
+    }
+
+    flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Filesystem>>>
+        filesystems_offset = builder.fbb()->CreateVector(filesystems);
+
+    FilesystemStatus::Builder filesystem_status_builder =
+        builder.MakeBuilder<FilesystemStatus>();
+
+    filesystem_status_builder.add_filesystems(filesystems_offset);
+
+    (void)builder.Send(filesystem_status_builder.Finish());
+  }
+
+  aos::EventLoop *event_loop_;
+
+  aos::Sender<FilesystemStatus> sender_;
+
+  aos::TimerHandler *periodic_timer_;
+};
+
+}  // namespace aos::util
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  aos::FlatbufferDetachedBuffer<aos::Configuration> config =
+      aos::configuration::ReadConfig(FLAGS_config);
+
+  aos::ShmEventLoop shm_event_loop(&config.message());
+
+  aos::util::FilesystemMonitor filesystem_monitor(&shm_event_loop);
+
+  shm_event_loop.Run();
+
+  return 0;
+}
diff --git a/frc971/analysis/trim_log_to_enabled.cc b/frc971/analysis/trim_log_to_enabled.cc
index 0853ea5..47e7703 100644
--- a/frc971/analysis/trim_log_to_enabled.cc
+++ b/frc971/analysis/trim_log_to_enabled.cc
@@ -9,12 +9,19 @@
 
 DEFINE_string(output_folder, "/tmp/trimmed/",
               "Name of the folder to write the trimmed log to.");
+DEFINE_string(node, "roborio", "");
 DEFINE_double(pre_enable_time_sec, 10.0,
               "Amount of time to leave in the new log before the first enable "
               "signal happens.");
 DEFINE_double(post_enable_time_sec, 1.0,
               "Amount of time to leave in the new log after the final enable "
               "signal ends.");
+DEFINE_double(force_start_monotonic, -1.0,
+              "If set, time, in seconds, at which to forcibly trim the start "
+              "of the log.");
+DEFINE_double(
+    force_end_monotonic, -1.0,
+    "If set, time, in seconds, at which to forcibly trim the end of the log.");
 
 int main(int argc, char *argv[]) {
   gflags::SetUsageMessage(
@@ -26,15 +33,16 @@
   std::optional<aos::monotonic_clock::time_point> start_time;
   std::optional<aos::monotonic_clock::time_point> end_time;
   bool printed_match = false;
+  bool force_time_range = FLAGS_force_start_monotonic > 0;
   // We need to do two passes through the logfile; one to figure out when the
   // start/end times are, one to actually do the trimming.
-  {
+  if (!force_time_range) {
     aos::logger::LogReader reader(logfiles);
     const aos::Node *roborio =
-        aos::configuration::GetNode(reader.configuration(), "roborio");
+        aos::configuration::GetNode(reader.configuration(), FLAGS_node);
     reader.Register();
     std::unique_ptr<aos::EventLoop> event_loop =
-        reader.event_loop_factory()->MakeEventLoop("roborio", roborio);
+        reader.event_loop_factory()->MakeEventLoop(FLAGS_node, roborio);
     event_loop->MakeWatcher(
         "/aos", [&start_time, &end_time, &printed_match,
                  &event_loop](const aos::JoystickState &msg) {
@@ -60,25 +68,33 @@
     if (!printed_match) {
       LOG(INFO) << "No match info.";
     }
+    if (!start_time.has_value()) {
+      LOG(WARNING) << "Log does not ontain any JoystickState messages.";
+      return 1;
+    }
+    LOG(INFO) << "First enable at " << start_time.value();
+    LOG(INFO) << "Final enable at " << end_time.value();
+    start_time.value() -= std::chrono::duration_cast<std::chrono::nanoseconds>(
+        std::chrono::duration<double>(FLAGS_pre_enable_time_sec));
+    end_time.value() += std::chrono::duration_cast<std::chrono::nanoseconds>(
+        std::chrono::duration<double>(FLAGS_post_enable_time_sec));
+  } else {
+    CHECK_LT(FLAGS_force_start_monotonic, FLAGS_force_end_monotonic);
+    start_time = aos::monotonic_clock::time_point(
+        std::chrono::duration_cast<std::chrono::nanoseconds>(
+            std::chrono::duration<double>(FLAGS_force_start_monotonic)));
+    end_time = aos::monotonic_clock::time_point(
+        std::chrono::duration_cast<std::chrono::nanoseconds>(
+            std::chrono::duration<double>(FLAGS_force_end_monotonic)));
   }
-  if (!start_time.has_value()) {
-    LOG(WARNING) << "Log does not ontain any JoystickState messages.";
-    return 1;
-  }
-  LOG(INFO) << "First enable at " << start_time.value();
-  LOG(INFO) << "Final enable at " << end_time.value();
-  start_time.value() -= std::chrono::duration_cast<std::chrono::nanoseconds>(
-      std::chrono::duration<double>(FLAGS_pre_enable_time_sec));
-  end_time.value() += std::chrono::duration_cast<std::chrono::nanoseconds>(
-      std::chrono::duration<double>(FLAGS_post_enable_time_sec));
 
   {
     aos::logger::LogReader reader(logfiles);
     const aos::Node *roborio =
-        aos::configuration::GetNode(reader.configuration(), "roborio");
+        aos::configuration::GetNode(reader.configuration(), FLAGS_node);
     reader.Register();
     std::unique_ptr<aos::EventLoop> event_loop =
-        reader.event_loop_factory()->MakeEventLoop("roborio", roborio);
+        reader.event_loop_factory()->MakeEventLoop(FLAGS_node, roborio);
     auto exit_timer = event_loop->AddTimer(
         [&reader]() { reader.event_loop_factory()->Exit(); });
     exit_timer->Schedule(start_time.value());
diff --git a/frc971/control_loops/drivetrain/libspline.cc b/frc971/control_loops/drivetrain/libspline.cc
index 57abe69..b1af886 100644
--- a/frc971/control_loops/drivetrain/libspline.cc
+++ b/frc971/control_loops/drivetrain/libspline.cc
@@ -120,8 +120,9 @@
                           int num_distance) {
   return new Trajectory(
       DistanceSpline(*spline),
-      ::y2020::control_loops::drivetrain::GetDrivetrainConfig(), nullptr, -1,
-      vmax, num_distance);
+      std::make_unique<DrivetrainConfig<double>>(
+          ::y2020::control_loops::drivetrain::GetDrivetrainConfig()),
+      nullptr, -1, vmax, num_distance);
 }
 
 void deleteTrajectory(Trajectory *t) { delete t; }
diff --git a/frc971/control_loops/drivetrain/splinedrivetrain.cc b/frc971/control_loops/drivetrain/splinedrivetrain.cc
index 508967c..f90e7e0 100644
--- a/frc971/control_loops/drivetrain/splinedrivetrain.cc
+++ b/frc971/control_loops/drivetrain/splinedrivetrain.cc
@@ -74,7 +74,7 @@
 
 void SplineDrivetrain::AddTrajectory(const fb::Trajectory *trajectory) {
   CHECK_LT(trajectories_.size(), trajectories_.capacity());
-  trajectories_.emplace_back(dt_config_, trajectory, velocity_drivetrain_);
+  trajectories_.emplace_back(&dt_config_, trajectory, velocity_drivetrain_);
   UpdateSplineHandles(commanded_spline_);
 }
 
diff --git a/frc971/control_loops/drivetrain/trajectory.cc b/frc971/control_loops/drivetrain/trajectory.cc
index 36d0947..a876ae3 100644
--- a/frc971/control_loops/drivetrain/trajectory.cc
+++ b/frc971/control_loops/drivetrain/trajectory.cc
@@ -33,7 +33,7 @@
 }  // namespace
 
 FinishedTrajectory::FinishedTrajectory(
-    const DrivetrainConfig<double> &config, const fb::Trajectory *buffer,
+    const DrivetrainConfig<double> *config, const fb::Trajectory *buffer,
     std::shared_ptr<
         StateFeedbackLoop<2, 2, 2, double, StateFeedbackHybridPlant<2, 2, 2>,
                           HybridKalman<2, 2, 2>>>
@@ -80,15 +80,15 @@
 
 BaseTrajectory::BaseTrajectory(
     const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
-    const DrivetrainConfig<double> &config,
+    const DrivetrainConfig<double> *config,
     std::shared_ptr<
         StateFeedbackLoop<2, 2, 2, double, StateFeedbackHybridPlant<2, 2, 2>,
                           HybridKalman<2, 2, 2>>>
         velocity_drivetrain)
     : velocity_drivetrain_(std::move(velocity_drivetrain)),
       config_(config),
-      robot_radius_l_(config.robot_radius),
-      robot_radius_r_(config.robot_radius),
+      robot_radius_l_(config->robot_radius),
+      robot_radius_r_(config->robot_radius),
       lateral_acceleration_(
           ConstraintValue(constraints, ConstraintType::LATERAL_ACCELERATION)),
       longitudinal_acceleration_(ConstraintValue(
@@ -96,7 +96,7 @@
       voltage_limit_(ConstraintValue(constraints, ConstraintType::VOLTAGE)) {}
 
 Trajectory::Trajectory(const SplineGoal &spline_goal,
-                       const DrivetrainConfig<double> &config)
+                       const DrivetrainConfig<double> *config)
     : Trajectory(DistanceSpline{spline_goal.spline()}, config,
                  spline_goal.spline()->constraints(),
                  spline_goal.spline_idx()) {
@@ -104,7 +104,7 @@
 }
 
 Trajectory::Trajectory(
-    DistanceSpline &&input_spline, const DrivetrainConfig<double> &config,
+    DistanceSpline &&input_spline, const DrivetrainConfig<double> *config,
     const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
     int spline_idx, double vmax, int num_distance)
     : BaseTrajectory(constraints, config),
@@ -127,6 +127,15 @@
   }
 }
 
+Trajectory::Trajectory(
+    DistanceSpline &&spline, std::unique_ptr<DrivetrainConfig<double>> config,
+    const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
+    int spline_idx, double vmax, int num_distance)
+    : Trajectory(std::move(spline), config.get(), constraints, spline_idx, vmax,
+                 num_distance) {
+  owned_config_ = std::move(config);
+}
+
 void Trajectory::LateralAccelPass() {
   for (size_t i = 0; i < plan_.size(); ++i) {
     const double distance = Distance(i);
@@ -751,7 +760,8 @@
 // finite-horizon much longer (albeit with the extension just using the
 // linearization for the infal point).
 void Trajectory::CalculatePathGains() {
-  const std::vector<Eigen::Matrix<double, 3, 1>> xva_plan = PlanXVA(config_.dt);
+  const std::vector<Eigen::Matrix<double, 3, 1>> xva_plan =
+      PlanXVA(config_->dt);
   if (xva_plan.empty()) {
     LOG(ERROR) << "Plan is empty--unable to plan trajectory.";
     return;
@@ -783,7 +793,7 @@
     PathRelativeContinuousSystem(distance, &A_continuous, &B_continuous);
     Eigen::Matrix<double, 5, 5> A_discrete;
     Eigen::Matrix<double, 5, 2> B_discrete;
-    controls::C2D(A_continuous, B_continuous, config_.dt, &A_discrete,
+    controls::C2D(A_continuous, B_continuous, config_->dt, &A_discrete,
                   &B_discrete);
 
     if (i == max_index) {
@@ -898,9 +908,9 @@
   result(2, 0) = spline().Theta(distance);
 
   result.block<2, 1>(3, 0) =
-      config_.Tla_to_lr() * (Eigen::Matrix<double, 2, 1>() << velocity,
-                             spline().DThetaDt(distance, velocity))
-                                .finished();
+      config_->Tla_to_lr() * (Eigen::Matrix<double, 2, 1>() << velocity,
+                              spline().DThetaDt(distance, velocity))
+                                 .finished();
   return result;
 }
 
diff --git a/frc971/control_loops/drivetrain/trajectory.h b/frc971/control_loops/drivetrain/trajectory.h
index a9f14f6..5964bf5 100644
--- a/frc971/control_loops/drivetrain/trajectory.h
+++ b/frc971/control_loops/drivetrain/trajectory.h
@@ -14,6 +14,11 @@
 #include "frc971/control_loops/runge_kutta.h"
 #include "frc971/control_loops/state_feedback_loop.h"
 
+// Note for all of these classes:
+// Whenever a pointer to a DrivetrainConfig is taken in the constructor, it must
+// live for the entire lifetime of the object. The classes here do not take
+// ownership of the pointer.
+
 namespace frc971::control_loops::drivetrain {
 
 template <typename F>
@@ -43,16 +48,16 @@
  public:
   BaseTrajectory(
       const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
-      const DrivetrainConfig<double> &config)
+      const DrivetrainConfig<double> *config)
       : BaseTrajectory(constraints, config,
                        std::make_shared<StateFeedbackLoop<
                            2, 2, 2, double, StateFeedbackHybridPlant<2, 2, 2>,
                            HybridKalman<2, 2, 2>>>(
-                           config.make_hybrid_drivetrain_velocity_loop())) {}
+                           config->make_hybrid_drivetrain_velocity_loop())) {}
 
   BaseTrajectory(
       const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
-      const DrivetrainConfig<double> &config,
+      const DrivetrainConfig<double> *config,
       std::shared_ptr<
           StateFeedbackLoop<2, 2, 2, double, StateFeedbackHybridPlant<2, 2, 2>,
                             HybridKalman<2, 2, 2>>>
@@ -192,7 +197,7 @@
                         HybridKalman<2, 2, 2>>>
       velocity_drivetrain_;
 
-  DrivetrainConfig<double> config_;
+  const DrivetrainConfig<double> *config_;
 
   // Robot radiuses.
   double robot_radius_l_;
@@ -209,20 +214,20 @@
   // Note: The lifetime of the supplied buffer is assumed to be greater than
   // that of this object.
   explicit FinishedTrajectory(
-      const DrivetrainConfig<double> &config, const fb::Trajectory *buffer,
+      const DrivetrainConfig<double> *config, const fb::Trajectory *buffer,
       std::shared_ptr<
           StateFeedbackLoop<2, 2, 2, double, StateFeedbackHybridPlant<2, 2, 2>,
                             HybridKalman<2, 2, 2>>>
           velocity_drivetrain);
 
-  explicit FinishedTrajectory(const DrivetrainConfig<double> &config,
+  explicit FinishedTrajectory(const DrivetrainConfig<double> *config,
                               const fb::Trajectory *buffer)
       : FinishedTrajectory(
             config, buffer,
             std::make_shared<StateFeedbackLoop<
                 2, 2, 2, double, StateFeedbackHybridPlant<2, 2, 2>,
                 HybridKalman<2, 2, 2>>>(
-                config.make_hybrid_drivetrain_velocity_loop())) {}
+                config->make_hybrid_drivetrain_velocity_loop())) {}
 
   FinishedTrajectory(const FinishedTrajectory &) = delete;
   FinishedTrajectory &operator=(const FinishedTrajectory &) = delete;
@@ -264,16 +269,23 @@
 class Trajectory : public BaseTrajectory {
  public:
   Trajectory(const SplineGoal &spline_goal,
-             const DrivetrainConfig<double> &config);
+             const DrivetrainConfig<double> *config);
   Trajectory(
-      DistanceSpline &&spline, const DrivetrainConfig<double> &config,
+      DistanceSpline &&spline, const DrivetrainConfig<double> *config,
       const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
       int spline_idx = 0, double vmax = 10.0, int num_distance = 0);
 
+  // Version that owns its own DrivetrainConfig.
+  Trajectory(
+      DistanceSpline &&spline, std::unique_ptr<DrivetrainConfig<double>> config,
+      const flatbuffers::Vector<flatbuffers::Offset<Constraint>> *constraints,
+      int spline_idx, double vmax, int num_distance);
+
   virtual ~Trajectory() = default;
 
   std::vector<Eigen::Matrix<double, 3, 1>> PlanXVA(std::chrono::nanoseconds dt);
 
+  const DrivetrainConfig<double> *drivetrain_config() { return config_; }
   enum class VoltageLimit {
     kConservative,
     kAggressive,
@@ -390,7 +402,8 @@
   // The spline we are planning for.
   const DistanceSpline spline_;
 
-  const DrivetrainConfig<double> config_;
+  std::unique_ptr<DrivetrainConfig<double>> owned_config_;
+  const DrivetrainConfig<double> *config_;
 
   // Velocities in the plan (distance for each index is defined by Distance())
   std::vector<double> plan_;
diff --git a/frc971/control_loops/drivetrain/trajectory_generator.cc b/frc971/control_loops/drivetrain/trajectory_generator.cc
index 8e3e0f8..e531167 100644
--- a/frc971/control_loops/drivetrain/trajectory_generator.cc
+++ b/frc971/control_loops/drivetrain/trajectory_generator.cc
@@ -14,7 +14,7 @@
 }
 
 void TrajectoryGenerator::HandleSplineGoal(const SplineGoal &goal) {
-  Trajectory trajectory(goal, dt_config_);
+  Trajectory trajectory(goal, &dt_config_);
   trajectory.Plan();
 
   aos::Sender<fb::Trajectory>::Builder builder =
diff --git a/frc971/control_loops/drivetrain/trajectory_plot.cc b/frc971/control_loops/drivetrain/trajectory_plot.cc
index 8c3ef47..9eb95b5 100644
--- a/frc971/control_loops/drivetrain/trajectory_plot.cc
+++ b/frc971/control_loops/drivetrain/trajectory_plot.cc
@@ -50,7 +50,7 @@
           (::Eigen::Matrix<double, 2, 4>() << 0.0, 1.2 * FLAGS_forward,
            -0.2 * FLAGS_forward, FLAGS_forward, 0.0, 0.0, 1.0, 1.0)
               .finished()))),
-      config, nullptr);
+      &config, nullptr);
   trajectory.set_lateral_acceleration(2.0);
   trajectory.set_longitudinal_acceleration(1.0);
 
@@ -131,7 +131,7 @@
   aos::FlatbufferDetachedBuffer<fb::Trajectory> trajectory_buffer(
       fbb.Release());
 
-  FinishedTrajectory finished_trajectory(config, &trajectory_buffer.message());
+  FinishedTrajectory finished_trajectory(&config, &trajectory_buffer.message());
 
   ::Eigen::Matrix<double, 5, 1> state = ::Eigen::Matrix<double, 5, 1>::Zero();
   state(0, 0) = FLAGS_dx;
diff --git a/frc971/control_loops/drivetrain/trajectory_test.cc b/frc971/control_loops/drivetrain/trajectory_test.cc
index fbf8970..6e9089a 100644
--- a/frc971/control_loops/drivetrain/trajectory_test.cc
+++ b/frc971/control_loops/drivetrain/trajectory_test.cc
@@ -74,7 +74,7 @@
     const int spline_index = 12345;
     // Run lots of steps to make the feedforwards terms more accurate.
     trajectory_ = ::std::unique_ptr<Trajectory>(new Trajectory(
-        DistanceSpline(GetParam().control_points), dt_config_,
+        DistanceSpline(GetParam().control_points), &dt_config_,
         /*constraints=*/nullptr, spline_index, GetParam().velocity_limit));
     distance_spline_ = &trajectory_->spline();
     trajectory_->set_lateral_acceleration(GetParam().lateral_acceleration);
@@ -111,7 +111,7 @@
     EXPECT_EQ(spline_index, trajectory_buffer_->message().handle());
 
     finished_trajectory_ = std::make_unique<FinishedTrajectory>(
-        dt_config_, &trajectory_buffer_->message());
+        &dt_config_, &trajectory_buffer_->message());
   }
 
   void TearDown() {
diff --git a/frc971/control_loops/python/constants.py b/frc971/control_loops/python/constants.py
index 260a6ad..f96e2fe 100644
--- a/frc971/control_loops/python/constants.py
+++ b/frc971/control_loops/python/constants.py
@@ -148,7 +148,8 @@
         return "y2022/actors/splines"
     elif field.year == 2023:
         return "y2023/autonomous/splines"
-    #TODO: Update 2024 spline jsons
+    elif field.year == 2024:
+        return "y2024/autonomous/splines"
     else:
         return "frc971/control_loops/python/spline_jsons"
 
diff --git a/frc971/control_loops/python/path_edit.py b/frc971/control_loops/python/path_edit.py
index 1659e55..0cd41cb 100755
--- a/frc971/control_loops/python/path_edit.py
+++ b/frc971/control_loops/python/path_edit.py
@@ -311,7 +311,8 @@
             file = filename.with_suffix(f".{index}.json")
             print(f"  {file.relative_to(export_folder)}")
             with open(file, mode='w') as points_file:
-                json.dump(multispline.toJsonObject(), points_file)
+                # Indent to make the file actually readable
+                json.dump(multispline.toJsonObject(), points_file, indent=4)
 
     def import_json(self, file_name):
         # Abort place mode
diff --git a/frc971/imu_fdcan/can_translator_lib.cc b/frc971/imu_fdcan/can_translator_lib.cc
index e4a927e..4336c82 100644
--- a/frc971/imu_fdcan/can_translator_lib.cc
+++ b/frc971/imu_fdcan/can_translator_lib.cc
@@ -3,7 +3,6 @@
 using frc971::imu_fdcan::CANTranslator;
 
 constexpr std::size_t kCanFrameSize = 64;
-constexpr int kImuCanId = 1;
 
 CANTranslator::CANTranslator(aos::EventLoop *event_loop,
                              std::string_view canframe_channel)
@@ -13,21 +12,23 @@
       can_translator_status_sender_(
           event_loop->MakeSender<frc971::imu::CanTranslatorStatusStatic>(
               "/imu")) {
+  packets_arrived_.fill(false);
   // TODO(max): Update this with a proper priority
   event_loop->SetRuntimeRealtimePriority(15);
 
   event_loop->MakeWatcher(
       canframe_channel, [this](const frc971::can_logger::CanFrame &can_frame) {
-        if (can_frame.data()->size() / sizeof(uint8_t) != 64) {
+        if (can_frame.data()->size() / sizeof(uint8_t) != 8) {
           invalid_packet_count_++;
+          return;
         }
 
-        if (can_frame.can_id() != kImuCanId) {
+        if (can_frame.can_id() == 0 || can_frame.can_id() > 8) {
           invalid_can_id_count_++;
           return;
         }
 
-        if (can_frame.data()->size() / sizeof(uint8_t) == 64) {
+        if (can_frame.data()->size() / sizeof(uint8_t) == 8) {
           valid_packet_count_++;
           HandleFrame(&can_frame);
         }
@@ -41,6 +42,7 @@
         status_builder->set_valid_packet_count(valid_packet_count_);
         status_builder->set_invalid_packet_count(invalid_packet_count_);
         status_builder->set_invalid_can_id_count(invalid_can_id_count_);
+        status_builder->set_out_of_order_count(out_of_order_count_);
 
         status_builder.CheckOk(status_builder.Send());
       },
@@ -61,10 +63,26 @@
 // Values from the data field mapping table in
 // https://docs.google.com/document/d/12AJUruW7DZ2pIrDzTyPC0qqFoia4QOSVlax6Jd7m4H0/edit?usp=sharing
 void CANTranslator::HandleFrame(const frc971::can_logger::CanFrame *can_frame) {
+  const size_t frame_index = can_frame->can_id() - 1u;
+  packets_arrived_[frame_index] = true;
+  for (size_t index = 0; index <= frame_index; ++index) {
+    if (!packets_arrived_[index]) {
+      ++out_of_order_count_;
+      packets_arrived_.fill(false);
+      return;
+    }
+  }
+  // Should have already checked length.
+  CHECK_EQ(can_frame->data()->size(), 8u);
+  memcpy(current_frame_.data() + frame_index * 8, can_frame->data()->data(), 8);
+  if (frame_index < 7) {
+    return;
+  }
+
   aos::Sender<frc971::imu::DualImuStatic>::StaticBuilder dual_imu_builder =
       dual_imu_sender_.MakeStaticBuilder();
 
-  std::span can_data(can_frame->data()->data(), kCanFrameSize);
+  std::span<const uint8_t> can_data(current_frame_.data(), kCanFrameSize);
 
   frc971::imu::SingleImuStatic *murata = dual_imu_builder->add_murata();
 
diff --git a/frc971/imu_fdcan/can_translator_lib.h b/frc971/imu_fdcan/can_translator_lib.h
index 12acb0a..af05aba 100644
--- a/frc971/imu_fdcan/can_translator_lib.h
+++ b/frc971/imu_fdcan/can_translator_lib.h
@@ -21,9 +21,13 @@
   aos::Sender<imu::DualImuStatic> dual_imu_sender_;
   aos::Sender<imu::CanTranslatorStatusStatic> can_translator_status_sender_;
 
+  std::array<uint8_t, 64> current_frame_;
+  std::array<bool, 8> packets_arrived_;
+
   uint64_t valid_packet_count_ = 0;
   uint64_t invalid_packet_count_ = 0;
   uint64_t invalid_can_id_count_ = 0;
+  uint64_t out_of_order_count_ = 0;
 };
 
 }  // namespace frc971::imu_fdcan
diff --git a/frc971/imu_fdcan/can_translator_lib_test.cc b/frc971/imu_fdcan/can_translator_lib_test.cc
index f8e6d85..fcef6a4 100644
--- a/frc971/imu_fdcan/can_translator_lib_test.cc
+++ b/frc971/imu_fdcan/can_translator_lib_test.cc
@@ -49,80 +49,23 @@
           aos::monotonic_clock::epoch() + std::chrono::seconds(0),
           aos::realtime_clock::epoch() + std::chrono::seconds(100));
   can_frame_event_loop_->OnRun([this] {
-    aos::Sender<frc971::can_logger::CanFrameStatic>::StaticBuilder
-        can_frame_builder = can_frame_sender_.MakeStaticBuilder();
+    std::array<uint8_t, 64> full_frame{
+        226, 100, 108, 8,   152, 40,  202, 121, 202, 121, 202, 121, 85,
+        85,  81,  189, 0,   0,   8,   189, 85,  213, 127, 191, 12,  189,
+        34,  187, 255, 219, 220, 59,  147, 173, 5,   61,  88,  68,  205,
+        188, 230, 92,  24,  189, 235, 1,   127, 191, 210, 7,   34,  54,
+        86,  103, 133, 186, 100, 205, 101, 185, 29,  26,  26,  0};
+    for (size_t i = 0; i < 8; ++i) {
+      aos::Sender<frc971::can_logger::CanFrameStatic>::StaticBuilder
+          can_frame_builder = can_frame_sender_.MakeStaticBuilder();
 
-    can_frame_builder->set_can_id(1);
-    can_frame_builder->set_realtime_timestamp_ns(100e9 + 971);
-    auto can_data = can_frame_builder->add_data();
-    CHECK(can_data->reserve(sizeof(uint8_t) * 64));
+      can_frame_builder->set_can_id(i + 1);
+      can_frame_builder->set_realtime_timestamp_ns(100e9 + 971);
+      auto can_data = can_frame_builder->add_data();
+      CHECK(can_data->FromData(full_frame.data() + i * 8, 8));
 
-    CHECK(can_data->emplace_back(226));
-    CHECK(can_data->emplace_back(100));
-    CHECK(can_data->emplace_back(108));
-    CHECK(can_data->emplace_back(8));
-    CHECK(can_data->emplace_back(152));
-    CHECK(can_data->emplace_back(40));
-    CHECK(can_data->emplace_back(202));
-    CHECK(can_data->emplace_back(121));
-    CHECK(can_data->emplace_back(202));
-    CHECK(can_data->emplace_back(121));
-    CHECK(can_data->emplace_back(202));
-    CHECK(can_data->emplace_back(121));
-    CHECK(can_data->emplace_back(85));
-    CHECK(can_data->emplace_back(85));
-    CHECK(can_data->emplace_back(81));
-    CHECK(can_data->emplace_back(189));
-    CHECK(can_data->emplace_back(0));
-    CHECK(can_data->emplace_back(0));
-    CHECK(can_data->emplace_back(8));
-    CHECK(can_data->emplace_back(189));
-    CHECK(can_data->emplace_back(85));
-    CHECK(can_data->emplace_back(213));
-    CHECK(can_data->emplace_back(127));
-    CHECK(can_data->emplace_back(191));
-    CHECK(can_data->emplace_back(12));
-    CHECK(can_data->emplace_back(189));
-    CHECK(can_data->emplace_back(34));
-    CHECK(can_data->emplace_back(187));
-    CHECK(can_data->emplace_back(255));
-    CHECK(can_data->emplace_back(219));
-    CHECK(can_data->emplace_back(220));
-    CHECK(can_data->emplace_back(59));
-    CHECK(can_data->emplace_back(147));
-    CHECK(can_data->emplace_back(173));
-    CHECK(can_data->emplace_back(5));
-    CHECK(can_data->emplace_back(61));
-    CHECK(can_data->emplace_back(88));
-    CHECK(can_data->emplace_back(68));
-    CHECK(can_data->emplace_back(205));
-    CHECK(can_data->emplace_back(188));
-    CHECK(can_data->emplace_back(230));
-    CHECK(can_data->emplace_back(92));
-    CHECK(can_data->emplace_back(24));
-    CHECK(can_data->emplace_back(189));
-    CHECK(can_data->emplace_back(235));
-    CHECK(can_data->emplace_back(1));
-    CHECK(can_data->emplace_back(127));
-    CHECK(can_data->emplace_back(191));
-    CHECK(can_data->emplace_back(210));
-    CHECK(can_data->emplace_back(7));
-    CHECK(can_data->emplace_back(34));
-    CHECK(can_data->emplace_back(54));
-    CHECK(can_data->emplace_back(86));
-    CHECK(can_data->emplace_back(103));
-    CHECK(can_data->emplace_back(133));
-    CHECK(can_data->emplace_back(186));
-    CHECK(can_data->emplace_back(100));
-    CHECK(can_data->emplace_back(205));
-    CHECK(can_data->emplace_back(101));
-    CHECK(can_data->emplace_back(185));
-    CHECK(can_data->emplace_back(29));
-    CHECK(can_data->emplace_back(26));
-    CHECK(can_data->emplace_back(26));
-    CHECK(can_data->emplace_back(0));
-
-    can_frame_builder.CheckOk(can_frame_builder.Send());
+      can_frame_builder.CheckOk(can_frame_builder.Send());
+    }
   });
 
   event_loop_factory_.RunFor(std::chrono::milliseconds(200));
@@ -132,7 +75,7 @@
 
   ASSERT_FALSE(can_translator_status_fetcher_->invalid_packet_count() > 0);
   ASSERT_FALSE(can_translator_status_fetcher_->invalid_can_id_count() > 0);
-  EXPECT_EQ(can_translator_status_fetcher_->valid_packet_count(), 1);
+  EXPECT_EQ(can_translator_status_fetcher_->valid_packet_count(), 8);
 
   EXPECT_EQ(dual_imu_fetcher_->board_timestamp_us(), 141321442);
   EXPECT_EQ(dual_imu_fetcher_->packet_counter(), 10392);
@@ -177,12 +120,11 @@
     aos::Sender<frc971::can_logger::CanFrameStatic>::StaticBuilder
         can_frame_builder = can_frame_sender_.MakeStaticBuilder();
 
-    can_frame_builder->set_can_id(2);
+    can_frame_builder->set_can_id(10);
     can_frame_builder->set_realtime_timestamp_ns(100);
     auto can_data = can_frame_builder->add_data();
-    CHECK(can_data->reserve(sizeof(uint8_t) * 1));
-
-    CHECK(can_data->emplace_back(0));
+    CHECK(can_data->reserve(sizeof(uint8_t) * 8));
+    can_data->resize(8);
 
     can_frame_builder.CheckOk(can_frame_builder.Send());
   });
@@ -192,6 +134,6 @@
   ASSERT_TRUE(can_translator_status_fetcher_.Fetch());
   ASSERT_FALSE(dual_imu_fetcher_.Fetch());
 
-  EXPECT_EQ(can_translator_status_fetcher_->invalid_packet_count(), 1);
+  EXPECT_EQ(can_translator_status_fetcher_->invalid_packet_count(), 0);
   EXPECT_EQ(can_translator_status_fetcher_->invalid_can_id_count(), 1);
 }
diff --git a/frc971/imu_fdcan/can_translator_status.fbs b/frc971/imu_fdcan/can_translator_status.fbs
index 232f3ba..63273bc 100644
--- a/frc971/imu_fdcan/can_translator_status.fbs
+++ b/frc971/imu_fdcan/can_translator_status.fbs
@@ -7,6 +7,8 @@
   invalid_packet_count: uint64 (id: 1);
   // Number of times we've gotten an invalid can id
   invalid_can_id_count: uint64 (id: 2);
+  // Number of times that we have observed an out of order can id.
+  out_of_order_count: uint64 (id: 3);
 }
 
 root_type CanTranslatorStatus;
diff --git a/frc971/imu_fdcan/dual_imu_blender_lib.cc b/frc971/imu_fdcan/dual_imu_blender_lib.cc
index 09655e3..2627739 100644
--- a/frc971/imu_fdcan/dual_imu_blender_lib.cc
+++ b/frc971/imu_fdcan/dual_imu_blender_lib.cc
@@ -14,6 +14,7 @@
 // Coefficient to multiply the saturation values by to give some room on where
 // we switch to tdk.
 static constexpr double kSaturationCoeff = 0.9;
+static constexpr size_t kSaturationCounterThreshold = 20;
 
 using frc971::imu_fdcan::DualImuBlender;
 
@@ -66,6 +67,12 @@
 
   if (std::abs(dual_imu->tdk()->gyro_z()) >=
       kSaturationCoeff * kMurataGyroSaturation) {
+    ++saturated_counter_;
+  } else {
+    saturated_counter_ = 0;
+  }
+
+  if (saturated_counter_ > kSaturationCounterThreshold) {
     dual_imu_blender_status_builder->set_gyro_z(imu::ImuType::TDK);
     imu_values->set_gyro_z(dual_imu->tdk()->gyro_z());
   } else {
diff --git a/frc971/imu_fdcan/dual_imu_blender_lib.h b/frc971/imu_fdcan/dual_imu_blender_lib.h
index 04044d2..b3aa5c0 100644
--- a/frc971/imu_fdcan/dual_imu_blender_lib.h
+++ b/frc971/imu_fdcan/dual_imu_blender_lib.h
@@ -20,6 +20,7 @@
  private:
   aos::Sender<IMUValuesBatchStatic> imu_values_batch_sender_;
   aos::Sender<imu::DualImuBlenderStatusStatic> dual_imu_blender_status_sender_;
+  size_t saturated_counter_ = 0;
 };
 
 }  // namespace frc971::imu_fdcan
diff --git a/frc971/imu_fdcan/dual_imu_blender_lib_test.cc b/frc971/imu_fdcan/dual_imu_blender_lib_test.cc
index b3a3015..4237bd1 100644
--- a/frc971/imu_fdcan/dual_imu_blender_lib_test.cc
+++ b/frc971/imu_fdcan/dual_imu_blender_lib_test.cc
@@ -30,6 +30,7 @@
         dual_imu_blender_(dual_imu_blender_event_loop_.get()) {}
 
   void CheckImuType(frc971::imu::ImuType type) {
+    dual_imu_blender_status_fetcher_.Fetch();
     EXPECT_EQ(dual_imu_blender_status_fetcher_->gyro_x(), type);
     EXPECT_EQ(dual_imu_blender_status_fetcher_->gyro_y(), type);
     EXPECT_EQ(dual_imu_blender_status_fetcher_->gyro_z(), type);
@@ -182,41 +183,43 @@
   CheckImuType(frc971::imu::ImuType::MURATA);
 
   // Make sure we switch to TDK on saturation
-  dual_imu_blender_event_loop_->OnRun([this] {
-    aos::Sender<frc971::imu::DualImuStatic>::StaticBuilder dual_imu_builder =
-        dual_imu_sender_.MakeStaticBuilder();
+  dual_imu_blender_event_loop_->AddPhasedLoop(
+      [this](int) {
+        aos::Sender<frc971::imu::DualImuStatic>::StaticBuilder
+            dual_imu_builder = dual_imu_sender_.MakeStaticBuilder();
 
-    frc971::imu::SingleImuStatic *murata = dual_imu_builder->add_murata();
+        frc971::imu::SingleImuStatic *murata = dual_imu_builder->add_murata();
 
-    auto *murata_chip_states = murata->add_chip_states();
-    frc971::imu::ChipStateStatic *murata_uno_chip_state =
-        murata_chip_states->emplace_back();
+        auto *murata_chip_states = murata->add_chip_states();
+        frc971::imu::ChipStateStatic *murata_uno_chip_state =
+            murata_chip_states->emplace_back();
 
-    frc971::imu::SingleImuStatic *tdk = dual_imu_builder->add_tdk();
+        frc971::imu::SingleImuStatic *tdk = dual_imu_builder->add_tdk();
 
-    dual_imu_builder->set_board_timestamp_us(1);
-    dual_imu_builder->set_kernel_timestamp(1);
+        dual_imu_builder->set_board_timestamp_us(1);
+        dual_imu_builder->set_kernel_timestamp(1);
 
-    tdk->set_gyro_x(6.0);
-    tdk->set_gyro_y(6.0);
-    tdk->set_gyro_z(6.0);
+        tdk->set_gyro_x(6.0);
+        tdk->set_gyro_y(6.0);
+        tdk->set_gyro_z(6.0);
 
-    murata->set_gyro_x(5.2);
-    murata->set_gyro_y(5.2);
-    murata->set_gyro_z(5.2);
+        murata->set_gyro_x(5.2);
+        murata->set_gyro_y(5.2);
+        murata->set_gyro_z(5.2);
 
-    tdk->set_accelerometer_x(6.2);
-    tdk->set_accelerometer_y(6.3);
-    tdk->set_accelerometer_z(6.5);
+        tdk->set_accelerometer_x(6.2);
+        tdk->set_accelerometer_y(6.3);
+        tdk->set_accelerometer_z(6.5);
 
-    murata->set_accelerometer_x(5.5);
-    murata->set_accelerometer_y(5.5);
-    murata->set_accelerometer_z(5.5);
+        murata->set_accelerometer_x(5.5);
+        murata->set_accelerometer_y(5.5);
+        murata->set_accelerometer_z(5.5);
 
-    murata_uno_chip_state->set_temperature(20);
+        murata_uno_chip_state->set_temperature(20);
 
-    dual_imu_builder.CheckOk(dual_imu_builder.Send());
-  });
+        dual_imu_builder.CheckOk(dual_imu_builder.Send());
+      },
+      std::chrono::milliseconds(1));
 
   event_loop_factory_.RunFor(std::chrono::milliseconds(200));
 
@@ -243,41 +246,43 @@
   CheckImuType(frc971::imu::ImuType::TDK);
 
   // Check negative values as well
-  dual_imu_blender_event_loop_->OnRun([this] {
-    aos::Sender<frc971::imu::DualImuStatic>::StaticBuilder dual_imu_builder =
-        dual_imu_sender_.MakeStaticBuilder();
+  dual_imu_blender_event_loop_->AddPhasedLoop(
+      [this](int) {
+        aos::Sender<frc971::imu::DualImuStatic>::StaticBuilder
+            dual_imu_builder = dual_imu_sender_.MakeStaticBuilder();
 
-    frc971::imu::SingleImuStatic *murata = dual_imu_builder->add_murata();
+        frc971::imu::SingleImuStatic *murata = dual_imu_builder->add_murata();
 
-    auto *murata_chip_states = murata->add_chip_states();
-    frc971::imu::ChipStateStatic *murata_uno_chip_state =
-        murata_chip_states->emplace_back();
+        auto *murata_chip_states = murata->add_chip_states();
+        frc971::imu::ChipStateStatic *murata_uno_chip_state =
+            murata_chip_states->emplace_back();
 
-    frc971::imu::SingleImuStatic *tdk = dual_imu_builder->add_tdk();
+        frc971::imu::SingleImuStatic *tdk = dual_imu_builder->add_tdk();
 
-    dual_imu_builder->set_board_timestamp_us(1);
-    dual_imu_builder->set_kernel_timestamp(1);
+        dual_imu_builder->set_board_timestamp_us(1);
+        dual_imu_builder->set_kernel_timestamp(1);
 
-    tdk->set_gyro_x(-6.0);
-    tdk->set_gyro_y(-6.0);
-    tdk->set_gyro_z(-6.0);
+        tdk->set_gyro_x(-6.0);
+        tdk->set_gyro_y(-6.0);
+        tdk->set_gyro_z(-6.0);
 
-    murata->set_gyro_x(-5.2);
-    murata->set_gyro_y(-5.2);
-    murata->set_gyro_z(-5.2);
+        murata->set_gyro_x(-5.2);
+        murata->set_gyro_y(-5.2);
+        murata->set_gyro_z(-5.2);
 
-    tdk->set_accelerometer_x(-6.2);
-    tdk->set_accelerometer_y(-6.3);
-    tdk->set_accelerometer_z(-6.5);
+        tdk->set_accelerometer_x(-6.2);
+        tdk->set_accelerometer_y(-6.3);
+        tdk->set_accelerometer_z(-6.5);
 
-    murata->set_accelerometer_x(-5.5);
-    murata->set_accelerometer_y(-5.5);
-    murata->set_accelerometer_z(-5.5);
+        murata->set_accelerometer_x(-5.5);
+        murata->set_accelerometer_y(-5.5);
+        murata->set_accelerometer_z(-5.5);
 
-    murata_uno_chip_state->set_temperature(20);
+        murata_uno_chip_state->set_temperature(20);
 
-    dual_imu_builder.CheckOk(dual_imu_builder.Send());
-  });
+        dual_imu_builder.CheckOk(dual_imu_builder.Send());
+      },
+      std::chrono::milliseconds(1));
 
   event_loop_factory_.RunFor(std::chrono::milliseconds(200));
 
diff --git a/frc971/imu_fdcan/dual_imu_test_config_source.json b/frc971/imu_fdcan/dual_imu_test_config_source.json
index eda06da..c425be2 100644
--- a/frc971/imu_fdcan/dual_imu_test_config_source.json
+++ b/frc971/imu_fdcan/dual_imu_test_config_source.json
@@ -12,7 +12,7 @@
     {
       "name": "/imu",
       "type": "frc971.imu.DualImu",
-      "frequency": 200
+      "frequency": 2000
     },
     {
       "name": "/can",
@@ -32,7 +32,7 @@
     {
       "name": "/imu",
       "type": "frc971.imu.DualImuBlenderStatus",
-      "frequency": 100
+      "frequency": 2000
     },
   ]
 }
diff --git a/frc971/math/flatbuffers_matrix.h b/frc971/math/flatbuffers_matrix.h
index 57013c9..cfca897 100644
--- a/frc971/math/flatbuffers_matrix.h
+++ b/frc971/math/flatbuffers_matrix.h
@@ -121,6 +121,22 @@
   return true;
 }
 
+template <int Rows, int Cols,
+          fbs::StorageOrder StorageOrder = fbs::StorageOrder::ColMajor>
+flatbuffers::Offset<fbs::Matrix> FromEigen(
+    const typename EigenMatrix<Rows, Cols, StorageOrder>::type &matrix,
+    flatbuffers::FlatBufferBuilder *fbb) {
+  constexpr size_t kSize = Rows * Cols;
+  flatbuffers::Offset<flatbuffers::Vector<double>> data_offset =
+      fbb->CreateVector(matrix.data(), kSize);
+  fbs::Matrix::Builder builder(*fbb);
+  builder.add_rows(Rows);
+  builder.add_cols(Cols);
+  builder.add_storage_order(StorageOrder);
+  builder.add_data(data_offset);
+  return builder.Finish();
+}
+
 template <typename T>
 bool FromEigen(const T &matrix, fbs::MatrixStatic *flatbuffer) {
   return FromEigen<T::RowsAtCompileTime, T::ColsAtCompileTime,
diff --git a/frc971/math/flatbuffers_matrix_test.cc b/frc971/math/flatbuffers_matrix_test.cc
index 2807309..b2587f3 100644
--- a/frc971/math/flatbuffers_matrix_test.cc
+++ b/frc971/math/flatbuffers_matrix_test.cc
@@ -24,11 +24,18 @@
   const Eigen::Matrix<double, 3, 4> expected{
       {0, 1, 2, 3}, {4, 5, 6, 7}, {8, 9, 10, 11}};
   aos::fbs::Builder<fbs::MatrixStatic> builder;
+  flatbuffers::FlatBufferBuilder fbb;
   ASSERT_TRUE(FromEigen(expected, builder.get()));
+  fbb.Finish(FromEigen<3, 4>(expected, &fbb));
   EXPECT_EQ(
       "{ \"rows\": 3, \"cols\": 4, \"storage_order\": \"ColMajor\", \"data\": "
       "[ 0.0, 4.0, 8.0, 1.0, 5.0, 9.0, 2.0, 6.0, 10.0, 3.0, 7.0, 11.0 ] }",
       aos::FlatbufferToJson(builder.AsFlatbufferSpan()));
+  EXPECT_EQ(
+      "{ \"rows\": 3, \"cols\": 4, \"storage_order\": \"ColMajor\", \"data\": "
+      "[ 0.0, 4.0, 8.0, 1.0, 5.0, 9.0, 2.0, 6.0, 10.0, 3.0, 7.0, 11.0 ] }",
+      aos::FlatbufferToJson(
+          aos::FlatbufferDetachedBuffer<fbs::Matrix>(fbb.Release())));
 
   const Eigen::Matrix<double, 3, 4> result =
       ToEigenOrDie<3, 4>(builder->AsFlatbuffer());
diff --git a/frc971/orin/BUILD b/frc971/orin/BUILD
index 06b1d6c..3ce9040 100644
--- a/frc971/orin/BUILD
+++ b/frc971/orin/BUILD
@@ -1,5 +1,7 @@
 load("//frc971:halide.bzl", "halide_library")
 
+exports_files(["orin_irq_config.json"])
+
 halide_library(
     name = "ycbcr",
     src = "crcv_generator.cc",
diff --git a/frc971/orin/gpu_apriltag.cc b/frc971/orin/gpu_apriltag.cc
index 55c5cd5..19bee47 100644
--- a/frc971/orin/gpu_apriltag.cc
+++ b/frc971/orin/gpu_apriltag.cc
@@ -26,7 +26,7 @@
     "1.0.");
 DEFINE_double(min_decision_margin, 50.0,
               "Minimum decision margin (confidence) for an apriltag detection");
-DEFINE_int32(pixel_border, 10,
+DEFINE_int32(pixel_border, 150,
              "Size of image border within which to reject detected corners");
 DEFINE_uint64(pose_estimation_iterations, 50,
               "Number of iterations for apriltag pose estimation.");
@@ -299,8 +299,10 @@
                                           before_pose_estimation)
                      .count()
               << " seconds for pose estimation";
-      VLOG(1) << "Pose err 1: " << pose_error_1;
-      VLOG(1) << "Pose err 2: " << pose_error_2;
+      VLOG(1) << "Pose err 1: " << std::setprecision(20) << std::fixed
+              << pose_error_1 << " " << (pose_error_1 < 1e-6 ? "Good" : "Bad");
+      VLOG(1) << "Pose err 2: " << std::setprecision(20) << std::fixed
+              << pose_error_2 << " " << (pose_error_2 < 1e-6 ? "Good" : "Bad");
 
       // Send undistorted corner points in pink
       std::vector<cv::Point2f> corner_points = MakeCornerVector(gpu_detection);
@@ -409,7 +411,7 @@
     timeprofile_display(tag_detector_->tp);
   }
 
-  VLOG(1) << "Took " << chrono::duration<double>(end_time - start_time).count()
+  VLOG(2) << "Took " << chrono::duration<double>(end_time - start_time).count()
           << " seconds to detect overall";
 
   return;
diff --git a/frc971/orin/orin_irq_config.json b/frc971/orin/orin_irq_config.json
index 0edb72b..66699c8 100644
--- a/frc971/orin/orin_irq_config.json
+++ b/frc971/orin/orin_irq_config.json
@@ -328,11 +328,6 @@
       "priority": 51
     },
     {
-      "name": "ivc/*.rtc",
-      "scheduler": "SCHEDULER_FIFO",
-      "nice": 49
-    },
-    {
       "name": "nvgpu_nvs_ga10b",
       "scheduler": "SCHEDULER_OTHER",
       "affinity": [2, 3],
diff --git a/frc971/vision/foxglove_image_converter_lib.cc b/frc971/vision/foxglove_image_converter_lib.cc
index 920eaf7..649f1bb 100644
--- a/frc971/vision/foxglove_image_converter_lib.cc
+++ b/frc971/vision/foxglove_image_converter_lib.cc
@@ -6,6 +6,8 @@
 DEFINE_int32(jpeg_quality, 60,
              "Compression quality of JPEGs, 0-100; lower numbers mean lower "
              "quality and resulting image sizes.");
+DEFINE_uint32(max_period_ms, 100,
+              "Fastest period at which to convert images, to limit CPU usage.");
 
 namespace frc971::vision {
 std::string_view ExtensionForCompression(ImageCompression compression) {
@@ -49,9 +51,13 @@
           event_loop_, input_channel,
           [this, compression](const cv::Mat image,
                               const aos::monotonic_clock::time_point eof) {
-            auto builder = sender_.MakeBuilder();
-            builder.CheckOk(builder.Send(
-                CompressImage(image, eof, builder.fbb(), compression)));
+            if (event_loop_->monotonic_now() >
+                (std::chrono::milliseconds(FLAGS_max_period_ms) +
+                 sender_.monotonic_sent_time())) {
+              auto builder = sender_.MakeBuilder();
+              builder.CheckOk(builder.Send(
+                  CompressImage(image, eof, builder.fbb(), compression)));
+            }
           }),
       sender_(
           event_loop_->MakeSender<foxglove::CompressedImage>(output_channel)) {}
diff --git a/frc971/vision/target_mapper.cc b/frc971/vision/target_mapper.cc
index c14b032..f5c8dab 100644
--- a/frc971/vision/target_mapper.cc
+++ b/frc971/vision/target_mapper.cc
@@ -23,6 +23,8 @@
 DEFINE_double(outlier_std_devs, 1.0,
               "Number of standard deviations above average error needed for a "
               "constraint to be considered an outlier and get removed.");
+DEFINE_bool(do_map_fitting, false,
+            "Whether to do a final fit of the solved map to the original map");
 
 namespace frc971::vision {
 Eigen::Affine3d PoseUtils::Pose3dToAffine3d(
@@ -448,36 +450,38 @@
   CHECK(SolveOptimizationProblem(&target_pose_problem_2))
       << "The target pose solve 2 was not successful, exiting.";
 
-  LOG(INFO) << "Solving the overall map's best alignment to the previous map";
-  ceres::Problem map_fitting_problem(
-      {.loss_function_ownership = ceres::DO_NOT_TAKE_OWNERSHIP});
-  std::unique_ptr<ceres::CostFunction> map_fitting_cost_function =
-      BuildMapFittingOptimizationProblem(&map_fitting_problem);
-  CHECK(SolveOptimizationProblem(&map_fitting_problem))
-      << "The map fitting solve was not successful, exiting.";
-  map_fitting_cost_function.release();
+  if (FLAGS_do_map_fitting) {
+    LOG(INFO) << "Solving the overall map's best alignment to the previous map";
+    ceres::Problem map_fitting_problem(
+        {.loss_function_ownership = ceres::DO_NOT_TAKE_OWNERSHIP});
+    std::unique_ptr<ceres::CostFunction> map_fitting_cost_function =
+        BuildMapFittingOptimizationProblem(&map_fitting_problem);
+    CHECK(SolveOptimizationProblem(&map_fitting_problem))
+        << "The map fitting solve was not successful, exiting.";
+    map_fitting_cost_function.release();
 
-  Eigen::Affine3d H_frozen_actual = T_frozen_actual_ * R_frozen_actual_;
-  LOG(INFO) << "H_frozen_actual: "
-            << PoseUtils::Affine3dToPose3d(H_frozen_actual);
+    Eigen::Affine3d H_frozen_actual = T_frozen_actual_ * R_frozen_actual_;
+    LOG(INFO) << "H_frozen_actual: "
+              << PoseUtils::Affine3dToPose3d(H_frozen_actual);
 
-  auto H_world_frozen =
-      PoseUtils::Pose3dToAffine3d(target_poses_[FLAGS_frozen_target_id]);
-  auto H_world_frozenactual = H_world_frozen * H_frozen_actual;
+    auto H_world_frozen =
+        PoseUtils::Pose3dToAffine3d(target_poses_[FLAGS_frozen_target_id]);
+    auto H_world_frozenactual = H_world_frozen * H_frozen_actual;
 
-  // Offset the solved poses to become the actual ones
-  for (auto &[id, pose] : target_poses_) {
-    // Don't offset targets we didn't solve for
-    if (id < FLAGS_min_target_id || id > FLAGS_max_target_id) {
-      continue;
+    // Offset the solved poses to become the actual ones
+    for (auto &[id, pose] : target_poses_) {
+      // Don't offset targets we didn't solve for
+      if (id < FLAGS_min_target_id || id > FLAGS_max_target_id) {
+        continue;
+      }
+
+      // Take the delta between the frozen target and the solved target, and put
+      // that on top of the actual pose of the frozen target
+      auto H_world_solved = PoseUtils::Pose3dToAffine3d(pose);
+      auto H_frozen_solved = H_world_frozen.inverse() * H_world_solved;
+      auto H_world_actual = H_world_frozenactual * H_frozen_solved;
+      pose = PoseUtils::Affine3dToPose3d(H_world_actual);
     }
-
-    // Take the delta between the frozen target and the solved target, and put
-    // that on top of the actual pose of the frozen target
-    auto H_world_solved = PoseUtils::Pose3dToAffine3d(pose);
-    auto H_frozen_solved = H_world_frozen.inverse() * H_world_solved;
-    auto H_world_actual = H_world_frozenactual * H_frozen_solved;
-    pose = PoseUtils::Affine3dToPose3d(H_world_actual);
   }
 
   auto map_json = MapToJson(field_name);
diff --git a/frc971/wpilib/pdp_fetcher.cc b/frc971/wpilib/pdp_fetcher.cc
index 5b02bb5..b8c5bf9 100644
--- a/frc971/wpilib/pdp_fetcher.cc
+++ b/frc971/wpilib/pdp_fetcher.cc
@@ -26,7 +26,8 @@
 PDPFetcher::~PDPFetcher() {}
 
 void PDPFetcher::Loop(int iterations) {
-  if (iterations != 1) {
+  // Only pollute the logs if we've missed many iterations.
+  if (iterations > 3) {
     AOS_LOG(DEBUG, "PDPFetcher skipped %d iterations\n", iterations - 1);
   }
   std::array<double, 16> currents;
diff --git a/frc971/wpilib/talonfx.cc b/frc971/wpilib/talonfx.cc
index 7959f2f..ba88548 100644
--- a/frc971/wpilib/talonfx.cc
+++ b/frc971/wpilib/talonfx.cc
@@ -62,6 +62,7 @@
   current_limits.StatorCurrentLimitEnable = true;
   current_limits.SupplyCurrentLimit = supply_current_limit_;
   current_limits.SupplyCurrentLimitEnable = true;
+  current_limits.SupplyTimeThreshold = 0.0;
 
   ctre::phoenix6::configs::MotorOutputConfigs output_configs;
   output_configs.NeutralMode = neutral_mode_;
diff --git a/package.json b/package.json
index df5df67..6853a14 100644
--- a/package.json
+++ b/package.json
@@ -11,18 +11,23 @@
     "@angular/core": "v16-lts",
     "@angular/forms": "v16-lts",
     "@angular/platform-browser": "v16-lts",
+    "@angular/service-worker": "v16-lts",
     "@angular/cli": "v16-lts",
     "@babel/cli": "^7.16.0",
     "@babel/core": "^7.16.0",
     "@types/jasmine": "3.10.3",
     "@types/babel__core": "^7.20.5",
     "@types/babel__generator": "^7.6.8",
+    "@types/pako": "2.0.3",
+    "angularx-qrcode": "^16.0.2",
     "html-insert-assets": "0.14.3",
-    "cypress": "12.3.0",
+    "cypress": "13.3.1",
+    "pako": "2.1.0",
     "prettier": "2.6.1",
     "requirejs": "2.3.6",
     "rollup": "4.12.0",
     "rxjs": "7.5.7",
+    "dexie": "^3.2.5",
     "@rollup/plugin-node-resolve": "15.2.3",
     "@types/flatbuffers": "1.10.0",
     "@types/node": "20.11.19",
@@ -30,4 +35,4 @@
     "terser": "5.16.4",
     "zone.js": "^0.13.0"
   }
-}
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8f37b3b..bf6a429 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@
       '@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)
+      '@angular/service-worker':
+        specifier: v16-lts
+        version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)
       '@babel/cli':
         specifier: ^7.16.0
         version: 7.23.9(@babel/core@7.23.9)
@@ -56,12 +59,24 @@
       '@types/node':
         specifier: 20.11.19
         version: 20.11.19
+      '@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)
       cypress:
-        specifier: 12.3.0
-        version: 12.3.0
+        specifier: 13.3.1
+        version: 13.3.1
+      dexie:
+        specifier: ^3.2.5
+        version: 3.2.5(karma@6.4.3)
       html-insert-assets:
         specifier: 0.14.3
         version: 0.14.3
+      pako:
+        specifier: 2.1.0
+        version: 2.1.0
       prettier:
         specifier: 2.6.1
         version: 2.6.1
@@ -102,6 +117,12 @@
       '@org_frc971/scouting/www/counter_button':
         specifier: workspace:*
         version: link:../counter_button
+      '@types/pako':
+        specifier: 2.0.3
+        version: 2.0.3
+      pako:
+        specifier: 2.1.0
+        version: 2.1.0
 
   scouting/www/match_list:
     dependencies:
@@ -126,6 +147,18 @@
 
   scouting/www/rpc: {}
 
+  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)
+      '@types/pako':
+        specifier: 2.0.3
+        version: 2.0.3
+      pako:
+        specifier: 2.1.0
+        version: 2.1.0
+
   scouting/www/shift_schedule:
     dependencies:
       '@angular/forms':
@@ -314,6 +347,19 @@
       '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
       tslib: 2.6.0
 
+  /@angular/service-worker@16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12):
+    resolution: {integrity: sha512-o0z0s4c76NmRASa+mUHn/q6vUKQNa06mGmLBDKm84vRQ1sQ2TJv+R1p8K9WkiM5mGy6tjQCDOgaz13TcxMFWOQ==}
+    engines: {node: ^16.14.0 || >=18.10.0}
+    hasBin: true
+    peerDependencies:
+      '@angular/common': 16.2.12
+      '@angular/core': 16.2.12
+    dependencies:
+      '@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==}
     engines: {node: '>=6.9.0'}
@@ -567,10 +613,9 @@
     engines: {node: '>=0.1.90'}
     requiresBuild: true
     dev: true
-    optional: true
 
-  /@cypress/request@2.88.11:
-    resolution: {integrity: sha512-M83/wfQ1EkspjkE2lNWNV5ui2Cv7UCv1swW1DqljahbzLVWltcsexQh8jYtuS/vzFXP+HySntGM83ZXA9fn17w==}
+  /@cypress/request@3.0.1:
+    resolution: {integrity: sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==}
     engines: {node: '>= 6'}
     dependencies:
       aws-sign2: 0.7.0
@@ -588,7 +633,7 @@
       performance-now: 2.1.0
       qs: 6.10.4
       safe-buffer: 5.2.1
-      tough-cookie: 2.5.0
+      tough-cookie: 4.1.3
       tunnel-agent: 0.6.0
       uuid: 8.3.2
     dev: true
@@ -925,6 +970,10 @@
       - supports-color
     dev: true
 
+  /@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==}
     engines: {node: '>= 10'}
@@ -972,6 +1021,16 @@
       '@babel/types': 7.23.9
     dev: true
 
+  /@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==}
     dev: true
@@ -984,8 +1043,10 @@
     resolution: {integrity: sha512-SWyMrjgdAUHNQmutvDcKablrJhkDLy4wunTme8oYLjKp41GnHGxMRXr2MQMvy/qy8H3LdzwQk9gH4hZ6T++H8g==}
     dev: true
 
-  /@types/node@14.18.53:
-    resolution: {integrity: sha512-soGmOpVBUq+gaBMwom1M+krC/NNbWlosh4AtGA03SyWNDiqSKtwp7OulO1M6+mg8YkHMvJ/y0AkCeO8d1hNb7A==}
+  /@types/node@18.19.22:
+    resolution: {integrity: sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==}
+    dependencies:
+      undici-types: 5.26.5
     dev: true
 
   /@types/node@20.11.19:
@@ -994,6 +1055,9 @@
       undici-types: 5.26.5
     dev: true
 
+  /@types/pako@2.0.3:
+    resolution: {integrity: sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==}
+
   /@types/resolve@1.20.2:
     resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
     dev: true
@@ -1022,6 +1086,14 @@
     resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
     dev: true
 
+  /accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.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==}
     engines: {node: '>=0.4.0'}
@@ -1072,6 +1144,16 @@
       uri-js: 4.4.1
     dev: true
 
+  /angularx-qrcode@16.0.2(@angular/core@16.2.12):
+    resolution: {integrity: sha512-FztOM7vjNu88sGxUU5jG2I+A9TxZBXXYBWINjpwIBbTL+COMgrtzXnScG7TyQeNknv5w3WFJWn59PcngRRYVXA==}
+    peerDependencies:
+      '@angular/core': ^16.0.0
+    dependencies:
+      '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
+      qrcode: 1.5.3
+      tslib: 2.6.0
+    dev: true
+
   /ansi-colors@4.1.3:
     resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
     engines: {node: '>=6'}
@@ -1182,6 +1264,11 @@
     resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
     dev: true
 
+  /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==}
     dependencies:
@@ -1209,6 +1296,26 @@
     resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
     dev: true
 
+  /body-parser@1.20.2:
+    resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+    dependencies:
+      bytes: 3.1.2
+      content-type: 1.0.5
+      debug: 2.6.9
+      depd: 2.0.0
+      destroy: 1.2.0
+      http-errors: 2.0.0
+      iconv-lite: 0.4.24
+      on-finished: 2.4.1
+      qs: 6.11.0
+      raw-body: 2.5.2
+      type-is: 1.6.18
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /brace-expansion@1.1.11:
     resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
     dependencies:
@@ -1266,6 +1373,11 @@
       semver: 7.6.0
     dev: true
 
+  /bytes@3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+    dev: true
+
   /cacache@16.1.3:
     resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
     engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -1318,10 +1430,15 @@
   /call-bind@1.0.2:
     resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
     dependencies:
-      function-bind: 1.1.1
+      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==}
     dev: true
@@ -1420,6 +1537,22 @@
     engines: {node: '>= 10'}
     dev: true
 
+  /cliui@6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+    dependencies:
+      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==}
+    dependencies:
+      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==}
     engines: {node: '>=12'}
@@ -1480,8 +1613,8 @@
     engines: {node: '>= 6'}
     dev: true
 
-  /commander@5.1.0:
-    resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
+  /commander@6.2.1:
+    resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
     engines: {node: '>= 6'}
     dev: true
 
@@ -1494,10 +1627,27 @@
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
     dev: true
 
+  /connect@3.7.0:
+    resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
+    engines: {node: '>= 0.10.0'}
+    dependencies:
+      debug: 2.6.9
+      finalhandler: 1.1.2
+      parseurl: 1.3.3
+      utils-merge: 1.0.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /console-control-strings@1.1.0:
     resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
     dev: true
 
+  /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==}
     dev: true
@@ -1506,10 +1656,23 @@
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
     dev: true
 
+  /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==}
     dev: true
 
+  /cors@2.8.5:
+    resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+    engines: {node: '>= 0.10'}
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+    dev: true
+
   /cross-spawn@7.0.3:
     resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
     engines: {node: '>= 8'}
@@ -1519,15 +1682,19 @@
       which: 2.0.2
     dev: true
 
-  /cypress@12.3.0:
-    resolution: {integrity: sha512-ZQNebibi6NBt51TRxRMYKeFvIiQZ01t50HSy7z/JMgRVqBUey3cdjog5MYEbzG6Ktti5ckDt1tfcC47lmFwXkw==}
-    engines: {node: ^14.0.0 || ^16.0.0 || >=18.0.0}
+  /custom-event@1.0.1:
+    resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==}
+    dev: true
+
+  /cypress@13.3.1:
+    resolution: {integrity: sha512-g4mJLZxYN+UAF2LMy3Znd4LBnUmS59Vynd81VES59RdW48Yt+QtR2cush3melOoVNz0PPbADpWr8DcUx6mif8Q==}
+    engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
     hasBin: true
     requiresBuild: true
     dependencies:
-      '@cypress/request': 2.88.11
+      '@cypress/request': 3.0.1
       '@cypress/xvfb': 1.2.4(supports-color@8.1.1)
-      '@types/node': 14.18.53
+      '@types/node': 18.19.22
       '@types/sinonjs__fake-timers': 8.1.1
       '@types/sizzle': 2.3.3
       arch: 2.2.0
@@ -1539,7 +1706,7 @@
       check-more-types: 2.24.0
       cli-cursor: 3.1.0
       cli-table3: 0.6.3
-      commander: 5.1.0
+      commander: 6.2.1
       common-tags: 1.8.2
       dayjs: 1.11.9
       debug: 4.3.4(supports-color@8.1.1)
@@ -1560,9 +1727,10 @@
       minimist: 1.2.8
       ospath: 1.2.2
       pretty-bytes: 5.6.0
+      process: 0.11.10
       proxy-from-env: 1.0.0
       request-progress: 3.0.0
-      semver: 7.5.3
+      semver: 7.6.0
       supports-color: 8.1.1
       tmp: 0.2.1
       untildify: 4.0.0
@@ -1576,10 +1744,26 @@
       assert-plus: 1.0.0
     dev: true
 
+  /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==}
     dev: true
 
+  /debug@2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: 2.0.0
+    dev: true
+
   /debug@3.2.7(supports-color@8.1.1):
     resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
     peerDependencies:
@@ -1605,6 +1789,11 @@
       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==}
     engines: {node: '>=0.10.0'}
@@ -1630,6 +1819,42 @@
     resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
     dev: true
 
+  /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==}
+    engines: {node: '>=6.0'}
+    dependencies:
+      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==}
+    dependencies:
+      custom-event: 1.0.1
+      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==}
     dev: true
@@ -1641,6 +1866,10 @@
       safer-buffer: 2.1.2
     dev: true
 
+  /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==}
     dev: true
@@ -1653,6 +1882,15 @@
     resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
     dev: true
 
+  /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==}
     requiresBuild: true
@@ -1667,6 +1905,31 @@
       once: 1.4.0
     dev: true
 
+  /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==}
+    engines: {node: '>=10.2.0'}
+    dependencies:
+      '@types/cookie': 0.4.1
+      '@types/cors': 2.8.17
+      '@types/node': 20.11.19
+      accepts: 1.3.8
+      base64id: 2.0.0
+      cookie: 0.4.2
+      cors: 2.8.5
+      debug: 4.3.4(supports-color@8.1.1)
+      engine.io-parser: 5.2.2
+      ws: 8.11.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+    dev: true
+
   /enquirer@2.3.6:
     resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
     engines: {node: '>=8.6'}
@@ -1674,6 +1937,10 @@
       ansi-colors: 4.1.3
     dev: true
 
+  /ent@2.2.0:
+    resolution: {integrity: sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==}
+    dev: true
+
   /env-paths@2.2.1:
     resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
     engines: {node: '>=6'}
@@ -1688,6 +1955,10 @@
     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==}
     engines: {node: '>=0.8.0'}
@@ -1701,6 +1972,10 @@
     resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
     dev: true
 
+  /eventemitter3@4.0.7:
+    resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+    dev: true
+
   /execa@4.1.0:
     resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
     engines: {node: '>=10'}
@@ -1783,6 +2058,43 @@
       to-regex-range: 5.0.1
     dev: true
 
+  /finalhandler@1.1.2:
+    resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      debug: 2.6.9
+      encodeurl: 1.0.2
+      escape-html: 1.0.3
+      on-finished: 2.3.0
+      parseurl: 1.3.3
+      statuses: 1.5.0
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /find-up@4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+    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==}
+    engines: {node: '>=4.0'}
+    peerDependencies:
+      debug: '*'
+    peerDependenciesMeta:
+      debug:
+        optional: true
+    dev: true
+
   /foreground-child@3.1.1:
     resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
     engines: {node: '>=14'}
@@ -1804,6 +2116,15 @@
       mime-types: 2.1.35
     dev: true
 
+  /fs-extra@8.1.0:
+    resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
+    engines: {node: '>=6 <7 || >=8'}
+    dependencies:
+      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==}
     engines: {node: '>=10'}
@@ -1879,7 +2200,7 @@
   /get-intrinsic@1.2.1:
     resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==}
     dependencies:
-      function-bind: 1.1.1
+      function-bind: 1.1.2
       has: 1.0.3
       has-proto: 1.0.1
       has-symbols: 1.0.3
@@ -2018,6 +2339,17 @@
     resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
     dev: true
 
+  /http-errors@2.0.0:
+    resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      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==}
     engines: {node: '>= 6'}
@@ -2029,6 +2361,17 @@
       - supports-color
     dev: true
 
+  /http-proxy@1.18.1:
+    resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
+    engines: {node: '>=8.0.0'}
+    dependencies:
+      eventemitter3: 4.0.7
+      follow-redirects: 1.15.5
+      requires-port: 1.0.0
+    transitivePeerDependencies:
+      - debug
+    dev: true
+
   /http-signature@1.3.6:
     resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==}
     engines: {node: '>=0.10'}
@@ -2254,6 +2597,11 @@
       is-docker: 2.2.1
     dev: true
 
+  /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==}
     dev: true
@@ -2312,6 +2660,12 @@
     resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
     dev: true
 
+  /jsonfile@4.0.0:
+    resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
+    optionalDependencies:
+      graceful-fs: 4.2.11
+    dev: true
+
   /jsonfile@6.1.0:
     resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
     dependencies:
@@ -2335,6 +2689,50 @@
       verror: 1.10.0
     dev: true
 
+  /karma-safari-launcher@1.0.0(karma@6.4.3):
+    resolution: {integrity: sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==}
+    peerDependencies:
+      karma: '>=0.9'
+    dependencies:
+      karma: 6.4.3
+    dev: true
+
+  /karma@6.4.3:
+    resolution: {integrity: sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==}
+    engines: {node: '>= 10'}
+    hasBin: true
+    dependencies:
+      '@colors/colors': 1.5.0
+      body-parser: 1.20.2
+      braces: 3.0.2
+      chokidar: 3.5.3
+      connect: 3.7.0
+      di: 0.0.1
+      dom-serialize: 2.2.1
+      glob: 7.2.3
+      graceful-fs: 4.2.11
+      http-proxy: 1.18.1
+      isbinaryfile: 4.0.10
+      lodash: 4.17.21
+      log4js: 6.9.1
+      mime: 2.6.0
+      minimatch: 3.1.2
+      mkdirp: 0.5.6
+      qjobs: 1.2.0
+      range-parser: 1.2.1
+      rimraf: 3.0.2
+      socket.io: 4.7.4
+      source-map: 0.6.1
+      tmp: 0.2.1
+      ua-parser-js: 0.7.37
+      yargs: 16.2.0
+    transitivePeerDependencies:
+      - bufferutil
+      - debug
+      - supports-color
+      - utf-8-validate
+    dev: true
+
   /lazy-ass@1.6.0:
     resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
     engines: {node: '> 0.8'}
@@ -2360,6 +2758,13 @@
       wrap-ansi: 7.0.0
     dev: true
 
+  /locate-path@5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+    dependencies:
+      p-locate: 4.1.0
+    dev: true
+
   /lodash.once@4.1.1:
     resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
     dev: true
@@ -2386,6 +2791,19 @@
       wrap-ansi: 6.2.0
     dev: true
 
+  /log4js@6.9.1:
+    resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==}
+    engines: {node: '>=8.0'}
+    dependencies:
+      date-format: 4.0.14
+      debug: 4.3.4(supports-color@8.1.1)
+      flatted: 3.3.1
+      rfdc: 1.3.0
+      streamroller: 3.1.5
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /lru-cache@10.2.0:
     resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==}
     engines: {node: 14 || >=16.14}
@@ -2472,6 +2890,11 @@
       - supports-color
     dev: true
 
+  /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==}
     dev: true
@@ -2488,6 +2911,12 @@
       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==}
     engines: {node: '>=6'}
@@ -2599,12 +3028,23 @@
       yallist: 4.0.0
     dev: true
 
+  /mkdirp@0.5.6:
+    resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+    hasBin: true
+    dependencies:
+      minimist: 1.2.8
+    dev: true
+
   /mkdirp@1.0.4:
     resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
     engines: {node: '>=10'}
     hasBin: true
     dev: true
 
+  /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
@@ -2748,10 +3188,29 @@
       set-blocking: 2.0.0
     dev: true
 
+  /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:
@@ -2798,6 +3257,20 @@
     resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
     dev: true
 
+  /p-limit@2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    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==}
     engines: {node: '>=10'}
@@ -2805,6 +3278,11 @@
       aggregate-error: 3.1.0
     dev: true
 
+  /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==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -2833,10 +3311,23 @@
       - supports-color
     dev: true
 
+  /pako@2.1.0:
+    resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
   /parse5@6.0.1:
     resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
     dev: true
 
+  /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'}
@@ -2886,6 +3377,11 @@
     engines: {node: '>=6'}
     dev: true
 
+  /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==}
     engines: {node: '>=10.13.0'}
@@ -2902,6 +3398,11 @@
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
     dev: true
 
+  /process@0.11.10:
+    resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+    engines: {node: '>= 0.6.0'}
+    dev: true
+
   /promise-inflight@1.0.1:
     resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
     peerDependencies:
@@ -2939,6 +3440,22 @@
     engines: {node: '>=6'}
     dev: true
 
+  /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==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+    dependencies:
+      dijkstrajs: 1.0.3
+      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==}
     engines: {node: '>=0.6'}
@@ -2946,6 +3463,32 @@
       side-channel: 1.0.4
     dev: true
 
+  /qs@6.11.0:
+    resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
+    engines: {node: '>=0.6'}
+    dependencies:
+      side-channel: 1.0.4
+    dev: true
+
+  /querystringify@2.2.0:
+    resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+    dev: true
+
+  /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==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      bytes: 3.1.2
+      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==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -3000,12 +3543,20 @@
     engines: {node: '>=0.10.0'}
     dev: true
 
+  /require-main-filename@2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+    dev: true
+
   /requirejs@2.3.6:
     resolution: {integrity: sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==}
     engines: {node: '>=0.4.0'}
     hasBin: true
     dev: true
 
+  /requires-port@1.0.0:
+    resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+    dev: true
+
   /resolve@1.22.2:
     resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==}
     hasBin: true
@@ -3096,14 +3647,6 @@
     hasBin: true
     dev: true
 
-  /semver@7.5.3:
-    resolution: {integrity: sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==}
-    engines: {node: '>=10'}
-    hasBin: true
-    dependencies:
-      lru-cache: 6.0.0
-    dev: true
-
   /semver@7.5.4:
     resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==}
     engines: {node: '>=10'}
@@ -3124,6 +3667,10 @@
     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==}
     engines: {node: '>=8'}
@@ -3195,6 +3742,44 @@
     engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
     dev: true
 
+  /socket.io-adapter@2.5.4:
+    resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==}
+    dependencies:
+      debug: 4.3.4(supports-color@8.1.1)
+      ws: 8.11.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+    dev: true
+
+  /socket.io-parser@4.2.4:
+    resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
+    engines: {node: '>=10.0.0'}
+    dependencies:
+      '@socket.io/component-emitter': 3.1.0
+      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==}
+    engines: {node: '>=10.2.0'}
+    dependencies:
+      accepts: 1.3.8
+      base64id: 2.0.0
+      cors: 2.8.5
+      debug: 4.3.4(supports-color@8.1.1)
+      engine.io: 6.5.4
+      socket.io-adapter: 2.5.4
+      socket.io-parser: 4.2.4
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+    dev: true
+
   /socks-proxy-agent@7.0.0:
     resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
     engines: {node: '>= 10'}
@@ -3283,6 +3868,27 @@
       minipass: 3.3.6
     dev: true
 
+  /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==}
+    engines: {node: '>=8.0'}
+    dependencies:
+      date-format: 4.0.14
+      debug: 4.3.4(supports-color@8.1.1)
+      fs-extra: 8.1.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /string-width@4.2.3:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
     engines: {node: '>=8'}
@@ -3414,12 +4020,19 @@
       is-number: 7.0.0
     dev: true
 
-  /tough-cookie@2.5.0:
-    resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==}
-    engines: {node: '>=0.8'}
+  /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==}
+    engines: {node: '>=6'}
     dependencies:
       psl: 1.9.0
       punycode: 2.3.0
+      universalify: 0.2.0
+      url-parse: 1.5.10
     dev: true
 
   /tslib@2.6.0:
@@ -3451,12 +4064,24 @@
     engines: {node: '>=10'}
     dev: true
 
+  /type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+    dev: true
+
   /typescript@5.1.6:
     resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
     engines: {node: '>=14.17'}
     hasBin: true
     dev: true
 
+  /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
@@ -3489,11 +4114,26 @@
       imurmurhash: 0.1.4
     dev: true
 
+  /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==}
+    engines: {node: '>= 4.0.0'}
+    dev: true
+
   /universalify@2.0.0:
     resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
     engines: {node: '>= 10.0.0'}
     dev: true
 
+  /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==}
     engines: {node: '>=8'}
@@ -3516,10 +4156,22 @@
       punycode: 2.3.0
     dev: true
 
+  /url-parse@1.5.10:
+    resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
+    dependencies:
+      querystringify: 2.2.0
+      requires-port: 1.0.0
+    dev: true
+
   /util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
     dev: true
 
+  /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==}
     hasBin: true
@@ -3539,6 +4191,11 @@
       builtins: 5.0.1
     dev: true
 
+  /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==}
     engines: {'0': node >=0.6.0}
@@ -3548,12 +4205,21 @@
       extsprintf: 1.3.0
     dev: true
 
+  /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==}
     dependencies:
       defaults: 1.0.4
     dev: true
 
+  /which-module@2.0.1:
+    resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+    dev: true
+
   /which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
     engines: {node: '>= 8'}
@@ -3607,6 +4273,23 @@
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
     dev: true
 
+  /ws@8.11.0:
+    resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: ^5.0.2
+    peerDependenciesMeta:
+      bufferutil:
+        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'}
@@ -3620,11 +4303,54 @@
     resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
     dev: true
 
+  /yargs-parser@18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+    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==}
     engines: {node: '>=12'}
     dev: true
 
+  /yargs@15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      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==}
+    engines: {node: '>=10'}
+    dependencies:
+      cliui: 7.0.4
+      escalade: 3.1.1
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      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==}
     engines: {node: '>=12'}
diff --git a/scouting/BUILD b/scouting/BUILD
index 82f058c..ba6ab6b 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -38,6 +38,22 @@
     ],
 )
 
+# The QR code test is separate from scouting_test because it's slow. Most of
+# the time folks will want to iterate on `scouting_test`.
+cypress_test(
+    name = "scouting_qrcode_test",
+    size = "large",
+    data = [
+        "scouting_qrcode_test.cy.js",
+        "//scouting/testing:scouting_test_servers",
+        "//scouting/testing/camera_simulator",
+    ],
+    runner = "scouting_test_runner.js",
+    tags = [
+        "no-remote-cache",
+    ],
+)
+
 apache_wrapper(
     name = "https",
     binary = ":scouting",
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 5d7a56c..016ca95 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -86,6 +86,7 @@
 	NotesDropped                                 int32
 	Penalties                                    int32
 	AvgCycle                                     int64
+	RobotDied                                    bool
 	Park, OnStage, Harmony, TrapNote, Spotlight  bool
 
 	// The username of the person who collected these statistics.
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 369ea58..882dfbc 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -149,7 +149,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 0, Amp: 5, SpeakerAmplified: 1, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: false, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "emma",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "emma",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "942",
@@ -157,7 +157,7 @@
 			SpeakerAuto: 2, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 0, Amp: 5, SpeakerAmplified: 1, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: false, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "harry",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "harry",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "432",
@@ -165,7 +165,7 @@
 			SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 2, Amp: 1, SpeakerAmplified: 3, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: true, Harmony: false, CollectedBy: "henry",
+			Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "henry",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "52A",
@@ -173,7 +173,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 0, Amp: 1, SpeakerAmplified: 2, AmpAmplified: 3,
 			NotesDropped: 2, Penalties: 0, TrapNote: true, Spotlight: false, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "jordan",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "jordan",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "745",
@@ -181,7 +181,7 @@
 			SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 5, Amp: 0, SpeakerAmplified: 2, AmpAmplified: 1,
 			NotesDropped: 1, Penalties: 1, TrapNote: true, Spotlight: true, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "taylor",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "taylor",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "934",
@@ -189,7 +189,7 @@
 			SpeakerAuto: 1, AmpAuto: 3, NotesDroppedAuto: 0, MobilityAuto: true,
 			Speaker: 0, Amp: 3, SpeakerAmplified: 2, AmpAmplified: 2,
 			NotesDropped: 0, Penalties: 3, TrapNote: true, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: false, Harmony: true, CollectedBy: "katie",
+			Park: false, OnStage: false, Harmony: true, RobotDied: false, CollectedBy: "katie",
 		},
 	}
 
@@ -236,7 +236,7 @@
 		SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 		Speaker: 0, Amp: 5, SpeakerAmplified: 1, AmpAmplified: 0,
 		NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: true, AvgCycle: 0,
-		Park: true, OnStage: false, Harmony: false, CollectedBy: "emma",
+		Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "emma",
 	}
 
 	// Attempt to insert the non-pre-scouted data and make sure it fails.
@@ -265,7 +265,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 0, Amp: 5, SpeakerAmplified: 1, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: true, AvgCycle: 0,
-			Park: false, OnStage: true, Harmony: false, CollectedBy: "emma",
+			Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "emma",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "978",
@@ -273,7 +273,7 @@
 			SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 1, Amp: 2, SpeakerAmplified: 0, AmpAmplified: 2,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: true, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "emma",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "emma",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "328A",
@@ -281,7 +281,7 @@
 			SpeakerAuto: 1, AmpAuto: 1, NotesDroppedAuto: 1, MobilityAuto: true,
 			Speaker: 0, Amp: 1, SpeakerAmplified: 1, AmpAmplified: 5,
 			NotesDropped: 1, Penalties: 0, TrapNote: false, Spotlight: true, AvgCycle: 0,
-			Park: false, OnStage: false, Harmony: true, CollectedBy: "emma",
+			Park: false, OnStage: false, Harmony: true, RobotDied: true, CollectedBy: "emma",
 		},
 	}
 
@@ -700,7 +700,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 1, Amp: 3, SpeakerAmplified: 1, AmpAmplified: 3,
 			NotesDropped: 0, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: true, Harmony: false, CollectedBy: "bailey",
+			Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "bailey",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "645",
@@ -708,7 +708,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 1, Amp: 2, SpeakerAmplified: 0, AmpAmplified: 1,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: true, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "kate",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "kate",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "323",
@@ -716,7 +716,7 @@
 			SpeakerAuto: 1, AmpAuto: 1, NotesDroppedAuto: 1, MobilityAuto: true,
 			Speaker: 0, Amp: 0, SpeakerAmplified: 2, AmpAmplified: 1,
 			NotesDropped: 1, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "tyler",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "tyler",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "542",
@@ -724,7 +724,7 @@
 			SpeakerAuto: 1, AmpAuto: 1, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 1, Amp: 2, SpeakerAmplified: 2, AmpAmplified: 1,
 			NotesDropped: 1, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: false, Harmony: true, CollectedBy: "max",
+			Park: false, OnStage: false, Harmony: true, RobotDied: false, CollectedBy: "max",
 		},
 	}
 
@@ -735,7 +735,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 1, Amp: 3, SpeakerAmplified: 1, AmpAmplified: 3,
 			NotesDropped: 0, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: true, Harmony: false, CollectedBy: "bailey",
+			Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "bailey",
 		},
 	}
 
@@ -1154,7 +1154,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 0, Amp: 5, SpeakerAmplified: 1, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: false, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "emma",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "emma",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "942",
@@ -1162,7 +1162,7 @@
 			SpeakerAuto: 2, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 0, Amp: 5, SpeakerAmplified: 1, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 2, TrapNote: true, Spotlight: false, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "harry",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "harry",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "432",
@@ -1170,7 +1170,7 @@
 			SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 2, MobilityAuto: true,
 			Speaker: 2, Amp: 1, SpeakerAmplified: 3, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: true, Harmony: false, CollectedBy: "henry",
+			Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "henry",
 		},
 		Stats2024{
 			PreScouting: false, TeamNumber: "52A",
@@ -1178,7 +1178,7 @@
 			SpeakerAuto: 1, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 0, Amp: 1, SpeakerAmplified: 2, AmpAmplified: 3,
 			NotesDropped: 2, Penalties: 0, TrapNote: true, Spotlight: true, AvgCycle: 0,
-			Park: true, OnStage: false, Harmony: false, CollectedBy: "jordan",
+			Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "jordan",
 		},
 	}
 
diff --git a/scouting/scouting_qrcode_test.cy.js b/scouting/scouting_qrcode_test.cy.js
new file mode 100644
index 0000000..668cba8
--- /dev/null
+++ b/scouting/scouting_qrcode_test.cy.js
@@ -0,0 +1,157 @@
+/// <reference types="cypress" />
+
+// On the 3rd row of matches (index 2) click on the third team
+// (index 2) which resolves to team 333 in quals match 3.
+const QUALS_MATCH_3_TEAM_333 = 2 * 6 + 2;
+
+function disableAlerts() {
+  cy.get('#block_alerts').check({force: true}).should('be.checked');
+}
+
+function switchToTab(tabName) {
+  cy.contains('.nav-link', tabName).click();
+}
+
+function headerShouldBe(text) {
+  cy.get('.header').should('have.text', text);
+}
+
+function clickButton(buttonName) {
+  cy.contains('button', buttonName).click();
+}
+
+// Wrapper around cy.exec() because it truncates the output of the subprocess
+// if it fails. This is a work around to manually print the full error on the
+// console if a failure happends.
+function exec(command) {
+  cy.exec(command, {failOnNonZeroExit: false}).then((result) => {
+    if (result.code) {
+      throw new Error(`Execution of "${command}" failed
+      Exit code: ${result.code}
+      Stdout:\n${result.stdout}
+      Stderr:\n${result.stderr}`);
+    }
+  });
+}
+
+// Prepares data entry so that we _could_ hit Submit.
+//
+// Options:
+//  matchButtonKey: The index into the big matchlist table that we want to
+//    click on to start the data entry.
+//  teamNumber: The team number that matches the button that we click on as
+//    specified by `matchButtonKey`.
+//
+// TODO(phil): Deduplicate with scouting_test.cy.js.
+function prepareDataScouting(options) {
+  const {matchButtonKey = SEMI_FINAL_2_MATCH_3_TEAM_5254, teamNumber = 5254} =
+    options;
+
+  // Click on a random team in the Match list. The exact details here are not
+  // important, but we need to know what they are. This could as well be any
+  // other team from any other match.
+  cy.get('button.match-item').eq(matchButtonKey).click();
+
+  // Select Starting Position.
+  headerShouldBe(teamNumber + ' Init ');
+  cy.get('[type="radio"]').first().check();
+  clickButton('Start Match');
+
+  // Pick and Place Note in Auto.
+  clickButton('NOTE');
+  clickButton('AMP');
+
+  // Pick and Place Cube in Teleop.
+  clickButton('Start Teleop');
+  clickButton('NOTE');
+  clickButton('AMP AMPLIFIED');
+
+  // Generate some extra actions so that we are guaranteed to have at least 2
+  // QR codes.
+  for (let i = 0; i < 5; i++) {
+    clickButton('NOTE');
+    clickButton('AMP');
+  }
+
+  // Robot dead and revive.
+  clickButton('DEAD');
+  clickButton('Revive');
+
+  // Endgame.
+  clickButton('Endgame');
+  cy.contains(/Harmony/).click();
+
+  clickButton('End Match');
+  headerShouldBe(teamNumber + ' Review and Submit ');
+  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')
+    .last()
+    .should(
+      'have.text',
+      ' Ended Match; stageType: kHARMONY, trapNote: false, spotlight: false '
+    );
+}
+
+before(() => {
+  cy.visit('/');
+  disableAlerts();
+  cy.title().should('eq', 'FRC971 Scouting Application');
+});
+
+beforeEach(() => {
+  cy.visit('/');
+  disableAlerts();
+});
+
+describe('Scouting app tests', () => {
+  // This test collects some scouting data and then generates the corresponding
+  // QR codes. The test takes screenshots of those QR codes. The QR codes get
+  // turned into a little video file for the browser to use as a fake camera
+  // input. The test then switches to the Scan tab to scan the QR codes from
+  // the "camera". We then make sure that the data gets submitted.
+  it('should: be able to generate and scan QR codes.', () => {
+    prepareDataScouting({
+      matchButtonKey: QUALS_MATCH_3_TEAM_333,
+      teamNumber: 333,
+    });
+    clickButton('Create QR Code');
+    headerShouldBe('333 QR Code ');
+
+    cy.get('#qr_code_piece_size').select('150');
+
+    // Go into a mobile-phone view so that we can guarantee that the QR code is
+    // visible.
+    cy.viewport(400, 660);
+
+    cy.get('.qrcode-buttons > li > a')
+      .should('have.length.at.least', 4)
+      .each(($button, index, $buttons) => {
+        if (index == 0 || index + 1 == $buttons.length) {
+          // Skip the "Previous" and "Next" buttons.
+          return;
+        }
+        // Click on the button to switch to that particular QR code.
+        // We use force:true here because without bootstrap (inside the
+        // sandbox) the buttons overlap one another a bit.
+        cy.wrap($button).click({force: true});
+        cy.get('div.qrcode').screenshot(`qrcode_${index}_screenshot`);
+      });
+
+    exec('./testing/camera_simulator/camera_simulator_/camera_simulator');
+
+    switchToTab('Scan');
+
+    // Since we cannot reliably predict how long it will take to scan all the
+    // QR codes, we use a really long timeout here.
+    cy.get('.progress_message', {timeout: 80000}).should('contain', 'Success!');
+
+    // Now that the data is submitted, the button should be disabled.
+    switchToTab('Match List');
+    cy.get('button.match-item')
+      .eq(QUALS_MATCH_3_TEAM_333)
+      .should('be.disabled');
+  });
+});
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index 31ca935..d15cdfc 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -117,15 +117,27 @@
   clickButton('Submit');
   headerShouldBe(teamNumber + ' Success ');
 }
+function visit(path) {
+  cy.visit(path, {
+    onBeforeLoad(win) {
+      // The service worker seems to interfere with Cypress somehow. There
+      // doesn't seem to be a proper fix for this issue. Work around it with
+      // this hack that disables the service worker.
+      // https://github.com/cypress-io/cypress/issues/16192#issuecomment-870421667
+      // https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
+      delete win.navigator.__proto__.serviceWorker;
+    },
+  });
+}
 
 before(() => {
-  cy.visit('/');
+  visit('/');
   disableAlerts();
   cy.title().should('eq', 'FRC971 Scouting Application');
 });
 
 beforeEach(() => {
-  cy.visit('/');
+  visit('/');
   disableAlerts();
 });
 
diff --git a/scouting/testing/camera_simulator/BUILD b/scouting/testing/camera_simulator/BUILD
new file mode 100644
index 0000000..b6e4bf3
--- /dev/null
+++ b/scouting/testing/camera_simulator/BUILD
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "camera_simulator_lib",
+    srcs = ["camera_simulator.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/testing/camera_simulator",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:private"],
+)
+
+go_binary(
+    name = "camera_simulator",
+    embed = [":camera_simulator_lib"],
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:public"],
+)
diff --git a/scouting/testing/camera_simulator/camera_simulator.go b/scouting/testing/camera_simulator/camera_simulator.go
new file mode 100644
index 0000000..a54e79f
--- /dev/null
+++ b/scouting/testing/camera_simulator/camera_simulator.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"image"
+	"image/jpeg"
+	_ "image/png"
+	"log"
+	"os"
+	"path/filepath"
+	"sort"
+)
+
+// Chrome plays back MJPEG files at a (hard-coded) 30 fps.
+const CHROME_FAKE_VIDEO_FPS = 30
+
+// For how many seconds to display a single image.
+const IMAGE_DURATION = 3
+
+// For how many frames (at CHROME_FAKE_VIDEO_FPS) to display a single image.
+const IMAGE_DURATION_FRAMES = int(CHROME_FAKE_VIDEO_FPS * IMAGE_DURATION)
+
+func checkErr(err error, message string) {
+	if err != nil {
+		log.Println(message)
+		log.Fatal(err)
+	}
+}
+
+func main() {
+	output_dir := os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR")
+
+	// The output file is at a fixed path as expected by
+	// `tools/build_rules/js/cypress.config.js`.
+	outName := output_dir + "/fake_camera.mjpeg"
+
+	// The Cypress test is expected to dump all the screenshots in this
+	// directory.
+	screenshotDir := output_dir + "/screenshots/scouting_qrcode_test.cy.js"
+	log.Printf("Looking for screenshots in %s", screenshotDir)
+
+	// Create a movie from images.
+	matches, err := filepath.Glob(screenshotDir + "/qrcode_*_screenshot.png")
+	checkErr(err, "Failed to glob for the screenshots")
+	sort.Strings(matches)
+
+	log.Println("Found images:", matches)
+	if len(matches) < 2 {
+		// For the purposes of the test, we expect at least 2 QR codes.
+		// If something goes wrong, then this is an opportunity to bail
+		// early.
+		log.Fatalf("Only found %d images", len(matches))
+	}
+
+	mjpeg, err := os.Create(outName)
+	checkErr(err, "Failed to open output file")
+	defer mjpeg.Close()
+
+	// MJPEG is litterally a bunch of JPEGs concatenated together. Read in
+	// each frame and append it to the output file.
+	for _, name := range matches {
+		reader, err := os.Open(name)
+		checkErr(err, "Could not open "+name)
+		defer reader.Close()
+
+		img, _, err := image.Decode(reader)
+		checkErr(err, "Could not decode image")
+
+		buffer := &bytes.Buffer{}
+		checkErr(jpeg.Encode(buffer, img, nil), "Failed to encode as jpeg")
+
+		// In order to show a single picture for 1 second, we need to
+		// inject CHROME_FAKE_VIDEO_FPS copies of the same image.
+		for i := 0; i < IMAGE_DURATION_FRAMES; i++ {
+			_, err = mjpeg.Write(buffer.Bytes())
+			checkErr(err, "Failed to write to mjpeg")
+		}
+	}
+
+	fmt.Printf("%s was written successfully.\n", outName)
+}
diff --git a/scouting/webserver/requests/messages/request_2024_data_scouting_response.fbs b/scouting/webserver/requests/messages/request_2024_data_scouting_response.fbs
index c36174d..f450d38 100644
--- a/scouting/webserver/requests/messages/request_2024_data_scouting_response.fbs
+++ b/scouting/webserver/requests/messages/request_2024_data_scouting_response.fbs
@@ -26,6 +26,7 @@
   on_stage: bool (id:16);
   harmony: bool (id:17);
   spotlight: bool (id:22);
+  robot_died: bool (id:23);
 
   pre_scouting:bool (id:20);
   collected_by:string (id:21);
diff --git a/scouting/webserver/requests/messages/submit_2024_actions.fbs b/scouting/webserver/requests/messages/submit_2024_actions.fbs
index e85563f..9462fbe 100644
--- a/scouting/webserver/requests/messages/submit_2024_actions.fbs
+++ b/scouting/webserver/requests/messages/submit_2024_actions.fbs
@@ -9,6 +9,7 @@
     kAMP_AMPLIFIED,
     kSPEAKER,
     kSPEAKER_AMPLIFIED,
+    kDROPPED,
 }
 
 table MobilityAction {
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index 76d632d..7d6e98e 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -452,7 +452,7 @@
 		PreScouting: submit2024Actions.PreScouting(), TeamNumber: string(submit2024Actions.TeamNumber()), MatchNumber: submit2024Actions.MatchNumber(), SetNumber: submit2024Actions.SetNumber(), CompLevel: string(submit2024Actions.CompLevel()),
 		StartingQuadrant: 0, SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 		Speaker: 0, Amp: 0, SpeakerAmplified: 0, AmpAmplified: 0, NotesDropped: 0, Penalties: 0,
-		TrapNote: false, Spotlight: false, AvgCycle: 0, Park: false, OnStage: false, Harmony: false, CollectedBy: "",
+		TrapNote: false, Spotlight: false, AvgCycle: 0, Park: false, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "",
 	}
 	// Loop over all actions.
 	for i := 0; i < submit2024Actions.ActionsListLength(); i++ {
@@ -481,19 +481,15 @@
 			penaltyAction.Init(actionTable.Bytes, actionTable.Pos)
 			stat.Penalties += penaltyAction.Penalties()
 
+		} else if action_type == submit_2024_actions.ActionTypeRobotDeathAction {
+			var robotDeathAction submit_2024_actions.RobotDeathAction
+			robotDeathAction.Init(actionTable.Bytes, actionTable.Pos)
+			stat.RobotDied = true
+
 		} else if action_type == submit_2024_actions.ActionTypePickupNoteAction {
 			var pick_up_action submit_2024_actions.PickupNoteAction
 			pick_up_action.Init(actionTable.Bytes, actionTable.Pos)
-			if picked_up == true {
-				auto := pick_up_action.Auto()
-				if auto == false {
-					stat.NotesDropped += 1
-				} else {
-					stat.NotesDroppedAuto += 1
-				}
-			} else {
-				picked_up = true
-			}
+			picked_up = true
 		} else if action_type == submit_2024_actions.ActionTypePlaceNoteAction {
 			var place_action submit_2024_actions.PlaceNoteAction
 			place_action.Init(actionTable.Bytes, actionTable.Pos)
@@ -514,6 +510,10 @@
 				stat.SpeakerAuto += 1
 			} else if score_type == submit_2024_actions.ScoreTypekSPEAKER_AMPLIFIED && !auto {
 				stat.SpeakerAmplified += 1
+			} else if score_type == submit_2024_actions.ScoreTypekDROPPED && auto {
+				stat.NotesDroppedAuto += 1
+			} else if score_type == submit_2024_actions.ScoreTypekDROPPED && !auto {
+				stat.NotesDropped += 1
 			} else {
 				return db.Stats2024{}, errors.New(fmt.Sprintf("Got unknown ObjectType/ScoreLevel/Auto combination"))
 			}
@@ -595,6 +595,7 @@
 			Park:             stat.Park,
 			OnStage:          stat.OnStage,
 			Harmony:          stat.Harmony,
+			RobotDied:        stat.RobotDied,
 			CollectedBy:      stat.CollectedBy,
 		})
 	}
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index ebe73f3..26fad0f 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -137,8 +137,8 @@
 				MatchNumber: 1, SetNumber: 1, CompLevel: "qm", StartingQuadrant: 3,
 				SpeakerAuto: 2, AmpAuto: 4, NotesDroppedAuto: 1, MobilityAuto: true,
 				Speaker: 0, Amp: 1, SpeakerAmplified: 2, AmpAmplified: 1,
-				NotesDropped: 0, Penalties: 01, TrapNote: true, Spotlight: false, AvgCycle: 233,
-				Park: false, OnStage: true, Harmony: false, CollectedBy: "alex",
+				NotesDropped: 0, Penalties: 1, TrapNote: true, Spotlight: false, AvgCycle: 233,
+				Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "alex",
 			},
 			{
 				PreScouting: false, TeamNumber: "973",
@@ -146,7 +146,7 @@
 				SpeakerAuto: 0, AmpAuto: 2, NotesDroppedAuto: 0, MobilityAuto: false,
 				Speaker: 0, Amp: 4, SpeakerAmplified: 3, AmpAmplified: 1,
 				NotesDropped: 0, Penalties: 1, TrapNote: true, Spotlight: false, AvgCycle: 120,
-				Park: true, OnStage: false, Harmony: false, CollectedBy: "bob",
+				Park: true, OnStage: false, Harmony: false, RobotDied: true, CollectedBy: "bob",
 			},
 		},
 	}
@@ -215,7 +215,7 @@
 				SpeakerAuto: 1, AmpAuto: 1, NotesDroppedAuto: 0, MobilityAuto: true,
 				Speaker: 4, Amp: 2, SpeakerAmplified: 1, AmpAmplified: 0,
 				NotesDropped: 2, Penalties: 2, TrapNote: true, Spotlight: true, AvgCycle: 0,
-				Park: true, OnStage: false, Harmony: false, CollectedBy: "alex",
+				Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "alex",
 			},
 			{
 				PreScouting: false, TeamNumber: "982",
@@ -223,7 +223,7 @@
 				SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 				Speaker: 0, Amp: 2, SpeakerAmplified: 3, AmpAmplified: 2,
 				NotesDropped: 1, Penalties: 0, TrapNote: false, Spotlight: true, AvgCycle: 0,
-				Park: false, OnStage: true, Harmony: false, CollectedBy: "george",
+				Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "george",
 			},
 		},
 	}
@@ -248,7 +248,7 @@
 				SpeakerAuto: 1, AmpAuto: 1, NotesDroppedAuto: 0, MobilityAuto: true,
 				Speaker: 4, Amp: 2, SpeakerAmplified: 1, AmpAmplified: 0,
 				NotesDropped: 2, Penalties: 2, TrapNote: true, Spotlight: true, AvgCycle: 0,
-				Park: true, OnStage: false, Harmony: false, CollectedBy: "alex",
+				Park: true, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "alex",
 			},
 			{
 				PreScouting: false, TeamNumber: "982",
@@ -256,7 +256,7 @@
 				SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 				Speaker: 0, Amp: 2, SpeakerAmplified: 3, AmpAmplified: 2,
 				NotesDropped: 1, Penalties: 0, TrapNote: false, Spotlight: true, AvgCycle: 0,
-				Park: false, OnStage: true, Harmony: false, CollectedBy: "george",
+				Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "george",
 			},
 		},
 	}
@@ -441,6 +441,43 @@
 						Auto: false,
 					},
 				},
+				Timestamp: 3200,
+			},
+			{
+				ActionTaken: &submit_2024_actions.ActionTypeT{
+					Type: submit_2024_actions.ActionTypePlaceNoteAction,
+					Value: &submit_2024_actions.PlaceNoteActionT{
+						ScoreType: submit_2024_actions.ScoreTypekDROPPED,
+						Auto:      false,
+					},
+				},
+				Timestamp: 3300,
+			},
+			{
+				ActionTaken: &submit_2024_actions.ActionTypeT{
+					Type: submit_2024_actions.ActionTypeRobotDeathAction,
+					Value: &submit_2024_actions.RobotDeathActionT{
+						RobotDead: true,
+					},
+				},
+				Timestamp: 3400,
+			},
+			{
+				ActionTaken: &submit_2024_actions.ActionTypeT{
+					Type: submit_2024_actions.ActionTypeRobotDeathAction,
+					Value: &submit_2024_actions.RobotDeathActionT{
+						RobotDead: false,
+					},
+				},
+				Timestamp: 3450,
+			},
+			{
+				ActionTaken: &submit_2024_actions.ActionTypeT{
+					Type: submit_2024_actions.ActionTypePickupNoteAction,
+					Value: &submit_2024_actions.PickupNoteActionT{
+						Auto: false,
+					},
+				},
 				Timestamp: 3500,
 			},
 			{
@@ -478,10 +515,10 @@
 	expected := db.Stats2024{
 		PreScouting: false, TeamNumber: "4244",
 		MatchNumber: 3, SetNumber: 1, CompLevel: "quals", StartingQuadrant: 2,
-		SpeakerAuto: 0, AmpAuto: 1, NotesDroppedAuto: 1, MobilityAuto: true,
+		SpeakerAuto: 0, AmpAuto: 1, NotesDroppedAuto: 0, MobilityAuto: true,
 		Speaker: 0, Amp: 0, SpeakerAmplified: 1, AmpAmplified: 1,
-		NotesDropped: 0, Penalties: 5, TrapNote: false, Spotlight: false, AvgCycle: 950,
-		Park: false, OnStage: false, Harmony: true, CollectedBy: "",
+		NotesDropped: 1, Penalties: 5, TrapNote: false, Spotlight: false, AvgCycle: 633,
+		Park: false, OnStage: false, Harmony: true, RobotDied: true, CollectedBy: "",
 	}
 
 	if expected != response {
@@ -1202,7 +1239,7 @@
 			SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 1, Amp: 0, SpeakerAmplified: 0, AmpAmplified: 0,
 			NotesDropped: 0, Penalties: 0, TrapNote: false, Spotlight: false, AvgCycle: 0,
-			Park: false, OnStage: false, Harmony: false, CollectedBy: "debug_cli",
+			Park: false, OnStage: false, Harmony: false, RobotDied: false, CollectedBy: "debug_cli",
 		},
 	}
 
@@ -1448,7 +1485,7 @@
 				SpeakerAuto: 0, AmpAuto: 1, NotesDroppedAuto: 1, MobilityAuto: true,
 				Speaker: 0, Amp: 1, SpeakerAmplified: 1, AmpAmplified: 1,
 				NotesDropped: 0, Penalties: 1, TrapNote: true, Spotlight: false, AvgCycle: 233,
-				Park: false, OnStage: false, Harmony: true, CollectedBy: "alek",
+				Park: false, OnStage: false, Harmony: true, RobotDied: false, CollectedBy: "alek",
 			},
 			{
 				PreScouting: false, TeamNumber: "244",
@@ -1456,7 +1493,7 @@
 				SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 				Speaker: 0, Amp: 0, SpeakerAmplified: 3, AmpAmplified: 1,
 				NotesDropped: 0, Penalties: 1, TrapNote: false, Spotlight: false, AvgCycle: 120,
-				Park: false, OnStage: true, Harmony: false, CollectedBy: "kacey",
+				Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "kacey",
 			},
 		},
 		actions: []db.Action{
@@ -1520,7 +1557,7 @@
 			SpeakerAuto: 0, AmpAuto: 0, NotesDroppedAuto: 0, MobilityAuto: false,
 			Speaker: 0, Amp: 0, SpeakerAmplified: 3, AmpAmplified: 1,
 			NotesDropped: 0, Penalties: 1, TrapNote: false, Spotlight: false, AvgCycle: 120,
-			Park: false, OnStage: true, Harmony: false, CollectedBy: "kacey",
+			Park: false, OnStage: true, Harmony: false, RobotDied: false, CollectedBy: "kacey",
 		},
 	}
 
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index a403bf3..a6ca0a1 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -1,25 +1,49 @@
 load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
+load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary")
+load("@npm//:@angular/service-worker/package_json.bzl", angular_service_worker = "bin")
 load("@npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/build_rules:js.bzl", "ng_application")
-load(":defs.bzl", "assemble_static_files")
+load(":defs.bzl", "assemble_service_worker_files", "assemble_static_files")
 
 npm_link_all_packages(name = "node_modules")
 
+assemble_service_worker_files(
+    name = "service_worker_files",
+    outs = [
+        "ngsw-worker.js",
+    ],
+)
+
+OPENCV_VERSION = "4.9.0"
+
+copy_file(
+    name = "opencv.js",
+    src = "@opencv_wasm//file",
+    out = "assets/opencv_{}/opencv.js".format(OPENCV_VERSION),
+)
+
 ng_application(
     name = "app",
+    assets = [
+        "manifest.json",
+        ":opencv.js",
+    ],
     extra_srcs = [
         "app/common.css",
     ],
     html_assets = [
         "favicon.ico",
+        "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",
     ],
@@ -30,6 +54,8 @@
     app_files = ":app",
     pictures = [
         "//third_party/y2024/field:pictures",
+        ":ngsw-worker.js",
+        ":ngsw.json",
     ],
     replace_prefixes = {
         "prod": "",
@@ -48,3 +74,35 @@
     out = "app/common.css",
     visibility = ["//scouting/www:__subpackages__"],
 )
+
+angular_service_worker.ngsw_config_binary(
+    name = "ngsw_config_binary",
+)
+
+js_binary(
+    name = "ngsw_config_wrapper",
+    data = [
+        ":ngsw_config_binary",
+    ],
+    entry_point = "ngsw_config_wrapper.js",
+)
+
+js_run_binary(
+    name = "ngsw_config",
+    srcs = [
+        "manifest.json",
+        "ngsw-config.json",
+        ":app",
+        ":ngsw_config_binary",
+    ],
+    outs = [
+        "ngsw.json",
+    ],
+    args = [
+        "$(rootpath :ngsw_config_binary)",
+        "$(rootpath :ngsw.json)",
+        "$(rootpath :prod)",
+        "$(rootpath ngsw-config.json)",
+    ],
+    tool = ":ngsw_config_wrapper",
+)
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index bf393e4..decd1f3 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -1,6 +1,7 @@
-import {NgModule} from '@angular/core';
+import {NgModule, isDevMode} from '@angular/core';
 import {BrowserModule} from '@angular/platform-browser';
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+import {ServiceWorkerModule} from '@angular/service-worker';
 
 import {App} from './app';
 import {EntryModule} from '../entry';
@@ -10,12 +11,19 @@
 import {ViewModule} from '../view';
 import {DriverRankingModule} from '../driver_ranking';
 import {PitScoutingModule} from '../pit_scouting';
+import {ScanModule} from '../scan';
 
 @NgModule({
   declarations: [App],
   imports: [
     BrowserModule,
     BrowserAnimationsModule,
+    ServiceWorkerModule.register('./ngsw-worker.js', {
+      enabled: !isDevMode(),
+      // Register the ServiceWorker as soon as the application is stable
+      // or after 30 seconds (whichever comes first).
+      registrationStrategy: 'registerWhenStable:30000',
+    }),
     EntryModule,
     NotesModule,
     MatchListModule,
@@ -23,6 +31,7 @@
     DriverRankingModule,
     ViewModule,
     PitScoutingModule,
+    ScanModule,
   ],
   exports: [App],
   bootstrap: [App],
diff --git a/scouting/www/app/app.ng.html b/scouting/www/app/app.ng.html
index 5f7ea9f..ef37c07 100644
--- a/scouting/www/app/app.ng.html
+++ b/scouting/www/app/app.ng.html
@@ -73,6 +73,15 @@
       Pit
     </a>
   </li>
+  <li class="nav-item">
+    <a
+      class="nav-link"
+      [class.active]="tabIs('Scan')"
+      (click)="switchTabToGuarded('Scan')"
+    >
+      Scan
+    </a>
+  </li>
 </ul>
 
 <ng-container [ngSwitch]="tab">
@@ -93,4 +102,5 @@
   <shift-schedule *ngSwitchCase="'ShiftSchedule'"></shift-schedule>
   <app-view *ngSwitchCase="'View'"></app-view>
   <app-pit-scouting *ngSwitchCase="'Pit'"></app-pit-scouting>
+  <app-scan *ngSwitchCase="'Scan'"></app-scan>
 </ng-container>
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index 597e5c5..ab15ae5 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -1,4 +1,4 @@
-import {Component, ElementRef, ViewChild} from '@angular/core';
+import {Component, ElementRef, ViewChild, isDevMode} from '@angular/core';
 
 type Tab =
   | 'MatchList'
@@ -7,10 +7,11 @@
   | 'DriverRanking'
   | 'ShiftSchedule'
   | 'View'
-  | 'Pit';
+  | 'Pit'
+  | 'Scan';
 
 // Ignore the guard for tabs that don't require the user to enter any data.
-const unguardedTabs: Tab[] = ['MatchList', 'View'];
+const unguardedTabs: Tab[] = ['MatchList', 'Scan', 'View'];
 
 type TeamInMatch = {
   teamNumber: string;
@@ -39,6 +40,8 @@
   @ViewChild('block_alerts') block_alerts: ElementRef;
 
   constructor() {
+    console.log(`Using development mode: ${isDevMode()}`);
+
     window.addEventListener('beforeunload', (e) => {
       if (!unguardedTabs.includes(this.tab)) {
         if (!this.block_alerts.nativeElement.checked) {
diff --git a/scouting/www/assets/971_144.png b/scouting/www/assets/971_144.png
new file mode 100644
index 0000000..881edfa
--- /dev/null
+++ b/scouting/www/assets/971_144.png
Binary files differ
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
index 828f30a..e7fb44a 100644
--- a/scouting/www/defs.bzl
+++ b/scouting/www/defs.bzl
@@ -27,6 +27,7 @@
         ),
         "pictures": attr.label_list(
             mandatory = True,
+            allow_files = True,
         ),
         "replace_prefixes": attr.string_dict(
             mandatory = True,
@@ -34,3 +35,45 @@
     },
     toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
 )
+
+def _assemble_service_worker_files_impl(ctx):
+    args = ctx.actions.args()
+    args.add_all(ctx.attr._package.files, before_each = "--input_dir", expand_directories = False)
+    args.add_all(ctx.outputs.outs, before_each = "--output")
+    args.add_all(ctx.attr.outs_as_strings, before_each = "--relative_output")
+    ctx.actions.run(
+        inputs = ctx.attr._package.files,
+        outputs = ctx.outputs.outs,
+        executable = ctx.executable._tool,
+        arguments = [args],
+        mnemonic = "AssembleAngularServiceWorker",
+    )
+
+_assemble_service_worker_files = rule(
+    implementation = _assemble_service_worker_files_impl,
+    attrs = {
+        "outs": attr.output_list(
+            allow_empty = False,
+            mandatory = True,
+        ),
+        "outs_as_strings": attr.string_list(
+            allow_empty = False,
+            mandatory = True,
+        ),
+        "_package": attr.label(
+            default = "//:node_modules/@angular/service-worker",
+        ),
+        "_tool": attr.label(
+            default = "//tools/build_rules/js:assemble_service_worker_files",
+            cfg = "exec",
+            executable = True,
+        ),
+    },
+)
+
+def assemble_service_worker_files(outs, **kwargs):
+    _assemble_service_worker_files(
+        outs = outs,
+        outs_as_strings = outs,
+        **kwargs
+    )
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 98b457b..48732f9 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -10,6 +10,10 @@
     ],
     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",
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index 9c50ee0..e646a25 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -20,5 +20,51 @@
 }
 
 #EndGame > div > label {
-  padding: 0;
+  padding: 0px;
+}
+
+.button_row {
+  display: flex;
+  width: 90vw;
+  justify-content: space-between;
+}
+
+.qr-container {
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  padding: 0px;
+}
+
+.qrcode-buttons > li.page-item {
+  padding: 0px;
+}
+
+/* Using deprecated ::ng-deep here, but couldn't find a better way to do it.
+ * The qrcode container generates a canvas without any style markers or
+ * classes. Angular's view encapsulation prevents this style from applying.
+ * Maybe https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted ? */
+:host ::ng-deep .qr-container canvas {
+  /* Make the QR code take up as much space as possible. Can't take up more
+   * than the screen size, however, because you can't scan a QR code while
+   * scrolling. It needs to be fully visibile. */
+  width: 100% !important;
+  height: 100% !important;
+  aspect-ratio: 1 / 1;
+}
+
+/* Make the UI a little more compact. The QR code itself already has a good
+ * amount of margin. */
+
+.qrcode-nav {
+  padding: 0px;
+}
+
+.qrcode-buttons {
+  padding: 0px;
+  margin: 0px;
+}
+
+.qrcode {
+  padding: 0px;
 }
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index b5bc51e..8ede3a2 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -26,6 +26,7 @@
 } from '../../webserver/requests/messages/submit_2024_actions_generated';
 import {Match} from '../../webserver/requests/messages/request_all_matches_response_generated';
 import {MatchListRequestor} from '../rpc';
+import * as pako from 'pako';
 
 type Section =
   | 'Team Selection'
@@ -35,6 +36,7 @@
   | 'Endgame'
   | 'Dead'
   | 'Review and Submit'
+  | 'QR Code'
   | 'Success';
 
 // TODO(phil): Deduplicate with match_list.component.ts.
@@ -50,6 +52,13 @@
   f: 'Finals',
 };
 
+// The maximum number of bytes per QR code. The user can adjust this value to
+// make the QR code contain less information, but easier to scan.
+const QR_CODE_PIECE_SIZES = [150, 300, 450, 600, 750, 900];
+
+// 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';
@@ -75,7 +84,7 @@
   | {
       type: 'robotDeathAction';
       timestamp?: number;
-      robotOn: boolean;
+      robotDead: boolean;
     }
   | {
       type: 'penaltyAction';
@@ -112,6 +121,7 @@
   // of radio buttons.
   readonly COMP_LEVELS = COMP_LEVELS;
   readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
+  readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
   readonly ScoreType = ScoreType;
   readonly StageType = StageType;
 
@@ -129,6 +139,8 @@
   errorMessage: string = '';
   autoPhase: boolean = true;
   mobilityCompleted: boolean = false;
+  selectedValue = 0;
+  nextTeamNumber = '';
 
   preScouting: boolean = false;
   matchStartTimestamp: number = 0;
@@ -136,16 +148,29 @@
 
   teamSelectionIsValid = false;
 
+  // When the user chooses to generate QR codes, we convert the flatbuffer into
+  // a long string. Since we frequently have more data than we can display in a
+  // single QR code, we break the data into multiple QR codes. The data for
+  // each QR code ("pieces") is stored in the `qrCodeValuePieces` list below.
+  // The `qrCodeValueIndex` keeps track of which QR code we're currently
+  // displaying.
+  qrCodeValuePieceSize = QR_CODE_PIECE_SIZES[DEFAULT_QR_CODE_PIECE_SIZE_INDEX];
+  qrCodeValuePieces: string[] = [];
+  qrCodeValueIndex: number = 0;
+
   constructor(private readonly matchListRequestor: MatchListRequestor) {}
 
   ngOnInit() {
     // 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';
+    this.fetchMatchList();
+  }
 
-    if (this.section == 'Team Selection') {
-      this.fetchMatchList();
-    }
+  goToNextTeam() {
+    this.ngOnInit();
+    this.teamNumber = this.nextTeamNumber;
+    this.nextTeamNumber = '';
   }
 
   async fetchMatchList() {
@@ -174,19 +199,18 @@
     if (this.teamNumber == null) {
       return false;
     }
-    const teamNumber = this.teamNumber;
 
     for (const match of this.matchList) {
       if (
         this.matchNumber == match.matchNumber() &&
         this.setNumber == match.setNumber() &&
         this.compLevel == match.compLevel() &&
-        (teamNumber === match.r1() ||
-          teamNumber === match.r2() ||
-          teamNumber === match.r3() ||
-          teamNumber === match.b1() ||
-          teamNumber === match.b2() ||
-          teamNumber === match.b3())
+        (this.teamNumber === match.r1() ||
+          this.teamNumber === match.r2() ||
+          this.teamNumber === match.r3() ||
+          this.teamNumber === match.b1() ||
+          this.teamNumber === match.b2() ||
+          this.teamNumber === match.b3())
       ) {
         return true;
       }
@@ -219,7 +243,8 @@
     }
 
     if (action.type == 'endMatchAction') {
-      // endMatchAction occurs at the same time as penaltyAction so add to its timestamp to make it unique.
+      // endMatchAction occurs at the same time as penaltyAction so add to its
+      // timestamp to make it unique.
       action.timestamp += 1;
     }
 
@@ -282,6 +307,11 @@
     this.errorMessage = '';
     this.progressMessage = '';
 
+    // For the QR code screen, we need to make the value to encode available.
+    if (target == 'QR Code') {
+      this.updateQrCodeValuePieceSize();
+    }
+
     this.section = target;
   }
 
@@ -291,7 +321,7 @@
     this.header.nativeElement.scrollIntoView();
   }
 
-  async submit2024Actions() {
+  createActionsBuffer() {
     const builder = new Builder();
     const actionOffsets: number[] = [];
 
@@ -363,7 +393,7 @@
 
         case 'robotDeathAction':
           const robotDeathActionOffset =
-            RobotDeathAction.createRobotDeathAction(builder, action.robotOn);
+            RobotDeathAction.createRobotDeathAction(builder, action.robotDead);
           actionOffset = Action.createAction(
             builder,
             BigInt(action.timestamp || 0),
@@ -419,16 +449,117 @@
     Submit2024Actions.addPreScouting(builder, this.preScouting);
     builder.finish(Submit2024Actions.endSubmit2024Actions(builder));
 
-    const buffer = builder.asUint8Array();
+    return builder.asUint8Array();
+  }
+
+  // Same as createActionsBuffer, but encoded as Base64. It's also split into
+  // a number of pieces so that each piece is roughly limited to
+  // `qrCodeValuePieceSize` bytes.
+  createBase64ActionsBuffers(): string[] {
+    const originalBuffer = this.createActionsBuffer();
+    const deflatedData = pako.deflate(originalBuffer, {level: 9});
+
+    const pieceSize = this.qrCodeValuePieceSize;
+    const fullValue = btoa(String.fromCharCode(...deflatedData));
+    const numPieces = Math.ceil(fullValue.length / pieceSize);
+
+    let splitData: string[] = [];
+    for (let i = 0; i < numPieces; i++) {
+      const splitPiece = fullValue.slice(i * pieceSize, (i + 1) * pieceSize);
+      splitData.push(`${i}_${numPieces}_${pieceSize}_${splitPiece}`);
+    }
+    return splitData;
+  }
+
+  setQrCodeValueIndex(index: number) {
+    this.qrCodeValueIndex = Math.max(
+      0,
+      Math.min(index, this.qrCodeValuePieces.length - 1)
+    );
+  }
+
+  updateQrCodeValuePieceSize() {
+    this.qrCodeValuePieces = this.createBase64ActionsBuffers();
+    this.qrCodeValueIndex = 0;
+  }
+
+  async submit2024Actions() {
     const res = await fetch('/requests/submit/submit_2024_actions', {
       method: 'POST',
-      body: buffer,
+      body: this.createActionsBuffer(),
     });
 
     if (res.ok) {
       // We successfully submitted the data. Report success.
       this.section = 'Success';
       this.actionList = [];
+
+      // Keep track of the position of the last robot, use to figure out what the next robot in the same position is.
+
+      let lastTeamPos = '0';
+      for (const match of this.matchList) {
+        if (
+          this.matchNumber === match.matchNumber() &&
+          this.setNumber === match.setNumber() &&
+          this.compLevel === match.compLevel()
+        ) {
+          this.teamNumber = this.teamNumber;
+          if (this.teamNumber == match.r1()) {
+            lastTeamPos = 'r1';
+          } else if (this.teamNumber == match.r2()) {
+            lastTeamPos = 'r2';
+          } else if (this.teamNumber == match.r3()) {
+            lastTeamPos = 'r3';
+          } else if (this.teamNumber == match.b1()) {
+            lastTeamPos = 'b1';
+          } else if (this.teamNumber == match.b2()) {
+            lastTeamPos = 'b2';
+          } else if (this.teamNumber == match.b3()) {
+            lastTeamPos = 'b3';
+          } else {
+            console.log('Position of scouted team not found.');
+          }
+          break;
+        }
+      }
+      if (lastTeamPos != '0') {
+        this.matchNumber += 1;
+        for (const match of this.matchList) {
+          if (
+            this.matchNumber == match.matchNumber() &&
+            this.setNumber == match.setNumber() &&
+            this.compLevel == match.compLevel()
+          ) {
+            if (lastTeamPos == 'r1') {
+              this.nextTeamNumber = match.r1();
+            } else if (lastTeamPos == 'r2') {
+              this.nextTeamNumber = match.r2();
+            } else if (lastTeamPos == 'r3') {
+              this.nextTeamNumber = match.r3();
+            } else if (lastTeamPos == 'b1') {
+              this.nextTeamNumber = match.b1();
+            } else if (lastTeamPos == 'b2') {
+              this.nextTeamNumber = match.b2();
+            } else if (lastTeamPos == 'b3') {
+              this.nextTeamNumber = match.b3();
+            } else {
+              console.log('Position of last team not found.');
+            }
+            break;
+          }
+        }
+      } else {
+        console.log('Last team position not found.');
+      }
+      this.matchList = [];
+      this.progressMessage = '';
+      this.errorMessage = '';
+      this.autoPhase = true;
+      this.actionList = [];
+      this.mobilityCompleted = false;
+      this.preScouting = false;
+      this.matchStartTimestamp = 0;
+      this.selectedValue = 0;
     } else {
       const resBuffer = await res.arrayBuffer();
       const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index c74d4d0..bcba4ee 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -2,10 +2,11 @@
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
 import {EntryComponent} from './entry.component';
+import {QRCodeModule} from 'angularx-qrcode';
 
 @NgModule({
   declarations: [EntryComponent],
   exports: [EntryComponent],
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, QRCodeModule],
 })
 export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index ea44ad3..553fa1e 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -123,7 +123,7 @@
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotOn: false});"
+        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
       >
         DEAD
       </button>
@@ -183,15 +183,21 @@
       selection and keep all buttons visible without scrolling on most devices.
     -->
     <div
-      [ngClass]="{'d-grid': true, 'gap-3': autoPhase === true, 'gap-5': autoPhase === false}"
+      [ngClass]="{'d-grid': true, 'gap-4': autoPhase === true, 'gap-3': autoPhase === false}"
     >
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotOn: false});"
+        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
       >
         DEAD
       </button>
+      <button
+        class="btn btn-info"
+        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kDROPPED});"
+      >
+        Dropped
+      </button>
       <div *ngIf="!autoPhase" class="d-grid gap-1" style="padding: 0">
         <div
           style="
@@ -298,40 +304,54 @@
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotOn: false});"
+        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
       >
         DEAD
       </button>
-      <label>
-        <input
-          #park
-          type="radio"
-          id="option1"
-          name="endgameaction"
-          value="park"
-        />
-        Park
-      </label>
-      <label>
-        <input
-          #onStage
-          type="radio"
-          id="option2"
-          name="endgameaction"
-          value="onStage"
-        />
-        On Stage
-      </label>
-      <label>
-        <input
-          #harmony
-          type="radio"
-          id="option3"
-          name="endgameaction"
-          value="harmony"
-        />
-        Harmony
-      </label>
+      <div class="button_row">
+        <label>
+          <input
+            #park
+            type="radio"
+            id="option1"
+            name="endgameaction"
+            value="park"
+          />
+          Park
+        </label>
+        <label>
+          <input
+            #onStage
+            type="radio"
+            id="option2"
+            name="endgameaction"
+            value="onStage"
+          />
+          On Stage
+        </label>
+      </div>
+      <div class="button_row">
+        <label>
+          <input
+            #harmony
+            type="radio"
+            id="option3"
+            name="endgameaction"
+            value="harmony"
+          />
+          Harmony
+        </label>
+        <label>
+          <input
+            #na
+            type="radio"
+            id="option2"
+            name="endgameaction"
+            value="na"
+          />
+          N/A
+        </label>
+      </div>
       <label>
         <input
           #trapNote
@@ -352,6 +372,7 @@
         />
         Spotlight
       </label>
+
       <div style="display: flex">
         <h5>Penalties :</h5>
         <button
@@ -381,13 +402,38 @@
   </div>
   <div *ngSwitchCase="'Dead'" id="Dead" class="container-fluid">
     <h2>Robot is dead</h2>
-    <div class="d-grid gap-2">
+    <div class="d-grid gap-3">
+      <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
+      <div style="display: flex">
+        <h5>Penalties :</h5>
+        <button
+          class="btn-light"
+          style="width: 40px; margin-right: 15px"
+          (click)="removePenalty()"
+        >
+          -
+        </button>
+        <p>{{this.penalties}}</p>
+        <button
+          class="btn-light"
+          style="width: 40px; margin-left: 15px"
+          (click)="addPenalty()"
+        >
+          +
+        </button>
+      </div>
       <button
         class="btn btn-success"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'robotDeathAction', robotOn: true}); "
+        (click)="changeSectionTo('Pickup'); addAction({type: 'robotDeathAction', 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});"
+      >
+        End Match
+      </button>
     </div>
   </div>
   <div *ngSwitchCase="'Review and Submit'" id="Review" class="container-fluid">
@@ -414,7 +460,7 @@
               spotlight: {{action.spotlight}}
             </span>
             <span *ngSwitchCase="'robotDeathAction'">
-              Robot on: {{action.robotOn}}
+              Robot dead: {{action.robotDead}}
             </span>
             <span *ngSwitchCase="'mobilityAction'">
               Mobility: {{action.mobility}}
@@ -429,6 +475,9 @@
     </div>
     <div class="d-grid gap-5">
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
+      <button class="btn btn-info" (click)="changeSectionTo('QR Code');">
+        Create QR Code
+      </button>
       <button class="btn btn-warning" (click)="submit2024Actions();">
         Submit
       </button>
@@ -436,6 +485,80 @@
   </div>
   <div *ngSwitchCase="'Success'" id="Success" class="container-fluid">
     <h2>Successfully submitted data.</h2>
+    <div class="d-grid gap-5" *ngIf="nextTeamNumber != ''">
+      <button class="btn btn-primary" (click)="goToNextTeam();">
+        SCOUT NEXT TEAM
+      </button>
+    </div>
+  </div>
+  <div *ngSwitchCase="'QR Code'" id="QR Code" class="container-fluid">
+    <span>Density:</span>
+    <select
+      [(ngModel)]="qrCodeValuePieceSize"
+      (ngModelChange)="updateQrCodeValuePieceSize()"
+      type="number"
+      id="qr_code_piece_size"
+    >
+      <option
+        *ngFor="let pieceSize of QR_CODE_PIECE_SIZES"
+        [ngValue]="pieceSize"
+      >
+        {{pieceSize}}
+      </option>
+    </select>
+    <div class="qr-container">
+      <qrcode
+        [qrdata]="qrCodeValuePieces[qrCodeValueIndex]"
+        [width]="1000"
+        [errorCorrectionLevel]="'M'"
+        [margin]="6"
+        class="qrcode"
+      ></qrcode>
+    </div>
+    <nav class="qrcode-nav">
+      <ul
+        class="qrcode-buttons pagination pagination-lg justify-content-center"
+      >
+        <li class="page-item">
+          <a
+            class="page-link"
+            href="#"
+            aria-label="Previous"
+            (click)="setQrCodeValueIndex(qrCodeValueIndex - 1)"
+          >
+            <span aria-hidden="true">&laquo;</span>
+            <span class="visually-hidden">Previous</span>
+          </a>
+        </li>
+        <li *ngFor="let _ of qrCodeValuePieces; index as i" class="page-item">
+          <a
+            class="page-link"
+            href="#"
+            (click)="setQrCodeValueIndex(i)"
+            [class.active]="qrCodeValueIndex == i"
+          >
+            {{i + 1}}
+          </a>
+        </li>
+        <li class="page-item">
+          <a
+            class="page-link"
+            href="#"
+            aria-label="Next"
+            (click)="setQrCodeValueIndex(qrCodeValueIndex + 1)"
+          >
+            <span aria-hidden="true">&raquo;</span>
+            <span class="visually-hidden">Next</span>
+          </a>
+        </li>
+      </ul>
+    </nav>
+    <button
+      class="btn btn-secondary"
+      (click)="changeSectionTo('Review and Submit')"
+    >
+      BACK
+    </button>
   </div>
 
   <span class="progress_message" role="alert">{{ progressMessage }}</span>
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index d37ce10..a02c0a6 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -2,6 +2,8 @@
     "name": "@org_frc971/scouting/www/entry",
     "private": true,
     "dependencies": {
+        "pako": "2.1.0",
+        "@types/pako": "2.0.3",
         "@org_frc971/scouting/www/counter_button": "workspace:*",
         "@angular/forms": "v16-lts"
     }
diff --git a/scouting/www/index.html b/scouting/www/index.html
index 46779cc..821acf2 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -4,6 +4,8 @@
     <meta charset="utf-8" />
     <title>FRC971 Scouting Application</title>
     <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <link rel="manifest" href="/manifest.json" />
     <link
       rel="stylesheet"
       href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
@@ -16,8 +18,17 @@
       integrity="d8824f7067cdfea38afec7e9ffaf072125266824206d69ef1f112d72153a505e"
     />
     <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></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
+      // way to interact with it.
+      var Module = {};
+    </script>
   </head>
   <body>
     <my-app></my-app>
+    <noscript>
+      Please enable JavaScript to continue using this application.
+    </noscript>
   </body>
 </html>
diff --git a/scouting/www/manifest.json b/scouting/www/manifest.json
new file mode 100644
index 0000000..9366399
--- /dev/null
+++ b/scouting/www/manifest.json
@@ -0,0 +1,17 @@
+{
+  "name": "FRC971 Scouting App",
+  "short_name": "scouting",
+  "theme_color": "#1976d2",
+  "background_color": "#fafafa",
+  "display": "standalone",
+  "scope": "./",
+  "start_url": "./",
+  "icons": [
+    {
+      "src": "assets/971_144.png",
+      "sizes": "144x144",
+      "type": "image/png",
+      "purpose": "maskable any"
+    }
+  ]
+}
diff --git a/scouting/www/ngsw-config.json b/scouting/www/ngsw-config.json
new file mode 100644
index 0000000..78d921d
--- /dev/null
+++ b/scouting/www/ngsw-config.json
@@ -0,0 +1,32 @@
+{
+    "$schema": "./node_modules/@angular/service-worker/config/schema.json",
+    "index": "/index.html",
+    "assetGroups": [
+      {
+        "name": "app",
+        "installMode": "prefetch",
+        "resources": {
+          "files": [
+            "/favicon.ico",
+            "/index.html",
+            "/manifest.json",
+            "/*.css",
+            "/bundle-*/*.js",
+            "/*.js"
+          ]
+        }
+      },
+      {
+        "name": "assets",
+        "installMode": "lazy",
+        "updateMode": "prefetch",
+        "resources": {
+          "files": [
+            "/assets/**",
+            "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
+          ]
+        }
+      }
+    ]
+  }
+  
diff --git a/scouting/www/ngsw_config_wrapper.js b/scouting/www/ngsw_config_wrapper.js
new file mode 100644
index 0000000..0bc7f19
--- /dev/null
+++ b/scouting/www/ngsw_config_wrapper.js
@@ -0,0 +1,37 @@
+const fs = require('fs');
+const path = require('path');
+const {spawnSync} = require('child_process');
+
+const output_dir = path.join(
+  process.env.BAZEL_BINDIR,
+  process.env.BAZEL_PACKAGE
+);
+console.log(output_dir);
+console.log(process.argv[2]);
+console.log(process.cwd());
+const ngsw_config = process.argv[2];
+console.log(`Trying to run ${ngsw_config} ${process.argv.slice(4).join(' ')}`);
+const result = spawnSync(ngsw_config, process.argv.slice(4), {
+  stdout: 'inherit',
+  stderr: 'inherit',
+});
+
+if (result.status || result.error || result.signal) {
+  console.log("Failed to run 'ngsw_config'");
+  console.log(`status: ${result.status}`);
+  console.log(`error: ${result.error}`);
+  console.log(`signal: ${result.signal}`);
+  console.log(`stdout: ${result.stdout}`);
+  console.log(`stderr: ${result.stderr}`);
+  process.exit(1);
+}
+
+const currentDirectory = process.cwd();
+
+// Read the contents of the current directory
+console.log(`Contents of the current directory: ${currentDirectory}`);
+fs.readdirSync(currentDirectory).forEach((file) => {
+  console.log(file);
+});
+
+fs.copyFileSync(path.join(process.argv[4], 'ngsw.json'), process.argv[3]);
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index 3848c22..431ec71 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -56,8 +56,8 @@
 const KEYWORD_CHECKBOX_LABELS = {
   goodDriving: 'Good Driving',
   badDriving: 'Bad Driving',
-  solidPlacing: 'Solid Placing',
-  sketchyPlacing: 'Sketchy Placing',
+  solidPlacing: 'Solid Shooting',
+  sketchyPlacing: 'Sketchy Shooting',
   goodDefense: 'Good Defense',
   badDefense: 'Bad Defense',
   easilyDefended: 'Easily Defended',
diff --git a/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
index d1367ea..592735c 100644
--- a/scouting/www/rpc/BUILD
+++ b/scouting/www/rpc/BUILD
@@ -10,6 +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",
diff --git a/scouting/www/rpc/db.ts b/scouting/www/rpc/db.ts
new file mode 100644
index 0000000..789ac0e
--- /dev/null
+++ b/scouting/www/rpc/db.ts
@@ -0,0 +1,18 @@
+import Dexie, {Table} from 'dexie';
+
+export interface MatchListData {
+  id?: number;
+  data: Uint8Array;
+}
+
+export class AppDB extends Dexie {
+  matchListData!: Table<MatchListData, number>;
+
+  constructor() {
+    super('ngdexieliveQuery');
+    this.version(1).stores({
+      matchListData: 'id,data',
+    });
+  }
+}
+export const db = new AppDB();
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index fa2dcbd..0a812ce 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -6,78 +6,82 @@
   Match,
   RequestAllMatchesResponse,
 } from '../../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'})
 export class MatchListRequestor {
   async fetchMatchList(): Promise<Match[]> {
     const builder = new Builder();
     RequestAllMatches.startRequestAllMatches(builder);
     builder.finish(RequestAllMatches.endRequestAllMatches(builder));
-
     const buffer = builder.asUint8Array();
     const res = await fetch('/requests/request/all_matches', {
       method: 'POST',
       body: buffer,
     });
-
     if (res.ok) {
       const resBuffer = await res.arrayBuffer();
-      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-      const parsedResponse =
-        RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
-
-      // Convert the flatbuffer list into an array. That's more useful.
-      const matchList = [];
-      for (let i = 0; i < parsedResponse.matchListLength(); i++) {
-        matchList.push(parsedResponse.matchList(i));
-      }
-
-      // Sort the list so it is in chronological order.
-      matchList.sort((a, b) => {
-        // First sort by match type. E.g. finals are last.
-        const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
-        const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
-        if (aMatchTypeIndex < bMatchTypeIndex) {
-          return -1;
-        }
-        if (aMatchTypeIndex > bMatchTypeIndex) {
-          return 1;
-        }
-        // Then sort by match number. E.g. in semi finals, all match 1 rounds
-        // are done first. Then come match 2 rounds. And then, if necessary,
-        // the match 3 rounds.
-        const aMatchNumber = a.matchNumber();
-        const bMatchNumber = b.matchNumber();
-        if (aMatchNumber < bMatchNumber) {
-          return -1;
-        }
-        if (aMatchNumber > bMatchNumber) {
-          return 1;
-        }
-        // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
-        // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
-        // Semi Final 2 Match 2.
-        const aSetNumber = a.setNumber();
-        const bSetNumber = b.setNumber();
-        if (aSetNumber < bSetNumber) {
-          return -1;
-        }
-        if (aSetNumber > bSetNumber) {
-          return 1;
-        }
-        return 0;
-      });
-
-      return matchList;
+      const u8Buffer = new Uint8Array(resBuffer);
+      // Cache the response.
+      await db.matchListData.put({id: 1, data: u8Buffer});
+      return this.parseMatchList(u8Buffer);
     } else {
+      const cachedResult = await db.matchListData.where({id: 1}).toArray();
+      if (cachedResult && cachedResult.length == 1) {
+        const u8Buffer = cachedResult[0].data;
+        return this.parseMatchList(u8Buffer);
+      }
       const resBuffer = await res.arrayBuffer();
       const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
       const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
-
       const errorMessage = parsedResponse.errorMessage();
       throw `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
     }
   }
+  parseMatchList(u8Buffer: Uint8Array): Match[] {
+    const fbBuffer = new ByteBuffer(u8Buffer);
+    const parsedResponse =
+      RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
+    // Convert the flatbuffer list into an array. That's more useful.
+    const matchList = [];
+    for (let i = 0; i < parsedResponse.matchListLength(); i++) {
+      matchList.push(parsedResponse.matchList(i));
+    }
+    // Sort the list so it is in chronological order.
+    matchList.sort((a, b) => {
+      // First sort by match type. E.g. finals are last.
+      const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
+      const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
+      if (aMatchTypeIndex < bMatchTypeIndex) {
+        return -1;
+      }
+      if (aMatchTypeIndex > bMatchTypeIndex) {
+        return 1;
+      }
+      // Then sort by match number. E.g. in semi finals, all match 1 rounds
+      // are done first. Then come match 2 rounds. And then, if necessary,
+      // the match 3 rounds.
+      const aMatchNumber = a.matchNumber();
+      const bMatchNumber = b.matchNumber();
+      if (aMatchNumber < bMatchNumber) {
+        return -1;
+      }
+      if (aMatchNumber > bMatchNumber) {
+        return 1;
+      }
+      // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
+      // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
+      // Semi Final 2 Match 2.
+      const aSetNumber = a.setNumber();
+      const bSetNumber = b.setNumber();
+      if (aSetNumber < bSetNumber) {
+        return -1;
+      }
+      if (aSetNumber > bSetNumber) {
+        return 1;
+      }
+      return 0;
+    });
+    return matchList;
+  }
 }
diff --git a/scouting/www/scan/BUILD b/scouting/www/scan/BUILD
new file mode 100644
index 0000000..88b2822
--- /dev/null
+++ b/scouting/www/scan/BUILD
@@ -0,0 +1,18 @@
+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 = "scan",
+    extra_srcs = [
+        "//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",
+    ],
+)
diff --git a/scouting/www/scan/package.json b/scouting/www/scan/package.json
new file mode 100644
index 0000000..a5950c3
--- /dev/null
+++ b/scouting/www/scan/package.json
@@ -0,0 +1,9 @@
+{
+    "name": "@org_frc971/scouting/www/scan",
+    "private": true,
+    "dependencies": {
+        "pako": "2.1.0",
+        "@types/pako": "2.0.3",
+        "@angular/forms": "v16-lts"
+    }
+}
diff --git a/scouting/www/scan/scan.component.css b/scouting/www/scan/scan.component.css
new file mode 100644
index 0000000..11cfe09
--- /dev/null
+++ b/scouting/www/scan/scan.component.css
@@ -0,0 +1,20 @@
+video {
+  width: 100%;
+  aspect-ratio: 1 / 1;
+}
+
+canvas {
+  /* We don't want to show the frames that we are scanning for QR codes. It's
+   * nicer to just see the video stream. */
+  display: none;
+}
+
+ul {
+  margin: 0px;
+}
+
+li > a.active {
+  /* Set the scanned QR codes to a green color. */
+  background-color: #198754;
+  border-color: #005700;
+}
diff --git a/scouting/www/scan/scan.component.ts b/scouting/www/scan/scan.component.ts
new file mode 100644
index 0000000..98a7b61
--- /dev/null
+++ b/scouting/www/scan/scan.component.ts
@@ -0,0 +1,236 @@
+import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {Builder, ByteBuffer} from 'flatbuffers';
+import * as pako from 'pako';
+
+declare var cv: any;
+declare var Module: any;
+
+// The number of milliseconds between QR code scans.
+const SCAN_PERIOD = 500;
+
+@Component({
+  selector: 'app-scan',
+  templateUrl: './scan.ng.html',
+  styleUrls: ['../app/common.css', './scan.component.css'],
+})
+export class ScanComponent implements OnInit {
+  @ViewChild('video')
+  public video: ElementRef;
+
+  @ViewChild('canvas')
+  public canvas: ElementRef;
+
+  errorMessage: string = '';
+  progressMessage: string = 'Waiting for QR code(s)';
+  scanComplete: boolean = false;
+  videoStartedSuccessfully = false;
+
+  qrCodeValuePieces: string[] = [];
+  qrCodeValuePieceSize = 0;
+
+  scanStream: MediaStream | null = null;
+  scanTimer: ReturnType<typeof setTimeout> | null = null;
+
+  constructor(private ngZone: NgZone) {}
+
+  ngOnInit() {
+    // If the user switched away from this tab, then the onRuntimeInitialized
+    // attribute will already be set. No need to load OpenCV again. If it's not
+    // loaded, however, we need to load it.
+    if (!Module['onRuntimeInitialized']) {
+      Module['onRuntimeInitialized'] = () => {
+        // Since the WASM code doesn't know about the Angular zone, we force
+        // it into the correct zone so that the UI gets updated properly.
+        this.ngZone.run(() => {
+          this.startScanning();
+        });
+      };
+      // Now that we set up the hook, we can load OpenCV.
+      this.loadOpenCv();
+    } else {
+      this.startScanning();
+    }
+  }
+
+  ngOnDestroy() {
+    clearInterval(this.scanTimer);
+
+    // Explicitly stop the streams so that the camera isn't being locked
+    // unnecessarily. I.e. other processes can use it too.
+    if (this.scanStream) {
+      this.scanStream.getTracks().forEach((track) => {
+        track.stop();
+      });
+    }
+  }
+
+  public ngAfterViewInit() {
+    // Start the video playback.
+    // It would be nice to let the user select which camera gets used. For now,
+    // we give the "environment" hint so that it faces away from the user.
+    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+      navigator.mediaDevices
+        .getUserMedia({video: {facingMode: 'environment'}})
+        .then(
+          (stream) => {
+            this.scanStream = stream;
+            this.video.nativeElement.srcObject = stream;
+            this.video.nativeElement.play();
+            this.videoStartedSuccessfully = true;
+          },
+          (reason) => {
+            this.progressMessage = '';
+            this.errorMessage = `Failed to start video: ${reason}`;
+          }
+        );
+    }
+  }
+
+  async scan() {
+    if (!this.videoStartedSuccessfully) {
+      return;
+    }
+
+    // Take a capture of the video stream. That capture can then be used by
+    // OpenCV to perform the QR code detection. Due to my inexperience, I could
+    // only make this code work if I size the (invisible) canvas to match the
+    // video element. Otherwise, I'd get cropped images.
+    // Can we stream the video directly into the canvas?
+    const width = this.video.nativeElement.clientWidth;
+    const height = this.video.nativeElement.clientHeight;
+    this.canvas.nativeElement.width = width;
+    this.canvas.nativeElement.height = height;
+    this.canvas.nativeElement
+      .getContext('2d')
+      .drawImage(this.video.nativeElement, 0, 0, width, height);
+
+    // Perform the QR code detection. We use the Aruco-variant of the detector
+    // here because it appears to detect QR codes much more reliably than the
+    // standard detector.
+    let mat = cv.imread('canvas');
+    let qrDecoder = new cv.QRCodeDetectorAruco();
+    const result = qrDecoder.detectAndDecode(mat);
+    mat.delete();
+
+    // Handle the result.
+    if (result) {
+      await this.scanSuccessHandler(result);
+    } else {
+      await this.scanFailureHandler();
+    }
+  }
+
+  async scanSuccessHandler(scanResult: string) {
+    // Reverse the conversion and obtain the original Uint8Array. In other
+    // words, undo the work in `scouting/www/entry/entry.component.ts`.
+    const [indexStr, numPiecesStr, pieceSizeStr, splitPiece] = scanResult.split(
+      '_',
+      4
+    );
+
+    // If we didn't get enough data, then maybe we scanned some non-scouting
+    // related QR code? Try to give a hint to the user.
+    if (!indexStr || !numPiecesStr || !pieceSizeStr || !splitPiece) {
+      this.progressMessage = '';
+      this.errorMessage = `Couldn't find scouting data in the QR code.`;
+      return;
+    }
+
+    const index = Number(indexStr);
+    const numPieces = Number(numPiecesStr);
+    const pieceSize = Number(pieceSizeStr);
+
+    if (
+      numPieces != this.qrCodeValuePieces.length ||
+      pieceSize != this.qrCodeValuePieceSize
+    ) {
+      // The number of pieces or the piece size changed. We need to reset our accounting.
+      this.qrCodeValuePieces = new Array<string>(numPieces);
+      this.qrCodeValuePieceSize = pieceSize;
+    }
+
+    this.qrCodeValuePieces[index] = splitPiece;
+    this.progressMessage = `Scanned QR code ${index + 1} out of ${
+      this.qrCodeValuePieces.length
+    }`;
+
+    // Count up the number of missing pieces so we can give a progress update.
+    let numMissingPieces = 0;
+    for (const piece of this.qrCodeValuePieces) {
+      if (!piece) {
+        numMissingPieces++;
+      }
+    }
+    if (numMissingPieces > 0) {
+      this.progressMessage = `Waiting for ${numMissingPieces} out of ${this.qrCodeValuePieces.length} QR codes.`;
+      this.errorMessage = '';
+      return;
+    }
+
+    // Stop scanning now that we have all the pieces.
+    this.progressMessage = 'Scanned all QR codes. Submitting.';
+    this.scanComplete = true;
+    clearInterval(this.scanTimer);
+
+    const encodedData = this.qrCodeValuePieces.join('');
+    const deflatedData = Uint8Array.from(atob(encodedData), (c) =>
+      c.charCodeAt(0)
+    );
+    const actionBuffer = pako.inflate(deflatedData);
+
+    const res = await fetch('/requests/submit/submit_2024_actions', {
+      method: 'POST',
+      body: actionBuffer,
+    });
+
+    if (res.ok) {
+      // We successfully submitted the data. Report success.
+      this.progressMessage = 'Success!';
+      this.errorMessage = '';
+    } else {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+
+      const errorMessage = parsedResponse.errorMessage();
+      this.progressMessage = '';
+      this.errorMessage = `Submission failed with ${res.status} ${res.statusText}: "${errorMessage}"`;
+    }
+  }
+
+  async scanFailureHandler() {
+    this.progressMessage = '';
+    this.errorMessage = 'Failed to scan!';
+  }
+
+  loadOpenCv() {
+    // Make the browser load OpenCV.
+    let body = <HTMLDivElement>document.body;
+    let script = document.createElement('script');
+    script.innerHTML = '';
+    script.src = 'assets/opencv_4.9.0/opencv.js';
+    script.async = false;
+    script.defer = true;
+    script.onerror = (error) => {
+      this.progressMessage = '';
+      if (typeof error === 'string') {
+        this.errorMessage = `OpenCV failed to load: ${error}`;
+      } else {
+        this.errorMessage = 'OpenCV failed to load.';
+      }
+      // Since we use the onRuntimeInitialized property as a flag to see if we
+      // need to perform loading, we need to delete the property. When the user
+      // switches away from this tab and then switches back, we want to attempt
+      // loading again.
+      delete Module['onRuntimeInitialized'];
+    };
+    body.appendChild(script);
+  }
+
+  startScanning() {
+    this.scanTimer = setInterval(() => {
+      this.scan();
+    }, SCAN_PERIOD);
+  }
+}
diff --git a/scouting/www/scan/scan.module.ts b/scouting/www/scan/scan.module.ts
new file mode 100644
index 0000000..00a9c58
--- /dev/null
+++ b/scouting/www/scan/scan.module.ts
@@ -0,0 +1,10 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {ScanComponent} from './scan.component';
+
+@NgModule({
+  declarations: [ScanComponent],
+  exports: [ScanComponent],
+  imports: [CommonModule],
+})
+export class ScanModule {}
diff --git a/scouting/www/scan/scan.ng.html b/scouting/www/scan/scan.ng.html
new file mode 100644
index 0000000..f9b82b3
--- /dev/null
+++ b/scouting/www/scan/scan.ng.html
@@ -0,0 +1,21 @@
+<h1>Scan</h1>
+<span class="progress_message" role="alert">{{ progressMessage }}</span>
+<span class="error_message" role="alert">{{ errorMessage }}</span>
+<nav class="qrcode-progress" *ngIf="!scanComplete">
+  <ul class="pagination pagination-lg justify-content-center">
+    <li *ngFor="let piece of qrCodeValuePieces" class="page-item">
+      <a class="page-link" href="#" [class.active]="piece">
+        <i *ngIf="piece" class="bi bi-check">
+          <span class="visually-hidden">&check;</span>
+        </i>
+        <i *ngIf="!piece" class="bi bi-camera">
+          <span class="visually-hidden">&#9746;</span>
+        </i>
+      </a>
+    </li>
+  </ul>
+</nav>
+<div *ngIf="!scanComplete">
+  <video #video id="video"></video>
+</div>
+<canvas #canvas id="canvas"></canvas>
diff --git a/third_party/rules_cypress/0001-fix-incorrect-linux-checksums.patch b/third_party/rules_cypress/0001-fix-incorrect-linux-checksums.patch
new file mode 100644
index 0000000..b69e828
--- /dev/null
+++ b/third_party/rules_cypress/0001-fix-incorrect-linux-checksums.patch
@@ -0,0 +1,42 @@
+From 500a658a00cbd7839d2431e51003429d542b7f4a Mon Sep 17 00:00:00 2001
+From: Jack Vincent <50340752+jackvincentnz@users.noreply.github.com>
+Date: Fri, 19 Jan 2024 21:12:12 -0800
+Subject: [PATCH] fix: incorrect linux checksums
+
+---
+ cypress/private/versions.bzl | 12 ++++++------
+ 1 file changed, 6 insertions(+), 6 deletions(-)
+
+diff --git a/cypress/private/versions.bzl b/cypress/private/versions.bzl
+index 8fad4be..f4b3a1f 100644
+--- a/cypress/private/versions.bzl
++++ b/cypress/private/versions.bzl
+@@ -6,22 +6,22 @@ TOOL_VERSIONS = {
+     "13.3.1": {
+         "darwin-x64": "417c5f1d77e15c8aef0a55f155c16c3dbbc637f918c1e51f8fec6eb1c73a9ba9",
+         "darwin-arm64": "143d905779c0b0a8a9049b0eb68b4b156db3d838d4546ce5082a8f7bd5dc5232",
+-        "linux-x64": "a165aa290d23d03f191852df2e8c3cb25ddccf1d4e226518a0a217f4ec405328",
+-        "linux-arm64": "b51810d7fda748f67d5d875d5e265ee22bb394af2bb4a545b78c99f1aae91cb0",
++        "linux-x64": "bb0ddd980bd82792a477b1c39ce8a0a7b275481031c559c065c94f1131151b0c",
++        "linux-arm64": "fbca9958e2a153f3f1ffdef1bb506db65401a8586229b9d9424cd16228d0353d",
+         "win32-x64": "acf1e478634e4da254bd7c3695d9032010c2ed17955e7339e1ea7d79cf8c9f7b",
+     },
+     "12.12.0": {
+         "darwin-x64": "53ddd917112a2c5c3c22d12f5bcffda596474c7cd0932a997e575b2b97ae36c0",
+         "darwin-arm64": "2daadfe4431a98f9dc6442c155239aaed2746b12a59721424e3b5fdaaf27c766",
+-        "linux-x64": "18bf251f683e0b0ca70918c2a51b7a457be6e5298be52203bd16d4e0eb361837",
+-        "linux-arm64": "1f754c912eb719d4ac4abe31f3cc6d5b65cf08e0266cf6808f376c099928c88e",
++        "linux-x64": "7f41d45da094380cc0d6716c8357b60f9c9304c2475cf502ea60649f040d52ad",
++        "linux-arm64": "55531b5ba8d03a979a5ef92981d27964583897c652eec3788f24ec8677d05dd2",
+         "win32-x64": "ffc47314ce5f74888066bc4a15ee18d375ee9680f9cca1b94eda7293e1dea4e5",
+     },
+     "12.3.0": {
+         "darwin-x64": "beae3678dd859ce89cc45c377eef97b67558ee1f2a0079e9b4c824260ef3801a",
+         "darwin-arm64": "bb08f247110dda9b180d2552a661b8669441f931b0332d818c306a14e8c7071a",
+-        "linux-x64": "57dd85936373e6ce2ae5378f9035a3ad118899341e0c6e71783c3e58c039ce92",
+-        "linux-arm64": "47c1188506b11644a332ab0949eab0b33179a64e4857e561d3c836c6f6f2cadf",
++        "linux-x64": "3a300d6c903a8f5fced488183dcc7faa06e9df14c946d6dab4b5822ec738e9cd",
++        "linux-arm64": "501671011a63fd450b87e1cae1b3ba3fabccf37e9c1c8c26e1d5f189f9afe688",
+         "win32-x64": "639a0e0ca5498fc5330064c3fa441c741e6b6cd01234bfa9851de9a33f4f56a6",
+     },
+     "10.8.0": {
diff --git a/third_party/rules_cypress/0002-Add-support-for-cypress-13.6.6.patch b/third_party/rules_cypress/0002-Add-support-for-cypress-13.6.6.patch
new file mode 100644
index 0000000..d4d9144
--- /dev/null
+++ b/third_party/rules_cypress/0002-Add-support-for-cypress-13.6.6.patch
@@ -0,0 +1,27 @@
+From 2c8ec34c8fd4f6f485a7c7036e73fa82cd99859d Mon Sep 17 00:00:00 2001
+From: Philipp Schrader <philipp.schrader@gmail.com>
+Date: Sat, 24 Feb 2024 16:08:46 -0800
+Subject: [PATCH] Add support for cypress 13.6.6
+
+---
+ cypress/private/versions.bzl | 7 +++++++
+ 1 file changed, 7 insertions(+)
+
+diff --git a/cypress/private/versions.bzl b/cypress/private/versions.bzl
+index f4b3a1f..6a3e665 100644
+--- a/cypress/private/versions.bzl
++++ b/cypress/private/versions.bzl
+@@ -3,6 +3,13 @@
+ # Use /scripts/mirror_release.sh to add a newer version below.
+ # Versions should be descending order so TOOL_VERSIONS.keys()[0] is the latest version.
+ TOOL_VERSIONS = {
++    "13.6.6": {
++        "darwin-x64": "4b845921f35f520b8217a4ebb9106180c56de37a9db7f57f14352060c9eddca6",
++        "darwin-arm64": "4c8818534bdab028a82aefc1936a552481fa0efb2a7c5c64350e9d9d1c709c85",
++        "linux-x64": "0fe6f52c41912245a71f71c0978a3a64e0dcc9dec4449809c5bf0442032a9912",
++        "linux-arm64": "0fc709f4ae9121f5f7f1bb68423b8ec9c9d9f6c9baf85fcbe0cde3ee9627cc38",
++        "win32-x64": "a31751f41f74af9c3f74860c00d50d0a0e5d5a5fa7c9049db9d56c6082382483",
++    },
+     "13.3.1": {
+         "darwin-x64": "417c5f1d77e15c8aef0a55f155c16c3dbbc637f918c1e51f8fec6eb1c73a9ba9",
+         "darwin-arm64": "143d905779c0b0a8a9049b0eb68b4b156db3d838d4546ce5082a8f7bd5dc5232",
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index 51fe987..c2468f8 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -378,7 +378,7 @@
 
     copy_file(
         name = name + "_config",
-        out = "cypress.config.js",
+        out = name + "_cypress.config.js",
         src = "//tools/build_rules/js:cypress.config.js",
         visibility = ["//visibility:private"],
     )
@@ -392,7 +392,7 @@
         name = name,
         args = [
             "run",
-            "--config-file=cypress.config.js",
+            "--config-file=%s_cypress.config.js" % name,
             "--browser=" + chrome_location,
         ],
         browsers = ["@chrome_linux//:all"],
diff --git a/tools/build_rules/js/BUILD b/tools/build_rules/js/BUILD
index 1bbe769..86a0288 100644
--- a/tools/build_rules/js/BUILD
+++ b/tools/build_rules/js/BUILD
@@ -24,3 +24,11 @@
         "//:node_modules/@types/node",
     ],
 )
+
+py_binary(
+    name = "assemble_service_worker_files",
+    srcs = [
+        "assemble_service_worker_files.py",
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/tools/build_rules/js/assemble_service_worker_files.py b/tools/build_rules/js/assemble_service_worker_files.py
new file mode 100644
index 0000000..d4485d5
--- /dev/null
+++ b/tools/build_rules/js/assemble_service_worker_files.py
@@ -0,0 +1,27 @@
+import argparse
+import shutil
+import sys
+from pathlib import Path
+
+
+def main(argv):
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--input_dir", type=Path, action="append", default=[])
+    parser.add_argument("--output", type=Path, action="append", default=[])
+    parser.add_argument("--relative_output",
+                        type=Path,
+                        action="append",
+                        default=[])
+    args = parser.parse_args(argv[1:])
+
+    for relative_output, output in zip(args.relative_output, args.output):
+        for input_dir in args.input_dir:
+            input_file = input_dir / relative_output
+            if input_file.exists():
+                print(f"Copying {input_file} to {output}")
+                shutil.copy(input_file, output)
+                break
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/tools/build_rules/js/cypress.config.js b/tools/build_rules/js/cypress.config.js
index e991016..5d2ec1c 100644
--- a/tools/build_rules/js/cypress.config.js
+++ b/tools/build_rules/js/cypress.config.js
@@ -9,6 +9,14 @@
         launchOptions.args.push('--disable-gpu-shader-disk-cache');
         launchOptions.args.push('--enable-logging');
         launchOptions.args.push('--v=stderr');
+
+        // Point the browser at a video file to use as a webcam. This lets us
+        // validate things like QR code scanning.
+        launchOptions.args.push('--use-fake-ui-for-media-stream');
+        launchOptions.args.push('--use-fake-device-for-media-stream');
+        const fakeCameraVideo = `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/fake_camera.mjpeg`;
+        launchOptions.args.push(`--use-file-for-fake-video-capture=${fakeCameraVideo}`);
+
         return launchOptions;
       });
 
diff --git a/tools/dependency_rewrite b/tools/dependency_rewrite
index 1cd117a..14980dd 100644
--- a/tools/dependency_rewrite
+++ b/tools/dependency_rewrite
@@ -18,6 +18,7 @@
 rewrite cdn.cypress.io/(.*) software.frc971.org/Build-Dependencies/cdn.cypress.io/$1
 rewrite www.googleapis.com/(.*) software.frc971.org/Build-Dependencies/www.googleapis.com/$1
 rewrite www.johnvansickle.com/(.*) software.frc971.org/Build-Dependencies/www.johnvansickle.com/$1
+rewrite docs.opencv.org/(.*) software.frc971.org/Build-Dependencies/docs.opencv.org/$1
 allow crates.io
 allow golang.org
 allow go.dev
diff --git a/y2019/control_loops/drivetrain/localizer_test.cc b/y2019/control_loops/drivetrain/localizer_test.cc
index 7f0b13d..7c33579 100644
--- a/y2019/control_loops/drivetrain/localizer_test.cc
+++ b/y2019/control_loops/drivetrain/localizer_test.cc
@@ -173,7 +173,7 @@
           spline_goal_buffer(fbb.Release());
 
       frc971::control_loops::drivetrain::Trajectory trajectory(
-          spline_goal_buffer.message(), dt_config_);
+          spline_goal_buffer.message(), &dt_config_);
       trajectory.Plan();
 
       flatbuffers::FlatBufferBuilder traj_fbb;
diff --git a/y2024/BUILD b/y2024/BUILD
index 212f70e..704a5a4 100644
--- a/y2024/BUILD
+++ b/y2024/BUILD
@@ -55,6 +55,7 @@
         "//aos/util:foxglove_websocket",
         "//frc971/image_streamer:image_streamer",
         "//frc971/vision:intrinsics_calibration",
+        "//aos/util:filesystem_monitor",
         "//y2024/vision:viewer",
         "//y2024/constants:constants_sender",
         "//y2024/localizer:localizer_main",
@@ -63,7 +64,7 @@
     ],
     data = [
         ":aos_config",
-        "//frc971/rockpi:rockpi_config.json",
+        "//frc971/orin:orin_irq_config.json",
         "//y2024/constants:constants.json",
         "//y2024/vision:image_streamer_start",
         "//y2024/www:www_files",
@@ -124,6 +125,7 @@
         "//y2024/localizer:status_fbs",
         "//y2024/localizer:visualization_fbs",
         "//aos/network:timestamp_fbs",
+        "//aos/util:filesystem_fbs",
         "//aos/network:remote_message_fbs",
         "//frc971/vision:calibration_fbs",
         "//frc971/vision:target_map_fbs",
@@ -183,6 +185,7 @@
         "//y2024/localizer:visualization_fbs",
         "//frc971/vision:target_map_fbs",
         "//frc971/vision:vision_fbs",
+        "//aos/util:filesystem_fbs",
         "@com_github_foxglove_schemas//:schemas",
     ],
     target_compatible_with = ["@platforms//os:linux"],
diff --git a/y2024/constants/971.json b/y2024/constants/971.json
index 5cee276..62015a3 100644
--- a/y2024/constants/971.json
+++ b/y2024/constants/971.json
@@ -8,13 +8,13 @@
 {
   "cameras": [
     {
-      "calibration": {% include 'y2024/constants/calib_files/calibration_orin1-971-0_cam-24-05_2024-03-01_11-01-05.102438041.json' %}
+      "calibration": {% include 'y2024/constants/calib_files/calibration_orin-971-c-1_cam-24-05_2024-03-08_17-16-02.325066070.json' %}
     },
     {
-      "calibration": {% include 'y2024/constants/calib_files/calibration_orin1-971-1_cam-24-06_2024-03-01_11-01-20.409861949.json' %}
+      "calibration": {% include 'y2024/constants/calib_files/calibration_orin-971-1-0_cam-24-06_2024-03-08_17-16-02.325390283.json' %}
     },
     {
-      "calibration": {% include 'y2024/constants/calib_files/calibration_imu-971-0_cam-24-07_2024-03-01_11-01-32.895328333.json' %}
+      "calibration": {% include 'y2024/constants/calib_files/calibration_orin-971-1-1_cam-24-07_2024-03-08_17-16-02.325267121.json' %}
     },
     {
       "calibration": {% include 'y2024/constants/calib_files/calibration_imu-971-1_cam-24-08_2024-03-01_11-02-11.982641320.json' %}
@@ -23,23 +23,23 @@
   "robot": {
     {% set _ = intake_pivot_zero.update(
       {
-          "measured_absolute_position" : 3.2990161941868
+          "measured_absolute_position" : 3.26046279713726
       }
     ) %}
     "intake_constants":  {{ intake_pivot_zero | tojson(indent=2)}},
     "climber_constants": {
       {% set _ = climber_zero.update(
           {
-              "measured_absolute_position" : 0.00260967415741875
+              "measured_absolute_position" : 0.0143601265619493
           }
       ) %}
       "zeroing_constants": {{ climber_zero | tojson(indent=2)}},
-      "potentiometer_offset": {{ -0.935529777248618 + 1.83632555414775 + 0.0431080619919798 - 0.493015437796464 }}
+      "potentiometer_offset": {{ -0.935529777248618 + 1.83632555414775 + 0.0431080619919798 - 0.493015437796464 + 0.001602382648064  +0.00194716776942403 }}
     },
     "catapult_constants": {
       {% set _ = catapult_zero.update(
           {
-              "measured_absolute_position" : 0.741253220327565
+              "measured_absolute_position" : 0.72750793510745
           }
       ) %}
       "zeroing_constants": {{ catapult_zero | tojson(indent=2)}},
@@ -48,29 +48,29 @@
     "altitude_constants": {
       {% set _ = altitude_zero.update(
           {
-              "measured_absolute_position" : 0.130841088837793
+              "measured_absolute_position" : 0.1964
           }
       ) %}
       "zeroing_constants": {{ altitude_zero | tojson(indent=2)}},
-      "potentiometer_offset": -0.15316323147786
+      "potentiometer_offset": -0.16416323147786
     },
     "turret_constants": {
       {% set _ = turret_zero.update(
           {
-              "measured_absolute_position" : 0.138686395993591
+              "measured_absolute_position" : 0.210464386547614
           }
       ) %}
       "zeroing_constants": {{ turret_zero | tojson(indent=2)}},
-      "potentiometer_offset": {{ -6.47164779835404 }}
+      "potentiometer_offset": {{ -6.47164779835404 - 0.0711209027239817 }}
     },
     "extend_constants": {
       {% set _ = extend_zero.update(
           {
-              "measured_absolute_position" : 0.0314256815130559
+              "measured_absolute_position" : 0.135593394632399
           }
       ) %}
       "zeroing_constants": {{ extend_zero | tojson(indent=2)}},
-      "potentiometer_offset": {{ -0.2574404033256 + 0.0170793439542 - 0.177097393974999 }}
+      "potentiometer_offset": {{ -0.2574404033256 + 0.0170793439542 - 0.177097393974999 + 0.3473623911879 }}
     }
   },
   {% include 'y2024/constants/common.json' %}
diff --git a/y2024/constants/calib_files/calibration_imu-971-0_cam-24-07_2024-03-01_11-01-32.895328333.json b/y2024/constants/calib_files/calibration_imu-971-0_cam-24-07_2024-03-01_11-01-32.895328333.json
deleted file mode 100755
index d013e2a..0000000
--- a/y2024/constants/calib_files/calibration_imu-971-0_cam-24-07_2024-03-01_11-01-32.895328333.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
- "node_name": "imu",
- "team_number": 971,
- "intrinsics": [
-  647.822815,
-  0.0,
-  715.37616,
-  0.0,
-  647.799316,
-  494.638641,
-  0.0,
-  0.0,
-  1.0
- ],
- "fixed_extrinsics": {
-  "data": [
-   1.0,
-   -0.0,
-   0.0,
-   0.111049,
-   0.0,
-   0.258819,
-   0.965926,
-   0.263806,
-   -0.0,
-   -0.965926,
-   0.258819,
-   0.347685,
-   0.0,
-   0.0,
-   0.0,
-   1.0
-  ]
- },
- "dist_coeffs": [
-  -0.2423,
-  0.057169,
-  0.000302,
-  0.000016,
-  -0.005638
- ],
- "calibration_timestamp": 1708833147338466592,
- "camera_id": "24-07",
- "camera_number": 0,
- "reprojection_error": 1.362672
-}
\ No newline at end of file
diff --git a/y2024/constants/calib_files/calibration_imu-971-1_cam-24-08_2024-03-01_11-02-11.982641320.json b/y2024/constants/calib_files/calibration_imu-971-1_cam-24-08_2024-03-01_11-02-11.982641320.json
index fbe79d5..c0ec0d8 100755
--- a/y2024/constants/calib_files/calibration_imu-971-1_cam-24-08_2024-03-01_11-02-11.982641320.json
+++ b/y2024/constants/calib_files/calibration_imu-971-1_cam-24-08_2024-03-01_11-02-11.982641320.json
@@ -41,6 +41,6 @@
  ],
  "calibration_timestamp": 1708820514420797344,
  "camera_id": "24-08",
- "camera_number": 1,
+ "camera_number": 0,
  "reprojection_error": 1.591953
-}
\ No newline at end of file
+}
diff --git a/y2024/constants/calib_files/calibration_orin1-971-1_cam-24-06_2024-03-01_11-01-20.409861949.json b/y2024/constants/calib_files/calibration_orin-971-1-0_cam-24-06_2024-03-08_17-16-02.325390283.json
similarity index 61%
rename from y2024/constants/calib_files/calibration_orin1-971-1_cam-24-06_2024-03-01_11-01-20.409861949.json
rename to y2024/constants/calib_files/calibration_orin-971-1-0_cam-24-06_2024-03-08_17-16-02.325390283.json
index 0eb10db..48da468 100755
--- a/y2024/constants/calib_files/calibration_orin1-971-1_cam-24-06_2024-03-01_11-01-20.409861949.json
+++ b/y2024/constants/calib_files/calibration_orin-971-1-0_cam-24-06_2024-03-08_17-16-02.325390283.json
@@ -14,18 +14,18 @@
  ],
  "fixed_extrinsics": {
   "data": [
-   -1.0,
-   0.0,
-   0.0,
-   0.111049,
-   -0.0,
-   -0.258819,
-   -0.965926,
-   -0.263806,
-   0.0,
-   -0.965926,
-   0.258819,
-   0.347685,
+   -0.997807,
+   0.015704,
+   -0.064302,
+   0.134715,
+   0.058111,
+   -0.25731,
+   -0.96458,
+   -0.273849,
+   -0.031694,
+   -0.966201,
+   0.255833,
+   0.295681,
    0.0,
    0.0,
    0.0,
@@ -39,8 +39,8 @@
   0.000005,
   -0.006342
  ],
- "calibration_timestamp": 409229245444672,
+ "calibration_timestamp": 1709946962325390283,
  "camera_id": "24-06",
- "camera_number": 1,
+ "camera_number": 0,
  "reprojection_error": 1.344104
 }
\ No newline at end of file
diff --git a/y2024/constants/calib_files/calibration_orin-971-1-1_cam-24-07_2024-03-08_17-16-02.325267121.json b/y2024/constants/calib_files/calibration_orin-971-1-1_cam-24-07_2024-03-08_17-16-02.325267121.json
new file mode 100755
index 0000000..56072d4
--- /dev/null
+++ b/y2024/constants/calib_files/calibration_orin-971-1-1_cam-24-07_2024-03-08_17-16-02.325267121.json
@@ -0,0 +1,46 @@
+{
+ "node_name": "orin1",
+ "team_number": 971,
+ "intrinsics": [
+  647.822815,
+  0.0,
+  715.37616,
+  0.0,
+  647.799316,
+  494.638641,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "fixed_extrinsics": {
+  "data": [
+   0.016995,
+   0.002328,
+   0.999853,
+   0.35278,
+   0.999839,
+   -0.005785,
+   -0.016981,
+   0.237526,
+   0.005745,
+   0.999981,
+   -0.002426,
+   0.387276,
+   0.0,
+   0.0,
+   0.0,
+   1.0
+  ]
+ },
+ "dist_coeffs": [
+  -0.2423,
+  0.057169,
+  0.000302,
+  0.000016,
+  -0.005638
+ ],
+ "calibration_timestamp": 1709946962325267121,
+ "camera_id": "24-07",
+ "camera_number": 1,
+ "reprojection_error": 1.362672
+}
\ No newline at end of file
diff --git a/y2024/constants/calib_files/calibration_orin-971-c-1_cam-24-05_2024-03-08_17-16-02.325066070.json b/y2024/constants/calib_files/calibration_orin-971-c-1_cam-24-05_2024-03-08_17-16-02.325066070.json
new file mode 100755
index 0000000..c1d4e60
--- /dev/null
+++ b/y2024/constants/calib_files/calibration_orin-971-c-1_cam-24-05_2024-03-08_17-16-02.325066070.json
@@ -0,0 +1,46 @@
+{
+ "node_name": "imu",
+ "team_number": 971,
+ "intrinsics": [
+  648.360168,
+  0.0,
+  729.818665,
+  0.0,
+  648.210327,
+  641.988037,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "fixed_extrinsics": {
+  "data": [
+   0.999556,
+   -0.026613,
+   0.013428,
+   0.146979,
+   -0.006922,
+   0.230925,
+   0.972947,
+   0.30388,
+   -0.028994,
+   -0.972608,
+   0.230638,
+   0.32572,
+   0.0,
+   0.0,
+   0.0,
+   1.0
+  ]
+ },
+ "dist_coeffs": [
+  -0.255473,
+  0.068444,
+  0.000028,
+  -0.000078,
+  -0.008004
+ ],
+ "calibration_timestamp": 1709946962325066070,
+ "camera_id": "24-05",
+ "camera_number": 1,
+ "reprojection_error": 1.058851
+}
\ No newline at end of file
diff --git a/y2024/constants/calib_files/calibration_orin1-971-0_cam-24-05_2024-03-01_11-01-05.102438041.json b/y2024/constants/calib_files/calibration_orin1-971-0_cam-24-05_2024-03-01_11-01-05.102438041.json
deleted file mode 100755
index 317e453..0000000
--- a/y2024/constants/calib_files/calibration_orin1-971-0_cam-24-05_2024-03-01_11-01-05.102438041.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
- "node_name": "orin1",
- "team_number": 971,
- "intrinsics": [
-  648.360168,
-  0.0,
-  729.818665,
-  0.0,
-  648.210327,
-  641.988037,
-  0.0,
-  0.0,
-  1.0
- ],
- "fixed_extrinsics": {
-  "data": [
-   0.0,
-   0.0,
-   1.0,
-   0.284397,
-   -1.0,
-   0.0,
-   0.0,
-   0.226771,
-   0.0,
-   -1.0,
-   0.0,
-   0.442951,
-   0.0,
-   0.0,
-   0.0,
-   1.0
-  ]
- },
- "dist_coeffs": [
-  -0.255473,
-  0.068444,
-  0.000028,
-  -0.000078,
-  -0.008004
- ],
- "calibration_timestamp": 409227793683328,
- "camera_id": "24-05",
- "camera_number": 0,
- "reprojection_error": 1.058851
-}
\ No newline at end of file
diff --git a/y2024/constants/common.json b/y2024/constants/common.json
index 1050815..a36844b 100644
--- a/y2024/constants/common.json
+++ b/y2024/constants/common.json
@@ -4,30 +4,55 @@
   "target_map": {% include 'y2024/vision/maps/target_map.json' %},
   "shooter_interpolation_table": [
     {
-        "distance_from_goal": 5.0,
+        "distance_from_goal": 0.8,
         "shot_params": {
-            "shot_velocity": 0.0,
-            "shot_altitude_angle": 0.0,
-            "shot_catapult_angle": 0.0,
-            "shot_speed_over_ground": 2.0
+            "shot_altitude_angle": 0.85,
+            "shot_speed_over_ground": 4.0
         }
     },
     {
-      "distance_from_goal": 10.0,
+      "distance_from_goal": 1.34,
       "shot_params": {
-          "shot_velocity": 0.0,
-          "shot_altitude_angle": 0.0,
-          "shot_catapult_angle": 0.0,
+          "shot_altitude_angle": 0.85,
+          "shot_speed_over_ground": 4.0
+      }
+    },
+    {
+      "distance_from_goal": 2.004,
+      "shot_params": {
+          "shot_altitude_angle": 0.73,
+          "shot_speed_over_ground": 4.0
+      }
+    },
+    // 2.2 -> high.
+    {
+      "distance_from_goal": 2.844,
+      "shot_params": {
+          "shot_altitude_angle": 0.62,
+          "shot_speed_over_ground": 4.0
+      }
+    },
+    {
+      "distance_from_goal": 3.374,
+      "shot_params": {
+          "shot_altitude_angle": 0.58,
+          "shot_speed_over_ground": 4.0
+      }
+    },
+    {
+      "distance_from_goal": 4.10,
+      "shot_params": {
+          "shot_altitude_angle": 0.54,
           "shot_speed_over_ground": 4.0
       }
     }
   ],
   "intake_roller_voltages": {
-    "spitting": -4.0,
-    "intaking": 12.0
+    "spitting": -6.0,
+    "intaking": 9.0
   },
   "intake_pivot_set_points": {
-    "extended": -0.03,
+    "extended": 0.045,
     "retracted": 1.73
   },
   "intake_pivot": {
@@ -38,8 +63,8 @@
       "max_acceleration": 3.0
     },
     "default_profile_params":{
-      "max_velocity": 4.0,
-      "max_acceleration": 10.0
+      "max_velocity": 6.0,
+      "max_acceleration": 40.0
     },
     "range": {
         "lower_hard": -0.2,
@@ -56,21 +81,21 @@
     "intake_pivot_supply_current_limit": 40,
     "intake_pivot_stator_current_limit": 100,
     "intake_roller_supply_current_limit": 20,
-    "intake_roller_stator_current_limit": 50,
+    "intake_roller_stator_current_limit": 100,
     "transfer_roller_supply_current_limit": 20,
     "transfer_roller_stator_current_limit": 50,
-    "drivetrain_supply_current_limit": 35,
-    "drivetrain_stator_current_limit": 60,
+    "drivetrain_supply_current_limit": 50,
+    "drivetrain_stator_current_limit": 200,
     "climber_supply_current_limit": 30,
     "climber_stator_current_limit": 100,
-    "extend_supply_current_limit": 20,
-    "extend_stator_current_limit": 100,
-    "extend_roller_supply_current_limit": 60,
-    "extend_roller_stator_current_limit": 200,
-    "turret_supply_current_limit": 20,
-    "turret_stator_current_limit": 40,
-    "altitude_supply_current_limit": 10,
-    "altitude_stator_current_limit": 60,
+    "extend_supply_current_limit": 30,
+    "extend_stator_current_limit": 180,
+    "extend_roller_supply_current_limit": 50,
+    "extend_roller_stator_current_limit": 180,
+    "turret_supply_current_limit": 30,
+    "turret_stator_current_limit": 80,
+    "altitude_supply_current_limit": 30,
+    "altitude_stator_current_limit": 150,
     "catapult_supply_current_limit": 60,
     "catapult_stator_current_limit": 250,
     "retention_roller_stator_current_limit": 20,
@@ -79,18 +104,18 @@
     "retention_roller_supply_current_limit": 10
   },
   "transfer_roller_voltages": {
-    "transfer_in": 12.0,
+    "transfer_in": 9.0,
     "transfer_out": -4.0,
     "extend_moving": 4.0
   },
   "extend_roller_voltages": {
-    "scoring": 12.0,
+    "scoring": 6.0,
     "reversing": -4.0
   },
   "climber_set_points": {
     "full_extend": -0.005,
-    "stowed": -0.35,
-    "retract": -0.478
+    "stowed": -0.442,
+    "retract": -0.472
   },
   "climber": {
     "zeroing_voltage": 3.0,
@@ -104,9 +129,9 @@
       "max_acceleration": 3.0
     },
     "range": {
-        "lower_hard": -0.488,
+        "lower_hard": -0.495,
         "upper_hard": 0.005,
-        "lower": -0.478,
+        "lower": -0.492,
         "upper": -0.005
     },
     "loop": {% include 'y2024/control_loops/superstructure/climber/integral_climber_plant.json' %}
@@ -139,8 +164,8 @@
       "max_acceleration": 3.0
     },
     "default_profile_params":{
-      "max_velocity": 3.0,
-      "max_acceleration": 5.0
+      "max_velocity": 5.0,
+      "max_acceleration": 30.0
     },
     "range": {
         "lower_hard": -0.01,
@@ -158,8 +183,8 @@
       "max_acceleration": 3.0
     },
     "default_profile_params":{
-      "max_velocity": 2.0,
-      "max_acceleration": 5.0
+      "max_velocity": 12.0,
+      "max_acceleration": 30.0
     },
     "range": {
         "lower_hard": -4.8,
@@ -177,8 +202,8 @@
       "max_acceleration": 3.0
     },
     "default_profile_params":{
-      "max_velocity": 0.1,
-      "max_acceleration": 0.3
+      "max_velocity": 2.0,
+      "max_acceleration": 10.0
     },
     "range": {
         "lower_hard": -0.005,
@@ -196,7 +221,7 @@
             "storage_order": "ColMajor",
             // The data field contains the x, y and z
             // coordinates of the speaker on the red alliance
-            "data": [8.0645, 1.4435, 2.0705]
+            "data": [8.309, 1.4435, 2.0705]
         },
         "theta": 0.0
     },
@@ -207,7 +232,7 @@
             "storage_order": "ColMajor",
             // The data field contains the x, y and z
             // coordinates of the speaker on the blue alliance
-            "data": [-8.0645, 1.4435, 2.0705]
+            "data": [-8.309, 1.4435, 2.0705]
         },
         "theta": 0.0
     }
@@ -215,7 +240,7 @@
   "altitude_loading_position": 0.02,
   "turret_loading_position": 0.58,
   "catapult_return_position": 0.0,
-  "min_altitude_shooting_angle": 0.55,
+  "min_altitude_shooting_angle": 0.4,
   "max_altitude_shooting_angle": 0.89,
   "retention_roller_voltages": {
     "retaining": 1.5,
@@ -223,8 +248,8 @@
   },
   // TODO(Filip): Update the speaker and amp shooter setpoints
   "shooter_speaker_set_point": {
-    "turret_position": 0.0,
-    "altitude_position": 0.75,
+    "turret_position": 0.22,
+    "altitude_position": 0.85,
     "shot_velocity": 0.0
   },
   "shooter_podium_set_point":{
@@ -233,10 +258,15 @@
     "shot_velocity": 0.0
   },
   "extend_set_points": {
-    "trap": 0.46,
-    "amp": 0.2,
+    "trap": 0.40,
+    "amp": 0.35,
     "catapult": 0.017,
     "retracted": 0.017
   },
-  "turret_avoid_extend_collision_position": 0.0
+  "turret_avoid_extend_collision_position": 0.0,
+  "autonomous_mode": "FOUR_PIECE",
+  "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]
+  }
 }
diff --git a/y2024/constants/constants.fbs b/y2024/constants/constants.fbs
index 31e2057..d33a161 100644
--- a/y2024/constants/constants.fbs
+++ b/y2024/constants/constants.fbs
@@ -115,6 +115,16 @@
   retracted:double (id: 3);
 }
 
+enum AutonomousMode : ubyte {
+  NONE = 0,
+  // Simple test S-spline auto mode
+  SPLINE_AUTO = 1,
+  // Simple drive-and-shoot to pick up at most one game piece.
+  MOBILITY_AND_SHOOT = 2,
+  // Auto to pick up four game pieces.
+  FOUR_PIECE = 3,
+}
+
 table RobotConstants {
   intake_constants:frc971.zeroing.AbsoluteEncoderZeroingConstants (id: 0);
   climber_constants:PotAndAbsEncoderConstants (id: 1);
@@ -147,6 +157,13 @@
   spitting:double (id: 1);
 }
 
+// Set of april tag targets, by april tag ID, to ignore when on a
+// given alliance.
+table IgnoreTargets {
+  red:[uint64] (id: 0);
+  blue:[uint64] (id: 1);
+}
+
 // Common table for constants unrelated to the robot
 table Common {
   target_map:frc971.vision.TargetMap (id: 0);
@@ -173,10 +190,12 @@
   max_altitude_shooting_angle: double (id: 25);
   shooter_speaker_set_point: ShooterSetPoint (id: 21);
   shooter_podium_set_point: ShooterSetPoint (id: 22);
-    extend_set_points:ExtendSetPoints (id: 23);
+  extend_set_points:ExtendSetPoints (id: 23);
   // The position to move the turret to when avoiding collision
   // with the extend when the extend is moving to amp/trap position.
   turret_avoid_extend_collision_position: double (id: 24);
+  autonomous_mode:AutonomousMode (id: 26);
+  ignore_targets:IgnoreTargets (id: 27);
 }
 
 table Constants {
diff --git a/y2024/control_loops/python/altitude.py b/y2024/control_loops/python/altitude.py
index d302b79..544eddc 100644
--- a/y2024/control_loops/python/altitude.py
+++ b/y2024/control_loops/python/altitude.py
@@ -24,7 +24,7 @@
     motor=control_loop.KrakenFOC(),
     G=(16.0 / 60.0) * (16.0 / 162.0),
     # 4340 in^ lb
-    J=1.27,
+    J=1.2,
     q_pos=0.60,
     q_vel=8.0,
     kalman_q_pos=0.12,
diff --git a/y2024/control_loops/superstructure/shooter.cc b/y2024/control_loops/superstructure/shooter.cc
index cc00454..7fdf748 100644
--- a/y2024/control_loops/superstructure/shooter.cc
+++ b/y2024/control_loops/superstructure/shooter.cc
@@ -44,7 +44,7 @@
     const double extend_goal, double *max_extend_position,
     double *min_extend_position, const double intake_pivot_position,
     double *max_intake_pivot_position, double *min_intake_pivot_position,
-    flatbuffers::FlatBufferBuilder *fbb,
+    NoteGoal requested_note_goal, flatbuffers::FlatBufferBuilder *fbb,
     aos::monotonic_clock::time_point monotonic_now) {
   drivetrain_status_fetcher_.Fetch();
 
@@ -96,8 +96,16 @@
 
   bool aiming = false;
 
-  if (shooter_goal == nullptr || !shooter_goal->auto_aim() ||
-      (!piece_loaded && state_ == CatapultState::READY)) {
+  if (requested_note_goal == NoteGoal::AMP) {
+    // Being asked to amp, lift the altitude up.
+    PopulateStaticZeroingSingleDOFProfiledSubsystemGoal(
+        turret_goal_builder.get(),
+        robot_constants_->common()->turret_loading_position());
+
+    PopulateStaticZeroingSingleDOFProfiledSubsystemGoal(
+        altitude_goal_builder.get(), 0.3);
+  } else if (shooter_goal == nullptr || !shooter_goal->auto_aim() ||
+             (!piece_loaded && state_ == CatapultState::READY)) {
     // We don't have the note so we should be ready to intake it.
     PopulateStaticZeroingSingleDOFProfiledSubsystemGoal(
         turret_goal_builder.get(),
@@ -106,7 +114,6 @@
     PopulateStaticZeroingSingleDOFProfiledSubsystemGoal(
         altitude_goal_builder.get(),
         robot_constants_->common()->altitude_loading_position());
-
   } else {
     // We have a game piece, lets start aiming.
     if (drivetrain_status_fetcher_.get() != nullptr) {
@@ -140,13 +147,18 @@
                            ? shooter_goal->altitude_position()
                            : &altitude_goal_builder->AsFlatbuffer();
 
-  bool subsystems_in_range =
+  const bool turret_in_range =
       (std::abs(turret_.estimated_position() - turret_goal->unsafe_goal()) <
-           kCatapultActivationThreshold &&
-       std::abs(altitude_.estimated_position() - altitude_goal->unsafe_goal()) <
-           kCatapultActivationThreshold &&
-       altitude_.estimated_position() >
-           robot_constants_->common()->min_altitude_shooting_angle());
+       kCatapultActivationThreshold);
+  const bool altitude_in_range =
+      (std::abs(altitude_.estimated_position() - altitude_goal->unsafe_goal()) <
+       kCatapultActivationThreshold);
+  const bool altitude_above_min_angle =
+      (altitude_.estimated_position() >
+       robot_constants_->common()->min_altitude_shooting_angle());
+
+  bool subsystems_in_range =
+      (turret_in_range && altitude_in_range && altitude_above_min_angle);
 
   const bool disabled = turret_.Correct(turret_goal, position->turret(),
                                         turret_output == nullptr);
@@ -301,6 +313,9 @@
   status_builder.add_altitude(altitude_status_offset);
   status_builder.add_catapult(catapult_status_offset);
   status_builder.add_catapult_state(state_);
+  status_builder.add_turret_in_range(turret_in_range);
+  status_builder.add_altitude_in_range(altitude_in_range);
+  status_builder.add_altitude_above_min_angle(altitude_above_min_angle);
   if (aiming) {
     status_builder.add_aimer(aimer_offset);
   }
diff --git a/y2024/control_loops/superstructure/shooter.h b/y2024/control_loops/superstructure/shooter.h
index 2571cab..12078ff 100644
--- a/y2024/control_loops/superstructure/shooter.h
+++ b/y2024/control_loops/superstructure/shooter.h
@@ -106,7 +106,7 @@
       const double extend_goal, double *max_extend_position,
       double *min_extend_position, const double intake_pivot_position,
       double *max_turret_intake_position, double *min_intake_pivot_position,
-      flatbuffers::FlatBufferBuilder *fbb,
+      NoteGoal requested_note_goal, flatbuffers::FlatBufferBuilder *fbb,
       aos::monotonic_clock::time_point monotonic_now);
 
  private:
diff --git a/y2024/control_loops/superstructure/superstructure.cc b/y2024/control_loops/superstructure/superstructure.cc
index dc9369e..a541a55 100644
--- a/y2024/control_loops/superstructure/superstructure.cc
+++ b/y2024/control_loops/superstructure/superstructure.cc
@@ -297,6 +297,7 @@
       extend_moving = false;
       extend_goal_location = ExtendStatus::CATAPULT;
       extend_roller_status = ExtendRollerStatus::TRANSFERING_TO_CATAPULT;
+      transfer_roller_status = TransferRollerStatus::EXTEND_MOVING;
 
       // If we lost the game piece, reset state to idle.
       if (((timestamp - loading_catapult_start_time_) >
@@ -563,7 +564,8 @@
           extend_goal_position, extend_.estimated_position(),
           &max_extend_position, &min_extend_position,
           intake_pivot_.estimated_position(), &max_intake_pivot_position,
-          &min_intake_pivot_position, status->fbb(), timestamp);
+          &min_intake_pivot_position, requested_note_goal_, status->fbb(),
+          timestamp);
 
   intake_pivot_.set_min_position(min_intake_pivot_position);
   intake_pivot_.set_max_position(max_intake_pivot_position);
diff --git a/y2024/control_loops/superstructure/superstructure_goal.fbs b/y2024/control_loops/superstructure/superstructure_goal.fbs
index 42caf9b..13cf4e0 100644
--- a/y2024/control_loops/superstructure/superstructure_goal.fbs
+++ b/y2024/control_loops/superstructure/superstructure_goal.fbs
@@ -12,6 +12,11 @@
     SPIT = 2,
 }
 
+enum IntakePivotGoal : ubyte {
+    UP = 0,
+    DOWN = 1,
+}
+
 // Represents goal for climber
 // FULL_EXTEND is for fully extending the climber
 // RETRACT is for retracting the climber
@@ -54,9 +59,13 @@
 
 table Goal {
     intake_goal:IntakeGoal = NONE (id: 0);
+    intake_pivot:IntakePivotGoal = UP (id: 5);
     climber_goal:ClimberGoal (id: 1);
     shooter_goal:ShooterGoal (id: 2);
     note_goal:NoteGoal (id: 3);
     fire: bool (id: 4);
+
+    // Tells the climber to go absurdly slow on FULL_EXTEND
+    slow_climber: bool = false (id: 6);
 }
 root_type Goal;
diff --git a/y2024/control_loops/superstructure/superstructure_lib_test.cc b/y2024/control_loops/superstructure/superstructure_lib_test.cc
index 56452c8..0b39ec6 100644
--- a/y2024/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2024/control_loops/superstructure/superstructure_lib_test.cc
@@ -1184,6 +1184,7 @@
             ExtendRollerStatus::IDLE);
 
   // Should now be loaded.
+  superstructure_output_fetcher_.Fetch();
   EXPECT_EQ(superstructure_output_fetcher_->transfer_roller_voltage(), 0.0);
 
   EXPECT_NEAR(superstructure_status_fetcher_->shooter()->altitude()->position(),
diff --git a/y2024/control_loops/superstructure/superstructure_status.fbs b/y2024/control_loops/superstructure/superstructure_status.fbs
index 8d6b14f..fd19e8d 100644
--- a/y2024/control_loops/superstructure/superstructure_status.fbs
+++ b/y2024/control_loops/superstructure/superstructure_status.fbs
@@ -77,6 +77,13 @@
 
   // Status of the aimer
   aimer:AimerStatus (id: 4);
+
+  // True if auto-aiming.
+  auto_aiming:bool (id: 5);
+
+  turret_in_range:bool (id: 6);
+  altitude_in_range:bool (id: 7);
+  altitude_above_min_angle:bool (id: 8);
 }
 
 // Contains status of transfer rollers
@@ -174,6 +181,9 @@
 
   extend_beambreak:bool (id: 19);
   catapult_beambreak:bool (id: 20);
+
+  // Number of shots we have taken.
+  shot_count:uint32 (id:21);
 }
 
 root_type Status;
diff --git a/y2024/vision/BUILD b/y2024/vision/BUILD
index d36c956..4904554 100644
--- a/y2024/vision/BUILD
+++ b/y2024/vision/BUILD
@@ -60,6 +60,7 @@
         "//aos/events:shm_event_loop",
         "//aos/events/logging:log_writer",
         "//aos/logging:log_namer",
+        "//aos/util:filesystem_fbs",
         "//frc971/input:joystick_state_fbs",
         "@com_github_gflags_gflags//:gflags",
         "@com_github_google_glog//:glog",
diff --git a/y2024/vision/image_logger.cc b/y2024/vision/image_logger.cc
index 55a4e12..2984824 100644
--- a/y2024/vision/image_logger.cc
+++ b/y2024/vision/image_logger.cc
@@ -9,6 +9,7 @@
 #include "aos/events/shm_event_loop.h"
 #include "aos/init.h"
 #include "aos/logging/log_namer.h"
+#include "aos/util/filesystem_generated.h"
 #include "frc971/input/joystick_state_generated.h"
 
 DEFINE_string(config, "aos_config.json", "Config file to use.");
@@ -48,10 +49,13 @@
 
   aos::ShmEventLoop event_loop(&config.message());
 
+  aos::Fetcher<aos::util::FilesystemStatus> filesystem_status =
+      event_loop.MakeFetcher<aos::util::FilesystemStatus>("/aos");
+
   bool logging = false;
   bool enabled = false;
   aos::monotonic_clock::time_point last_disable_time =
-      event_loop.monotonic_now();
+      aos::monotonic_clock::min_time;
   aos::monotonic_clock::time_point last_rotation_time =
       event_loop.monotonic_now();
   aos::logger::Logger logger(&event_loop);
@@ -76,13 +80,36 @@
   event_loop.MakeWatcher(
       "/imu/aos", [&](const aos::JoystickState &joystick_state) {
         const auto timestamp = event_loop.context().monotonic_event_time;
+        filesystem_status.Fetch();
+
         // Store the last time we got disabled
         if (enabled && !joystick_state.enabled()) {
           last_disable_time = timestamp;
         }
         enabled = joystick_state.enabled();
 
-        if (!logging && enabled) {
+        bool enough_space = true;
+
+        if (filesystem_status.get() != nullptr) {
+          enough_space = false;
+          for (const aos::util::Filesystem *fs :
+               *filesystem_status->filesystems()) {
+            CHECK(fs->has_path());
+            if (fs->path()->string_view() == "/") {
+              if (fs->free_space() > 50ull * 1024ull * 1024ull * 1024ull) {
+                enough_space = true;
+              }
+            }
+          }
+        }
+
+        const bool should_be_logging =
+            (enabled ||
+             timestamp < last_disable_time + std::chrono::duration<double>(
+                                                 FLAGS_disabled_time)) &&
+            enough_space;
+
+        if (!logging && should_be_logging) {
           auto log_namer = MakeLogNamer(&event_loop);
           if (log_namer == nullptr) {
             return;
@@ -93,9 +120,7 @@
           logger.StartLogging(std::move(log_namer));
           logging = true;
           last_rotation_time = event_loop.monotonic_now();
-        } else if (logging && !enabled &&
-                   (timestamp - last_disable_time) >
-                       std::chrono::duration<double>(FLAGS_disabled_time)) {
+        } else if (logging && !should_be_logging) {
           // Stop logging if we've been disabled for a non-negligible amount of
           // time
           LOG(INFO) << "Stopping logging";
diff --git a/y2024/wpilib_interface.cc b/y2024/wpilib_interface.cc
index d286939..8060a69 100644
--- a/y2024/wpilib_interface.cc
+++ b/y2024/wpilib_interface.cc
@@ -521,6 +521,11 @@
         current_limits->catapult_stator_current_limit(),
         current_limits->catapult_supply_current_limit());
 
+    transfer_roller->set_neutral_mode(
+        ctre::phoenix6::signals::NeutralModeValue::Coast);
+    intake_roller->set_neutral_mode(
+        ctre::phoenix6::signals::NeutralModeValue::Coast);
+
     ctre::phoenix::platform::can::CANComm_SetRxSchedPriority(
         constants::Values::kDrivetrainRxPriority, true, "Drivetrain Bus");
     ctre::phoenix::platform::can::CANComm_SetTxSchedPriority(
diff --git a/y2024/www/BUILD b/y2024/www/BUILD
index 5ff91d6..5cb57c4 100644
--- a/y2024/www/BUILD
+++ b/y2024/www/BUILD
@@ -39,6 +39,8 @@
         "//frc971/control_loops/drivetrain/localization:localizer_output_ts_fbs",
         "//frc971/vision:target_map_ts_fbs",
         "//y2024/control_loops/superstructure:superstructure_status_ts_fbs",
+        "//y2024/localizer:status_ts_fbs",
+        "//y2024/localizer:visualization_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
     ],
 )
diff --git a/y2024/www/field.html b/y2024/www/field.html
index cfb8778..8fd4ebb 100644
--- a/y2024/www/field.html
+++ b/y2024/www/field.html
@@ -11,15 +11,8 @@
     <div>
       <div id="field"> </div>
       <div id="legend"> </div>
-      <div id="vision_readouts">
-      </div>
-      <div id="message_bridge_status">
-        <div>
-          <div>Node</div>
-          <div>Client</div>
-          <div>Server</div>
-        </div>
-      </div>
+      <h3>Zeroing Faults:</h3>
+      <p id="zeroing_faults"> NA </p>
     </div>
     <div>
       <table>
@@ -114,6 +107,18 @@
           <td>Altitude at Loading Position</td>
           <td id="altitude_ready_for_load">FALSE</td>
         </tr>
+        <tr>
+          <td>Turret in Range of Goal</td>
+          <td id="turret_in_range">FALSE</td>
+        </tr>
+        <tr>
+          <td>Altitude in Range of Goal</td>
+          <td id="altitude_in_range">FALSE</td>
+        </tr>
+        <tr>
+          <td>Altitude Above Minimum Angle </td>
+          <td id="altitude_above_min_angle">FALSE</td>
+        </tr>
       </table>
       <table>
         <tr>
@@ -136,9 +141,6 @@
           <td id="shot_distance"> NA </td>
         </tr>
       </table>
-
-      <h3>Zeroing Faults:</h3>
-      <p id="zeroing_faults"> NA </p>
     </div>
     <div>
       <table>
@@ -266,6 +268,14 @@
       </table>
     </div>
   </div>
+  <div id="message_bridge_status">
+    <div>
+      <div>Node</div>
+      <div>Client</div>
+      <div>Server</div>
+    </div>
+  </div>
+  <div id="vision_readouts"> </div>
 </body>
 
 </html>
\ No newline at end of file
diff --git a/y2024/www/field_handler.ts b/y2024/www/field_handler.ts
index 949b34b..1b4ad1e 100644
--- a/y2024/www/field_handler.ts
+++ b/y2024/www/field_handler.ts
@@ -9,6 +9,8 @@
 import {SuperstructureState, IntakeRollerStatus, CatapultState, TransferRollerStatus, ExtendRollerStatus, ExtendStatus, NoteStatus, Status as SuperstructureStatus} from '../control_loops/superstructure/superstructure_status_generated'
 import {LocalizerOutput} from '../../frc971/control_loops/drivetrain/localization/localizer_output_generated'
 import {TargetMap} from '../../frc971/vision/target_map_generated'
+import {RejectionReason} from '../localizer/status_generated'
+import {TargetEstimateDebug, Visualization} from '../localizer/visualization_generated'
 
 
 import {FIELD_LENGTH, FIELD_WIDTH, FT_TO_M, IN_TO_M} from './constants';
@@ -20,6 +22,9 @@
 const ROBOT_WIDTH = 29 * IN_TO_M;
 const ROBOT_LENGTH = 32 * IN_TO_M;
 
+const CAMERA_COLORS = ['#ff00ff', '#ffff00', '#00ffff', '#ffa500'];
+const CAMERAS = ['/orin1/camera0', '/orin1/camera1', '/imu/camera0', '/imu/camera1'];
+
 export class FieldHandler {
   private canvas = document.createElement('canvas');
   private localizerOutput: LocalizerOutput|null = null;
@@ -28,11 +33,26 @@
   private drivetrainCANPosition: DrivetrainCANPosition|null = null;
   private superstructureStatus: SuperstructureStatus|null = null;
 
+  // Image information indexed by timestamp (seconds since the epoch), so that
+  // we can stop displaying images after a certain amount of time.
+  private localizerImageMatches = new Map<number, Visualization>();
   private x: HTMLElement = (document.getElementById('x') as HTMLElement);
   private y: HTMLElement = (document.getElementById('y') as HTMLElement);
   private theta: HTMLElement =
       (document.getElementById('theta') as HTMLElement);
 
+  private imagesAcceptedCounter: HTMLElement =
+      (document.getElementById('images_accepted') as HTMLElement);
+  // HTML elements for rejection reasons for individual cameras. Indices
+  // corresponding to RejectionReason enum values will be for those reasons. The
+  // final row will account for images rejected by the aprilrobotics detector
+  // instead of the localizer.
+  private rejectionReasonCells: HTMLElement[][] = [];
+  private messageBridgeDiv: HTMLElement =
+      (document.getElementById('message_bridge_status') as HTMLElement);
+  private clientStatuses = new Map<string, HTMLElement>();
+  private serverStatuses = new Map<string, HTMLElement>();
+
   private fieldImage: HTMLImageElement = new Image();
 
   private zeroingFaults: HTMLElement =
@@ -70,6 +90,13 @@
   private altitude_ready_for_load: HTMLElement =
   (document.getElementById('altitude_ready_for_load') as HTMLElement);
 
+  private turret_in_range: HTMLElement =
+  (document.getElementById('turret_in_range') as HTMLElement);
+  private altitude_in_range: HTMLElement =
+  (document.getElementById('altitude_in_range') as HTMLElement);
+  private altitude_above_min_angle: HTMLElement =
+  (document.getElementById('altitude_above_min_angle') as HTMLElement);
+
 
   private intakePivot: HTMLElement =
     (document.getElementById('intake_pivot') as HTMLElement);
@@ -138,7 +165,82 @@
 
     this.fieldImage.src = '2024.png';
 
+    // Construct a table header.
+    {
+      const row = document.createElement('div');
+      const nameCell = document.createElement('div');
+      nameCell.innerHTML = 'Rejection Reason';
+      row.appendChild(nameCell);
+      for (const camera of CAMERAS) {
+        const nodeCell = document.createElement('div');
+        nodeCell.innerHTML = camera;
+        row.appendChild(nodeCell);
+      }
+      document.getElementById('vision_readouts').appendChild(row);
+    }
+
+    for (const value in RejectionReason) {
+      // Typescript generates an iterator that produces both numbers and
+      // strings... don't do anything on the string iterations.
+      if (isNaN(Number(value))) {
+        continue;
+      }
+      const row = document.createElement('div');
+      const nameCell = document.createElement('div');
+      nameCell.innerHTML = RejectionReason[value];
+      row.appendChild(nameCell);
+      this.rejectionReasonCells.push([]);
+      for (const camera of CAMERAS) {
+        const valueCell = document.createElement('div');
+        valueCell.innerHTML = 'NA';
+        this.rejectionReasonCells[this.rejectionReasonCells.length - 1].push(
+            valueCell);
+        row.appendChild(valueCell);
+      }
+      document.getElementById('vision_readouts').appendChild(row);
+    }
+
+    // Add rejection reason row for aprilrobotics rejections.
+    {
+      const row = document.createElement('div');
+      const nameCell = document.createElement('div');
+      nameCell.innerHTML = 'Rejected by aprilrobotics';
+      row.appendChild(nameCell);
+      this.rejectionReasonCells.push([]);
+      for (const camera of CAMERAS) {
+        const valueCell = document.createElement('div');
+        valueCell.innerHTML = 'NA';
+        this.rejectionReasonCells[this.rejectionReasonCells.length - 1].push(
+            valueCell);
+        row.appendChild(valueCell);
+      }
+      document.getElementById('vision_readouts').appendChild(row);
+    }
+
+    for (let ii = 0; ii < CAMERA_COLORS.length; ++ii) {
+      const legendEntry = document.createElement('div');
+      legendEntry.style.color = CAMERA_COLORS[ii];
+      legendEntry.innerHTML = CAMERAS[ii];
+      document.getElementById('legend').appendChild(legendEntry);
+    }
+
     this.connection.addConfigHandler(() => {
+      // Visualization message is reliable so that we can see *all* the vision
+      // matches.
+      for (const camera in CAMERAS) {
+        this.connection.addHandler(
+            CAMERAS[camera], 'y2024.localizer.Visualization',
+            (data) => {
+              this.handleLocalizerDebug(Number(camera), data);
+            });
+      }
+      for (const camera in CAMERAS) {
+        // Make unreliable to reduce network spam.
+        this.connection.addHandler(
+          CAMERAS[camera], 'frc971.vision.TargetMap', (data) => {
+              this.handleCameraTargetMap(camera, data);
+            });
+      }
 
       this.connection.addHandler(
         '/drivetrain', 'frc971.control_loops.drivetrain.Status', (data) => {
@@ -161,8 +263,43 @@
         (data) => {
           this.handleSuperstructureStatus(data)
           });
+      this.connection.addHandler(
+        '/aos', 'aos.message_bridge.ServerStatistics',
+        (data) => {this.handleServerStatistics(data)});
+      this.connection.addHandler(
+        '/aos', 'aos.message_bridge.ClientStatistics',
+        (data) => {this.handleClientStatistics(data)});
       });
   }
+  private handleLocalizerDebug(camera: number, data: Uint8Array): void {
+    const now = Date.now() / 1000.0;
+
+    const fbBuffer = new ByteBuffer(data);
+    this.localizerImageMatches.set(
+        now, Visualization.getRootAsVisualization(fbBuffer));
+
+    const debug = this.localizerImageMatches.get(now);
+
+    if (debug.statistics()) {
+      if ((debug.statistics().rejectionReasonsLength() + 1) ==
+          this.rejectionReasonCells.length) {
+        for (let ii = 0; ii < debug.statistics().rejectionReasonsLength();
+             ++ii) {
+          this.rejectionReasonCells[ii][camera].innerHTML =
+              debug.statistics().rejectionReasons(ii).count().toString();
+        }
+      } else {
+        console.error('Unexpected number of rejection reasons in counter.');
+      }
+    }
+  }
+
+  private handleCameraTargetMap(pi: string, data: Uint8Array): void {
+    const fbBuffer = new ByteBuffer(data);
+    const targetMap = TargetMap.getRootAsTargetMap(fbBuffer);
+    this.rejectionReasonCells[this.rejectionReasonCells.length - 1][pi]
+        .innerHTML = targetMap.rejections().toString();
+  }
 
   private handleDrivetrainStatus(data: Uint8Array): void {
     const fbBuffer = new ByteBuffer(data);
@@ -189,6 +326,67 @@
 	  this.superstructureStatus = SuperstructureStatus.getRootAsStatus(fbBuffer);
   }
 
+  private populateNodeConnections(nodeName: string): void {
+    const row = document.createElement('div');
+    this.messageBridgeDiv.appendChild(row);
+    const nodeDiv = document.createElement('div');
+    nodeDiv.innerHTML = nodeName;
+    row.appendChild(nodeDiv);
+    const clientDiv = document.createElement('div');
+    clientDiv.innerHTML = 'N/A';
+    row.appendChild(clientDiv);
+    const serverDiv = document.createElement('div');
+    serverDiv.innerHTML = 'N/A';
+    row.appendChild(serverDiv);
+    this.serverStatuses.set(nodeName, serverDiv);
+    this.clientStatuses.set(nodeName, clientDiv);
+  }
+
+  private setCurrentNodeState(element: HTMLElement, state: ConnectionState):
+      void {
+    if (state === ConnectionState.CONNECTED) {
+      element.innerHTML = ConnectionState[state];
+      element.classList.remove('faulted');
+      element.classList.add('connected');
+    } else {
+      element.innerHTML = ConnectionState[state];
+      element.classList.remove('connected');
+      element.classList.add('faulted');
+    }
+  }
+
+  private handleServerStatistics(data: Uint8Array): void {
+    const fbBuffer = new ByteBuffer(data);
+    const serverStatistics =
+        ServerStatistics.getRootAsServerStatistics(fbBuffer);
+
+    for (let ii = 0; ii < serverStatistics.connectionsLength(); ++ii) {
+      const connection = serverStatistics.connections(ii);
+      const nodeName = connection.node().name();
+      if (!this.serverStatuses.has(nodeName)) {
+        this.populateNodeConnections(nodeName);
+      }
+      this.setCurrentNodeState(
+          this.serverStatuses.get(nodeName), connection.state());
+    }
+  }
+
+  private handleClientStatistics(data: Uint8Array): void {
+    const fbBuffer = new ByteBuffer(data);
+    const clientStatistics =
+        ClientStatistics.getRootAsClientStatistics(fbBuffer);
+
+    for (let ii = 0; ii < clientStatistics.connectionsLength(); ++ii) {
+      const connection = clientStatistics.connections(ii);
+      const nodeName = connection.node().name();
+      if (!this.clientStatuses.has(nodeName)) {
+        this.populateNodeConnections(nodeName);
+      }
+      this.setCurrentNodeState(
+          this.clientStatuses.get(nodeName), connection.state());
+    }
+  }
+
   drawField(): void {
     const ctx = this.canvas.getContext('2d');
     ctx.save();
@@ -292,6 +490,9 @@
     this.reset();
     this.drawField();
 
+    // Draw the matches with debugging information from the localizer.
+    const now = Date.now() / 1000.0;
+
     if (this.superstructureStatus) {
       this.superstructureState.innerHTML =
         SuperstructureState[this.superstructureStatus.state()];
@@ -323,6 +524,12 @@
 
       this.setBoolean(this.extend_ready_for_catapult_transfer, this.superstructureStatus.extendReadyForCatapultTransfer());
 
+      this.setBoolean(this.turret_in_range, this.superstructureStatus.shooter().turretInRange())
+
+      this.setBoolean(this.altitude_in_range, this.superstructureStatus.shooter().altitudeInRange())
+
+      this.setBoolean(this.altitude_above_min_angle, this.superstructureStatus.shooter().altitudeAboveMinAngle())
+
       if (this.superstructureStatus.shooter() &&
           this.superstructureStatus.shooter().aimer()) {
         this.turret_position.innerHTML = this.superstructureStatus.shooter()
@@ -536,6 +743,37 @@
       this.drawRobot(
           this.localizerOutput.x(), this.localizerOutput.y(),
           this.localizerOutput.theta());
+
+      this.imagesAcceptedCounter.innerHTML =
+          this.localizerOutput.imageAcceptedCount().toString();
+    }
+
+    for (const [time, value] of this.localizerImageMatches) {
+      const age = now - time;
+      const kRemovalAge = 1.0;
+      if (age > kRemovalAge) {
+        this.localizerImageMatches.delete(time);
+        continue;
+      }
+      const kMaxImageAlpha = 0.5;
+      const ageAlpha = kMaxImageAlpha * (kRemovalAge - age) / kRemovalAge
+      for (let i = 0; i < value.targetsLength(); i++) {
+        const imageDebug = value.targets(i);
+        const x = imageDebug.impliedRobotX();
+        const y = imageDebug.impliedRobotY();
+        const theta = imageDebug.impliedRobotTheta();
+        const cameraX = imageDebug.cameraX();
+        const cameraY = imageDebug.cameraY();
+        const cameraTheta = imageDebug.cameraTheta();
+        const accepted = imageDebug.accepted();
+        // Make camera readings fade over time.
+        const alpha = Math.round(255 * ageAlpha).toString(16).padStart(2, '0');
+        const dashed = false;
+        const cameraRgb = CAMERA_COLORS[imageDebug.camera()];
+        const cameraRgba = cameraRgb + alpha;
+        this.drawRobot(x, y, theta, cameraRgba, dashed);
+        this.drawCamera(cameraX, cameraY, cameraTheta, cameraRgba);
+      }
     }
 
     window.requestAnimationFrame(() => this.draw());
diff --git a/y2024/www/styles.css b/y2024/www/styles.css
index 39b7519..a11187b 100644
--- a/y2024/www/styles.css
+++ b/y2024/www/styles.css
@@ -65,7 +65,8 @@
 #vision_readouts > div > div {
   display: table-cell;
   padding: 5px;
-  text-align: right;
+  text-align: left;
+  font: small;
 }
 
 #message_bridge_status > div {
diff --git a/y2024/y2024_imu.json b/y2024/y2024_imu.json
index 155edb4..62254e1 100644
--- a/y2024/y2024_imu.json
+++ b/y2024/y2024_imu.json
@@ -2,6 +2,12 @@
   "channels": [
     {
       "name": "/imu/aos",
+      "type": "aos.util.FilesystemStatus",
+      "source_node": "imu",
+      "frequency": 2
+    },
+    {
+      "name": "/imu/aos",
       "type": "aos.JoystickState",
       "source_node": "imu",
       "frequency": 100,
@@ -35,8 +41,8 @@
       "type": "aos.timing.Report",
       "source_node": "imu",
       "frequency": 50,
-      "num_senders": 20,
-      "max_size": 6184
+      "num_senders": 30,
+      "max_size": 8192
     },
     {
       "name": "/imu/aos",
@@ -274,7 +280,8 @@
       "name": "/can/cana",
       "type": "frc971.can_logger.CanFrame",
       "source_node": "imu",
-      "frequency": 6000,
+      "frequency": 9000,
+      "channel_storage_duration": 7000000000,
       "num_senders": 2,
       "max_size": 200
     },
@@ -339,6 +346,22 @@
       ]
     },
     {
+      "name": "irq_affinity",
+      "executable_name": "irq_affinity",
+      "user": "root",
+      "args": ["--user=pi", "--irq_config=orin_irq_config.json"],
+      "nodes": [
+          "imu"
+      ]
+    },
+    {
+      "name": "filesystem_monitor",
+      "executable_name": "filesystem_monitor",
+      "nodes": [
+          "imu"
+      ]
+    },
+    {
       "name": "joystick_republish",
       "executable_name": "joystick_republish",
       "user": "pi",
@@ -385,7 +408,7 @@
       "name": "can_translator",
       "executable_name": "can_translator",
       "args": [
-          "--channel=/can/canb"
+          "--channel=/can/cana"
       ],
       "nodes": [
         "imu"
@@ -480,7 +503,6 @@
       "name": "argus_camera0",
       "executable_name": "argus_camera",
       "args": [
-          "--enable_ftrace",
           "--camera=0",
           "--channel=/camera0"
       ],
@@ -493,7 +515,6 @@
       "name": "argus_camera1",
       "executable_name": "argus_camera",
       "args": [
-          "--enable_ftrace",
           "--camera=1",
           "--channel=/camera1"
       ],
diff --git a/y2024/y2024_orin1.json b/y2024/y2024_orin1.json
index a9f75d5..0b2c6af 100644
--- a/y2024/y2024_orin1.json
+++ b/y2024/y2024_orin1.json
@@ -2,10 +2,16 @@
   "channels": [
     {
       "name": "/orin1/aos",
+      "type": "aos.util.FilesystemStatus",
+      "source_node": "orin1",
+      "frequency": 2
+    },
+    {
+      "name": "/orin1/aos",
       "type": "aos.timing.Report",
       "source_node": "orin1",
       "frequency": 50,
-      "num_senders": 20,
+      "num_senders": 30,
       "max_size": 8192
     },
     {
@@ -261,7 +267,14 @@
       "name": "irq_affinity",
       "executable_name": "irq_affinity",
       "user": "root",
-      "args": ["--user=pi"],
+      "args": ["--user=pi", "--irq_config=orin_irq_config.json"],
+      "nodes": [
+          "orin1"
+      ]
+    },
+    {
+      "name": "filesystem_monitor",
+      "executable_name": "filesystem_monitor",
       "nodes": [
           "orin1"
       ]
@@ -344,7 +357,6 @@
       "name": "argus_camera0",
       "executable_name": "argus_camera",
       "args": [
-          "--enable_ftrace",
           "--camera=0",
           "--channel=/camera0",
       ],
@@ -357,7 +369,6 @@
       "name": "argus_camera1",
       "executable_name": "argus_camera",
       "args": [
-          "--enable_ftrace",
           "--camera=1",
           "--channel=/camera1",
       ],