IO Layers
IO Layers are a fundamental concept when using the AdvantageKit
library. AdvantageKit
is primarily used to provide
abstract interfaces for controlling groups of hardware, which can then be used to easily log and simulate said hardware.
What is an IO Layer?
An IO Layer is a class that exposes a set of methods for interacting with a specific piece or group of hardware. For
example, a Gripper
IO Layer might expose methods for opening and closing the gripper, while a DriveTrain
IO Layer
might
expose methods for driving the robot.
IO Layers are designed to be simple and easy to use, and are intended to abstract away the complexities of the hardware they control. This makes it easy to write code that interacts with the hardware, without having to worry about the specifics of how the hardware works.
Creating an IO Layer
Creating an IO Layer involves two steps, creating the IO Layer interface, which is basically a list of methods that the IO Layer will expose, and than as many IO Layer implementations as you need, which are the classes that actually implement the methods, but for different sets of hardware, or on a more basic level, real vs simulated hardware.
The interface also contains a nested Inputs
class, which contains a list of values that the IO Layer will read from
the hardware. These values then get logged and can be used for match replays, and similar things.
Creating the Interface
The interface is a simple Kotlin interface, with a nested Inputs
class. Here is an example of a simple IO Layer
interface for a Gripper
:
interface GripperIO { // Define the interface for the Gripper
class GripperIOInputs : LoggableInputs { // Define the inputs class
var isOpen: Boolean = false // Define an input for the gripper state
var holdingCube: Boolean = false // Define an input for whether the gripper is holding a cube
var cubeDistance: Double = 0.0 // Define an input for the distance of the cube from the back of the gripper
override fun toLog(table: LogTable?) {
table?.put("isOpen", isOpen)
table?.put("holdingCube", holdingCube)
table?.put("cubeDistance", cubeDistance)
}
override fun fromLog(table: LogTable?) {
isOpen = table?.get("isOpen") ?: isOpen
holdingCube = table?.get("holdingCube") ?: holdingCube
cubeDistance = table?.get("cubeDistance") ?: cubeDistance
}
}
fun updateInputs(inputs: GripperIOInputs) {} // Define a method for updating the inputs of the gripper
fun openGripper() {} // Define a method for opening the gripper
fun closeGripper() {} // Define a method for closing the gripper
}
As you can see, it's a very simple concept. You may also notice the LoggableInputs
interface, which is a simple
interface that defines methods for converting the inputs to and from a LogTable
, which is a simple key-value store
that is used for logging. Note the curly braces after each method, this is required for the default implementation of
the IO layer to be used for replay.
Input Classes In Depth
The Inputs
class is used to "Log" the state of the mechanism at a given point in time. This allows us to do things like
look at the state of our code when an error occurred, or even replay a match with code changes, using the original inputs.
The inputs class is comprised of 3 parts, the variables, the toLog
method, and the fromLog
method. The variables are
the values that you want to log, and are the values that you will be updating in the IO layer's updateInputs
method.
The toLog
method is used to convert the inputs to a LogTable
, which is a simple key-value store that is used for
logging the state of the mechanism to a file. The fromLog
method is used to convert the LogTable
back into the
inputs, so that you can use them in your code.
The purpose of these inputs classes is to sit in between the hardware and the control logic, and log anything that is input into the control logic. Anything that is a output of the control logic, such as desired motor speeds, should not be logged in the inputs class. We will go over how to log those at a later date. Here are some examples of what would be inputs, and what would be outputs:
Input | Output |
---|---|
Anything Read From A Sensor or Device (Motor voltage, Position, Limit Switch State | Desired Motor Speed |
If devices are enabled or disabled | Pose of the robot |
If a mechanism is holding a game piece |
There are a lot of things that can be inputs, and a lot of things that can be outputs, but the general rule of thumb is that if it's something that is read from a sensor or device, it should be an input, and if it's something that is calculated by the control logic, it should be an output.
Creating the Implementation
Now that you've defined the interface, you need to create an implementation of the interface. Here is an example of a
simple implementation of the GripperIO
interface using solenoids:
class GripperIOSolenoids : GripperIO {
val solenoid = DoubleSolenoid(0) // Create a new Solenoid on port 0
val sensor = Ultrasonic(0, 1) // Create a new Ultrasonic sensor on ports 0 and 1
override fun updateInputs(inputs: GripperIOInputs) {
inputs.isOpen = solenoid.get() == Value.kForward
inputs.holdingCube = sensor.getRangeMM < 100
inputs.cubeDistance = sensor.getRangeInches
}
override fun openGripper() {
solenoid.set(Value.kForward)
}
override fun closeGripper() {
solenoid.set(Value.kReverse)
}
}
This implementation uses a DoubleSolenoid
to control the gripper, and an Ultrasonic
sensor to detect if a cube is
present in the gripper. The updateInputs
method reads the state of the solenoid and the distance of the cube from the
back of the gripper, and updates the inputs accordingly.
You may notice that we're not exposing any methods for reading the inputs, this is because when using an IO layer, the
inputs will be created elsewhere, and when the updateInputs
method is called, it will update the inputs with the
current state of the hardware, so then you can read the inputs from the inputs object.
Creating Inputs out of Measures (Units Library)
WPILib's java units library doesn't translate perfectly to AdvantageKit's inputs like doubles do, so let's real quick go over how you create your toLog and fromLog methods when using Measures.
interface FlywheelIO {
class FlywheelIOInputs : LoggableInputs {
var speed: MutableMeasure<Velocity<Angle>> = MutableMeasure.zero(Units.RadiansPerSecond)
var voltage: MutableMeasure<Voltage> = MutableMeasure.zero(Units.Volts)
// Luckily, you can just put the measure directly into the table
override fun toLog(table: LogTable?) {
table?.put("speed", speed)
table?.put("voltage", voltage)
}
// This is where it gets a bit more complicated
override fun fromLog(table: LogTable?) {
speed.mut_replace(table?.get("speed", speed)) // Replace the speed measure with the one from the table, or keep the old one if it can't be found
voltage.mut_replace(table?.get("voltage", voltage)) // Replace the voltage measure with the one from the table, or keep the old one if it can't be found
}
}
fun updateInputs(inputs: FlywheelIOInputs)
fun setVoltage(voltage: Measure<Voltage>)
}
Your updateInputs also looks slightly different, so lets quickly go over that as well for completeness.
class FlywheelIOSpeedController : FlywheelIO {
val motor = CANSparkMax(0, MotorType.kBrushless) // Create a new SparkMax on port 0
override fun updateInputs(inputs: FlywheelIOInputs) {
inputs.speed.mut_replace(motor.encoder.velocity, Units.RPM) // Assuming the encoder is outputting RPM velocity readings
inputs.voltage.mut_replace(motor.appliedOutput * motor.busVoltage, Units.Volts)
}
override fun setVoltage(voltage: Measure<Voltage>) {
motor.setVoltage(voltage into Units.Volts)
}
}
Conclusion
IO Layers are a powerful tool for abstracting away the complexities of hardware, and making it easy to write code that interacts with the hardware. By creating simple interfaces and implementations, you can easily control and log hardware in your robot code, without having to worry about the specifics of how the hardware works. This makes it easy to write clean, maintainable code that is easy to understand and debug.