Imported from enesbirlik/claude-code-robotics (
skills/ros2-workspace/SKILL.md). Install upstream withnpx skills add enesbirlik/claude-code-robotics --skill ros2-workspace. Copyright stays with the author.
ROS 2 Workspace Engineering
Rules and templates for producing ROS 2 packages that build cleanly with colcon, pass
ament_lint, and follow the conventions expected in Humble and Jazzy.
1. Workspace layout
ros2_ws/
├── src/
│ ├── my_robot_bringup/ # launch + top-level config, no code
│ ├── my_robot_description/ # URDF/Xacro + meshes (see urdf-xacro-builder skill)
│ ├── my_robot_msgs/ # custom .msg/.srv/.action, ament_cmake only
│ └── my_robot_controller/ # the actual node(s), ament_cmake or ament_python
├── install/ # generated by colcon build — never hand-edit, gitignore it
├── build/ # generated — gitignore it
└── log/ # generated — gitignore it
Rules:
- One package = one clear responsibility (bringup, description, msgs, driver, algorithm).
Never mix message/service/action definitions into a package that also has other build
dependents — put custom interfaces in their own
_msgsor_interfacespackage to avoid circular dependencies. - Package names are
snake_case, never start with a digit, and should be prefixed with the robot/project name (my_robot_controller, notcontroller). - Never commit
build/,install/, orlog/— the plugin's.gitignoretemplate already excludes them.
2. package.xml (format 3)
<?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_robot_controller</name>
<version>0.1.0</version>
<description>Velocity controller node for my_robot.</description>
<maintainer email="you@example.com">Your Name</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<depend>geometry_msgs</depend>
<depend>sensor_msgs</depend>
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
For a Python package, use <build_type>ament_python</build_type> and
<buildtool_depend>ament_python</buildtool_depend>.
Rules:
- Every runtime import/link dependency is a
<depend>(or split<build_depend>/<exec_depend>only when build-time and run-time deps genuinely differ — otherwise prefer the combined<depend>tag for readability). - Always declare
ament_lint_auto+ament_lint_commonastest_dependsocolcon testruns static checks (ament_cpplint,ament_flake8,ament_xmllint, ...). <version>follows SemVer and should be bumped alongsideCHANGELOG.rstentries.
3. C++ package: CMakeLists.txt
cmake_minimum_required(VERSION 3.8)
project(my_robot_controller)
if(NOT CMAKE_CXX_STANDARD)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
endif()
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(sensor_msgs REQUIRED)
add_executable(velocity_controller_node
src/velocity_controller_node.cpp
)
ament_target_dependencies(velocity_controller_node
rclcpp
geometry_msgs
sensor_msgs
)
target_include_directories(velocity_controller_node PRIVATE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
)
install(TARGETS velocity_controller_node
DESTINATION lib/${PROJECT_NAME}
)
install(DIRECTORY launch config
DESTINATION share/${PROJECT_NAME}
OPTIONAL
)
if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
ament_lint_auto_find_test_dependencies()
endif()
ament_package()
Rules:
- Warnings-as-errors are opt-in per repo, but
-Wall -Wextra -Wpedanticis always on. - Every installed executable target gets an explicit
install(TARGETS ...). Every sharedlaunch/,config/,urdf/,meshes/directory gets aninstall(DIRECTORY ...). ament_package()must be the last line.
4. Python package: setup.py / setup.cfg
# setup.py
from setuptools import find_packages, setup
package_name = 'my_robot_controller'
setup(
name=package_name,
version='0.1.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
('share/' + package_name + '/launch', ['launch/controller.launch.py']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='Your Name',
maintainer_email='you@example.com',
description='Velocity controller node for my_robot.',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'velocity_controller_node = my_robot_controller.velocity_controller_node:main',
],
},
)
# setup.cfg
[develop]
script_dir=$base/lib/my_robot_controller
[install]
install_scripts=$base/lib/my_robot_controller
Rules:
- Every
console_scriptsentry point mapsexecutable_name = package.module:main; themain()function must exist and take no required arguments. - Don't forget the
resource/<package_name>marker file (empty) — without itros2 pkg listandros2 runwon't find the package.
5. rclpy node template (Python)
#!/usr/bin/env python3
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from geometry_msgs.msg import Twist
from sensor_msgs.msg import LaserScan
import rclpy
class VelocityControllerNode(Node):
def __init__(self) -> None:
super().__init__('velocity_controller_node')
self.declare_parameter('max_linear_speed', 0.5)
self.declare_parameter('scan_topic', '/scan')
self._max_linear_speed = self.get_parameter('max_linear_speed').value
sensor_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=5,
)
self._scan_sub = self.create_subscription(
LaserScan,
self.get_parameter('scan_topic').value,
self._on_scan,
sensor_qos,
)
self._cmd_pub = self.create_publisher(Twist, 'cmd_vel', 10)
self._timer = self.create_timer(0.1, self._on_timer)
def _on_scan(self, msg: LaserScan) -> None:
self._latest_scan = msg
def _on_timer(self) -> None:
cmd = Twist()
cmd.linear.x = self._max_linear_speed
self._cmd_pub.publish(cmd)
def main(args: list[str] | None = None) -> None:
rclpy.init(args=args)
node = VelocityControllerNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Rules:
- Always declare parameters with
declare_parameterbefore reading them — never assume a default exists implicitly. - Choose QoS explicitly. Sensor data:
BEST_EFFORT+ shallowKEEP_LASTdepth. Commands and state:RELIABLE+KEEP_LASTwith a depth matched to consumer speed. Never leave QoS at an implicit default when publisher and subscriber are in different packages — mismatches silently drop all messages. - Use
rclpy.spin(node)for single-purpose nodes; useMultiThreadedExecutorwith explicitReentrantCallbackGroup/MutuallyExclusiveCallbackGroupassignment only when you have callbacks that must run concurrently (e.g. a long-running service alongside a fast timer) — don't reach for it by default, it adds real concurrency hazards. - Always
destroy_node()thenrclpy.shutdown()in afinallyblock so Ctrl+C and exceptions both clean up the node's middleware resources.
6. rclcpp node template (C++)
#include <memory>
#include <rclcpp/rclcpp.hpp>
#include <geometry_msgs/msg/twist.hpp>
#include <sensor_msgs/msg/laser_scan.hpp>
class VelocityControllerNode : public rclcpp::Node
{
public:
VelocityControllerNode()
: Node("velocity_controller_node")
{
max_linear_speed_ = this->declare_parameter("max_linear_speed", 0.5);
const auto scan_topic = this->declare_parameter("scan_topic", std::string("/scan"));
auto sensor_qos = rclcpp::SensorDataQoS();
scan_sub_ = this->create_subscription<sensor_msgs::msg::LaserScan>(
scan_topic, sensor_qos,
std::bind(&VelocityControllerNode::onScan, this, std::placeholders::_1));
cmd_pub_ = this->create_publisher<geometry_msgs::msg::Twist>("cmd_vel", 10);
timer_ = this->create_wall_timer(
std::chrono::milliseconds(100),
std::bind(&VelocityControllerNode::onTimer, this));
}
private:
void onScan(const sensor_msgs::msg::LaserScan::SharedPtr msg)
{
latest_scan_ = msg;
}
void onTimer()
{
geometry_msgs::msg::Twist cmd;
cmd.linear.x = max_linear_speed_;
cmd_pub_->publish(cmd);
}
double max_linear_speed_;
sensor_msgs::msg::LaserScan::SharedPtr latest_scan_;
rclcpp::Subscription<sensor_msgs::msg::LaserScan>::SharedPtr scan_sub_;
rclcpp::Publisher<geometry_msgs::msg::Twist>::SharedPtr cmd_pub_;
rclcpp::TimerBase::SharedPtr timer_;
};
int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<VelocityControllerNode>());
rclcpp::shutdown();
return 0;
}
Rules:
- Prefer composition-friendly node classes (inherit
rclcpp::Node, no rawmain-only logic) so the node can later be loaded as a component without rewriting it. - Use
rclcpp::SensorDataQoS()/rclcpp::SystemDefaultsQoS()helpers instead of hand-rollingrclcpp::QoSprofiles unless you need a genuinely custom depth/durability. - Never call
shared_from_this()inside a constructor — theshared_ptrdoesn't exist yet. If you need it (e.g. to register a callback that capturesthisas ashared_ptr), do it from a separateinit()method called right after construction. - Use
rclcpp::executors::MultiThreadedExecutorwith explicitCallbackGroups only when you have a proven concurrency need (e.g. a blocking service call must not stall a control-loop timer); default to the implicit single-threadedrclcpp::spin.
7. Launch files
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description() -> LaunchDescription:
max_speed_arg = DeclareLaunchArgument(
'max_linear_speed', default_value='0.5',
description='Maximum commanded linear speed (m/s).',
)
controller_node = Node(
package='my_robot_controller',
executable='velocity_controller_node',
name='velocity_controller_node',
output='screen',
parameters=[{
'max_linear_speed': LaunchConfiguration('max_linear_speed'),
}],
)
return LaunchDescription([max_speed_arg, controller_node])
Rules:
- Every tunable value exposed as a
DeclareLaunchArgument, never hardcoded, so the same launch file works in simulation and on hardware. - Use
FindPackageShare+PathJoinSubstitutionto locate config/URDF files — never a hardcoded absolute path. - Prefer one launch file per package that composes into a top-level
bringuplaunch file viaIncludeLaunchDescription, instead of one monolithic launch file per robot.
8. colcon build & test workflow
# From the workspace root (parent of src/):
colcon build --symlink-install # symlink python/config for fast iteration
colcon build --packages-select my_robot_controller # scope a build to one package
colcon test --packages-select my_robot_controller
colcon test-result --verbose
source install/setup.bash # re-source after every structural change
Rules:
- Use
--symlink-installin development workspaces so Python source and non-compiled resources update without a rebuild. - Scope builds with
--packages-selectwhile iterating; only build the whole workspace before a final integration check. - Always re-run
colcon testafter touchingpackage.xmldependencies or CMake install rules —ament_lintcatches missing/unused dependencies that only surface at test time. - This plugin's
colcon_build_checkhook automatically runs a scopedcolcon build --packages-select <pkg>whenever a new node source file is created inside a detected colcon workspace — no need to trigger it manually during normal editing.