Merge "Update ordering map to match the robot"
diff --git a/.bazelignore b/.bazelignore
index 145e9e2..9166728 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -6,6 +6,7 @@
 scouting/www/entry/node_modules
 scouting/www/match_list/node_modules
 scouting/www/notes/node_modules
+scouting/www/pipes/node_modules
 scouting/www/rpc/node_modules
 scouting/www/shift_schedule/node_modules
 scouting/www/view/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index a345ff5..f68d1b0 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -918,6 +918,7 @@
         "@//scouting/www/entry:package.json",
         "@//scouting/www/match_list:package.json",
         "@//scouting/www/notes:package.json",
+        "@//scouting/www/pipes:package.json",
         "@//scouting/www/rpc:package.json",
         "@//scouting/www/scan:package.json",
         "@//scouting/www/shift_schedule:package.json",
diff --git a/frc971/vision/intrinsics_calibration.cc b/frc971/vision/intrinsics_calibration.cc
index 16a53a7..d3ddba7 100644
--- a/frc971/vision/intrinsics_calibration.cc
+++ b/frc971/vision/intrinsics_calibration.cc
@@ -43,7 +43,19 @@
       << "Need a base intrinsics json to use to auto-capture images when the "
          "camera moves.";
   std::unique_ptr<aos::ExitHandle> exit_handle = event_loop.MakeExitHandle();
-  IntrinsicsCalibration extractor(&event_loop, hostname, FLAGS_channel,
+
+  std::string camera_name = absl::StrCat(
+      "/", aos::network::ParsePiOrOrin(hostname).value(),
+      std::to_string(aos::network::ParsePiOrOrinNumber(hostname).value()),
+      FLAGS_channel);
+  // THIS IS A HACK FOR 2024, since we call Orin2 "Imu"
+  if (aos::network::ParsePiOrOrin(hostname).value() == "orin" &&
+      aos::network::ParsePiOrOrinNumber(hostname).value() == 2) {
+    LOG(INFO) << "\nHACK for 2024: Renaming orin2 to imu\n";
+    camera_name = absl::StrCat("/imu", FLAGS_channel);
+  }
+
+  IntrinsicsCalibration extractor(&event_loop, hostname, camera_name,
                                   FLAGS_camera_id, FLAGS_base_intrinsics,
                                   FLAGS_display_undistorted,
                                   FLAGS_calibration_folder, exit_handle.get());
diff --git a/frc971/vision/intrinsics_calibration_lib.cc b/frc971/vision/intrinsics_calibration_lib.cc
index 59a45ac..5584ed7 100644
--- a/frc971/vision/intrinsics_calibration_lib.cc
+++ b/frc971/vision/intrinsics_calibration_lib.cc
@@ -36,17 +36,27 @@
                           rvecs_eigen, tvecs_eigen);
           }),
       image_callback_(
-          event_loop,
-          absl::StrCat("/", aos::network::ParsePiOrOrin(hostname_).value(),
-                       std::to_string(cpu_number_.value()), camera_channel_),
+          event_loop, camera_channel_,
           [this](cv::Mat rgb_image,
                  const aos::monotonic_clock::time_point eof) {
+            if (exit_collection_) {
+              return;
+            }
             charuco_extractor_.HandleImage(rgb_image, eof);
           },
           kMaxImageAge),
       display_undistorted_(display_undistorted),
       calibration_folder_(calibration_folder),
-      exit_handle_(exit_handle) {
+      exit_handle_(exit_handle),
+      exit_collection_(false) {
+  if (!FLAGS_visualize) {
+    // The only way to exit into the calibration routines is by hitting "q"
+    // while visualization is running.  The event_loop doesn't pause enough
+    // to handle ctrl-c exit requests
+    LOG(INFO) << "Setting visualize to true, since currently the intrinsics "
+                 "only works this way";
+    FLAGS_visualize = true;
+  }
   LOG(INFO) << "Hostname is: " << hostname_ << " and camera channel is "
             << camera_channel_;
 
@@ -81,7 +91,11 @@
   }
 
   int keystroke = cv::waitKey(1);
-
+  if ((keystroke & 0xFF) == static_cast<int>('q')) {
+    LOG(INFO) << "Going to exit";
+    exit_collection_ = true;
+    exit_handle_->Exit();
+  }
   // If we haven't got a valid pose estimate, don't use these points
   if (!valid) {
     LOG(INFO) << "Skipping because pose is not valid";
@@ -161,9 +175,6 @@
                   << kDeltaTThreshold;
       }
     }
-
-  } else if ((keystroke & 0xFF) == static_cast<int>('q')) {
-    exit_handle_->Exit();
   }
 }
 
@@ -175,8 +186,14 @@
     std::string_view camera_id, uint16_t team_number,
     double reprojection_error) {
   flatbuffers::FlatBufferBuilder fbb;
+  // THIS IS A HACK FOR 2024, since we call Orin2 "Imu"
+  std::string cpu_name = absl::StrFormat("%s%d", cpu_type, cpu_number);
+  if (cpu_type == "orin" && cpu_number == 2) {
+    LOG(INFO) << "Renaming orin2 to imu";
+    cpu_name = "imu";
+  }
   flatbuffers::Offset<flatbuffers::String> name_offset =
-      fbb.CreateString(absl::StrFormat("%s%d", cpu_type, cpu_number));
+      fbb.CreateString(cpu_name.c_str());
   flatbuffers::Offset<flatbuffers::String> camera_id_offset =
       fbb.CreateString(camera_id);
   flatbuffers::Offset<flatbuffers::Vector<float>> intrinsics_offset =
diff --git a/frc971/vision/intrinsics_calibration_lib.h b/frc971/vision/intrinsics_calibration_lib.h
index 605741f..faa82e9 100644
--- a/frc971/vision/intrinsics_calibration_lib.h
+++ b/frc971/vision/intrinsics_calibration_lib.h
@@ -77,6 +77,8 @@
   const bool display_undistorted_;
   const std::string calibration_folder_;
   aos::ExitHandle *exit_handle_;
+
+  bool exit_collection_;
 };
 
 }  // namespace frc971::vision
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2ddfb24..f718ebc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,6 +123,9 @@
       '@org_frc971/scouting/www/notes':
         specifier: workspace:*
         version: link:notes
