| name | ros2_controller_creation |
| description | Scaffold or extend a ros2_control controller or broadcaster (ControllerInterface / ChainableControllerInterface) — base-class choice, command/state interface configuration, lifecycle, real-time-safe update(), generate_parameter_library, pluginlib export, tests. Trigger when the user asks to write a ros2_control controller or broadcaster. |
Writing a ros2_control Controller / Broadcaster
How to create or extend a controller in the ros2_control framework,
following the conventions used across ~/nav2_ws/src/ros2_controllers/.
- Framework + lifecycle + RT rules:
rules/ros2_control_architecture.md.
- Existing controller catalog (copy the closest one):
rules/ros2_controllers_reference.md.
- Reference implementations to mirror:
- simplest controller →
forward_command_controller/
- feedback + odometry + chaining →
diff_drive_controller/
- read-only sensor →
imu_sensor_broadcaster/
- PID / pure chaining →
pid_controller/, chained_filter_controller/
First decision: which base class?
| You are building… | Base class | Override |
|---|
| A controller that just writes commands | ControllerInterface | update() |
| A controller others can chain into / that exposes a reference | ChainableControllerInterface | update_reference_from_subscribers() + update_and_write_commands() |
| A read-only sensor/state publisher | ControllerInterface (or Chainable to re-export state) with NONE command interfaces | update() (read state → publish) |
A controller is a pluginlib plugin, not a node. It runs inside
controller_manager. It never opens hardware directly — only through the
command/state interfaces the manager loans it.
Minimal package skeleton
my_controller/
├── include/my_controller/my_controller.hpp
├── src/my_controller.cpp
├── src/my_controller_parameters.yaml
├── my_controller_plugin.xml
├── CMakeLists.txt
├── package.xml
├── doc/userdoc.rst
└── test/test_my_controller.cpp
1. Header — the class
#include "controller_interface/controller_interface.hpp"
#include "my_controller/my_controller_parameters.hpp"
#include "realtime_tools/realtime_publisher.hpp"
#include "realtime_tools/realtime_thread_safe_box.hpp"
namespace my_controller
{
class MyController : public controller_interface::ControllerInterface
{
public:
controller_interface::CallbackReturn on_init() override;
controller_interface::InterfaceConfiguration command_interface_configuration() const override;
controller_interface::InterfaceConfiguration state_interface_configuration() const override;
controller_interface::CallbackReturn on_configure(const rclcpp_lifecycle::State &) override;
controller_interface::CallbackReturn on_activate(const rclcpp_lifecycle::State &) override;
controller_interface::CallbackReturn on_deactivate(const rclcpp_lifecycle::State &) override;
controller_interface::return_type update(
const rclcpp::Time & time, const rclcpp::Duration & period) override;
protected:
std::shared_ptr<ParamListener> param_listener_;
Params params_;
};
}
2. Interface configuration
controller_interface::InterfaceConfiguration
MyController::command_interface_configuration() const
{
return { controller_interface::interface_configuration_type::INDIVIDUAL,
{ params_.joint + "/velocity" } };
}
controller_interface::InterfaceConfiguration
MyController::state_interface_configuration() const
{
return { controller_interface::interface_configuration_type::INDIVIDUAL,
{ params_.joint + "/position", params_.joint + "/velocity" } };
}
3. Lifecycle + the hot path
controller_interface::CallbackReturn MyController::on_init()
{
param_listener_ = std::make_shared<ParamListener>(get_node());
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::CallbackReturn MyController::on_configure(const rclcpp_lifecycle::State &)
{
params_ = param_listener_->get_params();
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::CallbackReturn MyController::on_activate(const rclcpp_lifecycle::State &)
{
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::return_type MyController::update(
const rclcpp::Time &, const rclcpp::Duration &)
{
const double fb = state_interfaces_[0].get_optional().value_or(0.0);
command_interfaces_[0].set_value(compute(fb));
return controller_interface::return_type::OK;
}
4. Parameters (generate_parameter_library)
src/my_controller_parameters.yaml:
my_controller:
joint: {
type: string,
default_value: "",
description: "Joint whose interfaces this controller claims",
read_only: true,
validation: { not_empty<>: [] }
}
gain: {
type: double, default_value: 1.0,
validation: { gt<>: [0.0] }
}
Never declare_parameter by hand in a controller — use the generated
ParamListener/Params. Re-call get_params() in update() only if you
need live updates (and guard with param_listener_->is_old(params_)).
5. Plugin export + CMake + macro
my_controller_plugin.xml:
<library path="my_controller">
<class name="my_controller/MyController" type="my_controller::MyController"
base_class_type="controller_interface::ControllerInterface">
<description>What it does.</description>
</class>
</library>
CMakeLists.txt essentials:
find_package(generate_parameter_library REQUIRED)
find_package(controller_interface REQUIRED)
find_package(pluginlib REQUIRED)
generate_parameter_library(my_controller_parameters src/my_controller_parameters.yaml)
add_library(my_controller SHARED src/my_controller.cpp)
target_link_libraries(my_controller PUBLIC
controller_interface::controller_interface
my_controller_parameters
pluginlib::pluginlib)
pluginlib_export_plugin_description_file(controller_interface my_controller_plugin.xml)
Bottom of src/my_controller.cpp:
#include "pluginlib/class_list_macros.hpp"
PLUGINLIB_EXPORT_CLASS(my_controller::MyController, controller_interface::ControllerInterface)
package.xml depends: controller_interface, pluginlib,
generate_parameter_library, realtime_tools, rclcpp_lifecycle, plus
your message packages.
Testing
Mirror an existing test/. Controllers are tested by constructing them
directly, assigning mock loaned interfaces, driving the lifecycle
(configure → activate), calling update(), and asserting on the
command-interface values / published messages — no real hardware. Use
controller_manager's test fixtures (ControllerInterfaceBaseTest style)
where helpful. Don't sleep() to synchronize.
Common pitfalls
- Work in
update() that isn't RT-safe — allocation, locks, throw,
RCLCPP_INFO every cycle, publisher_->publish() instead of
RealtimePublisher. This causes deadline misses.
- Claiming interfaces in
on_configure — interfaces are valid only
from on_activate; cache handle references there.
- Hand-rolled
declare_parameter instead of generate_parameter_library.
- Renaming the plugin alias (
<pkg>/<Class>) after release — it is
user-facing API in controller_manager YAML.
- A broadcaster claiming command interfaces — broadcasters return
NONE for command config and must not actuate.
- Treating the controller as a node — no standalone
rclcpp::spin; it
is ticked by the controller_manager.