已开启
接入AirSim仿真 #4
Noah创建于 4月3日
接入AirSim仿真 #4
已开启
共 23 个文件变更+1032-64
| @@ -0,0 +1,4 @@ | |||
| 1 | +[submodule "AirSim"] | ||
| 2 | + path = AirSim | ||
| 3 | + url = https://github.com/Air-Suck/AirSim.git | ||
| 4 | + branch = main | ||
| @@ -0,0 +1,145 @@ | |||
| 1 | +# OpenHarmony 无人机 AirSim 仿真后端使用说明 | ||
| 2 | + | ||
| 3 | +本文档说明如何在 **宿主机 ROS 2 环境** 中将 **Microsoft AirSim** 作为可选仿真后端,与 **Navigation2 / RViz** 及 **OpenHarmony 端侧** 协同运行。AirSim 以子模块形式集成于本仓库的 `AirSim/` 目录,并配合自定义 **桥接节点** 对齐原有 Gazebo 后端的 Topic / TF 约定。 | ||
| 4 | + | ||
| 5 | +> **前置阅读**:[快速开始](./QUICKSTART.md)(Docker、端侧 HDC、`simhost` / `simoh` 启动流程)。 | ||
| 6 | + | ||
| 7 | +## 功能概览 | ||
| 8 | + | ||
| 9 | +| 能力 | 说明 | | ||
| 10 | +|------|------| | ||
| 11 | +| 后端切换 | 通过 `demos/config/sim_backend.yaml` 在 `gazebo` 与 `airsim` 之间切换,无需改 launch 入口。 | | ||
| 12 | +| AirSim ROS 2 Wrapper | 使用上游 `airsim_ros_pkgs` 中的 `airsim_node`,订阅/发布 AirSim 标准话题。 | | ||
| 13 | +| Nav2 兼容桥接 | `demos` 包内 `airsim_bridge` 将 AirSim 的里程计、激光相关数据与控制接口转换为 Nav2 / `diffdrive_car` 期望的 `/odom`、`/scan`、`/cmd_vel` 与静态 TF。 | | ||
| 14 | +| Python RPC(可选) | 桥接节点可选连接 AirSim `MultirotorClient` 做高度保持等控制;未安装 `airsim` Python 包时回退为 VelCmd 方式。 | | ||
| 15 | + | ||
| 16 | +## 仓库内相关路径 | ||
| 17 | + | ||
| 18 | +``` | ||
| 19 | +software/host/ | ||
| 20 | +├── AirSim/ # git submodule,内含 ros2/airsim_ros_pkgs 等 | ||
| 21 | +├── demos/ | ||
| 22 | +│ ├── config/sim_backend.yaml # 仿真后端与 AirSim 参数 | ||
| 23 | +│ ├── launch/ | ||
| 24 | +│ │ ├── _airsim_sim.launch.py # AirSim 节点 + 桥接节点 | ||
| 25 | +│ │ ├── simhost.nav2.launch.py # 宿主机:根据配置选择 Gazebo 或 AirSim | ||
| 26 | +│ │ └── sim.nav2.launch.py # 完整 Nav2 流程(含 sim 后端) | ||
| 27 | +│ └── demos/airsim_bridge_node.py # 桥接实现 | ||
| 28 | +├── ros-demo.sh # 启动预置 Nav2 Demo 镜像的容器 | ||
| 29 | +└── ros-desktop.sh # GUI / 显示相关 Docker 参数 | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +初始化子模块(首次克隆或 PR 检出后): | ||
| 33 | + | ||
| 34 | +```bash | ||
| 35 | +git submodule update --init --recursive | ||
| 36 | +``` | ||
| 37 | + | ||
| 38 | +## 配置仿真后端 | ||
| 39 | + | ||
| 40 | +编辑 **安装后** 会被安装到 share 目录的配置源文件(开发时改工作区中的这一份即可,与包内路径一致): | ||
| 41 | + | ||
| 42 | +**文件**:`demos/config/sim_backend.yaml` | ||
| 43 | + | ||
| 44 | +```yaml | ||
| 45 | +# sim_backend: "gazebo" 或 "airsim" | ||
| 46 | +sim_backend: "airsim" | ||
| 47 | + | ||
| 48 | +airsim: | ||
| 49 | + host_ip: "172.30.224.1" # 运行 AirSim 的机器 IP(从 Docker 容器内必须可访问) | ||
| 50 | + publish_clock: true | ||
| 51 | + vehicle_name: "drone_1" | ||
| 52 | + target_altitude: 3.0 | ||
| 53 | + lidar_sensor_name: "LidarSensor1" | ||
| 54 | + update_airsim_img_response_every_n_sec: 0.05 | ||
| 55 | + update_airsim_control_every_n_sec: 0.01 | ||
| 56 | + update_lidar_every_n_sec: 0.01 | ||
| 57 | +``` | ||
| 58 | + | ||
| 59 | +- **`host_ip`**:AirSim 的 RPC 所在地址。宿主机 Docker 场景下常为 **Windows 主机在 WSL2/虚拟网桥下的地址**,或 Linux 上跑 AirSim 时用 `localhost` / 宿主机局域网 IP。若 `airsim_node` 无法连接,请用容器内 `ping` / `nc` 核对到该 IP 的连通性。 | ||
| 60 | +- **`vehicle_name` / `lidar_sensor_name`**:需与 AirSim 环境中无人机/Car 及传感器命名一致(默认多旋翼常见为 `drone_1` 等,以你的 `settings.json` 为准)。 | ||
| 61 | + | ||
| 62 | +桥接节点额外参数(可在 launch 或参数文件中扩展)包括 **`airsim_rpc_ip`**、**`airsim_rpc_port`**(默认 `41451`,与 AirSim Python/C++ 客户端默认端口一致,若你修改过 settings 请同步)。 | ||
| 63 | + | ||
| 64 | +## 宿主机运行流程(与 QUICKSTART 对齐) | ||
| 65 | + | ||
| 66 | +### 1. 启动 AirSim | ||
| 67 | + | ||
| 68 | +在 **安装并运行 AirSim 的机器**(常见为 Windows + Unreal)上启动仿真场景,并确保: | ||
| 69 | + | ||
| 70 | +- 已启用对外 **RPC**(默认端口与 AirSim 文档一致,一般为 **41451**)。 | ||
| 71 | +- 防火墙允许 **Docker 宿主机 / WSL** 所在网段访问该端口。 | ||
| 72 | + | ||
| 73 | +具体编译、场景与 `settings.json` 请参考子模块 [AirSim/README.md](./AirSim/README.md) 与 [官方文档](https://microsoft.github.io/AirSim/)。 | ||
| 74 | + | ||
| 75 | +### 2. 启动 ROS 2 容器并配置 `host_ip` | ||
| 76 | + | ||
| 77 | +```bash | ||
| 78 | +./ros-demo.sh | ||
| 79 | +# 进入容器后 | ||
| 80 | +cd /root/oh_robot_sim # 若与你本地挂载路径不同,以实际 workspace 为准 | ||
| 81 | +source ~/.bashrc_ros | ||
| 82 | +source install/setup.bash | ||
| 83 | +export ROS_DOMAIN_ID=0 | ||
| 84 | +export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp | ||
| 85 | +``` | ||
| 86 | + | ||
| 87 | +确认 `demos/config/sim_backend.yaml` 中 `sim_backend: airsim` 且 `airsim.host_ip` 指向 **从容器内** 能访问的 AirSim 机器地址。 | ||
| 88 | + | ||
| 89 | +### 3. 启动宿主机仿真 + Nav2 | ||
| 90 | + | ||
| 91 | +```bash | ||
| 92 | +ros2 launch demos simhost.nav2.launch.py | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +Launch 会加载 `_airsim_sim.launch.py`:先起 **`airsim_node`**,约 **5 秒** 后启动 **`airsim_bridge`**(延时用于等待 AirSim 侧就绪)。 | ||
| 96 | + | ||
| 97 | +### 4. (可选)OpenHarmony 端侧 | ||
| 98 | + | ||
| 99 | +在 OH 设备/模拟器上按 [QUICKSTART](./QUICKSTART.md) 完成 `hdc`、`ros2ohos.env` 与 `ors` 环境后: | ||
| 100 | + | ||
| 101 | +```bash | ||
| 102 | +ros2 launch demos simoh.nav2.launch.py | ||
| 103 | +``` | ||
| 104 | + | ||
| 105 | +保证 **`ROS_DOMAIN_ID`** 与宿主机一致,且网络可达,以便 DDS 发现。 | ||
| 106 | + | ||
| 107 | +## 桥接节点行为摘要 | ||
| 108 | + | ||
| 109 | +实现文件:`demos/demos/airsim_bridge_node.py`(入口名 `airsim_bridge`)。 | ||
| 110 | + | ||
| 111 | +面向 Nav2 与 Gazebo `diff_drive` 插件的约定,桥接主要完成: | ||
| 112 | + | ||
| 113 | +1. **`/cmd_vel`** → AirSim `VelCmd`(世界系)及可选 RPC 速度/高度控制。 | ||
| 114 | +2. **AirSim 本地 NED 里程计** → **`/odom`**(ENU 与 frame 修正)及 **`odom` → `base_footprint`** 动态 TF。 | ||
| 115 | +3. **AirSim 激光点云** → **`/scan`**(`LaserScan` + 坐标与高度滤波参数可配)。 | ||
| 116 | +4. **静态 TF**:`base_footprint` → `base_link` → `laser_link`。 | ||
| 117 | + | ||
| 118 | +依赖的 AirSim 侧话题前缀形如:`/airsim_node/<vehicle_name>/...`,与 `airsim_ros_pkgs` 默认一致。 | ||
| 119 | + | ||
| 120 | +**Python 依赖(可选)**:若在容器内使用 RPC 定高,可安装 `airsim` Python 包,或将 `AirSim/PythonClient` 加入 `PYTHONPATH`。未安装时节点会记录告警并回退到纯 ROS 控制路径。 | ||
| 121 | + | ||
| 122 | +## 常见问题 | ||
| 123 | + | ||
| 124 | +| 现象 | 排查建议 | | ||
| 125 | +|------|----------| | ||
| 126 | +| `airsim_node` 连接超时 | 检查 `host_ip`、防火墙、AirSim 是否已加载场景;在容器内测试到 RPC 端口的连通性。 | | ||
| 127 | +| `/odom` 或 `/scan` 无数据 | 确认 `vehicle_name`、`lidar_sensor_name` 与 AirSim 中一致;查看 `airsim_node` 是否已发布对应话题。 | | ||
| 128 | +| 桥接节点报 RPC 失败 | 安装 Python `airsim` 或核对 `airsim_rpc_ip`/`airsim_rpc_port`;或关闭 RPC 路径仅用 VelCmd。 | | ||
| 129 | +| Nav2 行为与地图不一致 | AirSim 场景与 2D 栅格地图(`assets/maps/...`)需语义上匹配;必要时更换 map 或仍使用 Gazebo 后端。 | | ||
| 130 | +| 仅宿主机调试、无 OH | 可只运行 `simhost.nav2.launch.py` + RViz,无需启动 `simoh`。 | | ||
| 131 | + | ||
| 132 | +## 参与贡献与 PR 说明 | ||
| 133 | + | ||
| 134 | +- 后端切换设计应 **保持 Gazebo 路径不受影响**:默认值仍为 `gazebo`,仅在配置为 `airsim` 时引入 AirSim 与桥接。 | ||
| 135 | +- 新增参数请同步 **`sim_backend.yaml` 示例** 与本文档。 | ||
| 136 | +- 子模块 `AirSim` 指向社区维护的 fork(见根目录 `.gitmodules`),升级子模块版本请在 PR 中说明变更原因与测试结果。 | ||
| 137 | + | ||
| 138 | +## 参考链接 | ||
| 139 | + | ||
| 140 | +- 主项目说明:[README.md](./README.md) | ||
| 141 | +- 快速开始:[QUICKSTART.md](./QUICKSTART.md) | ||
| 142 | +- AirSim 子模块说明:[AirSim/README.md](./AirSim/README.md) | ||
| 143 | +- AirSim ROS 2 包说明:[AirSim/ros2/src/airsim_ros_pkgs/README.md](./AirSim/ros2/src/airsim_ros_pkgs/README.md) | ||
| 144 | + | ||
| 145 | +PS: 本文档为AI生成,仅供参考。 | ||
| @@ -19,6 +19,8 @@ Robot Sim 是首个面向 OpenHarmony (EDU) 生态的具身智能机器人模拟 | |||
| 19 | 19 | ||
| 20 | - [不使用 OH 的开发环境?](./NO-OH-DEV.md); | 20 | - [不使用 OH 的开发环境?](./NO-OH-DEV.md); |
| 21 | 21 | ||
| 22 | +- [AirSim 仿真后端与 Nav2 桥接](./README-AIRSIM.md); | ||
| 23 | + | ||
| 22 | 24 | ||
| 23 | ## ✨ 核心特性 | 25 | ## ✨ 核心特性 |
| 24 | 26 | ||
| @@ -56,7 +58,7 @@ Robot Sim 是首个面向 OpenHarmony (EDU) 生态的具身智能机器人模拟 | |||
| 56 | | ------ | ------------------------------------------- | -------------------- | | 58 | | ------ | ------------------------------------------- | -------------------- | |
| 57 | | 交互层 | ArkTS 前端、端侧伺服遥控、Agent+MCP | 多模态指令输入、可视化交互、端侧协同控制 | | 59 | | 交互层 | ArkTS 前端、端侧伺服遥控、Agent+MCP | 多模态指令输入、可视化交互、端侧协同控制 | |
| 58 | | 控制层 | ROS2 Controllers、Robot State Nodes | 关节轨迹规划、状态采集发布、控制信号生成 | | 60 | | 控制层 | ROS2 Controllers、Robot State Nodes | 关节轨迹规划、状态采集发布、控制信号生成 | |
| 59 | -| 仿真层 | Gazebo/MuJoCo 仿真节点 | 物理仿真、机器人模型驱动、图像渲染 | | 61 | +| 仿真层 | Gazebo/MuJoCo 仿真节点;可选 [AirSim](./README-AIRSIM.md) 后端 | 物理仿真、机器人模型驱动、图像渲染;无人机 AirSim 与 ROS 2 Wrapper 桥接 | |
| 60 | | 数据与模型层 | LeRobot Data Collector、VLA Train\&Inference | 多源数据采集、模型训练、推理部署 | | 62 | | 数据与模型层 | LeRobot Data Collector、VLA Train\&Inference | 多源数据采集、模型训练、推理部署 | |
| 61 | 63 | ||
| 62 | ## 🛠️ 技术栈 | 64 | ## 🛠️ 技术栈 |
Binary files do not support preview
| @@ -0,0 +1,7 @@ | |||
| 1 | +image: blocks_map.pgm | ||
| 2 | +mode: trinary | ||
| 3 | +resolution: 0.05 | ||
| 4 | +origin: [-1.63, -66.5, 0] | ||
| 5 | +negate: 0 | ||
| 6 | +occupied_thresh: 0.65 | ||
| 7 | +free_thresh: 0.25 | ||
| @@ -2,6 +2,7 @@ from ament_index_python.packages import get_package_share_directory | |||
| 2 | 2 | ||
| 3 | import glob | 3 | import glob |
| 4 | import os | 4 | import os |
| 5 | +import yaml | ||
| 5 | 6 | ||
| 6 | # keep track with config.h | 7 | # keep track with config.h |
| 7 | EMERGENCY_STOP_MARK_SVC = "/emergency_stop" | 8 | EMERGENCY_STOP_MARK_SVC = "/emergency_stop" |
| @@ -86,3 +87,20 @@ def is_robot_support_mujoco(robot_name: str) -> bool: | |||
| 86 | return os.path.exists( | 87 | return os.path.exists( |
| 87 | get_robot_mujoco_resource_share_dir(robot_name)) | 88 | get_robot_mujoco_resource_share_dir(robot_name)) |
| 88 | 89 | ||
| 90 | + | ||
| 91 | +_SIM_BACKEND_CONFIG_FILENAME = 'sim_backend.yaml' | ||
| 92 | + | ||
| 93 | +def get_sim_backend_config() -> dict: | ||
| 94 | + """Read simulation backend configuration from the demos package. | ||
| 95 | + Returns a dict with at least {'sim_backend': 'gazebo'} as default. | ||
| 96 | + """ | ||
| 97 | + try: | ||
| 98 | + demos_share = get_package_share_directory('demos') | ||
| 99 | + except Exception: | ||
| 100 | + return {'sim_backend': 'gazebo'} | ||
| 101 | + config_path = os.path.join(demos_share, 'config', _SIM_BACKEND_CONFIG_FILENAME) | ||
| 102 | + if not os.path.isfile(config_path): | ||
| 103 | + return {'sim_backend': 'gazebo'} | ||
| 104 | + with open(config_path) as f: | ||
| 105 | + return yaml.safe_load(f) or {'sim_backend': 'gazebo'} | ||
| 106 | + | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | ||
| 2 | 2 | ||
| 3 | + | ||
| 3 | 4 | ||
| 4 | 5 | ||
| 5 | 6 | ||
| @@ -0,0 +1,51 @@ | |||
| 1 | + | ||
| 2 | + | ||
| 3 | +// Workaround for musl-based sysroots (e.g. OHOS) + libc++ + gRPC. | ||
| 4 | +// On glibc (normal Linux) this file is intentionally empty. | ||
| 5 | + | ||
| 6 | + | ||
| 7 | + | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +typedef decltype(nullptr) nullptr_t; | ||
| 11 | + | ||
| 12 | + | ||
| 13 | + | ||
| 14 | + | ||
| 15 | + | ||
| 16 | + | ||
| 17 | + | ||
| 18 | + | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + | ||
| 22 | + | ||
| 23 | + | ||
| 24 | + | ||
| 25 | + | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +inline int (fpclassify)(float __x) { return __fpclassifyf(__x); } | ||
| 32 | +inline int (fpclassify)(double __x) { return __fpclassify(__x); } | ||
| 33 | +inline int (fpclassify)(long double __x) { return __fpclassifyl(__x); } | ||
| 34 | + | ||
| 35 | +inline int (signbit)(float __x) { return __builtin_signbit(__x); } | ||
| 36 | +inline int (signbit)(double __x) { return __builtin_signbit(__x); } | ||
| 37 | +inline int (signbit)(long double __x) { return __builtin_signbit(__x); } | ||
| 38 | + | ||
| 39 | +inline int (isinf)(float __x) { return __builtin_isinf(__x); } | ||
| 40 | +inline int (isinf)(double __x) { return __builtin_isinf(__x); } | ||
| 41 | +inline int (isinf)(long double __x) { return __builtin_isinf(__x); } | ||
| 42 | + | ||
| 43 | +inline int (isnan)(float __x) { return __builtin_isnan(__x); } | ||
| 44 | +inline int (isnan)(double __x) { return __builtin_isnan(__x); } | ||
| 45 | +inline int (isnan)(long double __x) { return __builtin_isnan(__x); } | ||
| 46 | + | ||
| 47 | +inline int (isfinite)(float __x) { return __builtin_isfinite(__x); } | ||
| 48 | +inline int (isfinite)(double __x) { return __builtin_isfinite(__x); } | ||
| 49 | +inline int (isfinite)(long double __x) { return __builtin_isfinite(__x); } | ||
| 50 | + | ||
| 51 | + | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | ||
| 2 | 2 | ||
| 3 | + | ||
| 3 | 4 | ||
| 4 | 5 | ||
| 5 | 6 | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | ||
| 2 | 2 | ||
| 3 | + | ||
| 3 | 4 | ||
| 4 | 5 | ||
| 5 | 6 | ||
| @@ -284,7 +284,7 @@ grpc::Status CoreServiceRos2Impl::GetEndEffectorState( | |||
| 284 | const std::string &eelink = move_group_->getEndEffectorLink(); | 284 | const std::string &eelink = move_group_->getEndEffectorLink(); |
| 285 | if (eelink.empty()) { | 285 | if (eelink.empty()) { |
| 286 | std::string errmsg = "there is no end effector link defined for move group '" + move_group_->getName() + "'"; | 286 | std::string errmsg = "there is no end effector link defined for move group '" + move_group_->getName() + "'"; |
| 287 | - RCLCPP_WARN(node_->get_logger(), errmsg.c_str()); | 287 | + RCLCPP_WARN(node_->get_logger(), "%s", errmsg.c_str()); |
| 288 | 288 | ||
| 289 | return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, errmsg); | 289 | return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, errmsg); |
| 290 | } else try { | 290 | } else try { |
| @@ -310,12 +310,12 @@ grpc::Status CoreServiceRos2Impl::GetEndEffectorState( | |||
| 310 | orientation->set_w(quat.w()); | 310 | orientation->set_w(quat.w()); |
| 311 | } catch (const std::exception &ex) { | 311 | } catch (const std::exception &ex) { |
| 312 | std::string err = std::string("failed to get end effector state: ") + ex.what(); | 312 | std::string err = std::string("failed to get end effector state: ") + ex.what(); |
| 313 | - RCLCPP_ERROR(node_->get_logger(), err.c_str()); | 313 | + RCLCPP_ERROR(node_->get_logger(), "%s", err.c_str()); |
| 314 | return grpc::Status(grpc::StatusCode::INTERNAL, err); | 314 | return grpc::Status(grpc::StatusCode::INTERNAL, err); |
| 315 | } | 315 | } |
| 316 | } else { | 316 | } else { |
| 317 | std::string err = "failed to current robot state for move group '" + move_group_->getName() + "': maybe it doesn't exist?"; | 317 | std::string err = "failed to current robot state for move group '" + move_group_->getName() + "': maybe it doesn't exist?"; |
| 318 | - RCLCPP_ERROR(node_->get_logger(), err.c_str()); | 318 | + RCLCPP_ERROR(node_->get_logger(), "%s", err.c_str()); |
| 319 | return grpc::Status(grpc::StatusCode::UNKNOWN, err); | 319 | return grpc::Status(grpc::StatusCode::UNKNOWN, err); |
| 320 | } | 320 | } |
| 321 | 321 | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | 1 | ||
| 2 | + | ||
| 2 | 3 | ||
| 3 | 4 | ||
| 4 | 5 | ||
| @@ -0,0 +1,188 @@ | |||
| 1 | +Panels: | ||
| 2 | + - Class: rviz_common/Displays | ||
| 3 | + Help Height: 0 | ||
| 4 | + Name: Displays | ||
| 5 | + Property Tree Widget: | ||
| 6 | + Expanded: | ||
| 7 | + - /Global Options1 | ||
| 8 | + - /TF1/Frames1 | ||
| 9 | + Splitter Ratio: 0.5 | ||
| 10 | + Tree Height: 600 | ||
| 11 | + - Class: rviz_common/Selection | ||
| 12 | + Name: Selection | ||
| 13 | + - Class: rviz_common/Tool Properties | ||
| 14 | + Expanded: ~ | ||
| 15 | + Name: Tool Properties | ||
| 16 | + Splitter Ratio: 0.5886790156364441 | ||
| 17 | + - Class: rviz_common/Views | ||
| 18 | + Expanded: | ||
| 19 | + - /Current View1 | ||
| 20 | + Name: Views | ||
| 21 | + Splitter Ratio: 0.5 | ||
| 22 | + - Class: rviz_common/Time | ||
| 23 | + Experimental: false | ||
| 24 | + Name: Time | ||
| 25 | + SyncMode: 0 | ||
| 26 | + SyncSource: LaserScan | ||
| 27 | +Visualization Manager: | ||
| 28 | + Class: "" | ||
| 29 | + Displays: | ||
| 30 | + - Alpha: 0.5 | ||
| 31 | + Cell Size: 1 | ||
| 32 | + Class: rviz_default_plugins/Grid | ||
| 33 | + Color: 160; 160; 164 | ||
| 34 | + Enabled: true | ||
| 35 | + Line Style: | ||
| 36 | + Line Width: 0.029999999329447746 | ||
| 37 | + Value: Lines | ||
| 38 | + Name: Grid | ||
| 39 | + Normal Cell Count: 0 | ||
| 40 | + Offset: | ||
| 41 | + X: 0 | ||
| 42 | + Y: 0 | ||
| 43 | + Z: 0 | ||
| 44 | + Plane: XY | ||
| 45 | + Plane Cell Count: 20 | ||
| 46 | + Reference Frame: <Fixed Frame> | ||
| 47 | + Value: true | ||
| 48 | + - Class: rviz_default_plugins/TF | ||
| 49 | + Enabled: true | ||
| 50 | + Frame Timeout: 15 | ||
| 51 | + Frames: | ||
| 52 | + All Enabled: true | ||
| 53 | + Marker Scale: 1 | ||
| 54 | + Name: TF | ||
| 55 | + Show Arrows: true | ||
| 56 | + Show Axes: true | ||
| 57 | + Show Names: false | ||
| 58 | + Tree: | ||
| 59 | + {} | ||
| 60 | + Update Interval: 0 | ||
| 61 | + Value: true | ||
| 62 | + - Alpha: 1 | ||
| 63 | + Class: rviz_default_plugins/Map | ||
| 64 | + Color Scheme: map | ||
| 65 | + Draw Behind: true | ||
| 66 | + Enabled: true | ||
| 67 | + Name: Map | ||
| 68 | + Topic: | ||
| 69 | + Depth: 1 | ||
| 70 | + Durability Policy: Transient Local | ||
| 71 | + History Policy: Keep Last | ||
| 72 | + Reliability Policy: Reliable | ||
| 73 | + Value: /map | ||
| 74 | + Use Timestamp: false | ||
| 75 | + Value: true | ||
| 76 | + - Alpha: 1 | ||
| 77 | + Autocompute Intensity Bounds: true | ||
| 78 | + Autocompute Value Bounds: | ||
| 79 | + Max Value: 10 | ||
| 80 | + Min Value: -10 | ||
| 81 | + Value: true | ||
| 82 | + Axis: Z | ||
| 83 | + Channel Name: intensity | ||
| 84 | + Class: rviz_default_plugins/LaserScan | ||
| 85 | + Color: 255; 255; 255 | ||
| 86 | + Color Transformer: Intensity | ||
| 87 | + Decay Time: 0 | ||
| 88 | + Enabled: true | ||
| 89 | + Invert Rainbow: false | ||
| 90 | + Max Color: 255; 255; 255 | ||
| 91 | + Max Intensity: 0 | ||
| 92 | + Min Color: 0; 0; 0 | ||
| 93 | + Min Intensity: 0 | ||
| 94 | + Name: LaserScan | ||
| 95 | + Position Transformer: XYZ | ||
| 96 | + Selectable: true | ||
| 97 | + Size (Pixels): 3 | ||
| 98 | + Size (m): 0.029999999329447746 | ||
| 99 | + Style: Points | ||
| 100 | + Topic: | ||
| 101 | + Depth: 5 | ||
| 102 | + Durability Policy: Volatile | ||
| 103 | + Filter size: 10 | ||
| 104 | + History Policy: Keep Last | ||
| 105 | + Reliability Policy: Best Effort | ||
| 106 | + Value: /scan | ||
| 107 | + Use Fixed Frame: true | ||
| 108 | + Use rainbow: true | ||
| 109 | + Value: true | ||
| 110 | + Enabled: true | ||
| 111 | + Global Options: | ||
| 112 | + Background Color: 48; 48; 48 | ||
| 113 | + Fixed Frame: map | ||
| 114 | + Frame Rate: 30 | ||
| 115 | + Name: root | ||
| 116 | + Tools: | ||
| 117 | + - Class: rviz_default_plugins/Interact | ||
| 118 | + Hide Inactive Objects: true | ||
| 119 | + - Class: rviz_default_plugins/MoveCamera | ||
| 120 | + - Class: rviz_default_plugins/Select | ||
| 121 | + - Class: rviz_default_plugins/FocusCamera | ||
| 122 | + - Class: rviz_default_plugins/Measure | ||
| 123 | + Line color: 128; 128; 0 | ||
| 124 | + - Class: rviz_default_plugins/SetInitialPose | ||
| 125 | + Covariance x: 0.25 | ||
| 126 | + Covariance y: 0.25 | ||
| 127 | + Covariance yaw: 0.06853891909122467 | ||
| 128 | + Topic: | ||
| 129 | + Depth: 5 | ||
| 130 | + Durability Policy: Volatile | ||
| 131 | + History Policy: Keep Last | ||
| 132 | + Reliability Policy: Reliable | ||
| 133 | + Value: /initialpose | ||
| 134 | + - Class: rviz_default_plugins/SetGoal | ||
| 135 | + Topic: | ||
| 136 | + Depth: 5 | ||
| 137 | + Durability Policy: Volatile | ||
| 138 | + History Policy: Keep Last | ||
| 139 | + Reliability Policy: Reliable | ||
| 140 | + Value: /goal_pose | ||
| 141 | + - Class: rviz_default_plugins/PublishPoint | ||
| 142 | + Single click: true | ||
| 143 | + Topic: | ||
| 144 | + Depth: 5 | ||
| 145 | + Durability Policy: Volatile | ||
| 146 | + History Policy: Keep Last | ||
| 147 | + Reliability Policy: Reliable | ||
| 148 | + Value: /clicked_point | ||
| 149 | + Transformation: | ||
| 150 | + Current: | ||
| 151 | + Class: rviz_default_plugins/TF | ||
| 152 | + Value: true | ||
| 153 | + Views: | ||
| 154 | + Current: | ||
| 155 | + Angle: -1.5708 | ||
| 156 | + Class: rviz_default_plugins/TopDownOrtho | ||
| 157 | + Enable Stereo Rendering: | ||
| 158 | + Stereo Eye Separation: 0.05999999865889549 | ||
| 159 | + Stereo Focal Distance: 1 | ||
| 160 | + Swap Stereo Eyes: false | ||
| 161 | + Value: false | ||
| 162 | + Invert Z Axis: false | ||
| 163 | + Name: Current View | ||
| 164 | + Near Clip Distance: 0.009999999776482582 | ||
| 165 | + Scale: 150 | ||
| 166 | + Target Frame: <Fixed Frame> | ||
| 167 | + Value: TopDownOrtho (rviz_default_plugins) | ||
| 168 | + X: 0 | ||
| 169 | + Y: 0 | ||
| 170 | + Saved: ~ | ||
| 171 | +Window Geometry: | ||
| 172 | + Displays: | ||
| 173 | + collapsed: false | ||
| 174 | + Height: 900 | ||
| 175 | + Hide Left Dock: false | ||
| 176 | + Hide Right Dock: false | ||
| 177 | + QMainWindow State: 000000ff00000000fd00000004000000000000016a0000034afc0200000002fb000000100044006900730070006c006100790073010000003d0000029b000000c900fffffffb0000000a0056006900650077007301000002de000000a90000008100ffffff000000010000010f0000034afc0200000000 | ||
| 178 | + Selection: | ||
| 179 | + collapsed: false | ||
| 180 | + Time: | ||
| 181 | + collapsed: false | ||
| 182 | + Tool Properties: | ||
| 183 | + collapsed: false | ||
| 184 | + Views: | ||
| 185 | + collapsed: false | ||
| 186 | + Width: 1500 | ||
| 187 | + X: 200 | ||
| 188 | + Y: 100 | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Simulation backend configuration. | ||
| 2 | +# sim_backend: "gazebo" or "airsim" | ||
| 3 | +sim_backend: "airsim" | ||
| 4 | + | ||
| 5 | +# AirSim-specific settings (only used when sim_backend is "airsim"). | ||
| 6 | +airsim: | ||
| 7 | + host_ip: "172.30.224.1" | ||
| 8 | + publish_clock: true | ||
| 9 | + vehicle_name: "drone_1" | ||
| 10 | + target_altitude: 3.0 | ||
| 11 | + update_airsim_img_response_every_n_sec: 0.05 | ||
| 12 | + update_airsim_control_every_n_sec: 0.01 | ||
| 13 | + update_lidar_every_n_sec: 0.01 | ||
Binary files do not support preview
| @@ -0,0 +1,443 @@ | |||
| 1 | +""" | ||
| 2 | +AirSim ↔ Nav2/RViz bridge node. | ||
| 3 | + | ||
| 4 | +Makes AirSim ROS2 wrapper appear identical to the Gazebo backend by providing | ||
| 5 | +the same topics, message types, and TF frames that Nav2 and RViz expect. | ||
| 6 | + | ||
| 7 | +Gazebo provided: | ||
| 8 | + PUB /odom (nav_msgs/Odometry) ← diff_drive plugin | ||
| 9 | + PUB /scan (sensor_msgs/LaserScan) ← ray sensor plugin | ||
| 10 | + PUB /tf odom → base_footprint ← diff_drive plugin | ||
| 11 | + PUB /tf_static base_footprint → base_link → laser_link ← URDF / robot_state_publisher | ||
| 12 | + PUB /clock (rosgraph_msgs/Clock) ← Gazebo core | ||
| 13 | + SUB /cmd_vel (geometry_msgs/Twist) ← diff_drive plugin | ||
| 14 | + | ||
| 15 | +AirSim provides: | ||
| 16 | + PUB ~/drone_1/odom_local_ned (nav_msgs/Odometry) — NED frame | ||
| 17 | + PUB ~/drone_1/lidar/<name> (sensor_msgs/PointCloud2) — NED frame | ||
| 18 | + PUB ~/clock (rosgraph_msgs/Clock) — remapped in launch | ||
| 19 | + PUB TF: world_ned → drone_1 | ||
| 20 | + SUB ~/drone_1/vel_cmd_world_frame (airsim_interfaces/VelCmd) | ||
| 21 | + | ||
| 22 | +This bridge handles: | ||
| 23 | + 1. /cmd_vel → VelCmd and/or AirSim RPC (moveByVelocityZ = fixed NED height while moving) | ||
| 24 | + 2. AirSim odom → /odom (NED→ENU + frame ID fix) | ||
| 25 | + 3. AirSim odom → /tf odom→base_footprint (NED→ENU + frame ID fix) | ||
| 26 | + 4. AirSim PointCloud2 → /scan (PointCloud2→LaserScan + NED→ENU) | ||
| 27 | + 5. Static TF: base_footprint → base_link → laser_link | ||
| 28 | +""" | ||
| 29 | + | ||
| 30 | +import math | ||
| 31 | +import struct | ||
| 32 | + | ||
| 33 | +import numpy as np | ||
| 34 | + | ||
| 35 | +try: | ||
| 36 | + import airsim | ||
| 37 | + from airsim.types import DrivetrainType, YawMode | ||
| 38 | +except ImportError: | ||
| 39 | + airsim = None | ||
| 40 | + DrivetrainType = None | ||
| 41 | + YawMode = None | ||
| 42 | + | ||
| 43 | +import rclpy | ||
| 44 | +from rclpy.node import Node | ||
| 45 | +from rclpy.qos import QoSProfile, ReliabilityPolicy | ||
| 46 | + | ||
| 47 | +from geometry_msgs.msg import Twist, TransformStamped | ||
| 48 | +from nav_msgs.msg import Odometry | ||
| 49 | +from sensor_msgs.msg import LaserScan, PointCloud2 | ||
| 50 | +from airsim_interfaces.msg import VelCmd | ||
| 51 | +from airsim_interfaces.srv import Takeoff, Land | ||
| 52 | + | ||
| 53 | +from tf2_ros import TransformBroadcaster, StaticTransformBroadcaster | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +class AirsimBridgeNode(Node): | ||
| 57 | + | ||
| 58 | + def __init__(self): | ||
| 59 | + super().__init__('airsim_cmd_vel_bridge') | ||
| 60 | + | ||
| 61 | + # ── parameters ────────────────────────────────────────────── | ||
| 62 | + self.declare_parameter('vehicle_name', 'drone_1') | ||
| 63 | + self.declare_parameter('target_altitude', 3.0) | ||
| 64 | + self.declare_parameter('altitude_kp', 2.0) | ||
| 65 | + self.declare_parameter('max_vz', 2.0) | ||
| 66 | + self.declare_parameter('latch_altitude_after_takeoff', False) | ||
| 67 | + self.declare_parameter('use_airsim_velocity_z_hold', True) | ||
| 68 | + self.declare_parameter('move_to_z_velocity', 2.0) | ||
| 69 | + self.declare_parameter('vel_cmd_duration_sec', 0.05) | ||
| 70 | + self.declare_parameter('airsim_rpc_ip', '172.30.224.1') | ||
| 71 | + self.declare_parameter('airsim_rpc_port', 41451) | ||
| 72 | + self.declare_parameter('cmd_vel_timeout', 0.5) | ||
| 73 | + self.declare_parameter('lidar_sensor_name', 'LidarSensor1') | ||
| 74 | + self.declare_parameter('scan_range_min', 0.12) | ||
| 75 | + self.declare_parameter('scan_range_max', 50.0) | ||
| 76 | + self.declare_parameter('scan_height_min', -1.0) | ||
| 77 | + self.declare_parameter('scan_height_max', 1.0) | ||
| 78 | + self.declare_parameter('scan_samples', 360) | ||
| 79 | + | ||
| 80 | + self.vehicle = self.get_parameter('vehicle_name').value | ||
| 81 | + self.target_alt = self.get_parameter('target_altitude').value | ||
| 82 | + self.alt_kp = self.get_parameter('altitude_kp').value | ||
| 83 | + self.max_vz = self.get_parameter('max_vz').value | ||
| 84 | + self.cmd_vel_timeout = self.get_parameter('cmd_vel_timeout').value | ||
| 85 | + self.lidar_name = self.get_parameter('lidar_sensor_name').value | ||
| 86 | + self.scan_range_min = self.get_parameter('scan_range_min').value | ||
| 87 | + self.scan_range_max = self.get_parameter('scan_range_max').value | ||
| 88 | + self.scan_height_min = self.get_parameter('scan_height_min').value | ||
| 89 | + self.scan_height_max = self.get_parameter('scan_height_max').value | ||
| 90 | + self.scan_samples = self.get_parameter('scan_samples').value | ||
| 91 | + | ||
| 92 | + self._latch_alt = self.get_parameter( | ||
| 93 | + 'latch_altitude_after_takeoff').value | ||
| 94 | + self._use_vel_z = self.get_parameter( | ||
| 95 | + 'use_airsim_velocity_z_hold').value | ||
| 96 | + self._move_to_z_v = float( | ||
| 97 | + self.get_parameter('move_to_z_velocity').value) | ||
| 98 | + self._vel_dur = float(self.get_parameter('vel_cmd_duration_sec').value) | ||
| 99 | + rpc_ip = self.get_parameter('airsim_rpc_ip').value | ||
| 100 | + rpc_port = int(self.get_parameter('airsim_rpc_port').value) | ||
| 101 | + | ||
| 102 | + self._alt_setpoint = float(self.target_alt) | ||
| 103 | + self._alt_latched = False | ||
| 104 | + self._airsim_client = None | ||
| 105 | + if self._use_vel_z: | ||
| 106 | + if airsim is None: | ||
| 107 | + self.get_logger().warn( | ||
| 108 | + 'airsim Python 未安装,改用 VelCmd+P 定高。' | ||
| 109 | + '可 pip install airsim 或把 AirSim/PythonClient 加入 PYTHONPATH。') | ||
| 110 | + self._use_vel_z = False | ||
| 111 | + else: | ||
| 112 | + try: | ||
| 113 | + self._airsim_client = airsim.MultirotorClient( | ||
| 114 | + ip=rpc_ip, port=rpc_port) | ||
| 115 | + self._airsim_client.confirmConnection() | ||
| 116 | + except Exception as e: | ||
| 117 | + self.get_logger().error( | ||
| 118 | + f'连接 AirSim RPC 失败 ({e}),改用 VelCmd+P 定高。') | ||
| 119 | + self._airsim_client = None | ||
| 120 | + self._use_vel_z = False | ||
| 121 | + | ||
| 122 | + prefix = f'/airsim_node/{self.vehicle}' | ||
| 123 | + | ||
| 124 | + # ── publishers ────────────────────────────────────────────── | ||
| 125 | + self.vel_pub = self.create_publisher( | ||
| 126 | + VelCmd, f'{prefix}/vel_cmd_world_frame', 10) | ||
| 127 | + self.odom_pub = self.create_publisher( | ||
| 128 | + Odometry, '/odom', 10) | ||
| 129 | + self.scan_pub = self.create_publisher( | ||
| 130 | + LaserScan, '/scan', 10) | ||
| 131 | + | ||
| 132 | + # ── subscribers ───────────────────────────────────────────── | ||
| 133 | + self.cmd_vel_sub = self.create_subscription( | ||
| 134 | + Twist, '/cmd_vel', self._cmd_vel_cb, | ||
| 135 | + QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE)) | ||
| 136 | + | ||
| 137 | + self.odom_sub = self.create_subscription( | ||
| 138 | + Odometry, f'{prefix}/odom_local_ned', self._odom_cb, | ||
| 139 | + QoSProfile(depth=10, reliability=ReliabilityPolicy.BEST_EFFORT)) | ||
| 140 | + | ||
| 141 | + self.lidar_sub = self.create_subscription( | ||
| 142 | + PointCloud2, f'{prefix}/lidar/{self.lidar_name}', self._lidar_cb, | ||
| 143 | + QoSProfile(depth=5, reliability=ReliabilityPolicy.BEST_EFFORT)) | ||
| 144 | + | ||
| 145 | + # ── TF broadcasters ──────────────────────────────────────── | ||
| 146 | + self.tf_broadcaster = TransformBroadcaster(self) | ||
| 147 | + self.static_tf_broadcaster = StaticTransformBroadcaster(self) | ||
| 148 | + self._publish_static_tfs() | ||
| 149 | + | ||
| 150 | + # ── service clients ───────────────────────────────────────── | ||
| 151 | + self.takeoff_cli = self.create_client(Takeoff, f'{prefix}/takeoff') | ||
| 152 | + self.land_cli = self.create_client(Land, f'{prefix}/land') | ||
| 153 | + | ||
| 154 | + # ── state ─────────────────────────────────────────────────── | ||
| 155 | + self.current_alt = 0.0 | ||
| 156 | + self.current_yaw = 0.0 | ||
| 157 | + self.last_cmd_vel = Twist() | ||
| 158 | + self.last_cmd_stamp = self.get_clock().now() | ||
| 159 | + self.flying = False | ||
| 160 | + | ||
| 161 | + self._do_takeoff() | ||
| 162 | + | ||
| 163 | + self.timer = self.create_timer(0.05, self._control_loop) | ||
| 164 | + self.get_logger().info( | ||
| 165 | + f'Bridge started: vehicle={self.vehicle}, ' | ||
| 166 | + f'target_alt={self.target_alt}m, lidar={self.lidar_name}') | ||
| 167 | + | ||
| 168 | + # ================================================================ | ||
| 169 | + # Static TFs — replaces robot_state_publisher's URDF-derived TFs | ||
| 170 | + # ================================================================ | ||
| 171 | + | ||
| 172 | + def _publish_static_tfs(self): | ||
| 173 | + stamp = self.get_clock().now().to_msg() | ||
| 174 | + static_tfs = [] | ||
| 175 | + | ||
| 176 | + # base_footprint → base_link (identity, same as typical URDF) | ||
| 177 | + t1 = TransformStamped() | ||
| 178 | + t1.header.stamp = stamp | ||
| 179 | + t1.header.frame_id = 'base_footprint' | ||
| 180 | + t1.child_frame_id = 'base_link' | ||
| 181 | + t1.transform.rotation.w = 1.0 | ||
| 182 | + static_tfs.append(t1) | ||
| 183 | + | ||
| 184 | + # base_link → laser_link (identity — LiDAR co-located with base) | ||
| 185 | + t2 = TransformStamped() | ||
| 186 | + t2.header.stamp = stamp | ||
| 187 | + t2.header.frame_id = 'base_link' | ||
| 188 | + t2.child_frame_id = 'laser_link' | ||
| 189 | + t2.transform.rotation.w = 1.0 | ||
| 190 | + static_tfs.append(t2) | ||
| 191 | + | ||
| 192 | + self.static_tf_broadcaster.sendTransform(static_tfs) | ||
| 193 | + | ||
| 194 | + # ================================================================ | ||
| 195 | + # Odom callback — AirSim → /odom + TF(odom → base_footprint) | ||
| 196 | + # ================================================================ | ||
| 197 | + | ||
| 198 | + def _odom_cb(self, msg: Odometry): | ||
| 199 | + # AirSim NED position → ROS (x=forward, y=left, z=up) | ||
| 200 | + ned_x = msg.pose.pose.position.x | ||
| 201 | + ned_y = msg.pose.pose.position.y | ||
| 202 | + ned_z = msg.pose.pose.position.z | ||
| 203 | + | ||
| 204 | + ros_x = ned_x | ||
| 205 | + ros_y = -ned_y | ||
| 206 | + ros_z = -ned_z | ||
| 207 | + | ||
| 208 | + # NED quaternion → ROS quaternion: (w, x, y, z) → (w, x, -y, -z) | ||
| 209 | + q = msg.pose.pose.orientation | ||
| 210 | + ros_qw = q.w | ||
| 211 | + ros_qx = q.x | ||
| 212 | + ros_qy = -q.y | ||
| 213 | + ros_qz = -q.z | ||
| 214 | + | ||
| 215 | + # extract yaw for control loop (NED yaw → ROS yaw = negated) | ||
| 216 | + siny = 2.0 * (q.w * q.z + q.x * q.y) | ||
| 217 | + cosy = 1.0 - 2.0 * (q.y * q.y + q.z * q.z) | ||
| 218 | + self.current_yaw = -math.atan2(siny, cosy) | ||
| 219 | + self.current_alt = ros_z | ||
| 220 | + | ||
| 221 | + if self.flying and self._latch_alt and not self._alt_latched: | ||
| 222 | + self._alt_setpoint = float(ros_z) | ||
| 223 | + self._alt_latched = True | ||
| 224 | + self.get_logger().info( | ||
| 225 | + f'定高设定为当前高度: {self._alt_setpoint:.2f} m') | ||
| 226 | + | ||
| 227 | + stamp = self.get_clock().now().to_msg() | ||
| 228 | + | ||
| 229 | + # publish /odom with standard frame IDs | ||
| 230 | + odom = Odometry() | ||
| 231 | + odom.header.stamp = stamp | ||
| 232 | + odom.header.frame_id = 'odom' | ||
| 233 | + odom.child_frame_id = 'base_footprint' | ||
| 234 | + odom.pose.pose.position.x = ros_x | ||
| 235 | + odom.pose.pose.position.y = ros_y | ||
| 236 | + odom.pose.pose.position.z = ros_z | ||
| 237 | + odom.pose.pose.orientation.w = ros_qw | ||
| 238 | + odom.pose.pose.orientation.x = ros_qx | ||
| 239 | + odom.pose.pose.orientation.y = ros_qy | ||
| 240 | + odom.pose.pose.orientation.z = ros_qz | ||
| 241 | + | ||
| 242 | + # velocity conversion: NED → ROS | ||
| 243 | + odom.twist.twist.linear.x = msg.twist.twist.linear.x | ||
| 244 | + odom.twist.twist.linear.y = -msg.twist.twist.linear.y | ||
| 245 | + odom.twist.twist.linear.z = -msg.twist.twist.linear.z | ||
| 246 | + odom.twist.twist.angular.x = msg.twist.twist.angular.x | ||
| 247 | + odom.twist.twist.angular.y = -msg.twist.twist.angular.y | ||
| 248 | + odom.twist.twist.angular.z = -msg.twist.twist.angular.z | ||
| 249 | + | ||
| 250 | + self.odom_pub.publish(odom) | ||
| 251 | + | ||
| 252 | + # broadcast TF: odom → base_footprint | ||
| 253 | + tf_msg = TransformStamped() | ||
| 254 | + tf_msg.header.stamp = stamp | ||
| 255 | + tf_msg.header.frame_id = 'odom' | ||
| 256 | + tf_msg.child_frame_id = 'base_footprint' | ||
| 257 | + tf_msg.transform.translation.x = ros_x | ||
| 258 | + tf_msg.transform.translation.y = ros_y | ||
| 259 | + tf_msg.transform.translation.z = ros_z | ||
| 260 | + tf_msg.transform.rotation.w = ros_qw | ||
| 261 | + tf_msg.transform.rotation.x = ros_qx | ||
| 262 | + tf_msg.transform.rotation.y = ros_qy | ||
| 263 | + tf_msg.transform.rotation.z = ros_qz | ||
| 264 | + self.tf_broadcaster.sendTransform(tf_msg) | ||
| 265 | + | ||
| 266 | + # ================================================================ | ||
| 267 | + # LiDAR callback — AirSim PointCloud2 → /scan (LaserScan) | ||
| 268 | + # ================================================================ | ||
| 269 | + | ||
| 270 | + def _lidar_cb(self, msg: PointCloud2): | ||
| 271 | + if msg.width == 0: | ||
| 272 | + return | ||
| 273 | + | ||
| 274 | + # parse PointCloud2 (3×float32 per point, NED frame) | ||
| 275 | + n_points = msg.width * msg.height | ||
| 276 | + fmt = f'<{n_points * 3}f' | ||
| 277 | + if len(msg.data) < struct.calcsize(fmt): | ||
| 278 | + return | ||
| 279 | + raw = struct.unpack(fmt, bytes(msg.data[:struct.calcsize(fmt)])) | ||
| 280 | + pts = np.array(raw, dtype=np.float32).reshape(-1, 3) | ||
| 281 | + | ||
| 282 | + # NED → ROS sensor frame: y=-y, z=-z | ||
| 283 | + pts[:, 1] = -pts[:, 1] | ||
| 284 | + pts[:, 2] = -pts[:, 2] | ||
| 285 | + | ||
| 286 | + # height filter (z in ROS sensor frame, z=up, 0=sensor level) | ||
| 287 | + mask = (pts[:, 2] >= self.scan_height_min) & (pts[:, 2] <= self.scan_height_max) | ||
| 288 | + pts = pts[mask] | ||
| 289 | + | ||
| 290 | + if len(pts) == 0: | ||
| 291 | + return | ||
| 292 | + | ||
| 293 | + # project to 2D scan | ||
| 294 | + angles = np.arctan2(pts[:, 1], pts[:, 0]) | ||
| 295 | + ranges = np.hypot(pts[:, 0], pts[:, 1]) | ||
| 296 | + | ||
| 297 | + # build LaserScan | ||
| 298 | + scan = LaserScan() | ||
| 299 | + scan.header.stamp = self.get_clock().now().to_msg() | ||
| 300 | + scan.header.frame_id = 'laser_link' | ||
| 301 | + scan.angle_min = -math.pi | ||
| 302 | + scan.angle_max = math.pi | ||
| 303 | + scan.angle_increment = 2.0 * math.pi / self.scan_samples | ||
| 304 | + scan.range_min = self.scan_range_min | ||
| 305 | + scan.range_max = self.scan_range_max | ||
| 306 | + scan.time_increment = 0.0 | ||
| 307 | + scan.scan_time = 0.1 | ||
| 308 | + | ||
| 309 | + # bin points into angular buckets, keep closest range per bucket | ||
| 310 | + scan.ranges = [float('inf')] * self.scan_samples | ||
| 311 | + for ang, rng in zip(angles, ranges): | ||
| 312 | + if rng < self.scan_range_min or rng > self.scan_range_max: | ||
| 313 | + continue | ||
| 314 | + idx = int((ang - scan.angle_min) / scan.angle_increment) | ||
| 315 | + idx = max(0, min(self.scan_samples - 1, idx)) | ||
| 316 | + if rng < scan.ranges[idx]: | ||
| 317 | + scan.ranges[idx] = rng | ||
| 318 | + | ||
| 319 | + self.scan_pub.publish(scan) | ||
| 320 | + | ||
| 321 | + # ================================================================ | ||
| 322 | + # cmd_vel callback — Nav2 → AirSim | ||
| 323 | + # ================================================================ | ||
| 324 | + | ||
| 325 | + def _cmd_vel_cb(self, msg: Twist): | ||
| 326 | + self.last_cmd_vel = msg | ||
| 327 | + self.last_cmd_stamp = self.get_clock().now() | ||
| 328 | + | ||
| 329 | + # ================================================================ | ||
| 330 | + # Takeoff / Land | ||
| 331 | + # ================================================================ | ||
| 332 | + | ||
| 333 | + def _do_takeoff(self): | ||
| 334 | + if not self.takeoff_cli.wait_for_service(timeout_sec=10.0): | ||
| 335 | + self.get_logger().error('Takeoff service not available') | ||
| 336 | + return | ||
| 337 | + req = Takeoff.Request() | ||
| 338 | + req.wait_on_last_task = True | ||
| 339 | + future = self.takeoff_cli.call_async(req) | ||
| 340 | + future.add_done_callback(self._takeoff_done) | ||
| 341 | + self.get_logger().info('Takeoff requested ...') | ||
| 342 | + | ||
| 343 | + def _takeoff_done(self, future): | ||
| 344 | + try: | ||
| 345 | + future.result() | ||
| 346 | + if ( | ||
| 347 | + self._use_vel_z and self._airsim_client is not None | ||
| 348 | + and not self._latch_alt): | ||
| 349 | + ned_z = -self._alt_setpoint | ||
| 350 | + self._airsim_client.moveToZAsync( | ||
| 351 | + ned_z, | ||
| 352 | + self._move_to_z_v, | ||
| 353 | + vehicle_name=self.vehicle, | ||
| 354 | + ).join() | ||
| 355 | + self.flying = True | ||
| 356 | + self.get_logger().info('Takeoff complete, bridge active') | ||
| 357 | + except Exception as e: | ||
| 358 | + self.get_logger().error(f'Takeoff failed: {e}') | ||
| 359 | + | ||
| 360 | + def _do_land(self): | ||
| 361 | + if not self.land_cli.wait_for_service(timeout_sec=5.0): | ||
| 362 | + self.get_logger().warn('Land service not available') | ||
| 363 | + return | ||
| 364 | + req = Land.Request() | ||
| 365 | + req.wait_on_last_task = True | ||
| 366 | + self.land_cli.call_async(req) | ||
| 367 | + self.get_logger().info('Landing requested') | ||
| 368 | + | ||
| 369 | + # ================================================================ | ||
| 370 | + # Control loop — cmd_vel → AirSim VelCmd (with altitude hold) | ||
| 371 | + # ================================================================ | ||
| 372 | + | ||
| 373 | + def _control_loop(self): | ||
| 374 | + if not self.flying: | ||
| 375 | + return | ||
| 376 | + if self._latch_alt and not self._alt_latched: | ||
| 377 | + return | ||
| 378 | + | ||
| 379 | + now = self.get_clock().now() | ||
| 380 | + dt = (now - self.last_cmd_stamp).nanoseconds * 1e-9 | ||
| 381 | + if dt > self.cmd_vel_timeout: | ||
| 382 | + vx_world = 0.0 | ||
| 383 | + vy_world = 0.0 | ||
| 384 | + vyaw = 0.0 | ||
| 385 | + else: | ||
| 386 | + vx_body = self.last_cmd_vel.linear.x | ||
| 387 | + vy_body = self.last_cmd_vel.linear.y | ||
| 388 | + vyaw = self.last_cmd_vel.angular.z | ||
| 389 | + # body-frame (base_link) → world-frame XY using current yaw | ||
| 390 | + # base_link convention: x forward, y left, z up | ||
| 391 | + c = math.cos(self.current_yaw) | ||
| 392 | + s = math.sin(self.current_yaw) | ||
| 393 | + # vel mixing: world-frame XY → body-frame XY using current yaw | ||
| 394 | + vx_world = c * vx_body - s * vy_body | ||
| 395 | + vy_world = s * vx_body + c * vy_body | ||
| 396 | + | ||
| 397 | + if self._use_vel_z and self._airsim_client is not None: | ||
| 398 | + yaw_mode = YawMode(is_rate=True, yaw_or_rate=math.degrees(-vyaw)) | ||
| 399 | + ned_z = -self._alt_setpoint | ||
| 400 | + self._airsim_client.moveByVelocityZAsync( | ||
| 401 | + vx_world, | ||
| 402 | + -vy_world, | ||
| 403 | + ned_z, | ||
| 404 | + self._vel_dur, | ||
| 405 | + DrivetrainType.MaxDegreeOfFreedom, | ||
| 406 | + yaw_mode, | ||
| 407 | + self.vehicle, | ||
| 408 | + ) | ||
| 409 | + return | ||
| 410 | + | ||
| 411 | + alt_err = self._alt_setpoint - self.current_alt | ||
| 412 | + vz = self.alt_kp * alt_err | ||
| 413 | + vz = max(-self.max_vz, min(self.max_vz, vz)) | ||
| 414 | + | ||
| 415 | + msg = VelCmd() | ||
| 416 | + msg.twist.linear.x = vx_world | ||
| 417 | + msg.twist.linear.y = -vy_world | ||
| 418 | + msg.twist.linear.z = -vz | ||
| 419 | + msg.twist.angular.x = 0.0 | ||
| 420 | + msg.twist.angular.y = 0.0 | ||
| 421 | + msg.twist.angular.z = -vyaw | ||
| 422 | + self.vel_pub.publish(msg) | ||
| 423 | + | ||
| 424 | + # ================================================================ | ||
| 425 | + # Shutdown | ||
| 426 | + # ================================================================ | ||
| 427 | + | ||
| 428 | + def destroy_node(self): | ||
| 429 | + self.flying = False | ||
| 430 | + self._do_land() | ||
| 431 | + super().destroy_node() | ||
| 432 | + | ||
| 433 | + | ||
| 434 | +def main(args=None): | ||
| 435 | + rclpy.init(args=args) | ||
| 436 | + node = AirsimBridgeNode() | ||
| 437 | + try: | ||
| 438 | + rclpy.spin(node) | ||
| 439 | + except KeyboardInterrupt: | ||
| 440 | + pass | ||
| 441 | + finally: | ||
| 442 | + node.destroy_node() | ||
| 443 | + rclpy.shutdown() | ||
| @@ -0,0 +1,61 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# launch: AirSim simulation backend | ||
| 3 | +# Counterpart to gzsim.classic.launch.py — launches the AirSim ROS2 wrapper | ||
| 4 | +# instead of Gazebo Classic, plus a bridge node for Nav2 integration. | ||
| 5 | + | ||
| 6 | +import launch | ||
| 7 | +from launch.actions import TimerAction | ||
| 8 | +from launch_ros.actions import Node | ||
| 9 | + | ||
| 10 | +from robot_sim_common import config | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def generate_launch_description(): | ||
| 14 | + sim_config = config.get_sim_backend_config() | ||
| 15 | + airsim_cfg = sim_config.get('airsim', {}) | ||
| 16 | + | ||
| 17 | + host_ip = str(airsim_cfg.get('host_ip', 'localhost')) | ||
| 18 | + publish_clock = bool(airsim_cfg.get('publish_clock', True)) | ||
| 19 | + vehicle_name = str(airsim_cfg.get('vehicle_name', 'drone_1')) | ||
| 20 | + target_altitude = float(airsim_cfg.get('target_altitude', 3.0)) | ||
| 21 | + lidar_sensor_name = str(airsim_cfg.get('lidar_sensor_name', 'LidarSensor1')) | ||
| 22 | + | ||
| 23 | + airsim_node = Node( | ||
| 24 | + package='airsim_ros_pkgs', | ||
| 25 | + executable='airsim_node', | ||
| 26 | + name='airsim_node', | ||
| 27 | + output='screen', | ||
| 28 | + remappings=[ | ||
| 29 | + ('/airsim_node/clock', '/clock'), | ||
| 30 | + ], | ||
| 31 | + parameters=[{ | ||
| 32 | + 'is_vulkan': False, | ||
| 33 | + 'update_airsim_img_response_every_n_sec': | ||
| 34 | + float(airsim_cfg.get('update_airsim_img_response_every_n_sec', 0.05)), | ||
| 35 | + 'update_airsim_control_every_n_sec': | ||
| 36 | + float(airsim_cfg.get('update_airsim_control_every_n_sec', 0.01)), | ||
| 37 | + 'update_lidar_every_n_sec': | ||
| 38 | + float(airsim_cfg.get('update_lidar_every_n_sec', 0.01)), | ||
| 39 | + 'publish_clock': publish_clock, | ||
| 40 | + 'host_ip': host_ip, | ||
| 41 | + }] | ||
| 42 | + ) | ||
| 43 | + | ||
| 44 | + bridge_node = Node( | ||
| 45 | + package='demos', | ||
| 46 | + executable='airsim_bridge', | ||
| 47 | + name='airsim_cmd_vel_bridge', | ||
| 48 | + output='screen', | ||
| 49 | + parameters=[{ | ||
| 50 | + 'vehicle_name': vehicle_name, | ||
| 51 | + 'target_altitude': target_altitude, | ||
| 52 | + 'lidar_sensor_name': lidar_sensor_name, | ||
| 53 | + }] | ||
| 54 | + ) | ||
| 55 | + | ||
| 56 | + bridge_delayed = TimerAction(period=5.0, actions=[bridge_node]) | ||
| 57 | + | ||
| 58 | + return launch.LaunchDescription([ | ||
| 59 | + airsim_node, | ||
| 60 | + bridge_delayed, | ||
| 61 | + ]) | ||
| @@ -35,6 +35,7 @@ setup( | |||
| 35 | tests_require=['pytest'], | 35 | tests_require=['pytest'], |
| 36 | entry_points={ | 36 | entry_points={ |
| 37 | 'console_scripts': [ | 37 | 'console_scripts': [ |
| 38 | + 'airsim_bridge = demos.airsim_bridge_node:main', | ||
| 38 | ], | 39 | ], |
| 39 | }, | 40 | }, |
| 40 | ) | 41 | ) |