+      '@org_frc971/scouting/www/pipes':
+        specifier: workspace:*
+        version: link:pipes
       '@org_frc971/scouting/www/pit_scouting':
         specifier: workspace:*
         version: link:pit_scouting
@@ -156,6 +159,9 @@
       '@org_frc971/scouting/webserver/requests/messages':
         specifier: workspace:*
         version: link:../../webserver/requests/messages
+      '@org_frc971/scouting/www/pipes':
+        specifier: workspace:*
+        version: link:../pipes
       '@org_frc971/scouting/www/rpc':
         specifier: workspace:*
         version: link:../rpc
@@ -190,6 +196,8 @@
         specifier: workspace:*
         version: link:../../webserver/requests/messages
 
+  scouting/www/pipes: {}
+
   scouting/www/pit_scouting:
     dependencies:
       '@angular/forms':
diff --git a/scouting/scouting_qrcode_test.cy.js b/scouting/scouting_qrcode_test.cy.js
index 668cba8..559481e 100644
--- a/scouting/scouting_qrcode_test.cy.js
+++ b/scouting/scouting_qrcode_test.cy.js
@@ -86,7 +86,7 @@
   cy.get('#review_data li')
     .eq(0)
     .should('have.text', ' Started match at position 1 ');
-  cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+  cy.get('#review_data li').eq(1).should('have.text', ' Picked up Note ');
   cy.get('#review_data li')
     .last()
     .should(
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index d15cdfc..2de6879 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -106,7 +106,7 @@
   cy.get('#review_data li')
     .eq(0)
     .should('have.text', ' Started match at position 1 ');
-  cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+  cy.get('#review_data li').eq(1).should('have.text', ' Picked up Note ');
   cy.get('#review_data li')
     .last()
     .should(
diff --git a/scouting/webserver/requests/messages/submit_2024_actions.fbs b/scouting/webserver/requests/messages/submit_2024_actions.fbs
index 9462fbe..e927b67 100644
--- a/scouting/webserver/requests/messages/submit_2024_actions.fbs
+++ b/scouting/webserver/requests/messages/submit_2024_actions.fbs
@@ -46,13 +46,21 @@
     spotlight:bool (id:2);
 }
 
+table EndAutoPhaseAction {
+}
+
+table EndTeleopPhaseAction {
+}
+
 union ActionType {
     MobilityAction,
     StartMatchAction,
+    EndAutoPhaseAction,
     PickupNoteAction,
     PlaceNoteAction,
     PenaltyAction,
     RobotDeathAction,
+    EndTeleopPhaseAction,
     EndMatchAction
 }
 
@@ -72,4 +80,4 @@
     // submission. I.e. checking that the match information exists in the match
     // list should be skipped.
     pre_scouting:bool (id: 5);
-}
\ No newline at end of file
+}
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index fdbf6fa..e23c2f3 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -4,6 +4,7 @@
 import {ServiceWorkerModule} from '@angular/service-worker';
 
 import {App} from './app';
+import {PipeModule} from '@org_frc971/scouting/www/pipes';
 import {EntryModule} from '@org_frc971/scouting/www/entry';
 import {MatchListModule} from '@org_frc971/scouting/www/match_list';
 import {NotesModule} from '@org_frc971/scouting/www/notes';
@@ -27,6 +28,7 @@
     EntryModule,
     NotesModule,
     MatchListModule,
+    PipeModule,
     ShiftScheduleModule,
     DriverRankingModule,
     ViewModule,
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 6bffbf8..24da904 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -1,11 +1,13 @@
 load("@npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/build_rules:js.bzl", "ng_pkg")
+load("//tools/build_rules:template.bzl", "jinja2_template")
 
 npm_link_all_packages(name = "node_modules")
 
 ng_pkg(
     name = "entry",
     extra_srcs = [
+        ":action_helper.ts",
         "//scouting/www:app_common_css",
     ],
     deps = [
@@ -13,3 +15,24 @@
         "//:node_modules/flatbuffers",
     ],
 )
+
+jinja2_template(
+    name = "action_helper.ts",
+    src = "action_helper.jinja2.ts",
+    list_parameters = {
+        # Is there a way to auto-generate the list of actions here? Would be
+        # nice not to have a duplicate list here when they're already known in
+        # the .fbs file.
+        "ACTIONS": [
+            "EndMatchAction",
+            "MobilityAction",
+            "PenaltyAction",
+            "PickupNoteAction",
+            "PlaceNoteAction",
+            "RobotDeathAction",
+            "StartMatchAction",
+            "EndAutoPhaseAction",
+            "EndTeleopPhaseAction",
+        ],
+    },
+)
diff --git a/scouting/www/entry/action_helper.jinja2.ts b/scouting/www/entry/action_helper.jinja2.ts
new file mode 100644
index 0000000..bdce4d3
--- /dev/null
+++ b/scouting/www/entry/action_helper.jinja2.ts
@@ -0,0 +1,32 @@
+import {
+  ActionT,
+  ActionType,
+{% for action in ACTIONS %}
+  {{ action }}T,
+{% endfor %}
+} from '@org_frc971/scouting/webserver/requests/messages/submit_2024_actions_generated';
+
+export type ConcreteAction =
+{% for action in ACTIONS %}
+  {{ action }}T {% if not loop.last %} | {% endif %}
+{% endfor %};
+
+export class ActionHelper {
+  constructor(
+    private addAction: (actionType: ActionType, action: ConcreteAction) => void
+  ){}
+
+  {% for action in ACTIONS %}
+  // Calls `addAction` in entry.component.ts with the proper arguments. This
+  // also forces users to specify all the attributes in the `action` object.
+  public add{{ action}}(action: NonFunctionProperties<{{ action }}T>): void {
+    this.addAction(ActionType.{{ action }}, Object.assign(new {{ action }}T(), action));
+  }
+  {% endfor %}
+}
+
+type NonFunctionPropertyNames<T> = {
+  [K in keyof T]: T[K] extends Function ? never : K
+}[keyof T];
+
+type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index d2c922e..cd59884 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -12,20 +12,29 @@
 import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
 import {
   StartMatchAction,
+  StartMatchActionT,
   ScoreType,
   StageType,
   Submit2024Actions,
   MobilityAction,
+  MobilityActionT,
   PenaltyAction,
+  PenaltyActionT,
   PickupNoteAction,
+  PickupNoteActionT,
   PlaceNoteAction,
+  PlaceNoteActionT,
   RobotDeathAction,
+  RobotDeathActionT,
   EndMatchAction,
+  EndMatchActionT,
   ActionType,
   Action,
+  ActionT,
 } from '@org_frc971/scouting/webserver/requests/messages/submit_2024_actions_generated';
 import {Match} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
 import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
