🗃️ Hardware Abstraction
APIs and control systems change season to season, and sometimes mid-season. Specific class names, file paths, and code examples throughout this section reflect FRC 190's codebase at the time of writing and may become outdated or obsolete as WPILib, CTRE, and other vendor libraries evolve. Treat this section as a guide to the underlying concepts, not a guaranteed match to the current source.
Robot Code Architecture mentioned that a subsystem "holds an IO interface to talk to
hardware, and lets the concrete implementation decide whether that means real hardware, simulation, or a no-op stub."
This page is about that pattern in full: why it exists, how the interface itself is shaped, how AdvantageKit turns a
handful of annotated fields into a working logging and replay system, and what a real hardware implementation
actually looks like. Everything here is grounded in 190's actual roller IO, GenericRollerIO, which is small
enough to see the whole pattern at once. The CTRE-specific pieces it leans on (configuration objects, status signals)
are covered from the hardware side in FRC Hardware.
Why Abstract Hardware?
The Problem With Calling Vendor APIs Directly
Imagine a roller subsystem that just imports com.ctre.phoenix6.hardware.TalonFX and calls methods on it
directly, wherever it needs to. That works, right up until it doesn't:
- Testability. The subsystem's logic, "am I at my voltage goal," "what state am I in," is now inseparable from a physical CAN device. There's no way to exercise that logic without a real TalonFX plugged into a real CAN bus, which means no way to catch a logic bug without a robot in front of you.
- Simulation. CTRE's real hardware calls don't mean anything on a desktop with no roboRIO and no CAN bus. Either
the subsystem silently does nothing in simulation, or every subsystem ends up full of
if (isSimulation())branches scattered through its actual behavior. - Vendor lock-in. 190 runs several robot versions out of one codebase (see Shared Codebase Architecture), and it's entirely possible for the same mechanism to be built with different hardware on different robots. If every subsystem method is written directly against one vendor's types, swapping hardware means rewriting the subsystem, not just its wiring.
What AdvantageKit's IO Pattern Buys
The fix is to never let a subsystem touch a vendor API at all. Instead, a thin interface, the IO, sits between them, and the subsystem only ever calls methods on that interface. Which concrete class actually satisfies it is decided once, at construction (see Runtime Switch), and the subsystem never finds out which one it got. That one change buys back everything the direct approach lost:
- Sim support.
GenericRollerIOSimimplements the exact same interface as the real hardware, backed by a WPILibDCMotorSiminstead of a CAN device. The subsystem code above it doesn't change by a single line. - Hardware swaps. A
RobotContainerpicks the implementation per robot:GenericRollerIOTalonFXfor real hardware,GenericRollerIOSimfor simulation, or a barenew GenericRollerIO() {}when neither applies (replay, or a robot that doesn't have that mechanism at all). Same subsystem, same wrapper class, three interchangeable backends. - Log replay. Because a subsystem only ever reads a device's state through one object (its inputs, covered next), that object can be logged during a real match and fed back in later exactly as it was recorded, replaying an entire match's subsystem behavior without a robot, or even a simulation, in the loop at all.
The IO Interface Pattern
An IO interface describes one hardware unit, and nothing else. GenericRollerIO is a good example precisely
because it's small:
Show code
public interface GenericRollerIO {
@AutoLog
public static class GenericRollerIOInputs {
public Rotation2d position = new Rotation2d();
public AngularVelocity velocity = RadiansPerSecond.of(0.0);
public double[] appliedVolts = new double[] {};
public double[] supplyCurrentAmps = new double[] {};
public double[] torqueCurrentAmps = new double[] {};
public double[] temperatureCelsius = new double[] {};
}
default void updateInputs(GenericRollerIOInputs inputs) {}
default void setVoltageGoal(Voltage volts) {}
default boolean atVoltageGoal(Voltage voltageReference) {
return false;
}
}
Notice what GenericRollerIO does not know about: it has no idea whether it's spinning an intake roller, a
feeder, or a climber's ratchet, that's decided entirely by whichever subsystem chooses to hold one (see
Hardware Definitions: Subsystems for how Intake
composes a GenericRoller together with a FourBarLinkage). All it describes is "a thing that spins and
reports back on itself," which is exactly why the same interface can back any mechanism built from a roller.
The interface is a contract with two directions, and every method on it falls into one or the other:
- Data in.
updateInputs(inputs)pulls fresh telemetry from the device and writes it into the sharedinputsobject. This is the only direction data flows from hardware toward the rest of the code. - Commands out.
setVoltageGoal(volts)pushes a command to the device. Nothing comes back from calling it; it's fire-and-forget, the same way control requests are.
atVoltageGoal(...) looks like it reads from hardware, but it doesn't have to. An implementation is free to
answer it from whatever it already has cached, without issuing a new CAN request.
Every method above is a default method, and every one of them defaults to doing nothing. That's deliberate, for two reasons. First, a concrete implementation only needs to override what it actually cares about, nothing forces every IO implementation to handle every possible interaction if a given device genuinely can't. Second, and more importantly, it's what makes an empty anonymous class a valid implementation all on its own:
new GenericRollerIO() {} // valid: every method already has a body, one that does nothing
That's precisely the "no hardware at all" stand-in a RobotContainer reaches for when a robot doesn't have a
given mechanism, or when nothing should touch real hardware at all (log replay). No dedicated "null" or "stub"
implementation class needs to exist anywhere. The interface already provides one for free.
IO Inputs and @AutoLog
The nested GenericRollerIOInputs class from above is the data contract: every field updateInputs() is
allowed to populate, and the only thing the rest of the subsystem is allowed to read back. Notice its fields are
plain, public, and mutable, no getters, no encapsulation, which looks unusual for 190's code but is required here:
the fields have to be visible and reflectable for what happens next.
GenericRollerIOInputs is annotated with @AutoLog, from AdvantageKit
(org.littletonrobotics.junction.AutoLog). At compile time, an annotation processor scans for every class
carrying that annotation and generates a matching subclass next to it. For GenericRollerIOInputs, that's
GenericRollerIOInputsAutoLogged. You'll never find that class's source in the repository, because nobody writes
it; it's produced fresh on every build, with one field-by-field toLog(table)/fromLog(table) pair per field
declared above it. Add a field to GenericRollerIOInputs, and the generated class picks it up automatically, no
logging code to hand-write or keep in sync.
The generated AutoLogged class is what a subsystem actually instantiates and holds, never the plain
...IOInputs class it's generated from:
Show code
public class GenericRoller {
private final GenericRollerIO io;
private final GenericRollerIOInputsAutoLogged inputs;
private final String aKitTopic;
public GenericRoller(GenericRollerIO io, Subsystem subsystem, GenericRollerConstants constants, String name, ...) {
this.io = io;
inputs = new GenericRollerIOInputsAutoLogged();
aKitTopic = subsystem.getName() + "/Roller" + name;
...
}
public void periodic() {
io.updateInputs(inputs);
Logger.processInputs(aKitTopic, inputs);
...
}
}
Logger.processInputs(key, inputs) is where this whole pattern actually pays off, and it behaves differently
depending on which RobotMode (see Runtime Switch) the program is
running under, without the surrounding code changing at all:
- On REAL or SIM,
updateInputs()has just filledinputswith fresh values, andprocessInputs()calls the generatedtoLog(...), writing every field out to this cycle's log entry. - On REPLAY,
updateInputs()was never meaningfully called (the container built with no-op IOs for replay), soprocessInputs()instead calls the generatedfromLog(...), and overwritesinputswith whatever was recorded for this same cycle during the original run.
Same call, same object, opposite direction of data flow, and every line of subsystem code that reads from
inputs after this point behaves identically either way.
Real Hardware Implementations
GenericRollerIOTalonFX is the concrete class that satisfies GenericRollerIO for an actual Kraken/TalonFX.
Its constructor is where the device gets wired up, once: it builds a TalonFX, applies a
TalonFXConfiguration through PhoenixUtil.tryUntilOk (see
Configuration Objects), and grabs a StatusSignal handle for every
field it will need, registering all of them for the batched, synchronized refresh described in
Status Signals:
Show code
public GenericRollerIOTalonFX(GenericRollerConstants constants) {
talonFX = new TalonFX(constants.leaderCANID, constants.canBus);
talonFXConfiguration = new TalonFXConfiguration();
talonFXConfiguration.MotorOutput.withInverted(constants.leaderInvertedValue);
talonFXConfiguration.CurrentLimits...;
PhoenixUtil.tryUntilOk(5, () -> talonFX.getConfigurator().apply(talonFXConfiguration, 0.25));
positionRotations = talonFX.getPosition();
velocityRotationsPerSecond = talonFX.getVelocity();
// ...remaining StatusSignal handles, then registered for batched refresh
voltageRequest = new VoltageOut(0.0).withEnableFOC(constants.enableFOC);
}
Everything up to this point only runs once. The part that runs every cycle is the updateInputs() override:
Show code
@Override
public void updateInputs(GenericRollerIOInputs inputs) {
inputs.position = Rotation2d.fromRotations(positionRotations.getValueAsDouble());
inputs.velocity = velocityRotationsPerSecond.getValue();
inputs.appliedVolts = new double[appliedVolts.size()];
inputs.supplyCurrentAmps = new double[supplyCurrentAmps.size()];
inputs.torqueCurrentAmps = new double[torqueCurrentAmps.size()];
inputs.temperatureCelsius = new double[temperatureCelsius.size()];
for (int i = 0; i <= followerTalonFX.length; i++) {
inputs.appliedVolts[i] = appliedVolts.get(i).getValueAsDouble();
inputs.supplyCurrentAmps[i] = supplyCurrentAmps.get(i).getValueAsDouble();
inputs.torqueCurrentAmps[i] = torqueCurrentAmps.get(i).getValueAsDouble();
inputs.temperatureCelsius[i] = temperatureCelsius.get(i).getValueAsDouble();
}
}
This is worth stating plainly: updateInputs() is the only method, in the only class, that ever calls
.getValue() or .getValueAsDouble() on one of this roller's status signals. Every other piece of code that
cares about this roller's position, velocity, or current (the subsystem's periodic(), its atGoal()
checks, its logging, any command that reads it) reads it off the inputs object instead, never the hardware
directly. That single choke point per device is the entire pattern in one sentence: there is exactly one place where
"go talk to real hardware" happens, and everything else in the codebase, including simulation and replay, is
completely decoupled from it.