State Machines
Your teleop is four independent button bindings, and none of them names what the robot is doing. This lesson rebuilds it as four named states with the arrows drawn between them. A jump nobody drew cannot happen.
- The arm and flywheel from Finish Conditions, including
flywheel.isAtTarget(). - The
mech-6-StateBasedbranch, one commit pastmech-5-Coroutines. It touches one file. - Your gains from PID Tuning in Tuner X and Motion Magic, ready to paste back.
- The simulator running, and a controller on port 0.
Each binding holds its own preset. Hold A and the flywheel spins, let go and it stops. Nothing names the difference between an arm that is up to shoot and one that is up by accident.
Pull the left trigger during the Y routine and the arm changes hands, which cancels the whole Y group. The flywheel keeps spinning, because a canceled command does not zero the last request.
A Command.sequence only moves forwards. A machine is a graph, so reach for one when a routine has to repeat or skip a phase.
One command per state
A state is one command that runs the whole time the machine sits in it. A transition cancels that command and starts the next one.
A state has to pose the whole robot. If Stowed only said "arm vertical," the flywheel would sit wherever the last state left it. So each state commands both mechanisms at once.
Command.parallel(a, b) starts both at once and finishes only when both have finished. Command.race ends its group on the first member to finish and cancels the rest. Two holds never finish, so neither does a parallel group. In a Command.sequencethat is a bug. Here it is fine: the machine cancels a state's command the moment a transition fires.
Build the machine
The constructor from last lesson gets replaced. Four imports are new: StateMachine, StateMachine.State and Scheduler from org.wpilib.command3, plus org.wpilib.system.DataLogManager.
StateMachine sm = new StateMachine("Superstructure");The name is required and reaches telemetry.- Add one state per pose with
sm.addState(...). Each call returns aStatefor step 4. sm.setInitialState(stowed);Leave it out andgradlew buildfails.- Wire the transitions. Declare the bare
switchFromAny()last: it only covers states that exist when it runs.
// 2. Add states. Each state owns one command. parallel(...) turns two commands into one, so// each state poses the whole robot. The poses are holds that never finish - that is fine,// because the machine cancels the old state's command when it switches. SpinUp is the// exception: .until(...) gives its command an ending, so it can use whenComplete() below.State stowed = sm.addState(Command.parallel(arm.vertical(), flywheel.stop()).named("Stowed (hold)"));State pickup = sm.addState(Command.parallel(arm.horizontal(), flywheel.stop()).named("Pickup (hold)"));State spinUp = sm.addState( Command.parallel(arm.vertical(), flywheel.runFast()) .until(flywheel::isAtTarget) .named("SpinUp until at speed"));State ready = sm.addState( Command.parallel(arm.vertical(), flywheel.runFast()).named("ReadyToShoot (hold)"));spinUp and ready pose the robot identically. The difference is that spinUp ends, which names the moment the shot becomes legal.
// 4. Wire the transitions. Each condition is checked every loop while its state is active,// and fires the moment it flips from false to true.stowed.switchTo(pickup).when(driver.leftTrigger()); // driver asks to intakepickup.switchTo(stowed).when(driver.leftTrigger().negate()); // trigger released - pack up stowed.switchTo(spinUp).when(driver.rightTrigger()); // driver asks to shoot // SpinUp's command ends on its own, so this uses whenComplete(): it fires once, when the// command finishes. Ready runs the same pose as SpinUp - it exists so drivers, LEDs, and// autos can tell "spinning up" apart from "ready to shoot".spinUp.switchTo(ready).whenComplete(); // Releasing the right trigger backs out of either shooting state.sm.switchFromAny(spinUp, ready).to(stowed).when(driver.rightTrigger().negate()); // B is the escape hatch: back to stowed from ANY state.// switchFromAny() with no args covers every state added so far, so declare it last.sm.switchFromAny().to(stowed).when(driver.b());Read the arrows and the robot's behavior fits on one screen. Notice the absence: no arrow runs from pickup to spinUp. You cannot start a shot with the arm down, because nobody drew it.
Hand it to the scheduler
A StateMachine implements Command, so a field holds it directly. Building one still runs nothing until the scheduler is handed it.
// Field on the OpMode - this one is new:private final Command machine;// (the driver field is already there from last lesson, unchanged) // Last line of the constructor:machine = sm; // a StateMachine is just a Command - schedule it like any other // Two overrides, below the constructor:@Overridepublic void start() { Scheduler.getDefault().schedule(machine);} @Overridepublic void end() { Scheduler.getDefault().cancel(machine);}Two kinds of transition
The state settles which spelling you use, not the condition. whenComplete()fires only when a state's command ends on its own. Only spinUp qualifies: .until(flywheel::isAtTarget) gives it an ending.
A rising edge is the first loop a condition goes from false to true. It has to go false again before it fires twice, so holding B does not spin the machine. The machine checks conditions in declaration order and the first edge wins, so a second one true at the same moment never fires.
.negate() hands back a Triggerthat is true exactly when the original is false. That is how the branch writes "the driver let go." Inverting a plain method means a ! in front of your lambda: one character that reverses the whole line.
Check your work
The branch ships the arm untuned
mech-6-StateBased ships Arm.java with kG, kS, kP and kD at 0.0, and MotionMagicCruiseVelocity and MotionMagicAcceleration at 0.0. The arm will not move. Paste your tuned values back first, or you will decide the machine is broken while it does exactly what you asked.
Add a pair of log markers first. A working machine and a stuck one look identical from the driver seat.
// onEnter/onExit run small extras on the way in and out of a state, without touching the// state's command. These two write markers into the log, so you can see exactly when the// machine entered and left ReadyToShoot.ready.onEnter(() -> DataLogManager.log("Superstructure: entered ReadyToShoot"));ready.onExit(() -> DataLogManager.log("Superstructure: left ReadyToShoot"));DataLogManager.log(...) prints to the console and writes into the .wpilog. On a transition onExit runs, then the cancel, then the next state's command, all in one loop.
- Enable in the simulator. The arm drives to vertical and the flywheel stays stopped. That is
Stowed (hold), running with no button pressed. - Hold the left trigger and the arm swings to horizontal. Release it and the arm comes back. That is stowed to pickup and back again.
- Hold the right trigger. The flywheel spins up and
entered ReadyToShootlands in the console. Release it and the flywheel stops. - Hold the left trigger, then press B while still holding it. The arm returns to vertical and stays there. Pickup needs the trigger released and pulled again.
- From pickup, pull the right trigger as well. Nothing happens. There is no arrow from
pickuptospinUp, so that jump does not exist.
Three ways this goes wrong
The build fails. Either setInitialState(...) is missing, or a Command.parallel(...) group has no .named(...). Both are compiler-plugin errors, and the message names which.
It never leaves SpinUp. .until(flywheel::isAtTarget) never comes true. The tolerance is 0.5 rotations per second. The branch ships the flywheel with kS and kP at 0.0, so nothing corrects the last of the error. Log the measured speed against the target and read the gap.
It reaches ReadyToShoot at once. getTargetVelocity() returns 0 until setVelocity(...) has run once, so a stopped wheel already counts as at target. Require the measured speed to be above zero too.
Check yourself
Why does each state on the branch use Command.parallel(arm.…(), flywheel.…()) instead of a single mechanism command?
What is the difference between Command.parallel(a, b) and Command.race(a, b)?
SpinUp uses spinUp.switchTo(ready).whenComplete() while every other transition uses .when(...). Why?
You add a fifth state to the machine, on the line right after sm.switchFromAny().to(stowed).when(driver.b());. Pressing B from that new state does nothing. Why?
A conditional transition fires while a state's command is running. In what order do things happen?
When should you reach for a StateMachine instead of Command.sequence?