Unit 6 · Lesson 12

Hardware Abstraction: Real IO vs. Simulated IO

The robot only exists in the shop 8 weeks a year. The code can exist — and be tested — every day of the year, on any laptop, without a robot present. Hardware abstraction is the design pattern that makes this possible. This lesson introduces the IO layer that every elite FRC team uses, and explains why it's the single best architectural investment for a programming team.

By the end of this lesson, you will:

  • Explain the problem that hardware abstraction solves: logic untestable without physical hardware
  • Describe the IO interface pattern: an interface, a real implementation, and a simulated implementation
  • Create a basic IO interface and real/sim implementations for a simple subsystem
  • Use WPILib's built-in simulation classes: DCMotorSim, EncoderSim, and DigitalInputSim
  • Run the robot code in the WPILib simulator (Ctrl+Shift+P → Simulate) and observe Glass
  • Understand this lesson as the introduction to a pattern covered in full depth in Unit 11

The Problem: Your Logic Is Trapped in Hardware

Every subsystem you've written so far has hardware objects declared directly in it: new WPI_TalonFX(3), new DigitalInput(4). These objects communicate with physical hardware. When the code runs without a roboRIO, they throw exceptions or silently fail.

This creates a hard constraint: you can only run and test your code when the robot is physically present and powered. If you want to test whether your autonomous sequence logic is correct, you need the robot. If you want to verify your state machine handles edge cases, you need the robot. If a student wants to learn and experiment at home, they can't — they need the robot.

This constraint costs hundreds of hours over a season. The solution is to separate your logic from your hardware access.

❌ Direct Hardware Access
  • Subsystem directly holds WPI_TalonFX
  • Can only test on real robot
  • Logic and hardware tightly coupled
  • Crashes in simulation
  • Impossible to inject fake sensor values
✅ IO Interface Pattern
  • Subsystem holds an IntakeIO interface
  • Real robot: inject IntakeIOReal
  • Laptop sim: inject IntakeIOSim
  • Logic and hardware decoupled
  • Sim IO can return any value you want

The IO Interface Pattern

The pattern has three components. Click each tab to see the code for each part.

The interface defines what data the subsystem can read and what commands it can send. It says nothing about how those are implemented. Real and sim both implement this contract.

IntakeIO.java — the contract
public interface IntakeIO {

  /** Inputs: data the subsystem reads from hardware */
  class IntakeIOInputs {
    public double motorVoltage = 0.0;
    public double statorCurrentA = 0.0;
    public boolean beamBreakTripped = false;
  }

  /** Update inputs struct — called every loop */
  default void updateInputs(IntakeIOInputs inputs) {}

  /** Commands the subsystem can receive */
  default void setVoltage(double volts) {}
  default void stop() {}
}
← click a highlighted token

The real implementation communicates with physical hardware. It is used when running on the actual robot. The subsystem doesn't know this class exists — it only knows the interface.

IntakeIOReal.java — hardware implementation
public class IntakeIOReal implements IntakeIO {

  private final WPI_TalonFX m_motor = new WPI_TalonFX(Constants.kIntakePort);
  private final DigitalInput m_beamBreak = new DigitalInput(Constants.kBeamBreakPort);

  public IntakeIOReal() {
    m_motor.setNeutralMode(NeutralModeValue.Brake);
    m_motor.setInverted(true);
  }

  @Override
  public void updateInputs(IntakeIOInputs inputs) {
    inputs.motorVoltage = m_motor.getMotorVoltage().getValueAsDouble();
    inputs.statorCurrentA = m_motor.getStatorCurrent().getValueAsDouble();
    inputs.beamBreakTripped = !m_beamBreak.get();
  }

  @Override
  public void setVoltage(double volts) { m_motor.setVoltage(volts); }

  @Override
  public void stop() { m_motor.set(0.0); }
}

All physical hardware lives here — and only here. The motor and beam break are private to this class. Nothing outside it has direct access to the hardware objects. If the wiring changes, only this file changes.

The simulated implementation uses WPILib physics models. No real hardware needed — motors are simulated by differential equations, sensors are simulated by the physics outcome. Used on any laptop without a robot connected.

IntakeIOSim.java — simulation implementation
public class IntakeIOSim implements IntakeIO {

