Gray Matter
WorkshopExample: Drive to Tag
Rough draft: nobody has reviewed this lesson yet, and it may not be how things are done this season.
LESSON 34

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.

13 minutes
You’ll need
  • Coroutines: coroutine.yield() and a waitUntil with a time limit.
  • Vision: the LimelightLib vendordep installed, and your camera's name.
  • Profiled Drive to Point: trapezoid profiles, PID plus feedforward.

One new file, commands/DriveToTagInline.java, and one line in TeleopOpMode: driver.x().whileTrue(DriveToTagInline.create(robot.drivetrain, "limelight", 1, 1.0)). The arguments are the drivetrain, the camera's name, the tag ID, and the standoff in meters. Tag 1 is a placeholder.

The file imports com.limelightvision.Limelight and FiducialTarget from the same package, plus a static import of org.wpilib.units.Units.Seconds. VS Code offers the rest as you type.

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. LimelightLib uses the same axes in every space: X points out of the tag's face, Y to the tag's left, and Z up.

DriveToTagInline.java: the pose helper
/** The robot's pose in our tag's frame, or null when the camera can't see that tag. */
private static Pose3d readRobotInTag(Limelight camera, int targetTagId) {
// False when the camera is unplugged, its newest frame is stale, or it sees nothing.
if (!camera.hasTarget()) {
return null;
}
for (FiducialTarget tag : camera.getLatestResults().fiducialTargets) {
if (tag.fiducialId == targetTagId) {
return tag.getRobotPose_TargetSpace();
}
}
return null;
}

A frame lists every tag in view, each with its own robot pose. The loop picks ours by ID and ignores the rest, so a second tag drifting into frame cannot pull the robot toward it. Leave the ID check in. Without it the robot drives at whichever tag comes first in the list.

hasTarget() matters because the getters never go blank. After a camera drops out they keep returning its last frame. Once the newest frame is a quarter second old, hasTarget() turns false. The helper then stops handing out a pose the robot has already driven past.

One controller per axis

DriveToTagInline.java: create(...), the setup
public static Command create(
DriveMechanism drivetrain, String cameraName, int targetTagId, double standoffMeters) {
Limelight camera = new Limelight(cameraName);
 
// One profiled PID per axis: the profile plans a smooth ramp, PID trims the drift.
// TODO: tune the speed limits and kP on your robot.
ProfiledPIDController distance =
new ProfiledPIDController(0.0, 0.0, 0.0, new TrapezoidProfile.Constraints(2.5, 3.0));
ProfiledPIDController lateral =
new ProfiledPIDController(0.0, 0.0, 0.0, new TrapezoidProfile.Constraints(2.5, 3.0));
ProfiledPIDController heading =
new ProfiledPIDController(
0.0, 0.0, 0.0, new TrapezoidProfile.Constraints(Math.PI, 2.0 * Math.PI));
 
SwerveRequest.ApplyRobotVelocity driveRequest =
new SwerveRequest.ApplyRobotVelocity()
.withDriveRequestType(DriveRequestType.OpenLoopVoltage);
 
heading.enableContinuousInput(-Math.PI, Math.PI);
distance.setTolerance(0.03); // meters
lateral.setTolerance(0.03); // meters
heading.setTolerance(Math.toRadians(2.0)); // radians

Distance, sideways offset and squareness are independent, so each gets its own ProfiledPIDController: a trapezoid profile and a PID controller in one object. All of them are locals, and the coroutine body below closes over them. setTolerance sets the band atGoal() reads, which is how the command decides it has arrived.

  • enableContinuousInput goes on the heading controller only. Angles wrap, and squared up sits right on the wrap, as the next section shows.
  • ApplyRobotVelocity, not ApplyFieldVelocity. This command has no idea where the field is. It knows forward and left as the robot sees them.

The Limelight made here is a second reader on the camera Vision already uses. It is safe. It only looks at the newest frame and never touches the queue Vision works through.

The loop

DriveToTagInline.java: create(...), the command
return drivetrain
.run(
coroutine -> {
boolean tracking = false;
 
while (true) {
Pose3d robotInTag = readRobotInTag(camera, targetTagId);
 
// No tag in view: stop, then give it a second to appear before giving up.
if (robotInTag == null) {
stop(drivetrain, driveRequest);
tracking = false;
if (coroutine
.waitUntil(() -> readRobotInTag(camera, targetTagId) != null, Seconds.of(1.0))
.timedOut()) {
return;
}
continue;
}
 
// First reading since the tag came into view. Start each profile here, at rest.
if (!tracking) {
distance.reset(robotInTag.getX());
lateral.reset(robotInTag.getY());
heading.reset(robotInTag.getRotation().getZ());
tracking = true;
}
 
// Back off to the standoff, slide until centered, turn until square.
double forward =
distance.calculate(robotInTag.getX(), standoffMeters)
+ distance.getSetpoint().velocity;
double sideways =
lateral.calculate(robotInTag.getY(), 0.0) + lateral.getSetpoint().velocity;
double turn =
heading.calculate(robotInTag.getRotation().getZ(), Math.PI)
+ heading.getSetpoint().velocity;
 
// Facing the tag, the robot's forward is the tag's -X and its left is the tag's -Y.
drivetrain.setControl(
driveRequest.withVelocity(new ChassisVelocities(-forward, -sideways, turn)));
 
if (distance.atGoal() && lateral.atGoal() && heading.atGoal()) {
break;
}
coroutine.yield();
}
 
stop(drivetrain, driveRequest);
})
.whenCanceled(() -> stop(drivetrain, driveRequest))
.named("DriveToTagInline");
}

