[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,91 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a method as a post-construction initializer. The WPILib compiler plugin will check for uses
* of methods with this annotation and report a compiler error if the method is not called after the
* object is constructed.
*
* <p>Limitations of this annotation:
*
* <ul>
* <li>Initializer methods must be called on the variable directly. They cannot be detected if
* called indirectly (e.g., on an object returned by a method)
* <pre>{@code
* // This is OK
* Foo foo = new Foo();
* foo.init();
*
* // This is not OK
* Box box = new Box(new Foo());
* box.getFoo().init();
*
* }</pre>
* <li>Static initializer methods must accept exactly one parameter of the type that defines the
* static method (they cannot accept a parameter of a supertype or derived type).
* <li>Static initializer methods with multiple parameters of the initialized type must annotate
* one of them with {@link InitializedParam} to disambiguate for the compiler.
* </ul>
*
* <p>Errors reported by the compiler plugin may be suppressed by annotating the offending method
* with {@code SuppressWarnings("PostConstructionInitializer")} or {@code
* SuppressWarnings(PostConstructionInitializer.SUPPRESSION_KEY)}. This is intended to be used in
* tests to allow runtime error handling code to be tested, but may also be used to suppress
* spurious warnings in production code.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PostConstructionInitializer {
/**
* The string key to use in {@link SuppressWarnings} annotations to suppress compiler error
* messages related to this annotation.
*/
String SUPPRESSION_KEY = "PostConstructionInitializer";
/**
* Marks a specific parameter in a static initializer method as being the initialized object. This
* disambiguates situations where static initializer methods accept multiple arguments of the same
* initialize-required type; for example:
*
* <pre>{@code
* class Foo {
* @PostConstructionInitializer
* static void copy(Foo src, @InitializedParam Foo dst) {
* // ...
* }
* }
* }</pre>
*
* <p>Static initializer methods must have a parameter of the exact type that defines the static
* method.
*
* <pre>{@code
* interface I {
* @PostConstructionInitializer
* static void init(I object) { ... }
* }
*
* class Foo implements I {
* @PostConstructionInitializer
* static void initFoo(Foo foo) { ... } // OK
*
* @PostConstructionInitializer
* static void initI(I object) { ... } // ERROR: I is not Foo
*
* @PostConstructionInitializer
* static void initOther(SomeOtherType o) { ... } // ERROR: SomeOtherType is not Foo
* }
* }</pre>
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@interface InitializedParam {}
}