  // WPILib physics model for a NEO 550 on a 5:1 gearbox
  private final DCMotorSim m_motorSim = new DCMotorSim(
    DCMotor.getNEO550(1), 5.0, // gearbox ratio
    0.001 // moment of inertia kg·m²
  );
  private double m_appliedVolts = 0.0;

  @Override
  public void updateInputs(IntakeIOInputs inputs) {
    m_motorSim.update(0.020); // advance 20 ms
    inputs.motorVoltage = m_appliedVolts;
    inputs.statorCurrentA = m_motorSim.getCurrentDrawAmps();
    // Simulate beam break: trip when motor has spun enough
    inputs.beamBreakTripped = m_motorSim.getAngularVelocityRPM() > 100;
  }

  @Override
  public void setVoltage(double volts) {
    m_appliedVolts = volts;
    m_motorSim.setInputVoltage(volts);
  }
}
← click a highlighted token

The subsystem uses the interface. It never knows whether it's talking to real hardware or a simulation. The injection happens in RobotContainer, which creates the correct implementation based on whether the code is running in simulation.

IntakeSubsystem.java — uses IO interface
public class IntakeSubsystem extends SubsystemBase {

  private final IntakeIO m_io;
  private final IntakeIO.IntakeIOInputs m_inputs = new IntakeIO.IntakeIOInputs();

  // IO is injected — subsystem doesn't create it
  public IntakeSubsystem(IntakeIO io) {
    m_io = io;
  }

  @Override
  public void periodic() {
    m_io.updateInputs(m_inputs); // refresh sensor data
    SmartDashboard.putBoolean("Intake/HasPiece", m_inputs.beamBreakTripped);
  }

  public boolean hasGamePiece() { return m_inputs.beamBreakTripped; }
  public void setSpeed(double s) { m_io.setVoltage(s * 12.0); }
  public void stop() { m_io.stop(); }
}

// ─── In RobotContainer ───────────────────────────
private final IntakeSubsystem m_intake = new IntakeSubsystem(
  Robot.isReal() ? new IntakeIOReal() : new IntakeIOSim()
);
← click a highlighted token

Running the WPILib Simulator

WPILib ships with a built-in robot simulator. With the IO pattern in place, you can run your entire codebase — state machines, commands, auto sequences — on a laptop, no robot needed.

  1. Press Ctrl+Shift+P → WPILib: Simulate Robot Code in VS Code.
  2. WPILib starts the robot program in simulation mode and opens Glass — a dashboard that shows simulated hardware state.
  3. The Driver Station (on the same machine or networked) connects and you can enable, run auto, and run TeleOp exactly as on a real robot.
  4. Glass shows a Field2d widget — a top-down field view that visualizes your robot's simulated pose. This is essential for debugging autonomous paths before the robot exists.
💡 You don't need the full IO pattern to use the simulator

WPILib's HAL simulation layer automatically intercepts hardware calls when running in simulation mode. new WPI_TalonFX(3) in simulation doesn't throw — it creates a simulated motor that responds to commands without crashing. The IO pattern gives you control over what simulated values you return (e.g., triggering a beam break at the right moment). But even without it, the simulator runs your code. Start simple: just run the simulator and see your commands execute in Glass.

🔍 LRI Perspective: "Teams that simulate are teams that iterate"

The teams with the best autonomous routines at worlds didn't just have good drivers — they had programmers who could iterate on auto logic 50 times a week because they weren't bottlenecked by robot access. They wrote the auto, ran the simulator, watched it fail in Glass, fixed the bug, and ran it again. All on a laptop in a classroom. By the time the robot was built, the auto was already debugged. Hardware abstraction is what makes that cycle possible. It's not a nice-to-have. It's how the top programs operate.

🔌 System Check — Verifying Simulation Setup

Confirm the simulator works in your project:

  • Run Ctrl+Shift+P → WPILib: Simulate Robot Code. If it fails to build, check that all vendor libraries (Phoenix 6, REVLib) have simulation variants installed. Run WPILib: Manage Vendor Libraries and check "Install Simulation Libraries" for each vendor.
  • Open Glass (it launches automatically). Look for "FMS" and "Joystick" sections. Connect a virtual joystick by enabling Joystick 0 in Glass and moving the axes — confirm SmartDashboard values update.
  • Enable the simulated robot in TeleOp mode. If your default drive command starts, the simulation is working end-to-end.
  • Without the IO pattern, simulation may crash on vendor-specific hardware calls (CANcoder, Pigeon2, SparkMax) that don't have simulation support. This is the primary motivation to implement the IO layer for vendor hardware.