+import {ActionHelper, ConcreteAction} from './action_helper';
 import * as pako from 'pako';
 
 type Section =
@@ -59,57 +68,12 @@
 // The default index into QR_CODE_PIECE_SIZES.
 const DEFAULT_QR_CODE_PIECE_SIZE_INDEX = QR_CODE_PIECE_SIZES.indexOf(750);
 
-type ActionT =
-  | {
-      type: 'startMatchAction';
-      timestamp?: number;
-      position: number;
-    }
-  | {
-      type: 'mobilityAction';
-      timestamp?: number;
-      mobility: boolean;
-    }
-  | {
-      type: 'pickupNoteAction';
-      timestamp?: number;
-      auto?: boolean;
-    }
-  | {
-      type: 'placeNoteAction';
-      timestamp?: number;
-      scoreType: ScoreType;
-      auto?: boolean;
-    }
-  | {
-      type: 'robotDeathAction';
-      timestamp?: number;
-      robotDead: boolean;
-    }
-  | {
-      type: 'penaltyAction';
-      timestamp?: number;
-      penalties: number;
-    }
-  | {
-      type: 'endMatchAction';
-      stageType: StageType;
-      trapNote: boolean;
-      spotlight: boolean;
-      timestamp?: number;
-    }
-  | {
-      // This is not a action that is submitted,
-      // It is used for undoing purposes.
-      type: 'endAutoPhase';
-      timestamp?: number;
-    }
-  | {
-      // This is not a action that is submitted,
-      // It is used for undoing purposes.
-      type: 'endTeleopPhase';
-      timestamp?: number;
-    };
+// The actions that are purely used for tracking state. They don't actually
+// have any permanent meaning and will not be saved in the database.
+const STATE_ACTIONS: ActionType[] = [
+  ActionType.EndAutoPhaseAction,
+  ActionType.EndTeleopPhaseAction,
+];
 
 @Component({
   selector: 'app-entry',
@@ -124,6 +88,15 @@
   readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
   readonly ScoreType = ScoreType;
   readonly StageType = StageType;
+  readonly ActionT = ActionT;
+  readonly ActionType = ActionType;
+  readonly StartMatchActionT = StartMatchActionT;
+  readonly MobilityActionT = MobilityActionT;
+  readonly PickupNoteActionT = PickupNoteActionT;
+  readonly PlaceNoteActionT = PlaceNoteActionT;
+  readonly RobotDeathActionT = RobotDeathActionT;
+  readonly PenaltyActionT = PenaltyActionT;
+  readonly EndMatchActionT = EndMatchActionT;
 
   section: Section = 'Team Selection';
   @Input() matchNumber: number = 1;
@@ -136,6 +109,7 @@
 
   matchList: Match[] = [];
 
+  actionHelper: ActionHelper;
   actionList: ActionT[] = [];
   progressMessage: string = '';
   errorMessage: string = '';
@@ -168,6 +142,12 @@
   constructor(private readonly matchListRequestor: MatchListRequestor) {}
 
   ngOnInit() {
+    this.actionHelper = new ActionHelper(
+      (actionType: ActionType, action: ConcreteAction) => {
+        this.addAction(actionType, action);
+      }
+    );
+
     // When the user navigated from the match list, we can skip the team
     // selection. I.e. we trust that the user clicked the correct button.
     this.section = this.skipTeamSelection ? 'Init' : 'Team Selection';
@@ -236,60 +216,58 @@
   }
 
   addPenalties(): void {
-    this.addAction({type: 'penaltyAction', penalties: this.penalties});
+    this.actionHelper.addPenaltyAction({penalties: this.penalties});
   }
 
-  addAction(action: ActionT): void {
-    if (action.type == 'startMatchAction') {
+  addAction(actionType: ActionType, action: ConcreteAction): void {
+    let timestamp: number = 0;
+
+    if (actionType == ActionType.StartMatchAction) {
       // Unix nanosecond timestamp.
       this.matchStartTimestamp = Date.now() * 1e6;
-      action.timestamp = 0;
     } else {
       // Unix nanosecond timestamp relative to match start.
-      action.timestamp = Date.now() * 1e6 - this.matchStartTimestamp;
+      timestamp = Date.now() * 1e6 - this.matchStartTimestamp;
     }
 
-    if (action.type == 'endMatchAction') {
+    if (actionType == ActionType.EndMatchAction) {
       // endMatchAction occurs at the same time as penaltyAction so add to its
       // timestamp to make it unique.
-      action.timestamp += 1;
+      timestamp += 1;
     }
 
-    if (action.type == 'mobilityAction') {
+    if (actionType == ActionType.MobilityAction) {
       this.mobilityCompleted = true;
     }
 
-    if (action.type == 'pickupNoteAction' || action.type == 'placeNoteAction') {
-      action.auto = this.autoPhase;
-    }
-    this.actionList.push(action);
+    this.actionList.push(new ActionT(BigInt(timestamp), actionType, action));
   }
 
   undoLastAction() {
     if (this.actionList.length > 0) {
       let lastAction = this.actionList.pop();
-      switch (lastAction?.type) {
-        case 'endAutoPhase':
+      switch (lastAction?.actionTakenType) {
+        case ActionType.EndAutoPhaseAction:
           this.autoPhase = true;
           this.section = 'Pickup';
-        case 'pickupNoteAction':
+        case ActionType.PickupNoteAction:
           this.section = 'Pickup';
           break;
-        case 'endTeleopPhase':
+        case ActionType.EndTeleopPhaseAction:
           this.section = 'Pickup';
           break;
-        case 'placeNoteAction':
+        case ActionType.PlaceNoteAction:
           this.section = 'Place';
           break;
-        case 'endMatchAction':
+        case ActionType.EndMatchAction:
           this.section = 'Endgame';
-        case 'mobilityAction':
+        case ActionType.MobilityAction:
           this.mobilityCompleted = false;
           break;
-        case 'startMatchAction':
+        case ActionType.StartMatchAction:
           this.section = 'Init';
           break;
-        case 'robotDeathAction':
+        case ActionType.RobotDeathAction:
           // TODO(FILIP): Return user to the screen they
           // clicked dead robot on. Pickup is fine for now but
           // might cause confusion.
@@ -331,111 +309,11 @@
     const actionOffsets: number[] = [];
 
     for (const action of this.actionList) {
-      let actionOffset: number | undefined;
-
-      switch (action.type) {
-        case 'startMatchAction':
-          const startMatchActionOffset =
-            StartMatchAction.createStartMatchAction(builder, action.position);
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.StartMatchAction,
-            startMatchActionOffset
-          );
-          break;
-        case 'mobilityAction':
-          const mobilityActionOffset = MobilityAction.createMobilityAction(
-            builder,
-            action.mobility
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.MobilityAction,
-            mobilityActionOffset
-          );
-          break;
-        case 'penaltyAction':
-          const penaltyActionOffset = PenaltyAction.createPenaltyAction(
-            builder,
-            action.penalties
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.PenaltyAction,
-            penaltyActionOffset
-          );
-          break;
-        case 'pickupNoteAction':
-          const pickupNoteActionOffset =
-            PickupNoteAction.createPickupNoteAction(
-              builder,
-              action.auto || false
-            );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.PickupNoteAction,
-            pickupNoteActionOffset
-          );
-          break;
-        case 'placeNoteAction':
-          const placeNoteActionOffset = PlaceNoteAction.createPlaceNoteAction(
-            builder,
-            action.scoreType,
-            action.auto || false
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.PlaceNoteAction,
-            placeNoteActionOffset
-          );
-          break;
-
-        case 'robotDeathAction':
-          const robotDeathActionOffset =
-            RobotDeathAction.createRobotDeathAction(builder, action.robotDead);
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.RobotDeathAction,
-            robotDeathActionOffset
-          );
-          break;
-
-        case 'endMatchAction':
-          const endMatchActionOffset = EndMatchAction.createEndMatchAction(
-            builder,
-            action.stageType,
-            action.trapNote,
-            action.spotlight
-          );
-          actionOffset = Action.createAction(
-            builder,
-            BigInt(action.timestamp || 0),
-            ActionType.EndMatchAction,
-            endMatchActionOffset
-          );
-          break;
-
-        case 'endAutoPhase':
-          // Not important action.
-          break;
-
-        case 'endTeleopPhase':
-          // Not important action.
-          break;
-
-        default:
-          throw new Error(`Unknown action type`);
+      if (STATE_ACTIONS.includes(action.actionTakenType)) {
+        // Actions only used for undo purposes are not submitted.
+        continue;
       }
-
-      if (actionOffset !== undefined) {
-        actionOffsets.push(actionOffset);
-      }
+      actionOffsets.push(action.pack(builder));
     }
     const teamNumberFb = builder.createString(this.teamNumber);
     const compLevelFb = builder.createString(this.compLevel);
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index df92042..8757ced 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -4,9 +4,11 @@
 import {EntryComponent} from './entry.component';
 import {QRCodeModule} from 'angularx-qrcode';
 
+import {PipeModule} from '@org_frc971/scouting/www/pipes';
+
 @NgModule({
   declarations: [EntryComponent],
   exports: [EntryComponent],
-  imports: [CommonModule, FormsModule, QRCodeModule],
+  imports: [PipeModule, CommonModule, FormsModule, QRCodeModule],
 })
 export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 233d63a..d95bcec 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -102,7 +102,7 @@
         <button
           class="btn btn-primary"
           [disabled]="!selectedValue"
-          (click)="changeSectionTo('Pickup'); addAction({type: 'startMatchAction', position: selectedValue});"
+          (click)="changeSectionTo('Pickup'); actionHelper.addStartMatchAction({position: selectedValue});"
         >
           Start Match
         </button>
@@ -111,7 +111,8 @@
   </div>
   <div *ngSwitchCase="'Pickup'" id="PickUp" class="container-fluid">
     <h6 class="text-muted">
-      Last Action: {{actionList[actionList.length - 1].type}}
+      Last Action: {{ActionType[actionList[actionList.length -
+      1].actionTakenType]}}
     </h6>
     <!--
       Decrease distance between buttons during auto to make space for auto balancing
@@ -123,20 +124,20 @@
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
+        (click)="changeSectionTo('Dead'); actionHelper.addRobotDeathAction({robotDead: true});"
       >
         DEAD
       </button>
       <button
         class="btn btn-warning"
-        (click)="changeSectionTo('Place'); addAction({type: 'pickupNoteAction'});"
+        (click)="changeSectionTo('Place'); actionHelper.addPickupNoteAction({auto: autoPhase});"
       >
         NOTE
       </button>
       <button
         *ngIf="autoPhase && !mobilityCompleted"
         class="btn btn-light"
-        (click)="addAction({type: 'mobilityAction', mobility: true});"
+        (click)="actionHelper.addMobilityAction({mobility: true});"
       >
         Mobility
       </button>
@@ -161,14 +162,14 @@
       <button
         *ngIf="autoPhase"
         class="btn btn-dark"
-        (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+        (click)="autoPhase = false; actionHelper.addEndAutoPhaseAction({});"
       >
         Start Teleop
       </button>
       <button
         *ngIf="!autoPhase"
         class="btn btn-info"
-        (click)="changeSectionTo('Endgame'); addAction({type: 'endTeleopPhase'});"
+        (click)="changeSectionTo('Endgame'); actionHelper.addEndTeleopPhaseAction({});"
       >
         Endgame
       </button>
@@ -176,7 +177,8 @@
   </div>
   <div *ngSwitchCase="'Place'" id="Place" class="container-fluid">
     <h6 class="text-muted">
-      Last Action: {{actionList[actionList.length - 1].type}}
+      Last Action: {{ActionType[actionList[actionList.length -
+      1].actionTakenType]}}
     </h6>
     <!--
       Decrease distance between buttons during auto to make space for auto balancing
@@ -188,13 +190,13 @@
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
+        (click)="changeSectionTo('Dead'); actionHelper.addRobotDeathAction({robotDead: true});"
       >
         DEAD
       </button>
       <button
         class="btn btn-info"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kDROPPED});"
+        (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kDROPPED});"
       >
         Dropped
       </button>
@@ -211,7 +213,7 @@
         >
           <button
             class="btn btn-success"
-            (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kAMP});"
+            (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kAMP});"
             style="width: 48%; height: 12vh; margin: 0px 10px 10px 0px"
           >
             AMP
@@ -219,21 +221,21 @@
 
           <button
             class="btn btn-warning"
-            (click)="changeSectionTo('Pickup');  addAction({type: 'placeNoteAction', scoreType: ScoreType.kAMP_AMPLIFIED});"
+            (click)="changeSectionTo('Pickup');  actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kAMP_AMPLIFIED});"
             style="width: 48%; height: 12vh; margin: 0px 0px 10px 0px"
           >
             AMP AMPLIFIED
           </button>
           <button
             class="btn btn-success"
-            (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kSPEAKER});"
+            (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kSPEAKER});"
             style="width: 48%; height: 12vh; margin: 0px 10px 0px 0px"
           >
             SPEAKER
           </button>
           <button
             class="btn btn-warning"
-            (click)="changeSectionTo('Pickup');  addAction({type: 'placeNoteAction', scoreType: ScoreType.kSPEAKER_AMPLIFIED});"
+            (click)="changeSectionTo('Pickup');  actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kSPEAKER_AMPLIFIED});"
             style="width: 48%; height: 12vh; margin: 0px 0px 0px 0px"
           >
             SPEAKER AMPLIFIED
@@ -244,21 +246,21 @@
       <button
         *ngIf="autoPhase"
         class="btn btn-success"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kAMP});"
