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
PartDirectionPurpose
GoalClient → Server"Navigate to position (2, 3)"
FeedbackServer → Client"Currently at (1.5, 2.1), ETA 5s"
ResultServer → 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)DirectionDegrees
0.0Facing +x (right)
1.57Facing +y (up)90°
3.14Facing -x (left)180°
-1.57Facing -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?
AspectArduino (P1)ROS2 (P4)
MovementanalogWrite(motorPin, 200)Publish to /cmd_vel or use Nav2 action
SensingdigitalRead(sensorPin)Subscribe to /scan
Control loopvoid loop()rclpy.spin() + callbacks
AbstractionDirect hardware controlHigh-level goals ("go to (2,3)")
ReusabilityCustom to your robotWorks on any ROS2 robot
The progression: Arduino → PLC → FANUC → ROS2. Each step increases the level of abstraction and reusability.

Summary

ConceptWhat It Does
rclpy NodePython process that participates in the ROS2 graph
TopicsOne-way data streams (publish/subscribe)
ActionsLong-running tasks with goal, feedback, and result
NavigateToPoseNav2's action for sending navigation goals
CallbacksAsynchronous handlers for when goals complete
colcon buildBuild system for ROS2 packages
Now let's write a node! → Activity 8