[cmd3] Add a declarative state machine API on top of commands v3 (#8297)

This provides an API for writing a finite state machine compatible with
the commands v3 framework. Individual states in the state machine are
wrappers around command objects (which may themselves be state
machines). Transitions between states are defined with a staged builder
DSL similar to command builders, and uses `@NoDiscard` to catch
partially configured transitions.

The FSM API is meant to handle highly complex cases that the fluent
command chaining DSL and coroutine-based imperative commands cannot
easily represent; specifically, where a command sequence may want to go
back to an arbitrary previous state or skip forward to an arbitrary
future state.

Here's an example from the design doc for a command that will drive to a
known scoring location, aim at a scoring target, and repeatedly shoot
balls until a storage hopper is empty. It also has conditions to stop
shooting and move back to the scoring location if it's jostled away, and
then automatically resume firing.

```java
public Command autoWithStateMachine() {
  // Declare the state machine
  StateMachine stateMachine = new StateMachine("Auto With State Machine");

  // Define states
  State getInPosition = stateMachine.addState(drivetrain.driveToScoringLocation());
  State aiming = stateMachine.addState(turret.aimAtGoal());
  State scoring = stateMachine.addState(shooter.fireOnce());
  State celebrating = stateMachine.addState(leds.celebrate());

  // Set the initial state. Neglecting this will cause a runtime exception when the state machine starts.
  // Teams using the WPILib compiler plugin will get a compiler error if they do not set this
  stateMachine.setInitialState(getInPosition);

  // Switch to aiming when we reach the scoring location.
  getInPosition.switchTo(aiming).whenComplete();
  // Set the swerve wheels in an X shape after reaching the scoring location to resist being pushed away.
  getInPosition.onExit(() -> Scheduler.getDefault().fork(drivetrain.setX()));

  // Then start scoring once the turret is aimed at the goal.
  aiming.switchTo(scoring).when(turret::aimedAtGoal);

  // Loop the scoring state as long as the hopper has a ball.
  scoring.switchTo(scoring).whenCompleteAnd(() -> hopper.hasBall());

  // Automatically interrupt any part of the aiming or scoring sequence if
  // the robot is moved away from the scoring location and move back into position.
  stateMachine.switchFromAny(aiming, scoring).to(getInPosition).when(atScoringLocation.negate());

  // Start celebrating once the final ball has been scored.
  scoring.switchTo(celebrating).whenCompleteAnd(() -> !hopper.hasBall());

  return stateMachine;
}
```

A compiler check is added to detect object construction that's not
followed by post-construction initializer methods (as defined by the
class by placing `@PostConstructionInitializer` on such methods).
`StateMachine.setInitialState` uses this to detect team code that
creates a state machine but does not set its initial state.
This commit is contained in:
Sam Carlberg
2026-05-07 23:08:09 -04:00
committed by GitHub
parent 0af65ea787
commit 1021ff88a9
13 changed files with 2811 additions and 20 deletions

View File

@@ -0,0 +1,273 @@
# State Machines in WPILib Commands Version 3
- See [Commands v3](commands-v3.md) for details on the commands framework
## Problem Statement {#problem-statement}
Coroutines are a powerful way to express low- to high-complexity behaviors. However, they become unwieldy at
representing highly complex behaviors where phases may be repeated or skipped to at any point in the sequence. State
machines excel at this by providing ways to transition from any arbitrary state to any other arbitrary state, flattening
the declarative structure of a coroutine into a linear sequence of states and transitions.
Example: consider a FRC game like 2022 Rapid React or 2017 Steamworks. The robot has a drivetrain, a hopper to store
balls, a turret to aim at a goal, and a flywheel shooter to launch balls at the goal. We want an autonomous mode to
drive to a known position on the field for optimal scoring, then aim at the goal, fire balls until the hopper is empty,
and finally play an LED animation to indicate the end of the autonomous sequence. If the robot is moved away from the
scoring location, the scoring portion of the sequence should stop and the robot should move back into position, and then
resume the scoring sequence.
```java
public Command autoWithStateMachine() {
// Declare the state machine
StateMachine stateMachine = new StateMachine("Auto With State Machine");
// Define states
State getInPosition = stateMachine.addState(drivetrain.driveToScoringLocation());
State aiming = stateMachine.addState(turret.aimAtGoal());
State scoring = stateMachine.addState(shooter.fireOnce());
State celebrating = stateMachine.addState(leds.celebrate());
// Set the initial state. Neglecting this will cause a runtime exception when the state machine starts.
stateMachine.setInitialState(getInPosition);
// Switch to aiming when we reach the scoring location.
getInPosition.switchTo(aiming).whenComplete();
// Set the swerve wheels in an X shape after reaching the scoring location to resist being pushed away.
getInPosition.onExit(() -> Scheduler.getDefault().fork(drivetrain.setX()));
// Then start scoring once the turret is aimed at the goal.
aiming.switchTo(scoring).when(turret::aimedAtGoal);
// Loop the scoring state as long as the hopper has a ball.
scoring.switchTo(scoring).whenCompleteAnd(() -> hopper.hasBall());
// Automatically interrupt any part of the aiming or scoring sequence if
// the robot is moved away from the scoring location and move back into position.
stateMachine.switchFromAny(aiming, scoring).to(getInPosition).when(atScoringLocation.negate());
// Start celebrating once the final ball has been scored.
scoring.switchTo(celebrating).whenCompleteAnd(() -> !hopper.hasBall());
return stateMachine;
}
```
```java
Command autoWithCoroutines() {
return Command.noRequirements().executing(coroutine -> {
// Automatically score while the robot is in scoring position.
// This will be canceled if the robot is bumped away from the scoring location.
atScoringLocation.whileTrue(
turret.aimAtGoal()
.andThen(shooter.fireOnce().repeatWhile(hopper::hasBall))
.andThen(leds.celebrate())
.withAutomaticName()
);
// Move back into scoring position if the robot is bumped away from the scoring location.
atScoringLocation.onFalse(drivetrain.driveToScoringLocation());
coroutine.await(drivetrain.driveToScoringLocation());
// Park to allow the triggered commands to run in the background.
// We assume the command will be canceled at the end of the autonomous period.
coroutine.park();
}).named("Auto With Coroutines");
}
```
## Implementation Details {#implementation-details}
### Overview
The public entry point is `org.wpilib.commands3.StateMachine` which implements `Command`.
Each state machine is named; `name()` returns the provided name for telemetry and debugging. State machine names are
specified in the constructor; there is no dedicated builder like `NeedsNameBuilderStage.named(...)` for regular
commands.
`requirements()` returns an empty set; the machine itself does not own any Mechanism. The commands that back states may
have requirements, which will be inherited by the state machine while those states are active, just like a normal
command with nested children.
### Constructing a state machine
State machines are created with `new StateMachine(String)`. The name cannot be null. The `StateMachine` class is final
and cannot be subclassed; v1-style group creation that does setup in a subclass' constructor is not supported:
```java
// Not allowed
class CustomStateMachine extends StateMachine {
// ...
}
```
States are defined with `addState(Command)`. Users need to manually wire states together using transition builders after
defining the states. A `State` object wraps the underlying command and is responsible for tracking the possible
transitions out of that state.
State machines have no initial state, which must be set explicitly:
```java
StateMachine stateMachine = new StateMachine("Example");
State initialState = stateMachine.addState(...);
stateMachine.setInitialState(initialState);
```
`setInitialState` throws an `NullPointerException` if given a null input. It may be called multiple times to override
the initial state before running.
`setInitialState` and all transitions require that both states belong to the same `StateMachine`
object; otherwise an `IllegalArgumentException` is thrown.
### State Machine Loop
A state machine is a regular command that manages the state lifecycle in its `run()` method. The lifecycle is managed by
a loop, where in each iteration the current state's command is scheduled, and then enters an inner loop that continues
to yield as long as the command is running, similar to `Coroutine.waitUntil`. However, the inner loop also checks for
state transitions before calling `yield()`; if a transition is determined to be active, the command is canceled and the
state moves to the transition's target state. The main loop is then restarted with the new state.
If a state's command finishes without triggering any transitions, the state machine checks for state completions. If
a completion is found, the state machine immediately moves to the completion's target state, and (to prevent a potential
infinite loop) conditionally inserts a `yield()` at the very end of the loop in case the command was a one-shot.
Pseudocode:
```
currentState = initialState
state_loop:
while currentState is not null:
currentState.onEnter()
fork currentState.command
didYield = false
while currentState.command is running
for each transition in currentState.transitions
if transition.check()
currentState.onExit()
currentState = transition.targetState
restart state_loop
didYield = true
yield
currentState.onExit()
currentState = currentState.completions.find(completion -> completion.active())?.targetState
if didYield is false and currentState is not null
yield
```
Note that state completions are different from transitions: transitions are only active on rising edge, while state
completions are active on every loop iteration (but are only checked once, when the state exits).
### Transitions
There are two kinds of transitions: conditional transitions (checked while the originating state's
command is running) and completion transitions (taken after the originating state's command finishes
on its own, if no conditional transition was taken).
Transitions are configured using a staged builder setup similar to command builders. The initial builder stage starts
with one or more originating states, then moves to a stage for specifying a target state (which may be null, indicating
that the state machine should exit), and finally to a stage for specifying the condition that triggers the transition.
Transitions start from one or more non-null originating states, and end with a single target state (which may be null,
indicating that the state machine should exit), and a condition that triggers the transition. Transitions are stored on
the originating states, rather than being stored on the state machine (this simplifies the implementation). Each
originating state gets its own copy of the transition.
Transitions can be defined starting from the state machine itself with `StateMachine.switchFromAny(...)`, or from a
specific starting state with `State.switchTo(...)`:
```
stateMachine.switchFromAny(state1, state2).to(state3).when(...)
// Identical to:
state1.switchTo(state3).when(...)
state2.switchTo(state3).when(...)
```
Builders have private constructors, so they cannot be instantiated directly. Users must use the fluent builder factories
with `StateMachine.switchFromAny(...)` or `State.switchTo(...)`.
Transitions must be resilient to commands that transition to themselves like `state.switchTo(state).when(...)`. If the
condition is naively checked, the state machine will enter an infinite loop before it would naturally yield (check
transition -> cancel command -> enter new state -> check transition -> ...). To avoid
this, the condition is checked with rising-edge logic so that the transition is only triggered once per internal loop
iteration:
```java
class Transition {
State targetState;
BooleanSupplier signal;
boolean previousSignal;
boolean shouldTransition() {
boolean currentSignal = signal.getAsBoolean();
boolean signalBecameTrue = currentSignal && !previousSignal;
previousSignal = currentSignal;
return signalBecameTrue;
}
}
```
#### Exiting the state machine explicitly
`State.switchTo(State)` is to be used for state-to-state transitions and cannot accept `null` as an input. To support
exiting the state machine explicitly, there are two additional methods available on the builders:
`State.exitStateMachine()` builds a transition that bypasses the null check and makes the user's intent clear, versus
`State.switchTo(null)` which may be confusing.
`TransitionNeedsTargetStage.exitStateMachine()` is a convenience method that returns a transition to `null`.
```
state.switchTo(null).when(...) // NullPointerException
state.exitStateMachine().when(...) // OK
stateMachine.switchFromAny(state1, state2).toExitStateMachine().when(...)
```
### Callbacks on state entry/exit
States maintain a list of `Runnable` callbacks that are run when the state is entered, and a separate list of callbacks
that are run when the state is exited. Callbacks are run in the order they were added.
Entry callbacks are run immediately after the state's command is forked, so it can see the current command. However,
one-shot commands will complete in the `fork` call, so entry callbacks will not see it.
Exit callbacks are run immediately before canceling the state's command (if the exit was caused by a transition). For
states that complete without a transition, the exit callbacks are run immediately after the state's command finishes and
before the next state is selected.
### Runtime semantics
- When a state becomes active:
1) The state's command is scheduled via `coroutine.fork(state.command)`.
2) `onEnter` callbacks run.
3) While the command is running each scheduler iteration:
- All conditional transitions from this state are checked in insertion order; each evaluates
`shouldTransition()`. The first to trigger wins.
- If a transition triggers: `onExit` callbacks run, the command is canceled, and the next state is set. The
machine immediately begins the next loop iteration with the new state without an extra yield; the next state's
command can start in the same scheduler run. If the next state is null, the machine exits.
- If no transition triggers: the coroutine yields once for this iteration.
- If the command stops running without any conditional transition firing:
- `onExit` callbacks run.
- The next state is selected from completion transitions in insertion order. If none match, the machine exits (next
state is null).
- To ensure fairness and prevent tight looping with one-shot commands, the machine guarantees at least one yield per
state. If the state command finished without ever yielding (one-shot), the machine yields once before starting the
next state's command (unless exiting).
### Edge cases and guarantees
- Self-transition is supported; the rising-edge guard ensures only a single re-entry per loop when the condition rises.
The exiting command is canceled and then immediately re-scheduled.
- One-shot commands should use completion transitions to continue the flow; conditional transitions cannot trigger for
them because the commands exit before conditional transitions can be checked.
- If multiple transitions are configured with the same condition on the same state, only the first will ever trigger in
a given loop iteration.
- Transitions cannot target states in a different state machine; an exception is thrown if attempted.
- The initial state must be set explicitly; otherwise the machine throws on first run and will not
remain scheduled.