+        (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kAMP});"
       >
         AMP
       </button>
       <button
         *ngIf="autoPhase"
         class="btn btn-warning"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'placeNoteAction', scoreType: ScoreType.kSPEAKER});"
+        (click)="changeSectionTo('Pickup'); actionHelper.addPlaceNoteAction({auto: autoPhase, scoreType: ScoreType.kSPEAKER});"
       >
         SPEAKER
       </button>
       <button
         *ngIf="autoPhase && !mobilityCompleted"
         class="btn btn-light"
-        (click)="addAction({type: 'mobilityAction', mobility: true});"
+        (click)="actionHelper.addMobilityAction({mobility: true});"
       >
         Mobility
       </button>
@@ -283,14 +285,14 @@
       <button
         class="btn btn-dark"
         *ngIf="autoPhase"
-        (click)="autoPhase = false; addAction({type: 'endAutoPhase'});"
+        (click)="autoPhase = false; actionHelper.addEndAutoPhaseAction({});"
       >
         Start Teleop
       </button>
       <button
         *ngIf="!autoPhase"
         class="btn btn-info"
-        (click)="changeSectionTo('Endgame'); addAction({type: 'endTeleopPhase'});"
+        (click)="changeSectionTo('Endgame'); actionHelper.addEndTeleopPhaseAction({});"
       >
         Endgame
       </button>
