Programmatic Waypoint Navigation¶
Send the car through multiple waypoints using a Python ROS 2 action client instead of clicking in the RViz2 GUI.
In the previous tutorials you sent goals by clicking in RViz2. That works for testing, but for autonomous racing you need repeatable routes that run without manual interaction. In this tutorial you will write a Python node that sends a list of waypoints to Nav2 programmatically.
What is an Action Client?¶
ROS 2 has three communication patterns:
Pattern |
Behavior |
Example |
|---|---|---|
Topic |
One-way publish/subscribe (fire-and-forget) |
|
Service |
Synchronous request → response (blocks until done) |
Setting a parameter |
Action |
Asynchronous goal → feedback → result (cancelable) |
Nav2 navigation |
Nav2 exposes its navigation capabilities as action servers. When you click 2D Goal Pose in RViz2, RViz2 is acting as an action client — it sends a goal to Nav2’s /navigate_to_pose action server. The /navigate_through_poses action server accepts a list of poses and drives through all of them in order.
In this tutorial you will write your own action client that does the same thing — but from code instead of the GUI.
Field |
Type |
Description |
|---|---|---|
Goal |
|
List of poses to navigate through |
Feedback |
|
Progress updates during navigation |
Result |
(empty) |
Returned on success or failure |
How to Get Waypoint Coordinates¶
Before writing the node, you need map coordinates for your waypoints. The easiest way is to use ros2 topic echo while clicking in RViz2:
With Nav2 running and the map visible in RViz2, open a terminal:
ros2 topic echo /goal_poseIn RViz2, click 2D Goal Pose and place it at a desired waypoint location.
The terminal prints a
PoseStampedmessage. Copy the values you need:header: frame_id: map pose: position: x: 1.234 # ← copy this y: 5.678 # ← copy this orientation: z: 0.707 # ← copy this w: 0.707 # ← copy thisRepeat for each waypoint (3–5 waypoints recommended).
Note
Orientation is a quaternion. For a 2D car, only z and w matter — just copy them directly from the echo output. You do not need to convert to/from degrees.
Steps¶
1️⃣ Create the Package¶
On the robot, navigate to your workspace and create a new package:
cd ~/f1tenth_ws/src
ros2 pkg create waypoint_nav --build-type ament_python --dependencies rclpy nav2_msgs geometry_msgs
This creates a waypoint_nav package with the necessary Nav2 and geometry message dependencies.
2️⃣ Create the Python Node¶
Open the node file:
code ~/f1tenth_ws/src/waypoint_nav/waypoint_nav/waypoint_nav_client.py
Paste the following code:
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from nav2_msgs.action import NavigateThroughPoses
from geometry_msgs.msg import PoseStamped
class WaypointNavClient(Node):
def __init__(self):
super().__init__('waypoint_nav_client')
self._client = ActionClient(
self, NavigateThroughPoses, 'navigate_through_poses')
def create_pose(self, x, y, oz=0.0, ow=1.0):
"""Create a PoseStamped in the map frame."""
pose = PoseStamped()
pose.header.frame_id = 'map'
pose.header.stamp = self.get_clock().now().to_msg()
pose.pose.position.x = x
pose.pose.position.y = y
pose.pose.orientation.z = oz
pose.pose.orientation.w = ow
return pose
def send_waypoints(self):
self.get_logger().info('Waiting for NavigateThroughPoses action server...')
self._client.wait_for_server()
self.get_logger().info('Action server available.')
goal = NavigateThroughPoses.Goal()
# ============================================================
# REPLACE THESE WITH YOUR OWN MAP COORDINATES
# Use "ros2 topic echo /goal_pose" to get coordinates
# ============================================================
goal.poses = [
self.create_pose(1.0, 0.5, 0.0, 1.0),
self.create_pose(2.0, 1.0, 0.707, 0.707),
self.create_pose(1.0, 2.0, 1.0, 0.0),
]
self.get_logger().info(f'Sending {len(goal.poses)} waypoints...')
future = self._client.send_goal_async(
goal, feedback_callback=self.feedback_callback)
future.add_done_callback(self.goal_response_callback)
def goal_response_callback(self, future):
goal_handle = future.result()
if not goal_handle.accepted:
self.get_logger().error('Goal rejected by Nav2')
return
self.get_logger().info('Goal accepted — navigating...')
result_future = goal_handle.get_result_async()
result_future.add_done_callback(self.result_callback)
def feedback_callback(self, feedback_msg):
current = feedback_msg.feedback.current_pose.pose.position
remaining = feedback_msg.feedback.number_of_poses_remaining
self.get_logger().info(
f'Poses remaining: {remaining} | '
f'Current position: ({current.x:.2f}, {current.y:.2f})')
def result_callback(self, future):
status = future.result().status
if status == 4:
self.get_logger().info('Navigation complete!')
else:
self.get_logger().warn(f'Navigation finished with status: {status}')
rclpy.shutdown()
def main():
rclpy.init()
node = WaypointNavClient()
node.send_waypoints()
rclpy.spin(node)
Important
The example coordinates above are placeholders. You must replace them with coordinates from your own map using the echo method described above.
3️⃣ Update setup.py¶
Open the package’s setup.py:
code ~/f1tenth_ws/src/waypoint_nav/setup.py
Add the node to the console_scripts entry point:
entry_points={
'console_scripts': [
'waypoint_nav_client = waypoint_nav.waypoint_nav_client:main',
],
},
4️⃣ Build and Source¶
cd ~/f1tenth_ws
colcon build --packages-select waypoint_nav
source install/setup.bash
5️⃣ Start Bringup (Terminal 1)¶
Make sure the PlayStation controller is connected to the car, then open a terminal on the robot and run:
bringup
This calls ros2 launch f1tenth_stack bringup_launch.py, which starts the car’s sensors and drivers.
Note
If Nav2 later reports Timed out waiting for transform from base_link to odom, the PlayStation controller is likely not connected. The VESC driver requires the joystick to fully initialize, and without it the odom frame is never published.
Warning
Hold R1 for autonomous mode. By default the joystick continuously publishes zero-speed commands at high priority, blocking Nav2. Hold R1 (button 5) on the PlayStation controller to enable autonomous mode — this lets Nav2’s drive commands through. Releasing R1 returns to manual joystick control.
Leave this terminal running.
6️⃣ Launch Nav2 (Terminal 2)¶
Open a new terminal and source the workspace:
cd ~/f1tenth_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
Launch the full Nav2 stack:
ros2 launch f1tenth_stack nav2_launch.py map_name:=hallway_map
Leave this terminal running.
7️⃣ Open RViz2 and Set Initial Pose (Terminal 3)¶
Open a new terminal on the robot and launch RViz2:
source /opt/ros/humble/setup.bash
rviz2
Add a Map display (Topic:
/map, Durability Policy:Transient Local)Add Path displays for
/planand/local_planto visualize the planned routeClick 2D Pose Estimate in the toolbar and set the car’s position and heading on the map
8️⃣ Run the Waypoint Navigation Client (Terminal 4)¶
Open a new terminal, source the workspace, and run the node:
cd ~/f1tenth_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 run waypoint_nav waypoint_nav_client
You should see output like:
[INFO] [waypoint_nav_client]: Waiting for NavigateThroughPoses action server...
[INFO] [waypoint_nav_client]: Action server available.
[INFO] [waypoint_nav_client]: Sending 3 waypoints...
[INFO] [waypoint_nav_client]: Goal accepted — navigating...
[INFO] [waypoint_nav_client]: Poses remaining: 2 | Current position: (0.85, 0.42)
[INFO] [waypoint_nav_client]: Poses remaining: 1 | Current position: (1.93, 0.98)
[INFO] [waypoint_nav_client]: Navigation complete!
Warning
Hold R1 on the PlayStation controller throughout the entire navigation run. Releasing R1 stops Nav2’s commands from reaching the wheels.
9️⃣ Watch the Car Navigate¶
The global planner computes a path through all waypoints
The controller follows the path in real time — watch the
/planand/local_plandisplays update in RViz2Unlike the RViz2 GUI waypoint method,
NavigateThroughPosesplans a continuous path through the poses without stopping at each oneThe node exits automatically when navigation completes
Note
If the car does not move after running the node, confirm that:
You set the initial pose with 2D Pose Estimate (step 7️⃣)
You are holding R1 on the PlayStation controller
Nav2 lifecycle nodes are all active (check
ros2 node list)The action server is available (check
ros2 action list— you should see/navigate_through_poses)Your waypoint coordinates are reachable on the map (not inside walls or outside the costmap)
Topics and Actions¶
Topic / Action |
Type |
Description |
|---|---|---|
|
|
Action server — accepts a list of poses and navigates through them |
|
|
Global path planned by Nav2 |
|
|
Local path the controller is currently following |
|
|
Velocity commands sent to the car by the controller |