Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/harunkurtdev-ros2-controller-creation/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 17% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 66% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 41% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 46% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 18% | 0% |
How to create or extend a controller in the ros2_control framework, following the conventions used across ~/nav2_ws/src/ros2_controllers/.
rules/ros2_control_architecture.md.rules/ros2_controllers_reference.md.forward_command_controller/diff_drive_controller/imu_sensor_broadcaster/pid_controller/, chained_filter_controller/| 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.
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.cppcpp#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
cppcontroller_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().
cppcontroller_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; }
generate_parameter_library)src/my_controller_parameters.yaml:
yamlmy_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_)).
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:
cmakefind_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:
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.
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.
update() that isn't RT-safe — allocation, locks, throw,RCLCPP_INFO every cycle, publisher_->publish() instead of RealtimePublisher. This causes deadline misses.
on_configure — interfaces are valid onlyfrom on_activate; cache handle references there.
declare_parameter instead of generate_parameter_library.<pkg>/<Class>) after release — it isuser-facing API in controller_manager YAML.
NONE for command config and must not actuate.
rclcpp::spin; itis ticked by the controller_manager.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 24,790 | 21,082 | -15% | 1 | 1 | 0% | 5,471 | 6,418 | +17% | 0 | 0 | — |
case-02 | fail→fail | 30,655 | 20,834 | -32% | 1 | 1 | 0% | 6,205 | 6,367 | +3% | 0 | 0 | — |
case-03 | pass→pass | 24,876 | 23,628 | -5% | 1 | 1 | 0% | 4,903 | 6,932 | +41% | 0 | 0 | — |
case-04 | pass→pass | 14,973 | 12,004 | -20% | 1 | 1 | 0% | 2,990 | 4,365 | +46% | 0 | 0 | — |
case-05 | pass→pass | 19,753 | 13,759 | -30% | 1 | 1 | 0% | 4,073 | 4,808 | +18% | 0 | 0 | — |
case-06 | pass→pass | 12,350 | 8,277 | -33% | 1 | 1 | 0% | 2,224 | 3,464 | +56% | 0 | 0 | — |
case-07 | pass→pass | 8,413 | 4,089 | -51% | 1 | 1 | 0% | 1,550 | 2,703 | +74% | 0 | 0 | — |
case-08 | pass→pass | 9,789 | 3,567 | -64% | 1 | 1 | 0% | 1,841 | 2,627 | +43% | 0 | 0 | — |
case-09 | pass→pass | 18,715 | 17,124 | -9% | 1 | 1 | 0% | 3,248 | 5,099 | +57% | 0 | 0 | — |
case-14 | fail→pass | 17,597 | 17,785 | +1% | 1 | 1 | 0% | 3,219 | 5,357 | +66% | 0 | 0 | — |
case-10 | pass→pass | 10,989 | 3,948 | -64% | 1 | 1 | 0% | 1,904 | 2,688 | +41% | 0 | 0 | — |
case-11 | pass→pass | 15,509 | 9,956 | -36% | 1 | 1 | 0% | 2,884 | 3,881 | +35% | 0 | 0 | — |
case-12 | pass→pass | 11,397 | 7,458 | -35% | 1 | 1 | 0% | 1,983 | 3,291 | +66% | 0 | 0 | — |
case-13 | pass→pass | 13,194 | 8,803 | -33% | 1 | 1 | 0% | 2,678 | 3,856 | +44% | 0 | 0 | — |
case-15 | pass→pass | 19,959 | 14,289 | -28% | 1 | 1 | 0% | 3,118 | 4,364 | +40% | 0 | 0 | — |
case-16 | pass→pass | 10,933 | 6,803 | -38% | 1 | 1 | 0% | 1,840 | 3,097 | +68% | 0 | 0 | — |
case-17 | pass→pass | 6,796 | 4,102 | -40% | 1 | 1 | 0% | 1,126 | 2,707 | +140% | 0 | 0 | — |
case-18 | pass→pass | 12,383 | 9,126 | -26% | 1 | 1 | 0% | 1,892 | 3,410 | +80% | 0 | 0 | — |
case-19 | pass→pass | 10,738 | 7,997 | -26% | 1 | 1 | 0% | 1,684 | 3,154 | +87% | 0 | 0 | — |
case-20 | pass→pass | 16,049 | 11,177 | -30% | 1 | 1 | 0% | 2,805 | 4,147 | +48% | 0 | 0 | — |
case-21 | pass→pass | 11,733 | 6,103 | -48% | 1 | 1 | 0% | 2,141 | 3,141 | +47% | 0 | 0 | — |
case-22 | pass→pass | 14,499 | 9,242 | -36% | 1 | 1 | 0% | 2,612 | 3,598 | +38% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.