#!/usr/bin/env python3 """Sequential waypoint navigation for car_nav_lite. Publishes /goal_pose waypoints one by one. Waits for the car to reach each waypoint before publishing the next. Usage: python3 waypoint_nav.py # default waypoints python3 waypoint_nav.py --waypoints waypoints.json # from JSON file python3 waypoint_nav.py --loop # loop back to start JSON format: [{"x": 1.0, "y": -1.5, "yaw": 0.0}, ...] """ import argparse import json import math import time import rclpy from rclpy.node import Node from geometry_msgs.msg import PoseStamped from nav_msgs.msg import Odometry from std_msgs.msg import String # Default waypoints for 5m×5m arena (car spawns near -2.0, -2.3) DEFAULT_WAYPOINTS = [ {"x": -0.5, "y": -1.5, "yaw": 0.0, "desc": "mid-left"}, {"x": 1.0, "y": -1.5, "yaw": 0.0, "desc": "right"}, {"x": 1.5, "y": 0.0, "yaw": 0.0, "desc": "top-right"}, {"x": 0.0, "y": 1.5, "yaw": 0.0, "desc": "top-center"}, {"x": -1.5, "y": 1.0, "yaw": 0.0, "desc": "top-left"}, {"x": -1.5, "y": -0.5, "yaw": 0.0, "desc": "left"}, ] class WaypointNav(Node): def __init__(self, waypoints, loop=False, threshold=0.25): super().__init__("waypoint_nav") self.waypoints = waypoints self.loop = loop self.threshold = threshold self.idx = 0 self.reached = False self.fail_gen = 0 self.skip_gen = -1 self.skip_streak = 0 # consecutive skips self.pose_x = self.pose_y = self.pose_yaw = 0.0 from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE, durability=DurabilityPolicy.TRANSIENT_LOCAL) self.pub_goal = self.create_publisher(PoseStamped, "/goal_pose", qos) self.sub_odom = self.create_subscription( Odometry, "/odom", self.odom_cb, 10) self.sub_status = self.create_subscription( String, "/nav_status", self.status_cb, 10) self.timer = self.create_timer(0.2, self.check) # 5Hz def odom_cb(self, msg): self.pose_x = msg.pose.pose.position.x self.pose_y = msg.pose.pose.position.y q = msg.pose.pose.orientation self.pose_yaw = math.atan2(2*(q.w*q.z + q.x*q.y), 1 - 2*(q.y*q.y + q.z*q.z)) def status_cb(self, msg): if msg.data == "fail" and self.reached and self.fail_gen != self.skip_gen: self.skip_streak += 1 if self.skip_streak > 3: self.get_logger().error("⏭ 3+ consecutive skips — stopping") return self.get_logger().warn(f"⏭ WP {self.idx+1} plan failed — skip #{self.skip_streak}") self.skip_gen = self.fail_gen self.idx += 1 self.reached = False def send_goal(self, wp): msg = PoseStamped() msg.header.frame_id = "map" msg.header.stamp = self.get_clock().now().to_msg() msg.pose.position.x = float(wp["x"]) msg.pose.position.y = float(wp["y"]) yaw_deg = float(wp.get("yaw", 0.0)) yaw = math.radians(yaw_deg) # JSON stores degrees msg.pose.orientation.z = math.sin(yaw / 2.0) msg.pose.orientation.w = math.cos(yaw / 2.0) self.pub_goal.publish(msg) desc = wp.get("desc", "") self.get_logger().info( f"→ WP {self.idx+1}/{len(self.waypoints)}: " f"({wp['x']:.2f},{wp['y']:.2f}) {desc}" ) def check(self): if self.idx >= len(self.waypoints): if self.loop: self.idx = 0 self.reached = False else: return wp = self.waypoints[self.idx] if not self.reached: self.fail_gen += 1 # new generation for this wp self.send_goal(wp) self.reached = True dist = math.hypot(self.pose_x - wp["x"], self.pose_y - wp["y"]) if dist < self.threshold: self.get_logger().info( f"✓ WP {self.idx+1} reached (dist={dist:.2f}m)") self.idx += 1 self.reached = False self.skip_streak = 0 def main(): parser = argparse.ArgumentParser() parser.add_argument("--waypoints", type=str, default=None, help="JSON file with waypoints") parser.add_argument("--loop", action="store_true", help="Loop back to first waypoint after last") parser.add_argument("--threshold", type=float, default=0.3, help="Arrival distance threshold (m)") args = parser.parse_args() if args.waypoints: with open(args.waypoints) as f: waypoints = json.load(f) print(f"Loaded {len(waypoints)} waypoints from {args.waypoints}") else: waypoints = DEFAULT_WAYPOINTS print(f"Using {len(waypoints)} default waypoints") rclpy.init() node = WaypointNav(waypoints, args.loop, args.threshold) try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == "__main__": main()