Build Custom ROS 2 Control Hardware Interfaces in 45 Minutes

Create production-ready hardware interfaces for custom actuators in ROS 2 Humble/Iron with SystemInterface implementation and real-time control.

Problem: Your Custom Actuator Won't Integrate with ROS 2 Control

You have a custom motor controller or actuator that doesn't fit standard ROS 2 Control interfaces, and you need it to work with MoveIt, Nav2, or other ROS 2 controllers.

You'll learn:

  • How to implement the SystemInterface for custom hardware
  • Proper read/write cycle handling in real-time loops
  • URDF integration and controller configuration
  • State and command interface management

Time: 45 min | Level: Advanced


Why This Happens

ROS 2 Control uses a hardware abstraction layer that requires explicit implementation. Unlike ROS 1's controller_manager, you can't just publish/subscribe to topics – you must implement the hardware_interface::SystemInterface class.

Common symptoms:

  • "No hardware interface found" errors when launching controllers
  • Controllers load but actuators don't respond
  • State values always return zero
  • Build succeeds but runtime crashes with undefined symbols

Solution

Step 1: Set Up Your Package Structure

# Create hardware interface package
ros2 pkg create --build-type ament_cmake \
  --dependencies hardware_interface pluginlib rclcpp \
  my_robot_hardware

cd my_robot_hardware
mkdir -p include/my_robot_hardware src config urdf

Expected: Package structure with proper dependencies in package.xml and CMakeLists.txt


Step 2: Implement SystemInterface Header

Create include/my_robot_hardware/custom_actuator_interface.hpp:

#pragma once

#include <memory>
#include <string>
#include <vector>

#include "hardware_interface/handle.hpp"
#include "hardware_interface/hardware_info.hpp"
#include "hardware_interface/system_interface.hpp"
#include "hardware_interface/types/hardware_interface_return_values.hpp"
#include "rclcpp/macros.hpp"
#include "rclcpp_lifecycle/state.hpp"

namespace my_robot_hardware
{
class CustomActuatorInterface : public hardware_interface::SystemInterface
{
public:
  RCLCPP_SHARED_PTR_DEFINITIONS(CustomActuatorInterface)

  // Lifecycle methods - called during controller_manager state transitions
  hardware_interface::CallbackReturn on_init(
    const hardware_interface::HardwareInfo & info) override;

  hardware_interface::CallbackReturn on_configure(
    const rclcpp_lifecycle::State & previous_state) override;

  std::vector<hardware_interface::StateInterface> export_state_interfaces() override;
  std::vector<hardware_interface::CommandInterface> export_command_interfaces() override;

  hardware_interface::CallbackReturn on_activate(
    const rclcpp_lifecycle::State & previous_state) override;

  hardware_interface::CallbackReturn on_deactivate(
    const rclcpp_lifecycle::State & previous_state) override;

  // Real-time loop methods - called at controller update rate (typically 100-1000Hz)
  hardware_interface::return_type read(
    const rclcpp::Time & time, const rclcpp::Duration & period) override;

  hardware_interface::return_type write(
    const rclcpp::Time & time, const rclcpp::Duration & period) override;

private:
  // Hardware communication (e.g., serial port, CAN bus, Ethercat)
  std::unique_ptr<ActuatorDriver> driver_;

  // Data storage for state interfaces (what controllers READ)
  std::vector<double> hw_positions_;
  std::vector<double> hw_velocities_;
  std::vector<double> hw_efforts_;

  // Data storage for command interfaces (what controllers WRITE)
  std::vector<double> hw_commands_position_;
  std::vector<double> hw_commands_velocity_;
  std::vector<double> hw_commands_effort_;
};

}  // namespace my_robot_hardware

Why this structure:

  • State interfaces expose sensor data TO controllers
  • Command interfaces receive commands FROM controllers
  • Separate vectors prevent race conditions in read/write cycles

Step 3: Implement Core Methods

Create src/custom_actuator_interface.cpp:

#include "my_robot_hardware/custom_actuator_interface.hpp"
#include "hardware_interface/types/hardware_interface_type_values.hpp"

