Skip to main content

Overview

ros2_control is a framework for real-time control of robots in ROS2. It provides hardware abstraction, allowing controllers to work with both real robots and simulators using the same interface.
Why ros2_control? Separates low-level hardware (ESP32 motors) from high-level control (velocity commands), enabling code reuse and standardization.

Architecture

Hardware Interface

Communicates with actual hardware (ESP32, motor drivers). Reads sensors, writes commands.

Controller Manager

Loads, configures, and manages controllers. Routes commands between controllers and hardware.

Controllers

Implement control algorithms (PID, diff-drive, Mecanum). Subscribe to cmd_vel, publish joint states.

Data Flow

┌─────────────────────┐
│   Navigation /      │
│   Teleop Node       │
└──────────┬──────────┘
           │ /cmd_vel (Twist)

┌─────────────────────┐
│  Mecanum Drive      │
│    Controller       │
└──────────┬──────────┘
           │ Joint Commands

┌─────────────────────┐
│  Controller         │
│    Manager          │
└──────────┬──────────┘
           │ Write/Read

┌─────────────────────┐
│   Hardware          │
│   Interface (ESP32) │
└──────────┬──────────┘
           │ Serial/USB

┌─────────────────────┐
│   Motor Drivers     │
│   (IBT-2 x4)        │
└─────────────────────┘

Key Concepts

Hardware Interface

Defines how software talks to hardware: State Interfaces (read from hardware):
  • Joint positions (encoder angles)
  • Joint velocities (wheel speeds)
  • Joint efforts (motor currents, optional)
Command Interfaces (write to hardware):
  • Joint velocities (target wheel speeds)
  • Joint efforts (target motor torques)

Controllers

Implement control logic: Common controllers:
  • joint_state_broadcaster - Publishes /joint_states topic
  • diff_drive_controller - 2-wheel differential drive
  • mecanum_drive_controller - 4-wheel Mecanum drive (we’ll use this!)
  • joint_trajectory_controller - Follows joint trajectories
Custom controllers:
  • You can write your own for specialized behaviors

Controller Manager

Central node that:
  1. Loads controllers from YAML config
  2. Activates/deactivates controllers
  3. Manages controller lifecycles (configure → activate → deactivate → cleanup)

ros2_control vs Direct Control

  • Without ros2_control
  • With ros2_control
# Direct motor control (simple but not reusable)
import serial

ser = serial.Serial('/dev/ttyUSB0', 115200)

def send_velocity(vx, vy, omega):
    # Manually compute wheel velocities
    w1, w2, w3, w4 = inverse_kinematics(vx, vy, omega)

    # Send to ESP32
    command = f"q{w1:.2f},{w2:.2f},{w3:.2f},{w4:.2f}\n"
    ser.write(command.encode())

# Teleop node
send_velocity(0.5, 0.0, 0.3)
Problems:
  • Kinematics hardcoded in every node
  • Can’t switch between sim and real robot
  • No standardization
  • Difficult to add new controllers

URDF Integration

ros2_control configuration lives in URDF/Xacro:
<robot name="mecanum_robot">
  <!-- Links and joints as before -->

  <!-- ros2_control tag -->
  <ros2_control name="mecanum_system" type="system">

    <!-- Hardware interface plugin -->
    <hardware>
      <plugin>mecanum_hardware/MecanumSystemHardware</plugin>
      <param name="serial_port">/dev/ttyUSB0</param>
      <param name="baud_rate">115200</param>
    </hardware>

    <!-- Joint: Front-Left Wheel -->
    <joint name="wheel_FL_joint">
      <command_interface name="velocity"/>  <!-- We send velocity commands -->
      <state_interface name="position"/>    <!-- We read encoder position -->
      <state_interface name="velocity"/>    <!-- We read wheel velocity -->
    </joint>

    <!-- Repeat for FR, RL, RR -->
    <joint name="wheel_FR_joint">
      <command_interface name="velocity"/>
      <state_interface name="position"/>
      <state_interface name="velocity"/>
    </joint>

    <joint name="wheel_RL_joint">
      <command_interface name="velocity"/>
      <state_interface name="position"/>
      <state_interface name="velocity"/>
    </joint>

    <joint name="wheel_RR_joint">
      <command_interface name="velocity"/>
      <state_interface name="position"/>
      <state_interface name="velocity"/>
    </joint>

  </ros2_control>
</robot>

Controller Configuration

File: config/mecanum_controller.yaml
controller_manager:
  ros__parameters:
    update_rate: 50  # Hz (20ms control loop)

    # List of controllers to load
    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

    mecanum_drive_controller:
      type: mecanum_drive_controller/MecanumDriveController