@@ -298,13 +300,14 @@
   </div>
   <div *ngSwitchCase="'Endgame'" id="Endgame" class="container-fluid">
     <h6 class="text-muted">
-      Last Action: {{actionList[actionList.length - 1].type}}
+      Last Action: {{ActionType[actionList[actionList.length -
+      1].actionTakenType]}}
     </h6>
     <div class="d-grid gap-2">
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
       <button
         class="btn btn-danger"
-        (click)="changeSectionTo('Dead'); addAction({type: 'robotDeathAction', robotDead: true});"
+        (click)="changeSectionTo('Dead'); actionHelper.addRobotDeathAction({robotDead: true});"
       >
         DEAD
       </button>
@@ -382,7 +385,7 @@
       <button
         *ngIf="!autoPhase"
         class="btn btn-info"
-        (click)="changeSectionTo('Review and Submit');  addPenalties(); addAction({type: 'endMatchAction', stageType: endGameAction, trapNote: noteIsTrapped, spotlight: endGameSpotlight});"
+        (click)="changeSectionTo('Review and Submit');  addPenalties(); actionHelper.addEndMatchAction({stageType: endGameAction, trapNote: noteIsTrapped, spotlight: endGameSpotlight});"
       >
         End Match
       </button>
@@ -412,13 +415,13 @@
       </div>
       <button
         class="btn btn-success"
-        (click)="changeSectionTo('Pickup'); addAction({type: 'robotDeathAction', robotDead: false}); "
+        (click)="changeSectionTo('Pickup'); actionHelper.addRobotDeathAction({robotDead: false}); "
       >
         Revive
       </button>
       <button
         class="btn btn-info"
-        (click)="changeSectionTo('Review and Submit');  addPenalties(); addAction({type: 'endMatchAction', stageType: endGameAction, trapNote: noteIsTrapped, spotlight: endGameSpotlight});"
+        (click)="changeSectionTo('Review and Submit');  addPenalties(); actionHelper.addEndMatchAction({stageType: endGameAction, trapNote: noteIsTrapped, spotlight: endGameSpotlight});"
       >
         End Match
       </button>
@@ -428,29 +431,40 @@
     <div class="row">
       <ul id="review_data">
         <li *ngFor="let action of actionList" style="display: flex">
-          <div [ngSwitch]="action.type" style="padding: 0px">
-            <span *ngSwitchCase="'startMatchAction'">
-              Started match at position {{$any(action).position}}
+          <div [ngSwitch]="action.actionTakenType" style="padding: 0px">
+            <span *ngSwitchCase="ActionType.StartMatchAction">
+              Started match at position {{(action.actionTaken | cast:
+              StartMatchActionT).position}}
             </span>
-            <span *ngSwitchCase="'pickupNoteAction'">Picked up Note</span>
-            <span *ngSwitchCase="'placeNoteAction'">
-              Placed at {{stringifyScoreType($any(action).scoreType)}}
+            <span *ngSwitchCase="ActionType.PickupNoteAction">
+              Picked up Note
             </span>
