Lab 3: Buttons and Triggers#
Why it matters#
In Lab 1 you made one button spin a motor while you held it. Real robots need more than that: tap a button to start something, tap another to stop it, flip a mode on and off, run something for exactly two seconds, or use a trigger as a gas pedal.
All of that comes down to how a command is started and how it ends. In this lab you'll wire up five different kinds of button on the same motor and watch exactly how each one behaves. Once you've felt the difference, you'll never mix up whileTrue and onTrue again.
Before you start: finish Lab 1. You'll add to the same TestBed project and the same SpinBrushlessMotorSubsystem. Lesson 7.1 explains what's happening underneath.
Read this#
Walkthrough#
Two questions every command answers#
Every command you bind to a button has to answer two questions:
- When does it start? That's decided by the binding:
whileTrue,onTrue,toggleOnTrue, and so on. - When does it end? That's decided by the binding and by the command itself: does it finish on its own, or run until something stops it?
Get either one wrong and the motor won't stop when you expect, or won't start at all.
Step 1: Add more commands to the subsystem#
Open SpinBrushlessMotorSubsystem.java. You already have spinMotorCommand. Add these three below it:
/** Stops the motor once, then ends immediately. */
public Command stopCommand() {
return runOnce(() -> setMotorSpeed(0.0)).withName("Stop");
}
/** Spins at a speed for a set number of seconds, then stops on its own. */
public Command spinForSecondsCommand(double speed, double seconds) {
return spinMotorCommand(speed).withTimeout(seconds).withName("Spin " + seconds + " s");
}
/** Spins at whatever speed the supplier gives, read fresh every 20 ms. */
public Command spinWithSupplierCommand(DoubleSupplier speed) {
return runEnd(
() -> setMotorSpeed(speed.getAsDouble()), // every loop: read the value, set the motor
() -> setMotorSpeed(0.0) // when it ends: stop
).withName("Throttle");
}
Add this import at the top:
import java.util.function.DoubleSupplier;
The three commands, piece by piece#
stopCommand
return runOnce(() -> setMotorSpeed(0.0)).withName("Stop");
runOnce(...) builds a command that runs its code one time and then finishes right away. Here, it sets the speed to 0. Because it requires the subsystem, starting it also interrupts whatever command was using the motor.
spinForSecondsCommand
return spinMotorCommand(speed).withTimeout(seconds).withName("Spin " + seconds + " s");
This doesn't write a new command from scratch. It reuses spinMotorCommand and adds a decorator:
| Part | What it does |
|---|---|
spinMotorCommand(speed) |
The command from Lab 1: start spinning, stop when it ends |
.withTimeout(seconds) |
Wraps it so it ends by itself after that many seconds. Ending runs spinMotorCommand's end action, so the motor stops. |
"Spin " + seconds + " s" |
Joins text and a number into one name, like Spin 2.0 s |
spinWithSupplierCommand
return runEnd(
() -> setMotorSpeed(speed.getAsDouble()), // every loop
() -> setMotorSpeed(0.0) // when it ends
).withName("Throttle");
| Part | What it does |
|---|---|
DoubleSupplier speed |
Something you can ask for a number at any time. Here it will be "how far is the trigger squeezed?" |
runEnd(run, end) |
Runs the first lambda every loop while the command runs, and the second once when it ends |
speed.getAsDouble() |
Asks the supplier for the current number, fresh every 20 ms |
Compare runEnd with Lab 1's startEnd: startEnd sets the speed once at the start, which is fine for a fixed speed. A trigger keeps changing, so it has to be read every loop.
When does each command end?
| Command | Ends on its own? | How it ends |
|---|---|---|
spinMotorCommand |
No | Only when something cancels it. Then it stops the motor. |
stopCommand |
Yes, instantly | Sets the speed to 0 once and finishes |
spinForSecondsCommand |
Yes | After the time runs out. withTimeout cancels it, which stops the motor. |
spinWithSupplierCommand |
No | When canceled. Then it stops the motor. |
Step 2: Bind five buttons#
Open RobotContainer.java. Keep Lab 1's A binding and add the rest in configureBindings():
private void configureBindings() {
// A: spin WHILE held (Lab 1)
m_controller.a().whileTrue(
m_spinBrushlessMotorSubsystem.spinMotorCommand(
Constants.kBrushlessConstants.BrushlessMotorSpeed));
// B: tap to START spinning. Nothing in this binding stops it!
m_controller.b().onTrue(
m_spinBrushlessMotorSubsystem.spinMotorCommand(
Constants.kBrushlessConstants.BrushlessMotorSpeed));
// X: tap to STOP
m_controller.x().onTrue(m_spinBrushlessMotorSubsystem.stopCommand());
// Y: tap once to start, tap again to stop
m_controller.y().toggleOnTrue(
m_spinBrushlessMotorSubsystem.spinMotorCommand(
Constants.kBrushlessConstants.BrushlessMotorSpeed));
// Right bumper: tap to spin for exactly 2 seconds
m_controller.rightBumper().onTrue(
m_spinBrushlessMotorSubsystem.spinForSecondsCommand(
Constants.kBrushlessConstants.BrushlessMotorSpeed, 2.0));
// Right trigger: squeeze like a gas pedal. Harder = faster.
m_controller.rightTrigger().whileTrue(
m_spinBrushlessMotorSubsystem.spinWithSupplierCommand(
() -> m_controller.getRightTriggerAxis() * 0.6)); // capped at 60%
// Show which commands are running, live
SmartDashboard.putData(CommandScheduler.getInstance());
}
Add the imports CommandScheduler and SmartDashboard if VS Code underlines them (click the lightbulb).
The bindings, piece by piece#
Every binding has the same shape: a trigger, then a binding type, then a command.
m_controller.b().onTrue( m_spinBrushlessMotorSubsystem.spinMotorCommand(...) );
// trigger binding command
| Trigger | Becomes true when |
|---|---|
m_controller.a(), b(), x(), y() |
That button is pressed |
m_controller.rightBumper() |
The right bumper is pressed |
m_controller.rightTrigger() |
The right trigger is squeezed past halfway |
| Binding type | What it does with the command |
|---|---|
whileTrue(cmd) |
Starts it when the trigger becomes true, cancels it when the trigger becomes false |
onTrue(cmd) |
Starts it when the trigger becomes true. Never cancels it. |
toggleOnTrue(cmd) |
Each press flips it: starts it if it isn't running, cancels it if it is |
So A (whileTrue) spins only while you hold it, but B (onTrue) starts the same command and never stops it. Step 4 is about that.
The last line, SmartDashboard.putData(CommandScheduler.getInstance()), puts the command scheduler on the dashboard: a live list of every command that's running.
Why SmartDashboard here?
The scheduler isn't a single number. It's a Sendable, a WPILib object that describes itself to the dashboard. In 2026, Sendables still go on the dashboard with SmartDashboard.putData. Plain values always use the course's Telemetry library (Lesson 6.1).
rightTrigger() vs. getRightTriggerAxis()
rightTrigger() is a button: it becomes true once you squeeze past halfway. getRightTriggerAxis() is a number from 0 to 1 that tells how far you're squeezing. The binding uses the button to start the command, and the command reads the number every loop.
Step 3: Test each button and fill in the table#
Deploy, open Elastic, find Scheduler in the SmartDashboard table, drag it onto your tab, and enable. Test one button at a time. For each, write down what you observe:
| Button | Binding | What starts it? | What stops it? | Your notes |
|---|---|---|---|---|
| A | whileTrue |
|||
| B | onTrue |
|||
| X | onTrue (stop) |
|||
| Y | toggleOnTrue |
|||
| Right bumper | onTrue + timeout |
|||
| Right trigger | whileTrue + supplier |
Watch the Scheduler widget while you test. You'll see each command's name appear when it starts and disappear when it ends.
Step 4: The B button puzzle#
Tap B. The motor spins and keeps spinning after you let go. Now try each of these and explain what happens:
- Tap B, then tap X.
- Tap B, then hold A and let go.
- Tap B, then tap the right bumper and wait.
In every case the motor stops, but for the same reason: only one command can use a subsystem at a time. All of these commands belong to SpinBrushlessMotorSubsystem, so starting any of them interrupts whatever was running. The interrupted command's end action runs, and the new command takes over.
The classic beginner bug
onTrue with a command that never ends means "start this and never stop it." That's fine when another button stops it (like B and X here), but it's a bug when you meant "while held." If a motor won't stop when you let go, check whether it's bound with onTrue.
Step 5: A default command#
Right now, when no command is running, the SPARK MAX just keeps doing whatever it was last told. A default command runs whenever nothing else is using the subsystem, so you can make "stopped" the resting state.
In configureBindings(), after the button bindings:
m_spinBrushlessMotorSubsystem.setDefaultCommand(
m_spinBrushlessMotorSubsystem.run(() -> m_spinBrushlessMotorSubsystem.setMotorSpeed(0.0))
.withName("Idle"));
| Part | What it does |
|---|---|
setDefaultCommand(cmd) |
Tells the scheduler: "whenever no other command is using this subsystem, run this one" |
m_spinBrushlessMotorSubsystem.run(...) |
Builds a command from outside the subsystem. It still requires the subsystem, because you called run on it. |
() -> ...setMotorSpeed(0.0) |
Every loop, set the speed to 0 |
Deploy and watch the Scheduler widget. Idle shows whenever no button command is running. Press any button: Idle disappears, the button's command runs, and when it ends, Idle comes right back.
Think about it
The default command uses run, not runOnce. Why? (Hint: what would happen if the default command finished instantly? See Lesson 7.1.)
Step 6: Commit#
git add .
git commit -m "Lab 3: button bindings"
Finished code
Stuck, or missed a lab? lab03-buttons-triggers on GitHub is the complete project as it looks at the end of this lab. It's a full WPILib 2026 project you can open in VS Code, build, and deploy. Try to finish the lab yourself first.
Try it#
- Fill in the whole table in Step 3
- Do the three B puzzles in Step 4 and explain each one in a sentence
- Change the right bumper's time from 2.0 to 0.5 seconds
- Add a left bumper binding with
onFalse: the motor spins for 1 second when you release the bumper - Make the right trigger spin the motor backward instead (hint: one minus sign)
- Bind the left trigger so it only works while A is also held, using
m_controller.leftTrigger().and(m_controller.a()). What happens when you press only one of them?
Check yourself#
What's the difference between whileTrue and onTrue?
whileTrue starts the command when the button is pressed and cancels it when released. onTrue starts it when pressed and then lets it run until it ends on its own or something interrupts it.
Why does tapping X stop the motor that B started?
Both commands use the same subsystem, and only one command can use a subsystem at a time. Starting X's command interrupts B's command, which runs its end action and stops the motor.
How does withTimeout(2.0) make a never-ending command end?
It cancels the command after 2 seconds. Canceling runs the command's end action, which stops the motor.
Why does the throttle command use a DoubleSupplier instead of a plain number?
So it reads the trigger every loop. A plain number would be read once when the robot starts, and the speed would never change.
What does a default command do?
It runs whenever no other command is using that subsystem, so the subsystem always has a known resting behavior.