WHY control_kit — the "write a controller" ceremony, stock ros2_control vs ours¶
ros2_control is C++-only by design: there is no Python controller API. Writing even a
trivial controller is a multi-file, multi-tool ceremony, and running it means launching a
separate controller_manager process and spawning the controller into it. control_kit
collapses that to one Python class and three lines, run in your own process.
Stock ros2_control: what a new controller costs¶
To add a controller you write, minimum:
- A C++ class deriving
controller_interface::ControllerInterface, implementing the pure virtualson_init,command_interface_configuration,state_interface_configuration,update, plus the lifecycleon_configure/on_activate/on_deactivate— in a.hpp+.cpppair, with visibility macros. - A
plugin_description.xmldeclaring the class as apluginlibplugin with itsbase_class_type. - A
CMakeLists.txt—ament_cmakeproject,pluginlib_export_plugin_description_file,generate_parameter_libraryfor the params, linkcontroller_interface/hardware_interface/rclcpp_lifecycle, install targets. - A
package.xmlwith the build/exec deps. - A colcon build of the workspace to produce the
.soand register the plugin in the ament index. - A YAML with the controller's parameters and type, and a launch file starting
ros2_control_node(orcontroller_manager) with the robot description + that YAML. - A spawner (
ros2 run controller_manager spawner my_controller) to load, configure and activate it into the running manager.
Edit the control law → rebuild the workspace → relaunch → respawn. The iteration loop is minutes, and every experiment is a C++ compile.
control_kit: the same controller, in Python¶
import rclcpp_kit
import control_kit as ck
bringup_rclcpp().init()
ck.bringup_control()
class MyPD(ck.ControllerInterface): # derive the REAL base class
def __init__(self):
super().__init__(); self.target = [0.5, -0.3]; self.kp = 0.4
def on_init(self): return ck.CallbackReturn.SUCCESS
def command_interface_configuration(self):
return ck.interface_config(["joint1/position", "joint2/position"])
def state_interface_configuration(self):
return ck.interface_config(["joint1/position", "joint2/position"])
def on_configure(self, prev): return ck.CallbackReturn.SUCCESS
def on_activate(self, prev): return ck.CallbackReturn.SUCCESS
def on_deactivate(self, prev): return ck.CallbackReturn.SUCCESS
def update(self, time, period): # the framework calls this each cycle
for i in range(ck.n_command_interfaces(self)):
cur = ck.read_state(self, i)
ck.write_command(self, i, cur + self.kp * (self.target[i] - cur))
return ck.return_type.OK
rig = ck.make_controller_manager(ck.mock_system_urdf(["joint1", "joint2"]))
rig.add_python_controller(MyPD(), "pd") # inject — no plugin xml, no .so
rig.configure("pd"); rig.activate(["pd"])
rig.run(seconds=2.0, rate_hz=100) # the REAL read/update/write loop
No plugin_description.xml, no CMakeLists.txt, no package.xml, no colcon build, no
launch file, no spawner, no second process. Edit the control law → rerun the script. And
it is not a mock or reimplementation: MyPD derives the actual
controller_interface::ControllerInterface, and the actual
controller_manager::ControllerManager calls its update() in the real control loop,
against real mock hardware (mock_components/GenericSystem) parsed from a URDF string.
Side-by-side¶
| stock ros2_control | control_kit | |
|---|---|---|
| controller definition | C++ .hpp+.cpp deriving ControllerInterface |
Python class deriving the same ControllerInterface |
| plugin registration | plugin_description.xml + pluginlib export |
none (injected via add_controller) |
| build | CMakeLists.txt + package.xml + colcon |
none (cppyy JITs the glue) |
| run | launch ros2_control_node + YAML + spawner |
make_controller_manager() + 3 calls, in-process |
| iterate | rebuild + relaunch + respawn (minutes) | rerun the script (seconds) |
| hardware | real / a SystemInterface plugin |
mock_components/GenericSystem from a URDF string |
| the loop | CM's RT thread | rig.run() in your Python process (the real read/update/write) |
What this is for (and what it isn't)¶
For: prototyping a control law against the real ros2_control machinery without the C++
build-and-launch ceremony — fast iteration, teaching, HIL/sim, and validating a controller
end-to-end before committing it to C++. See REPORT.md §4 for the real-time
verdict: 100 Hz is rock-solid, 1 kHz works on average but Python's GC/GIL pauses cost the
odd deadline, so this is soft-real-time / prototyping-grade, not hard-real-time.
The miss rate is not fixed, though: the jitter benchmark drove this same rig at 1 kHz
with unprivileged real-time knobs — prctl(PR_SET_TIMERSLACK, 1) (Linux's default 50 µs
timer slack, not Python, dominates the median: 52.4 → 2.4 µs on the pure-Python timer
loop), mlockall, and CPU pinning — and measured ~2.4 µs median wakeup latency for the
in-process ControllerManager loop on a stock kernel
(jitter bench report). Tail spikes under load remain
until SCHED_FIFO/preemption tuning is applied (owner-action commands in that report).
Not for (yet): shipping a controller that a separately launched, stock (C++)
controller_manager loads by name — that still needs a compiled pluginlib .so
(REPORT §3, Route B). The intended path is to prototype in Python here, then lower the
validated update() to a native C++ plugin (the L2 direct-compile recipe) for hard-RT
deployment — the interface contract is identical, so the port is mechanical.