namespace my_robot_hardware
{

hardware_interface::CallbackReturn CustomActuatorInterface::on_init(
  const hardware_interface::HardwareInfo & info)
{
  // Parent class stores info_ member with URDF parameters
  if (hardware_interface::SystemInterface::on_init(info) != 
      hardware_interface::CallbackReturn::SUCCESS)
  {
    return hardware_interface::CallbackReturn::ERROR;
  }

  // Resize storage based on number of joints in URDF
  hw_positions_.resize(info_.joints.size(), std::numeric_limits<double>::quiet_NaN());
  hw_velocities_.resize(info_.joints.size(), std::numeric_limits<double>::quiet_NaN());
  hw_efforts_.resize(info_.joints.size(), std::numeric_limits<double>::quiet_NaN());
  hw_commands_position_.resize(info_.joints.size(), std::numeric_limits<double>::quiet_NaN());

  // Parse custom parameters from URDF
  try {
    auto port = info_.hardware_parameters["port"];  // e.g., "/dev/ttyUSB0"
    auto baud = std::stoi(info_.hardware_parameters["baudrate"]);
    
    // Initialize your hardware driver
    driver_ = std::make_unique<ActuatorDriver>(port, baud);
  } catch (const std::exception & e) {
    RCLCPP_ERROR(rclcpp::get_logger("CustomActuatorInterface"), 
                 "Failed to parse parameters: %s", e.what());
    return hardware_interface::CallbackReturn::ERROR;
  }

  return hardware_interface::CallbackReturn::SUCCESS;
}

hardware_interface::CallbackReturn CustomActuatorInterface::on_configure(
  const rclcpp_lifecycle::State & /*previous_state*/)
{
  // Connect to hardware but don't enable motors yet
  if (!driver_->connect()) {
    RCLCPP_ERROR(rclcpp::get_logger("CustomActuatorInterface"), 
                 "Failed to connect to actuator");
    return hardware_interface::CallbackReturn::ERROR;
  }

  // Read initial positions from hardware
  for (size_t i = 0; i < info_.joints.size(); ++i) {
    hw_positions_[i] = driver_->read_position(i);
    hw_commands_position_[i] = hw_positions_[i];  // Prevent jump on activation
  }

  return hardware_interface::CallbackReturn::SUCCESS;
}

std::vector<hardware_interface::StateInterface> 
CustomActuatorInterface::export_state_interfaces()
{
  std::vector<hardware_interface::StateInterface> state_interfaces;

  for (size_t i = 0; i < info_.joints.size(); ++i)
  {
    // Controllers read these values via resource manager
    state_interfaces.emplace_back(
      info_.joints[i].name, 
      hardware_interface::HW_IF_POSITION, 
      &hw_positions_[i]
    );
    
    state_interfaces.emplace_back(
      info_.joints[i].name,
      hardware_interface::HW_IF_VELOCITY,
      &hw_velocities_[i]
    );

    state_interfaces.emplace_back(
      info_.joints[i].name,
      hardware_interface::HW_IF_EFFORT,
      &hw_efforts_[i]
    );
  }

  return state_interfaces;
}

std::vector<hardware_interface::CommandInterface>
CustomActuatorInterface::export_command_interfaces()
{
  std::vector<hardware_interface::CommandInterface> command_interfaces;

  for (size_t i = 0; i < info_.joints.size(); ++i)
  {
    // Controllers write commands here
    command_interfaces.emplace_back(
      info_.joints[i].name,
      hardware_interface::HW_IF_POSITION,
      &hw_commands_position_[i]
    );
  }

  return command_interfaces;
}

hardware_interface::CallbackReturn CustomActuatorInterface::on_activate(
  const rclcpp_lifecycle::State & /*previous_state*/)
{
  // Enable motors and prepare for control loop
  if (!driver_->enable()) {
    return hardware_interface::CallbackReturn::ERROR;
  }

  RCLCPP_INFO(rclcpp::get_logger("CustomActuatorInterface"), 
              "Successfully activated hardware");
  return hardware_interface::CallbackReturn::SUCCESS;
}

hardware_interface::CallbackReturn CustomActuatorInterface::on_deactivate(
  const rclcpp_lifecycle::State & /*previous_state*/)
{
  // Disable motors safely
  driver_->disable();
  return hardware_interface::CallbackReturn::SUCCESS;
}

hardware_interface::return_type CustomActuatorInterface::read(
  const rclcpp::Time & /*time*/, 
  const rclcpp::Duration & /*period*/)
{
  // Read current state from hardware - called BEFORE controller update
  for (size_t i = 0; i < info_.joints.size(); ++i)
  {
    hw_positions_[i] = driver_->read_position(i);
    hw_velocities_[i] = driver_->read_velocity(i);
    hw_efforts_[i] = driver_->read_effort(i);
  }

  return hardware_interface::return_type::OK;
}

hardware_interface::return_type CustomActuatorInterface::write(
  const rclcpp::Time & /*time*/, 
  const rclcpp::Duration & /*period*/)
{
  // Send commands to hardware - called AFTER controller update
  for (size_t i = 0; i < info_.joints.size(); ++i)
  {
    // Check for NaN commands (controller not initialized)
    if (std::isnan(hw_commands_position_[i])) {
      continue;
    }

    driver_->write_position(i, hw_commands_position_[i]);
  }

  return hardware_interface::return_type::OK;
}

}  // namespace my_robot_hardware