# Joint State Broadcaster config
joint_state_broadcaster:
  ros__parameters:
    # Publishes /joint_states for all joints

# Mecanum Drive Controller config
mecanum_drive_controller:
  ros__parameters:
    # Joint names (must match URDF)
    front_left_wheel_joint: wheel_FL_joint
    front_right_wheel_joint: wheel_FR_joint
    rear_left_wheel_joint: wheel_RL_joint
    rear_right_wheel_joint: wheel_RR_joint

    # Kinematics
    wheel_separation_x: 0.30  # Front-rear distance (m)
    wheel_separation_y: 0.35  # Left-right distance (m)
    wheel_radius: 0.05        # Wheel radius (m)

    # Command topic
    cmd_vel_topic: /cmd_vel

    # Odometry
    publish_odom: true
    odom_frame_id: odom
    base_frame_id: base_link
    publish_rate: 50.0

    # Velocity limits
    linear:
      x:
        max_velocity: 1.0      # m/s
        max_acceleration: 2.0  # m/s²
      y:
        max_velocity: 1.0
        max_acceleration: 2.0
    angular:
      z:
        max_velocity: 2.0      # rad/s
        max_acceleration: 4.0  # rad/s²

Launch File

File: launch/robot_control.launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.substitutions import Command, PathJoinSubstitution
from launch_ros.substitutions import FindPackageShare
import os

def generate_launch_description():
    # Paths
    pkg_share = FindPackageShare('mecanum_description').find('mecanum_description')
    urdf_file = PathJoinSubstitution([pkg_share, 'urdf', 'mecanum_robot.urdf.xacro'])
    controller_config = PathJoinSubstitution([pkg_share, 'config', 'mecanum_controller.yaml'])

    # Process xacro
    robot_description = Command(['xacro ', urdf_file])

    return LaunchDescription([
        # Robot State Publisher
        Node(
            package='robot_state_publisher',
            executable='robot_state_publisher',
            parameters=[{'robot_description': robot_description}]
        ),

        # Controller Manager
        Node(
            package='controller_manager',
            executable='ros2_control_node',
            parameters=[
                {'robot_description': robot_description},
                controller_config
            ],
            output='screen'
        ),

        # Spawner: Joint State Broadcaster
        Node(
            package='controller_manager',
            executable='spawner',
            arguments=['joint_state_broadcaster'],
            output='screen'
        ),

        # Spawner: Mecanum Drive Controller
        Node(
            package='controller_manager',
            executable='spawner',
            arguments=['mecanum_drive_controller'],
            output='screen'
        ),
    ])

Controller Lifecycle

Controllers have managed lifecycles:
  1. Unconfigured (initial state)
  2. Inactive (configured but not running)
  3. Active (running)
  4. Finalized (shutdown)

Managing Controllers

# List all controllers
ros2 control list_controllers

# Example output:
# joint_state_broadcaster[joint_state_broadcaster/JointStateBroadcaster] active
# mecanum_drive_controller[mecanum_drive_controller/MecanumDriveController] active

# Load controller
ros2 control load_controller mecanum_drive_controller

# Configure controller
ros2 control set_controller_state mecanum_drive_controller configure

# Activate controller
ros2 control switch_controllers --activate mecanum_drive_controller

# Deactivate controller
ros2 control switch_controllers --deactivate mecanum_drive_controller

# Unload controller
ros2 control unload_controller mecanum_drive_controller
Spawner does it all: The spawner node in launch file automatically loads + configures + activates controllers. Manual commands useful for debugging only.

Hardware Interface Plugin

The hardware interface is a C++ plugin that talks to ESP32: Skeleton structure:
#include "hardware_interface/system_interface.hpp"

namespace mecanum_hardware {

class MecanumSystemHardware : public hardware_interface::SystemInterface {
public:
  // Lifecycle callbacks
  CallbackReturn on_init(const hardware_interface::HardwareInfo & info) override;
  CallbackReturn on_configure(const rclcpp_lifecycle::State & previous_state) override;
  CallbackReturn on_activate(const rclcpp_lifecycle::State & previous_state) override;
  CallbackReturn on_deactivate(const rclcpp_lifecycle::State & previous_state) override;

  // State interfaces (read from hardware)
  std::vector<hardware_interface::StateInterface> export_state_interfaces() override;

  // Command interfaces (write to hardware)
  std::vector<hardware_interface::CommandInterface> export_command_interfaces() override;

