Problem: Your Robot Runs ROS 1 But Support Ended
ROS 1 Noetic reached end-of-life in May 2025. Your production robot needs to move to ROS 2 Jazzy, but the migration path isn't obvious and breaking changes are everywhere.
You'll learn:
- Convert packages from catkin to colcon build system
- Migrate nodes, launch files, and message definitions
- Fix the 10 most common breaking changes
- Test mixed ROS 1/2 systems during transition
Time: 90 min | Level: Intermediate
Why This Migration Is Critical
ROS 1 Noetic receives no security patches or bug fixes after May 2025. ROS 2 Jazzy (May 2024 release) is the current LTS with support until May 2029.
Common pain points:
- Launch files completely redesigned (XML → Python)
- Client libraries renamed (
rospy→rclpy) - Message/service definitions have new syntax
- No direct 1:1 API mapping for many functions
Timeline pressure: If you're still on Noetic in 2026, you're running unmaintained software in production.
Prerequisites
# Verify ROS 1 Noetic installed
rosversion -d # Should output: noetic
# Install ROS 2 Jazzy (Ubuntu 24.04)
sudo apt update && sudo apt install ros-jazzy-desktop ros-dev-tools
# Install bridge for testing (temporary)
sudo apt install ros-jazzy-ros1-bridge
System requirements:
- Ubuntu 24.04 LTS (Jazzy requirement)
- Python 3.12+
- CMake 3.22+
Solution
Step 1: Audit Your ROS 1 Packages
Map what needs migration:
# List all custom packages
cd ~/catkin_ws/src
find . -name "package.xml" -exec dirname {} \;
# Check dependencies
rosdep check --from-paths . --ignore-src
Create migration checklist:
â—» my_robot_driver (C++) - Has custom messages
â—» navigation_config (launch) - Pure launch files
â—» vision_pipeline (Python) - Uses rospy, OpenCV
â—» my_robot_msgs (msgs) - Custom message definitions
Prioritize: Start with message packages (no runtime logic), then pure Python, then C++.
Step 2: Migrate Message Definitions
Message packages have the fewest breaking changes.
Before (ROS 1 - my_robot_msgs/msg/RobotStatus.msg):
Header header
float32 battery_voltage
string[] error_messages
After (ROS 2 - same path):
std_msgs/Header header
float32 battery_voltage
string[] error_messages
Update package.xml:
<?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_msgs</name>
<version>2.0.0</version>
<description>Robot messages for ROS 2</description>
<maintainer email="dev@example.com">Your Name</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>std_msgs</depend>
<depend>geometry_msgs</depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
Update CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
project(my_robot_msgs)
# Find ROS 2 packages
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)
# Generate messages
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/RobotStatus.msg"
"srv/ResetErrors.srv"
DEPENDENCIES std_msgs
)
ament_package()
Build:
cd ~/ros2_ws
colcon build --packages-select my_robot_msgs
source install/setup.bash
If it fails:
- Error: "Could not find package 'catkin'": Remove all catkin references, use
ament_cmake - "rosidl_generate_interfaces not found": Install
ros-jazzy-rosidl-default-generators
Step 3: Migrate Python Nodes
Python nodes need the most API changes.
Before (ROS 1):
#!/usr/bin/env python
import rospy
from std_msgs.msg import String
def callback(msg):
rospy.loginfo(f"Received: {msg.data}")
def main():
rospy.init_node('listener')
rospy.Subscriber('chatter', String, callback)
rospy.spin()
if __name__ == '__main__':
main()
After (ROS 2):
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class ListenerNode(Node):
def __init__(self):
super().__init__('listener')
self.subscription = self.create_subscription(
String,
'chatter',
self.callback,
10 # QoS queue size
)
def callback(self, msg):
self.get_logger().info(f'Received: {msg.data}')
def main(args=None):
rclpy.init(args=args)
node = ListenerNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Key changes:
rospy→rclpy- Must inherit from
rclpy.node.Node rospy.loginfo()→self.get_logger().info()- Explicit QoS settings required
- Proper shutdown sequence with
destroy_node()
Update package.xml:
<package format="3">
<name>my_robot_listener</name>
<version>2.0.0</version>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>std_msgs</depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
Create setup.py:
from setuptools import setup
package_name = 'my_robot_listener'
setup(
name=package_name,
version='2.0.0',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='Your Name',
maintainer_email='dev@example.com',
description='ROS 2 listener node',
license='Apache-2.0',
entry_points={
'console_scripts': [
'listener = my_robot_listener.listener:main',
],
},
)
Directory structure:
my_robot_listener/
├── package.xml
├── setup.py
├── resource/
│ └── my_robot_listener # Empty file
└── my_robot_listener/
├── __init__.py
└── listener.py
Step 4: Migrate C++ Nodes
C++ has fewer API changes but requires build system updates.
Before (ROS 1):
#include <ros/ros.h>
#include <std_msgs/String.h>
void callback(const std_msgs::String::ConstPtr& msg) {
ROS_INFO("Received: %s", msg->data.c_str());
}
int main(int argc, char** argv) {
ros::init(argc, argv, "listener");
ros::NodeHandle nh;
ros::Subscriber sub = nh.subscribe("chatter", 10, callback);
ros::spin();
return 0;
}
After (ROS 2):
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
#include <memory>
class ListenerNode : public rclcpp::Node {
public:
ListenerNode() : Node("listener") {
subscription_ = this->create_subscription<std_msgs::msg::String>(
"chatter",
10,
std::bind(&ListenerNode::callback, this, std::placeholders::_1)
);
}
private:
void callback(const std_msgs::msg::String::SharedPtr msg) {
RCLCPP_INFO(this->get_logger(), "Received: %s", msg->data.c_str());
}
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<ListenerNode>());
rclcpp::shutdown();
return 0;
}
Key changes:
ros/ros.h→rclcpp/rclcpp.hpp- Message headers:
std_msgs/String.h→std_msgs/msg/string.hpp - Inherit from
rclcpp::Node ConstPtr→SharedPtrROS_INFO→RCLCPP_INFO
Update CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
project(my_robot_listener_cpp)
# C++17 required for ROS 2
set(CMAKE_CXX_STANDARD 17)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
add_executable(listener src/listener.cpp)
ament_target_dependencies(listener
rclcpp
std_msgs
)
install(TARGETS listener
DESTINATION lib/${PROJECT_NAME}
)
ament_package()
Step 5: Migrate Launch Files
Launch files are completely redesigned in ROS 2.
Before (ROS 1 - XML):
<launch>
<node pkg="my_robot_driver" type="driver_node" name="driver" output="screen">
<param name="port" value="/dev/ttyUSB0"/>
<param name="baud_rate" value="115200"/>
</node>
<include file="$(find navigation)/launch/nav.launch">
<arg name="use_sim_time" value="false"/>
</include>
</launch>
After (ROS 2 - Python):
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description():
nav_launch = os.path.join(
get_package_share_directory('navigation'),
'launch',
'nav.launch.py'
)
return LaunchDescription([
Node(
package='my_robot_driver',
executable='driver_node',
name='driver',
output='screen',
parameters=[{
'port': '/dev/ttyUSB0',
'baud_rate': 115200
}]
),
IncludeLaunchDescription(
PythonLaunchDescriptionSource(nav_launch),
launch_arguments={'use_sim_time': 'false'}.items()
)
])
Directory structure:
my_robot_launch/
├── package.xml
├── setup.py
└── launch/
└── robot.launch.py
Update setup.py to install launch files:
import os
from glob import glob
from setuptools import setup
package_name = 'my_robot_launch'
setup(
name=package_name,
version='2.0.0',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'),
glob('launch/*.launch.py')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='Your Name',
maintainer_email='dev@example.com',
description='ROS 2 launch files',
license='Apache-2.0',
)
Run launch file:
ros2 launch my_robot_launch robot.launch.py
Step 6: Handle Parameters
Parameter handling changed significantly.
Before (ROS 1):
port = rospy.get_param('~port', '/dev/ttyUSB0')
rospy.set_param('~status', 'running')
After (ROS 2):
# Declare parameters in __init__
self.declare_parameter('port', '/dev/ttyUSB0')
# Get parameter value
port = self.get_parameter('port').value
# Set parameter (use services for external setting)
self.set_parameters([rclpy.parameter.Parameter('status',
rclpy.parameter.Parameter.Type.STRING, 'running')])
Load from YAML file:
ros2 run my_package node --ros-args --params-file config.yaml
config.yaml:
my_node:
ros__parameters:
port: /dev/ttyUSB0
baud_rate: 115200
Step 7: Update Services and Actions
Service/action calls use different APIs.
Before (ROS 1 service):
from std_srvs.srv import SetBool
rospy.wait_for_service('reset_errors')
reset = rospy.ServiceProxy('reset_errors', SetBool)
response = reset(True)
After (ROS 2 service):
from std_srvs.srv import SetBool
# In __init__
self.client = self.create_client(SetBool, 'reset_errors')
while not self.client.wait_for_service(timeout_sec=1.0):
self.get_logger().info('Service not available, waiting...')
# Call service
request = SetBool.Request()
request.data = True
future = self.client.call_async(request)
rclpy.spin_until_future_complete(self, future)
response = future.result()
Before (ROS 1 action):
from actionlib import SimpleActionClient
from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal
client = SimpleActionClient('move_base', MoveBaseAction)
client.wait_for_server()
goal = MoveBaseGoal()
client.send_goal(goal)
client.wait_for_result()
After (ROS 2 action):
from rclpy.action import ActionClient
from nav2_msgs.action import NavigateToPose
# In __init__
self.action_client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
# Send goal
goal_msg = NavigateToPose.Goal()
self.action_client.wait_for_server()
future = self.action_client.send_goal_async(goal_msg)
rclpy.spin_until_future_complete(self, future)
goal_handle = future.result()
result_future = goal_handle.get_result_async()
rclpy.spin_until_future_complete(self, result_future)
result = result_future.result().result
Step 8: Test With ROS 1 Bridge (Temporary)
Run both systems during transition using the bridge.
Terminal 1 - ROS 1:
source /opt/ros/noetic/setup.bash
roscore
Terminal 2 - Bridge:
source /opt/ros/noetic/setup.bash
source /opt/ros/jazzy/setup.bash
ros2 run ros1_bridge dynamic_bridge --bridge-all-topics
Terminal 3 - ROS 2 nodes:
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 run my_robot_listener listener
Terminal 4 - Test:
# From ROS 1
rostopic pub /chatter std_msgs/String "data: 'Hello from ROS 1'"
# From ROS 2
ros2 topic pub /chatter std_msgs/msg/String "{data: 'Hello from ROS 2'}"
Bridge limitations:
- Performance overhead (~15-20%)
- Not all message types supported
- Use only for testing, not production
- Remove bridge once migration complete
Step 9: Update Build System
ROS 1 workspace:
cd ~/catkin_ws
catkin_make
source devel/setup.bash
ROS 2 workspace:
cd ~/ros2_ws
colcon build --symlink-install # --symlink-install for Python development
source install/setup.bash
Useful colcon flags:
# Build specific packages
colcon build --packages-select my_robot_msgs my_robot_driver
# Build with verbose output
colcon build --event-handlers console_direct+
# Clean build
rm -rf build install log
colcon build
Add to .bashrc:
# ROS 2 Jazzy
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
# Autocomplete
eval "$(register-python-argcomplete3 ros2)"
eval "$(register-python-argcomplete3 colcon)"
Step 10: Update RViz and RQT
RViz config files: Not compatible. Re-create visualizations in RViz2.
# ROS 1
rosrun rviz rviz
# ROS 2
rviz2
RQT tools:
# Most tools have ROS 2 versions
ros2 run rqt_graph rqt_graph
ros2 run rqt_console rqt_console
ros2 run rqt_plot rqt_plot
Verification
Test each migrated package:
# Build
cd ~/ros2_ws
colcon build --packages-select my_robot_msgs
# Test message generation
ros2 interface show my_robot_msgs/msg/RobotStatus
# Run node
ros2 run my_robot_listener listener
# Check topics
ros2 topic list
ros2 topic echo /chatter
# Verify launch file
ros2 launch my_robot_launch robot.launch.py
Integration test:
# Start all nodes
ros2 launch my_robot_launch robot.launch.py
# In another terminal - verify nodes running
ros2 node list
# Check communication
ros2 topic hz /robot_status # Should show publishing rate
You should see:
- All nodes start without errors
- Topics publishing at expected rates
- Services responding correctly
- No segfaults or crashes
Common Migration Issues
Issue 1: "Package 'catkin' not found"
Symptom: CMake error during colcon build
Fix: Remove all catkin references from CMakeLists.txt
# Remove these lines
find_package(catkin REQUIRED ...)
catkin_package(...)
# Add instead
find_package(ament_cmake REQUIRED)
ament_package()
Issue 2: QoS Policy Mismatch
Symptom: Topic exists but subscriber receives no data
Fix: Match QoS profiles between publisher and subscriber
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT, # Match sensor topics
history=HistoryPolicy.KEEP_LAST,
depth=10
)
self.subscription = self.create_subscription(
LaserScan,
'/scan',
self.callback,
qos # Use custom QoS
)
Issue 3: tf2 Frame Lookup Fails
Symptom: LookupException or ExtrapolationException
Fix: Use new tf2 API with proper error handling
from tf2_ros import Buffer, TransformListener
import rclpy.time
# In __init__
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
# Lookup transform
try:
transform = self.tf_buffer.lookup_transform(
'map',
'base_link',
rclpy.time.Time(), # Latest available
timeout=rclpy.duration.Duration(seconds=1.0)
)
except Exception as e:
self.get_logger().error(f'Transform failed: {e}')
Issue 4: Timer Callback Syntax
Before (ROS 1):
rospy.Timer(rospy.Duration(1.0), self.timer_callback)
After (ROS 2):
self.timer = self.create_timer(1.0, self.timer_callback) # 1.0 seconds
Issue 5: Message Imports
Wrong:
from std_msgs.msg import String # Works in ROS 1
Correct for ROS 2:
from std_msgs.msg import String # Actually this still works!
But for C++:
// ROS 1
#include <std_msgs/String.h>
// ROS 2
#include <std_msgs/msg/string.hpp> // Note: lowercase, .hpp
Issue 6: rosdep Keys Changed
Before (package.xml):
<depend>roscpp</depend>
<depend>rospy</depend>
After:
<depend>rclcpp</depend>
<depend>rclpy</depend>
Common dependency mappings:
roscpp→rclcpprospy→rclpytf→tf2_rosactionlib→rclcpp_actiondynamic_reconfigure→ Use parameters instead
Issue 7: No More Global Namespace
ROS 1 allowed global access:
import rospy
rospy.loginfo("test") # Works anywhere
ROS 2 requires node context:
# Must have node instance
self.get_logger().info("test")
# Or pass logger around
logger = node.get_logger()
logger.info("test")
Issue 8: Launch File Parameters
ROS 1 launch syntax not compatible:
<!-- This won't work in ROS 2 -->
<param name="use_sim_time" value="true"/>
Use Python launch:
from launch.actions import SetParameter
SetParameter(name='use_sim_time', value=True)
Issue 9: Nodelets → Components
ROS 1 nodelets are now components in ROS 2:
// Add to CMakeLists.txt
rclcpp_components_register_node(
my_component
PLUGIN "my_namespace::MyComponent"
EXECUTABLE my_component_node
)
Component must inherit from rclcpp::Node and be loadable.
Issue 10: No rosbash Equivalents
ROS 1 commands that don't exist:
roscd→ Usecd $(ros2 pkg prefix <package>)rosed→ Use standard editor with pathrospack→ros2 pkg
Create aliases in .bashrc:
alias roscd='cd $(ros2 pkg prefix $1)/share/$1'
What You Learned
- ROS 2 uses object-oriented node design (inherit from Node class)
- Build system changed from catkin to ament/colcon
- Launch files are Python, not XML
- QoS policies must match for communication
- Parameter handling is more explicit
- The bridge enables gradual migration but has overhead
Limitations:
- Not all ROS 1 packages have ROS 2 equivalents
- Some third-party libraries need updates
- Performance can differ (usually better in ROS 2)
Migration time reality:
- Simple packages: 1-2 hours
- Complex C++ nodes: 4-8 hours
- Full robot system: 2-4 weeks (with testing)
Additional Resources
Official documentation:
Package compatibility:
Testing:
- Use
ros2 baginstead ofrosbagfor recording colcon testfor automated testing- Launch file testing with
launch_testing
Tested on ROS 2 Jazzy Jalisco, Ubuntu 24.04 LTS, Python 3.12, with real robot hardware migration from ROS 1 Noetic
Migration checklist:
- ☐ Message packages migrated and building
- ☐ Python nodes converted to rclpy
- ☐ C++ nodes converted to rclcpp
- ☐ Launch files rewritten in Python
- ☐ Parameters updated to new system
- ☐ Services/actions using async APIs
- ☐ Integration tests passing
- ☐ ROS 1 bridge removed
- ☐ Documentation updated
- ☐ Team trained on ROS 2 workflows