diff --git a/.run/deploy.run.xml b/.run/deploy.run.xml index 3c2dda37..9d76a291 100644 --- a/.run/deploy.run.xml +++ b/.run/deploy.run.xml @@ -1,11 +1,10 @@ - + @@ -22,4 +21,4 @@ false - + \ No newline at end of file diff --git a/docs/mkdocs/docs/library/commands/vision/oculus/ping.md b/docs/mkdocs/docs/library/commands/vision/oculus/ping.md new file mode 100644 index 00000000..0d566a1f --- /dev/null +++ b/docs/mkdocs/docs/library/commands/vision/oculus/ping.md @@ -0,0 +1,19 @@ +# Ping Command + +A command that verifies communication with the Oculus headset by sending a ping request and waiting for a response. + +## Required Subsystems +- [Oculus Navigation Subsystem](/5152_Template/library/subsystems/vision/questnav) + +## Constructor Parameters +```java +public PingCommand(OculusSubsystem oculus) +``` +- `oculus`: The Oculus subsystem instance to ping + +## Configuration +No additional configuration required. + +## Reference Documentation + +[JavaDoc Reference](/5152_Template/javadoc/frc/alotobots/library/subsystems/vision/questnav/commands/PingCommand.html) diff --git a/docs/mkdocs/docs/library/commands/vision/oculus/resetpose.md b/docs/mkdocs/docs/library/commands/vision/oculus/resetpose.md new file mode 100644 index 00000000..223f25ee --- /dev/null +++ b/docs/mkdocs/docs/library/commands/vision/oculus/resetpose.md @@ -0,0 +1,20 @@ +# Reset Pose Command + +A command that resets the Oculus headset's position tracking to align with a specified pose on the field. + +## Required Subsystems +- [Oculus Navigation Subsystem](/5152_Template/library/subsystems/vision/questnav) + +## Constructor Parameters +```java +public ResetPoseCommand(OculusSubsystem oculus, Pose2d targetPose) +``` +- `oculus`: The Oculus subsystem instance to reset +- `targetPose`: The target pose to reset the tracking to + +## Configuration +The target pose should be specified in field coordinates. + +## Reference Documentation + +[JavaDoc Reference](/5152_Template/javadoc/frc/alotobots/library/subsystems/vision/questnav/commands/ResetPoseCommand.html) diff --git a/docs/mkdocs/docs/library/commands/vision/oculus/zeroheading.md b/docs/mkdocs/docs/library/commands/vision/oculus/zeroheading.md new file mode 100644 index 00000000..344eb110 --- /dev/null +++ b/docs/mkdocs/docs/library/commands/vision/oculus/zeroheading.md @@ -0,0 +1,19 @@ +# Zero Heading Command + +A command that zeros the heading (yaw) of the Oculus headset, setting the current heading as the zero reference point. + +## Required Subsystems +- [Oculus Navigation Subsystem](/5152_Template/library/subsystems/vision/questnav) + +## Constructor Parameters +```java +public ZeroHeadingCommand(OculusSubsystem oculus) +``` +- `oculus`: The Oculus subsystem instance to reset + +## Configuration +No additional configuration required. + +## Reference Documentation + +[JavaDoc Reference](/5152_Template/javadoc/frc/alotobots/library/subsystems/vision/questnav/commands/ZeroHeadingCommand.html) diff --git a/docs/mkdocs/docs/library/subsystems/vision/oculus.md b/docs/mkdocs/docs/library/subsystems/vision/oculus.md new file mode 100644 index 00000000..2e6df9da --- /dev/null +++ b/docs/mkdocs/docs/library/subsystems/vision/oculus.md @@ -0,0 +1,36 @@ +# Oculus Navigation Subsystem + +The Oculus Navigation subsystem provides robot positioning and orientation tracking using an Oculus Quest VR headset. This system enables high-precision robot navigation and positioning by leveraging the Oculus Quest's advanced tracking capabilities. + +## Constructor and Configuration + +The subsystem is constructed with an OculusIO interface implementation: + +```java +public OculusSubsystem(OculusIO io) +``` + +The io parameter can be either: +- `OculusIOReal` for real hardware operation +- `OculusIOSim` for simulation testing + +## Commands + +The following commands interact with the Oculus subsystem: + +- [Ping Command](/5152_Template/library/commands/questnav/pingcommand) - Tests communication with the headset +- [Reset Pose Command](/5152_Template/library/commands/questnav/resetposecommand) - Resets the headset's position tracking +- [Zero Heading Command](/5152_Template/library/commands/questnav/zeroheadingcommand) - Zeros the headset's rotation tracking + +## Required Configuration + +1. Software Setup: + - [QuestNav](https://github.com/juchong/QuestNav/tree/main) must be deployed and running on the Oculus Quest headset + +2. Physical Setup: + - Oculus Quest headset must be mounted securely to the robot + - Headset position relative to robot center must be configured in OculusConstants.OCULUS_TO_ROBOT + +## Reference Documentation + +Full JavaDoc reference: [Package Documentation](/5152_Template/javadoc/frc/alotobots/library/subsystems/vision/questnav/package-summary.html) diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 8de7989c..cd18b01d 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -24,12 +24,17 @@ nav: - Object Detection: - Drive Facing Best Object: library/commands/vision/objectdetection/drivefacingbestobject.md - Pathfind to Best Object: library/commands/vision/objectdetection/pathfindtobestobject.md + - Oculus QuestNav: + - Ping: library/commands/vision/oculus/ping.md + - Reset Pose: library/commands/vision/oculus/resetpose.md + - Zero Heading: library/commands/vision/oculus/zeroheading.md - Subsystems: - Swerve: library/subsystems/swerve.md - Bling: library/subsystems/bling.md - Vision: - Object Detection: library/subsystems/vision/objectdetection.md - AprilTag: library/subsystems/vision/apriltag.md + - Oculus: library/subsystems/vision/oculus.md - Core: - Controller Bindings: core/bindings.md - Auto Named Commands: core/autonamedcommands.md diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/quest-tools.sh b/quest-tools.sh new file mode 100644 index 00000000..b5f8b1fb --- /dev/null +++ b/quest-tools.sh @@ -0,0 +1,234 @@ +#!/bin/bash + +APP_PACKAGE="com.DerpyCatAviationLLC.QuestNav" +QUEST_PORT=5555 + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +print_help() { + echo -e "${YELLOW}Quest Development Tools${NC}" + echo "Usage: ./quest-tools.sh [command] [team_number]" + echo "" + echo "Commands:" + echo " connect - Find and connect to Quest on robot network (e.g., 5152)" + echo " stopwireless - Disable WiFi and Bluetooth and reboot" + echo " setup - Disable guardian and restart app" + echo " restart - Restart the QuestNav app" + echo " redeploy - Install APK from path and restart app" + echo " screen - Launch scrcpy for screen mirroring" + echo " reboot - Reboot the Quest" + echo " shutdown - Shutdown the Quest" + echo " status - Check ADB connection status" + echo " logs [filter] - Show app logs, optionally filtered by string" +} + +check_adb() { + if ! command -v adb &> /dev/null; then + echo -e "${RED}Error: ADB not found. Please install Android platform tools.${NC}" + exit 1 + fi +} + +ensure_connected() { + if ! adb devices | grep -q "$QUEST_PORT"; then + echo -e "${RED}Error: Quest not connected. Use 'connect' command first.${NC}" + exit 1 + fi +} + +find_quest() { + local team=$1 + local subnet="10.${team:0:2}.${team:2:2}" + + echo -e "${GREEN}Scanning network ${subnet}.0/24 for Quest...${NC}" + + # Check if nmap is installed + if ! command -v nmap &> /dev/null; then + echo -e "${RED}Error: nmap not found. Please install nmap first.${NC}" + exit 1 + fi + + # Kill any existing ADB server first + adb kill-server &> /dev/null + adb start-server &> /dev/null + + # Use nmap to quickly scan network, excluding .1 and .2 + echo "Running quick network scan..." + local devices=$(nmap -n -sn --exclude ${subnet}.1,${subnet}.2 ${subnet}.0/24 | grep "report for" | cut -d " " -f 5) + + for ip in $devices; do + echo -e "${YELLOW}Found device at ${ip}, attempting ADB connection...${NC}" + + # Try ADB connect with timeout + timeout 3 adb connect "${ip}:$QUEST_PORT" &> /dev/null + + # Quick check if device is connected + if timeout 1 adb devices | grep -q "${ip}:$QUEST_PORT"; then + echo -e "${GREEN}Successfully connected to Quest!${NC}" + return 0 + fi + + adb disconnect "${ip}:$QUEST_PORT" &> /dev/null + done + + echo -e "${RED}Could not find Quest on network${NC}" + return 1 +} + +stopwireless() { + ensure_connected + echo "Disabling wireless connections..." + adb shell settings put global bluetooth_on 0 + adb shell settings put global wifi_on 0 + echo "Rebooting..." + adb reboot +} + +setup_quest() { + ensure_connected + echo "Disabling Guardian..." + adb shell setprop debug.oculus.guardian_pause 1 + restart_app +} + +restart_app() { + ensure_connected + echo "Restarting QuestNav..." + + # Store last known device IP + local device_ip=$(adb devices | grep -m 1 "${QUEST_PORT}" | cut -f1 | cut -d: -f1) + + # Force stop the app + adb shell am force-stop $APP_PACKAGE + + # Give USB Ethernet interface time to settle + sleep 3 + + # Start the app directly + adb shell monkey -p $APP_PACKAGE 1 + + # Wait for potential network reset + sleep 5 + + # Check if we need to reconnect + if ! adb devices | grep -q "$QUEST_PORT"; then + echo "Network reset detected, reconnecting..." + adb disconnect + sleep 2 + + # Try to reconnect multiple times with increasing delays + for i in {1..5}; do + echo "Reconnection attempt $i..." + adb connect "${device_ip}:${QUEST_PORT}" + sleep $(($i * 2)) # Increasing delay between attempts + if adb devices | grep -q "$QUEST_PORT"; then + echo -e "${GREEN}Successfully reconnected!${NC}" + return 0 + fi + done + echo -e "${RED}Failed to reconnect. The network interface might need more time to stabilize.${NC}" + echo "You can try: ./quest-tools.sh connect to reconnect manually." + return 1 + fi + + echo -e "${GREEN}App restarted successfully${NC}" +} + +redeploy() { + ensure_connected + local apk_path="$1" + + if [ -z "$apk_path" ]; then + echo -e "${RED}Error: APK path required${NC}" + echo "Usage: ./quest-tools.sh redeploy path/to/app.apk" + exit 1 + fi + + if [ ! -f "$apk_path" ]; then + echo -e "${RED}Error: APK file not found: $apk_path${NC}" + exit 1 + fi + + echo "Installing APK from: $apk_path" + adb install -r -d "$apk_path" + restart_app +} + +launch_scrcpy() { + ensure_connected + if ! command -v scrcpy &> /dev/null; then + echo -e "${RED}Error: scrcpy not found. Please install it first.${NC}" + exit 1 + fi + scrcpy --crop 1920:1080:0:0 -b 10M +} + +tail_logs() { + ensure_connected + local filter="$1" + echo "Starting log stream..." + if [ -z "$filter" ]; then + # No filter, show all Unity logs + adb logcat -s Unity:* + else + # Use exact working command format + adb logcat -s Unity:* | grep "\\${filter}" + fi +} + +# Main command handler +case "$1" in + "connect") + if [ -z "$2" ]; then + echo -e "${RED}Error: Team number required${NC}" + exit 1 + fi + check_adb + find_quest "$2" + ;; + "stopwireless") + check_adb + stopwireless + ;; + "setup") + check_adb + setup_quest + ;; + "restart") + check_adb + restart_app + ;; + "redeploy") + check_adb + redeploy "$2" + ;; + "screen") + check_adb + launch_scrcpy + ;; + "reboot") + check_adb + ensure_connected + adb reboot + ;; + "shutdown") + check_adb + ensure_connected + adb shell reboot -p + ;; + "status") + check_adb + adb devices + ;; + "logs") + check_adb + tail_logs "$2" + ;; + *|"help") + print_help + ;; +esac diff --git a/src/main/java/frc/alotobots/Constants.java b/src/main/java/frc/alotobots/Constants.java index ab614c12..acea753b 100644 --- a/src/main/java/frc/alotobots/Constants.java +++ b/src/main/java/frc/alotobots/Constants.java @@ -23,7 +23,7 @@ */ public final class Constants { /** The simulation mode to use when not running on real hardware. */ - public static final Mode simMode = Mode.SIM; + public static final Mode simMode = Mode.REPLAY; /** The current runtime mode, determined by whether running on real hardware or in simulation. */ public static final Mode currentMode = RobotBase.isReal() ? Mode.REAL : simMode; diff --git a/src/main/java/frc/alotobots/OI.java b/src/main/java/frc/alotobots/OI.java index ba1ab1c2..a2c97d7f 100644 --- a/src/main/java/frc/alotobots/OI.java +++ b/src/main/java/frc/alotobots/OI.java @@ -80,4 +80,10 @@ public static double getTurboSpeedTrigger() { /** Button for activating the pathfind to best object command. */ public static Trigger pathfindToBestObjectButton = driverController.b(); + + /** A temporary test button. */ + public static Trigger testButton = driverController.y(); + + /** A temporary test button. */ + public static Trigger testButton2 = driverController.x(); } diff --git a/src/main/java/frc/alotobots/RobotContainer.java b/src/main/java/frc/alotobots/RobotContainer.java index a3b7d2a2..b02ef6ec 100644 --- a/src/main/java/frc/alotobots/RobotContainer.java +++ b/src/main/java/frc/alotobots/RobotContainer.java @@ -12,65 +12,46 @@ */ package frc.alotobots; -import static frc.alotobots.OI.driveFacingBestObjectButton; -import static frc.alotobots.OI.pathfindToBestObjectButton; +import static frc.alotobots.OI.*; import static frc.alotobots.library.subsystems.vision.photonvision.objectdetection.constants.ObjectDetectionConstants.NOTE; import com.pathplanner.lib.auto.AutoBuilder; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.sysid.SysIdRoutine; import frc.alotobots.library.subsystems.bling.BlingSubsystem; -import frc.alotobots.library.subsystems.bling.commands.NoAllianceWaiting; -import frc.alotobots.library.subsystems.bling.commands.SetToAllianceColor; -import frc.alotobots.library.subsystems.bling.io.BlingIO; -import frc.alotobots.library.subsystems.bling.io.BlingIOReal; -import frc.alotobots.library.subsystems.bling.io.BlingIOSim; -import frc.alotobots.library.subsystems.swervedrive.ModulePosition; -import frc.alotobots.library.subsystems.swervedrive.SwerveDriveSubsystem; -import frc.alotobots.library.subsystems.swervedrive.commands.DefaultDrive; -import frc.alotobots.library.subsystems.swervedrive.commands.FeedforwardCharacterization; -import frc.alotobots.library.subsystems.swervedrive.commands.WheelRadiusCharacterization; +import frc.alotobots.library.subsystems.bling.commands.*; +import frc.alotobots.library.subsystems.bling.io.*; +import frc.alotobots.library.subsystems.swervedrive.*; +import frc.alotobots.library.subsystems.swervedrive.commands.*; import frc.alotobots.library.subsystems.swervedrive.io.*; +import frc.alotobots.library.subsystems.swervedrive.util.PathPlannerManager; +import frc.alotobots.library.subsystems.vision.localizationfusion.LocalizationFusion; +import frc.alotobots.library.subsystems.vision.localizationfusion.commands.RequestPositionResetViaTags; +import frc.alotobots.library.subsystems.vision.oculus.OculusSubsystem; +import frc.alotobots.library.subsystems.vision.oculus.io.*; +import frc.alotobots.library.subsystems.vision.oculus.util.OculusPoseSource; import frc.alotobots.library.subsystems.vision.photonvision.apriltag.AprilTagSubsystem; import frc.alotobots.library.subsystems.vision.photonvision.apriltag.constants.AprilTagConstants; -import frc.alotobots.library.subsystems.vision.photonvision.apriltag.io.AprilTagIO; -import frc.alotobots.library.subsystems.vision.photonvision.apriltag.io.AprilTagIOPhotonVision; -import frc.alotobots.library.subsystems.vision.photonvision.apriltag.io.AprilTagIOPhotonVisionSim; +import frc.alotobots.library.subsystems.vision.photonvision.apriltag.io.*; +import frc.alotobots.library.subsystems.vision.photonvision.apriltag.util.AprilTagPoseSource; import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.ObjectDetectionSubsystem; -import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.commands.DriveFacingBestObject; -import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.commands.PathfindToBestObject; +import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.commands.*; import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.constants.ObjectDetectionConstants; -import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.io.ObjectDetectionIO; -import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.io.ObjectDetectionIOPhotonVision; +import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.io.*; import org.littletonrobotics.junction.networktables.LoggedDashboardChooser; -/** - * The main container class for the robot. This class is responsible for initializing all - * subsystems, configuring button bindings, and setting up autonomous commands. It follows a - * dependency injection pattern for hardware IO to support simulation and replay modes. - */ public class RobotContainer { - /** The swerve drive subsystem that handles robot movement. */ private final SwerveDriveSubsystem swerveDriveSubsystem; - - /** The AprilTag vision subsystem for robot localization. */ + private final OculusSubsystem oculusSubsystem; private final AprilTagSubsystem aprilTagSubsystem; - - /** The object detection subsystem for game piece tracking. */ + private final LocalizationFusion localizationFusion; private final ObjectDetectionSubsystem objectDetectionSubsystem; - - /** The LED control subsystem for robot status indication. */ private final BlingSubsystem blingSubsystem; + private final PathPlannerManager pathPlannerManager; + private LoggedDashboardChooser autoChooser; - /** Dashboard chooser for selecting autonomous routines. */ - private final LoggedDashboardChooser autoChooser; - - /** - * Constructs the RobotContainer and initializes all robot subsystems and commands. Different IO - * implementations are used based on whether the robot is running in real, simulation, or replay - * mode. - */ public RobotContainer() { + switch (Constants.currentMode) { case REAL: // Real robot hardware initialization @@ -81,11 +62,26 @@ public RobotContainer() { new ModuleIOTalonFX(ModulePosition.FRONT_RIGHT.index), new ModuleIOTalonFX(ModulePosition.BACK_LEFT.index), new ModuleIOTalonFX(ModulePosition.BACK_RIGHT.index)); + pathPlannerManager = new PathPlannerManager(swerveDriveSubsystem); + configureAutoChooser(); + + oculusSubsystem = new OculusSubsystem(new OculusIOReal()); aprilTagSubsystem = new AprilTagSubsystem( - swerveDriveSubsystem::addVisionMeasurement, new AprilTagIOPhotonVision(AprilTagConstants.CAMERA_CONFIGS[0]), new AprilTagIOPhotonVision(AprilTagConstants.CAMERA_CONFIGS[1])); + + // Create pose sources + OculusPoseSource oculusPoseSource = new OculusPoseSource(oculusSubsystem); + AprilTagPoseSource aprilTagPoseSource = new AprilTagPoseSource(aprilTagSubsystem); + + localizationFusion = + new LocalizationFusion( + swerveDriveSubsystem::addVisionMeasurement, + oculusPoseSource, + aprilTagPoseSource, + autoChooser); + objectDetectionSubsystem = new ObjectDetectionSubsystem( swerveDriveSubsystem::getPose, @@ -102,13 +98,27 @@ public RobotContainer() { new ModuleIOSim(ModulePosition.FRONT_RIGHT.index), new ModuleIOSim(ModulePosition.BACK_LEFT.index), new ModuleIOSim(ModulePosition.BACK_RIGHT.index)); + pathPlannerManager = new PathPlannerManager(swerveDriveSubsystem); + configureAutoChooser(); + + oculusSubsystem = new OculusSubsystem(new OculusIOSim()); aprilTagSubsystem = new AprilTagSubsystem( - swerveDriveSubsystem::addVisionMeasurement, new AprilTagIOPhotonVisionSim( AprilTagConstants.CAMERA_CONFIGS[0], swerveDriveSubsystem::getPose), new AprilTagIOPhotonVisionSim( AprilTagConstants.CAMERA_CONFIGS[1], swerveDriveSubsystem::getPose)); + + // Create pose sources + oculusPoseSource = new OculusPoseSource(oculusSubsystem); + aprilTagPoseSource = new AprilTagPoseSource(aprilTagSubsystem); + localizationFusion = + new LocalizationFusion( + swerveDriveSubsystem::addVisionMeasurement, + oculusPoseSource, + aprilTagPoseSource, + autoChooser); + objectDetectionSubsystem = new ObjectDetectionSubsystem(swerveDriveSubsystem::getPose, new ObjectDetectionIO() {}); blingSubsystem = new BlingSubsystem(new BlingIOSim()); @@ -123,21 +133,51 @@ public RobotContainer() { new ModuleIO() {}, new ModuleIO() {}, new ModuleIO() {}); - aprilTagSubsystem = - new AprilTagSubsystem( + pathPlannerManager = new PathPlannerManager(swerveDriveSubsystem); + configureAutoChooser(); + + oculusSubsystem = new OculusSubsystem(new OculusIO() {}); + aprilTagSubsystem = new AprilTagSubsystem(new AprilTagIO() {}, new AprilTagIO() {}); + + // Create pose sources + oculusPoseSource = new OculusPoseSource(oculusSubsystem); + aprilTagPoseSource = new AprilTagPoseSource(aprilTagSubsystem); + localizationFusion = + new LocalizationFusion( swerveDriveSubsystem::addVisionMeasurement, - new AprilTagIO() {}, - new AprilTagIO() {}); + oculusPoseSource, + aprilTagPoseSource, + autoChooser); + objectDetectionSubsystem = new ObjectDetectionSubsystem(swerveDriveSubsystem::getPose, new ObjectDetectionIO() {}); blingSubsystem = new BlingSubsystem(new BlingIO() {}); break; } + configureDefaultCommands(); + configureLogicCommands(); + } + private void configureDefaultCommands() { + swerveDriveSubsystem.setDefaultCommand(new DefaultDrive(swerveDriveSubsystem).getCommand()); + blingSubsystem.setDefaultCommand( + new NoAllianceWaiting(blingSubsystem).andThen(new SetToAllianceColor(blingSubsystem))); + } + + private void configureLogicCommands() { + driveFacingBestObjectButton.toggleOnTrue( + new DriveFacingBestObject(objectDetectionSubsystem, swerveDriveSubsystem, NOTE)); + pathfindToBestObjectButton.onTrue( + new PathfindToBestObject( + objectDetectionSubsystem, swerveDriveSubsystem, pathPlannerManager, NOTE)); + testButton.onTrue(new RequestPositionResetViaTags(localizationFusion).withTimeout(1)); + } + + private void configureAutoChooser() { // Set up auto routines autoChooser = new LoggedDashboardChooser<>("Auto Choices", AutoBuilder.buildAutoChooser()); - // Set up SysId routines + // Add SysId routines autoChooser.addOption( "Drive Wheel Radius Characterization", new WheelRadiusCharacterization(swerveDriveSubsystem)); @@ -155,34 +195,8 @@ public RobotContainer() { autoChooser.addOption( "Drive SysId (Dynamic Reverse)", swerveDriveSubsystem.sysIdDynamic(SysIdRoutine.Direction.kReverse)); - - configureDefaultCommands(); - configureLogicCommands(); - } - - /** - * Configures the default commands for each subsystem. These commands run automatically when no - * other commands are scheduled for a subsystem. - */ - private void configureDefaultCommands() { - swerveDriveSubsystem.setDefaultCommand(new DefaultDrive(swerveDriveSubsystem).getCommand()); - blingSubsystem.setDefaultCommand( - new NoAllianceWaiting(blingSubsystem).andThen(new SetToAllianceColor(blingSubsystem))); - } - - /** Configures commands that are triggered by button presses or other logic conditions. */ - private void configureLogicCommands() { - driveFacingBestObjectButton.toggleOnTrue( - new DriveFacingBestObject(objectDetectionSubsystem, swerveDriveSubsystem, NOTE)); - pathfindToBestObjectButton.onTrue( - new PathfindToBestObject(objectDetectionSubsystem, swerveDriveSubsystem, NOTE)); } - /** - * Returns the command to run in autonomous mode. - * - * @return The command selected in the autonomous chooser - */ public Command getAutonomousCommand() { return autoChooser.get(); } diff --git a/src/main/java/frc/alotobots/library/subsystems/swervedrive/SwerveDriveSubsystem.java b/src/main/java/frc/alotobots/library/subsystems/swervedrive/SwerveDriveSubsystem.java index 8fbb752c..f5d5362f 100644 --- a/src/main/java/frc/alotobots/library/subsystems/swervedrive/SwerveDriveSubsystem.java +++ b/src/main/java/frc/alotobots/library/subsystems/swervedrive/SwerveDriveSubsystem.java @@ -14,10 +14,7 @@ import static edu.wpi.first.units.Units.*; -import com.pathplanner.lib.auto.AutoBuilder; -import com.pathplanner.lib.pathfinding.Pathfinding; import com.pathplanner.lib.util.DriveFeedforwards; -import com.pathplanner.lib.util.PathPlannerLogging; import com.pathplanner.lib.util.swerve.SwerveSetpoint; import com.pathplanner.lib.util.swerve.SwerveSetpointGenerator; import edu.wpi.first.hal.FRCNetComm.tInstances; @@ -34,21 +31,17 @@ import edu.wpi.first.math.kinematics.SwerveModuleState; import edu.wpi.first.math.numbers.N1; import edu.wpi.first.math.numbers.N3; -import edu.wpi.first.units.measure.LinearVelocity; import edu.wpi.first.wpilibj.Alert; import edu.wpi.first.wpilibj.Alert.AlertType; import edu.wpi.first.wpilibj.DriverStation; -import edu.wpi.first.wpilibj.DriverStation.Alliance; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.SubsystemBase; import edu.wpi.first.wpilibj2.command.sysid.SysIdRoutine; -import frc.alotobots.AutoNamedCommands; import frc.alotobots.Constants; import frc.alotobots.Constants.Mode; import frc.alotobots.library.subsystems.swervedrive.io.GyroIO; import frc.alotobots.library.subsystems.swervedrive.io.GyroIOInputsAutoLogged; import frc.alotobots.library.subsystems.swervedrive.io.ModuleIO; -import frc.alotobots.util.LocalADStarAK; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import org.littletonrobotics.junction.AutoLogOutput; @@ -134,28 +127,6 @@ public SwerveDriveSubsystem( // Initialize and start odometry thread PhoenixOdometryThread.getInstance().start(); - // Configure AutoBuilder for PathPlanner - AutoBuilder.configure( - this::getPose, - this::setPose, - this::getChassisSpeeds, - this::runVelocity, - Constants.tunerConstants.getHolonomicDriveController(), - Constants.tunerConstants.getPathPlannerConfig(), - () -> DriverStation.getAlliance().orElse(Alliance.Blue) == Alliance.Red, - this); - AutoNamedCommands.setupNamedCommands(); // Setup the named commands - Pathfinding.setPathfinder(new LocalADStarAK()); - PathPlannerLogging.setLogActivePathCallback( - (activePath) -> { - Logger.recordOutput( - "Odometry/Trajectory", activePath.toArray(new Pose2d[activePath.size()])); - }); - PathPlannerLogging.setLogTargetPoseCallback( - (targetPose) -> { - Logger.recordOutput("Odometry/TrajectorySetpoint", targetPose); - }); - setpointGenerator = new SwerveSetpointGenerator( Constants.tunerConstants.getPathPlannerConfig(), @@ -331,7 +302,7 @@ private SwerveModulePosition[] getModulePositions() { * @return Current chassis speeds */ @AutoLogOutput(key = "SwerveChassisSpeeds/Measured") - private ChassisSpeeds getChassisSpeeds() { + public ChassisSpeeds getChassisSpeeds() { return kinematics.toChassisSpeeds(getModuleStates()); } @@ -421,16 +392,4 @@ public double getMaxLinearSpeedMetersPerSec() { public double getMaxAngularSpeedRadPerSec() { return getMaxLinearSpeedMetersPerSec() / Constants.tunerConstants.getDriveBaseRadius(); } - - /** - * Creates a pathfinding command to the specified pose. - * - * @param target Target pose - * @param velocity Target velocity - * @return Pathfinding command - */ - public Command getPathFinderCommand(Pose2d target, LinearVelocity velocity) { - return AutoBuilder.pathfindToPose( - target, Constants.tunerConstants.getPathfindingConstraints(), velocity); - } } diff --git a/src/main/java/frc/alotobots/library/subsystems/swervedrive/commands/FeedforwardCharacterization.java b/src/main/java/frc/alotobots/library/subsystems/swervedrive/commands/FeedforwardCharacterization.java index a05954a6..8f57a788 100644 --- a/src/main/java/frc/alotobots/library/subsystems/swervedrive/commands/FeedforwardCharacterization.java +++ b/src/main/java/frc/alotobots/library/subsystems/swervedrive/commands/FeedforwardCharacterization.java @@ -51,7 +51,7 @@ public void initialize() { velocitySamples.clear(); voltageSamples.clear(); characterizationStarted = false; - startTime = Timer.getFPGATimestamp(); + startTime = Timer.getTimestamp(); timer.restart(); } diff --git a/src/main/java/frc/alotobots/library/subsystems/swervedrive/util/PathPlannerManager.java b/src/main/java/frc/alotobots/library/subsystems/swervedrive/util/PathPlannerManager.java new file mode 100644 index 00000000..92178c46 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/swervedrive/util/PathPlannerManager.java @@ -0,0 +1,112 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.swervedrive.util; + +import com.pathplanner.lib.auto.AutoBuilder; +import com.pathplanner.lib.pathfinding.Pathfinding; +import com.pathplanner.lib.util.PathPlannerLogging; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.units.measure.LinearVelocity; +import edu.wpi.first.wpilibj.DriverStation; +import edu.wpi.first.wpilibj.DriverStation.Alliance; +import edu.wpi.first.wpilibj2.command.Command; +import frc.alotobots.AutoNamedCommands; +import frc.alotobots.Constants; +import frc.alotobots.library.subsystems.swervedrive.SwerveDriveSubsystem; +import frc.alotobots.util.LocalADStarAK; +import org.littletonrobotics.junction.Logger; + +/** + * Manages PathPlanner integration for autonomous path following and path finding. Abstracts path + * planning functionality from the SwerveDriveSubsystem. + */ +public class PathPlannerManager { + private final SwerveDriveSubsystem driveSubsystem; + + /** + * Creates a new PathPlannerManager. + * + * @param driveSubsystem The swerve drive subsystem to control + */ + public PathPlannerManager(SwerveDriveSubsystem driveSubsystem) { + this.driveSubsystem = driveSubsystem; + configurePathPlanner(); + } + + /** Configures PathPlanner with necessary callbacks and settings. */ + private void configurePathPlanner() { + // Configure AutoBuilder for PathPlanner + AutoBuilder.configure( + driveSubsystem::getPose, + driveSubsystem::setPose, + driveSubsystem::getChassisSpeeds, + driveSubsystem::runVelocity, + Constants.tunerConstants.getHolonomicDriveController(), + Constants.tunerConstants.getPathPlannerConfig(), + () -> DriverStation.getAlliance().orElse(Alliance.Blue) == Alliance.Red, + driveSubsystem); + + // Setup named commands for autonomous routines + AutoNamedCommands.setupNamedCommands(); + + // Configure pathfinding + Pathfinding.setPathfinder(new LocalADStarAK()); + + // Setup logging callbacks + configureLogging(); + } + + /** Configures PathPlanner logging callbacks. */ + private void configureLogging() { + PathPlannerLogging.setLogActivePathCallback( + (activePath) -> { + Logger.recordOutput( + "Odometry/Trajectory", activePath.toArray(new Pose2d[activePath.size()])); + }); + + PathPlannerLogging.setLogTargetPoseCallback( + (targetPose) -> { + Logger.recordOutput("Odometry/TrajectorySetpoint", targetPose); + }); + } + + /** + * Creates a pathfinding command to the specified pose. + * + * @param target Target pose + * @param velocity Target velocity + * @return Pathfinding command + */ + public Command getPathFinderCommand(Pose2d target, LinearVelocity velocity) { + return AutoBuilder.pathfindToPose( + target, Constants.tunerConstants.getPathfindingConstraints(), velocity); + } + + /** + * Gets maximum linear speed capability. + * + * @return Maximum speed in meters per second + */ + public double getMaxLinearSpeedMetersPerSec() { + return driveSubsystem.getMaxLinearSpeedMetersPerSec(); + } + + /** + * Gets maximum angular speed capability. + * + * @return Maximum angular speed in radians per second + */ + public double getMaxAngularSpeedRadPerSec() { + return driveSubsystem.getMaxAngularSpeedRadPerSec(); + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationFusion.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationFusion.java new file mode 100644 index 00000000..e9512eab --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationFusion.java @@ -0,0 +1,1211 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion; + +import static frc.alotobots.library.subsystems.vision.localizationfusion.LocalizationFusionConstants.InitializationRequirements.*; +import static frc.alotobots.library.subsystems.vision.localizationfusion.LocalizationFusionConstants.Timing.*; +import static frc.alotobots.library.subsystems.vision.localizationfusion.LocalizationFusionConstants.ValidationThresholds.*; + +import com.pathplanner.lib.auto.AutoBuilder; +import com.pathplanner.lib.commands.PathPlannerAuto; +import com.pathplanner.lib.util.FlippingUtil; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.wpilibj.DriverStation; +import edu.wpi.first.wpilibj.Timer; +import edu.wpi.first.wpilibj.shuffleboard.Shuffleboard; +import edu.wpi.first.wpilibj.shuffleboard.ShuffleboardTab; +import edu.wpi.first.wpilibj2.command.Command; +import edu.wpi.first.wpilibj2.command.SubsystemBase; +import frc.alotobots.library.subsystems.vision.oculus.util.OculusPoseSource; +import frc.alotobots.library.subsystems.vision.photonvision.apriltag.util.AprilTagPoseSource; +import frc.alotobots.util.Elastic; +import org.littletonrobotics.junction.Logger; +import org.littletonrobotics.junction.networktables.LoggedDashboardChooser; + +/** + * The LocalizationFusion subsystem manages robot pose estimation by fusing data from multiple + * sources. It primarily uses Oculus Quest SLAM for continuous tracking, with AprilTag detection as + * a backup and validation source. The system handles initialization, validation, and switching + * between sources based on their availability and reliability. + */ +public class LocalizationFusion extends SubsystemBase implements StateTransitionLogger { + + // -------------------- Dependencies -------------------- + /** Interface for consuming pose updates from the localization system. */ + private final PoseVisionConsumer poseConsumer; + + /** Primary pose source using Oculus Quest SLAM tracking. */ + private final OculusPoseSource oculusSource; + + /** Secondary pose source using AprilTag vision tracking. */ + private final AprilTagPoseSource tagSource; + + /** State machine managing transitions between different localization states. */ + private final LocalizationState state; + + /** Auto chooser for fallback pose setting */ + LoggedDashboardChooser autoChooser; + + /** Tab for drivers to ensure readiness */ + private final ShuffleboardTab prematchTab = Shuffleboard.getTab("Prematch"); + + // -------------------- State Variables -------------------- + /** Timestamp of the last pose update processed. */ + private double lastUpdateTime = 0.0; + + /** Timestamp of the last valid Quest pose update. */ + private double lastQuestUpdate = 0.0; + + /** Timestamp when the current reset sequence started. */ + private double resetStartTime = 0.0; + + // Connection State + /** Previous DriverStation connection state. */ + private boolean wasConnected = false; + + /** Tracks whether we've had our initial DriverStation connection. */ + private boolean hadInitialConnection = false; + + /** Indicates if an initial reset is needed due to pre-connected DriverStation. */ + private boolean needsInitialReset = false; + + // Quest State + /** Timestamp when Quest initialization started. */ + private double questInitStartTime = 0.0; + + /** Counter for consecutive valid Quest pose updates during initialization. */ + private int consecutiveValidQuestUpdates = 0; + + /** Last valid Quest pose during initialization. */ + private Pose2d lastQuestInitPose = null; + + /** Indicates if Quest initialization is complete. */ + private boolean questInitialized = false; + + /** Timestamp of the last Quest disconnection. */ + private double lastQuestDisconnectTime = 0.0; + + /** Indicates if Quest had a previous valid calibration before disconnection. */ + private boolean hadPreviousCalibration = false; + + // AprilTag State + /** Timestamp when AprilTag initialization started. */ + private double tagInitStartTime = 0.0; + + /** Counter for consecutive valid AprilTag pose updates during initialization. */ + private int consecutiveValidTagUpdates = 0; + + /** Last valid AprilTag pose during initialization. */ + private Pose2d lastTagInitPose = null; + + /** Indicates if AprilTag initialization is complete. */ + private boolean tagInitialized = false; + + // Pose Validation + /** Timestamp when initial pose validation started. */ + private double initialPoseValidationStartTime = 0.0; + + /** Last validated pose during initialization or disabled state. */ + private Pose2d lastValidatedPose = null; + + /** Indicates if initial pose validation is complete. */ + private boolean initialPoseValidated = false; + + /** Stores the previous and current auto selection state for comparison */ + private String previousAutoSelection = ""; + + private String currentAutoSelection = ""; + + /** Timestamp when stability check started. */ + private double stabilityStartTime = 0.0; + + /** Timestamp of the last auto-realignment. */ + private double lastAutoRealignTime = 0.0; + + /** Pose when stability check started. */ + private Pose2d stabilityStartPose = null; + + // Separate flags for pose validation sources + private boolean hasTagValidatedPose = false; // Set only when we get AprilTag validation + private boolean hasAutoPose = false; // Set when we accept an auto pose + + // -------------------- Constructor -------------------- + /** + * Creates a new LocalizationFusion subsystem. + * + * @param poseConsumer The consumer interface that will receive pose updates + * @param oculusSource The primary pose source using Quest SLAM + * @param aprilTagSource The secondary pose source using AprilTags + */ + public LocalizationFusion( + PoseVisionConsumer poseConsumer, + OculusPoseSource oculusSource, + AprilTagPoseSource aprilTagSource, + LoggedDashboardChooser autoChooser) { + this.poseConsumer = poseConsumer; + this.oculusSource = oculusSource; + this.tagSource = aprilTagSource; + this.autoChooser = autoChooser; + this.state = new LocalizationState(this); + initializeConnectionState(); + setupShuffleboardLogging(); + } + + // -------------------- Core System Methods -------------------- + /** + * Sets up Shuffleboard logging for critical system status indicators in the /Prematch tab. + * Configures three status indicators arranged vertically: + * + *

