ROS 2 hardware interface tutorial: move real motors

Write a ros2_control hardware interface: SystemInterface lifecycle, state and command interfaces, URDF and YAML setup, testing with mock components and safety limits.

Reference
RE-1144
Published
Read
7 min

About the author · Sources, testing and AI policy

ROS 2 terminal showing ros2_control controller list beside a robot joint driver board

ros2_control stops being a simulation the moment you write a hardware interface. The controllers do not change; the layer underneath them starts talking to a motor driver.

This guide explains the ros2_control architecture, the SystemInterface lifecycle, a minimal working plugin, the configuration in URDF and YAML, the errors behind most failed first attempts, and how to test before the hardware exists.

Where the hardware interface sits

ros2_control separates four things: controllers that compute, a controller manager that loads and switches them, a resource manager that owns the hardware, and the hardware interface that talks to the device. Your job is the last one.

ComponentResponsibilityYou write it?
ControllerComputes commands from a goal or trajectoryUsually use an existing one
Controller managerLoads, configures and switches controllersNo
Resource managerOwns interfaces and the update loopNo
Hardware interfaceReads state, writes commands, handles the deviceYes
Robot descriptionDeclares joints and the ros2_control blockYes

That division is the reason a robot can move from simulation to hardware without rewriting a controller. If you find yourself putting control logic inside the hardware interface, the design has already gone wrong.

The SystemInterface lifecycle

A hardware component is a plugin with a fixed set of callbacks. The framework calls them in order, and each one must succeed before the next runs.

CallbackWhat you do
on_initRead parameters, store the joint names
on_configureOpen the device, validate limits, check the connection
export_state_interfacesPublish position, velocity, effort per joint
export_command_interfacesAccept position, velocity or effort per joint
on_activateEnable the drives, start the communication thread
readCopy device state into the state interfaces
writeCopy commands out and send them to the device
on_deactivateStop motion safely, disable drives
on_cleanup / on_shutdownClose ports, free resources
on_errorPut the hardware into a safe state

The read and write calls run every control cycle. They must be fast and must not block. A blocking serial read inside read turns a 1 kHz loop into a 20 Hz loop and makes every controller behave badly.

A minimal component

The skeleton below is the shape most hardware plugins follow. It uses a serial link, but the structure is the same for CAN, EtherCAT or a vendor SDK.

class MyArmHardware : public hardware_interface::SystemInterface {
public:
  CallbackReturn on_init(const HardwareInfo & info) override {
    CallbackReturn r = SystemInterface::on_init(info);
    if (r != CallbackReturn::SUCCESS) return r;
    joints_ = info.joints.size();
    pos_.assign(joints_, 0.0);
    vel_.assign(joints_, 0.0);
    cmd_.assign(joints_, 0.0);
    return CallbackReturn::SUCCESS;
  }

  CallbackReturn on_configure(const rclcpp_lifecycle::State &) override {
    port_.open("/dev/ttyUSB0", 115200);
    if (!port_.is_open()) return CallbackReturn::ERROR;
    return CallbackReturn::SUCCESS;
  }

  std::vector<StateInterface> export_state_interfaces() override {
    std::vector<StateInterface> out;
    for (size_t i = 0; i < joints_; ++i) {
      out.emplace_back(info_.joints[i].name, HW_IF_POSITION, &pos_[i]);
      out.emplace_back(info_.joints[i].name, HW_IF_VELOCITY, &vel_[i]);
    }
    return out;
  }

  hardware_interface::return_type read(const rclcpp::Time &,
                                       const rclcpp::Duration &) override {
    if (!port_.readFrame(pos_, vel_)) return return_type::ERROR;
    return return_type::OK;
  }

  hardware_interface::return_type write(const rclcpp::Time &,
                                        const rclcpp::Duration &) override {
    if (!port_.sendFrame(cmd_)) return return_type::ERROR;
    return return_type::OK;
  }

private:
  size_t joints_{0};
  std::vector<double> pos_, vel_, cmd_;
  SerialPort port_;
};

Register it as a plugin, and the controller manager can load it by name from the URDF. The plugin declaration is a macro plus an export in the package’s CMake and XML files; if the name in the URDF does not match exactly, the hardware simply will not appear.

Declare the joints in URDF

The URDF’s ros2_control block tells the framework which joints exist, which interfaces they expose and which plugin implements them. This block is not a comment; it is the contract between the description and the hardware.

<ros2_control name="MyArmSystem" type="system">
  <hardware>
    <plugin>my_arm_hardware/MyArmHardware</plugin>
    <param name="serial_port">/dev/ttyUSB0</param>
  </hardware>
  <joint name="joint1">
    <command_interface name="position"/>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
  </joint>
  <joint name="joint2">
    <command_interface name="position"/>
    <state_interface name="position"/>
  </joint>
