The SwerveModule Class
This is the lesson where everything converges. The hardware configuration from Lesson 2, the optimize() call from Lesson 3, and the position data for Lesson 4's odometry all live inside one class. The SwerveModule is the heart of the swerve system — and it runs four times every 20 ms.
By the end of this lesson, you will:
- Write a complete
SwerveModuleclass that encapsulates a TalonFX drive motor, TalonFX steering motor, and CANcoder - Configure Phoenix 6 onboard PID (Slot 0) on the steering motor with continuous wrap enabled
- Configure velocity feedforward on the drive motor using Phoenix 6's
VelocityVoltagecontrol request - Implement a complete
setDesiredState()method withoptimize(), near-zero guard, and unit conversions - Implement
getState()andgetPosition()to return current module data for odometry and telemetry - Publish per-module telemetry to SmartDashboard and explain which values to watch during first bringup
The SwerveModule Class: One Job, Done Completely
The SwerveModule class is not a WPILib subsystem — it does not extend SubsystemBase. It is a plain Java class, instantiated four times by the SwerveDrivetrain subsystem. Its job is narrow and total: given a desired state (speed + angle), make the physical hardware achieve it as accurately and quickly as possible.
Every public method on this class serves one of two purposes: commanding the module (setDesiredState()) or reading from it (getState(), getPosition()). Nothing else belongs here.
- Constructor: CAN IDs, CANcoder offset, drive inversion flag
setDesiredState(SwerveModuleState)— target speed + angle from kinematics
getState()→ currentSwerveModuleState(actual velocity + angle)getPosition()→SwerveModulePosition(distance + angle) for odometry- SmartDashboard telemetry in
updateTelemetry()
Two Control Problems, Two Strategies
Inside every setDesiredState() call, two independent control problems are solved simultaneously — one for the steering motor and one for the drive motor. They use different strategies because they have different requirements.
Steering: Position PID
The steering motor must reach and hold a precise angle. PID position control is the right tool: the P term applies force proportional to the angular error, the I term eliminates steady-state offset, and the D term damps overshoot. The critical configuration: continuous input wrapping. Without it, going from 179° to −179° triggers a 358° turn. With wrapping enabled, the controller sees this as a 2° transition. In Phoenix 6, this is set as withContinuousWrap(true) on the position control request.
Drive: Velocity Feedforward + P
The drive motor must hold a commanded speed against varying loads. Feedforward + PID velocity control is the right tool. Feedforward (kV, kS) predicts the voltage needed for a given speed — it does 95% of the work instantly, without waiting for error to accumulate. A small proportional gain cleans up the remaining error. This combination produces much smoother, more responsive drive behavior than P-only control. In Phoenix 6, this is expressed as a VelocityVoltage control request with onboard Slot 0 gains and feedforward constants.
Steering PID Behavior Visualizer
Select a scenario to see how the steering PID responds. The purple bar is the current wheel angle, the red marker is the target angle.
Part 1: The Constructor and Hardware Configuration
The constructor accepts the three CAN IDs, the CANcoder offset, and an optional drive inversion flag. It creates hardware objects, applies all Phoenix 6 configuration, and stores Signal handles for fast periodic reads. Click the highlighted tokens.
// ── Hardware ─────────────────────────────────────
private final TalonFX m_driveMotor;
private final TalonFX m_steerMotor;
private final CANcoder m_cancoder;
// ── Control requests (reused each loop, no GC) ───
private final VelocityVoltage m_driveRequest = new VelocityVoltage(0).withSlot(0);
private final PositionVoltage m_steerRequest = new PositionVoltage(0).withSlot(0);
// ── Signal handles for fast reads ────────────────
private final StatusSignal<Double> m_driveVelocity;
private final StatusSignal<Double> m_drivePosition;
private final StatusSignal<Double> m_steerAngle;
public SwerveModule(int driveId, int steerId, int encoderId,
double encoderOffsetRot, boolean driveInverted) {
m_driveMotor = new TalonFX(driveId);
m_steerMotor = new TalonFX(steerId);
m_cancoder = new CANcoder(encoderId);
configDriveMotor(driveInverted);
configSteerMotor();
configEncoder(encoderOffsetRot);
// Cache signal handles — avoid repeated API calls
m_driveVelocity = m_driveMotor.getVelocity();
m_drivePosition = m_driveMotor.getPosition();
m_steerAngle = m_cancoder.getAbsolutePosition();
// Set signal update rates
BaseStatusSignal.setUpdateFrequencyForAll(100,
m_driveVelocity, m_drivePosition, m_steerAngle);
}
private void configDriveMotor(boolean inverted) {
TalonFXConfiguration cfg = new TalonFXConfiguration();
cfg.MotorOutput.withNeutralMode(NeutralModeValue.Brake)
.withInverted(inverted ? InvertedValue.Clockwise_Positive
: InvertedValue.CounterClockwise_Positive);
cfg.CurrentLimits.withStatorCurrentLimit(Constants.Swerve.kDriveCurrentLimitAmps)
.withStatorCurrentLimitEnable(true);
cfg.Feedback.withSensorToMechanismRatio(Constants.Swerve.kDriveGearRatio);
// Slot 0: velocity PID + feedforward for drive
cfg.Slot0.withKP(0.1).withKV(0.12).withKS(0.25);
m_driveMotor.getConfigurator().apply(cfg);
}
private void configSteerMotor() {
TalonFXConfiguration cfg = new TalonFXConfiguration();
cfg.MotorOutput.withNeutralMode(NeutralModeValue.Brake);
cfg.CurrentLimits.withStatorCurrentLimit(Constants.Swerve.kSteerCurrentLimitAmps)
.withStatorCurrentLimitEnable(true);
cfg.Feedback.withSensorToMechanismRatio(Constants.Swerve.kSteerGearRatio)
.withRemoteCANcoder(m_cancoder);
// Slot 0: position PID for steering
cfg.Slot0.withKP(50.0).withKD(0.5);
m_steerMotor.getConfigurator().apply(cfg);
}
}
Part 2: setDesiredState() — The Core Method
This is the method the drivetrain calls 50 times per second, once per module. It receives a target state from kinematics, optimizes it, converts units, and issues the two hardware commands. Every word of this method matters.
// 1. Read current steering angle
Rotation2d currentAngle = Rotation2d.fromRotations(
m_steerAngle.refresh().getValueAsDouble());
// 2. Optimize — never rotate more than 90°
SwerveModuleState state =
SwerveModuleState.optimize(desired, currentAngle);
// 3. Near-zero guard — prevents jitter at standstill
if (Math.abs(state.speedMetersPerSecond) < 0.001) {
m_driveMotor.stopMotor();
return;
}
// 4. Drive: m/s → wheel RPS → set velocity
double driveRPS = state.speedMetersPerSecond
/ Constants.Swerve.kWheelCircumference;
m_driveMotor.setControl(m_driveRequest.withVelocity(driveRPS));
// 5. Steer: Rotation2d → rotations → set position
double targetRot = state.angle.getRotations();
m_steerMotor.setControl(m_steerRequest.withPosition(targetRot));
}
// ── Sensor reads for drivetrain / odometry ────────────
public SwerveModuleState getState() {
return new SwerveModuleState(
m_driveVelocity.refresh().getValueAsDouble()
* Constants.Swerve.kWheelCircumference,
Rotation2d.fromRotations(m_steerAngle.refresh().getValueAsDouble())
);
}
public SwerveModulePosition getPosition() {
return new SwerveModulePosition(
m_drivePosition.refresh().getValueAsDouble()
* Constants.Swerve.kWheelCircumference,
Rotation2d.fromRotations(m_steerAngle.refresh().getValueAsDouble())
);
}
// ── Telemetry — call from drivetrain periodic() ────────
public void updateTelemetry(String prefix) {
SmartDashboard.putNumber(prefix+"/SteerAngleDeg",
m_steerAngle.getValueAsDouble() * 360);
SmartDashboard.putNumber(prefix+"/DriveVelocityMPS",
m_driveVelocity.getValueAsDouble()*Constants.Swerve.kWheelCircumference);
SmartDashboard.putNumber(prefix+"/DriveCurrentA",
m_driveMotor.getStatorCurrent().getValueAsDouble());
}
Two subtle but critical details: (1) Without the near-zero guard, a 0.001 m/s command still tells the drive motor to spin slowly — and the steering PID will try to hold the angle precisely, causing visible motor activity at a standstill. The guard returns immediately, stopping both motors when the robot isn't supposed to be moving. (2) The PositionVoltage control request must have continuous wrap enabled via m_steerRequest.withEnableFOC(true) and the motor's ClosedLoopGeneral.withContinuousWrap(true) in TalonFXConfiguration. Without it, the steering PID will try to drive through 0°/360° the long way around.
Notice that m_driveRequest and m_steerRequest are declared as fields, not created inside setDesiredState(). Creating new VelocityVoltage() or new PositionVoltage() 50 times per second generates significant garbage collection pressure. Phoenix 6 is designed to have request objects mutated in place (the .withVelocity() and .withPosition() calls return this, so you can chain them). Declare once as a field, mutate in the loop.
Unit Conversions: The Full Chain
Swerve module code involves multiple unit systems. Getting any conversion wrong produces wrong behavior with no compile error. Here is the complete chain for the drive motor, traced from hardware to code and back:
- TalonFX native position unit: Rotations of the motor shaft
- After
withSensorToMechanismRatio(6.75): Position is now in wheel rotations (motor rotations ÷ 6.75) - getPosition() conversion: Wheel rotations × wheel circumference (meters) = distance in meters
- getVelocity() conversion: Wheel RPS × wheel circumference = velocity in m/s
- setDesiredState() conversion: Target m/s ÷ wheel circumference = target wheel RPS → passed to
VelocityVoltage
The gear ratio is the pivot. With withSensorToMechanismRatio(), you express it once in configuration and every subsequent encoder read is already in wheel rotations. Multiplying by circumference then gives meters. Every conversion in the class reduces to these two multiplications.
First-Bringup Telemetry: What to Watch
When enabling the robot for the first time after building the SwerveModule class, open Shuffleboard and watch these four values for each module. They tell you immediately which categories of problems exist.
Every swerve bringup goes through the same first-enable ritual on 2910: publish SteerAngleDeg, DriveVelocityMPS, DriveCurrentA, and desired vs. actual angle. Enable. Push the joystick gently. Watch the four numbers for each module on Shuffleboard. A module that doesn't move its SteerAngleDeg when commanded → steering motor or encoder issue. A module with SteerAngleDeg changing but DriveVelocityMPS stuck at zero → drive motor issue. A module driving in the wrong direction → inversion wrong. A module with SteerAngleDeg oscillating wildly → P gain too high. Four numbers diagnose every first-enable failure in under ten minutes. That diagnostic check takes thirty seconds to set up. Don't skip it.
Before testing all four modules together:
- Create a test command or use
teleopInit()to callsetDesiredState(new SwerveModuleState(0, new Rotation2d()))on one module. Verify it holds at 0° without oscillation. If it oscillates, reduce the steer Slot 0 kP gain. If it holds weakly and can be manually deflected, increase kP. - Call
setDesiredState(new SwerveModuleState(1.0, new Rotation2d()))on the same module with the robot on blocks. Verify the drive wheel spins forward at approximately 1 m/s (usegetState().speedMetersPerSecondon SmartDashboard). If it spins backward, the drive inversion flag is wrong — flip it in the constructor call. - Command steering to 90° (
Rotation2d.fromDegrees(90)). The wheel should rotate to the left side of the robot (positive Y direction in WPILib convention). If it rotates the wrong direction, the steering motor or CANcoder may need inversion. Verify in Tuner X which direction produces increasing absolute position. - Check
getPosition().distanceMetersincreases as the wheel spins forward and decreases as it spins backward. If it behaves oppositely, the drive motor position inversion doesn't match the velocity inversion — both must be consistent.
Knowledge Check
1. You enable the robot and one module's wheel rotates 270° counterclockwise to reach a target that's only 90° clockwise away. What is missing from your code?
2. Your drive motor's TalonFX reports a velocity of 4.44 wheel rotations/second. What is the wheel's speed in m/s? (Wheel circumference = 0.319 m. withSensorToMechanismRatio(6.75) is applied.)
3. The steering motor has kP = 50.0 in Slot 0 and uses the remote CANcoder as feedback. The current angle is 0.10 rotations and the target is 0.35 rotations. Approximately what output voltage does the P controller apply? (Assume kD = 0, kI = 0, and the controller output is in volts where 12V is full.)
Build and Test the Complete SwerveModule
- Create
SwerveModule.javawith the constructor, hardware fields, control request fields, and all three configuration methods from this lesson. Use your Constants values from Lesson 2. Add aString m_namefield passed into the constructor for telemetry prefixing (e.g., "FL", "FR", "BL", "BR"). - Implement
setDesiredState()exactly as shown: refresh the steer angle signal, calloptimize(), apply the near-zero guard, convert m/s to wheel RPS for the drive command, and convert Rotation2d to rotations for the steer command. Double-check that you're dividing bykWheelCircumference(not multiplying) for the drive RPS conversion. - Implement
getState()andgetPosition(). AddupdateTelemetry(m_name)that publishes SteerAngleDeg, DriveVelocityMPS, and DriveCurrentA. In the drivetrain'speriodic(), callupdateTelemetry()on each module. - With the robot on blocks, instantiate a single SwerveModule in
RobotContainerusing the Front Left CAN IDs and offset. InteleopInit(), callsetDesiredState(new SwerveModuleState(0, Rotation2d.fromDegrees(0))). Enable and confirm the wheel holds at 0°. Then test 90°, 180°, −90°. Verify the steering angle on SmartDashboard matches each commanded angle within ±3°. - Stretch goal: Enable
ClosedLoopGeneral.withContinuousWrap(true)in the steer motor'sTalonFXConfiguration. Command 170°, then immediately command −170°. Without continuous wrap, the module rotates 340°. With it, the module rotates 20°. Verify the difference on SmartDashboard. This single config line is the difference between sluggish and snappy steering at angle boundaries.