Writing ROS2 Nodes
Week 12 • CMPSC 304 Robotic Agents
Press Space or → to advance
Course Progression
SLAM
Build maps
→
Nav2
Click goals
→
Today
Code goals
→
Friday
Real robots
The key shift: Moving from using ROS2 tools (CLI, RViz) to writing ROS2 code (Python nodes that interact with the navigation stack programmatically).
Why Write Your Own Nodes?
Clicking Goals in RViz
- One goal at a time
- Requires a human at the screen
- No conditional logic
- No reaction to events
- Great for testing
Sending Goals from Code
- Multiple waypoints in sequence
- Fully autonomous — no human needed
- Conditional: "if goal fails, try alternative"
- React to sensor data or events
- Required for real robots
Anatomy of a ROS2 Python Node
import rclpy
from rclpy.node import Node
class MyNode(Node):
def __init__(self):
super().__init__('my_node_name')
self.get_logger().info('Node started!')
def main(args=None):
rclpy.init(args=args) # Initialize ROS2
node = MyNode() # Create the node
rclpy.spin(node) # Keep it running
rclpy.shutdown() # Clean up
rclpy — the ROS2 Python client library
Node — base class that gives you publishers, subscribers, timers, etc.
spin() — event loop that processes callbacks
Communication: Topics Review
Topics are one-way data streams: publishers send, subscribers receive. No response expected.
# Publishing to a topic (e.g., velocity commands)
from geometry_msgs.msg import Twist
self.publisher = self.create_publisher(Twist, '/cmd_vel', 10)
msg = Twist()
msg.linear.x = 0.2 # Forward at 0.2 m/s
msg.angular.z = 0.0 # No rotation
self.publisher.publish(msg)
# Subscribing to a topic (e.g., lidar data)
from sensor_msgs.msg import LaserScan
self.subscription = self.create_subscription(
LaserScan, '/scan', self.scan_callback, 10)
def scan_callback(self, msg):
min_distance = min(msg.ranges)
self.get_logger().info(f'Closest obstacle: {min_distance:.2f}m')
Communication: Actions
An action is for long-running tasks with feedback. The client sends a goal; the server executes it and reports progress.
Your
Node
Goal
⟶
⟸
Feedback + Result
Nav2
Server
| Part | Direction | Purpose |
| Goal | Client → Server | "Navigate to position (2, 3)" |
| Feedback | Server → Client | "Currently at (1.5, 2.1), ETA 5s" |
| Result | Server → Client | "Reached goal" or "Failed" |
The NavigateToPose Action
Nav2 exposes a NavigateToPose action — the same thing RViz uses when you click a goal.
from rclpy.action import ActionClient
from nav2_msgs.action import NavigateToPose
from geometry_msgs.msg import PoseStamped
# Create the action client
self._client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
# Build a goal
goal = NavigateToPose.Goal()
goal.pose = PoseStamped()
goal.pose.header.frame_id = 'map'
goal.pose.pose.position.x = 2.0
goal.pose.pose.position.y = -1.0
# Send it
self._client.wait_for_server()
future = self._client.send_goal_async(goal)
Handling the Response (Callbacks)
Actions are asynchronous — you send the goal and get callbacks when things happen.
# When goal is accepted/rejected:
def goal_response_callback(self, future):
goal_handle = future.result()
if not goal_handle.accepted:
self.get_logger().warn('Goal rejected!')
return
self.get_logger().info('Goal accepted!')
# Now wait for the result
result_future = goal_handle.get_result_async()
result_future.add_done_callback(self.result_callback)
# When navigation completes:
def result_callback(self, future):
self.get_logger().info('Goal reached!')
# Send the next waypoint here...
Key pattern: Send goal → get acceptance → wait for result → send next goal. This is how you chain waypoints.
Pattern: Sequential Waypoints
class WaypointNavigator(Node):
def __init__(self):
super().__init__('waypoint_navigator')
self._client = ActionClient(
self, NavigateToPose, 'navigate_to_pose')
# Define your route
self.waypoints = [
(0.5, -1.0, 0.0), # (x, y, yaw)
(2.0, -1.0, 1.57),
(2.0, 0.5, 3.14),
(0.0, 0.0, 0.0), # Back to start
]
self.current = 0
def result_callback(self, future):
self.current += 1
if self.current < len(self.waypoints):
self.send_next_goal() # Keep going
else:
self.get_logger().info('Route complete!')
This is the pattern for P4 Part 3. The full starter code is in the project README.
Specifying Orientation
Goal poses include orientation as a quaternion. For 2D robots, we only care about yaw (rotation around the z-axis).
import math
yaw = 1.57 # radians (90 degrees)
# Convert yaw to quaternion:
orientation_z = math.sin(yaw / 2.0)
orientation_w = math.cos(yaw / 2.0)
goal.pose.pose.orientation.z = orientation_z
goal.pose.pose.orientation.w = orientation_w
| Yaw (radians) | Direction | Degrees |
| 0.0 | Facing +x (right) | 0° |
| 1.57 | Facing +y (up) | 90° |
| 3.14 | Facing -x (left) | 180° |
| -1.57 | Facing -y (down) | -90° |
How to Find Coordinates
Where should your waypoints be? Use RViz to find coordinates on your map.
- Hover method: Hover your mouse over the map in RViz — coordinates appear at the bottom of the RViz window
- Topic method: Click a "Nav2 Goal" and check the terminal for the goal coordinates
- Echo method:
ros2 topic echo /goal_pose to see goals as they're sent
Coordinate frame: All positions are in the map frame. The origin (0, 0) is defined in your map.yaml file. Positive x is forward, positive y is left.
Creating a ROS2 Python Package
# Create workspace and package
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python waypoint_nav
# Package structure:
# waypoint_nav/
# ├── package.xml
# ├── setup.py
# ├── setup.cfg
# └── waypoint_nav/
# ├── __init__.py
# └── navigate_waypoints.py ← your node
# Register node in setup.py → entry_points:
'console_scripts': [
'navigate_waypoints = waypoint_nav.navigate_waypoints:main',
]
# Build and run
cd ~/ros2_ws
colcon build --packages-select waypoint_nav
source install/setup.bash
ros2 run waypoint_nav navigate_waypoints
Common Pitfalls
✗ Mistakes
- Forgetting
wait_for_server()
- Wrong
frame_id (must be 'map')
- Waypoint inside an obstacle
- Not sourcing the workspace
- Forgetting to set initial pose first
✓ Best Practices
- Test waypoints in RViz first
- Always handle goal rejection
- Start with 2 waypoints, add more once working
- Source workspace after every
colcon build
- Use
self.get_logger() for debugging
Arduino vs. ROS2: A Comparison
You've already written robot code — in Arduino. How does ROS2 compare?
| Aspect | Arduino (P1) | ROS2 (P4) |
| Movement | analogWrite(motorPin, 200) | Publish to /cmd_vel or use Nav2 action |
| Sensing | digitalRead(sensorPin) | Subscribe to /scan |
| Control loop | void loop() | rclpy.spin() + callbacks |
| Abstraction | Direct hardware control | High-level goals ("go to (2,3)") |
| Reusability | Custom to your robot | Works on any ROS2 robot |
The progression: Arduino → PLC → FANUC → ROS2. Each step increases the level of abstraction and reusability.
Summary
| Concept | What It Does |
| rclpy Node | Python process that participates in the ROS2 graph |
| Topics | One-way data streams (publish/subscribe) |
| Actions | Long-running tasks with goal, feedback, and result |
| NavigateToPose | Nav2's action for sending navigation goals |
| Callbacks | Asynchronous handlers for when goals complete |
| colcon build | Build system for ROS2 packages |