</ros2_control>

Joint names must match the links and joints in the kinematic model. A typo here produces a controller that configures but never moves anything, which is a harder bug to see than a load failure.

YAML and the update rate

controller_manager:
  ros__parameters:
    update_rate: 100   # Hz

    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

    arm_controller:
      type: joint_trajectory_controller/JointTrajectoryController

arm_controller:
  ros__parameters:
    joints: [joint1, joint2]
    command_interfaces: [position]
    state_interfaces: [position, velocity]

The update rate is a budget, not a wish. If your communication takes 15 ms per cycle, a 100 Hz loop cannot run. Either raise the baud rate, shrink the payload, or lower the update rate and accept slower control.

Position, velocity or effort

The command interface you expose decides which controllers can drive the robot. A position interface suits a servo drive with an internal position loop. A velocity interface suits a drive that closes its own velocity loop and expects a setpoint. An effort interface hands torque control to your software and expects the drive to be in torque mode with a fast, reliable link.

InterfaceDrive modeTypical use
PositionPosition controlMost arms, simple integration
VelocityVelocity controlMobile bases, conveyor tracking
EffortTorque controlForce control, impedance, research

Exposing more than one is allowed and can be useful, but each extra interface is another path to test. Start with the one the drive is designed for, get the robot moving, then add the others if a controller needs them.

Test before the hardware exists

The framework ships a simulated system component that behaves like hardware without a device. Point the URDF at it and the controllers, the launch files and the controller switching all get tested before a motor is connected.

  • Use the mock components plugin and confirm every controller configures and activates.
  • Send a trajectory and check that joint states change.
  • Then swap to your plugin and repeat the same test with the device powered but the robot unloaded.
  • Only then connect the mechanism.

Errors you will actually see

MessageUsual cause
Hardware component not foundPlugin name in URDF does not match the exported name
Could not load controllerMissing type, wrong package or the plugin is not built
Controller failed to configureJoint name mismatch or a missing command interface
No state interface for jointInterface not exported in the plugin
Controller manager not availableLaunch order, wrong namespace or the node crashed
Robot moves then freezesBlocking read, exception in write, or a watchdog trip
Joint moves the wrong waySign convention between encoder and command

Safety belongs here, not later

The hardware interface is the last line before the motor, so it is the right place for hard limits. Software limits in a controller are a convenience; the drive’s own limits and the physical e-stop are the protection.

  • Check commands against joint limits before sending them, and clamp rather than pass through.
  • Add a watchdog that stops the drive if no command arrives within a set time.
  • Treat lost communication as a stop, not a retry loop.
  • Handle the e-stop input in hardware and mirror its state into software.
  • Log faults with a timestamp; a robot that stops without a reason is impossible to fix.

A working order

  • Get joint states read correctly with the motor disabled.
  • Send one joint a small position command and watch it move.
  • Add the second joint and check the sign convention.
  • Run the trajectory controller with a short path.
  • Check the loop timing with the real communication loaded.
  • Add limits, watchdog and fault handling.
  • Repeat the same sequence with the full payload.

Related: ros2_control tutorial, URDF for ROS 2, ROS 2 launch files, MoveIt 2 tutorial, topics, services and actions.

What is a ros2_control hardware interface?

A plugin that connects the ros2_control framework to a real device. It exports state and command interfaces for each joint and implements read and write calls that run every control cycle.

Do I need C++ for a hardware interface?

The standard SystemInterface plugin is C++. Python hardware components exist in some distributions but are not the mainstream path, so most drivers are written in C++.

How do I test a hardware interface without a robot?

Use the mock components plugin with the same URDF and controllers. Test loading, configuration, activation and trajectories, then swap in your plugin with the device powered but unloaded.

Why does my controller fail to configure?

Usually a joint name mismatch between the URDF and the controller YAML, or a command interface that the hardware does not export.

Where should joint limits and the watchdog go?

Hard limits and the e-stop belong in hardware. Clamping commands and a communication watchdog in the hardware interface are the software backstop, not a replacement for the drive's protection.

The command and state fields your hardware plugin exposes usually come from a custom ROS 2 interface package.

Hardware interfaces benefit from managed states, covered in the lifecycle nodes tutorial.

Sources and further reading

Pass it onWhatsAppLinkedIn

Keep reading

Continue learning

The Build Note

One short email when something is worth your bench time.

A new build sheet, a part price that moved, or a mistake worth avoiding. No daily drip, no recycled news.

One short email when a build or a price is worth your time. Unsubscribe in one click.