-            <span *ngSwitchCase="'endAutoPhase'">Ended auto phase</span>
-            <span *ngSwitchCase="'endMatchAction'">
-              Ended Match; stageType:
-              {{stringifyStageType($any(action).stageType)}}, trapNote:
-              {{$any(action).trapNote}}, spotlight: {{$any(action).spotlight}}
+            <span *ngSwitchCase="ActionType.PlaceNoteAction">
+              Placed at {{stringifyScoreType((action.actionTaken | cast:
+              PlaceNoteActionT).scoreType)}}
             </span>
-            <span *ngSwitchCase="'robotDeathAction'">
-              Robot dead: {{$any(action).robotDead}}
+            <span *ngSwitchCase="ActionType.EndAutoPhaseAction">
+              Ended auto phase
             </span>
-            <span *ngSwitchCase="'mobilityAction'">
-              Mobility: {{$any(action).mobility}}
+            <span *ngSwitchCase="ActionType.EndMatchAction">
+              Ended Match; stageType: {{stringifyStageType((action.actionTaken |
+              cast: EndMatchActionT).stageType)}}, trapNote:
+              {{(action.actionTaken | cast: EndMatchActionT).trapNote}},
+              spotlight: {{(action.actionTaken | cast:
+              EndMatchActionT).spotlight}}
             </span>
-            <span *ngSwitchDefault>{{action.type}}</span>
-            <span *ngSwitchCase="'penaltyAction'">
-              Penalties: {{$any(action).penalties}}
+            <span *ngSwitchCase="ActionType.RobotDeathAction">
+              Robot dead: {{(action.actionTaken | cast:
+              RobotDeathActionT).robotDead}}
+            </span>
+            <span *ngSwitchCase="ActionType.MobilityAction">
+              Mobility: {{(action.actionTaken | cast:
+              MobilityActionT).mobility}}
+            </span>
+            <span *ngSwitchDefault>{{action.actionTakenType}}</span>
+            <span *ngSwitchCase="ActionType.PenaltyAction">
+              Penalties: {{(action.actionTaken | cast:
+              PenaltyActionT).penalties}}
             </span>
           </div>
         </li>
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index 39479f4..e97d92c 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -8,6 +8,7 @@
         "@angular/platform-browser": "v16-lts",
         "@types/pako": "2.0.3",
         "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
-        "@org_frc971/scouting/www/rpc": "workspace:*"
+        "@org_frc971/scouting/www/rpc": "workspace:*",
+        "@org_frc971/scouting/www/pipes": "workspace:*"
     }
 }
diff --git a/scouting/www/package.json b/scouting/www/package.json
index 80fa5c9..da5f279 100644
--- a/scouting/www/package.json
+++ b/scouting/www/package.json
@@ -7,6 +7,7 @@
         "@org_frc971/scouting/www/entry": "workspace:*",
         "@org_frc971/scouting/www/match_list": "workspace:*",
         "@org_frc971/scouting/www/notes": "workspace:*",
+        "@org_frc971/scouting/www/pipes": "workspace:*",
         "@org_frc971/scouting/www/pit_scouting": "workspace:*",
         "@org_frc971/scouting/www/scan": "workspace:*",
         "@org_frc971/scouting/www/shift_schedule": "workspace:*",
diff --git a/scouting/www/pipes/BUILD b/scouting/www/pipes/BUILD
new file mode 100644
index 0000000..680eb09
--- /dev/null
+++ b/scouting/www/pipes/BUILD
@@ -0,0 +1,12 @@
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
+    name = "pipes",
+    extra_srcs = [
+        "public-api.ts",
+    ],
+    generate_public_api = False,
+)
diff --git a/scouting/www/pipes/cast.ts b/scouting/www/pipes/cast.ts
new file mode 100644
index 0000000..1bb24e8
--- /dev/null
+++ b/scouting/www/pipes/cast.ts
@@ -0,0 +1,15 @@
+import {Pipe, PipeTransform} from '@angular/core';
+
+@Pipe({name: 'cast'})
+export class CastPipe implements PipeTransform {
+  /**
+   * Cast (S: SuperType) into (T: Type) using @Generics.
+   * @param value (S: SuperType) obtained from input type.
+   * @optional @param type (T CastingType)
+   * type?: { new (): T }
+   * type?: new () => T
+   */
+  transform<S, T extends S>(value: S, type?: new () => T): T {
+    return <T>value;
+  }
+}
diff --git a/scouting/www/pipes/package.json b/scouting/www/pipes/package.json
new file mode 100644
index 0000000..b4ce582
--- /dev/null
+++ b/scouting/www/pipes/package.json
@@ -0,0 +1,4 @@
+{
+    "name": "@org_frc971/scouting/www/pipes",
+    "private": true
+}
diff --git a/scouting/www/pipes/pipes.module.ts b/scouting/www/pipes/pipes.module.ts
new file mode 100644
index 0000000..b7dd4c4
--- /dev/null
+++ b/scouting/www/pipes/pipes.module.ts
@@ -0,0 +1,12 @@
+import {NgModule} from '@angular/core';
+import {CastPipe} from './cast';
+
+// Export types needed for the public API.
+export {CastPipe};
+
+@NgModule({
+  declarations: [CastPipe],
+  exports: [CastPipe],
+  imports: [],
+})
+export class PipeModule {}
diff --git a/scouting/www/pipes/public-api.ts b/scouting/www/pipes/public-api.ts
new file mode 100644
index 0000000..77a5641
--- /dev/null
+++ b/scouting/www/pipes/public-api.ts
@@ -0,0 +1 @@
+export * from './pipes.module';
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index b735802..bb15c2c 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -252,6 +252,7 @@
     srcs = native.glob(
         ["**/*.ts", "**/*.css", "**/*.html"],
         exclude = test_spec_srcs + [
+            "**/*.jinja2.*",
             "public-api.ts",
         ],
     ) + extra_srcs
