From 1bdc635ba86b4cb2050bc2b6542afcc912b3ba4a Mon Sep 17 00:00:00 2001 From: Ryan Friedman Date: Thu, 19 Sep 2024 23:55:16 -0600 Subject: [PATCH] AP_DDS: Implement joystick support Signed-off-by: Ryan Friedman Co-Authored-by: Tiziano Fiorenzani --- .../test_joy_msg_received.py | 229 ++++++++++++++++++ libraries/AP_DDS/AP_DDS_Client.cpp | 23 +- libraries/AP_DDS/README.md | 34 ++- 3 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 Tools/ros2/ardupilot_dds_tests/test/ardupilot_dds_tests/test_joy_msg_received.py diff --git a/Tools/ros2/ardupilot_dds_tests/test/ardupilot_dds_tests/test_joy_msg_received.py b/Tools/ros2/ardupilot_dds_tests/test/ardupilot_dds_tests/test_joy_msg_received.py new file mode 100644 index 0000000000..9df6c37145 --- /dev/null +++ b/Tools/ros2/ardupilot_dds_tests/test/ardupilot_dds_tests/test_joy_msg_received.py @@ -0,0 +1,229 @@ +# Copyright 2023 ArduPilot.org. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Bring up ArduPilot SITL and check the Joy message is being published. + +Arms the copter and commands a throttle up. Check that the vehicle climbs. + +colcon test --packages-select ardupilot_dds_tests \ +--event-handlers=console_cohesion+ --pytest-args -k test_joy_msg_received + +""" + +import rclpy +import time +import launch_pytest +from rclpy.node import Node +from builtin_interfaces.msg import Time +from geographic_msgs.msg import GeoPoseStamped +from geometry_msgs.msg import TwistStamped +from geopy import point +from ardupilot_msgs.srv import ArmMotors +from ardupilot_msgs.srv import ModeSwitch +from sensor_msgs.msg import Joy +from scipy.spatial.transform import Rotation as R +import pytest +from launch_pytest.tools import process as process_tools +from launch import LaunchDescription +import threading + +GUIDED = 4 +LOITER = 5 + +FRAME_GLOBAL_INT = 5 + +# Hard code some known locations +# Note - Altitude in geopy is in km! +GRAYHOUND_TRACK = point.Point(latitude=-35.345996, longitude=149.159017, altitude=0.575) +CMAC = point.Point(latitude=-35.3627010, longitude=149.1651513, altitude=0.585) + + +class PlaneFbwbJoyControl(Node): + """Plane follow waypoints using guided control.""" + + def __init__(self): + """Initialise the node.""" + super().__init__("copter_joy_listener") + + self.arming_event_object = threading.Event() + self.mode_event_object = threading.Event() + self.climbing_event_object = threading.Event() + + self._client_arm = self.create_client(ArmMotors, "/ap/arm_motors") + + self._client_mode_switch = self.create_client(ModeSwitch, "/ap/mode_switch") + + self._joy_pub = self.create_publisher(Joy, "/ap/joy", 1) + + # Create subscriptions after services are up + qos = rclpy.qos.QoSProfile( + reliability=rclpy.qos.ReliabilityPolicy.BEST_EFFORT, durability=rclpy.qos.DurabilityPolicy.VOLATILE, depth=1 + ) + self._subscription_geopose = self.create_subscription(GeoPoseStamped, "/ap/geopose/filtered", self.geopose_cb, qos) + self._cur_geopose = GeoPoseStamped() + + self._subscription_twist = self.create_subscription(TwistStamped, "/ap/twist/filtered", self.twist_cb, qos) + self._climb_rate = 0.0 + + # Add a spin thread. + self.ros_spin_thread = threading.Thread(target=lambda node: rclpy.spin(node), args=(self,)) + self.ros_spin_thread.start() + + def geopose_cb(self, msg: GeoPoseStamped): + """Process a GeoPose message.""" + stamp = msg.header.stamp + if stamp.sec: + # Store current state + self._cur_geopose = msg + + def twist_cb(self, msg: TwistStamped): + """Process a Twist message.""" + linear = msg.twist.linear + self._climb_rate = linear.z + + def arm(self): + req = ArmMotors.Request() + req.arm = True + future = self._client_arm.call_async(req) + time.sleep(0.2) + try: + return future.result().result + except: + return False + + def arm_with_timeout(self, timeout: rclpy.duration.Duration): + """Try to arm. Returns true on success, or false if arming fails or times out.""" + armed = False + start = self.get_clock().now() + while not armed and self.get_clock().now() - start < timeout: + armed = self.arm() + time.sleep(1) + return armed + + def switch_mode(self, mode): + req = ModeSwitch.Request() + req.mode = mode + future = self._client_mode_switch.call_async(req) + time.sleep(1) + try: + return future.result().status and future.result().curr_mode == mode + except: + return False + + def switch_mode_with_timeout(self, desired_mode: int, timeout: rclpy.duration.Duration): + """Try to switch mode. Returns true on success, or false if mode switch fails or times out.""" + is_in_desired_mode = False + start = self.get_clock().now() + while not is_in_desired_mode: + is_in_desired_mode = self.switch_mode(desired_mode) + time.sleep(1) + + return is_in_desired_mode + + def get_cur_geopose(self): + """Return latest geopose.""" + return self._cur_geopose + + def send_joy_value(self, joy_val): + self._joy_pub.publish(joy_val) + + #-------------- PROCESSES ----------------- + def process_arm(self): + if self.arm_with_timeout(rclpy.duration.Duration(seconds=30)): + self.arming_event_object.set() + + def start_arm(self): + self.arm_thread = threading.Thread(target=self.process_arm) + self.arm_thread.start() + + def process_climb(self): + joy_msg = Joy() + joy_msg.axes.append(0.0) + joy_msg.axes.append(0.0) + joy_msg.axes.append(0.8) #- straight up + joy_msg.axes.append(0.0) + + while True: + self.send_joy_value(joy_msg) + self.arm() #- Keep the system armed (sometimes it disarms and the test fails) + time.sleep(0.1) + self.get_logger().info("From AP : Climb rate {}".format(self._climb_rate)) + if self._climb_rate > 0.5: + self.climbing_event_object.set() + return + + def climb(self): + self.climb_thread = threading.Thread(target=self.process_climb) + self.climb_thread.start() + + def arm_and_takeoff_process(self): + self.process_arm() + self.climb() + + def arm_and_takeoff(self): + self.climb_thread = threading.Thread(target=self.arm_and_takeoff_process) + self.climb_thread.start() + +@launch_pytest.fixture +def launch_sitl_copter_dds_serial(sitl_copter_dds_serial): + """Fixture to create the launch description.""" + sitl_ld, sitl_actions = sitl_copter_dds_serial + + ld = LaunchDescription( + [ + sitl_ld, + launch_pytest.actions.ReadyToTest(), + ] + ) + actions = sitl_actions + yield ld, actions + +@launch_pytest.fixture +def launch_sitl_copter_dds_udp(sitl_copter_dds_udp): + """Fixture to create the launch description.""" + sitl_ld, sitl_actions = sitl_copter_dds_udp + + ld = LaunchDescription( + [ + sitl_ld, + launch_pytest.actions.ReadyToTest(), + ] + ) + actions = sitl_actions + yield ld, actions + +@pytest.mark.launch(fixture=launch_sitl_copter_dds_udp) +def test_dds_udp_joy_msg_recv(launch_context, launch_sitl_copter_dds_udp): + """Test joy messages are published by AP_DDS.""" + _, actions = launch_sitl_copter_dds_udp + micro_ros_agent = actions["micro_ros_agent"].action + mavproxy = actions["mavproxy"].action + sitl = actions["sitl"].action + + # Wait for process to start. + process_tools.wait_for_start_sync(launch_context, micro_ros_agent, timeout=2) + process_tools.wait_for_start_sync(launch_context, mavproxy, timeout=2) + process_tools.wait_for_start_sync(launch_context, sitl, timeout=2) + + rclpy.init() + try: + node = PlaneFbwbJoyControl() + node.arm_and_takeoff() + climb_flag = node.climbing_event_object.wait(10) + assert climb_flag, "Could not climb" + finally: + rclpy.shutdown() + yield diff --git a/libraries/AP_DDS/AP_DDS_Client.cpp b/libraries/AP_DDS/AP_DDS_Client.cpp index a17d8b5231..d224a22551 100644 --- a/libraries/AP_DDS/AP_DDS_Client.cpp +++ b/libraries/AP_DDS/AP_DDS_Client.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -573,11 +574,23 @@ void AP_DDS_Client::on_topic(uxrSession* uxr_session, uxrObjectId object_id, uin } if (rx_joy_topic.axes_size >= 4) { - GCS_SEND_TEXT(MAV_SEVERITY_INFO, "%s Received sensor_msgs/Joy: %f, %f, %f, %f", - msg_prefix, rx_joy_topic.axes[0], rx_joy_topic.axes[1], rx_joy_topic.axes[2], rx_joy_topic.axes[3]); - // TODO implement joystick RC control to AP - } else { - GCS_SEND_TEXT(MAV_SEVERITY_INFO, "%s Received sensor_msgs/Joy. Axes size must be >= 4", msg_prefix); + const uint32_t t_now = AP_HAL::millis(); + + for (uint8_t i = 0; i < MIN(8U, rx_joy_topic.axes_size); i++) { + // Ignore channel override if NaN. + if (std::isnan(rx_joy_topic.axes[i])) { + // Setting the RC override to 0U releases the channel back to the RC. + RC_Channels::set_override(i, 0U, t_now); + } else { + const uint16_t mapped_data = static_cast( + linear_interpolate(rc().channel(i)->get_radio_min(), + rc().channel(i)->get_radio_max(), + rx_joy_topic.axes[i], + -1.0, 1.0)); + RC_Channels::set_override(i, mapped_data, t_now); + } + } + } break; } diff --git a/libraries/AP_DDS/README.md b/libraries/AP_DDS/README.md index 6d0c39e154..3b163a3948 100644 --- a/libraries/AP_DDS/README.md +++ b/libraries/AP_DDS/README.md @@ -76,6 +76,7 @@ Run the simulator with the following command. If using UDP, the only parameter y | DDS_ENABLE | Set to 1 to enable DDS, or 0 to disable | 1 | | SERIAL1_BAUD | The serial baud rate for DDS | 57 | | SERIAL1_PROTOCOL | Set this to 45 to use DDS on the serial port | 0 | + ```console # Wipe params till you see "AP: ArduPilot Ready" # Select your favorite vehicle type @@ -220,7 +221,7 @@ In order to consume the transforms, it's highly recommended to [create and run a ## Using ROS 2 services -The `AP_DDS` library exposes services which are automatically mapped to ROS 2 +The `AP_DDS` library exposes services which are automatically mapped to ROS 2 services using appropriate naming conventions for topics and message and service types. An earlier version of `AP_DDS` required the use of the eProsima [Integration Service](https://github.com/eProsima/Integration-Service) to map @@ -253,7 +254,32 @@ requester: making request: ardupilot_msgs.srv.ModeSwitch_Request(mode=4) response: ardupilot_msgs.srv.ModeSwitch_Response(status=True, curr_mode=4) ``` - + +## Commanding using ROS 2 Topics + +The following topic can be used to control the vehicle. + +- `/ap/joy` (type `sensor_msgs/msg/Joy`): overrides a maximum of 8 RC channels, +at least 4 axes must be sent. Values are clamped between -1.0 and 1.0. +Use `NaN` to disable the override of a single channel. +A channel defaults back to RC after 1 second of not receiving commands. + +```bash +ros2 topic pub /ap/joy sensor_msgs/msg/Joy "{axes: [0.0, 0.0, 0.0, 0.0]}" + +publisher: beginning loop +publishing #1: sensor_msgs.msg.Joy(header=std_msgs.msg.Header(stamp=builtin_interfaces.msg.Time(sec=0, nanosec=0), frame_id=''), axes=[0.0, 0.0, 0.0, 0.0], buttons=[]) +``` +- `/ap/cmd_gps_pose` (type `ardupilot_msgs/msg/GlobalPosition`): sends +a waypoint to head to when the selected mode is GUIDED. + +```bash +ros2 topic pub /ap/cmd_gps_pose ardupilot_msgs/msg/GlobalPosition "{latitude: 34, longitude: 118, altitude: 1000}" + +publisher: beginning loop +publishing #1: ardupilot_msgs.msg.GlobalPosition(header=std_msgs.msg.Header(stamp=builtin_interfaces.msg.Time(sec=0, nanosec=0), frame_id=''), coordinate_frame=0, type_mask=0, latitude=34.0, longitude=118.0, altitude=1000.0, velocity=geometry_msgs.msg.Twist(linear=geometry_msgs.msg.Vector3(x=0.0, y=0.0, z=0.0), angular=geometry_msgs.msg.Vector3(x=0.0, y=0.0, z=0.0)), acceleration_or_force=geometry_msgs.msg.Twist(linear=geometry_msgs.msg.Vector3(x=0.0, y=0.0, z=0.0), angular=geometry_msgs.msg.Vector3(x=0.0, y=0.0, z=0.0)), yaw=0.0) +``` + ## Contributing to `AP_DDS` library ### Adding DDS messages to Ardupilot @@ -296,7 +322,7 @@ topic and service tables. ROS 2 message and interface definitions are mangled by the `rosidl_adapter` when mapping from ROS 2 to DDS to avoid naming conflicts in the C/C++ libraries. The ROS 2 object `namespace::Struct` is mangled to `namespace::dds_::Struct_` -for DDS. The table below provides some example mappings: +for DDS. The table below provides some example mappings: | ROS 2 | DDS | | --- | --- | @@ -322,7 +348,7 @@ The request / response pair for services require an additional suffix. | parameter | rp/ | | | action | ra/ | | -The table below provides example mappings for topics and services +The table below provides example mappings for topics and services | ROS 2 | DDS | | --- | --- |