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, andDigitalInputSim - 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.
- 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
- Subsystem holds an
IntakeIOinterface - 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.
/** 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() {}
}
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.
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.
// 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);
}
}
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.
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()
);
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.
- Press Ctrl+Shift+P → WPILib: Simulate Robot Code in VS Code.
- WPILib starts the robot program in simulation mode and opens Glass — a dashboard that shows simulated hardware state.
- 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.
- 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.
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.
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.
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)?
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?
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?
Add Simulation to Your Intake Subsystem
- Create
IntakeIO.javawith the inputs class and default methods as shown. Migrate your existingIntakeSubsystemto accept anIntakeIOin its constructor. Move all hardware objects into a newIntakeIOReal.java. UpdateRobotContainerto injectnew IntakeIOReal(). Deploy and verify all existing behavior still works — the subsystem's public API is unchanged. - Create
IntakeIOSim.javausingDCMotorSim. ImplementsetVoltage()andupdateInputs(). 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). UpdateRobotContainerto injectnew IntakeIOSim()when!Robot.isReal(). - 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.
- Stretch goal: Add a
Field2dwidget to yourDriveSubsystem: declareprivate final Field2d m_field = new Field2d();, callSmartDashboard.putData("Field", m_field);in the constructor, and update the robot pose inperiodic()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.
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