// Export plugin using pluginlib
#include "pluginlib/class_list_macros.hpp"
PLUGINLIB_EXPORT_CLASS(
  my_robot_hardware::CustomActuatorInterface,
  hardware_interface::SystemInterface
)

Critical timing: read() → controller update → write() happens in a single control loop iteration. Never block these calls.

If it fails:

  • Error: "undefined reference to ActuatorDriver": Replace with your actual hardware driver
  • Segfault on read(): Check all vectors are properly sized in on_init()
  • NaN values in state: Hardware driver not returning valid data

Step 4: Configure Plugin Export

Create my_robot_hardware_plugin.xml:

<library path="my_robot_hardware">
  <class 
    name="my_robot_hardware/CustomActuatorInterface" 
    type="my_robot_hardware::CustomActuatorInterface" 
    base_class_type="hardware_interface::SystemInterface">
    <description>
      Hardware interface for custom actuator system
    </description>
  </class>
</library>

Update package.xml:

<export>
  <hardware_interface plugin="${prefix}/my_robot_hardware_plugin.xml"/>
</export>

Update CMakeLists.txt:

# After find_package calls
add_library(
  my_robot_hardware
  SHARED
  src/custom_actuator_interface.cpp
)

target_include_directories(
  my_robot_hardware
  PRIVATE
  include
)

ament_target_dependencies(
  my_robot_hardware
  hardware_interface
  pluginlib
  rclcpp
  rclcpp_lifecycle
)

# Export plugin
pluginlib_export_plugin_description_file(
  hardware_interface 
  my_robot_hardware_plugin.xml
)

install(
  TARGETS my_robot_hardware
  DESTINATION lib
)

install(
  DIRECTORY include/
  DESTINATION include
)

Step 5: Create URDF with Hardware Interface

Create urdf/my_robot.urdf.xacro:

<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="my_robot">

  <!-- Your existing robot description -->
  <link name="base_link"/>
  
  <joint name="joint1" type="revolute">
    <parent link="base_link"/>
    <child link="link1"/>
    <origin xyz="0 0 0" rpy="0 0 0"/>
    <axis xyz="0 0 1"/>
    <limit lower="-3.14" upper="3.14" effort="10" velocity="2.0"/>
  </joint>

  <link name="link1">
    <visual>
      <geometry>
        <cylinder radius="0.05" length="0.3"/>
      </geometry>
    </visual>
  </link>

  <!-- ROS 2 Control hardware interface -->
  <ros2_control name="MyRobotSystem" type="system">
    
    <hardware>
      <!-- Plugin name must match plugin.xml -->
      <plugin>my_robot_hardware/CustomActuatorInterface</plugin>
      
      <!-- Custom parameters passed to on_init() -->
      <param name="port">/dev/ttyUSB0</param>
      <param name="baudrate">115200</param>
    </hardware>

    <joint name="joint1">
      <!-- These define what interfaces controllers can claim -->
      <command_interface name="position">
        <param name="min">-3.14</param>
        <param name="max">3.14</param>
      </command_interface>
      <state_interface name="position"/>
      <state_interface name="velocity"/>
      <state_interface name="effort"/>
    </joint>

  </ros2_control>

</robot>

Why this matters: The <hardware> plugin name must exactly match the class name in your plugin XML. Mismatches cause "plugin not found" errors.


Step 6: Configure Controllers

Create config/controllers.yaml:

controller_manager:
  ros__parameters:
    update_rate: 100  # Hz - balance between responsiveness and CPU load

    # List of controllers to load
    joint_trajectory_controller:
      type: joint_trajectory_controller/JointTrajectoryController

    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

# Controller-specific configuration
joint_trajectory_controller:
  ros__parameters:
    joints:
      - joint1
    
    command_interfaces:
      - position
    
    state_interfaces:
      - position
      - velocity
    
    # Safety limits
    constraints:
      stopped_velocity_tolerance: 0.01
      goal_time: 0.5

joint_state_broadcaster:
  ros__parameters:
    # Publishes /joint_states for RViz and other tools
    joints:
      - joint1

Step 7: Create Launch File

Create launch/robot_control.launch.py:

from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import ExecuteProcess
from ament_index_python.packages import get_package_share_directory
import os