Knowledge Check

1. Without the IO pattern, what happens when you try to run your code in the WPILib simulator if your subsystem contains new WPI_TalonFX(3)?

  • A The simulator rejects the project and refuses to launch.
  • B WPILib's HAL simulation layer intercepts the TalonFX constructor and creates a simulated motor object — the code doesn't crash. However, sensor data (encoder position, velocity, current) all return 0 or default values unless you explicitly connect them to a DCMotorSim physics model.
  • C The simulator always throws a NullPointerException on any vendor motor controller class.
  • D The motor runs at full speed because simulation cannot apply voltage limits.

2. The IO interface pattern has an IntakeIO interface, IntakeIOReal, and IntakeIOSim. The subsystem holds an IntakeIO field. The selection logic Robot.isReal() ? new IntakeIOReal() : new IntakeIOSim() lives in RobotContainer. Why is this placement correct?

  • A Robot.isReal() can only be called from RobotContainer.
  • B RobotContainer is the construction and wiring layer — it decides which implementations to create and inject. Putting this decision in the subsystem would couple the subsystem to the question of where it's running, violating single responsibility. The subsystem's only job is to use the interface.
  • C The IO selection must happen in the constructor of the subsystem itself for thread safety.
  • D This placement is arbitrary — the selection could be in any file without affecting behavior.

3. In your IntakeIOSim, the beamBreakTripped field always returns false regardless of motor state. What is the consequence for your IntakeCommand when running in simulation?

  • A The command crashes immediately because hasGamePiece() is not implemented.
  • B IntakeCommand.isFinished() returns m_intake.hasGamePiece(), which reads from the sim's inputs. Since beamBreakTripped is always false, the command never ends in simulation — the same "sensor never fires" bug from Lesson 8, but now you can reproduce and fix it without the robot.
  • C The simulation automatically substitutes a default true value when the sensor returns false.
  • D The command ends immediately because simulated sensors default to their "activated" state.
💪 Practice Prompt — Unit 6 Capstone

Add Simulation to Your Intake Subsystem

  1. Create IntakeIO.java with the inputs class and default methods as shown. Migrate your existing IntakeSubsystem to accept an IntakeIO in its constructor. Move all hardware objects into a new IntakeIOReal.java. Update RobotContainer to inject new IntakeIOReal(). Deploy and verify all existing behavior still works — the subsystem's public API is unchanged.
  2. Create IntakeIOSim.java using DCMotorSim. Implement setVoltage() and updateInputs(). For the beam break: simulate it tripping when the motor has been running above 100 RPM for more than 0.3 seconds (use a timer field). Update RobotContainer to inject new IntakeIOSim() when !Robot.isReal().
  3. Run the WPILib simulator (Ctrl+Shift+P → Simulate). Enable TeleOp in Glass. Hold the A button to run IntakeCommand. Watch Shuffleboard — confirm the simulated beam break fires after 0.3 seconds and the command ends. This is your first end-to-end simulated mechanism test.
  4. Stretch goal: Add a Field2d widget to your DriveSubsystem: declare private final Field2d m_field = new Field2d();, call SmartDashboard.putData("Field", m_field); in the constructor, and update the robot pose in periodic() with simulated encoder + gyro data. In Glass, open the Field2d widget and watch the robot move when you push the joystick in simulation. You are now looking at the same tool 2910 uses to debug autonomous paths before the robot is built.
🎓 Unit 6 Complete — Command-Based Programming

You've covered the full Command-Based architecture: from the philosophy of IS vs. DOES all the way through hardware abstraction and simulation. You've built subsystems, commands, trigger bindings, autonomous groups, logging infrastructure, and a basic IO layer. That's the foundation that every championship-level FRC robot program is built on.

Unit 7 takes this architecture into its most demanding application: swerve drive. The drivetrain is the most complex subsystem on a modern FRC robot — and now you have the architecture to build it cleanly.

  • Unit 7, Lesson 1: Introduction to Swerve Drive — Mechanics, Hardware, and Why It Dominates