Hardware Abstraction with IO Layers

advanced35 min

The Problem It Solves

A normal subsystem calls the motor directly:

double position = pivot.getPosition().getValueAsDouble();

That line only works when a real TalonFX is on the CAN bus. In simulation there is no motor. When replaying a match log there is a file, not a motor. And nothing about the read is recorded, so nothing can be reproduced afterwards.

The IO layer puts one interface between the subsystem and the hardware. The subsystem talks to the interface; the interface is implemented differently depending on where the code is running.

Three pieces, every time

  • XxxIO — an interface holding an inputs class and a few methods.
  • XxxIOSomething — one implementation per hardware or environment: ModuleIOTalonFX, ModuleIOSim, VisionIOLimelight.
  • Xxx — the subsystem, which holds an XxxIO and never names a motor class.

This pattern comes from Team 6328’s AdvantageKit template and is the standard way to structure a subsystem you intend to simulate or replay.

The Interface

A swerve module’s IO interface is nothing but data and empty defaults:

public interface ModuleIO {
  @AutoLog
  public static class ModuleIOInputs {
    public boolean driveConnected = false;
    public double drivePositionRad = 0.0;
    public double driveVelocityRadPerSec = 0.0;
    public double driveAppliedVolts = 0.0;
    public double driveStatorCurrentAmps = 0.0;

    public boolean turnConnected = false;
    public Rotation2d turnAbsolutePosition = Rotation2d.kZero;
    public Rotation2d turnPosition = Rotation2d.kZero;
    public double turnVelocityRadPerSec = 0.0;

    public double[] odometryTimestamps = new double[] {};
    public double[] odometryDrivePositionsRad = new double[] {};
    public Rotation2d[] odometryTurnPositions = new Rotation2d[] {};
  }

  /** Updates the set of loggable inputs. */
  public default void updateInputs(ModuleIOInputs inputs) {}

  /** Run the drive motor at the specified open loop value. */
  public default void setDriveOpenLoop(double output) {}

  /** Run the drive motor at the specified velocity. */
  public default void setDriveVelocity(double velocityRadPerSec) {}

  /** Run the turn motor to the specified rotation. */
  public default void setTurnPosition(Rotation2d rotation) {}
}

Three things to notice.

Every method is default and empty. That is deliberate — it means new ModuleIO() {} compiles and gives you a working do-nothing implementation, which is exactly what log replay needs.

The inputs class is plain public fields with defaults. No getters, no logic. Primitives, arrays, and WPILib geometry types only.

Units are in the field names. drivePositionRad, driveVelocityRadPerSec, driveStatorCurrentAmps. Nobody has to guess, and nobody has to open the implementation to check.

What @AutoLog Generates

@AutoLog on the inputs class makes the build generate a companion class called ModuleIOInputsAutoLogged. It extends your inputs class and adds the code to write every field to a log and read every field back out.

You never write that class and you do not commit it — it appears in build/ when you compile. You just use it:

private final ModuleIO io;
private final ModuleIOInputsAutoLogged inputs = new ModuleIOInputsAutoLogged();

Add a field to ModuleIOInputs, rebuild, and it is logged. That is the whole workflow.

The Subsystem Side

Every IO-backed subsystem starts its periodic() the same way:

public void periodic() {
  io.updateInputs(inputs);
  Logger.processInputs("Drive/Module" + Integer.toString(index), inputs);

  // Everything below reads only from `inputs`
  int sampleCount = inputs.odometryTimestamps.length;
  odometryPositions = new SwerveModulePosition[sampleCount];
  for (int i = 0; i < sampleCount; i++) {
    double positionMeters = inputs.odometryDrivePositionsRad[i] * constants.WheelRadius;
    odometryPositions[i] = new SwerveModulePosition(positionMeters, inputs.odometryTurnPositions[i]);
  }

  driveDisconnectedAlert.set(!inputs.driveConnected);
  turnDisconnectedAlert.set(!inputs.turnConnected);
}

updateInputs fills the struct from wherever the data comes from. processInputs logs it — or, in replay, overwrites it from the log. Everything after those two lines is ordinary code that reads inputs.something and cannot tell the difference.

The rule that makes replay work

After Logger.processInputs(...), the subsystem must only read from inputs. Never call the motor again for a value.

// correct
double velocity = inputs.driveVelocityRadPerSec;

// wrong — invisible to the log, and replay will not reproduce it
double velocity = driveTalon.getVelocity().getValueAsDouble();

One direct read is enough to make a replay quietly wrong, which is worse than a replay that obviously fails.

The Alert calls show a second thing the pattern gives you: driveConnected is an input, so a disconnected motor is visible in the log and on the dashboard, and it replays.

Naming the Log Path

Logger.processInputs("Drive/Module" + Integer.toString(index), inputs);

The first argument becomes the prefix for every field in the struct — Drive/Module0/drivePositionRad, Drive/Module1/turnConnected, and so on. Four modules each get their own subtree because the index is in the path.

Swapping Implementations

RobotContainer is the only place that knows which implementation to build:

switch (Constants.currentMode) {
  case REAL:
    drive = new Drive(
        new GyroIOPigeon2(),
        new ModuleIOTalonFX(TunerConstants.FrontLeft),
        new ModuleIOTalonFX(TunerConstants.FrontRight),
        new ModuleIOTalonFX(TunerConstants.BackLeft),
        new ModuleIOTalonFX(TunerConstants.BackRight));
    break;

  case SIM:
    drive = new Drive(
        new GyroIO() {},
        new ModuleIOSim(TunerConstants.FrontLeft),
        new ModuleIOSim(TunerConstants.FrontRight),
        new ModuleIOSim(TunerConstants.BackLeft),
        new ModuleIOSim(TunerConstants.BackRight));
    break;

  default: // REPLAY
    drive = new Drive(
        new GyroIO() {},
        new ModuleIO() {},
        new ModuleIO() {},
        new ModuleIO() {},
        new ModuleIO() {});
    break;
}

Drive is constructed identically in all three branches. It has no mode flag and no if (isSimulation()) — everything mode-specific is in which object gets passed in. See Simulation Basics for what each mode does.

new ModuleIO() {} looks like a mistake and is the most important line here. It creates an anonymous implementation that overrides nothing, so updateInputs leaves the struct untouched — and Logger.processInputs then fills it from the log file a line later.

When To Use It, and When Not

The IO layer costs an interface, an implementation per environment, and an inputs class. That buys simulation, replay, and easy hardware swaps.

SubsystemIO layer?Why
DrivetrainYesNeeds simulation, replay, and multiple hardware variants
VisionYesCamera data is the thing you most want to replay
Elevator, arm, pivotOftenSimulation is genuinely useful for a profiled mechanism
Roller, conveyor, feederUsually notDirect hardware plus @AutoLogOutput is enough

For a conveyor that runs one motor at one duty cycle, the abstraction usually costs more than it returns. Use @AutoLogOutput on that subsystem’s getters and setters instead — see Subsystems — and accept that its outputs are logged but its inputs are not replayed.

Common mistakes

  • Methods that are not default. Then new ModuleIO() {} will not compile and replay has no empty implementation.
  • Logic in the IO implementation. It reads and writes hardware; decisions belong in the subsystem.
  • Getters on the inputs class. @AutoLog works on plain fields.
  • Forgetting Logger.processInputs. updateInputs alone fills the struct but logs nothing, so replay has no data to feed back.
  • Units left out of field names, which is how a value in rotations gets used as radians.

Resources