Warning
This repository has been archived. cake has moved to a new home and been renamed to jig.
You can find jig here: https://github.com/nineyards-robotics/jig
Declarative code generation for ROS 2 nodes
Cake transforms simple YAML interface definitions into strongly-typed C++ and Python ROS 2 lifecycle node scaffolding. Define your publishers, subscribers, services, actions and parameters in one simple file and Cake will handle the rest.
Using cake, writing ROS2 nodes becomes a... piece of cake (hehe)
Cake uses a convention-over-configuration approach with automatic build system integration. Here's how to create a complete ROS 2 package in minutes:
Your package should follow this structure:
my_package/
├── nodes/
│ └── my_node/
│ ├── interface.yaml # Interface definition
│ ├── my_node.hpp # Header (C++ only)
│ └── my_node.cpp # Implementation (.cpp for C++, .py for Python)
├── CMakeLists.txt
└── package.xml
Create nodes/my_node/interface.yaml:
parameters:
important_parameter:
type: string
default_value: "oh hi mark"
description: "A very important string."
publishers:
- topic: some_topic
type: std_msgs/msg/String
qos:
history: 10
reliability: RELIABLE
subscribers:
- topic: other_topic
type: std_msgs/msg/Bool
qos:
history: 5
reliability: BEST_EFFORT
services:
- name: my_service
type: example_interfaces/srv/AddTwoIntsFirst, create the header (nodes/my_node/my_node.hpp):
Design Pattern: Cake uses a lifecycle
Sessionclass rather than subclassingrclcpp_lifecycle::LifecycleNode. This separation makes testing easier (you can test logic without spinning up ROS), keeps state explicit, and allows callbacks to be simple free functions. TheSessionis created during theon_configurelifecycle transition and destroyed oncleanup/shutdown. To define theSessionof your node, you subclass the auto-generated<NodeName>Sessionstruct and add your own variables to it. The auto-generatedSessionclass will contain a reference to the lifecycle node instance, as well as all publishers, subscribers, services, actions and parameters.
#pragma once
#include <memory>
#include <my_package/my_node_interface.hpp>
namespace my_package::my_node {
// Extend the generated session with custom state
struct Session : MyNodeSession<Session> {
using MyNodeSession::MyNodeSession;
// Add any custom state here
int my_counter = 0;
};
// Forward declare on_configure function
CallbackReturn on_configure(std::shared_ptr<Session> sn);
// Define the node class using the generated base
// This must match the pattern: package::node_name::NodeName
using MyNode = MyNodeBase<Session, on_configure>;
} // namespace my_package::my_nodeThen implement it (nodes/my_node/my_node.cpp):
Design Pattern: Cake uses a free function
on_configure()approach instead of subclassingrclcpp_lifecycle::LifecycleNode. Theon_configure()function receives a fully-constructed session with all publishers, subscribers, and parameters ready to use. This functional approach, coupled with the session object, makes nodes easier to reason about, simpler to write and more testable. By storing a reference to the lifecycle node in the session, we create a "has-a" relationship with the Node rather than "is-a", cleanly separating ROS communication from your implementation logic.
#include "my_node.hpp"
namespace my_package::my_node {
void msg_callback(std::shared_ptr<Session> sn, std_msgs::msg::Bool::ConstSharedPtr msg) {
sn->my_counter++;
RCLCPP_INFO(sn->node.get_logger(), "Got a bool: %d (count: %d)", msg->data, sn->my_counter);
}
void addition_request_handler(
std::shared_ptr<Session> sn,
example_interfaces::srv::AddTwoInts::Request::SharedPtr request,
example_interfaces::srv::AddTwoInts::Response::SharedPtr response
) {
response->sum = request->a + request->b;
}
CallbackReturn on_configure(std::shared_ptr<Session> sn) {
// Access parameters
RCLCPP_INFO(sn->node.get_logger(), "important_parameter: %s", sn->params.important_parameter.c_str());
// Publish messages
auto msg = std_msgs::msg::String();
msg.data = sn->params.important_parameter;
sn->publishers.some_topic->publish(msg);
// Set callbacks
sn->subscribers.other_topic->set_callback(msg_callback);
sn->services.my_service->set_request_handler(addition_request_handler);
return CallbackReturn::SUCCESS;
}
} // namespace my_package::my_nodefrom dataclasses import dataclass
from cake import TransitionCallbackReturn
from my_package.my_node import MyNodeSession, run
from std_msgs.msg import String
# Extend the generated session with custom state
@dataclass
class MySession(MyNodeSession):
my_counter: int = 0
def msg_callback(sn: MySession, msg):
sn.my_counter += 1
sn.logger.info(f"Got a bool: {msg.data} (count: {sn.my_counter})")
def on_configure(sn: MySession) -> TransitionCallbackReturn:
# Access parameters
sn.logger.info(f"important_parameter: {sn.params.important_parameter}")
# Publish messages
msg = String()
msg.data = sn.params.important_parameter
sn.publishers.some_topic.publish(msg)
# Set callbacks
sn.subscribers.other_topic.set_callback(msg_callback)
return TransitionCallbackReturn.SUCCESS
if __name__ == "__main__":
run(MySession, on_configure)This is all you need in your CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
project(my_package)
find_package(cake REQUIRED)
cake_auto_package()Note: Cake assumes a single-threaded executor. See Threading Model for details.
That's it! cake_auto_package() automatically:
- Detects C++ and Python nodes in the
nodes/folder - Generates interfaces and parameter libraries
- Builds libraries and executables
- Registers components for C++ nodes
- Installs everything correctly
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>my_package</name>
<version>0.0.0</version>
<description>My cake package</description>
<maintainer email="you@example.com">Your Name</maintainer>
<license>Apache 2.0</license>
<depend>cake</depend>
<depend>rclcpp</depend> <!-- For C++ nodes -->
<depend>rclpy</depend> <!-- For Python nodes -->
<!-- Add your message dependencies -->
<depend>std_msgs</depend>
<depend>example_interfaces</depend>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>cd ~/ros2_ws
colcon build --packages-select my_package
source install/setup.bash
# Run as executable
ros2 run my_package my_node
# Or load as component (if written in C++)
ros2 component standalone my_package my_package::MyNodeCake nodes follow the standard ROS 2 lifecycle state machine. The Quick Start examples show on_configure, but there are five lifecycle callbacks you can implement. Only on_configure is required — the rest are optional.
| Callback | Return Type | Required | Transition |
|---|---|---|---|
on_configure |
CallbackReturn |
Yes | Unconfigured → Inactive |
on_activate |
CallbackReturn |
No | Inactive → Active |
on_deactivate |
CallbackReturn |
No | Active → Inactive |
on_cleanup |
CallbackReturn |
No | Inactive → Unconfigured |
on_shutdown |
void |
No | Any → Finalized |
All callbacks except on_shutdown can return SUCCESS, FAILURE, or ERROR. Returning FAILURE rejects the transition and the node stays in its previous state. Returning ERROR transitions the node to the Finalized (error) state.
Cake handles entity lifecycle management automatically around your callbacks:
- Configure: Session is created with all entities, then
on_configureruns. On failure, the session is destroyed. - Activate:
on_activateruns first, then entities are activated automatically. - Deactivate:
on_deactivateruns first, then entities are deactivated automatically. - Cleanup:
on_cleanupruns first. On success, the session is destroyed. - Shutdown:
on_shutdownruns, then the session is always destroyed regardless of outcome.
Callbacks are passed as template parameters to the generated Base class. Optional callbacks default to returning SUCCESS (or no-op for on_shutdown):
#pragma once
#include <my_package/my_node_interface.hpp>
namespace my_package::my_node {
struct Session : MyNodeSession<Session> {
using MyNodeSession::MyNodeSession;
bool is_running = false;
};
CallbackReturn on_configure(std::shared_ptr<Session> sn);
CallbackReturn on_activate(std::shared_ptr<Session> sn);
CallbackReturn on_deactivate(std::shared_ptr<Session> sn);
CallbackReturn on_cleanup(std::shared_ptr<Session> sn);
void on_shutdown(std::shared_ptr<Session> sn);
// Pass all callbacks as template parameters (only on_configure is required)
using MyNode = MyNodeBase<Session, on_configure, on_activate, on_deactivate, on_cleanup, on_shutdown>;
} // namespace my_package::my_node#include "my_node.hpp"
namespace my_package::my_node {
CallbackReturn on_configure(std::shared_ptr<Session> sn) {
RCLCPP_INFO(sn->node.get_logger(), "Configuring...");
sn->subscribers.sensor->set_callback(sensor_callback);
return CallbackReturn::SUCCESS;
}
CallbackReturn on_activate(std::shared_ptr<Session> sn) {
RCLCPP_INFO(sn->node.get_logger(), "Activating...");
sn->is_running = true;
return CallbackReturn::SUCCESS;
}
CallbackReturn on_deactivate(std::shared_ptr<Session> sn) {
RCLCPP_INFO(sn->node.get_logger(), "Deactivating...");
sn->is_running = false;
return CallbackReturn::SUCCESS;
}
CallbackReturn on_cleanup(std::shared_ptr<Session> sn) {
RCLCPP_INFO(sn->node.get_logger(), "Cleaning up...");
return CallbackReturn::SUCCESS;
}
void on_shutdown(std::shared_ptr<Session> sn) {
RCLCPP_INFO(sn->node.get_logger(), "Shutting down...");
}
} // namespace my_package::my_nodeYou can also provide only the callbacks you need — omitted ones use sensible defaults:
// Only on_configure and on_activate
using MyNode = MyNodeBase<Session, on_configure, on_activate>;Callbacks are passed as keyword arguments to run(). Optional callbacks are simply omitted:
from cake import TransitionCallbackReturn
from my_package.my_node import MyNodeSession, run
@dataclass
class MySession(MyNodeSession):
is_running: bool = False
def on_configure(sn: MySession) -> TransitionCallbackReturn:
sn.logger.info("Configuring...")
sn.subscribers.sensor.set_callback(sensor_callback)
return TransitionCallbackReturn.SUCCESS
def on_activate(sn: MySession) -> TransitionCallbackReturn:
sn.logger.info("Activating...")
sn.is_running = True
return TransitionCallbackReturn.SUCCESS
def on_deactivate(sn: MySession) -> TransitionCallbackReturn:
sn.logger.info("Deactivating...")
sn.is_running = False
return TransitionCallbackReturn.SUCCESS
def on_cleanup(sn: MySession) -> TransitionCallbackReturn:
sn.logger.info("Cleaning up...")
return TransitionCallbackReturn.SUCCESS
def on_shutdown(sn: MySession) -> None:
sn.logger.info("Shutting down...")
if __name__ == "__main__":
run(
MySession,
on_configure,
on_activate=on_activate,
on_deactivate=on_deactivate,
on_cleanup=on_cleanup,
on_shutdown=on_shutdown,
)The cake_auto_package() macro eliminates the need for manual CMake configuration by following a simple convention-over-configuration approach.
When you call cake_auto_package(), it:
- Scans the
nodes/directory for subdirectories containinginterface.yamlfiles - Auto-detects languages by looking for
.cppor.pyfiles in each node directory - Generates code for each node:
- C++: Interface headers, parameter libraries, and component registration code
- Python: Interface modules, parameter classes, and executable wrappers
- Builds C++ libraries from all
.cppfiles in thenodes/directory - Registers components with naming convention
${PROJECT_NAME}::${NodeName} - Creates executables for both C++ (via component registration) and Python (via runpy wrappers)
- Installs everything to proper locations (headers, libraries, executables, Python packages)
- Auto-installs common directories like
launch/andconfig/if they exist
my_package/
├── nodes/ # Required: All nodes go here
│ ├── my_cpp_node/
│ │ ├── interface.yaml # Required
│ │ └── my_cpp_node.hpp # Implementation
│ │ └── my_cpp_node.cpp # Implementation
│ └── my_py_node/
│ ├── interface.yaml # Required
│ └── my_py_node.py # Implementation
├── launch/ # Optional: Auto-installed if exists
├── config/ # Optional: Auto-installed if exists
├── interfaces/ # Optional: Package-level interface definitions
├── CMakeLists.txt
└── package.xml
You can have multiple nodes (both C++ and Python) in a single package:
my_package/
├── nodes/
│ ├── driver_node/
│ │ ├── interface.yaml
│ │ └── driver_node.hpp
│ │ └── driver_node.cpp
│ ├── controller_node/
│ │ ├── interface.yaml
│ │ └── controller_node.hpp
│ │ └── controller_node.cpp
│ └── monitor_node/
│ ├── interface.yaml
│ └── monitor_node.py
└── ...
All nodes will be built and registered automatically.
# Install additional directories to share/
cake_auto_package(INSTALL_TO_SHARE
maps
rviz
)C++ nodes are registered as rclcpp components with this naming pattern:
- Plugin class:
${PROJECT_NAME}::${NodeName} - Executable:
${NODE_NAME}(snake_case)
Example: A node my_node in package my_package becomes:
- Plugin:
my_package::MyNode - Executable:
my_node
The cake_auto_interface_package() macro simplifies the creation of ROS 2 interface packages by automatically discovering and generating all message, service, and action definitions.
When you call cake_auto_interface_package(), it:
- Finds ament_cmake_auto and discovers all dependencies from
package.xml - Auto-discovers interface files in standard ROS 2 directories:
msg/*.msgfor message definitionssrv/*.srvfor service definitionsaction/*.actionfor action definitions
- Generates interfaces using
rosidl_generate_interfaces()with auto-detected dependencies - Finalizes the package with
ament_auto_package()
CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
project(my_interfaces)
find_package(cake REQUIRED)
cake_auto_interface_package()That's it! Just 2 lines of actual code. The macro handles everything else.
package.xml:
<?xml version="1.0"?>
<package format="3">
<name>my_interfaces</name>
<version>0.0.0</version>
<description>My interface definitions</description>
<maintainer email="you@example.com">Your Name</maintainer>
<license>Apache 2.0</license>
<buildtool_depend>cake</buildtool_depend>
<!-- msg/service/action dependencies go here (if required) -->
<depend>std_msgs</depend>
<depend>geometry_msgs</depend>
<depend>std_srvs</depend>
<!-- important! this must be included in all interface package xmls -->
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>Directory structure:
my_interfaces/
├── msg/
│ ├── MyMessage.msg
│ └── AnotherMessage.msg
├── srv/
│ └── MyService.srv
├── action/
│ └── MyAction.action
├── CMakeLists.txt
└── package.xml
Traditional interface package CMakeLists.txt files require manual file listing and explicit dependency management:
# Old way (8+ lines)
cmake_minimum_required(VERSION 3.22)
project(my_interfaces)
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)
find_package(geometry_msgs REQUIRED)
set(msg_files "msg/MyMessage.msg" "msg/AnotherMessage.msg")
set(srv_files "srv/MyService.srv")
set(action_files "action/MyAction.action")
rosidl_generate_interfaces(${PROJECT_NAME}
${msg_files}
${srv_files}
${action_files}
DEPENDENCIES std_msgs geometry_msgs action_msgs
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()With cake_auto_interface_package(), this becomes just 2 lines. All dependencies are automatically discovered from package.xml, and all interface files are automatically found.
The interface YAML file defines your node's ROS 2 interfaces.
Cake provides a YAML Schema for interface.yaml files, enabling IDE autocompletion and validation.
Add to your .vscode/settings.json (adjust the path to your workspace):
{
"yaml.schemas": {
"<your_workspace>/install/cake/share/cake/schemas/interface.schema.yaml": ["**/interface.yaml"]
}
}Or add a modeline comment to individual files:
# yaml-language-server: $schema=<your_workspace>/install/cake/share/cake/schemas/interface.schema.yaml
publishers:
...The node: section is optional. When omitted, name and package default to the values provided by the build system (derived from the directory structure and CMake PROJECT_NAME).
# Minimal: no node section needed, defaults from build system
publishers:
- topic: /cmd_vel
...You can explicitly provide node: to override the defaults:
node:
name: custom_node_name
package: custom_packageFor backward compatibility, ${THIS_NODE} and ${THIS_PACKAGE} placeholders are still supported but no longer needed:
node:
name: ${THIS_NODE} # Equivalent to omitting name entirely
package: ${THIS_PACKAGE} # Equivalent to omitting package entirelyUses generate_parameter_library (https://github.com/PickNikRobotics/generate_parameter_library) syntax:
parameters:
my_param:
type: double
default_value: 1.0
description: "Parameter description"
validation:
gt<>: [0.0]publishers:
- topic: /cmd_vel
type: geometry_msgs/msg/Twist
qos:
history: 10
reliability: RELIABLEQoS is required for all publishers. See QoS Configuration for details. Topic names support ${param:name} substitution — see Dynamic Topic/Service/Action Names.
subscribers:
- topic: /odom
type: nav_msgs/msg/Odometry
qos:
history: 5
reliability: BEST_EFFORTQoS is required for all subscribers. See QoS Configuration for details. Topic names support ${param:name} substitution — see Dynamic Topic/Service/Action Names.
services:
- name: my_service
type: example_interfaces/srv/AddTwoIntsservice_clients:
- name: external_service
type: std_srvs/srv/Triggeractions:
- name: navigate
type: nav2_msgs/action/NavigateToPoseaction_clients:
- name: navigate
type: nav2_msgs/action/NavigateToPoseAll entity types (services, service clients, actions, action clients) support ${param:name} substitution in their names — see Dynamic Topic/Service/Action Names.
All interface types (publishers, subscribers, services, service_clients, actions, action_clients) support the following optional field:
manually_created: false # Set to true to completely exclude from code generationWhen manually_created: true, Cake will completely skip this interface during code generation - it won't appear in the generated session struct at all. This is useful when you want to document an interface in the YAML without having Cake generate code for it.
Example:
subscribers:
- topic: /camera/image
type: sensor_msgs/msg/Image
qos:
history: 5
reliability: BEST_EFFORT
manually_created: true # Won't be generated - handle this yourselfQoS (Quality of Service) is required for all publishers and subscribers. QoS is not applicable to services, service clients, actions, or action clients.
Required fields:
| Field | Type | Values |
|---|---|---|
history |
integer or string | Integer > 0 for KEEP_LAST(n), or "ALL" for KEEP_ALL |
reliability |
string | BEST_EFFORT or RELIABLE |
Optional fields:
| Field | Type | Values |
|---|---|---|
durability |
string | TRANSIENT_LOCAL or VOLATILE |
deadline_ms |
integer | >= 0 (milliseconds) |
lifespan_ms |
integer | >= 0 (milliseconds) |
liveliness |
string | AUTOMATIC or MANUAL_BY_TOPIC |
lease_duration_ms |
integer | >= 0 (milliseconds, used with liveliness) |
Minimal QoS (required fields only):
qos:
history: 10
reliability: RELIABLESensor data (best effort, small queue):
qos:
history: 5
reliability: BEST_EFFORTLatched topic (transient local durability):
qos:
history: 1
reliability: RELIABLE
durability: TRANSIENT_LOCALWith deadline monitoring:
qos:
history: 10
reliability: RELIABLE
deadline_ms: 1000 # 1 second deadlineKeep all messages:
qos:
history: ALL
reliability: RELIABLEFull configuration with all options:
qos:
history: 5
reliability: BEST_EFFORT
durability: VOLATILE
deadline_ms: 100
lifespan_ms: 500
liveliness: AUTOMATIC
lease_duration_ms: 200QoS fields can reference read_only parameters using ${param:parameter_name} syntax, allowing QoS settings to be configured at launch time rather than hardcoded.
Requirements:
- The referenced parameter must exist in the
parameterssection - The parameter must have
read_only: true - The parameter type must be compatible with the QoS field:
history,deadline_ms,lifespan_ms,lease_duration_ms: requiresinttypereliability,durability,liveliness: requiresstringtype
Example:
parameters:
sensor_queue_depth:
type: int
default_value: 20
read_only: true
description: Queue depth for sensor data
sensor_reliability:
type: string
default_value: RELIABLE
read_only: true
description: Reliability policy for sensor data
subscribers:
- topic: /sensor_data
type: sensor_msgs/msg/LaserScan
qos:
history: ${param:sensor_queue_depth}
reliability: ${param:sensor_reliability}
publishers:
- topic: /processed_data
type: std_msgs/msg/String
qos:
history: ${param:sensor_queue_depth}
reliability: RELIABLE # Can mix literal values and param refsYou can then override QoS settings at launch time:
ros2 run my_package my_node --ros-args -p sensor_queue_depth:=50 -p sensor_reliability:=BEST_EFFORTOr in a launch file:
Node(
package='my_package',
executable='my_node',
parameters=[{
'sensor_queue_depth': 50,
'sensor_reliability': 'BEST_EFFORT',
}]
)Validation: Invalid parameter values (e.g., "INVALID" for reliability) will raise an exception at node startup with a clear error message.
Topic, service, and action names can contain ${param:parameter_name} references for dynamic name construction at startup. This is useful for multi-robot systems or configurable namespacing.
Requirements:
- The referenced parameter must exist in the
parameterssection - The parameter must have
read_only: true - The parameter type must be
stringorint - A
field_namemust be provided (since the topic name can't be used to derive a C++ identifier)
Example:
parameters:
robot_id:
type: string
default_value: "robot1"
read_only: true
description: "Robot identifier for topic namespacing"
publishers:
- topic: /robot/${param:robot_id}/cmd_vel
field_name: cmd_vel
type: geometry_msgs/msg/Twist
qos:
history: 10
reliability: RELIABLE
subscribers:
- topic: /robot/${param:robot_id}/odom
field_name: odom
type: nav_msgs/msg/Odometry
qos:
history: 5
reliability: BEST_EFFORT
services:
- name: /robot/${param:robot_id}/get_state
field_name: get_state
type: std_srvs/srv/TriggerThis generates code that constructs the topic name at startup using the parameter value. In C++:
// Generated: topic name built from parameter
cake::create_publisher<...>(sn, "/robot/" + cake::to_string(sn->params.robot_id) + "/cmd_vel", ...);In Python:
# Generated: topic name built from parameter
sn.publishers.cmd_vel._initialise(sn, Twist, f"/robot/{params.robot_id}/cmd_vel", ...)You can then configure different robots at launch time:
ros2 run my_package my_node --ros-args -p robot_id:=robot2Multiple substitutions are supported in a single name:
subscribers:
- topic: /${param:namespace}/${param:robot_id}/sensor
field_name: sensor
type: sensor_msgs/msg/Imu
qos:
history: 10
reliability: RELIABLEInteger parameters also work (converted to string automatically):
parameters:
sensor_num:
type: int
default_value: 1
read_only: true
publishers:
- topic: /sensor_${param:sensor_num}/data
field_name: sensor_data
type: std_msgs/msg/String
qos:
history: 10
reliability: RELIABLEfield_name explained: When a topic/service/action name contains ${param:...}, Cake can't derive a valid C++ field name from it automatically, so you must provide one explicitly. The field_name is used as the struct member name in the generated session:
// With field_name: cmd_vel
sn->publishers.cmd_vel->publish(msg);# With field_name: cmd_vel
sn.publishers.cmd_vel.publish(msg)The field_name property is also available for entities without parameter substitution, allowing you to override the auto-derived field name if desired.
When the number of entities varies per deployment (e.g., a lifecycle manager that needs N service clients for N managed nodes), use ${for_each_param:parameter_name} in entity names. This generates a std::unordered_map (C++) or dict (Python) keyed by the parameter's string values, with a loop that creates one entity per element at startup.
Requirements:
- The referenced parameter must exist in the
parameterssection - The parameter must have
read_only: true - The parameter type must be
string_array - A
field_namemust be provided - Only one
${for_each_param:...}reference is allowed per entity name (but${param:...}references can coexist alongside it)
Example:
parameters:
managed_nodes:
type: string_array
default_value:
- "node_a"
- "node_b"
read_only: true
description: "List of managed node names"
robot_id:
type: string
default_value: "robot1"
read_only: true
description: "Robot identifier"
publishers:
# Regular publisher — single instance, uses ${param:...}
- topic: /robot/${param:robot_id}/status
field_name: status
type: std_msgs/msg/String
qos:
history: 10
reliability: RELIABLE
subscribers:
# for_each_param subscriber — one per managed node
- topic: /${for_each_param:managed_nodes}/state
field_name: node_states
type: std_msgs/msg/String
qos:
history: 10
reliability: RELIABLE
service_clients:
# for_each_param service client — one per managed node
- name: /${for_each_param:managed_nodes}/change_state
field_name: change_state_clients
type: lifecycle_msgs/srv/ChangeStateThis generates map/dict-typed fields and loop initialization. In C++:
// Struct field is a map
std::unordered_map<std::string, std::shared_ptr<cake::Subscriber<std_msgs::msg::String, SessionType>>> node_states;
// Constructor loops over parameter values
for (const auto& key : sn->params.managed_nodes) {
sn->subscribers.node_states[key] = cake::create_subscriber<std_msgs::msg::String>(
sn, "/" + key + "/state", rclcpp::QoS(10).reliable());
}In Python:
# Dataclass field is a dict
node_states: dict[str, cake.Subscriber[String]] = field(default_factory=dict)
# Initialization loops over parameter values
for key in params.managed_nodes:
sn.subscribers.node_states[key] = cake.Subscriber[String]()
sn.subscribers.node_states[key]._initialise(sn, String, f"/{key}/state", ...)Access entities by iterating over the map/dict at runtime:
// C++
for (const auto& [name, client] : sn->service_clients.change_state_clients) {
auto request = std::make_shared<lifecycle_msgs::srv::ChangeState::Request>();
client->async_send_request(request);
}# Python
for name, client in sn.service_clients.change_state_clients.items():
request = ChangeState.Request()
client.call_async(request)${for_each_param:...} works with all entity types: publishers, subscribers, services, service clients, actions, and action clients.
Cake subscribers and publishers support QoS event callbacks to react when deadlines are missed or liveliness changes.
The deadline callback fires when no message is received within the deadline period specified in QoS:
C++ Example:
CallbackReturn on_configure(std::shared_ptr<Session> sn) {
// Set the message callback
sn->subscribers.ok->set_callback(
[](std::shared_ptr<Session> sn, std_msgs::msg::Bool::ConstSharedPtr msg) {
sn->ok_received = true;
sn->ok_status = msg->data;
}
);
// Set deadline callback - fires when no message received in time
sn->subscribers.ok->set_deadline_callback(
[](std::shared_ptr<Session> sn, rclcpp::QOSDeadlineRequestedInfo& event) {
RCLCPP_WARN(sn->node.get_logger(), "Deadline missed!");
sn->ok_received = false;
}
);
return CallbackReturn::SUCCESS;
}Python Example:
def on_configure(sn: MySession) -> TransitionCallbackReturn:
def on_msg(sn, msg):
sn.ok_received = True
sn.ok_status = msg.data
def on_deadline_missed(sn, event):
sn.node.get_logger().warning("Deadline missed!")
sn.ok_received = False
sn.subscribers.ok.set_callback(on_msg)
sn.subscribers.ok.set_deadline_callback(on_deadline_missed)
return TransitionCallbackReturn.SUCCESSThe liveliness callback fires when a publisher's liveliness state changes:
sn->subscribers.sensor->set_liveliness_callback(
[](std::shared_ptr<Session> sn, rclcpp::QOSLivelinessChangedInfo& event) {
RCLCPP_INFO(sn->node.get_logger(),
"Liveliness changed: %d alive, %d not alive",
event.alive_count, event.not_alive_count);
}
);Subscribers also expose the underlying rclcpp::Subscription / rclpy.subscription.Subscription via the subscription() method for advanced use cases.
Publishers also support QoS event callbacks. Note the different event types compared to subscribers:
- Subscriber deadline:
QOSDeadlineRequestedInfo- didn't receive message in time - Publisher deadline:
QOSDeadlineOfferedInfo- didn't publish in time - Subscriber liveliness:
QOSLivelinessChangedInfo- publisher liveliness changed - Publisher liveliness:
QOSLivelinessLostInfo- our liveliness was lost
C++ Example:
CallbackReturn on_configure(std::shared_ptr<Session> sn) {
// Deadline callback - fires when we don't publish in time
sn->publishers.status->set_deadline_callback(
[](std::shared_ptr<Session> sn, rclcpp::QOSDeadlineOfferedInfo& event) {
RCLCPP_WARN(sn->node.get_logger(), "Missed publish deadline!");
}
);
// Liveliness callback - fires when our liveliness is lost
sn->publishers.status->set_liveliness_callback(
[](std::shared_ptr<Session> sn, rclcpp::QOSLivelinessLostInfo& event) {
RCLCPP_WARN(sn->node.get_logger(), "Liveliness lost!");
}
);
return CallbackReturn::SUCCESS;
}Python Example:
def on_configure(sn: MySession) -> TransitionCallbackReturn:
def on_deadline_missed(sn, event):
sn.node.get_logger().warning("Missed publish deadline!")
def on_liveliness_lost(sn, event):
sn.node.get_logger().warning("Liveliness lost!")
sn.publishers.status.set_deadline_callback(on_deadline_missed)
sn.publishers.status.set_liveliness_callback(on_liveliness_lost)
return TransitionCallbackReturn.SUCCESSPublishers also expose the underlying rclcpp::Publisher / rclpy.publisher.Publisher via the publisher() method for advanced use cases like wait_for_all_acked() or get_subscription_count().
For deadline callbacks to work, you must set a deadline in your QoS configuration:
subscribers:
- topic: ok
type: std_msgs/msg/Bool
qos:
history: 10
reliability: RELIABLE
deadline_ms: 1000 # 1 second
publishers:
- topic: status
type: std_msgs/msg/String
qos:
history: 10
reliability: RELIABLE
deadline_ms: 500 # 500msFor liveliness callbacks, configure liveliness and lease duration:
subscribers:
- topic: sensor
type: sensor_msgs/msg/Imu
qos:
history: 5
reliability: BEST_EFFORT
liveliness: AUTOMATIC
lease_duration_ms: 2000 # 2 seconds
publishers:
- topic: heartbeat
type: std_msgs/msg/Empty
qos:
history: 1
reliability: RELIABLE
liveliness: AUTOMATIC
lease_duration_ms: 1000 # 1 secondBy default, Cake nodes automatically transition through configure → activate on startup, so they begin processing immediately without requiring an external lifecycle manager.
This is controlled by the autostart parameter (default: true). A zero-delay timer fires once on construction to call trigger_configure() followed by trigger_activate(). If either transition fails, an error is logged and the sequence stops.
To disable autostart (e.g., when using a lifecycle manager):
ros2 run my_package my_node --ros-args -p autostart:=falseOr in a launch file:
Node(
package='my_package',
executable='my_node',
parameters=[{'autostart': False}]
)When autostart is disabled, you must trigger lifecycle transitions externally:
ros2 lifecycle set /my_node configure
ros2 lifecycle set /my_node activateEvery Cake node publishes its current lifecycle state on ~/state at 10 Hz. This provides a lightweight monitoring and watchdog interface without polling the lifecycle service.
| Property | Value |
|---|---|
| Topic | ~/state |
| Type | lifecycle_msgs/msg/State |
| Rate | 100 ms |
| QoS | Reliable, transient-local, 100 ms deadline, automatic liveliness (100 ms lease) |
The publisher only serialises messages when there are active subscribers, so there is zero overhead when nobody is listening.
Watchdog usage: External monitors can subscribe with a matching deadline QoS. If the node hangs or crashes and stops publishing, the subscriber's deadline-missed or liveliness change callback fires, enabling automatic fault detection.
# Monitor a node's state from the command line
ros2 topic echo /my_node/stateC++ Cake nodes enable intra-process communication (IPC) by default. When multiple Cake nodes run in the same process (e.g., via component composition), messages are passed by pointer rather than serialised, providing zero-copy performance.
This is set automatically in the BaseNode constructor — no configuration is needed.
Note: IPC is a C++ feature. Python nodes are unaffected.
Cake automatically attaches default QoS event handlers to every generated subscriber. These handlers provide a safety net that deactivates the node when QoS contracts are violated:
| Event | Behaviour |
|---|---|
| Deadline missed | Logs an error and deactivates the node |
| Liveliness changed (alive publishers drops to 0) | Logs an error and deactivates the node |
Both handlers are no-ops when the node is not in the ACTIVE state, preventing spurious triggers during transitions.
This gives you cascading shutdown for free: if an upstream node deactivates and stops publishing, downstream subscribers miss their deadline (or lose liveliness) and automatically deactivate too.
For every subscriber defined in interface.yaml, the generated code calls attach_default_qos_handlers() immediately after creation. No user code is needed — the handlers are always present.
To make the handlers trigger, configure a deadline_ms and/or liveliness + lease_duration_ms in your subscriber's QoS:
subscribers:
- topic: heartbeat
type: std_msgs/msg/Bool
qos:
history: 1
reliability: RELIABLE
deadline_ms: 1000 # deactivate if no message for 1s
liveliness: AUTOMATIC
lease_duration_ms: 1000 # deactivate if publisher disappearsWithout deadline or liveliness QoS settings, the handlers are attached but will never fire.
The default handlers call set_deadline_callback() and set_liveliness_callback() on the subscriber. If you set your own callbacks in on_configure, they will replace the defaults:
// Override the default deadline handler with custom logic
sn->subscribers.heartbeat->set_deadline_callback(
[](std::shared_ptr<Session> sn, rclcpp::QOSDeadlineRequestedInfo& event) {
RCLCPP_WARN(sn->node.get_logger(), "Custom deadline handling");
// your logic here
}
);You can also attach the default handlers to manually-created subscribers:
C++:
#include <cake/default_qos_handlers.hpp>
// After creating a subscriber manually
cake::attach_default_qos_handlers(sn->subscribers.my_sub);Python:
import cake
cake.attach_default_qos_handlers(sn.subscribers.my_sub)Cake assumes a single-threaded executor for most callbacks. Session state (publishers, subscribers, parameters, timers, etc.) is not protected by any synchronization primitives, so concurrent access from multiple executor threads would be a data race. Multi-threading executors is out of scope for cake at the moment. External concurrent execution of work is still available to the user via standard threading, but synchronisation is the users responsibility.
Service clients and action clients are placed on a dedicated callback group with its own background SingleThreadedExecutor thread. This means their response callbacks are processed independently of the main executor — preventing deadlocks when calling services synchronously from lifecycle callbacks (e.g., on_configure).
Without this, calling a service synchronously from on_configure would deadlock: the main executor thread is blocked waiting for the response, but that same thread is the only one that can process the response.
The background executor is created in the BaseNode constructor and torn down in the destructor. Generated code automatically passes the isolated callback group when creating service and action clients — no user configuration is needed.
Cake provides <cake/call_sync.hpp> with blocking wrappers for service and action clients. These are safe to call from lifecycle callbacks because the isolated background executor processes the responses.
#include <cake/call_sync.hpp>
CallbackReturn on_configure(std::shared_ptr<Session> sn) {
if (sn->service_clients.my_service->wait_for_service(2s)) {
auto req = std::make_shared<MyService::Request>();
req->data = 42;
auto resp = cake::call_sync<MyService>(sn->service_clients.my_service, req, 5s);
if (resp) {
RCLCPP_INFO(sn->node.get_logger(), "Got response: %d", resp->result);
} else {
RCLCPP_WARN(sn->node.get_logger(), "Service call timed out");
}
}
return CallbackReturn::SUCCESS;
}Returns nullptr on timeout. Default timeout is 5 seconds.
#include <cake/call_sync.hpp>
auto goal = MyAction::Goal();
goal.order = 5;
auto goal_handle = cake::send_goal_sync<MyAction>(
sn->action_clients.my_action, goal, {}, 5s);
if (goal_handle) {
RCLCPP_INFO(sn->node.get_logger(), "Goal accepted");
}Returns nullptr if the goal is rejected or the request times out.
auto result = cake::get_result_sync<MyAction>(
sn->action_clients.my_action, goal_handle, 5min);
if (result) {
RCLCPP_INFO(sn->node.get_logger(), "Result: %d", result->sequence.size());
}Returns std::nullopt on timeout. Default timeout is 5 minutes.
auto cancel_resp = cake::cancel_goal_sync<MyAction>(
sn->action_clients.my_action, goal_handle, 5s);Returns nullptr on timeout.
Important: These helpers are designed for calling services/actions on other nodes. Calling a service hosted on the same node from a callback on the main executor will still deadlock, because the service handler also needs the main executor thread to run.
cd cake/tests
./run_tests.shAfter making changes to the code generator:
cd cake/tests
./run_tests.sh # Generate new outputs
./accept_outputs.sh # Accept as expected outputs
./run_tests.sh # Verify tests passThe cake_example package demonstrates usage with:
- Multiple nodes: C++ node (
my_node) and Python node (python_node) - Interface examples: Publishers, subscribers, services, actions, and parameters
- Synchronous calls:
my_nodeusescake::call_syncandcake::send_goal_syncinon_configureto callpython_node's service and action synchronously — demonstrating deadlock-free sync calls from lifecycle callbacks - Cascading deactivation:
my_nodepublishes a heartbeat;python_nodesubscribes with a 1 s deadline — ifmy_nodedeactivates, the default QoS handler automatically deactivatespython_nodetoo - Minimal CMakeLists.txt: Just 3 lines using
cake_auto_package() - Component registration: Automatic component plugin setup
- Package-level interfaces: Optional
interfaces/directory for shared definitions
Structure:
cake_example/
├── nodes/
│ ├── my_node/
│ │ ├── interface.yaml
│ │ ├── my_node.cpp
│ │ └── my_node.hpp
│ └── python_node/
│ ├── interface.yaml
│ └── python_node.py
├── interfaces/
│ ├── external_node.yaml
│ └── transition_node.yaml
├── launch/
│ └── test.launch.py
├── CMakeLists.txt # Just cake_auto_package()!
└── package.xml
Build and run the example:
colcon build --packages-select cake_example
source install/setup.bash
# Run C++ node
ros2 run cake_example my_node
# Run Python node
ros2 run cake_example python_node
# Load as component
ros2 component standalone cake_example cake_example::MyNodeLicensed under the Apache License, Version 2.0. See LICENSE for details.