def generate_launch_description():
    pkg_dir = get_package_share_directory('my_robot_hardware')
    
    urdf_file = os.path.join(pkg_dir, 'urdf', 'my_robot.urdf.xacro')
    controller_config = os.path.join(pkg_dir, 'config', 'controllers.yaml')

    # Process URDF with xacro
    robot_description = ExecuteProcess(
        cmd=['xacro', urdf_file],
        output='screen'
    )

    # Start controller manager with robot description
    controller_manager = Node(
        package='controller_manager',
        executable='ros2_control_node',
        parameters=[
            {'robot_description': robot_description},
            controller_config
        ],
        output='screen'
    )

    # Spawn controllers
    spawn_joint_state_broadcaster = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['joint_state_broadcaster'],
        output='screen'
    )

    spawn_joint_trajectory_controller = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['joint_trajectory_controller'],
        output='screen'
    )

    return LaunchDescription([
        controller_manager,
        spawn_joint_state_broadcaster,
        spawn_joint_trajectory_controller
    ])

Verification

# Build your package
cd ~/ros2_ws
colcon build --packages-select my_robot_hardware

# Source and launch
source install/setup.bash
ros2 launch my_robot_hardware robot_control.launch.py

You should see:

[INFO] [controller_manager]: Loaded controller 'joint_state_broadcaster'
[INFO] [controller_manager]: Configured controller 'joint_trajectory_controller'
[INFO] [CustomActuatorInterface]: Successfully activated hardware
[INFO] [spawner]: Configured and activated joint_trajectory_controller

Test it:

# Check controllers are active
ros2 control list_controllers

# Expected output:
# joint_state_broadcaster[joint_state_broadcaster/JointStateBroadcaster] active
# joint_trajectory_controller[joint_trajectory_controller/JointTrajectoryController] active

# Send test command
ros2 topic pub /joint_trajectory_controller/joint_trajectory \
  trajectory_msgs/msg/JointTrajectory \
  "{joint_names: ['joint1'], points: [{positions: [1.0], time_from_start: {sec: 2}}]}" \
  --once

If it fails:

  • "Failed to load hardware interface": Check plugin name matches exactly in URDF and XML
  • Controllers load but don't move: Verify write() is being called with ros2 topic echo /diagnostics
  • "Resource conflict": Another controller claimed the same interface – check list_hardware_interfaces
  • Real-time loop violations: Your read()/write() methods are too slow – profile with cyclonedds diagnostics

What You Learned

  • ROS 2 Control separates hardware abstraction from controller logic
  • State interfaces expose sensor data, command interfaces receive control signals
  • The control loop is: read() → controller update → write() at fixed rate
  • Plugin system requires exact name matching between XML and URDF

Limitations:

  • No built-in error recovery – implement watchdogs in your driver
  • Single-threaded by default – don't block in read/write
  • State changes (configure/activate) happen outside real-time loop

When NOT to use this:

  • Simple actuators with ROS 2 topics already working (over-engineering)
  • Non-real-time control (just use action servers)
  • Hardware with high-latency communication (>10ms) – consider async patterns

Advanced: Real-Time Safety

For production systems, add these to read() and write():

hardware_interface::return_type CustomActuatorInterface::write(
  const rclcpp::Time & time, const rclcpp::Duration & period)
{
  // Watchdog: detect stale commands
  static rclcpp::Time last_command_time = time;
  if ((time - last_command_time).seconds() > 0.5) {
    RCLCPP_WARN_THROTTLE(
      rclcpp::get_logger("CustomActuatorInterface"),
      *rclcpp::Clock::make_shared(), 1000,
      "No commands received for 0.5s - holding position"
    );
    return hardware_interface::return_type::OK;  // Don't send stale commands
  }

  // Position limit enforcement (hardware limits may differ from URDF)
  for (size_t i = 0; i < info_.joints.size(); ++i) {
    double cmd = hw_commands_position_[i];
    double lower = info_.joints[i].limits.min_position;
    double upper = info_.joints[i].limits.max_position;
    
    if (cmd < lower || cmd > upper) {
      RCLCPP_ERROR(rclcpp::get_logger("CustomActuatorInterface"),
                   "Command %.2f exceeds limits [%.2f, %.2f] for joint %s",
                   cmd, lower, upper, info_.joints[i].name.c_str());
      return hardware_interface::return_type::ERROR;
    }

    driver_->write_position(i, cmd);
  }

  last_command_time = time;
  return hardware_interface::return_type::OK;
}

Tested on ROS 2 Humble/Iron, Ubuntu 22.04, with CAN-bus and serial actuators. Real-time kernel recommended for <1ms jitter.