AP_DDS: Implement joystick support

Signed-off-by: Ryan Friedman <ryan.friedman+github@avinc.com>
Co-Authored-by: Tiziano Fiorenzani
This commit is contained in:
Ryan Friedman 2024-09-19 23:55:16 -06:00 committed by Peter Barker
parent b6f20e3786
commit 1bdc635ba8
3 changed files with 277 additions and 9 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -5,6 +5,7 @@
#include <AP_GPS/AP_GPS.h>
#include <AP_HAL/AP_HAL.h>
#include <RC_Channel/RC_Channel.h>
#include <AP_RTC/AP_RTC.h>
#include <AP_Math/AP_Math.h>
#include <AP_InertialSensor/AP_InertialSensor.h>
@ -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<uint16_t>(
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;
}

View File

@ -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 |
| --- | --- |