1. "Quest Ready" - Indicates if the Oculus Quest SLAM system is connected and initialized + * + *

2. "Tags Ready" - Indicates if the AprilTag vision system is connected and initialized + * + *

3. "System Ready" - Indicates if both localization systems are ready and not in emergency + * state + * + *

Each indicator is configured with a size of 2x1 grid units and positioned vertically in the + * Prematch tab. The indicators automatically update based on system state changes and will show + * as green when true and red when false. + * + *

Layout: + * + *

+   * [Quest Ready]
+   * [Tags Ready]
+   * [System Ready]
+   * 
+ */ + private void setupShuffleboardLogging() { + prematchTab + .addBoolean("Quest Ready", () -> oculusSource.isConnected() && questInitialized) + .withSize(2, 1) + .withPosition(0, 0); + + prematchTab + .addBoolean("Tags Ready", () -> tagSource.isConnected() && tagInitialized) + .withSize(2, 1) + .withPosition(0, 1); + + prematchTab + .addBoolean( + "System Ready", + () -> + (oculusSource.isConnected() && questInitialized) + && (tagSource.isConnected() && tagInitialized) + && state.getCurrentState() != LocalizationState.State.EMERGENCY) + .withSize(2, 1) + .withPosition(0, 2); + } + + /** + * Gets the starting pose from the currently selected autonomous routine in PathPlanner. + * + * @return The starting Pose2d from the selected auto, or null if none available + */ + private Pose2d getAutoStartingPose() { + try { + String autoName = autoChooser.getSendableChooser().getSelected(); + if (autoName.equals("None")) { + Logger.recordOutput("LocalizationFusion/Event", "No auto selected"); + return null; + } + + Logger.recordOutput("LocalizationFusion/SelectedAuto", autoName); + + var autoPath = PathPlannerAuto.getPathGroupFromAutoFile(autoName); + if (autoPath.isEmpty()) { + Logger.recordOutput("LocalizationFusion/Event", "No paths found in auto: " + autoName); + return null; + } + + var startPose = autoPath.get(0).getStartingHolonomicPose(); + if (startPose.isPresent()) { + if (AutoBuilder.shouldFlip()) { + return FlippingUtil.flipFieldPose(startPose.get()); + } else { + return startPose.get(); + } + } else { + Logger.recordOutput( + "LocalizationFusion/Event", "No starting pose defined in auto: " + autoName); + return null; + } + } catch (Exception e) { + Logger.recordOutput( + "LocalizationFusion/Event", "Error getting auto starting pose: " + e.getMessage()); + return null; + } + } + + /** + * Periodic update method called by the command scheduler. Handles system state updates, source + * initialization, and pose processing. + */ + @Override + public void periodic() { + logSystemStatus(); + handleDriverStationConnection(); + updateAutoSelection(); // Automatically set pose to auto path start pose if we don't have any + // other valid poses + + // Handle initialization of both sources + if (!questInitialized && oculusSource.isConnected()) { + handleQuestInitialization(); + } + if (!tagInitialized && tagSource.isConnected()) { + handleTagInitialization(); + } + + // State-specific handling + double currentTime = Timer.getTimestamp(); + switch (state.getCurrentState()) { + case RESETTING: + handleResettingState(); + break; + case EMERGENCY: + handleEmergencyState(); + break; + case QUEST_PRIMARY: + handleQuestPrimaryState(currentTime); + break; + case TAG_BACKUP: + handleTagBackupState(currentTime); + break; + case UNINITIALIZED: + handleUninitializedState(); + break; + } + + logDetailedStatus(); + } + + // -------------------- State Handlers -------------------- + /** + * Handles the RESETTING state where the system is attempting to align Quest and AprilTag poses. + * Monitors timeout conditions and validates pose alignment before transitioning to primary state. + */ + private void handleResettingState() { + if (resetStartTime > 0 && Timer.getTimestamp() - resetStartTime > RESET_TIMEOUT) { + Logger.recordOutput( + "LocalizationFusion/Event", "Reset sequence timed out - switching to emergency mode"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("Reset Failed") + .withDescription("Reset sequence timed out - Check vision systems") + .withDisplaySeconds(10.0)); + resetStartTime = 0.0; + resetQuestInitialization(); + state.transitionTo(LocalizationState.State.EMERGENCY); + return; + } + + // If we have no tag pose, but Quest is connected and initialized + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose == null) { + // If this was triggered by auto selection and Quest is ready, + // we can proceed without tag validation + if (!hasTagValidatedPose && oculusSource.isConnected() && !isResetInProgress()) { + Pose2d questPose = oculusSource.getCurrentPose(); + if (questPose != null) { + resetStartTime = 0.0; + lastQuestUpdate = Timer.getTimestamp(); + state.transitionTo(LocalizationState.State.QUEST_PRIMARY); + return; + } + } + + if (resetStartTime == 0.0) { + resetStartTime = Timer.getTimestamp(); + } + return; + } + + if (!oculusSource.isConnected() || !tagSource.isConnected()) { + return; + } + + if (isResetInProgress()) { + return; + } + + Pose2d questPose = oculusSource.getCurrentPose(); + if (questPose == null) { + return; + } + + double poseError = questPose.getTranslation().getDistance(tagPose.getTranslation()); + if (poseError <= INIT_VALIDATION_THRESHOLD) { + resetStartTime = 0.0; + lastQuestUpdate = Timer.getTimestamp(); + hasTagValidatedPose = true; // Now we have tag validation + state.transitionTo(LocalizationState.State.QUEST_PRIMARY); + } + } + + /** + * Handles the EMERGENCY state where normal operation has failed. Attempts to recover using + * AprilTag detection and reinitialize the system. + */ + private void handleEmergencyState() { + if (tagSource.isConnected()) { + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose != null) { + Logger.recordOutput( + "LocalizationFusion/Event", + "Valid AprilTag pose detected in EMERGENCY state - attempting recovery"); + + if (oculusSource.isConnected() && resetToPose(tagPose)) { + resetStartTime = Timer.getTimestamp(); + state.transitionTo(LocalizationState.State.RESETTING); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Recovery Attempt") + .withDescription("Valid AprilTags found - Attempting to restore tracking") + .withDisplaySeconds(3.0)); + } else { + // If Quest isn't available, fall back to tag-only tracking + state.transitionTo(LocalizationState.State.TAG_BACKUP); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Partial Recovery") + .withDescription("Switching to AprilTag-only tracking") + .withDisplaySeconds(3.0)); + } + } + } + } + + /** + * Handles the QUEST_PRIMARY state where Quest SLAM is the primary pose source. Validates poses + * and handles Quest disconnection scenarios. + * + * @param currentTime The current system timestamp + */ + private void handleQuestPrimaryState(double currentTime) { + if (currentTime - lastUpdateTime < POSE_UPDATE_INTERVAL) { + return; + } + lastUpdateTime = currentTime; + + if (!oculusSource.isConnected()) { + if (questInitialized) { + lastQuestDisconnectTime = Timer.getTimestamp(); + hadPreviousCalibration = true; + lastQuestInitPose = oculusSource.getCurrentPose(); + } + state.transitionTo(LocalizationState.State.TAG_BACKUP); + return; + } + + Pose2d questPose = oculusSource.getCurrentPose(); + if (questPose != null) { + if (validatePose(questPose, false)) { + // Try auto realign if its enabled + if (LocalizationFusionConstants.AutoRealignConstants.ENABLED) { + tryAutoRealign(currentTime); + } + + poseConsumer.accept(questPose, currentTime, oculusSource.getStdDevs()); + lastQuestUpdate = currentTime; + } else { + if (currentTime - lastQuestUpdate > QUEST_INIT_TIMEOUT) { + state.transitionTo(LocalizationState.State.TAG_BACKUP); + } + } + } + } + + /** + * Handles the TAG_BACKUP state where AprilTag detection is the primary pose source. Attempts + * Quest reconnection when possible and processes AprilTag poses. + * + * @param currentTime The current system timestamp + */ + private void handleTagBackupState(double currentTime) { + if (currentTime - lastUpdateTime < POSE_UPDATE_INTERVAL) { + return; + } + lastUpdateTime = currentTime; + + if (oculusSource.isConnected() && hadPreviousCalibration) { + + Pose2d questPose = oculusSource.getCurrentPose(); + if (validatePose(questPose, true)) { + state.transitionTo(LocalizationState.State.QUEST_PRIMARY); + Logger.recordOutput( + "LocalizationFusion/Event", + "Quest Reconnected; Pose Validated - maintaining Quest tracking"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Quest Reconnected") + .withDescription("Quest tracking restored - Pose validated") + .withDisplaySeconds(3.0)); + return; + } else { + // Re-init as we couldn't validate pose + Logger.recordOutput( + "LocalizationFusion/Event", + "Quest Reconnected; Pose Failed to Validate - reinitializing Quest tracking"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Quest Validation Failed") + .withDescription("Quest reconnected but pose invalid - Reinitializing") + .withDisplaySeconds(3.0)); + handleQuestInitialization(); + } + } + + if (oculusSource.isConnected() && !hadPreviousCalibration) { + handleQuestInitialization(); + return; + } + + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose != null) { + poseConsumer.accept(tagPose, currentTime, tagSource.getStdDevs()); + } + } + + /** + * Handles the UNINITIALIZED state where the system is starting up. Attempts to initialize using + * AprilTag detection. + */ + private void handleUninitializedState() { + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose != null) { + if (DriverStation.isDSAttached() && resetToPose(tagPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + } + } + } + + // -------------------- Initialization Methods -------------------- + /** + * Handles the initialization of the Quest SLAM system. Manages calibration, validation, and state + * transitions during Quest startup. + */ + private void handleQuestInitialization() { + if (!oculusSource.isConnected()) { + if (questInitialized) { + lastQuestDisconnectTime = Timer.getTimestamp(); + hadPreviousCalibration = true; + lastQuestInitPose = oculusSource.getCurrentPose(); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("Quest Disconnected") + .withDescription("Quest tracking system disconnected - Check USB connection") + .withDisplaySeconds(10.0)); + } + resetQuestInitialization(); + return; + } + + // Skip validation during mid-match reboot + if (isMidMatchReboot()) { + questInitialized = true; + lastQuestUpdate = Timer.getTimestamp(); + state.transitionTo(LocalizationState.State.QUEST_PRIMARY); + return; + } + + Pose2d questPose = oculusSource.getCurrentPose(); + if (questPose == null) { + return; + } + + if (questInitStartTime == 0.0) { + questInitStartTime = Timer.getTimestamp(); + lastQuestInitPose = questPose; + return; + } + + if (isNewPoseValid(questPose, lastQuestInitPose, INIT_VALIDATION_THRESHOLD)) { + consecutiveValidQuestUpdates++; + lastQuestInitPose = questPose; + } else { + consecutiveValidQuestUpdates = 0; + } + + double initDuration = Timer.getTimestamp() - questInitStartTime; + if (consecutiveValidQuestUpdates >= MIN_QUEST_VALID_UPDATES + && initDuration >= QUEST_INIT_TIMEOUT) { + questInitialized = true; + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Quest Initialized") + .withDescription("Quest tracking system successfully initialized") + .withDisplaySeconds(3.0)); + Pose2d referencePose = getValidReferencePose(); + if (referencePose != null && resetToPose(referencePose)) { + state.transitionTo(LocalizationState.State.RESETTING); + resetStartTime = Timer.getTimestamp(); + } + } else if (initDuration > QUEST_INIT_TIMEOUT * QUEST_INIT_GRACE_MULTIPLIER) { + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("Quest Init Failed") + .withDescription("Quest initialization timed out - Check headset") + .withDisplaySeconds(10.0)); + resetQuestInitialization(); + } + } + + /** + * Handles the initialization of the AprilTag vision system. Manages detection validation and + * state transitions during AprilTag startup. + */ + private void handleTagInitialization() { + if (!tagSource.isConnected()) { + if (tagInitialized) { + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("AprilTag System Disconnected") + .withDescription("AprilTag tracking system disconnected - Check camera connection") + .withDisplaySeconds(10.0)); + } + resetTagInitialization(); + return; + } + + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose == null) { + return; + } + + if (tagInitStartTime == 0.0) { + tagInitStartTime = Timer.getTimestamp(); + lastTagInitPose = tagPose; + return; + } + + if (isNewPoseValid(tagPose, lastTagInitPose, INIT_VALIDATION_THRESHOLD)) { + consecutiveValidTagUpdates++; + lastTagInitPose = tagPose; + } else { + consecutiveValidTagUpdates = 0; + } + + double initDuration = Timer.getTimestamp() - tagInitStartTime; + if (consecutiveValidTagUpdates >= MIN_TAG_VALID_UPDATES && initDuration >= TAG_INIT_TIMEOUT) { + tagInitialized = true; + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("AprilTag System Ready") + .withDescription("AprilTag tracking system successfully initialized") + .withDisplaySeconds(3.0)); + + if (!questInitialized || !shouldPreferQuest()) { + state.transitionTo(LocalizationState.State.TAG_BACKUP); + } + } else if (initDuration > TAG_INIT_TIMEOUT * QUEST_INIT_GRACE_MULTIPLIER) { + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("AprilTag Init Failed") + .withDescription("AprilTag initialization timed out - Check camera view of tags") + .withDisplaySeconds(3.0)); + resetTagInitialization(); + } + } + + // -------------------- Connection Management -------------------- + /** + * Checks if we're rebooting during an active match by examining match time. A non-zero match time + * indicates the FMS->DS->Robot connection is active and we're in a match that was already + * running. + * + * @return true if this appears to be a mid-match reboot + */ + private boolean isMidMatchReboot() { + return DriverStation.isEnabled() && Timer.getMatchTime() > 0; + } + + /** + * Initializes the DriverStation connection state tracking. Called during subsystem construction + * to handle pre-connected scenarios and mid-match reboots. + */ + private void initializeConnectionState() { + wasConnected = DriverStation.isDSAttached(); + hadInitialConnection = wasConnected; + + if (isMidMatchReboot()) { + Logger.recordOutput( + "LocalizationFusion/Event", "Mid-match reboot detected - maintaining Quest tracking"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Mid-Match Reboot") + .withDescription("Maintaining Quest tracking after reboot") + .withDisplaySeconds(3.0)); + // Skip initialization and trust Quest's maintained tracking + questInitialized = true; + hadPreviousCalibration = true; + + if (oculusSource.isConnected()) { + // Go straight to Quest primary if available + state.transitionTo(LocalizationState.State.QUEST_PRIMARY); + } else { + // Fallback to tags if Quest isn't ready yet + state.transitionTo(LocalizationState.State.TAG_BACKUP); + } + } else if (hadInitialConnection) { + state.transitionTo(LocalizationState.State.UNINITIALIZED); + needsInitialReset = true; + } + } + + /** + * Handles DriverStation connection state changes and triggers appropriate system responses. + * Manages initial connection validation and pose recalibration during disabled state. + */ + private void handleDriverStationConnection() { + boolean isConnected = DriverStation.isDSAttached(); + + if (!wasConnected && isConnected) { + if (!hadInitialConnection) { + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Initial Connection") + .withDescription("Driver Station connected - Starting pose validation") + .withDisplaySeconds(3.0)); + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose != null) { + lastValidatedPose = tagPose; + initialPoseValidationStartTime = Timer.getTimestamp(); + initialPoseValidated = false; + } + hadInitialConnection = true; + } else { + Logger.recordOutput( + "LocalizationFusion/Event", "DriverStation reconnected - maintaining current pose"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Connection Restored") + .withDescription("Driver Station reconnected - Maintaining current pose") + .withDisplaySeconds(3.0)); + } + } + + if (isConnected && DriverStation.isDisabled()) { + handleDisabledPoseValidation(); + } + + wasConnected = isConnected; + } + + // -------------------- Validation Methods -------------------- + /** + * Handles pose validation during the disabled state. Only performs validation and resets before + * the match starts to avoid disrupting auto-to-teleop transitions. + */ + private void handleDisabledPoseValidation() { + // Skip disabled validation if match has started + if (DriverStation.getMatchTime() > 0) { + return; + } + + Pose2d currentTagPose = tagSource.getCurrentPose(); + + // If we're in EMERGENCY state and we see tags, attempt to recover + if (state.getCurrentState() == LocalizationState.State.EMERGENCY && currentTagPose != null) { + Logger.recordOutput( + "LocalizationFusion/Event", + "Tags detected while in EMERGENCY state during disabled - attempting recovery"); + if (resetToPose(currentTagPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + resetStartTime = Timer.getTimestamp(); + return; + } + } + + if (currentTagPose == null) return; + + if (!initialPoseValidated) { + if (Timer.getTimestamp() - initialPoseValidationStartTime >= INITIAL_POSE_STABILITY_TIME) { + if (lastValidatedPose != null) { + double poseChange = + currentTagPose.getTranslation().getDistance(lastValidatedPose.getTranslation()); + + if (poseChange <= INIT_VALIDATION_THRESHOLD) { + Logger.recordOutput( + "LocalizationFusion/Event", + "Initial pose validated after stability period - initiating reset"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Initial Pose Validated") + .withDescription("Robot position confirmed stable - Starting tracking") + .withDisplaySeconds(3.0)); + if (resetToPose(currentTagPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + resetStartTime = Timer.getTimestamp(); + initialPoseValidated = true; + } + } else { + Logger.recordOutput( + "LocalizationFusion/Event", + "Pose changed during validation - restarting stability timer"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Position Changed") + .withDescription("Robot moved during validation - Restarting stability check") + .withDisplaySeconds(3.0)); + initialPoseValidationStartTime = Timer.getTimestamp(); + lastValidatedPose = currentTagPose; + } + } + } + lastValidatedPose = currentTagPose; + return; + } + + // Only check for pose changes requiring recalibration before match starts + if (lastValidatedPose != null) { + double poseChange = + currentTagPose.getTranslation().getDistance(lastValidatedPose.getTranslation()); + + if (poseChange >= DISABLED_RECALIBRATION_THRESHOLD) { + Logger.recordOutput( + "LocalizationFusion/Event", + String.format( + "Significant pre-match pose change detected while disabled (%.2fm) - recalibrating", + poseChange)); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Pre-Match Position Changed") + .withDescription( + String.format( + "Robot moved %.2f meters while disabled - Recalibrating", poseChange)) + .withDisplaySeconds(3.0)); + + initialPoseValidationStartTime = Timer.getTimestamp(); + lastValidatedPose = currentTagPose; + initialPoseValidated = false; + + // Attempt immediate reset with new pose + if (resetToPose(currentTagPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + resetStartTime = Timer.getTimestamp(); + } + } + } + + lastValidatedPose = currentTagPose; + } + + /** + * Validates a pose against the current AprilTag reference. Uses different thresholds for + * initialization and normal operation. + * + * @param pose The pose to validate + * @param strict True if we require tags to be present for validation, false to assume valid with + * no tags + * @return true if the pose is valid, false otherwise + */ + private boolean validatePose(Pose2d pose, boolean strict) { + if (pose == null) return false; + + Pose2d tagPose = tagSource.getCurrentPose(); + if (tagPose == null) { + return !strict; + } + + double poseError = tagPose.getTranslation().getDistance(pose.getTranslation()); + boolean isValid; + + if (!questInitialized) { + isValid = poseError <= INIT_VALIDATION_THRESHOLD; + } else { + isValid = poseError <= APRILTAG_VALIDATION_THRESHOLD; + } + + if (isValid) { + hasTagValidatedPose = true; // Update this instead of hasValidPoseOffset + } + + return isValid; + } + + /** + * Validates a new pose against a previous pose using maximum change thresholds. + * + * @param newPose The new pose to validate + * @param lastPose The previous pose to compare against + * @param maxChange The maximum allowed position change in meters + * @return true if the pose change is within acceptable limits + */ + private boolean isNewPoseValid(Pose2d newPose, Pose2d lastPose, double maxChange) { + if (lastPose == null) return true; + + double poseChange = newPose.getTranslation().getDistance(lastPose.getTranslation()); + double rotationChange = + Math.abs(newPose.getRotation().minus(lastPose.getRotation()).getDegrees()); + + return poseChange <= maxChange && rotationChange <= MAX_ROTATION_CHANGE_DEGREES; + } + + /** + * Gets a valid reference pose from the AprilTag system if available. + * + * @return A valid reference Pose2d or null if unavailable + */ + private Pose2d getValidReferencePose() { + if (tagInitialized) { + return tagSource.getCurrentPose(); + } + return null; + } + + // -------------------- Reset Methods -------------------- + /** + * Attempts to perform an auto-realignment if conditions are met. + * + * @param currentTime the current system timestamp + * @return true if realignment was attempted + */ + private boolean tryAutoRealign(double currentTime) { + + if (!LocalizationFusionConstants.AutoRealignConstants.ENABLED + || !DriverStation.isTeleopEnabled()) { + return false; + } + + // Check cooldown period + if (currentTime - lastAutoRealignTime + < LocalizationFusionConstants.AutoRealignConstants.COOLDOWN) { + return false; + } + + // Get current poses + Pose2d questPose = oculusSource.getCurrentPose(); + Pose2d tagPose = tagSource.getCurrentPose(); + + if (questPose == null || tagPose == null) { + lastAutoRealignTime = currentTime; // Rate limit so we arent dumping calls + return false; + } + + // Verify stability + if (!checkStability(questPose, currentTime)) { + lastAutoRealignTime = currentTime; // Rate limit so we arent dumping calls + return false; + } + + // Check pose error + double poseError = questPose.getTranslation().getDistance(tagPose.getTranslation()); + if (poseError > LocalizationFusionConstants.AutoRealignConstants.THRESHOLD) { + String message = String.format("Auto-realignment triggered (error: %.2fm)", poseError); + Logger.recordOutput("LocalizationFusion/Event", message); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Auto Realignment") + .withDescription(message) + .withDisplaySeconds(3.0)); + + if (resetToPose(tagPose)) { + lastAutoRealignTime = currentTime; // Rate limit so we arent dumping calls + state.transitionTo(LocalizationState.State.RESETTING); + resetStartTime = currentTime; + // Reset stability tracking after realignment + stabilityStartTime = 0.0; + stabilityStartPose = null; + return true; + } + } else { + lastAutoRealignTime = currentTime; // Rate limit so we arent dumping calls + Logger.recordOutput( + "LocalizationFusion/Event", + String.format("Auto-realignment skipped! (error: %.2fm)", poseError)); + } + + return false; + } + + /** + * Attempts to reset the Quest pose to match a target pose. + * + * @param pose The target pose to reset to + * @return true if reset was initiated successfully + */ + private boolean resetToPose(Pose2d pose) { + return oculusSource.subsystem.resetToPose(pose); + } + + /** + * Checks if a pose reset operation is currently in progress. + * + * @return true if a reset is in progress + */ + private boolean isResetInProgress() { + return oculusSource.subsystem.isPoseResetInProgress(); + } + + /** + * Manually triggers a pose reset using the current AprilTag pose. + * + * @return true if reset was initiated successfully + */ + public boolean requestResetOculusPoseViaAprilTags() { + Pose2d currentTagPose = tagSource.getCurrentPose(); + if (currentTagPose == null) { + Logger.recordOutput( + "LocalizationFusion/Event", "Manual reset failed - no AprilTag pose available"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("Reset Failed") + .withDescription("Manual reset failed - No AprilTags visible") + .withDisplaySeconds(3.0)); + return false; + } + + Logger.recordOutput( + "LocalizationFusion/Event", + "Manual reset requested using AprilTag pose - initiating pose reset"); + if (resetToPose(currentTagPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + hasTagValidatedPose = true; + return true; + } + return false; + } + + /** + * Manually triggers a pose reset to a specific pose. + * + * @param targetPose The pose to reset to + * @return true if reset was initiated successfully + */ + public boolean requestResetOculusPose(Pose2d targetPose) { + if (targetPose == null) { + Logger.recordOutput("LocalizationFusion/Event", "Manual reset failed - null pose provided"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("Reset Failed") + .withDescription("Manual reset failed - Invalid pose provided") + .withDisplaySeconds(3.0)); + return false; + } + + Logger.recordOutput( + "LocalizationFusion/Event", + "Manual reset requested with custom pose - initiating pose reset"); + if (resetToPose(targetPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + hasTagValidatedPose = true; // Set on manual reset + return true; + } + return false; + } + + // -------------------- State Management Helpers -------------------- + /** + * Checks if the robot has been stable (minimal pose change) for the required time period. + * + * @param currentPose the current robot pose + * @param currentTime the current system timestamp + * @return true if the robot has been stable for the required duration + */ + private boolean checkStability(Pose2d currentPose, double currentTime) { + // Start stability check if not already started + if (stabilityStartTime == 0.0) { + stabilityStartTime = currentTime; + stabilityStartPose = currentPose; + return false; + } + + // Check if we've moved too much since starting stability check + double poseChange = + currentPose.getTranslation().getDistance(stabilityStartPose.getTranslation()); + if (poseChange > LocalizationFusionConstants.AutoRealignConstants.MAX_MOVEMENT) { + // Reset stability check + stabilityStartTime = currentTime; + stabilityStartPose = currentPose; + return false; + } + + // Check if we've been stable long enough + return (currentTime - stabilityStartTime) + >= LocalizationFusionConstants.AutoRealignConstants.STABILITY_TIME; + } + + /** Updates the stored auto selection and initiates reset if needed */ + private void updateAutoSelection() { + String selectedAuto = autoChooser.get().getName(); + currentAutoSelection = (selectedAuto != null) ? selectedAuto : ""; + + // Update pose if either: + // 1. We don't have any pose source (!hasTagValidatedPose && !hasAutoPose) + // 2. We only have an auto pose (!hasTagValidatedPose && hasAutoPose) and auto changed + if (!hasTagValidatedPose + && (!hasAutoPose || !currentAutoSelection.equals(previousAutoSelection))) { + + Logger.recordOutput( + "LocalizationFusion/Event", + "Auto selection changed from '" + + previousAutoSelection + + "' to '" + + currentAutoSelection + + "'"); + + Pose2d autoPose = getAutoStartingPose(); + if (autoPose != null) { + Logger.recordOutput( + "LocalizationFusion/Event", "Updating pose to new auto starting position"); + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Auto Selection Changed") + .withDescription("Updating robot position for new auto: " + currentAutoSelection) + .withDisplaySeconds(3.0)); + + if (resetToPose(autoPose)) { + state.transitionTo(LocalizationState.State.RESETTING); + lastValidatedPose = autoPose; + hasAutoPose = true; + } + } + } + + previousAutoSelection = currentAutoSelection; + } + + /** Resets all Quest initialization state variables. */ + private void resetQuestInitialization() { + questInitStartTime = 0.0; + consecutiveValidQuestUpdates = 0; + lastQuestInitPose = null; + questInitialized = false; + } + + /** Resets all AprilTag initialization state variables. */ + private void resetTagInitialization() { + tagInitStartTime = 0.0; + consecutiveValidTagUpdates = 0; + lastTagInitPose = null; + tagInitialized = false; + } + + /** + * Determines whether Quest should be preferred as the primary pose source. Considers + * initialization status, match state, and pose validity. + * + * @return true if Quest should be the primary source + */ + private boolean shouldPreferQuest() { + if (!questInitialized) return false; + if (!tagInitialized) return true; + + boolean isMidMatch = + DriverStation.isEnabled() && Timer.getMatchTime() > MATCH_STARTUP_PERIOD_SECONDS; + + if (isMidMatch + && Timer.getTimestamp() - questInitStartTime + < QUEST_INIT_TIMEOUT * QUEST_INIT_GRACE_MULTIPLIER) { + return false; + } + + return validatePose(oculusSource.getCurrentPose(), false); + } + + // -------------------- Logging Methods -------------------- + /** Logs basic system status information including state and connection status. */ + private void logSystemStatus() { + Logger.recordOutput("LocalizationFusion/State", state.getCurrentState().getDescription()); + Logger.recordOutput("LocalizationFusion/OculusConnected", oculusSource.isConnected()); + Logger.recordOutput("LocalizationFusion/AprilTagConnected", tagSource.isConnected()); + } + + /** Logs detailed system status including initialization states and update counts. */ + private void logDetailedStatus() { + Logger.recordOutput("LocalizationFusion/QuestInitialized", questInitialized); + Logger.recordOutput("LocalizationFusion/TagInitialized", tagInitialized); + Logger.recordOutput("LocalizationFusion/QuestHadCalibration", hadPreviousCalibration); + + if (questInitialized) { + Logger.recordOutput("LocalizationFusion/QuestValidUpdates", consecutiveValidQuestUpdates); + Logger.recordOutput("LocalizationFusion/LastQuestUpdate", lastQuestUpdate); + } + if (tagInitialized) { + Logger.recordOutput("LocalizationFusion/TagValidUpdates", consecutiveValidTagUpdates); + } + } + + /** + * Logs state transitions with descriptive messages. + * + * @param from The previous state + * @param to The new state + */ + @Override + public void logTransition(LocalizationState.State from, LocalizationState.State to) { + Logger.recordOutput( + "LocalizationFusion/StateTransition", + String.format("State transition: %s -> %s", from.name(), to.name())); + + // Send appropriate notifications based on state transitions + switch (to) { + case EMERGENCY: + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.ERROR) + .withTitle("Localization Emergency") + .withDescription( + "Localization system entered emergency state - Check vision systems") + .withDisplaySeconds(3.0)); + break; + case RESETTING: + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Localization Reset") + .withDescription("Realigning Quest and AprilTag poses") + .withDisplaySeconds(3.0)); + break; + case QUEST_PRIMARY: + if (from == LocalizationState.State.TAG_BACKUP) { + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.INFO) + .withTitle("Quest Tracking Restored") + .withDescription("Switched back to primary Quest-based tracking") + .withDisplaySeconds(3.0)); + } + break; + case TAG_BACKUP: + Elastic.sendAlert( + new Elastic.ElasticNotification() + .withLevel(Elastic.ElasticNotification.NotificationLevel.WARNING) + .withTitle("Using Backup Tracking") + .withDescription("Switched to AprilTag-based tracking") + .withDisplaySeconds(3.0)); + break; + } + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationFusionConstants.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationFusionConstants.java new file mode 100644 index 00000000..e6a9a8b0 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationFusionConstants.java @@ -0,0 +1,106 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion; + +import lombok.experimental.UtilityClass; + +/** + * Constants used by the LocalizationFusion subsystem for robot pose estimation and tracking. Groups + * related constants into inner classes for better organization. + */ +@UtilityClass +public class LocalizationFusionConstants { + + /** + * Constants related to pose validation and thresholds. Defines distance and rotation thresholds + * for validating poses from different sources. + */ + @UtilityClass + public static class ValidationThresholds { + /** Maximum acceptable difference between AprilTag and Quest poses for validation (meters). */ + public static final double APRILTAG_VALIDATION_THRESHOLD = 1.0; + + /** Stricter threshold used during initialization phase for validating poses (meters). */ + public static final double INIT_VALIDATION_THRESHOLD = 0.3; + + /** Maximum allowed pose change during disabled state to trigger recalibration (meters). */ + public static final double DISABLED_RECALIBRATION_THRESHOLD = 0.05; + + /** Maximum allowed rotation change between poses (degrees). */ + public static final double MAX_ROTATION_CHANGE_DEGREES = 35.0; + } + + /** + * Constants related to the auto-realignment feature for correcting Quest drift using AprilTags. + * This system monitors robot stability and pose error to trigger automatic realignments when the + * robot is stationary and significant drift is detected. + */ + @UtilityClass + public class AutoRealignConstants { + /** Whether auto-realignment should be enabled. */ + public static final boolean ENABLED = true; + + /** Threshold for auto-realignment when pose error exceeds this value (meters). */ + public static final double THRESHOLD = 0.1; + + /** Maximum pose change over stability period to be considered stable (meters). */ + public static final double MAX_MOVEMENT = 0.05; + + /** Time robot must be stable before realigning (seconds). */ + public static final double STABILITY_TIME = 1.0; + + /** Minimum time between auto-realignments (seconds). */ + public static final double COOLDOWN = 10.0; + } + + /** + * Constants related to timing and update intervals. Defines various timeouts, intervals, and + * timing windows used in the localization system. + */ + @UtilityClass + public static class Timing { + /** Update interval matching Quest's native 120Hz update rate (seconds). */ + public static final double POSE_UPDATE_INTERVAL = 1.0 / 120.0; + + /** Time required for Quest initialization to complete (seconds). */ + public static final double QUEST_INIT_TIMEOUT = 2.0; + + /** Time required for AprilTag initialization to complete (seconds). */ + public static final double TAG_INIT_TIMEOUT = 1.0; + + /** Minimum time required to validate initial pose stability (seconds). */ + public static final double INITIAL_POSE_STABILITY_TIME = 15.0; + + /** Maximum time to wait for reset sequence to complete (seconds). */ + public static final double RESET_TIMEOUT = 5.0; + + /** Time after match start to consider it in mid-match state (seconds). */ + public static final double MATCH_STARTUP_PERIOD_SECONDS = 5.0; + } + + /** + * Constants related to initialization requirements and validation counts. Defines minimum update + * counts and multipliers for system initialization. + */ + @UtilityClass + public static class InitializationRequirements { + /** Minimum number of valid Quest updates required for successful initialization. */ + public static final int MIN_QUEST_VALID_UPDATES = 10; + + /** Minimum number of valid AprilTag updates required for successful initialization. */ + public static final int MIN_TAG_VALID_UPDATES = 3; + + /** Multiplier for extended Quest initialization grace period. */ + public static final double QUEST_INIT_GRACE_MULTIPLIER = 3.0; + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationState.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationState.java new file mode 100644 index 00000000..5ba4acc2 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/LocalizationState.java @@ -0,0 +1,123 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion; + +import lombok.Getter; + +/** + * Manages the state machine for the robot localization system. + * + *

This class implements a hierarchical state machine that controls transitions between different + * pose estimation sources based on their availability and reliability. The system follows this + * hierarchy: + * + *

1. Quest SLAM as primary source (120Hz updates) 2. AprilTags as backup source 3. Odometry as + * emergency fallback + * + *

State transitions are logged for debugging and monitoring purposes. + */ +public class LocalizationState { + + /** + * Represents the possible states of the localization system. States are ordered by initialization + * sequence and fallback hierarchy. + */ + public enum State { + /** + * System is waiting for initial pose acquisition. Valid transitions: - To RESETTING when + * initial AprilTag pose is acquired + */ + UNINITIALIZED("Waiting for initial pose"), + + /** + * System is resetting to a reference pose. Valid transitions: - To QUEST_PRIMARY when reset + * completes successfully - To TAG_BACKUP if Quest becomes unavailable during reset + */ + RESETTING("Resetting to reference pose"), + + /** + * System is using Quest SLAM as primary pose source. Valid transitions: - To TAG_BACKUP if + * Quest connection is lost or validation fails - To EMERGENCY if both Quest and AprilTags are + * unavailable + */ + QUEST_PRIMARY("Using Quest as primary source"), + + /** + * System is using AprilTags as backup pose source. Valid transitions: - To QUEST_PRIMARY when + * Quest connection is restored - To EMERGENCY if AprilTags become unavailable + */ + TAG_BACKUP("Using AprilTags as backup"), + + /** + * System is using only wheel odometry for pose estimation. Valid transitions: - To TAG_BACKUP + * when AprilTags become available - To QUEST_PRIMARY when Quest connection is restored + */ + EMERGENCY("Using only odometry"); + + @Getter private final String description; + + /** + * Creates a new State with the specified description. + * + * @param description Human-readable description of the state + */ + State(String description) { + this.description = description; + } + } + + /** Current state of the localization system */ + @Getter private State currentState = State.UNINITIALIZED; + + /** Logger for state transitions */ + @Getter private final StateTransitionLogger transitionLogger; + + /** + * Creates a new LocalizationState instance. + * + * @param logger Logger to record state transitions + */ + public LocalizationState(StateTransitionLogger logger) { + this.transitionLogger = logger; + } + + /** + * Attempts to transition to a new state. The transition will be logged through the + * StateTransitionLogger. Invalid transitions will be logged but not executed. + * + * @param newState The target state to transition to + */ + public void transitionTo(State newState) { + if (newState != currentState) { + // Log state transition + transitionLogger.logTransition(currentState, newState); + + // Execute transition + currentState = newState; + } + } + + /** + * Checks if the system is currently in any of the specified states. This is useful for checking + * multiple valid states at once. + * + * @param states Variable number of states to check against + * @return true if current state matches any of the specified states + */ + public boolean isInState(State... states) { + for (State state : states) { + if (currentState == state) return true; + } + return false; + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/PoseSource.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/PoseSource.java new file mode 100644 index 00000000..2d9c4348 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/PoseSource.java @@ -0,0 +1,78 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion; + +import edu.wpi.first.math.Matrix; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.math.numbers.N1; +import edu.wpi.first.math.numbers.N3; + +/** + * Standardized interface for pose estimation sources in the robot localization system. + * + *

This interface defines the contract that all pose sources must fulfill to integrate with the + * localization fusion system. It supports various types of pose sources including: + * + *

- Meta Quest SLAM (primary source) - AprilTag vision (secondary source) - Wheel odometry + * (emergency backup) - Other potential sources (e.g., LiDAR, external cameras) + * + *

Each source must provide: - Connection status monitoring - Current pose estimation - + * Measurement uncertainty estimates - Source identification + */ +public interface PoseSource { + + /** + * Checks if the pose source is currently connected and providing valid data. + * + *

This method should: - Verify hardware connectivity (if applicable) - Check data freshness - + * Validate sensor readings - Monitor update frequency + * + * @return true if the source is connected and providing valid data + */ + boolean isConnected(); + + /** + * Gets the most recent pose estimate from this source. + * + *

The returned pose should be: - In field-relative coordinates - Using the standard FRC + * coordinate system: - Origin at field corner - +X towards opposite alliance wall - +Y towards + * driver station - CCW positive rotation + * + *

If no valid pose is available, returns null. + * + * @return The current robot pose estimate, or null if unavailable + */ + Pose2d getCurrentPose(); + + /** + * Gets the standard deviations of measurement uncertainty for this pose source. + * + *

Returns a 3x1 matrix containing standard deviations for: - X position (meters) - Y position + * (meters) - Rotation (radians) + * + *

These values should: - Reflect real measurement uncertainty - Scale with distance/conditions + * - Account for systematic errors - Consider environmental factors + * + * @return 3x1 matrix of standard deviations [x, y, theta] + */ + Matrix getStdDevs(); + + /** + * Gets a human-readable identifier for this pose source. + * + *

Used for: - Logging - Debugging - User interfaces - Status reporting + * + * @return String identifier for this pose source + */ + String getSourceName(); +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/PoseVisionConsumer.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/PoseVisionConsumer.java new file mode 100644 index 00000000..fa6c11ae --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/PoseVisionConsumer.java @@ -0,0 +1,31 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion; + +import edu.wpi.first.math.Matrix; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.math.numbers.N1; +import edu.wpi.first.math.numbers.N3; + +/** Functional interface for consuming pose updates from the fusion system. */ +@FunctionalInterface +public interface PoseVisionConsumer { + /** + * Accepts a new pose update from the fusion system. + * + * @param pose The current robot pose in field coordinates + * @param timestampSeconds The timestamp when this pose was measured + * @param stdDevs Standard deviations for x, y, and rotation measurements + */ + void accept(Pose2d pose, double timestampSeconds, Matrix stdDevs); +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/StateTransitionLogger.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/StateTransitionLogger.java new file mode 100644 index 00000000..681021be --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/StateTransitionLogger.java @@ -0,0 +1,47 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion; + +/** + * Interface for logging state transitions in the localization system. + * + *

This interface provides a standardized way to record and monitor state machine transitions + * within the localization system. It enables: + * + *

- Real-time monitoring of system behavior - Post-match analysis of state changes - Debugging + * of unexpected transitions - Performance tracking and optimization - System health monitoring + * + *

Implementations should consider: - Logging to NetworkTables for real-time monitoring - + * Persistent storage for post-match analysis - Timestamp recording for timing analysis - Context + * preservation for debugging - Rate limiting for high-frequency transitions + */ +public interface StateTransitionLogger { + + /** + * Records a state transition in the localization system. + * + *

This method is called whenever the system transitions between states. Implementations + * should: - Record the timestamp of the transition - Log both the previous and new states - + * Include relevant context if available - Handle rapid transitions appropriately - Ensure thread + * safety + * + *

Common transitions to monitor: - UNINITIALIZED → RESETTING (Initial pose acquisition) - + * RESETTING → QUEST_PRIMARY (Successful initialization) - QUEST_PRIMARY → TAG_BACKUP (Quest + * failure or validation error) - TAG_BACKUP → QUEST_PRIMARY (Quest recovery) - Any → EMERGENCY + * (System failure) + * + * @param from The state transitioning from + * @param to The state transitioning to + */ + void logTransition(LocalizationState.State from, LocalizationState.State to); +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/commands/RequestPositionResetViaTags.java b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/commands/RequestPositionResetViaTags.java new file mode 100644 index 00000000..5b0fb5cf --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/localizationfusion/commands/RequestPositionResetViaTags.java @@ -0,0 +1,46 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.localizationfusion.commands; + +import edu.wpi.first.wpilibj2.command.Command; +import frc.alotobots.library.subsystems.vision.localizationfusion.LocalizationFusion; + +public class RequestPositionResetViaTags extends Command { + boolean reset = false; + LocalizationFusion localizationFusion; + + public RequestPositionResetViaTags(LocalizationFusion localizationFusion) { + this.localizationFusion = localizationFusion; + addRequirements(localizationFusion); + } + + @Override + public void initialize() { + reset = false; + } + + @Override + public void execute() { + reset = localizationFusion.requestResetOculusPoseViaAprilTags(); + } + + @Override + public boolean isFinished() { + return reset; + } + + @Override + public boolean runsWhenDisabled() { + return true; + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/OculusSubsystem.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/OculusSubsystem.java new file mode 100644 index 00000000..f4602b55 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/OculusSubsystem.java @@ -0,0 +1,418 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus; + +import static frc.alotobots.library.subsystems.vision.oculus.constants.OculusConstants.*; + +import edu.wpi.first.math.Matrix; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.math.geometry.Rotation2d; +import edu.wpi.first.math.geometry.Translation2d; +import edu.wpi.first.math.numbers.N1; +import edu.wpi.first.math.numbers.N3; +import edu.wpi.first.wpilibj.Timer; +import edu.wpi.first.wpilibj2.command.SubsystemBase; +import frc.alotobots.library.subsystems.vision.localizationfusion.PoseSource; +import frc.alotobots.library.subsystems.vision.oculus.io.OculusIO; +import frc.alotobots.library.subsystems.vision.oculus.io.OculusIOInputsAutoLogged; +import lombok.Getter; +import org.littletonrobotics.junction.Logger; + +/** + * Manages communication and pose estimation with a Meta Quest VR headset. + * + *

This subsystem leverages the Quest's inside-out SLAM tracking system to provide high-frequency + * (120Hz) robot pose estimation. Key features: + * + *

- Global SLAM-based localization - Field mapping and persistence - Sub-millimeter tracking + * precision - High update rate (120Hz) - Drift-free position tracking - Fast relocalization + * + *

The system operates in phases: 1. Pre-match mapping to capture field features 2. Initial pose + * acquisition and alignment 3. Continuous pose updates during match 4. Recovery handling if + * tracking is lost + */ +public class OculusSubsystem extends SubsystemBase implements PoseSource { + /** Status indicating system is ready for commands */ + private static final int STATUS_READY = 0; + + /** Status indicating heading reset completion */ + private static final int STATUS_HEADING_RESET_COMPLETE = 99; + + /** Status indicating pose reset completion */ + private static final int STATUS_POSE_RESET_COMPLETE = 98; + + /** Status indicating ping response receipt */ + private static final int STATUS_PING_RESPONSE = 97; + + /** Connection timeout threshold (seconds) */ + private static final double CONNECTION_TIMEOUT = 1; + + /** Hardware communication interface */ + private final OculusIO io; + + /** Logged inputs from Quest hardware */ + private final OculusIOInputsAutoLogged inputs = new OculusIOInputsAutoLogged(); + + /** Flag indicating active pose reset */ + @Getter private boolean poseResetInProgress = false; + + /** Flag indicating active heading reset */ + @Getter private boolean headingResetInProgress = false; + + /** Flag indicating active ping operation */ + @Getter private boolean pingInProgress = false; + + /** Previous connection state */ + @Getter private boolean wasConnected = false; + + /** Reset operation start timestamp */ + private double resetStartTime = 0; + + /** Current reset attempt counter */ + private int currentResetAttempt = 0; + + /** Previous Quest timestamp */ + private double lastTimestamp = 0.0; + + /** Current Quest timestamp */ + private double currentTimestamp = 0.0; + + /** Target pose for reset operation */ + private Pose2d pendingResetPose = null; + + /** Current robot pose estimate */ + private Pose2d currentPose = null; + + private double lastQuestUpdateTime = Timer.getTimestamp(); + + /** + * Creates a new OculusSubsystem. + * + *

Initializes communication with Quest hardware and prepares logging systems. The subsystem + * starts in an uninitialized state requiring pose calibration. + * + * @param io Interface for Quest hardware communication + */ + public OculusSubsystem(OculusIO io) { + this.io = io; + Logger.recordOutput("Oculus/status", "Initialized"); + } + + /** + * Updates subsystem state and processes Quest data. + * + *

Called periodically by the command scheduler. This method: - Updates hardware inputs - + * Processes new pose data - Handles state transitions - Manages reset operations - Updates + * logging + */ + @Override + public void periodic() { + io.updateInputs(inputs); + Logger.processInputs("Oculus", inputs); + + lastTimestamp = currentTimestamp; + currentTimestamp = inputs.timestamp; + + // Update current pose + var oculusPose = getOculusPose(); + currentPose = oculusPose.transformBy(ROBOT_TO_OCULUS.inverse()); + Logger.recordOutput("Oculus/status/poses/headsetPose", oculusPose); + Logger.recordOutput("Oculus/status/poses/robotPose", currentPose); + + handleResetTimeout(); + handleResetCompletion(); + handlePingResponse(); + } + + /** + * Manages timeouts for reset operations. + * + *

Reset operations that exceed the timeout threshold are either: - Retried up to the maximum + * attempt limit - Abandoned with appropriate status logging + */ + private void handleResetTimeout() { + double currentTime = Timer.getTimestamp(); + if (currentTime - resetStartTime > RESET_TIMEOUT_SECONDS) { + if (headingResetInProgress) handleReset(true); + if (poseResetInProgress) handleReset(false); + } + } + + /** + * Processes reset operation outcomes. + * + * @param isHeadingReset true for heading reset, false for pose reset + */ + private void handleReset(boolean isHeadingReset) { + if (currentResetAttempt < MAX_RESET_ATTEMPTS) { + String resetType = isHeadingReset ? "Heading" : "Pose"; + Logger.recordOutput( + "Oculus/status", + resetType + " Reset attempt " + (currentResetAttempt + 1) + " timed out, retrying..."); + currentResetAttempt++; + resetStartTime = Timer.getTimestamp(); + + io.setMosi(0); // Clear command + + if (isHeadingReset) { + io.setMosi(1); // Request heading reset + } else { + io.setResetPose( + pendingResetPose.getX(), + pendingResetPose.getY(), + pendingResetPose.getRotation().getDegrees()); + io.setMosi(2); // Request pose reset + } + } else { + Logger.recordOutput( + "Oculus/status", + (isHeadingReset ? "Heading" : "Pose") + + " Reset failed after " + + MAX_RESET_ATTEMPTS + + " attempts"); + if (isHeadingReset) { + clearHeadingResetState(); + } else { + clearPoseResetState(); + } + } + } + + /** + * Handles completion of reset operations. + * + *

Processes successful reset confirmations from Quest and updates system state. + */ + private void handleResetCompletion() { + if (headingResetInProgress && inputs.misoValue == STATUS_HEADING_RESET_COMPLETE) { + Logger.recordOutput( + "Oculus/status", + "Heading Reset completed successfully on attempt " + (currentResetAttempt + 1)); + clearHeadingResetState(); + } + if (poseResetInProgress && inputs.misoValue == STATUS_POSE_RESET_COMPLETE) { + Logger.recordOutput( + "Oculus/status", + "Pose Reset completed successfully on attempt " + (currentResetAttempt + 1)); + clearPoseResetState(); + } + } + + /** + * Handles ping response from Quest. + * + *

Processes communication test responses and updates status. + */ + private void handlePingResponse() { + if (inputs.misoValue == STATUS_PING_RESPONSE) { + Logger.recordOutput("Oculus/status", "Ping response received"); + io.setMosi(0); // Clear command + pingInProgress = false; + } + } + + /** + * Clears pose reset operation state. + * + *

Resets all state variables related to pose reset operations. + */ + private void clearPoseResetState() { + Logger.recordOutput("Oculus/status", "Clearing pose reset state"); + poseResetInProgress = false; + pendingResetPose = null; + currentResetAttempt = 0; + io.setMosi(0); // Clear command + } + + /** + * Clears heading reset operation state. + * + *

Resets all state variables related to heading reset operations. + */ + private void clearHeadingResetState() { + Logger.recordOutput("Oculus/status", "Clearing heading reset state"); + headingResetInProgress = false; + currentResetAttempt = 0; + io.setMosi(0); // Clear command + } + + /** + * Gets current Quest rotation in field frame. + * + *

Converts Quest IMU data to field-relative rotation. + * + * @return Current headset rotation + */ + private Rotation2d getOculusYaw() { + return Rotation2d.fromDegrees(-inputs.eulerAngles[1]); + } + + /** + * Gets current Quest position in field frame. + * + *

Converts Quest SLAM position to field coordinates. + * + * @return Current headset position + */ + private Translation2d getOculusPosition() { + return new Translation2d(inputs.position[2], -inputs.position[0]); + } + + /** + * Gets complete Quest pose in field frame. + * + *

Combines position and rotation data into a field-relative pose. + * + * @return Current headset pose + */ + private Pose2d getOculusPose() { + return new Pose2d(getOculusPosition(), getOculusYaw()); + } + + /** + * Initiates pose reset operation. + * + *

Attempts to reset Quest SLAM origin to align with target pose. + * + * @param targetPose Desired robot pose after reset + * @return true if reset initiated successfully + */ + public boolean resetToPose(Pose2d targetPose) { + if (poseResetInProgress) { + Logger.recordOutput("Oculus/status", "Cannot reset pose - reset already in progress"); + return false; + } + + if (inputs.misoValue != STATUS_READY) { + Logger.recordOutput( + "Oculus/status", "Cannot reset pose - Quest busy (MISO=" + inputs.misoValue + ")"); + return false; + } + + targetPose = targetPose.plus(ROBOT_TO_OCULUS); + pendingResetPose = targetPose; + Logger.recordOutput( + "Oculus/status", + String.format( + "Initiating pose reset to X:%.2f Y:%.2f Rot:%.2f°", + targetPose.getX(), targetPose.getY(), targetPose.getRotation().getDegrees())); + + io.setResetPose(targetPose.getX(), targetPose.getY(), targetPose.getRotation().getDegrees()); + poseResetInProgress = true; + resetStartTime = Timer.getTimestamp(); + currentResetAttempt = 0; + io.setMosi(2); // Request pose reset + + return true; + } + + /** + * Initiates heading reset operation. + * + *

Attempts to zero current Quest heading measurement. + * + * @return true if reset initiated successfully + */ + public boolean zeroHeading() { + if (headingResetInProgress) { + Logger.recordOutput("Oculus/status", "Cannot zero heading - reset already in progress"); + return false; + } + + if (inputs.misoValue != STATUS_READY) { + Logger.recordOutput( + "Oculus/status", "Cannot zero heading - Quest busy (MISO=" + inputs.misoValue + ")"); + return false; + } + + Logger.recordOutput("Oculus/status", "Zeroing heading"); + headingResetInProgress = true; + resetStartTime = Timer.getTimestamp(); + currentResetAttempt = 0; + io.setMosi(1); // Request heading reset + return true; + } + + /** + * Tests Quest communication. + * + *

Sends ping command to verify hardware connectivity. + * + * @return true if ping initiated successfully + */ + public boolean ping() { + if (pingInProgress) { + Logger.recordOutput("Oculus/status", "Cannot ping - ping already in progress"); + return false; + } + + if (inputs.misoValue != STATUS_READY) { + Logger.recordOutput( + "Oculus/status", "Cannot ping - system busy (MISO=" + inputs.misoValue + ")"); + return false; + } + + Logger.recordOutput("Oculus/status", "Sending ping..."); + Logger.recordOutput("Oculus/ping/sendTime", Timer.getTimestamp()); + pingInProgress = true; + io.setMosi(3); // Send ping + return true; + } + + // PoseSource interface implementation + + /** + * {@inheritDoc} + * + *

Quest connection is determined by checking if we've received any new Quest timestamp updates + * within our timeout window. Small variations in update timing are allowed, only triggering + * disconnect on significant delays. + */ + @Override + public boolean isConnected() { + // If timestamp has changed since last check, update our last update time + if (inputs.timestamp != lastTimestamp) { + lastQuestUpdateTime = Timer.getTimestamp(); + } + + // Only consider disconnected if we haven't seen ANY new timestamps + // for longer than our timeout period + return (Timer.getTimestamp() - lastQuestUpdateTime) < CONNECTION_TIMEOUT; + } + + /** + * {@inheritDoc} + * + *

Returns field-relative robot pose from Quest SLAM. + */ + @Override + public Pose2d getCurrentPose() { + return currentPose; + } + + /** + * {@inheritDoc} + * + *

Returns constant measurement uncertainty for Quest tracking. + */ + @Override + public Matrix getStdDevs() { + return OCULUS_STD_DEVS; + } + + /** {@inheritDoc} */ + @Override + public String getSourceName() { + return "Oculus Quest"; + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/PingCommand.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/PingCommand.java new file mode 100644 index 00000000..463755cd --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/PingCommand.java @@ -0,0 +1,45 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.commands; + +import edu.wpi.first.wpilibj2.command.Command; +import frc.alotobots.library.subsystems.vision.oculus.OculusSubsystem; + +/** + * Command that sends a ping to the Oculus system and waits for a response. Used to verify + * communication with the Oculus headset. + */ +public class PingCommand extends Command { + /** The Oculus subsystem instance to use for pinging */ + private final OculusSubsystem oculus; + + /** + * Creates a new PingCommand. + * + * @param oculus The Oculus subsystem to ping + */ + public PingCommand(OculusSubsystem oculus) { + this.oculus = oculus; + addRequirements(oculus); + } + + @Override + public void initialize() { + oculus.ping(); + } + + @Override + public boolean isFinished() { + return !oculus.isPingInProgress(); + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/ResetPoseCommand.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/ResetPoseCommand.java new file mode 100644 index 00000000..f5254d59 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/ResetPoseCommand.java @@ -0,0 +1,51 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.commands; + +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.wpilibj2.command.Command; +import frc.alotobots.library.subsystems.vision.oculus.OculusSubsystem; + +/** + * Command that resets the Oculus system's pose estimation to a specified target pose. This is + * useful for initializing or correcting the robot's position tracking. + */ +public class ResetPoseCommand extends Command { + /** The Oculus subsystem instance to reset */ + private final OculusSubsystem oculus; + + /** The target pose to reset to */ + private final Pose2d targetPose; + + /** + * Creates a new ResetPoseCommand. + * + * @param oculus The Oculus subsystem to reset + * @param targetPose The desired pose to reset to + */ + public ResetPoseCommand(OculusSubsystem oculus, Pose2d targetPose) { + this.oculus = oculus; + this.targetPose = targetPose; + addRequirements(oculus); + } + + @Override + public void initialize() { + oculus.resetToPose(targetPose); + } + + @Override + public boolean isFinished() { + return !oculus.isPoseResetInProgress(); + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/ZeroHeadingCommand.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/ZeroHeadingCommand.java new file mode 100644 index 00000000..1c2a97c4 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/commands/ZeroHeadingCommand.java @@ -0,0 +1,45 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.commands; + +import edu.wpi.first.wpilibj2.command.Command; +import frc.alotobots.library.subsystems.vision.oculus.OculusSubsystem; + +/** + * Command that zeros the heading (rotation) of the Oculus system. This is useful for aligning the + * robot's coordinate system with the field. + */ +public class ZeroHeadingCommand extends Command { + /** The Oculus subsystem instance to zero */ + private final OculusSubsystem oculus; + + /** + * Creates a new ZeroHeadingCommand. + * + * @param oculus The Oculus subsystem to zero + */ + public ZeroHeadingCommand(OculusSubsystem oculus) { + this.oculus = oculus; + addRequirements(oculus); + } + + @Override + public void initialize() { + oculus.zeroHeading(); + } + + @Override + public boolean isFinished() { + return !oculus.isHeadingResetInProgress(); + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/constants/OculusConstants.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/constants/OculusConstants.java new file mode 100644 index 00000000..13aa5e61 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/constants/OculusConstants.java @@ -0,0 +1,59 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.constants; + +import edu.wpi.first.math.Matrix; +import edu.wpi.first.math.VecBuilder; +import edu.wpi.first.math.geometry.Rotation2d; +import edu.wpi.first.math.geometry.Transform2d; +import edu.wpi.first.math.numbers.N1; +import edu.wpi.first.math.numbers.N3; + +/** + * Constants used by the Oculus Quest navigation subsystem. Contains configuration values for + * physical setup and operation parameters. + */ +public class OculusConstants { + /** + * Transform from the robot center to the headset. Coordinate system: - X: Positive is forwards - + * Y: Positive is left - Rotation: Positive is counter-clockwise + */ + public static final Transform2d ROBOT_TO_OCULUS = new Transform2d(0.075, 0.0, new Rotation2d()); + + /** + * Timeout duration in seconds for reset operations (pose reset, heading reset, ping). If a reset + * operation takes longer than this time, it will be considered failed. + */ + public static final double RESET_TIMEOUT_SECONDS = 0.2; + + /** + * Maximum number of attempts for reset operations. If a reset operation fails this many times, + * the command will terminate. + */ + public static final int MAX_RESET_ATTEMPTS = 3; + + /** Timeout threshold for considering Quest disconnected (seconds) */ + public static final double CONNECTION_TIMEOUT = 0.1; + + /** + * Standard deviations representing how much we "trust" the position from the Oculus. By default, + * the Quest 3 provides sub-millimeter accuracy. Values represent: [0]: X position trust (5mm) + * [1]: Y position trust (5mm) [2]: Rotation trust (~2.87 degrees) + */ + public static final Matrix OCULUS_STD_DEVS = + VecBuilder.fill( + 0.005, // Trust down to 5mm + 0.005, // Trust down to 5mm + 0.05 // Trust down to ~2.87deg + ); +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIO.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIO.java new file mode 100644 index 00000000..20d44949 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIO.java @@ -0,0 +1,66 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.io; + +import org.littletonrobotics.junction.AutoLog; + +/** Interface for handling input/output operations with the Oculus Quest hardware. */ +public interface OculusIO { + /** Data structure for Oculus inputs that can be automatically logged. */ + @AutoLog + public static class OculusIOInputs { + /** 3D position coordinates [x, y, z] */ + public float[] position = new float[] {0.0f, 0.0f, 0.0f}; + + /** Quaternion orientation [w, x, y, z] */ + public float[] quaternion = new float[] {0.0f, 0.0f, 0.0f, 0.0f}; + + /** Euler angles [roll, pitch, yaw] in degrees */ + public float[] eulerAngles = new float[] {0.0f, 0.0f, 0.0f}; + + /** Current timestamp from the Oculus */ + public double timestamp = -1.0; + + /** Frame counter from the Oculus */ + public int frameCount = -1; + + /** Battery level percentage */ + public double batteryPercent = -1.0; + + /** Current MISO (Master In Slave Out) value */ + public int misoValue = 0; + } + + /** + * Updates the set of loggable inputs from the Oculus. + * + * @param inputs The input object to update with current values + */ + public default void updateInputs(OculusIOInputs inputs) {} + + /** + * Sets MOSI (Master Out Slave In) value for Quest communication. + * + * @param value The MOSI value to set + */ + public default void setMosi(int value) {} + + /** + * Sets the pose components for resetting the Oculus position tracking. + * + * @param x The X coordinate + * @param y The Y coordinate + * @param rotation The rotation in degrees + */ + public default void setResetPose(double x, double y, double rotation) {} +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIOReal.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIOReal.java new file mode 100644 index 00000000..f1e0b7f9 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIOReal.java @@ -0,0 +1,90 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.io; + +import edu.wpi.first.networktables.*; + +/** Implementation of OculusIO for real hardware communication via NetworkTables. */ +public class OculusIOReal implements OculusIO { + /** NetworkTable for Oculus communication */ + private final NetworkTable nt4Table; + + /** Subscriber for MISO (Master In Slave Out) values */ + private final IntegerSubscriber questMiso; + + /** Publisher for MOSI (Master Out Slave In) values */ + private final IntegerPublisher questMosi; + + /** Subscriber for frame count updates */ + private final IntegerSubscriber questFrameCount; + + /** Subscriber for timestamp updates */ + private final DoubleSubscriber questTimestamp; + + /** Subscriber for position updates */ + private final FloatArraySubscriber questPosition; + + /** Subscriber for quaternion orientation updates */ + private final FloatArraySubscriber questQuaternion; + + /** Subscriber for Euler angle updates */ + private final FloatArraySubscriber questEulerAngles; + + /** Subscriber for battery percentage updates */ + private final DoubleSubscriber questBatteryPercent; + + /** Publisher for pose reset commands */ + private final DoubleArrayPublisher resetPosePub; + + /** + * Creates a new OculusIOReal instance and initializes all NetworkTable publishers and + * subscribers. + */ + public OculusIOReal() { + nt4Table = NetworkTableInstance.getDefault().getTable("questnav"); + questMiso = nt4Table.getIntegerTopic("miso").subscribe(0); + questMosi = nt4Table.getIntegerTopic("mosi").publish(); + questFrameCount = nt4Table.getIntegerTopic("frameCount").subscribe(-1); + questTimestamp = nt4Table.getDoubleTopic("timestamp").subscribe(-1.0); + questPosition = + nt4Table.getFloatArrayTopic("position").subscribe(new float[] {0.0f, 0.0f, 0.0f}); + questQuaternion = + nt4Table.getFloatArrayTopic("quaternion").subscribe(new float[] {0.0f, 0.0f, 0.0f, 0.0f}); + questEulerAngles = + nt4Table.getFloatArrayTopic("eulerAngles").subscribe(new float[] {0.0f, 0.0f, 0.0f}); + questBatteryPercent = nt4Table.getDoubleTopic("batteryPercent").subscribe(-1.0); + + resetPosePub = nt4Table.getDoubleArrayTopic("resetpose").publish(); + } + + @Override + public void updateInputs(OculusIOInputs inputs) { + inputs.position = questPosition.get(); + inputs.quaternion = questQuaternion.get(); + inputs.eulerAngles = questEulerAngles.get(); + inputs.timestamp = questTimestamp.get(); + inputs.frameCount = (int) questFrameCount.get(); + inputs.batteryPercent = questBatteryPercent.get(); + inputs.misoValue = (int) questMiso.get(); + } + + @Override + public void setMosi(int value) { + questMosi.set(value); + } + + @Override + public void setResetPose(double x, double y, double rotation) { + resetPosePub.set(new double[] {x, y, rotation}); + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIOSim.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIOSim.java new file mode 100644 index 00000000..8a2dacf1 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/io/OculusIOSim.java @@ -0,0 +1,38 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.io; + +/** Simulation implementation of OculusIO that provides static test values. */ +public class OculusIOSim implements OculusIO { + @Override + public void updateInputs(OculusIOInputs inputs) { + // Simulate static position for testing + inputs.position = new float[] {0.0f, 0.0f, 0.0f}; + inputs.quaternion = new float[] {1.0f, 0.0f, 0.0f, 0.0f}; + inputs.eulerAngles = new float[] {0.0f, 0.0f, 0.0f}; + inputs.timestamp = -1.0; + inputs.frameCount = -1; + inputs.batteryPercent = 100.0; + inputs.misoValue = 0; + } + + @Override + public void setMosi(int value) { + // Simulation does not need to handle MOSI values + } + + @Override + public void setResetPose(double x, double y, double rotation) { + // Simulation does not need to handle pose reset + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/oculus/util/OculusPoseSource.java b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/util/OculusPoseSource.java new file mode 100644 index 00000000..63beb2e0 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/oculus/util/OculusPoseSource.java @@ -0,0 +1,84 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.oculus.util; + +import edu.wpi.first.math.Matrix; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.math.numbers.N1; +import edu.wpi.first.math.numbers.N3; +import frc.alotobots.library.subsystems.vision.localizationfusion.PoseSource; +import frc.alotobots.library.subsystems.vision.oculus.OculusSubsystem; +import frc.alotobots.library.subsystems.vision.oculus.constants.OculusConstants; + +/** + * Adapts the Meta Quest SLAM system to the standardized PoseSource interface. + * + *

This class wraps the OculusSubsystem to provide high-frequency pose estimation through the + * standardized PoseSource interface. The Quest serves as: - Primary pose estimation source - + * High-frequency (120Hz) updates - Global SLAM-based localization - Drift-free position tracking + * + *

Features: - Sub-millimeter tracking precision - Building-scale SLAM mapping - Robust to + * reflective surfaces - Fast relocalization from saved maps - High-speed movement handling + */ +public class OculusPoseSource implements PoseSource { + /** The underlying Oculus subsystem */ + public final OculusSubsystem subsystem; + + /** + * Creates a new OculusPoseSource. + * + * @param subsystem The Oculus subsystem to wrap + */ + public OculusPoseSource(OculusSubsystem subsystem) { + this.subsystem = subsystem; + } + + /** + * {@inheritDoc} + * + *

For Quest SLAM, connection status depends on: - Quest hardware connection - SLAM tracking + * quality - Update frequency - Environment mapping status + */ + @Override + public boolean isConnected() { + return subsystem.isConnected(); + } + + /** + * {@inheritDoc} + * + *

Quest poses are: - Globally consistent via SLAM - Updated at 120Hz - Drift-compensated - + * Environment-referenced - Transform-corrected to field frame + */ + @Override + public Pose2d getCurrentPose() { + return subsystem.getCurrentPose(); + } + + /** + * {@inheritDoc} + * + *

Quest measurement uncertainty: - Sub-millimeter position accuracy - Factory-calibrated IMU - + * Environment-dependent SLAM quality - Constant across operating range + */ + @Override + public Matrix getStdDevs() { + return OculusConstants.OCULUS_STD_DEVS; + } + + /** {@inheritDoc} */ + @Override + public String getSourceName() { + return "Oculus Quest"; + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/AprilTagSubsystem.java b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/AprilTagSubsystem.java index 5913552b..fb3b9f82 100644 --- a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/AprilTagSubsystem.java +++ b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/AprilTagSubsystem.java @@ -16,56 +16,41 @@ import edu.wpi.first.math.Matrix; import edu.wpi.first.math.VecBuilder; +import edu.wpi.first.math.Vector; import edu.wpi.first.math.geometry.Pose2d; import edu.wpi.first.math.geometry.Pose3d; -import edu.wpi.first.math.geometry.Rotation2d; import edu.wpi.first.math.numbers.N1; import edu.wpi.first.math.numbers.N3; import edu.wpi.first.wpilibj.Alert; import edu.wpi.first.wpilibj.Alert.AlertType; +import edu.wpi.first.wpilibj.Timer; import edu.wpi.first.wpilibj2.command.SubsystemBase; +import frc.alotobots.library.subsystems.vision.localizationfusion.PoseSource; import frc.alotobots.library.subsystems.vision.photonvision.apriltag.io.AprilTagIO; import frc.alotobots.library.subsystems.vision.photonvision.apriltag.io.AprilTagIOInputsAutoLogged; import java.util.LinkedList; import java.util.List; import org.littletonrobotics.junction.Logger; -/** - * A subsystem that handles AprilTag detection and processing using PhotonVision. This subsystem - * processes AprilTag data from multiple cameras and provides pose estimation with statistical - * confidence measures. - */ -public class AprilTagSubsystem extends SubsystemBase { - /** Consumer interface for receiving AprilTag pose data with statistical confidence */ - private final AprilTagConsumer consumer; +public class AprilTagSubsystem extends SubsystemBase implements PoseSource { - /** Array of AprilTag IO interfaces for each camera */ private final AprilTagIO[] io; - - /** Array of input logs for each camera */ private final AprilTagIOInputsAutoLogged[] inputs; - - /** Array of alerts for disconnected cameras */ private final Alert[] disconnectedAlerts; - /** - * Constructs a new AprilTagSubsystem. - * - * @param consumer The consumer that will receive processed AprilTag data - * @param io Array of AprilTagIO interfaces for each camera - * @throws IllegalArgumentException if camera standard deviation factors are invalid - */ - public AprilTagSubsystem(AprilTagConsumer consumer, AprilTagIO... io) { - this.consumer = consumer; - this.io = io; + private Pose2d latestPose = null; + private Vector latestStdDevs = null; + private boolean hasValidPose = false; + private boolean isConnected = false; + private double lastPoseTimestamp = 0.0; - // Initialize inputs + public AprilTagSubsystem(AprilTagIO... io) { + this.io = io; this.inputs = new AprilTagIOInputsAutoLogged[io.length]; for (int i = 0; i < inputs.length; i++) { inputs[i] = new AprilTagIOInputsAutoLogged(); } - // Initialize disconnected alerts this.disconnectedAlerts = new Alert[io.length]; for (int i = 0; i < inputs.length; i++) { disconnectedAlerts[i] = @@ -74,34 +59,49 @@ public AprilTagSubsystem(AprilTagConsumer consumer, AprilTagIO... io) { AlertType.kWarning); } - // Ensure no illegal values for STD factors - for (double factor : CAMERA_STD_DEV_FACTORS) { - if (factor < 1.0) - throw new IllegalArgumentException( - "[AprilTagSubsystem] Value must be greater than or equal to 1, but was: " + factor); + validateConfiguration(); + } + + // PoseSource interface implementation + @Override + public boolean isConnected() { + return isConnected; + } + + @Override + public Pose2d getCurrentPose() { + double timeSinceLastPose = Timer.getTimestamp() - lastPoseTimestamp; + if (timeSinceLastPose > POSE_TIMEOUT) { + return null; // Return null if pose is stale } + return hasValidPose ? latestPose : null; } - /** - * Returns the X angle to the best target for simple vision-based servoing. - * - * @param cameraIndex The index of the camera to use - * @return The rotation angle to the best target - */ - public Rotation2d getTargetX(int cameraIndex) { - return inputs[cameraIndex].latestTargetObservation.tx(); + @Override + public Matrix getStdDevs() { + return latestStdDevs; + } + + @Override + public String getSourceName() { + return "AprilTag"; } - /** - * Periodic function that processes AprilTag data from all cameras. This method: - Updates camera - * inputs - Processes pose observations - Applies statistical confidence calculations - Logs - * vision data - Sends accepted pose estimates to the consumer - */ @Override public void periodic() { + hasValidPose = false; + isConnected = false; + + // Update inputs and check camera connections for (int i = 0; i < io.length; i++) { io[i].updateInputs(inputs[i]); Logger.processInputs("Vision/AprilTag/Camera" + CAMERA_CONFIGS[i].name(), inputs[i]); + + // If any camera is connected, consider the system connected + if (inputs[i].connected) { + isConnected = true; + } + disconnectedAlerts[i].set(!inputs[i].connected); } // Initialize logging values @@ -110,118 +110,129 @@ public void periodic() { List allRobotPosesAccepted = new LinkedList<>(); List allRobotPosesRejected = new LinkedList<>(); - // Loop over cameras - for (int cameraIndex = 0; cameraIndex < io.length; cameraIndex++) { - // Update disconnected alert - disconnectedAlerts[cameraIndex].set(!inputs[cameraIndex].connected); + // Process camera data + processCameraData(allTagPoses, allRobotPoses, allRobotPosesAccepted, allRobotPosesRejected); + + // Log summary data + logSummaryData(allTagPoses, allRobotPoses, allRobotPosesAccepted, allRobotPosesRejected); - // Initialize logging values + // Log pose staleness + double timeSinceLastPose = Timer.getTimestamp() - lastPoseTimestamp; + Logger.recordOutput("Vision/AprilTag/TimeSinceLastPose", timeSinceLastPose); + Logger.recordOutput("Vision/AprilTag/PoseStale", timeSinceLastPose > POSE_TIMEOUT); + } + + private void validateConfiguration() { + for (double factor : CAMERA_STD_DEV_FACTORS) { + if (factor < 1.0) { + throw new IllegalArgumentException( + "[AprilTagSubsystem] STD factor must be >= 1.0, but was: " + factor); + } + } + } + + private void processCameraData( + List allTagPoses, + List allRobotPoses, + List allRobotPosesAccepted, + List allRobotPosesRejected) { + + for (int cameraIndex = 0; cameraIndex < io.length; cameraIndex++) { List tagPoses = new LinkedList<>(); List robotPoses = new LinkedList<>(); List robotPosesAccepted = new LinkedList<>(); List robotPosesRejected = new LinkedList<>(); - // Add tag poses - for (int tagId : inputs[cameraIndex].tagIds) { - var tagPose = APRIL_TAG_LAYOUT.getTagPose(tagId); - if (tagPose.isPresent()) { - tagPoses.add(tagPose.get()); - } - } + processTagPoses(cameraIndex, tagPoses); + processPoseObservations(cameraIndex, robotPoses, robotPosesAccepted, robotPosesRejected); - // Loop over pose observations - for (var observation : inputs[cameraIndex].poseObservations) { - // Check whether to reject pose - boolean rejectPose = - observation.tagCount() == 0 // Must have at least one tag - || (observation.tagCount() == 1 - && observation.ambiguity() > MAX_AMBIGUITY) // Cannot be high ambiguity - || Math.abs(observation.pose().getZ()) - > MAX_Z_ERROR // Must have realistic Z coordinate - - // Must be within the field boundaries - || observation.pose().getX() < 0.0 - || observation.pose().getX() > APRIL_TAG_LAYOUT.getFieldLength() - || observation.pose().getY() < 0.0 - || observation.pose().getY() > APRIL_TAG_LAYOUT.getFieldWidth(); - - // Add pose to log - robotPoses.add(observation.pose()); - if (rejectPose) { - robotPosesRejected.add(observation.pose()); - } else { - robotPosesAccepted.add(observation.pose()); - } - - // Skip if rejected - if (rejectPose) { - continue; - } - - // Calculate standard deviations - double stdDevFactor = - Math.pow(observation.averageTagDistance(), 2.0) / observation.tagCount(); - double linearStdDev = LINEAR_STD_DEV_BASE * stdDevFactor; - double angularStdDev = ANGULAR_STD_DEV_BASE * stdDevFactor; - - if (cameraIndex < CAMERA_STD_DEV_FACTORS.length) { - linearStdDev *= CAMERA_STD_DEV_FACTORS[cameraIndex]; - angularStdDev *= CAMERA_STD_DEV_FACTORS[cameraIndex]; - } - - // Send vision observation - consumer.accept( - observation.pose().toPose2d(), - observation.timestamp(), - VecBuilder.fill(linearStdDev, linearStdDev, angularStdDev)); - } + logCameraData(cameraIndex, tagPoses, robotPoses, robotPosesAccepted, robotPosesRejected); - // Log camera data - Logger.recordOutput( - "Vision/AprilTag/Camera" + CAMERA_CONFIGS[cameraIndex].name() + "/TagPoses", - tagPoses.toArray(new Pose3d[tagPoses.size()])); - Logger.recordOutput( - "Vision/AprilTag/Camera" + CAMERA_CONFIGS[cameraIndex].name() + "/RobotPoses", - robotPoses.toArray(new Pose3d[robotPoses.size()])); - Logger.recordOutput( - "Vision/AprilTag/Camera" + CAMERA_CONFIGS[cameraIndex].name() + "/RobotPosesAccepted", - robotPosesAccepted.toArray(new Pose3d[robotPosesAccepted.size()])); - Logger.recordOutput( - "Vision/AprilTag/Camera" + CAMERA_CONFIGS[cameraIndex].name() + "/RobotPosesRejected", - robotPosesRejected.toArray(new Pose3d[robotPosesRejected.size()])); allTagPoses.addAll(tagPoses); allRobotPoses.addAll(robotPoses); allRobotPosesAccepted.addAll(robotPosesAccepted); allRobotPosesRejected.addAll(robotPosesRejected); } + } - // Log summary data - Logger.recordOutput( - "Vision/AprilTag/Summary/TagPoses", allTagPoses.toArray(new Pose3d[allTagPoses.size()])); - Logger.recordOutput( - "Vision/AprilTag/Summary/RobotPoses", - allRobotPoses.toArray(new Pose3d[allRobotPoses.size()])); + private void processTagPoses(int cameraIndex, List tagPoses) { + for (int tagId : inputs[cameraIndex].tagIds) { + var tagPose = APRIL_TAG_LAYOUT.getTagPose(tagId); + tagPose.ifPresent(tagPoses::add); + } + } + + private void processPoseObservations( + int cameraIndex, + List robotPoses, + List robotPosesAccepted, + List robotPosesRejected) { + + for (var observation : inputs[cameraIndex].poseObservations) { + boolean rejectPose = shouldRejectPose(observation); + + robotPoses.add(observation.pose()); + if (rejectPose) { + robotPosesRejected.add(observation.pose()); + continue; + } + + robotPosesAccepted.add(observation.pose()); + updateLatestPose(observation, cameraIndex); + hasValidPose = true; + lastPoseTimestamp = Timer.getTimestamp(); + } + } + + private boolean shouldRejectPose(AprilTagIO.PoseObservation observation) { + return observation.tagCount() == 0 + || (observation.tagCount() == 1 && observation.ambiguity() > MAX_AMBIGUITY) + || Math.abs(observation.pose().getZ()) > MAX_Z_ERROR + || observation.pose().getX() < 0.0 + || observation.pose().getX() > APRIL_TAG_LAYOUT.getFieldLength() + || observation.pose().getY() < 0.0 + || observation.pose().getY() > APRIL_TAG_LAYOUT.getFieldWidth(); + } + + private void updateLatestPose(AprilTagIO.PoseObservation observation, int cameraIndex) { + double stdDevFactor = Math.pow(observation.averageTagDistance(), 2.0) / observation.tagCount(); + double linearStdDev = LINEAR_STD_DEV_BASE * stdDevFactor; + double angularStdDev = ANGULAR_STD_DEV_BASE * stdDevFactor; + + if (cameraIndex < CAMERA_STD_DEV_FACTORS.length) { + linearStdDev *= CAMERA_STD_DEV_FACTORS[cameraIndex]; + angularStdDev *= CAMERA_STD_DEV_FACTORS[cameraIndex]; + } + + latestPose = observation.pose().toPose2d(); + latestStdDevs = VecBuilder.fill(linearStdDev, linearStdDev, angularStdDev); + } + + private void logCameraData( + int cameraIndex, + List tagPoses, + List robotPoses, + List robotPosesAccepted, + List robotPosesRejected) { + String cameraPrefix = "Vision/AprilTag/Camera" + CAMERA_CONFIGS[cameraIndex].name(); + Logger.recordOutput(cameraPrefix + "/TagPoses", tagPoses.toArray(new Pose3d[0])); + Logger.recordOutput(cameraPrefix + "/RobotPoses", robotPoses.toArray(new Pose3d[0])); Logger.recordOutput( - "Vision/AprilTag/Summary/RobotPosesAccepted", - allRobotPosesAccepted.toArray(new Pose3d[allRobotPosesAccepted.size()])); + cameraPrefix + "/RobotPosesAccepted", robotPosesAccepted.toArray(new Pose3d[0])); Logger.recordOutput( - "Vision/AprilTag/Summary/RobotPosesRejected", - allRobotPosesRejected.toArray(new Pose3d[allRobotPosesRejected.size()])); + cameraPrefix + "/RobotPosesRejected", robotPosesRejected.toArray(new Pose3d[0])); } - /** Functional interface for consuming AprilTag pose data with statistical confidence measures. */ - @FunctionalInterface - public static interface AprilTagConsumer { - /** - * Accepts AprilTag pose data with statistical confidence measures. - * - * @param visionRobotPoseMeters The estimated robot pose in field coordinates - * @param timestampSeconds The timestamp of the measurement in seconds - * @param visionMeasurementStdDevs Standard deviations for the measurement [x, y, theta] - */ - public void accept( - Pose2d visionRobotPoseMeters, - double timestampSeconds, - Matrix visionMeasurementStdDevs); + private void logSummaryData( + List allTagPoses, + List allRobotPoses, + List allRobotPosesAccepted, + List allRobotPosesRejected) { + Logger.recordOutput("Vision/AprilTag/Summary/TagPoses", allTagPoses.toArray(new Pose3d[0])); + Logger.recordOutput("Vision/AprilTag/Summary/RobotPoses", allRobotPoses.toArray(new Pose3d[0])); + Logger.recordOutput( + "Vision/AprilTag/Summary/RobotPosesAccepted", allRobotPosesAccepted.toArray(new Pose3d[0])); + Logger.recordOutput( + "Vision/AprilTag/Summary/RobotPosesRejected", allRobotPosesRejected.toArray(new Pose3d[0])); } } diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/constants/AprilTagConstants.java b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/constants/AprilTagConstants.java index e3bd7f47..1419a5c1 100644 --- a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/constants/AprilTagConstants.java +++ b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/constants/AprilTagConstants.java @@ -65,4 +65,7 @@ public class AprilTagConstants { // Basic Filtering public static double MAX_AMBIGUITY = 0.3; public static double MAX_Z_ERROR = 0.75; + + /** Time after which a pose is considered stale (seconds) */ + public static final double POSE_TIMEOUT = 0.06; } diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/util/AprilTagPoseSource.java b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/util/AprilTagPoseSource.java new file mode 100644 index 00000000..ff40e195 --- /dev/null +++ b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/apriltag/util/AprilTagPoseSource.java @@ -0,0 +1,83 @@ +/* +* ALOTOBOTS - FRC Team 5152 + https://github.com/5152Alotobots +* Copyright (C) 2024 ALOTOBOTS +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Source code must be publicly available on GitHub or an alternative web accessible site +*/ +package frc.alotobots.library.subsystems.vision.photonvision.apriltag.util; + +import edu.wpi.first.math.Matrix; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.math.numbers.N1; +import edu.wpi.first.math.numbers.N3; +import frc.alotobots.library.subsystems.vision.localizationfusion.PoseSource; +import frc.alotobots.library.subsystems.vision.photonvision.apriltag.AprilTagSubsystem; + +/** + * Adapts the AprilTag vision system to the standardized PoseSource interface. + * + *

This class wraps the AprilTagSubsystem to provide pose estimation through the standardized + * PoseSource interface. AprilTags serve as: - Secondary pose validation source - Initial pose + * calibration reference - Backup pose estimation when Quest is unavailable + * + *

Pose accuracy depends on: - Number of visible AprilTags - Distance to detected tags - Camera + * calibration quality - Tag pose ambiguity factors + */ +public class AprilTagPoseSource implements PoseSource { + /** The underlying AprilTag subsystem */ + private final AprilTagSubsystem subsystem; + + /** + * Creates a new AprilTagPoseSource. + * + * @param subsystem The AprilTag subsystem to wrap + */ + public AprilTagPoseSource(AprilTagSubsystem subsystem) { + this.subsystem = subsystem; + } + + /** + * {@inheritDoc} + * + *

For AprilTags, connection status depends on: - Camera connection status - Valid pose + * availability - Recent detection history + */ + @Override + public boolean isConnected() { + return subsystem.isConnected(); + } + + /** + * {@inheritDoc} + * + *

AprilTag poses are: - Field-relative - Validated for ambiguity - Filtered for physical + * feasibility - Updated at camera frame rate + */ + @Override + public Pose2d getCurrentPose() { + return subsystem.getCurrentPose(); + } + + /** + * {@inheritDoc} + * + *

Measurement uncertainty scales with: - Tag distance - Number of visible tags - Viewing angle + * - Tag pose ambiguity + */ + @Override + public Matrix getStdDevs() { + return subsystem.getStdDevs(); + } + + /** {@inheritDoc} */ + @Override + public String getSourceName() { + return "AprilTag"; + } +} diff --git a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/objectdetection/commands/PathfindToBestObject.java b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/objectdetection/commands/PathfindToBestObject.java index 6008f691..c907db6e 100644 --- a/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/objectdetection/commands/PathfindToBestObject.java +++ b/src/main/java/frc/alotobots/library/subsystems/vision/photonvision/objectdetection/commands/PathfindToBestObject.java @@ -25,6 +25,7 @@ import frc.alotobots.library.subsystems.swervedrive.SwerveDriveSubsystem; import frc.alotobots.library.subsystems.swervedrive.commands.DefaultDrive; import frc.alotobots.library.subsystems.swervedrive.commands.DriveFacingPose; +import frc.alotobots.library.subsystems.swervedrive.util.PathPlannerManager; import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.ObjectDetectionSubsystem; import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.io.ObjectDetectionIO; import frc.alotobots.library.subsystems.vision.photonvision.objectdetection.util.GameElement; @@ -42,6 +43,9 @@ public class PathfindToBestObject extends Command { /** Subsystem for controlling robot movement */ private final SwerveDriveSubsystem swerveDriveSubsystem; + /** The pathplanner instance */ + private final PathPlannerManager pathPlannerManager; + /** Array of game elements to target, in priority order */ private final GameElement[] targetGameElementNames; @@ -64,9 +68,11 @@ public class PathfindToBestObject extends Command { public PathfindToBestObject( ObjectDetectionSubsystem objectDetectionSubsystem, SwerveDriveSubsystem swerveDriveSubsystem, + PathPlannerManager pathPlannerManager, GameElement... targetGameElementNames) { this.objectDetectionSubsystem = objectDetectionSubsystem; this.swerveDriveSubsystem = swerveDriveSubsystem; + this.pathPlannerManager = pathPlannerManager; this.targetGameElementNames = targetGameElementNames; this.driveFacingPose = new DriveFacingPose(swerveDriveSubsystem); this.defaultDrive = new DefaultDrive(swerveDriveSubsystem); @@ -119,7 +125,7 @@ public void execute() { new Pose2d(objectPose.getTranslation().plus(offsetTranslation), objectPose.getRotation()); Command command = - swerveDriveSubsystem.getPathFinderCommand( + pathPlannerManager.getPathFinderCommand( targetPose, LinearVelocity.ofBaseUnits(0, Units.MetersPerSecond)); command.schedule(); }