With no tag in view the loop stops the robot and waits up to a second for one. A tag that never shows up ends the command, stopped, rather than leaving a held button doing nothing forever. When a tag does show up, tracking is false, so each profile restarts from the robot's real position at rest. Without that, a profile picks up its old plan mid-ramp and the robot lurches.

Each speed is a plan plus a correction. getSetpoint().velocity is what the profile planned for this instant, and calculate(...) adds the PID output on top. Centered is zero, and the distance goal is the standoff. Squared up means facing the tag, so the robot points back down the tag's X axis. That is a yaw of half a turn, Math.PI, right on the wrap the heading controller was told about.

The minus signs come from the same fact. The robot faces the tag, so its forward and left run opposite to the tag's X and Y. The break sits after the three calculate(...) calls. 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 start 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, 20 cm short stays 20 cm short.

A stop on every exit

DriveToTagInline.java: the stop helper
/** Sends zero speed. Ending a command does not do this for you. */
private static void stop(DriveMechanism drivetrain, SwerveRequest.ApplyRobotVelocity request) {
drivetrain.setControl(request.withVelocity(new ChassisVelocities()));
}

The command can end three ways. It arrives and breaks out of the loop. It waits a second for a tag that never comes and returns. Or it is canceled, because the driver let go of X. The first two run a stop(...) in the body. A canceled body is dropped where it stands and runs nothing more, so .whenCanceled(...) calls the same helper.

The helper exists because ending a command does not stop a motor. In teleop the joystick default would cover for a missing stop. Schedule this from an autonomous OpMode, where the drivetrain has no default, and the robot keeps rolling at its last speed.

Check your work

There is no camera in simulation, so the helper returns null, the wait runs out after a second, and the command ends stopped. That still checks the binding and the requirement.

  1. In simulation, enable Teleop and drive with the left stick, then hold X and keep pushing. You should see: the robot stops dead. A second later the command ends and the sticks work again, X still held.
  2. Put the robot on blocks, with a printed tag two meters away. Confirm the Limelight web interface 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 few loops. Uncover it inside a second and they start again. Leave it covered and the command ends.
  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 stops the robot and nothing else. The helper returns null every pass. Either the camera name is wrong, the tag ID is wrong, or the camera cannot see the tag. The web interface settles which.

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

You hold X and the camera never sees tag 1. What does the command do?

Two tags are in frame, tag 1 and tag 4, and you asked for tag 1. Which pose does readRobotInTag return?

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

Squared up and facing the tag, what yaw does the robot have in the tag's frame?

Why does .whenCanceled(...) call stop(...) when the body already ends with one?

Someone swaps stop(...) for drivetrain.setControl(new SwerveRequest.Idle()). What happens when the command ends in autonomous?

Pick an answer for each.