Subsystems

beginner30 min

Overview

Subsystems encapsulate hardware (motors, sensors) and provide methods to control them. They extend SubsystemBase and should contain minimal logic—just the ‘how’ to control hardware.

Subsystem Principles

Best practices:

  • Encapsulation: Hide hardware details from commands.
  • Methods: Provide simple, focused control methods.
  • No Complex Logic: Commands handle when and why; subsystems handle how.
  • Configuration: Set up hardware in the constructor.
  • Nearly empty periodic(): Logging and safety checks only.

Intake Subsystem Example

package frc.robot.subsystems.intake;

import edu.wpi.first.wpilibj2.command.SubsystemBase;
import com.ctre.phoenix6.hardware.TalonFX;
import com.ctre.phoenix6.configs.TalonFXConfiguration;
import com.ctre.phoenix6.signals.NeutralModeValue;
import frc.robot.util.PhoenixUtil;

import static frc.robot.Constants.IntakeConstants.*;

public class Intake extends SubsystemBase {
    TalonFX motor = new TalonFX(INTAKE_MOTOR_PORT);
    TalonFXConfiguration config = new TalonFXConfiguration();

    public Intake() {
        config.MotorOutput.NeutralMode = NeutralModeValue.Coast;
        config.CurrentLimits.StatorCurrentLimitEnable = true;
        config.CurrentLimits.SupplyCurrentLimitEnable = true;
        config.CurrentLimits.StatorCurrentLimit = 60;
        config.CurrentLimits.SupplyCurrentLimit = 40;

        PhoenixUtil.tryUntilOk(5, () -> motor.getConfigurator().apply(config));
    }

    public double setIntakeDutyCycle(double dc) {
        motor.set(dc);
        return dc;
    }

    public double getIntakeVelocity() {
        return motor.getVelocity().getValueAsDouble();
    }
}

Configuration Can Fail Silently

On the CTRE side, getConfigurator().apply(config) returns a StatusCode, and on a busy CAN bus at startup — twenty-odd devices powering up at once — it does not always succeed.

A failed apply reports nothing. The motor keeps whatever configuration it already had, which might be last season’s inversion or no current limit at all. You find out when a mechanism runs backwards or a breaker trips.

Retry it:

PhoenixUtil.tryUntilOk(5, () -> motor.getConfigurator().apply(config));

Five attempts cost nothing and remove the failure mode. The helper itself is about ten lines — see Motor Configuration.

Keep the Config as a Field

Notice the TalonFXConfiguration above is a field, not a local variable. That lets you change one setting and re-apply it later without rebuilding the whole configuration:

public void coast() {
  config.MotorOutput.NeutralMode = NeutralModeValue.Coast;
  motor.getConfigurator().apply(config);
}

Useful in the pit, where you want to push a mechanism by hand.

It also makes configuring two mirrored motors trivial — set the shared values once, apply, flip the inversion, apply again:

config.MotorOutput.Inverted = InvertedValue.Clockwise_Positive;
PhoenixUtil.tryUntilOk(5, () -> rightTransfer.getConfigurator().apply(config));

config.MotorOutput.Inverted = InvertedValue.CounterClockwise_Positive;
PhoenixUtil.tryUntilOk(5, () -> leftTransfer.getConfigurator().apply(config));

That one flipped line is the entire difference between the two sides.

Setters That Return What They Set

The setters above return their argument instead of being void. That is not decorative — it is what makes commanded values loggable with a single annotation:

@AutoLogOutput(key = "Intake/targetDutyCycle")
public double setIntakeDutyCycle(double dc) {
    motor.set(dc);
    return dc;
}

@AutoLogOutput(key = "Intake/velocity")
public double getIntakeVelocity() {
    return motor.getVelocity().getValueAsDouble();
}

@AutoLogOutput records the return value of the method. A void setter has nothing to record, so the value you commanded never reaches the log — and then tuning means guessing at half the picture.

With both sides logged, the commanded value and the measured result can be dragged onto the same graph. See AdvantageKit for the annotation and AdvantageScope for reading the result.

What to log on a new subsystem

At minimum:

  • Every commanded value, via a setter that returns it.
  • Every measured value a command depends on — position, velocity.
  • A key path starting with the subsystem name, so it groups correctly in the log viewer.

If you would want it on a graph while tuning, log it now. Adding it after the match where you needed it is too late.

Command Factories

A subsystem exposes its actions as methods returning Command. SubsystemBase provides run() and runOnce(), which build a command that already requires this subsystem:

// Duty cycle — must be re-commanded every loop
public Command setIntakeCommand(double speed) {
    return run(() -> setIntakeDutyCycle(speed));
}

// Closed-loop setpoint — the controller holds it
public Command setIntakePosCommand(double pos) {
    return runOnce(() -> setIntakePosition(pos));
}

Because these are instance methods, the requirement is automatic — there is no addRequirements() to forget. And because they are methods, they take parameters, so one factory replaces several near-identical command classes.

Full treatment in Commands.

Default Commands

Subsystems can have a default command that runs when no other command requires the subsystem. Use setDefaultCommand() in RobotContainer to set one.

intake.setDefaultCommand(intake.setIntakeCommand(0));

Default commands are how mechanisms stop

Release the trigger and the intake command is unscheduled. The scheduler immediately restarts the default command, which commands zero.

Without a default command, the last value stays applied and the roller keeps spinning until something else claims the subsystem. Every mechanism that can be left running needs one — and the drivetrain’s default command is the joystick drive, for the same reason.

Common mistakes

  • Applying configuration without a retry, so a failure is silent.
  • Setting only one current limit. Stator protects the motor; supply protects the battery. See Current Limiting.
  • void setters, so commanded values never reach the log.
  • Logic in periodic() that belongs in a command.
  • Blocking loops in a subsystem. A while loop waiting on a mechanism stalls the entire scheduler — every other subsystem stops updating, including the sensor the loop is waiting on. Use Commands.waitUntil instead.
  • No default command, so a mechanism never stops.

Resources