ros2_controller_creation
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.
git clone --depth 1 https://github.com/harunkurtdev/ros2-claude-code-template /tmp/ros2_controller_creation && cp -r /tmp/ros2_controller_creation/.claude/skills/ros2_controller_creation ~/.claude/skills/ros2_controller_creationSKILL.md
# 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
```cpp
#include "controller_interface/controller_interface.hpp" // or chainable_controller_interface.hpp
#include "my_controller/my_controller_parameters.hpp" // generated
#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_;
};
} // namespace my_controller
```
### 2. Interface configuration
```cpp
controller_interface::InterfaceConfiguration
MyController::command_interface_configuration() const
{
return { controller_interface::interface_configuration_type::INDIVIDUAL,
{ params_.joint + "/velocity" } }; // names you will write
}
controller_interface::InterfaceConfiguration
MyController::state_interface_configuration() const
{
return { controller_interface::interface_configuration_type::INDIVIDUAL,
{ params_.joint + "/position", params_.joint + "/velocity" } };
}
// A broadcaster returns type NONE for command_interface_configuration().
```
### 3. Lifecycle + the hot path
```cpp
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();
// create subscribers / RealtimePublisher / preallocate messages HERE
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::CallbackReturn MyController::on_activate(const rclcpp_lifecycle::State &)
{
// cache references into command_interfaces_ / state_interfaces_ by index here
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::return_type MyController::update(
const rclcpp::Time &, const rclcpp::Duration &)
{
// RT-safe ONLY: no new/malloc, no locks, no throw, no unthrottled logging
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`:
```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`:
```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:
```cmake
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_inUse proactively before opening a PR that adds or changes BehaviorTree.CPP nodes or BehaviorTree.ROS2 wrappers (RosActionNode/RosServiceNode/RosTopicPub/SubNode, TreeExecutionServer). Reviews a diff against BT.CPP v4 conventions — node base-class choice, non-blocking ticks, ports/blackboard typing, factory/plugin registration, XML v4, and the ROS 2 wrapper contract. Returns a punch list with file:line anchors, not a rewrite.
Use when a design decision touches Clean Architecture boundaries in a ROS 2 project — which layer a new behaviour belongs to, whether a port belongs in domain or application, whether a new node should be lifecycle-managed, whether to compose nodes or split packages. Returns an architectural recommendation with trade-offs, not implementation.
Use when a design decision touches the gz-sim ECS — where new state should live, which system phase should write it, how to avoid coupling, whether to add a component vs. a member variable, whether a new system should be split or merged with an existing one. Returns an architectural recommendation with trade-offs, not implementation.
Use proactively before opening any gz-sim PR. Reviews a diff against the project's C++17 style, ECS conventions, plugin registration patterns, CMake structure, test placement, Migration.md / Changelog.md expectations, and pre-commit configuration. Returns a punch list, not a rewrite.
Use proactively before opening a PR that adds or changes a ros2_control controller, broadcaster, or hardware component (incl. URDF <ros2_control> bringup). Reviews a diff against ros2_controllers / ros2_control_demos conventions — controller & hardware lifecycle, command/state interface configuration, real-time safety of update()/read()/write(), generate_parameter_library usage, pluginlib registration, chainable-controller correctness, URDF wiring, and tests. Returns a punch list with file:line anchors, not a rewrite.
Use proactively before opening any ROS 2 / Nav 2 PR. Reviews a diff against this template's Clean Architecture, ROS 2 communication, lifecycle, testing, and Nav 2 plugin conventions. Returns a punch list with file:line anchors, not a rewrite.
Use proactively before opening a PR that touches a VDA 5050 connector / fleet bridge. Reviews a diff against VDA 5050 v3.0.0 protocol compliance (topics, QoS, header rules, base/horizon, action state machine, schema validation) and the template's Clean Architecture for the MQTT↔Nav 2 bridge. Returns a punch list with file:line anchors, not a rewrite.
Build the colcon workspace (optionally a single package) and report the outcome.