How-To: Add New Hardware to ROCKO
This article describes how to add new hardware to ROCKO. This could be a sensor, a controller, or any other device that you would like to integrate in ROCKO’s system.
The following document details two forms of hardware integration: hardware with C/C++ libraries and hardware with Python libraries. Follow the instructions applicable to your device: check what kinds of libraries are provided by default through the manufacturer.
Linux Commands Used In This Article
mkdir
Create a new folder/directory
cp
Copy a file from one place to another
ls
List the files and folders in the given folder
nano
File text editor
Configuring Hardware for Use in ROS2
This section applies to both Python and C/C++ hardware.
Creating the Hardware Configuration
First, we will create the appropriate folders to house the hardware configuration. In this example, we will use the icm20948 IMU.
Substitute hardware names with new names descriptive of the hardware you are installing. In our example case, all instances of icm20948 will be replaced with a name for your hardware.
Create the folder for system libraries.
mkdir ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948
If you are adding a motor component instead, create a folder in the ‘motors’ subfolder rather than ‘sensors’:
mkdir ~/rocko_env/rocko_env/rocko_env/hardware/motors/MotorName
Remember to create all of the following files in the ‘motors' subfolder in this case. That means replacing all instances of ‘sensors’ to ‘motors’.
Create the file to store the hardware’s library logic.
If you are using a C++ library to interface with your hardware:
We do this by copying the hardware CPP example file to our new folder and renaming it.cp ~/rocko_env/examples/example_hardware/hardware_template.cpp ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/ICM20948.cpp
If you are using a Python library to interface with your hardware:
We do this by copying the hardware CPP example file to our new folder and renaming it.cp ~/rocko_env/examples/example_hardware/py_hardware_template.cpp ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/ICM20948.cpp
Create the folder to store headers for the hardware’s library logic.
mkdir ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/include/rocko_env
Create the file to store headers for the hardware’s library logic.
We do this by copying the hardware HPP example file to our new folder and renaming it.cp ~/rocko_env/examples/example_hardware/hardware_template.hpp ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/include/rocko_env/ICM20948.hpp
We create a special “include/rocko_env” folder for each hardware implementation so we can tell ROS2 later where they are located. This is necessary because ROS2 will need to build the code to make it accessible while running.
Next, we will update the contents of each file to reflect the installed hardware.
If you are implementing a motor:
Open the hpp file to edit.
nano ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/include/rocko_env/ICM20948.hpp
Follow the instructions inside the file. This means un-commenting the specified lines if you are implementing motor hardware.
“Un-commenting” in this case means removing the double slashes (//) prefixing a given line of code.
If you are implementing a motor or a sensor:
Open the cpp file to edit.
nano ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/ICM20948.cpp
We will follow the instructions listed in the TODO from here onward.
Include the .hpp file
#include “rocko_env/ICM20948.hpp”
Replace <CLASS_NAME> with your class name
We replace all instances of<CLASS_NAME>
withICM20948
.If implementing a motor, we un-comment the lines underneath the “TODO: Uncomment if implementing a motor” comment.
If implementing a motor, we change
SensorInterface
toActuatorInterface
at the end of the file.
Updating the ROS2 Configuration
We must update ROS2 configuration files to indicate to the system that new hardware is present, and tell it where the hardware files are stored.
Open the ROS2_control xacro file for editing.
nano ~/rocko_env/rocko_env/rocko_env/description/ros2_control/rocko.ros2_control.xacro
We will add a new
<ros2_control>
block to account for the new hardware. This means fulfilling the following requirements for your particular hardware implementation:The
<plugin>
block should contain the name of the hardware class.There should be
<state_interface>
tags for each state interface you intend to export. In the case of a motor controller, this might be<state_interface name="position"/>
and<state_interface name="velocity"/>
. For our IMU, we export yaw, pitch, and roll angles, so we add 3 state_interface tags.state interfaces are data points that we “expose” to ROS2, meaning when we define a variable as a state interface, that data point can be used by other hardware and logic.
There should be <command_interface> tags for each command interface you intend to export. In the case of a motor controller, this might be
<command_interface name="velocity"/>
. For our IMU, we have no command interfaces, so we don’t add any.command interfaces are data points that we “expose” to ROS2 that can be monitored as well as modified. That means our “command interfaces” are data points that other hardware or controllers might influence.
An example motor configuration:
<ros2_control name="LeftDriveMotor" type="actuator"> <hardware> <plugin>rocko_env/Motor12VoltQuadEncoder</plugin> <param name="pinNumberSpeed">14</param> <param name="pinNumberDirection">10</param> <param name="invert">true</param> </hardware> <joint name="left_wheel_joint"> <command_interface name="velocity"/> <state_interface name="position"/> <state_interface name="velocity"/> </joint> </ros2_control>
Open the hardware XML file for editing.
nano ~/rocko_env/rocko_env/rocko_env_hardware.xml
We now add a
<class>
section to define our new hardware.<class name="rocko_env/ICM20948" type="rocko_env::ICM20948" base_class_type="hardware_interface::SensorInterface"> <description> A 9-axis IMU for robot orientation </description> </class>
If you are adding motor hardware, remember to replace SensorInterface with ActuatorInterface.
Open the CMakeLists file for editing.
nano ~/rocko_env/rocko_env/CMakeLists.txt
Add the path to the newly created .cpp file to the
add_library()
section.In this case, we add our new IMU .cpp underneath the “Hardware .cpp files” section.
rocko_env/hardware/sensors/icm20948/ICM20948.cpp
Add the following line underneath “paths to hardware include folders”, with the hardware name replaced with your own.
$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/rocko_env/hardware/sensors/icm20948/include>
Add the ‘include’ directory to the first
install()
statement.After adding our IMU include directory, the block now looks like this:
install( DIRECTORY rocko_env/hardware/motors/include/ rocko_env/hardware/sensors/icm20948/include/ rocko_env/hardware/sensors/quadEncoder/include/ DESTINATION include )
Congrats, ROS2 has now been configured to support your new hardware!
Exporting State and Command Interfaces
This section applies to both Python and C/C++ hardware.
Earlier you determined the state and command interfaces for your system into rocko.ros2_control.xacro. Now, we need to put them into actual code.
This is the last step that both Python and C++ hardware share. After this section, navigate to the correct section depending on what language you’re writing your hardware in.
Open the .hpp file you created earlier.
In here we will be adding keys and variables for each command and state interface you declared.In the
private
section of the .hpp file, create astd::string
variable and storage variable for each command and state interface you have.
These keys will be used to help ros2_control access the interfaces when ROCKO is running.
The storage variables are where we keep the data that comes in through the command interfaces or gets sent out through the state interfaces.
The name of thestd::string
variable should follow this convention: <InterfaceName>_KEY, and the value assigned should be the exact text you put in thename=""
section of the tag in rocko.ros2_control.xacro.
The name of the storage variable should follow this convention: _<InterfaceName>.
The type of these storage variables depends on what kind of data you’re sending in/out. Below is a table with some common data types. Note that there are many other kinds of data that can be sent not listed in this table.
Data Type | Variable Type |
---|---|
A whole number, can be positive or negative | int |
A whole number, can only be positive | uint |
A number that can have decimal values | double |
Text | std::string |
True or False | bool |
For our implementation of icm20948, we have 3 state interfaces and no command interfaces. The state interfaces are all decimal values and are called yaw, pitch, and roll. Below is what our implementation’s private
section looks like:
private:
std::string YAW_KEY = "yaw";
std::string PITCH_KEY = "pitch";
std::string ROLL_KEY = "roll";
double _yaw = 0;
double _pitch = 0;
double _roll = 0;
Open the .cpp file you created earlier.
Now that we’ve made keys to access our command and state interfaces and variables to store their values in, we need to use them!If you have state interfaces, navigate to
export_state_interfaces()
. In here we will expose the storage variables we just made to ros2_control. This will allow ros2_control to get data from our hardware and use it in other places. Each state interface requires one of these lines to expose it to ros2_control:state_interfaces.emplace_back(hardware_interface::StateInterface( <JointName>, <KeyVariable>, &<StorageVariable>));
Replace <JointName>
with the joint name you used in rocko.ros2_control.xacro<joint name="<JointName>">
Replace <KeyVariable>
with the variable you made in the .hpp file that holds the state interface’s name.
Replace <StorageVariable>
with the variable you made in the .hpp file that stores the data corresponding to this state interface.
For the icm20948, export_state_interfaces()
looks like this:
std::vector<hardware_interface::StateInterface> ICM20948::export_state_interfaces()
{
// Export the member level vars we saved data to in read()
std::vector<hardware_interface::StateInterface> state_interfaces;
state_interfaces.emplace_back(hardware_interface::StateInterface(
"left_wheel_joint", YAW_KEY, &_yaw));
state_interfaces.emplace_back(hardware_interface::StateInterface(
"left_wheel_joint", PITCH_KEY, &_pitch));
state_interfaces.emplace_back(hardware_interface::StateInterface(
"left_wheel_joint", ROLL_KEY, &_roll));
state_interfaces.emplace_back(hardware_interface::StateInterface(
"right_wheel_joint", YAW_KEY, &_yaw));
state_interfaces.emplace_back(hardware_interface::StateInterface(
"right_wheel_joint", PITCH_KEY, &_pitch));
state_interfaces.emplace_back(hardware_interface::StateInterface(
"right_wheel_joint", ROLL_KEY, &_roll));
return state_interfaces;
}
If you have command interfaces, navigate to
export_command_interfaces()
. In here we will expose the storage variables we just made to ros2_control. This will allow ros2_control to send data to our hardware. Each command interface requires one of these lines to expose it to ros2_control:command_interfaces.emplace_back(hardware_interface::CommandInterface( <JointName>, <KeyVariable>, &<StorageVariable>));
Replace <JointName>
with the joint name you used in rocko.ros2_control.xacro<joint name="<JointName>">
Replace <KeyVariable>
with the variable you made in the .hpp file that holds the command interface’s name.
Replace <StorageVariable>
with the variable you made in the .hpp file that stores the data corresponding to this command interface.
The icm20948 doesn’t have any command interfaces, but our motors do. They have one command interface, called velocity
. This is what export_command_interfaces()
looks like for a motor:
std::vector<hardware_interface::CommandInterface> Motor12VoltQuadEncoder::export_command_interfaces()
{
std::vector<hardware_interface::CommandInterface> command_interfaces;
command_interfaces.emplace_back(hardware_interface::CommandInterface(
"left_wheel_joint", VELOCITY_KEY, &cmd));
return command_interfaces;
}
Do I use Python or C++?
The rest of this tutorial is a choose-your-own-adventure, depending on if you’re going to implement your hardware in Python or C++. It can be hard to decide which to use, so here are some things to consider.
Use Python if:
The library provided for your sensor (if there is one) is written in Python
You’re newer to writing code
Your robot’s algorithms can deal with lag (Python runs much slower than C++)
Use C++ if:
The library provided for your sensor (if there is one) is written in C++
You have experience with writing code
Your robot’s algorithms need fast cycle times
If Python fits better with your hardware and system setup, go to the Python section.
If C++ is more your speed, go to the C++ section.
Using Python Hardware Libraries
If you are using a Python library to interface with your new hardware, there are some extra steps to get everything running.
Creating a New Service Message
Navigate to the rocko_interface package’s services folder by calling
cd ~/ROCKO-env/rocko_interfaces/srv/
Create a new service message file with the name of your new hardware.
In our example with icm20948, we’d make that file withnano Icm20948Data.srv
.
Make sure to follow these naming rules for your service message file, or it will not build:
- Capitalize the first letter of the file name
- No spaces, underscores, or hyphens are allowed in the file name
Add request and response data to the service message file
The ros2_control library will send a request to your Python node, and your Python node will send a response back to ros2_control.
You’ll want to add data to the request section of the message if you want ros2_control to command parts of your hardware. This is a must for motor implementations but can also be useful for sensors that may need to be reset while ROCKO is running.
You’ll want to add data to the response section if you want to send info back to ros2_control. This is a must for sensor implementations.
Below is our example with the icm20948:--- float64 yaw float64 pitch float64 roll
Above the --- is where you put the request data and below is where the response data goes. For this implementation, we only send back data; ros2_control doesn’t command the icm20948 at all. You must also give data types for each piece of data in the request and response. You can utilize the standard message package or use others as outlined in this tutorial.
Creating a Python Node
Navigate to the folder where you put the CPP file earlier in the tutorial.
In our example with icm20948, that’s~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948
.Create the file to store the python node.
We do this by copying the hardware Python example file to our new folder and renaming it.cp ~/rocko_env/examples/example_hardware/hardware_template.py ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/ICM20948.py
Open the Python file to edit.
nano ~/rocko_env/rocko_env/rocko_env/hardware/sensors/icm20948/ICM20948.py
Complete the TODOs in the Python file.
Implementing a Python Node
The Python node is split into two sections: the initialization section and the callback section.
The initialization section, found under def __init__(self):
, runs once when ROCKO starts up. Here the hardware is set up and the service that communicates with ros2_control is started.
The callback section, found under def callback(self, request, response):
, is what gets run whenever ros2_control sends your hardware a request. In here you’ll either command your hardware to do something or ask it for data that you’ll then send back to ros2_control as a response.
Below is our implementation for the icm20948 sensor
def __init__(self):
super().__init__('icm20948_node')
# Create the IMU object and initialize it with the I2C bus
i2c = board.I2C()
self.icm = adafruit_bno055.BNO055_I2C(i2c)
# Create a new service to send data to ros2_control
self.srv = self.create_service(Icm20948Data, 'icm20948_data', self.callback)
def callback(self, request, response):
# Get angle data from IMU object and put it into the response object
# to send to ros2_control
response.yaw = self.icm.euler[0]
response.roll = self.icm.euler[1]
response.pitch = self.icm.euler[2]
return response
Add Service Message to .hpp file
Navigate to and open the .hpp file you made for this hardware.
Add this line to the top of the file, where the other
#include
statements are.#include "rocko_interfaces/srv/<ServiceType>.hpp"
Replace
<ServiceType>
with the name of the service message you created in rocko_interfaces
The name of the .hpp file for your service message will not match the format that you used in Python. The file name will
- Have an _ between each capital letter
- Be entirely lowercase
In our example, we named our service message Icm20948Data.srv; the file name we imported was icm20948_data.hpp.
If you’re unable to figure out the name of your service message’s .hpp file, you can run colcon build --packages-select rocko_interfaces
, then run ls ~/ROCKO-env/install/rocko_interfaces/include/rocko_interfaces/rocko_interfaces/srv/
to list all of the service message .hpp files.
Create Service Client
Navigate to the
onInit()
function in your .cpp file
In here is where we set up, among other things, the client that will send requests and receive data from the Python node.Complete the TODOs in this function.
If your new service message has data in its request, then navigate to the
write()
function. You may need to uncomment it. In here lives code that will send a request to your Python node and receive a response back from itComplete the first TODO.
Find the second TODO. Here is where we will put data into the request we will send to your Python node. Oftentimes, this data will come from the command interfaces you exported using
export_command_interfaces()
.Find the third TODO. You only need to do this step if your service message has data in its response.
Theres
variable stores the response data from your Python node; this data can be used for internal calculations for this piece of hardware, but more commonly we’ll want to give it to ros2_control to be used by other hardware or controllers. In that case, you’ll want to store the values coming from the response in the state interface storage variables we made earlier in this tutorial.As an example, let’s consider a Python node for a motor, which takes in a speed in radians/second. Our exported command interface is of type double to allow us to have decimal values in our speed, and we store that reference in the variable
_cmd
. Our exported state interface is also of type double, and we store that reference in the variable_speed
. Our Python node takes in a request with the desired speed in radians/second. Our Python node will send back its current speed in radians/second in the response. Below is what our service message looks like:float64 desiredSpeed --- float64 currentSpeed
Below is what our
write()
will look like:auto request = std::make_shared<ServiceType::Request>(); // TODO: Fill in data for your request request->desiredSpeed = _cmd; auto result = _client->async_send_request(request); // Wait for the result. if ((rclcpp::spin_until_future_complete(_node, result) == rclcpp::FutureReturnCode::SUCCESS) && result.valid()) { auto res = result.get(); // TODO: Grab the response data and put into variables for you to use _speed = res->currentSpeed } else { RCLCPP_ERROR(rclcpp::get_logger("rclcpp"), "Failed to call service <ClassName>"); }
Using C/C++ Hardware Libraries
Open the .cpp file you created earlier.
Determine which functions you need to implement.
C++ hardware classes have several functions that you can choose to implement depending on what you need. All of the functions are included in the .cpp file you’ve been working in, and the optional ones are commented out. Feel free to delete the ones you don’t need. Below is a table outlining each function and its purpose:
Function | What do I do in here? | Required for? |
---|---|---|
onInit() | Construct any hardware objects you need to interface with and acquire parameters from rocko.ros2_control.xacro. Hardware should not be able to move after calling this function. | Sensors and Actuators |
onActivate() | Enable hardware to start moving. | Actuators |
onDeactivate() | Disable hardware from moving. Hardware should be left in a state where it could be reactivated by onActivate(). | Actuators |
onShutdown() | Deallocate constructed hardware if needed. | Sensors and Actuators (if needed) |
read() | Supply new data to the state interface supply variables. | Hardware with state interfaces |
write() | Read the new data in the command interface supply variables and do something with it | Hardware with command interfaces |
For sample code using these functions, see our motor implementation.