Writing ROS2 Nodes
Week 12 • CMPSC 304 Robotic Agents
Press Space or → to advance
Course Progression
SLAM
Build maps
→
Nav2
Click goals
→
Today
Write nodes
→
P4 Part 3
Nav goals
Today's plan: Learn to publish velocity commands directly (topics & publishers), then learn to send navigation goals programmatically (actions). Activity 8 practices direct velocity control; P4 Part 3 uses goal-based navigation.
Why Write Your Own Nodes?
Using ROS2 Tools
- CLI:
ros2 topic pub
- RViz: click goals one at a time
- Requires a human at the screen
- No conditional logic
- Great for testing & exploration
Writing ROS2 Code
- Publish commands on a timer
- Send multiple waypoints in sequence
- Fully autonomous — no human needed
- React to sensor data or events
- Required for real robots
Velocity commands (Activity 8) → goal-based navigation (P4 Part 3).
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 (like Arduino's loop())
Communication: Topics Review
Topics are one-way data streams: publishers send, subscribers receive. No response expected.
# Publishing velocity commands
from geometry_msgs.msg import Twist
self.publisher = self.create_publisher(Twist, '/turtle1/cmd_vel', 10)
msg = Twist()
msg.linear.x = 1.0 # Forward at 1.0 m/s
msg.angular.z = 0.0 # No rotation
self.publisher.publish(msg)
Subscribing is the mirror image: self.create_subscription(LaserScan, '/scan', callback, 10) — your callback fires whenever new data arrives.
Your First Node: A Velocity Publisher
Combine a publisher and a timer to send velocity commands repeatedly.
class DriveForward(Node):
def __init__(self):
super().__init__('drive_forward')
self.pub = self.create_publisher(
Twist, '/turtle1/cmd_vel', 10)
self.timer = self.create_timer(0.1, self.on_timer)
self.state = 'forward' # simple state machine
def on_timer(self):
msg = Twist()
if self.state == 'forward':
msg.linear.x = 1.0 # drive forward
elif self.state == 'turn':
msg.angular.z = 1.0 # rotate in place
self.pub.publish(msg)
Activity 8 uses this pattern — you'll run a node like this with TurtleSim and watch the turtle draw patterns on screen.
From Direct Control to Smart Navigation
Activity 8: Direct Control
- Publish
Twist to /cmd_vel
- "Go forward 1 m/s, turn 1 rad/s"
- No map, no sensors, no obstacles
- Robot hits walls and stops
- ROS2 pattern: topic (publisher)
P4 Part 3: Goal-Based Nav
- Send
NavigateToPose goal
- "Go to position (2.0, −1.0)"
- Uses map, costmaps, path planning
- Nav2 avoids obstacles & recovers
- ROS2 pattern: action (client)
The upgrade: Instead of telling the robot how to move, you tell it where to go. Nav2 figures out the rest. This requires a new ROS2 pattern: actions.
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 | Example |
| Goal | Client → Server | "Navigate to (2, 3)" |
| Feedback | Server → Client | "At (1.5, 2.1), ETA 5s" |
| Result | Server → Client | "Reached goal" / "Failed" |
Compare to topics: with direct velocity control, you publish and get nothing back. Actions give you feedback and a result.
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
self._client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
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
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.
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!')
result_future = goal_handle.get_result_async()
result_future.add_done_callback(self.result_callback)
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.
Pattern: Sequential Waypoints
class WaypointNavigator(Node):
def __init__(self):
super().__init__('waypoint_navigator')
self._client = ActionClient(
self, NavigateToPose, 'navigate_to_pose')
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()
else:
self.get_logger().info('Route complete!')
This is the pattern for P4 Part 3. Full starter code is in the project README.
Specifying Orientation
Goal poses include orientation as a quaternion. For 2D robots, only yaw matters.
import math
yaw = 1.57 # radians (90 degrees)
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 (rad) | 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 over the map in RViz — coordinates appear at the bottom
- Topic method: Click a "Nav2 Goal" and check the terminal output
- Echo method:
ros2 topic echo /goal_pose to see goals as they're sent
Coordinate frame: All positions are in the map frame. Origin (0, 0) is defined in your map.yaml.
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
# Structure: waypoint_nav/
# ├── package.xml, setup.py, setup.cfg
# └── waypoint_nav/
# ├── __init__.py
# └── navigate_waypoints.py ← your node
# Register 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
| Aspect | Arduino (P1) | ROS2 (P4) |
| Movement | analogWrite(motorPin, 200) | Publish to /cmd_vel or 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 abstraction and reusability.
Summary
| Concept | What It Does | Used In |
| rclpy Node | Python process in the ROS2 graph | Activity 8 & P4 |
| Publisher + Timer | Send velocity commands on a loop | Activity 8 |
| Topics | One-way data streams (pub/sub) | Both |
| Actions + Callbacks | Long-running goals with feedback | P4 Part 3 |
| NavigateToPose | Nav2 action for waypoint navigation | P4 Part 3 |