diff --git a/tools/lint/prettier.sh b/tools/lint/prettier.sh
index 0198a71..ef4f5ca 100755
--- a/tools/lint/prettier.sh
+++ b/tools/lint/prettier.sh
@@ -28,6 +28,7 @@
 # TODO(phil): Support more than just //scouting.
 web_files=($(git ls-tree --name-only --full-tree -r @ \
     | grep '^scouting/' \
+    | grep -v '\.jinja2\.' \
     | (grep \
         -e '\.ts$' \
         -e '\.js$' \
diff --git a/y2024/constants/971.json b/y2024/constants/971.json
index 1a9d221..c146417 100644
--- a/y2024/constants/971.json
+++ b/y2024/constants/971.json
@@ -23,7 +23,7 @@
   "robot": {
     {% set _ = intake_pivot_zero.update(
       {
-          "measured_absolute_position" : 3.49222521810232
+          "measured_absolute_position" : 3.229
       }
     ) %}
     "intake_constants":  {{ intake_pivot_zero | tojson(indent=2)}},
@@ -48,7 +48,7 @@
     "altitude_constants": {
       {% set _ = altitude_zero.update(
           {
-              "measured_absolute_position" : 0.1877
+              "measured_absolute_position" : 0.2135
           }
       ) %}
       "zeroing_constants": {{ altitude_zero | tojson(indent=2)}},
@@ -57,11 +57,11 @@
     "turret_constants": {
       {% set _ = turret_zero.update(
           {
-              "measured_absolute_position" : 0.961143535321169
+              "measured_absolute_position" : 0.2077
           }
       ) %}
       "zeroing_constants": {{ turret_zero | tojson(indent=2)}},
-      "potentiometer_offset": {{ -6.47164779835404 - 0.0711209027239817 + 1.0576004531907 }}
+      "potentiometer_offset": {{ -6.47164779835404 - 0.0711209027239817 + 1.0576004531907 - 0.343 }}
     },
     "extend_constants": {
       {% set _ = extend_zero.update(
diff --git a/y2024/control_loops/superstructure/superstructure.cc b/y2024/control_loops/superstructure/superstructure.cc
index 4ab5966..9edc626 100644
--- a/y2024/control_loops/superstructure/superstructure.cc
+++ b/y2024/control_loops/superstructure/superstructure.cc
@@ -51,8 +51,10 @@
           robot_constants_->common()->extend(),
           robot_constants_->robot()->extend_constants()->zeroing_constants()),
       extend_debouncer_(std::chrono::milliseconds(30),
-                        std::chrono::milliseconds(8)) {
-  event_loop->SetRuntimeRealtimePriority(37);
+                        std::chrono::milliseconds(8)),
+      transfer_debouncer_(std::chrono::milliseconds(30),
+                          std::chrono::milliseconds(8)) {
+  event_loop->SetRuntimeRealtimePriority(30);
 }
 
 bool PositionNear(double position, double goal, double threshold) {
@@ -79,6 +81,9 @@
   extend_debouncer_.Update(position->extend_beambreak(), timestamp);
   const bool extend_beambreak = extend_debouncer_.state();
 
+  transfer_debouncer_.Update(position->transfer_beambreak(), timestamp);
+  const bool transfer_beambreak = transfer_debouncer_.state();
+
   // Handle Climber Goal separately from main superstructure state machine
   double climber_position =
       robot_constants_->common()->climber_set_points()->retract();
@@ -221,6 +226,7 @@
           unsafe_goal->intake_goal() == IntakeGoal::INTAKE &&
           extend_at_retracted) {
         state_ = SuperstructureState::INTAKING;
+        note_in_transfer_ = false;
       }
 
       extend_goal_location = ExtendStatus::RETRACTED;
@@ -231,7 +237,18 @@
       if (extend_beambreak) {
         state_ = SuperstructureState::LOADED;
       }
-      intake_roller_state = IntakeRollerStatus::INTAKING;
+
+      if (transfer_beambreak) {
+        note_in_transfer_ = true;
+      }
+
+      // Once the note is in the transfer, stop the intake rollers
+      if (note_in_transfer_) {
+        intake_roller_state = IntakeRollerStatus::NONE;
+      } else {
+        intake_roller_state = IntakeRollerStatus::INTAKING;
+      }
+
       transfer_roller_status = TransferRollerStatus::TRANSFERING_IN;
       extend_roller_status = ExtendRollerStatus::TRANSFERING_TO_EXTEND;
       extend_goal_location = ExtendStatus::RETRACTED;
diff --git a/y2024/control_loops/superstructure/superstructure.h b/y2024/control_loops/superstructure/superstructure.h
index ce279a6..a1bf841 100644
--- a/y2024/control_loops/superstructure/superstructure.h
+++ b/y2024/control_loops/superstructure/superstructure.h
@@ -73,6 +73,9 @@
 
   NoteGoal requested_note_goal_ = NoteGoal::NONE;
 
+  // True if the transfer beambreak has been triggered since last intake request
+  bool note_in_transfer_ = false;
+
   aos::monotonic_clock::time_point transfer_start_time_ =
       aos::monotonic_clock::time_point::min();
 
@@ -91,6 +94,8 @@
 
   Debouncer extend_debouncer_;
 
+  Debouncer transfer_debouncer_;
+
   DISALLOW_COPY_AND_ASSIGN(Superstructure);
 };
 
diff --git a/y2024/control_loops/superstructure/superstructure_lib_test.cc b/y2024/control_loops/superstructure/superstructure_lib_test.cc
index 52c9a6c..9e8b3a5 100644
--- a/y2024/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2024/control_loops/superstructure/superstructure_lib_test.cc
@@ -66,6 +66,7 @@
             event_loop_->MakeFetcher<Output>("/superstructure")),
         extend_beambreak_(false),
         catapult_beambreak_(false),
+        transfer_beambreak_(false),
         intake_pivot_(
             new CappedTestPlant(intake_pivot::MakeIntakePivotPlant()),
             PositionSensorSimulator(simulated_robot_constants->robot()
@@ -275,6 +276,7 @@
 
     position_builder.add_extend_beambreak(extend_beambreak_);
     position_builder.add_catapult_beambreak(catapult_beambreak_);
+    position_builder.add_transfer_beambreak(transfer_beambreak_);
     position_builder.add_intake_pivot(intake_pivot_offset);
     position_builder.add_catapult(catapult_offset);
     position_builder.add_altitude(altitude_offset);
@@ -292,6 +294,10 @@
     catapult_beambreak_ = triggered;
   }
 
+  void set_transfer_beambreak(bool triggered) {
+    transfer_beambreak_ = triggered;
+  }
+
   AbsoluteEncoderSimulator *intake_pivot() { return &intake_pivot_; }
   PotAndAbsoluteEncoderSimulator *catapult() { return &catapult_; }
   PotAndAbsoluteEncoderSimulator *altitude() { return &altitude_; }
@@ -312,6 +318,7 @@
 
   bool extend_beambreak_;
   bool catapult_beambreak_;
+  bool transfer_beambreak_;
 
   AbsoluteEncoderSimulator intake_pivot_;
   PotAndAbsoluteEncoderSimulator climber_;
@@ -945,6 +952,58 @@
   EXPECT_EQ(superstructure_output_fetcher_->transfer_roller_voltage(), 0.0);
 }
 
+// Make sure we stop intaking once transfer beambreak is triggered
+TEST_F(SuperstructureTest, TransferBeamBreakStopsIntake) {
+  SetEnabled(true);
+
+  WaitUntilZeroed();
+
+  superstructure_plant_.intake_pivot()->InitializePosition(
+      frc971::constants::Range::FromFlatbuffer(
+          simulated_robot_constants_->common()->intake_pivot()->range())
+          .middle());
+
+  WaitUntilZeroed();
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_intake_goal(IntakeGoal::INTAKE);
+    goal_builder.add_intake_pivot(IntakePivotGoal::DOWN);
+    goal_builder.add_note_goal(NoteGoal::NONE);
+
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  RunFor(chrono::seconds(5));
+
+  VerifyNearGoal();
+
+  EXPECT_EQ(superstructure_status_fetcher_->state(),
+            SuperstructureState::INTAKING);
+
+  EXPECT_EQ(superstructure_output_fetcher_->intake_roller_voltage(),
+            simulated_robot_constants_->common()
+                ->intake_roller_voltages()
+                ->intaking());
+
+  superstructure_plant_.set_transfer_beambreak(true);
+
+  RunFor(chrono::seconds(2));
+
+  VerifyNearGoal();
+
+  EXPECT_EQ(superstructure_status_fetcher_->state(),
+            SuperstructureState::INTAKING);
+
+  EXPECT_EQ(superstructure_status_fetcher_->intake_roller(),
+            IntakeRollerStatus::NONE);
+
+  EXPECT_EQ(superstructure_output_fetcher_->intake_roller_voltage(), 0.0);
+}
+
 // Tests the full range of activities we need to be doing from loading ->
 // shooting
 TEST_F(SuperstructureTest, LoadingToShooting) {
diff --git a/y2024/joystick_reader.cc b/y2024/joystick_reader.cc
index 4a8d414..293e57e 100644
--- a/y2024/joystick_reader.cc
+++ b/y2024/joystick_reader.cc
@@ -51,6 +51,7 @@
 const ButtonLocation kCatapultLoad(2, 1);
 const ButtonLocation kAmp(2, 4);
 const ButtonLocation kFire(2, 8);
+const ButtonLocation kDriverFire(1, 1);
 const ButtonLocation kTrap(2, 6);
 const ButtonLocation kAutoAim(1, 8);
 const ButtonLocation kAimSpeaker(2, 11);
@@ -159,7 +160,8 @@
                                                    ->shooter_speaker_set_point()
                                                    ->turret_position());
     }
-    superstructure_goal_builder->set_fire(data.IsPressed(kFire));
+    superstructure_goal_builder->set_fire(data.IsPressed(kFire) ||
+                                          data.IsPressed(kDriverFire));
 
     if (data.IsPressed(kRetractClimber)) {
       superstructure_goal_builder->set_climber_goal(
diff --git a/y2024/vision/BUILD b/y2024/vision/BUILD
index 4904554..bd4fe76 100644
--- a/y2024/vision/BUILD
+++ b/y2024/vision/BUILD
@@ -167,3 +167,20 @@
         "@org_tuxfamily_eigen//:eigen",
     ],
 )
+
+cc_binary(
+    name = "image_replay",
+    srcs = [
+        "image_replay.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//y2024:__subpackages__"],
+    deps = [
+        "//aos:configuration",
+        "//aos:init",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_reader",
+        "//frc971/vision:vision_fbs",
+        "//third_party:opencv",
+    ],
+)
diff --git a/y2024/vision/image_replay.cc b/y2024/vision/image_replay.cc
new file mode 100644
index 0000000..f03bcf1
--- /dev/null
+++ b/y2024/vision/image_replay.cc
@@ -0,0 +1,47 @@
+#include "gflags/gflags.h"
+#include "opencv2/imgproc.hpp"
+#include <opencv2/highgui.hpp>
+
+#include "aos/events/logging/log_reader.h"
+#include "aos/events/logging/log_writer.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/init.h"
+#include "aos/json_to_flatbuffer.h"
+#include "aos/logging/log_message_generated.h"
+#include "frc971/vision/vision_generated.h"
+
+DEFINE_string(node, "orin1", "The node to view the log from");
+DEFINE_string(channel, "/camera0", "The channel to view the log from");
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  // open logfiles
+  aos::logger::LogReader reader(
+      aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
+
+  aos::SimulatedEventLoopFactory factory(reader.configuration());
+  reader.Register(&factory);
+
+  aos::NodeEventLoopFactory *node = factory.GetNodeEventLoopFactory(FLAGS_node);
+
+  std::unique_ptr<aos::EventLoop> image_loop = node->MakeEventLoop("image");
+  image_loop->MakeWatcher(
+      "/" + FLAGS_node + "/" + FLAGS_channel,
+      [](const frc971::vision::CameraImage &msg) {
+        cv::Mat color_image(cv::Size(msg.cols(), msg.rows()), CV_8UC2,
+                            (void *)msg.data()->data());
+
+        cv::Mat bgr(color_image.size(), CV_8UC3);
+        cv::cvtColor(color_image, bgr, cv::COLOR_YUV2BGR_YUYV);
+
+        cv::imshow("Replay", bgr);
+        cv::waitKey(1);
+      });
+
+  factory.Run();
+
+  reader.Deregister();
+
+  return 0;
+}