Gray Matter
WorkshopExample: Drive to Tag
WPILib 2027 is still in alpha: these pages change as the APIs settle.
LESSON 30

Example: Drive to Tag

Hold X and the robot drives to a meter in front of an AprilTag, squares up to it, and stops. No odometry, no field map: the camera is the only sensor.

Branch7-InlineCommands20 minutes, plus bench time
You’ll need
  • Coroutines: coroutine.yield() and what a coroutine body is.
  • Vision: the Limelight, AprilTags, and LimelightHelpers.
  • Profiled Drive to Point: trapezoid profiles, PID plus feedforward.

The branch adds one file, commands/DriveToTagInline.java, and four lines in opmodes/TeleopOpMode.java. Nothing else changes.

The binding is one line: driver.x().whileTrue(DriveToTagInline.create(drivetrain, "limelight", 1, 1.0)). The arguments are the drivetrain, the camera's NetworkTables name, the tag ID, and the standoff in meters. Tag 1 is a placeholder.

Loading file...

The tag's frame

Every other drive command here works in field space, a Pose2d from the blue corner. This one works in target space, where the origin is the tag. No odometry needed.

DriveToTagInline.java: the pose helper
/**
* The robot's pose in the tag's frame, or null when the camera isn't looking at our tag.
*
* <p>Limelight target space: +X is the tag's right, +Y is down, +Z points out of the tag face. So
* distance is Z, sideways is X, squareness is the rotation about Y. TODO: verify the signs on
* hardware.
*/
private static Pose3d readRobotInTag(String limelightName, int targetTagId) {
// Also false when the camera is unplugged.
if (!LimelightHelpers.getTV(limelightName)) {
return null;
}
if ((int) LimelightHelpers.getFiducialID(limelightName) != targetTagId) {
return null;
}
Pose3d pose = LimelightHelpers.getBotPose3d_TargetSpace(limelightName);
// All zeros means no target-space data yet.
return pose.equals(Pose3d.kZero) ? null : pose;
}

Three checks, and any one failing means there is no usable reading. The ID and the pose are published separately, so some frames carry the right ID and a pose of all zeros. Driving on those would mean driving at a tag underneath the robot.

Do not delete the ID check. The camera reports one tag at a time, and without it the robot drives at whichever tag becomes primary. No error, no warning.

One controller per axis

Distance, sideways offset, and squareness are independent, so each gets its own ProfiledPIDController. That class is new here: a trapezoid profile and a PID controller in one object. All three are locals in create(...), and the coroutine body closes over them.

The tolerances are 3 cm and 2 degrees. setTolerance sets the band atGoal() reads, which is how the command decides it has arrived.

  • enableContinuousInput goes on the heading controller only. Angles wrap. Without it, a controller sent from +3.0 rad to -3.0 rad takes the long way round instead of the 0.28 rad shortcut.
  • ApplyRobotVelocity, not ApplyFieldVelocity. This command has no idea where the field is. It knows forward and left as the robot sees them.

The loop

Above the loop, setPriorityTagID pins the camera to our tag. Then reset(...) plants each profile at the current measurement, so the first pass ramps instead of lurching. Everything below runs once per robot loop.

When the helper returns null, the loop sends new SwerveRequest.Idle(), yields, and looks again. It does not guess and it does not give up. That is why the loop is while (true) with a break in the middle: the finish condition is not always answerable.

DriveToTagInline.java: three numbers, three speeds
// Which number is which is explained on readRobotInTag below.
double measuredDistance = robotInTag.getZ();
double measuredLateral = robotInTag.getX();
double measuredYaw = robotInTag.getRotation().getY();
 
// Back off to the standoff, slide until centered, turn until square.
double forward =
distance.calculate(measuredDistance, standoffMeters)
+ distance.getSetpoint().velocity;
double sideways =
lateral.calculate(measuredLateral, 0.0) + lateral.getSetpoint().velocity;
double turn = heading.calculate(measuredYaw, 0.0) + heading.getSetpoint().velocity;
 