  // Read from hardware (called every control cycle)
  hardware_interface::return_type read(
    const rclcpp::Time & time, const rclcpp::Duration & period) override;

  // Write to hardware (called every control cycle)
  hardware_interface::return_type write(
    const rclcpp::Time & time, const rclcpp::Duration & period) override;

private:
  // Serial connection to ESP32
  int serial_fd_;

  // Joint states
  std::vector<double> hw_positions_;
  std::vector<double> hw_velocities_;
  std::vector<double> hw_commands_;
};

}  // namespace mecanum_hardware
Key methods:
  • read() - Read encoder positions/velocities from ESP32 via serial
  • write() - Send velocity commands to ESP32 via serial
  • export_state_interfaces() - Declare what states we provide (position, velocity)
  • export_command_interfaces() - Declare what commands we accept (velocity)

Package Dependencies

File: package.xml
<depend>hardware_interface</depend>
<depend>controller_interface</depend>
<depend>controller_manager</depend>
<depend>joint_state_broadcaster</depend>
<depend>mecanum_drive_controller</depend>
<depend>rclcpp</depend>
<depend>rclcpp_lifecycle</depend>

Installation

# Install ros2_control packages
sudo apt install ros-jazzy-ros2-control ros-jazzy-ros2-controllers

# Install specific controllers
sudo apt install ros-jazzy-joint-state-broadcaster
sudo apt install ros-jazzy-mecanum-drive-controller
Mecanum controller availability: If mecanum_drive_controller not available as Debian package, you may need to build from source or use diff_drive_controller with custom kinematics.

Alternative: diff_drive_controller

If Mecanum controller unavailable, use differential drive controller (works for straight + rotation only, no strafing):
diff_drive_controller:
  ros__parameters:
    left_wheel_names: ["wheel_FL_joint", "wheel_RL_joint"]
    right_wheel_names: ["wheel_FR_joint", "wheel_RR_joint"]

    wheel_separation: 0.35
    wheel_radius: 0.05

    cmd_vel_topic: /cmd_vel

    publish_odom: true
    odom_frame_id: odom
    base_frame_id: base_link
Limitation: No lateral (Y-axis) movement. For full Mecanum capability, custom controller needed.

Testing Setup

Verify Controller Manager

# Launch robot control
ros2 launch mecanum_description robot_control.launch.py

# Check controllers loaded
ros2 control list_controllers

# Expected output:
# joint_state_broadcaster[joint_state_broadcaster/JointStateBroadcaster] active
# mecanum_drive_controller[mecanum_drive_controller/MecanumDriveController] active

Check Topics

# List topics
ros2 topic list

# Should include:
# /cmd_vel                      (Twist commands IN)
# /joint_states                 (Joint states OUT)
# /mecanum_drive_controller/odom (Odometry OUT)
# /tf                           (Transforms OUT)

Send Test Command

# Send velocity command
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
  "{linear: {x: 0.2, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}" --once

# If hardware interface working:
# - ESP32 receives velocity command
# - Motors spin
# - Robot moves forward

Troubleshooting

Error: Failed to load controller ...Solutions:
  • Check robot_description parameter published
  • Verify URDF has <ros2_control> tag
  • Check controller YAML syntax
  • Install missing controller packages
Error: Could not load hardware interface plugin ...Solutions:
  • Build hardware interface package
  • Check plugin exported in CMakeLists.txt
  • Verify plugin name in URDF matches class name
  • Source workspace: source install/setup.bash
Symptom: /cmd_vel messages sent but robot doesn’t moveDebug:
# Check controller active
ros2 control list_controllers

# Check command interfaces
ros2 control list_hardware_interfaces

# Monitor serial communication (ESP32 side)
# Add debug prints in hardware interface read/write
Symptom: /joint_states topic emptySolutions:
  • Check joint_state_broadcaster active
  • Verify hardware interface read() updates positions/velocities
  • Check joint names in URDF match controller config

Benefits of ros2_control

Hardware Abstraction

Same controller code works with real robot and Gazebo simulator by swapping hardware interface.

Modular Design

Add/remove controllers without changing other code. Test new control algorithms easily.

Standardization

Controllers work with any robot using ros2_control. Reuse community-developed controllers.

Real-Time Capable

Designed for deterministic real-time control loops (important for smooth motion).

Next Steps

References

[1] ros2_control Documentation: https://control.ros.org/ [2] ros2_controllers: https://control.ros.org/jazzy/doc/ros2_controllers/doc/controllers_index.html [3] Hardware Interface: https://control.ros.org/jazzy/doc/ros2_control/hardware_interface/doc/writing_new_hardware_component.html