Lesson 3: IO Layers + Simple Velocity Control
This lesson introduces a critical architectural pattern used in production FRC robots: separating subsystem logic from hardware implementation using an IO layer. You'll also learn how to implement simple proportional (P-only) control to regulate mechanism velocity instead of just setting voltage directly.
Learning Objectives
By the end of this lesson, you will understand:
- ✅ Why we separate IO from subsystem logic (testability, swapping sim/real hardware)
- ✅ How to structure an IO layer with inputs, interfaces, and implementations
- ✅ The read inputs → compute outputs → write outputs control loop pattern
- ✅ What proportional control is and why we use it
- ✅ How to implement simple P-only velocity control
- ✅ How to publish telemetry for debugging and tuning
Prerequisites
Before starting this lesson:
- Complete Lesson 0: Setup - WPILib installation
- Complete Lesson 1: C++ Basics - C++ fundamentals
- Complete Lesson 2: Subsystem Basics - State machine pattern
- Review Style Guide - Architecture patterns
- Review Controller Map - Button conventions
Why IO Layers?
The Problem with Direct Hardware Access
In Lesson 2, we created a subsystem that directly controlled simulated hardware:
// ❌ Direct hardware access in subsystem
class SimIntake : public frc2::SubsystemBase {
private:
double m_rollerSpeed = 0.0; // Simulated motor
void handleIntakingState() {
m_rollerSpeed = 0.8; // Directly set speed
}
};
This works for simple examples, but has problems:
- Can't swap between sim and real hardware: Logic is tied to simulation
- Hard to test: You can't test subsystem logic without the hardware
- Duplicate code: Same logic written twice (sim version and real version)
- Messy state: Sensor readings and control outputs mixed with state logic
The IO Layer Solution
Instead, we create a boundary between subsystem logic and hardware:
// ✅ Subsystem logic separated from hardware
class SimShooterWheel : public frc2::SubsystemBase {
private:
std::unique_ptr<ShooterWheelIO> m_io; // Hardware abstraction
ShooterWheelIOInputs m_inputs; // Sensor readings snapshot
void processState() {
// 1. Read inputs from hardware
m_io->updateInputs(m_inputs);
// 2. Compute outputs based on state and inputs
double command = computeMotorCommand();
// 3. Write outputs to hardware
m_io->setVoltage(command);
}
};
Now:
- ✅ Subsystem logic is hardware-agnostic (works with any IO implementation)
- ✅ Easy to swap implementations (sim, real hardware, mock for testing)
- ✅ Clear separation of concerns (state logic vs. hardware interface)
- ✅ Sensor readings are atomic snapshots (no race conditions)
Anatomy of an IO Layer
Let's break down the three key components using a shooter wheel as our example mechanism.
1. IOInputs - Snapshot Struct
A struct that holds all sensor readings at a single point in time:
struct ShooterWheelIOInputs {
double velocityRadPerSec = 0.0; // Current wheel velocity
double appliedVolts = 0.0; // Voltage being applied
double currentAmps = 0.0; // Current draw
double tempCelsius = 0.0; // Motor temperature
};
Key Points:
- Plain struct (no methods, just data)
- Initialized with safe defaults
- Represents a snapshot in time (atomic)
- Used for telemetry and control decisions
2. IO Interface - Abstract Base Class
An interface defining what operations the hardware supports:
class ShooterWheelIO {
public:
virtual ~ShooterWheelIO() = default;
/**
* Update inputs with current sensor readings.
* Called by subsystem to get fresh data.
*/
virtual void updateInputs(ShooterWheelIOInputs& inputs) = 0;
/**
* Set motor voltage command.
* @param volts Voltage to apply (-12 to +12)
*/
virtual void setVoltage(double volts) = 0;
};
Key Points:
- Pure virtual methods (
= 0) make it an abstract interface - No hardware-specific code here
- Defines the contract all implementations must follow
- Virtual destructor for proper cleanup
3. IO Implementation - Sim or Real
Concrete implementations for different hardware contexts:
class ShooterWheelIOSim : public ShooterWheelIO {
public:
void updateInputs(ShooterWheelIOInputs& inputs) override {
// Update simulation physics
m_velocity += (m_appliedVolts / 12.0) * kMaxAccel * 0.02; // Simple first-order
m_velocity *= 0.98; // Friction damping
// Fill snapshot
inputs.velocityRadPerSec = m_velocity;
inputs.appliedVolts = m_appliedVolts;
inputs.currentAmps = std::abs(m_appliedVolts) * 2.0;
inputs.tempCelsius = 20.0;
}
void setVoltage(double volts) override {
m_appliedVolts = std::clamp(volts, -12.0, 12.0);
}
private:
double m_velocity = 0.0;
double m_appliedVolts = 0.0;
static constexpr double kMaxAccel = 50.0; // rad/s² per full voltage
};
Key Points:
- Overrides all virtual methods from interface
- Contains simulation-specific physics
- Could swap with
ShooterWheelIORealusing real motor controllers - Simulation updates in
updateInputs(), not in a separate periodic
For real hardware, you'd create ShooterWheelIOReal using motor controller objects (e.g., TalonFX, SparkMax). The subsystem doesn't know or care which implementation it's using!
Simple Proportional (P) Control
Now that we have clean sensor readings from the IO layer, let's use them to control velocity accurately.
Why Not Just Set Voltage?
In previous lessons, we set motor voltages directly:
// ❌ Open-loop voltage control
void handleIntakingState() {
m_io->setVoltage(6.0); // Hope this gives us the right speed?
}
Problems:
- Actual velocity depends on battery voltage (12V vs. 10V gives different speeds)
- Load changes affect speed (more resistance = slower)
- No way to hit a target velocity precisely
Enter Proportional Control
Instead, we use feedback control to automatically adjust voltage based on error:
// ✅ Closed-loop proportional control
void processState() {
m_io->updateInputs(m_inputs); // Read current velocity
// Calculate error
double error = m_targetVelocity - m_inputs.velocityRadPerSec;
// Proportional response: voltage proportional to error
double voltage = kP * error;
m_io->setVoltage(voltage); // Apply correction
}
How it works:
- Measure current velocity from sensors
- Calculate error: how far off from target
- Respond proportionally: bigger error = bigger correction
- Repeat every loop until error is small
Visual: P Control in Action
Imagine you're driving a car and trying to maintain 60 mph:
Target: 60 mph
Current: 40 mph → Error: +20 → Press gas pedal MORE (big correction)
Target: 60 mph
Current: 55 mph → Error: +5 → Press gas pedal slightly (small correction)
Target: 60 mph
Current: 60 mph → Error: 0 → Hold steady (no correction needed)
Target: 60 mph
Current: 65 mph → Error: -5 → Release gas slightly (negative correction)
This is exactly how P control works! The controller automatically adjusts its output (voltage) based on how far you are from the target.
Watch it in action:
- PID Control Animation - Shows how P, I, and D terms affect response
- Understanding PID Control (Video) - 9 minute overview with visual examples
Tuning kP
The proportional gain kP controls how aggressively the controller responds:
-
Too small (kP = 0.02):
Target: ─────────100─────────
Actual: ___/‾‾‾‾‾‾‾‾‾‾‾‾‾ (slow, takes forever) -
Too large (kP = 0.5):
Target: ─────────100─────────
Actual: __/\__/\__/\__/\__ (oscillates wildly) -
Just right (kP = 0.1):
Target: ─────────100─────────
Actual: __/‾‾‾‾‾‾‾‾‾‾‾‾‾‾ (fast, smooth approach)
For a shooter wheel, a good starting point might be kP = 0.1 (0.1 volts per rad/s of error).
Example:
- Target: 100 rad/s
- Current: 50 rad/s
- Error: 100 - 50 = 50 rad/s
- Command: 0.1 * 50 = 5.0 volts
As the wheel speeds up, error decreases, so the commanded voltage decreases too. Eventually it settles near the target!
Understanding PID Terms
While we're only using P (Proportional) in this lesson, here's what the full PID controller includes:
P (Proportional) - What we're using:
- Responds to current error
- Bigger error = bigger response
- Simple and effective for many mechanisms
I (Integral) - Advanced (not in this lesson):
- Responds to accumulated past error
- Eliminates steady-state error (tiny remaining offset)
- Can cause overshoot if not tuned carefully
D (Derivative) - Advanced (not in this lesson):
- Responds to rate of change of error
- Dampens oscillations
- Provides "braking" as you approach target
Visual comparison:
P only: Target ────────
Actual ___/‾‾ (slight steady-state error)
P + I: Target ────────
Actual ___/‾‾‾ (reaches exactly, may overshoot)
P + I + D: Target ────────
Actual ___/‾‾‾ (smooth, no overshoot, exact)
Learn more:
- WPILib PID Introduction
- Brian Douglas - What is PID Control? - Excellent visual explanation
Why P-Only?
In real FRC, you'd often use PID control (Proportional + Integral + Derivative) for better performance. But for this lesson:
- P-only is simpler to understand and implement
- Good enough for many mechanisms (especially flywheels)
- Easy to tune with just one constant
- Introduces the core concept of feedback control
You'll learn more advanced control (I, D terms, feedforward) in later lessons or in real competition code.
Subsystem with IO + P Control
Now let's see how to structure a subsystem that uses an IO layer and proportional control.
Constructor - Dependency Injection
Pass in the IO implementation when creating the subsystem:
class SimShooterWheel : public frc2::SubsystemBase {
public:
explicit SimShooterWheel(std::unique_ptr<ShooterWheelIO> io)
: m_io(std::move(io)) {
SetName("SimShooterWheel");
}
private:
std::unique_ptr<ShooterWheelIO> m_io;
ShooterWheelIOInputs m_inputs;
};
In RobotContainer:
// For simulation
m_shooterWheel{std::make_unique<ShooterWheelIOSim>()}
// For real robot, just swap the implementation
m_shooterWheel{std::make_unique<ShooterWheelIOReal>()}
State Machine with Velocity Control
The subsystem state machine now includes velocity setpoints:
enum class State {
kOff, // Motor off, no target
kSpinUp // Motor running to target velocity
};
void processState() {
// 1. Read inputs from hardware
m_io->updateInputs(m_inputs);
// 2. State transition
if (m_currentState != m_desiredState) {
m_currentState = m_desiredState;
}
// 3. Execute state logic
switch (m_currentState) {
case State::kOff:
handleOffState();
break;
case State::kSpinUp:
handleSpinUpState();
break;
}
}
void handleOffState() {
m_io->setVoltage(0.0);
m_targetVelocity = 0.0;
}
void handleSpinUpState() {
// Simple P controller
double error = m_targetVelocity - m_inputs.velocityRadPerSec;
double voltage = kP * error;
voltage = std::clamp(voltage, -12.0, 12.0); // Safety limits
m_io->setVoltage(voltage);
}
Setting Target Velocity
Add a method to update the velocity setpoint:
void setTargetVelocity(double velocityRadPerSec) {
m_targetVelocity = velocityRadPerSec;
}
Or combine with state change:
frc2::CommandPtr setSpinUpCmd(double velocityRadPerSec) {
return frc2::cmd::RunOnce([this, velocityRadPerSec] {
m_desiredState = State::kSpinUp;
m_targetVelocity = velocityRadPerSec;
}, {this});
}
Telemetry for Debugging
Publishing telemetry is crucial for tuning and debugging control loops:
void ConfigureTelemetry() {
m_shooterWheel.registerTelemetry([this] {
auto state = m_shooterWheel.getState();
// State and targets
frc::SmartDashboard::PutString("ShooterWheel/State", stateToString(state.currentState));
frc::SmartDashboard::PutNumber("ShooterWheel/TargetVelocity", state.targetVelocity);
// Actual measurements (from IO layer)
frc::SmartDashboard::PutNumber("ShooterWheel/ActualVelocity", state.inputs.velocityRadPerSec);
frc::SmartDashboard::PutNumber("ShooterWheel/Error", state.error);
// Motor outputs
frc::SmartDashboard::PutNumber("ShooterWheel/AppliedVolts", state.inputs.appliedVolts);
frc::SmartDashboard::PutNumber("ShooterWheel/CurrentAmps", state.inputs.currentAmps);
});
}
What to publish:
- Current state (for understanding behavior)
- Target values (what we're trying to achieve)
- Measured values (what's actually happening)
- Error (difference between target and measured)
- Commanded outputs (what we're commanding)
Viewing these in real-time on the Elastic dashboard helps you:
- Verify the controller is working
- Tune kP for better response
- Debug unexpected behavior
- Monitor motor health (current, temperature)
Controller Bindings
Use the familiar hold-to-run pattern with velocity control:
void ConfigureBindings() {
// R2: Hold to spin up to target velocity, release returns to off
m_driverController.R2()
.OnTrue(m_shooterWheel.setSpinUpCmd(100.0)) // 100 rad/s target
.OnFalse(m_shooterWheel.setDesiredStateCmd(SimShooterWheel::State::kOff));
// Cross: Emergency stop - set Off state (NOT direct motor stop)
m_driverController.Cross()
.OnTrue(m_shooterWheel.setDesiredStateCmd(SimShooterWheel::State::kOff));
}
Key Points:
- R2 press sets SpinUp state AND target velocity in one command
- R2 release returns to Off (stops motor via state machine)
- Cross emergency stop uses state machine pattern (consistent with Lesson 2)
- Target velocity is a constant for now (later could make it variable)
Read → Compute → Write Loop
The core control loop pattern is:
1. READ INPUTS
└─> m_io->updateInputs(m_inputs)
Get fresh sensor data from hardware
2. COMPUTE OUTPUTS
└─> Based on current state, inputs, and targets
Calculate what to command (e.g., P controller)
3. WRITE OUTPUTS
└─> m_io->setVoltage(voltage)
Send commands to hardware
This happens every robot loop (~50Hz, or every 20ms) in processState().
Why this order matters:
- Reading first ensures you're using fresh data
- Computing uses consistent data from one snapshot
- Writing last ensures all decisions are made before acting
- The cycle repeats, creating a feedback loop
Hands-On: Project 03
Open projects/03-io-velocity-control/ in WPILib VS Code and explore the code:
-
Study the IO layer:
- Find
ShooterWheelIOInputsstruct - Examine
ShooterWheelIOinterface (pure virtual methods) - Trace how
ShooterWheelIOSimimplements simulation physics - Notice how velocity updates with a simple first-order response
- Find
-
Study
SimShooterWheelsubsystem:- Find the state enum (Off, SpinUp)
- Follow the Read → Compute → Write pattern in
processState() - Understand the P controller implementation in
handleSpinUpState() - See how IO is injected via constructor
-
Study
RobotContainer:- See how
ShooterWheelIOSimis created and passed to subsystem - Find R2 binding with velocity setpoint
- Find Cross button emergency stop
- Examine telemetry publishing
- See how
-
Run in simulation:
- Build and simulate the project
- Open Elastic dashboard
- Press R2 (or keyboard equivalent) and watch telemetry:
ActualVelocityshould ramp up towardTargetVelocityErrorshould decrease as velocity approaches targetAppliedVoltsshould start high, then decrease as error decreases
- Release R2 and verify motor stops (velocity decays to zero)
- Press Cross and verify emergency stop works
Checkpoints & Questions
Before moving on, make sure you can answer these questions:
IO Layer Fundamentals
-
What are the three key components of an IO layer?
Answer
- IOInputs: Snapshot struct holding all sensor readings
- IO Interface: Abstract base class defining operations
- IO Implementation: Concrete class for sim or real hardware
-
Why do we use
std::unique_ptr<ShooterWheelIO>instead of a specific implementation?Answer
- Subsystem holds an interface pointer, not a concrete type
- This allows swapping implementations (sim, real, mock)
- Subsystem logic doesn't depend on which implementation is used
- This is dependency injection pattern
-
What's the benefit of using an IOInputs snapshot struct?
Answer
- Atomic snapshot of all sensors at one point in time
- No race conditions (all values from same moment)
- Easy to log/record entire state
- Clear separation: reading (IO) vs. using (subsystem logic)
Proportional Control
-
What is the formula for proportional control?
Answer
output = kP * errorwhereerror = target - measured -
What happens if kP is too large?
Answer
- Controller is too aggressive
- Overshoots target significantly
- Oscillates back and forth
- May never settle down
-
What happens if kP is too small?
Answer
- Controller is too gentle
- Takes a long time to reach target
- May never fully reach target (steady-state error)
- Slow response to disturbances
-
Why is P-only control "good enough" for a shooter flywheel?
Answer
- Flywheels have high inertia (resist velocity changes)
- Small steady-state error is often acceptable
- Simple to tune (one constant)
- Fast response is more important than perfect accuracy
- Can add feedforward for even better performance
Control Loop
-
What are the three steps in the Read → Compute → Write loop?
Answer
- Read:
updateInputs()to get sensor data - Compute: Calculate outputs based on state/inputs/targets
- Write: Send commands to hardware with
setVoltage()etc.
- Read:
-
Why do we call
updateInputs()at the start of everyprocessState()loop?Answer
- Get fresh sensor data for this control cycle
- Ensures decisions are based on current measurements
- Completes the feedback loop (measure → adjust → measure...)
Architecture
-
How does emergency stop work with this architecture?
Answer
- Cross button sets
m_desiredState = State::kOff handleOffState()sets voltage to 0.0 and clears target- Uses state machine pattern (doesn't bypass subsystem logic)
- Telemetry accurately shows Off state
- Cross button sets
Stretch Goals
After completing the basic lesson, try these challenges:
Challenge 1: Variable Velocity Control
Replace the fixed 100 rad/s target with analog stick control:
- Read right stick Y axis in RobotContainer
- Map stick value (-1 to +1) to velocity range (e.g., 0 to 150 rad/s)
- Update target velocity every loop while R2 is held
- Publish the commanded velocity to telemetry
- Test varying the stick position and watch velocity track
Challenge 2: Bang-Bang vs. P Control
Implement an alternative "bang-bang" controller and compare:
- Add a
bool m_useBangBangtoggle in subsystem - Implement bang-bang logic: full voltage if below target, zero if above
- Add button binding to toggle controller type
- Run both controllers and compare telemetry:
- Which settles faster?
- Which overshoots more?
- Which is smoother?
Challenge 3: Tune kP
Experiment with different kP values:
- Try kP = 0.05 (small)
- Try kP = 0.5 (large)
- Plot or observe ActualVelocity vs. Time for each
- Find the "sweet spot" kP that reaches target quickly without oscillation
- Document your findings in comments
Challenge 4: "At Target" Indicator
Add logic to detect when velocity is close to target:
- Add method
bool isAtTarget(double tolerance) - Return true if error is within tolerance (e.g., 5 rad/s)
- Publish "AtTarget" boolean to telemetry
- Test and verify it goes true when stable, false when changing
Challenge 5: Add Feedforward
Improve the controller with feedforward:
- Add a
kS(static friction) constant - Add a
kV(velocity feedforward) constant - Compute feedforward:
ff = kS * sgn(target) + kV * target - Combine with proportional:
voltage = ff + kP * error - Observe faster response and smaller steady-state error
Key Takeaways
🎯 IO Layer:
- Separates subsystem logic from hardware implementation
- Enables swapping sim/real hardware without changing subsystem
- Uses IOInputs snapshots for clean, atomic sensor readings
- Interface + implementation = flexibility and testability
🎯 Proportional Control:
- Feedback control: measure → compare → correct
- Output proportional to error:
output = kP * error - Automatically adjusts to reach target velocity
- Simple to implement and tune (one constant)
🎯 Control Loop Pattern:
- Read inputs from hardware (
updateInputs()) - Compute outputs based on state and targets (P controller)
- Write outputs to hardware (
setVoltage())
- Repeats every 20ms, creating a feedback loop
🎯 Benefits:
- ✅ Hardware-agnostic subsystem logic
- ✅ Accurate velocity control (not just voltage)
- ✅ Easy to test and swap implementations
- ✅ Clear telemetry for debugging and tuning
- ✅ Follows TEAM1771 production patterns
Next Steps
- ✅ Complete the hands-on project
03-io-velocity-control - ✅ Answer all checkpoint questions
- ✅ Try at least one stretch goal (kP tuning recommended)
- 📖 Review real IO layers in Reefscape-2025
- 🚀 Move to Lesson 4 (coming soon!)
Additional Resources
- WPILib State-Space Control
- Controls Engineering in FRC - Tyler Veness
- PID Control Explained
- TEAM1771 Reefscape-2025 - Real IO layer examples
- TEAM1771 Style Guide - Architecture patterns reference