// TODO: if the robot slides or turns the wrong way, flip that value's sign.
drivetrain.setControl(
driveRequest.withVelocity(new ChassisVelocities(forward, sideways, turn)));

Each speed is a plan plus a correction. getSetpoint().velocity is what the profile planned for this instant; calculate(...)is the PID output on top. Two of the goals are zero, because centered and square are both zero in the tag's frame. The third is the standoff, since zero distance is inside the tag.

The break sits after those three calculate(...) calls, on distance.atGoal() && lateral.atGoal() && heading.atGoal(). Move it above them and you are asking a controller with no measurement whether it has arrived.

Watch out

Every gain ships at zero

All three controllers are built with kP, kI and kD at 0.0, so calculate(...) returns zero and the profile does the whole job. That is a safer first run than an untuned gain.

Nothing corrects error, though. When the profile runs out the robot stops wherever it is, and 20 cm short stays 20 cm short. DriveToPoint.java, in the same folder, says which way to move kP.

Cleanup, twice

DriveToTagInline.java: the two exits
// Cleanup, on a normal finish.
drivetrain.setControl(new SwerveRequest.Idle());
LimelightHelpers.setPriorityTagID(limelightName, -1); // -1 = no priority
})
// Being interrupted skips the cleanup above, so repeat it here.
.whenCanceled(
() -> {
drivetrain.setControl(new SwerveRequest.Idle());
LimelightHelpers.setPriorityTagID(limelightName, -1);
})
.named("DriveToTagInline");

Breaking out of the loop falls through to the two lines below it. Cancellation does not. The body is dropped where it stands, so .whenCanceled(...) repeats both lines.

Don't

Do not lean on the default command

In teleop, deleting .whenCanceled(...) looks harmless. Let go of X, the joystick default command reclaims the drivetrain, and the robot stops. That is the default command, not your cleanup.

Now schedule the same command from an autonomous OpMode, where the drivetrain has no default command. The last velocity request stays in force and the robot keeps rolling.

Check your work

There is no camera in simulation, so the helper returns null every pass and the command idles forever. That is the correct result, and it still checks the binding, the requirements, and the guard clause.

  1. Run gradlew simulateJava and Enable. Drive with the left stick, then hold X and keep pushing. You should see: the robot stops dead and the sticks do nothing. Release X and they work again.
  2. Put the robot on blocks, with a printed tag two meters away. Confirm the Limelight dashboard reports the right ID before you enable.
  3. Enable and hold X. You should see: the wheels swing to an angle and spin. Wrong direction means a sign to flip, and blocks make that check free.
  4. Cover the camera with your hand. You should see: the wheels stop within a loop or two, and start again when you uncover it.
  5. Signs right? On the floor, area clear, hold X. You should see: a ramp, a cruise, a slow-down, then a stop a meter out and square to the tag.
Watch out

If it did not work

Holding X does nothing, ever. The helper is returning null every pass. Check the dashboard: either the camera name in the binding is wrong, or the tag ID is, or the camera cannot see the tag. The name has to match what Robot.java passes to Limelight.registerAll(...), and the name on the camera.

It drives away, slides sideways, or spins. That is a sign. Negate the one value that matches what the robot did, and only that one.

It stops short and never ends. The profile finished and there is no kP to close the last gap, so the measurement stays outside the 3 cm tolerance. Give distance and lateral a small kP.

Check yourself

01

In a coroutine body, which part corresponds to a ClassicCommand's initialize()?

02

Why does this command work in the tag's frame instead of field space?

03

All three controllers ship with kP, kI and kD set to 0.0. What is driving the robot?

04

The camera loses the tag halfway through a run. Then what?

05

Why does the cleanup appear twice, once after the loop and once in .whenCanceled(...)?

06

The loop breaks once all three controllers report atGoal(). Why does that check sit after the three calculate(...) calls?

Pick an answer for each.