Skip to content

Conversation

@SamCarlberg
Copy link
Member

@SamCarlberg SamCarlberg commented Oct 24, 2025

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.

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.

@SamCarlberg SamCarlberg requested a review from a team as a code owner October 24, 2025 23:24
@SamCarlberg SamCarlberg added component: command-based WPILib Command Based Library 2027 2027 target labels Oct 24, 2025
@github-actions github-actions bot removed the component: command-based WPILib Command Based Library label Oct 24, 2025
@Daniel1464
Copy link
Contributor

Looks good - I have a couple of suggestions:

  1. When the javac plugin gets merged, a plugin should be added that makes sure the return types of switchTo(), switchFromAny(), etc. aren't dropped.
  2. Add a whenCompleteOr().
  3. Change whenComplete() to whenDone()(conveys the same meaning in less characters).

@SamCarlberg
Copy link
Member Author

SamCarlberg commented Oct 26, 2025

When the javac plugin gets merged, a plugin should be added that makes sure the return types of switchTo(), switchFromAny(), etc. aren't dropped.

This is already in place. The @NoDiscard annotations on the builder classes are enforcing it

Add a whenCompleteOr()

Seems unnecessary. You can use when and whenComplete separately for that behavior. whenCompleteAnd is only needed because there is no other way to express that behavior.

Change whenComplete() to whenDone()(conveys the same meaning in less characters).

I'm not sure if I like this. It reads a little weirdly, and is a little awkward with whenDoneAnd

Used to annotate methods that are required to be called after constructing a new object

Methods with this annotation must be called directly on the field or variable; the plugin can't check for calls on objects returned by methods, or on objects returned by ternary expressions or similar
Makes usages that forget to set the initial state a compiler error, rather than just a runtime error

Runtime error checking is left in place in case teams build their code without the compiler plugin enabled
Add an annotation processor to validate definitions of static initializer methods (requiring exactly one unambiguous input argument)

DRY out suppression detections
@SamCarlberg SamCarlberg added component: javac plugin Java compiler plugin component: wpiannotations WPILib Java annotations component: commands v3 WPILib commands library version 3 labels Oct 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2027 2027 target component: commands v3 WPILib commands library version 3 component: javac plugin Java compiler plugin component: wpiannotations WPILib Java annotations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants