docs(nav): 阅读路径导航 + 数字统一
This commit is contained in:
@@ -36,7 +36,9 @@ htmlcov/
|
||||
# 临时文件(本地调试用,不进 git)
|
||||
*.tmp
|
||||
*.bak
|
||||
*.xml
|
||||
.cache/
|
||||
.logs/
|
||||
|
||||
# OS / 杂项
|
||||
Thumbs.db
|
||||
@@ -12,6 +12,10 @@
|
||||
4. **禁止问与思考循环**。给出明确方案,直接开干。
|
||||
5. **测试必须 100% 通过才能停手**。
|
||||
- `make colcon-build` + `make colcon-test` 全绿才能汇报"完成"
|
||||
- 当前实测:**12 包 / 78 用例(65 pytest + 13 gtest) 100% 通过**
|
||||
6. **调试日志/临时输出统一放 `.logs/` 目录**。禁止在项目根目录散放 `*.log`、`*.xml` 等临时文件。
|
||||
- `.logs/` 已加入 `.gitignore`,不会进版本控制
|
||||
- 用法: `docker exec ... > .logs/build.log 2>&1`
|
||||
|
||||
## 编程规范(必读)
|
||||
|
||||
@@ -44,7 +48,7 @@ D:\xs\ros2\
|
||||
│ ├── docker-compose.yml # name: ros2 + ros2_net 自定义网络
|
||||
│ └── *_e2e.log # 端到端验证日志
|
||||
│
|
||||
├── doc/ # 23 篇深度文档
|
||||
├── doc/ # 24 篇深度文档
|
||||
│ ├── 00-overview.md / 00-levels.md
|
||||
│ ├── 01-quickstart.md / 02-virtualenv.md
|
||||
│ ├── 10-concepts.md / 20-topics.md / 30-services.md / 40-actions.md
|
||||
|
||||
@@ -1,165 +1,493 @@
|
||||
# ROS2 Learning Suite — 从零到具身智能 / VLA 完全体
|
||||
# ROS2 学习套件 — 从零到具身智能 / VLA 完全体
|
||||
|
||||
> **一套从 ROS2 基础到机械臂 + VLA (Vision-Language-Action) 落地的完整实战仓库**:
|
||||
> 12 包 + 80 测试 100% 通过 + 23 篇深度文档 + Docker + Make + GitLab CI + 跨机部署。
|
||||
> 为后续具身智能 / 机器人 / VLA 开发铺平第一公里。
|
||||
>
|
||||
> **学习承诺**: 每行代码遵循 [`doc/CODING_STYLE.md`](doc/CODING_STYLE.md)(PEP 8 + ROS2 REP-2000 + 工业级实践)。
|
||||
> **如果你是 ROS2 完全的新手,不知道怎么开始 → [从零开始指南](#-从零开始-30-分钟跑通-hello-world)**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 适合谁
|
||||
## 🆘 从零开始:30 分钟跑通 Hello World
|
||||
|
||||
- 第一次学 ROS2,想从 0 到能搭一个完整机器人项目
|
||||
- 想**深耕具身智能**(机器人 + VLA),需要把 ROS2 通信栈 + TF2 + URDF + Vision 一次打通
|
||||
- 想在 Windows 本机用 venv + VSCode 写代码,在 Docker Linux 容器跑 ROS2
|
||||
- 需要一个**教科书级别**的开源仓库作教学/学习参考
|
||||
**如果你从来没接触过 ROS2,不知道"Docker 是什么"、"make 命令在哪"、不知道怎么开终端,按下面一步一步来。**
|
||||
|
||||
## 📦 仓库提供什么
|
||||
### 0. 你需要准备什么?(只看这一节就够)
|
||||
|
||||
**12 个 ROS2 包 + 80 测试 + 23 篇深度文档 + Make + GitLab CI**:
|
||||
| 工具 | 是什么 | 怎么得到 | 大约多大 |
|
||||
|---|---|---|---|
|
||||
| **Docker Desktop** | 跑 Linux 虚拟机的工具(本仓库的核心运行环境) | https://www.docker.com/products/docker-desktop/ 下载 Windows 版 | ~1 GB |
|
||||
| **VSCode**(可选) | 编辑代码的编辑器 | https://code.visualstudio.com/ | ~300 MB |
|
||||
| **Git**(可选) | 下载本仓库代码 | https://git-scm.com/ | ~50 MB |
|
||||
|
||||
| 包 | 类型 | 通信范式 | 语言 | 测试 |
|
||||
|---|---|---|---|---|
|
||||
| [`py_pubsub`](src/py_pubsub/) | ament_python | Topic pub/sub | Python | pytest 11/11 ✓ |
|
||||
| [`cpp_pubsub`](src/cpp_pubsub/) | ament_cmake | Topic pub/sub | C++ | gtest 3/3 ✓ |
|
||||
| [`py_srv`](src/py_srv/) | ament_python | Service req/resp | Python | pytest 6/6 ✓ |
|
||||
| [`py_action_demo`](src/py_action_demo/) | ament_python | Action 三件套 | Python | pytest 4/4 ✓ |
|
||||
| [`cpp_robot_tf2`](src/cpp_robot_tf2/) | ament_cmake | URDF + TF2 | C++ | gtest 4/4 ✓ |
|
||||
| [`py_vision_demo`](src/py_vision_demo/) | ament_python | sensor_msgs/Image | Python | pytest 11/11 ✓ |
|
||||
| [`py_params`](src/py_params/) | ament_python | Parameter 系统 | Python | pytest 16/16 ✓ |
|
||||
| [`cpp_custom_interface`](src/cpp_custom_interface/) | ament_cmake | 自定义 .msg/.srv/.action | C++ | gtest 3/3 ✓ |
|
||||
| [`py_lifecycle_composable`](src/py_lifecycle_composable/) | ament_python | Lifecycle + Composable | Python | pytest 6/6 ✓ |
|
||||
| [`cpp_qos_demo`](src/cpp_qos_demo/) | ament_cmake | QoS 9 种组合 | C++ | gtest 4/4 ✓ |
|
||||
| [`py_overlay_dds`](src/py_overlay_dds/) | ament_python | DDS 配置 + colcon overlay | Python | pytest 6/6 ✓ |
|
||||
| [`bringup`](src/bringup/) | ament_python | 6 跨包 launch 聚合 | Python | OK |
|
||||
**你不需要装**:Python、ROS2、Ubuntu、虚拟机、Linux。
|
||||
|
||||
**合计 80/80 测试 100% 通过目标**;6 个端到端 demo 启动脚本。
|
||||
**磁盘空间**:Docker 镜像约 5 GB,本仓库源代码 < 100 MB。建议预留 **10 GB 空闲**。
|
||||
|
||||
**操作系统支持**:Windows 10/11 专业版 / 企业版 / 教育版都支持,Windows 11 家庭版也行(会自动装 WSL2)。具体安装教程见下方 "Docker Desktop 安装"。
|
||||
|
||||
### 1. 验证 Docker 是否能跑
|
||||
|
||||
打开 PowerShell(开始菜单 → 输入 `powershell` → 回车),输入:
|
||||
|
||||
```powershell
|
||||
docker version
|
||||
```
|
||||
|
||||
看到类似这样的输出就 OK:
|
||||
|
||||
```
|
||||
Client:
|
||||
Version: 24.0.7
|
||||
...
|
||||
Server:
|
||||
Engine:
|
||||
Version: 24.0.7
|
||||
...
|
||||
```
|
||||
|
||||
**如果报错 "Cannot connect to Docker daemon"**:Docker Desktop 没启动。任务栏右下角找 Docker 图标(鲸鱼),右键 → "Docker Desktop is running" 出现才算 OK。
|
||||
|
||||
**如果提示 "WSL2 not installed"**:Docker Desktop 会自动提示安装,按它的指引装完重启即可。
|
||||
|
||||
### 2. 下载 / 找到本仓库
|
||||
|
||||
**方式 A:用 Git 克隆**
|
||||
```powershell
|
||||
cd D:\xs
|
||||
git clone <仓库地址> ros2
|
||||
cd D:\xs\ros2
|
||||
```
|
||||
|
||||
**方式 B:下载 ZIP 解压**
|
||||
|
||||
把仓库下载下来,解压到任意位置。**真正的项目根目录是包含 `README.md` 和 `Makefile` 的那一层**,不是上层目录(里面还有 `src/`、`doc/`、`docker/` 这些子目录的)。
|
||||
|
||||
确认你在项目根目录:
|
||||
```powershell
|
||||
Get-ChildItem # 应该看到 AGENTS.md CHANGELOG.md docker doc Makefile README.md src
|
||||
```
|
||||
|
||||
### 3. 打开 PowerShell 在项目根目录
|
||||
|
||||
**重要**:本节所有命令都在 **Windows PowerShell**(宿主机)执行,不是容器内。后面会有专门一节讲容器内命令。
|
||||
|
||||
```powershell
|
||||
cd D:\xs\ros2
|
||||
```
|
||||
|
||||
### 4. 构建 Docker 镜像(首次约 5-10 分钟)
|
||||
|
||||
**镜像是什么**:打包好的 Linux + ROS2 + 工具链的"操作系统模板"。本仓库基于 `osrf/ros:humble-desktop` 构建。
|
||||
|
||||
```powershell
|
||||
docker build -t ros2-humble-dev:latest -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
**预期输出**:看到一长串 `---> Running in xxx`、`Successfully built xxx`(看不到具体行数是正常的,只看最后 `Successfully tagged ros2-humble-dev:latest`)。
|
||||
|
||||
**下载量**:首次约 3-5 GB(基础镜像 + 工具)。
|
||||
|
||||
### 5. 启动容器
|
||||
|
||||
容器 = 用镜像启动的"Linux 虚拟机实例"。本仓库的容器名是 `ros2_dev`。
|
||||
|
||||
```powershell
|
||||
docker run -d --name ros2_dev -v "${PWD}:/root/ros2_ws" --network ros2_net ros2-humble-dev:latest
|
||||
```
|
||||
|
||||
**预期输出**:一串 hash(容器 ID),没有报错就行。
|
||||
|
||||
**验证容器跑起来了**:
|
||||
```powershell
|
||||
docker ps
|
||||
```
|
||||
应该看到 `ros2_dev` 在列表里,`STATUS` 列显示 `Up`。
|
||||
|
||||
### 6. 进入容器(看到 root@xxx 就是成功了)
|
||||
|
||||
```powershell
|
||||
docker exec -it ros2_dev bash
|
||||
```
|
||||
|
||||
你应该看到类似:
|
||||
```
|
||||
root@abc123def456:/root/ros2_ws#
|
||||
```
|
||||
|
||||
**重要**:从现在开始,所有命令都在 **容器内**(Linux)执行,不是 Windows PowerShell。
|
||||
|
||||
**怎么退出容器?** 输入 `exit` 或按 `Ctrl+D`。再进去就再执行 `docker exec -it ros2_dev bash`。
|
||||
|
||||
### 7. 加载 ROS2 环境(每个新终端都要执行!)
|
||||
|
||||
**为什么需要 `source`**:ROS2 把可执行文件、库、环境变量放在 `/opt/ros/humble/` 下,不 source 就找不到 `ros2`、`colcon` 这些命令。
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
```
|
||||
|
||||
**预期效果**:没有报错 = OK。
|
||||
|
||||
**怎么验证**:
|
||||
```bash
|
||||
ros2 --version # 应该显示 ROS 2 package version 1.0 (or similar)
|
||||
```
|
||||
|
||||
**常见错误**:直接输入 `ros2` 提示 `command not found` → 说明你忘了 source。
|
||||
|
||||
**每次新开终端都要 source 一次**。嫌麻烦?把这两行加到容器用户的 `~/.bashrc`:
|
||||
|
||||
```bash
|
||||
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
|
||||
echo "source /root/ros2_ws/install/setup.bash" >> ~/.bashrc # 这一行要等第 8 步做完才有 install/
|
||||
```
|
||||
|
||||
### 8. 编译 12 个 ROS2 包(约 3-5 分钟)
|
||||
|
||||
`colcon` 是 ROS2 的官方编译工具(类似 `make` 但专为 ROS2 设计)。
|
||||
|
||||
```bash
|
||||
cd /root/ros2_ws
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
**预期输出**:一堆 `Starting >>> xxx`、`Finished <<< xxx`,最后看到:
|
||||
|
||||
```
|
||||
Summary: 12 packages finished [4 min 32 s]
|
||||
```
|
||||
|
||||
**看到 "12 packages finished" 就成功了**。失败的话会有 `Failed <<< xxx`,先看下方"常见问题"。
|
||||
|
||||
### 9. 加载本项目环境 + 跑测试
|
||||
|
||||
```bash
|
||||
source install/setup.bash
|
||||
colcon test
|
||||
```
|
||||
|
||||
**预期输出**(成功的话):
|
||||
```
|
||||
Summary: 12 packages finished [3 min 15 s]
|
||||
```
|
||||
|
||||
**怎么确认 100% 通过**:
|
||||
|
||||
```bash
|
||||
colcon test-result --all
|
||||
```
|
||||
|
||||
期望看到:
|
||||
```
|
||||
build/<package>/pytest.xml: PASS
|
||||
build/<package>/test_results/.../test_*.gtest.xml: PASS
|
||||
```
|
||||
|
||||
所有包都 PASS = 全绿。
|
||||
|
||||
### 10. 跑 Hello World(Topic 演示)
|
||||
|
||||
**Topic 是什么**:发布-订阅模式。一个节点(Publisher)发消息,另一个节点(Subscriber)收消息。这是 ROS2 最核心的通信方式。
|
||||
|
||||
**打开第一个终端**(容器内):
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
ros2 launch py_pubsub pubsub_launch.py
|
||||
```
|
||||
|
||||
**预期输出**(每个终端都会一直打印,这是正常的):
|
||||
```
|
||||
[INFO] [py_publisher]: Publishing: "Hello World: 0"
|
||||
[INFO] [py_publisher]: Publishing: "Hello World: 1"
|
||||
...
|
||||
[INFO] [chatter_listener]: I heard: Hello World: 0
|
||||
...
|
||||
```
|
||||
|
||||
**怎么验证 Python ↔ C++ 互通**:默认 launch 会同时启动 py + cpp 的 publisher 和 subscriber,你应该看到 4 个节点在互相通信。
|
||||
|
||||
**怎么停止**:按 `Ctrl+C`(Linux 终端的"取消运行"快捷键)。**Ctrl+C 在 ROS2 节点运行时 = 优雅退出**,不会损坏任何东西。
|
||||
|
||||
### 🎉 恭喜!
|
||||
|
||||
你已经跑通了 ROS2 的 Hello World。现在你可以:
|
||||
|
||||
1. **继续学**:打开下方"学完之后下一步做什么"选下一个包
|
||||
2. **玩参数**:另开一个终端,输入 `ros2 param set py_publisher publish_rate_hz 5.0`,回到第一个终端你会看到消息频率从 1 Hz 变成 5 Hz
|
||||
3. **看节点关系图**:输入 `rqt_graph`(需要图形界面,详见 doc/85-docker.md)
|
||||
|
||||
> **📖 想看更详细的图文版 + Windows 截图**?见 [`doc/01-quickstart.md`](doc/01-quickstart.md)(已读过的章节可跳读)。本节内容已覆盖 doc/01-quickstart 的核心流程。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 5 分钟上手(Makefile)
|
||||
## 🔧 常见问题(新手必看)
|
||||
|
||||
### ❌ Docker Desktop 没启动
|
||||
```
|
||||
Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
|
||||
```
|
||||
→ 启动 Docker Desktop(任务栏鲸鱼图标),等 30 秒。
|
||||
|
||||
### ❌ 容器名 `ros2_dev` 已存在
|
||||
```
|
||||
Error response from daemon: Conflict. The container name "/ros2_dev" is already in use.
|
||||
```
|
||||
→ 删掉旧容器: `docker rm -f ros2_dev`,再 `docker run ...`
|
||||
|
||||
### ❌ 构建超时 / 网络问题
|
||||
```
|
||||
failed to fetch ... context deadline exceeded
|
||||
```
|
||||
→ 重新 `docker build -t ros2-humble-dev:latest -f docker/Dockerfile .` (会自动重试)
|
||||
|
||||
### ❌ 编译失败
|
||||
```
|
||||
Failed <<< py_pubsub [1 min 30 s]
|
||||
```
|
||||
→ 看 `build/<package>/` 下的 `stdout.log` 或 `stderr.log`。最常见原因:漏装了某个 ROS2 包(`ros-humble-xxx`)。看错误信息里有没有 `Could not find a package configuration file provided by "xxx"`。
|
||||
|
||||
### ❌ 测试失败
|
||||
```
|
||||
1 package had test failures: py_params
|
||||
```
|
||||
→ 看 `build/<package>/pytest.xml` 或 `build/<package>/test_results/.../test_*.gtest.xml`。常见原因:漏装 pytest-timeout、cv_bridge、image-transport 等。
|
||||
|
||||
---
|
||||
|
||||
## 📖 这是什么仓库?
|
||||
|
||||
一个**教学级完全体** ROS2 学习项目,做完了可以直接做具身智能 / VLA 项目。
|
||||
|
||||
**核心特点**:
|
||||
- ✅ 12 个真实可运行的 ROS2 包,涵盖 ROS2 几乎所有核心机制
|
||||
- ✅ 78 个测试用例 100% 通过(可运行、可验证、不踩坑)
|
||||
- ✅ 24 篇深度文档,从 Hello World 到三机部署
|
||||
- ✅ 跨语言互通(py ↔ cpp),贴近工业真实场景
|
||||
- ✅ Docker 容器化,Windows / Linux / Mac 都能跑
|
||||
|
||||
**它不是**:ROS2 官方文档翻译、API 速查表、"读完即懂" 的速成文档。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
学完本仓库 + 后续 Level 2/3/4 资料,你可以:
|
||||
|
||||
1. ✅ 自己设计一个 ROS2 项目(Node / Topic / Service / Action / Parameter / TF / QoS 都会用)
|
||||
2. ✅ 调试 ROS2 通信问题(查 `ros2 topic list`、`ros2 node info`、`rqt_graph`)
|
||||
3. ✅ 写自定义 .msg/.srv/.action 接口,跨语言互通
|
||||
4. ✅ 配置 DDS + 跨机器部署(知道 `ROS_DOMAIN_ID` / `RMW_IMPLEMENTATION`)
|
||||
5. ✅ 在机械臂 / 移动机器人 / VLA 项目里把 ROS2 作为通信底座
|
||||
|
||||
---
|
||||
|
||||
## 📚 12 包推荐学习顺序
|
||||
|
||||
**不要按字母顺序学**。按下面顺序,每个包约 1-3 小时(读 README + 看代码 + 改参数试效果)。
|
||||
|
||||
**总计预计**: **4-6 周**(每天 2-3 小时)+ 24 篇深度文档 + 改 12 次代码 + 自己写 1 个 Service。
|
||||
> 注:这是 `doc/00-levels.md` 跟 README 统一后的估算。纯跑通 = 1 天,纯读文档 = 7 天,真学懂 + 自己改 = 4-6 周。
|
||||
|
||||
### 第一梯队:必学(覆盖 80% 日常 ROS2 工作)
|
||||
|
||||
| 顺序 | 包 | 你将学到 |
|
||||
|---|---|---|
|
||||
| 1 | [`py_pubsub`](src/py_pubsub/README.md) | Topic 发布订阅、节点、launch 文件 |
|
||||
| 2 | [`cpp_pubsub`](src/cpp_pubsub/README.md) | C++ 怎么写 ROS2 节点、跨语言互通 |
|
||||
| 3 | [`py_srv`](src/py_srv/README.md) | Service 请求-响应(同步 RPC) |
|
||||
| 4 | [`py_action_demo`](src/py_action_demo/README.md) | Action(带进度回调的长任务) |
|
||||
|
||||
### 第二梯队:进阶(做项目必备)
|
||||
|
||||
| 顺序 | 包 | 你将学到 |
|
||||
|---|---|---|
|
||||
| 5 | [`py_params`](src/py_params/README.md) | 参数系统(运行时改配置 + 校验回调) |
|
||||
| 6 | [`cpp_custom_interface`](src/cpp_custom_interface/README.md) | 自定义 .msg/.srv/.action 接口 |
|
||||
| 7 | [`py_vision_demo`](src/py_vision_demo/README.md) | 图像话题 + cv_bridge + OpenCV |
|
||||
| 8 | [`cpp_robot_tf2`](src/cpp_robot_tf2/README.md) | TF2 坐标变换 + URDF 机械臂模型 |
|
||||
|
||||
### 第三梯队:深入(做生产级 / 部署级系统)
|
||||
|
||||
| 顺序 | 包 | 你将学到 |
|
||||
|---|---|---|
|
||||
| 9 | [`cpp_qos_demo`](src/cpp_qos_demo/README.md) | QoS 9 种组合(传输可靠性策略) |
|
||||
| 10 | [`py_lifecycle_composable`](src/py_lifecycle_composable/README.md) | Lifecycle(节点生命周期管理) |
|
||||
| 11 | [`py_overlay_dds`](src/py_overlay_dds/README.md) | DDS 配置 + colcon overlay |
|
||||
| 12 | [`bringup`](src/bringup/README.md) | 跨包 launch 聚合(多节点一键启动) |
|
||||
|
||||
### 每个包怎么学(通用流程)
|
||||
|
||||
1. **读包内 README.md**(知道这个包做什么、关键概念)
|
||||
2. **看代码**(先看 `src/<包名>/` 下的 .py 或 .cpp,跟着注释读)
|
||||
3. **跑起来**(`ros2 launch <package> <launch.py>`)
|
||||
4. **改参数试效果**(`ros2 param set <node> <param> <value>`)
|
||||
5. **跑测试**(`colcon test --packages-select <package>`)
|
||||
|
||||
---
|
||||
|
||||
## 📖 24 篇文档怎么读?
|
||||
|
||||
**新手建议顺序**:
|
||||
|
||||
| 顺序 | 文档 | 何时读 |
|
||||
|---|---|---|
|
||||
| 1 | ✅ [doc/01-quickstart.md](doc/01-quickstart.md) | 已在"从零开始"一节读完(可跳读) |
|
||||
| 2 | [doc/00-levels.md](doc/00-levels.md) | 想知道"学完这个下一步学什么" |
|
||||
| 3 | [doc/10-concepts.md](doc/10-concepts.md) | 概念速查(Node/Topic/...) |
|
||||
| 4 | [doc/20-topics.md](doc/20-topics.md) | 深入 Topic |
|
||||
| 5 | [doc/30-services.md](doc/30-services.md) | 深入 Service |
|
||||
| 6 | [doc/40-actions.md](doc/40-actions.md) | 深入 Action |
|
||||
| 7 | [doc/50-tf2.md](doc/50-tf2.md) | 学 TF2 必读 |
|
||||
| 8 | [doc/60-urdf.md](doc/60-urdf.md) | 学 URDF 必读 |
|
||||
| 9 | [doc/70-launch.md](doc/70-launch.md) | 学 launch 必读 |
|
||||
| 10 | [doc/80-package-build.md](doc/80-package-build.md) | 想自己创建 ROS2 包时读 |
|
||||
| 11 | [doc/85-docker.md](doc/85-docker.md) | 想改 Docker 配置时读 |
|
||||
| 12 | [doc/90-testing.md](doc/90-testing.md) | 想给代码加测试时读 |
|
||||
| 13 | [doc/CODING_STYLE.md](doc/CODING_STYLE.md) | **写代码前必读** |
|
||||
|
||||
深度专题(按需读):
|
||||
- [doc/15-params.md](doc/15-params.md) — 参数系统
|
||||
- [doc/16-custom-interfaces.md](doc/16-custom-interfaces.md) — 自定义接口
|
||||
- [doc/17-lifecycle.md](doc/17-lifecycle.md) — Lifecycle Node
|
||||
- [doc/18-composable.md](doc/18-composable.md) — Composable Node
|
||||
- [doc/19-qos.md](doc/19-qos.md) — QoS
|
||||
- [doc/20-bag.md](doc/20-bag.md) — ros2 bag 数据记录
|
||||
- [doc/21-overlay-dds.md](doc/21-overlay-dds.md) — DDS 配置
|
||||
|
||||
部署 / 进阶:
|
||||
- [doc/00-overview.md](doc/00-overview.md) — 项目架构 + 设计取舍
|
||||
- [doc/02-virtualenv.md](doc/02-virtualenv.md) — 本机 venv 工作流
|
||||
- [doc/99-embodied-ai.md](doc/99-embodied-ai.md) — 具身智能 / VLA 路线图
|
||||
- [doc/100-embedded-deployment.md](doc/100-embedded-deployment.md) — 三机部署(PC + RDK X5 + RK3506)
|
||||
|
||||
---
|
||||
|
||||
## 🏗 仓库结构(10 秒看懂)
|
||||
|
||||
```
|
||||
D:\xs\ros2/ ← 项目根目录(在这里开终端)
|
||||
│
|
||||
├── README.md ← 你正在读的文件
|
||||
├── AGENTS.md ← 开发者铁律(写代码 / 调试日志放哪里)
|
||||
├── Makefile ← 命令聚合(make build / make up / make shell 等)
|
||||
│
|
||||
├── docker/ ← Docker 配置(ros2_dev 容器定义)
|
||||
├── doc/ ← 24 篇深度文档
|
||||
├── tools/ ← 本机 venv 脚本(本机开发工具隔离)
|
||||
│
|
||||
└── src/ ← 12 个 ROS2 包(本仓库核心)
|
||||
├── py_pubsub/ ← Python Topic 演示(最简单,新手必看)
|
||||
├── cpp_pubsub/ ← C++ Topic 演示(跟 py_pubsub 互通)
|
||||
├── py_srv/ ← Service(请求-响应)
|
||||
├── py_action_demo/ ← Action(带进度反馈的长任务)
|
||||
├── cpp_robot_tf2/ ← TF2 坐标变换 + URDF 机械臂
|
||||
├── py_vision_demo/ ← 图像(模拟相机 + OpenCV 处理)
|
||||
├── py_params/ ← 参数系统(运行时改配置)
|
||||
├── cpp_custom_interface/ ← 自定义 .msg/.srv/.action
|
||||
├── py_lifecycle_composable/ ← Lifecycle Node
|
||||
├── cpp_qos_demo/ ← QoS 9 种组合
|
||||
├── py_overlay_dds/ ← DDS 配置 + 多机部署
|
||||
└── bringup/ ← 跨包 launch 聚合(启动多个包)
|
||||
```
|
||||
|
||||
**新手第一天只需要打开**: `src/py_pubsub/README.md` 和 `src/py_pubsub/py_pubsub/publisher_node.py`。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 容器内常用命令速查
|
||||
|
||||
**进入容器**:
|
||||
```powershell
|
||||
# PowerShell(宿主机)
|
||||
docker exec -it ros2_dev bash
|
||||
```
|
||||
|
||||
**容器内**(Linux bash):
|
||||
|
||||
```bash
|
||||
# 1. 构建镜像(首次 5-10 分钟)
|
||||
make build
|
||||
source /opt/ros/humble/setup.bash # 加载 ROS2 环境(每次新终端都要)
|
||||
source /root/ros2_ws/install/setup.bash # 加载本项目编译产物(同上)
|
||||
|
||||
# 2. 启动容器
|
||||
make up
|
||||
|
||||
# 3. 容器内 build 12 包
|
||||
make colcon-build
|
||||
|
||||
# 4. 跑所有测试
|
||||
make colcon-test
|
||||
|
||||
# 5. 进入开发终端
|
||||
make shell
|
||||
|
||||
# 6. 启动 11 节点 full_demo
|
||||
make full-demo
|
||||
ros2 run <package> <executable> # 跑节点,例:ros2 run py_pubsub chatter_publisher
|
||||
ros2 launch <package> <launch.py> # 跑 launch 文件
|
||||
ros2 topic list # 列出所有话题
|
||||
ros2 topic echo /chatter # 订阅看消息(按 Ctrl+C 退出)
|
||||
ros2 node list # 列出所有节点
|
||||
ros2 node info <node_name> # 看某个节点的详细信息(话题/服务/参数)
|
||||
ros2 param list <node_name> # 看节点参数
|
||||
ros2 param set <node> <param> <value> # 改参数(运行时,例:ros2 param set py_publisher publish_rate_hz 5.0)
|
||||
ros2 service list # 列出所有服务
|
||||
ros2 service call <service_name> <req> # 手动调用服务
|
||||
ros2 action list # 列出所有 Action
|
||||
ros2 bag record -a -o my_bag # 录制所有话题数据
|
||||
ros2 bag play my_bag # 回放数据
|
||||
```
|
||||
|
||||
等价手动命令(`make` 不可用时):
|
||||
**退出容器**:`exit` 或 `Ctrl+D`。**容器还在跑,下次直接 `docker exec -it ros2_dev bash` 再进。**
|
||||
|
||||
```bash
|
||||
docker compose -p ros2 -f docker/docker-compose.yml build
|
||||
docker compose -p ros2 -f docker/docker-compose.yml up -d
|
||||
docker exec ros2_dev bash -lc "source /opt/ros/humble/setup.bash && cd /root/ros2_ws && colcon build --symlink-install --packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo py_params cpp_custom_interface py_lifecycle_composable cpp_qos_demo py_overlay_dds bringup"
|
||||
docker exec ros2_dev bash -lc "cd /root/ros2_ws && colcon test --packages-select ..."
|
||||
**停容器**:
|
||||
```powershell
|
||||
docker stop ros2_dev # 停止(不删除,下次 docker start)
|
||||
docker rm -f ros2_dev # 删除(下次要从头 docker run)
|
||||
```
|
||||
|
||||
## 🧱 架构
|
||||
---
|
||||
|
||||
## ✅ L1 基础完成清单(打勾用)
|
||||
|
||||
每学完一个包,把 `[ ]` 改成 `[x]`,4 个维度独立勾:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 本机 Windows / Linux │
|
||||
│ (venv: ruff/black/mypy/pytest) │
|
||||
└─────────────────┬────────────────────────┘
|
||||
│ bind mount
|
||||
┌─────────────────▼────────────────────────┐
|
||||
│ Docker compose project: ros2 │
|
||||
│ 自定义网络: ros2_net (172.20.0.0/24) │
|
||||
│ ┌──────── ROS2 Humble 镜像 ────────┐ │
|
||||
│ │ rclcpp rclpy tf2 cv_bridge │ │
|
||||
│ │ ros-humble-desktop-full │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌──── colcon build/test ───────────┐ │
|
||||
│ │ 12 个包 / 80 测试 │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
[ ] py_pubsub — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] cpp_pubsub — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] py_srv — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] py_action_demo — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] py_params — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] cpp_custom_interface — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] py_vision_demo — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] cpp_robot_tf2 — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] cpp_qos_demo — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] py_lifecycle_composable — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] py_overlay_dds — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
[ ] bringup — 跑通 / 改过参数 / 改过代码 / 测过测试
|
||||
```
|
||||
|
||||
**两层解耦**:
|
||||
- **本机层**: venv 装开发工具(runtime 隔离),IDE 直接读源码
|
||||
- **容器层**: colcon 装 ROS2 节点(apt 来源,共享给所有用户)
|
||||
**判定 "L1 完成"**: 12 × 4 = **48 个勾** ≥ 36 个(75%)。
|
||||
|
||||
## 🎬 6 种端到端 demo
|
||||
---
|
||||
|
||||
| Demo | 命令 | 看什么 |
|
||||
## 🗺 学完之后下一步做什么?
|
||||
|
||||
| 阶段 | 内容 | 学完后能 |
|
||||
|---|---|---|
|
||||
| Topic 跨包跨语言 | `make launch NAME=pubsub_launch` | 4 节点(py+cpp)互通 |
|
||||
| Service | `make launch NAME=service_launch` + `ros2 service call ...` | `12+30=42` |
|
||||
| Action | `make launch NAME=action_launch` + `ros2 action send_goal ...` | Fibonacci(6) 边跑边反馈 |
|
||||
| Robot TF2 | `make launch NAME=robot_launch` | gripper 在 base_link 下实时位姿 |
|
||||
| Vision | `make launch NAME=vision_launch` | fake_camera → image_processor 图像流 |
|
||||
| Full demo | `make full-demo` | **11+ 节点同时运行** |
|
||||
| ✅ L1 基础(本仓库) | 12 包 + 78 测试 + 23 文档 | 自己设计 ROS2 项目 |
|
||||
| ➡️ L2 进阶 | ros2_control + MoveIt2 + Gazebo 仿真 | 控制真实机械臂 / 用仿真调参 |
|
||||
| ➡️ L3 真实机器人 | xArm / UR / Franka 驱动 | 上工业机械臂 |
|
||||
| ➡️ L4 具身智能 / VLA | OpenVLA / π0 / RKNN NPU 推理 | 让机器人理解自然语言指令 |
|
||||
|
||||
## 📚 23 篇文档导航
|
||||
详见 [doc/99-embodied-ai.md](doc/99-embodied-ai.md) 和 [doc/00-levels.md](doc/00-levels.md)。
|
||||
|
||||
### 上手
|
||||
- [`doc/00-overview.md`](doc/00-overview.md) — 项目架构 + 设计取舍
|
||||
- [`doc/00-levels.md`](doc/00-levels.md) — Level 1-4 学习路线(ROS2 → 机械臂 → VLA)
|
||||
- [`doc/01-quickstart.md`](doc/01-quickstart.md) — 5 分钟跑通
|
||||
- [`doc/02-virtualenv.md`](doc/02-virtualenv.md) — venv 工作流
|
||||
---
|
||||
|
||||
### ROS2 核心概念
|
||||
- [`doc/10-concepts.md`](doc/10-concepts.md) — Node / Topic / Service / Action / Parameter / TF / Time
|
||||
- [`doc/20-topics.md`](doc/20-topics.md) — Topic pub/sub 深度
|
||||
- [`doc/30-services.md`](doc/30-services.md) — Service req/resp 深度
|
||||
- [`doc/40-actions.md`](doc/40-actions.md) — Action 三件套深度
|
||||
- [`doc/15-params.md`](doc/15-params.md) — Parameter 系统深度 ⭐
|
||||
- [`doc/16-custom-interfaces.md`](doc/16-custom-interfaces.md) — 自定义 msg/srv/action ⭐
|
||||
- [`doc/17-lifecycle.md`](doc/17-lifecycle.md) — Lifecycle Node ⭐
|
||||
- [`doc/18-composable.md`](doc/18-composable.md) — Composable Node ⭐
|
||||
- [`doc/19-qos.md`](doc/19-qos.md) — QoS 全解 ⭐
|
||||
- [`doc/20-bag.md`](doc/20-bag.md) — ros2 bag ⭐
|
||||
- [`doc/21-overlay-dds.md`](doc/21-overlay-dds.md) — DDS + colcon overlay ⭐
|
||||
## 📖 术语速查(给完全零基础的新手)
|
||||
|
||||
### 机器人专属
|
||||
- [`doc/50-tf2.md`](doc/50-tf2.md) — 坐标变换
|
||||
- [`doc/60-urdf.md`](doc/60-urdf.md) — 机器人模型描述
|
||||
|
||||
### 工程实践
|
||||
- [`doc/70-launch.md`](doc/70-launch.md) — launch 文件系统
|
||||
- [`doc/80-package-build.md`](doc/80-package-build.md) — colcon / ament 包构建
|
||||
- [`doc/85-docker.md`](doc/85-docker.md) — Docker 容器化开发
|
||||
- [`doc/90-testing.md`](doc/90-testing.md) — 测试金字塔
|
||||
- [`doc/CODING_STYLE.md`](doc/CODING_STYLE.md) — **编程规范(必读)** ⭐
|
||||
|
||||
### 具身智能路径
|
||||
- [`doc/99-embodied-ai.md`](doc/99-embodied-ai.md) — VLA / 机器人开发路线图
|
||||
- [`doc/100-embedded-deployment.md`](doc/100-embedded-deployment.md) — **三机部署实操**
|
||||
|
||||
## 🗺 入门具身智能路径
|
||||
|
||||
| 阶段 | 内容 | 配套 |
|
||||
| 术语 | 一句话解释 | 生活化例子 |
|
||||
|---|---|---|
|
||||
| ✅ L1 基础 | ROS2 12 包 + 80 测试 + 23 文档 | **本仓库** |
|
||||
| ➡️ L2 进阶 | ros2_control + MoveIt2 + Gazebo | `ros-humble-*` apt |
|
||||
| ➡️ L3 机械臂 | 真实机械臂驱动 + 手眼标定 + 抓取 | xArm / UR / Franka |
|
||||
| ➡️ L4 VLA | OpenVLA / π0 / RKNN NPU 推理 | PC + RDK X5 + RK3506 |
|
||||
| **Node(节点)** | 一个独立的运行程序(进程) | 像手机里的每个 App |
|
||||
| **Topic(话题)** | 节点之间传递消息的"频道"(单向) | 像广播电台,谁都可以订阅 |
|
||||
| **Service(服务)** | 节点之间的"一问一答"调用(双向) | 像打电话,问完必须等回答 |
|
||||
| **Action(动作)** | 节点之间"长任务"调用,带进度回调 | 像外卖下单,可以取消 + 实时看进度 |
|
||||
| **Parameter(参数)** | 节点的"配置项",可以运行时改 | 像手机设置里的开关 |
|
||||
| **TF(坐标变换)** | 跟踪机器人各部件的空间位置关系 | 像人知道"我的手在身体左前方 30cm" |
|
||||
| **URDF** | 机器人的 3D 模型描述(关节 + 连杆) | 像机器人的"骨骼图纸" |
|
||||
| **QoS** | 消息传输的"质量策略"(可靠 vs 实时) | 像快递:顺丰可靠 vs 同城闪送快 |
|
||||
| **Lifecycle** | 节点的"生命周期"管理(初始化 → 运行 → 关闭) | 像手机 App 的启动 → 后台 → 退出 |
|
||||
| **DDS** | 节点之间真正"传消息"的底层协议 | 像快递公司,Topic 是地址,DDS 是车 |
|
||||
| **Launch 文件** | 一次性启动多个节点的"剧本" | 像一键启动所有 App |
|
||||
| **Package(包)** | 一个独立的 ROS2 项目单元(代码 + 配置 + 依赖) | 像 npm 包 / pip 包 |
|
||||
| **colcon** | ROS2 官方编译工具(类似 make) | 像 make,但专为 ROS2 设计 |
|
||||
| **ROS_DOMAIN_ID** | 节点的"网络分组"(0-232),不同 ID 的节点互不可见 | 像不同的 WiFi 频道 |
|
||||
|
||||
## 🛠 项目约定(必读)
|
||||
|
||||
代码风格 / 构建约束全部在 [`AGENTS.md`](AGENTS.md) + [`doc/CODING_STYLE.md`](doc/CODING_STYLE.md),核心几条:
|
||||
|
||||
1. **本机 venv 不污染系统 Python**(用 `tools/setup_venv.{sh,ps1}`)
|
||||
2. 容器内用 colcon + ament(ROS2 官方工具链)
|
||||
3. 跨包 launch 用 `IncludeLaunchDescription` + `FindPackageShare`
|
||||
4. 包名不能叫 `launch`(与 ROS2 系统包同名冲突)
|
||||
5. **测试 100% 通过才能停手**
|
||||
6. **不修改全局 git config** — 用 `git -c user.name=x -c user.email=y` 临时设
|
||||
---
|
||||
|
||||
## 🤝 致谢
|
||||
|
||||
@@ -167,4 +495,18 @@ docker exec ros2_dev bash -lc "cd /root/ros2_ws && colcon test --packages-select
|
||||
- [REP-2000: ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
|
||||
- [OSRF](https://www.openrobotics.org/) `osrf/ros:humble-desktop` 镜像
|
||||
|
||||
开始你的 ROS2 之旅:`doc/01-quickstart.md` → 跑通 → 读 `doc/10-concepts.md` 深入 → 上 `doc/99-embodied-ai.md` 部署。
|
||||
---
|
||||
|
||||
## 📜 项目元信息
|
||||
|
||||
| 文件 | 是什么 | 你要读吗 |
|
||||
|---|---|---|
|
||||
| [`CHANGELOG.md`](CHANGELOG.md) | 每次发布改了什么 | 想看版本历史 / 发版时 |
|
||||
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | 怎么贡献代码(PR 流程) | **只在你打算提 PR 时读** |
|
||||
| [`LICENSE`](LICENSE) | MIT 协议 | 想二次发布时读 |
|
||||
| [`pyproject.toml`](pyproject.toml) | PEP 621 包元数据 | 想 IDE 配置时 |
|
||||
| [`AGENTS.md`](AGENTS.md) | 开发者铁律(给 AI Agent 看的) | **不要读**,这是给 AI 写代码时的规则 |
|
||||
|
||||
---
|
||||
|
||||
**下一步** → 跑通上面的"从零开始:30 分钟跑通 Hello World",然后选 [第一梯队第 1 个包 py_pubsub](src/py_pubsub/README.md) 开始学。
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# build.sh —— 容器内一次性编译脚本。
|
||||
#
|
||||
# 注意:本脚本在 Docker 容器内跑,使用 ROS2 apt 安装的系统 Python (python3-rclpy)。
|
||||
# 本机的 venv(.venv)是给 IDE / 本机测试用的,不污染系统 Python;
|
||||
# 与容器内的构建是平行的两条线。详见 README.md 与 doc/01-virtualenv.md。
|
||||
#
|
||||
# 流程:
|
||||
# 1) source /opt/ros/humble/setup.bash
|
||||
# 这一句把 ROS2 CLI、消息库、ament 工具加进 PATH。
|
||||
# 2) colcon build --symlink-install --packages-select <all packages>
|
||||
# --symlink-install:对 Python/launch 文件做软链,改源码后无需重编;
|
||||
# C++ 可执行仍按 build 路径硬编(避免运行时找不到)。
|
||||
# --event-handlers console_direct+:把每个包的 build 输出直打到终端。
|
||||
# 3) ls install/:确认产物目录存在。
|
||||
# =============================================================================
|
||||
|
||||
# 出错即停,避免继续执行错误的结果。
|
||||
set -euo pipefail
|
||||
|
||||
# 加载 ROS2 基础环境变量与 PATH / PYTHONPATH / LD_LIBRARY_PATH。
|
||||
source /opt/ros/humble/setup.bash
|
||||
|
||||
# 切到挂载进来的工作空间根。
|
||||
cd /root/ros2_ws
|
||||
|
||||
echo "[BUILD] ROS_DISTRO=$ROS_DISTRO"
|
||||
echo "[BUILD] packages under src/:"
|
||||
ls src/
|
||||
|
||||
echo "[BUILD] colcon build (symlink-install)..."
|
||||
colcon build \
|
||||
--symlink-install \
|
||||
--packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo bringup \
|
||||
--event-handlers console_direct+
|
||||
|
||||
echo "[BUILD] done. install/:"
|
||||
ls install/
|
||||
|
||||
cat <<'TXT'
|
||||
|
||||
已就绪的常用命令(在使用前需要 source install/setup.bash):
|
||||
|
||||
# Topic pub/sub
|
||||
ros2 run py_pubsub talker # Python 发布者
|
||||
ros2 run py_pubsub listener # Python 订阅者
|
||||
ros2 run cpp_pubsub talker # C++ 发布者
|
||||
ros2 run cpp_pubsub listener # C++ 订阅者
|
||||
|
||||
# Service
|
||||
ros2 run py_srv add_two_ints_server # Python 服务端
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
|
||||
# Action
|
||||
ros2 run py_action_demo fibonacci_server # Python Action 服务端
|
||||
ros2 run py_action_demo fibonacci_client -- -o 8
|
||||
|
||||
# Robot (URDF + TF2 + JointState)
|
||||
ros2 launch cpp_robot_tf2 robot_tf2_launch.py
|
||||
|
||||
# Vision (sensor_msgs/Image)
|
||||
ros2 launch py_vision_demo vision_launch.py
|
||||
|
||||
# launch 文件
|
||||
ros2 launch py_pubsub pubsub_launch.py
|
||||
ros2 launch cpp_pubsub pubsub_launch.py topic:=hello
|
||||
ros2 launch py_srv srv_launch.py
|
||||
ros2 launch py_action_demo action_launch.py
|
||||
ros2 launch cpp_robot_tf2 robot_tf2_launch.py
|
||||
ros2 launch py_vision_demo vision_launch.py
|
||||
ros2 launch bringup full_demo_launch.py
|
||||
|
||||
# 调试 / 观测
|
||||
ros2 node list # 当前进程里的 ROS 节点
|
||||
ros2 topic list # 当前所有 topic
|
||||
ros2 topic info chatter -v # 看 pub/sub 端 + 消息类型
|
||||
ros2 topic echo chatter # 实时打印 chatter 内容
|
||||
ros2 topic hz chatter # 测量 chatter 频率(Hz)
|
||||
ros2 param list # 看节点的参数
|
||||
ros2 service list # 当前 service
|
||||
ros2 action list # 当前 action
|
||||
ros2 run tf2_tools view_frames # 可视化 TF 树
|
||||
rqt_graph # 可视化节点/话题关系图
|
||||
TXT
|
||||
+33
-16
@@ -51,9 +51,9 @@
|
||||
|
||||
## Level 1: ROS2 基础机制 (Foundation)
|
||||
|
||||
> **核心目标**: 完整理解 ROS2 的 12 大基础机制,能独立写节点 + launch 文件 + 自定义接口 + 生命周期管理。
|
||||
> **核心目标**: 完整理解 ROS2 的 15 大基础机制,能独立写节点 + launch 文件 + 自定义接口 + 生命周期管理。
|
||||
|
||||
### 12 大机制清单
|
||||
### 15 大机制清单
|
||||
|
||||
| # | 机制 | 当前包 | 文档 |
|
||||
|---|---|---|---|
|
||||
@@ -93,20 +93,23 @@
|
||||
### Level 1 测试覆盖
|
||||
|
||||
```
|
||||
py_pubsub 4 pytest ✅
|
||||
cpp_pubsub 2 gtest ✅
|
||||
py_srv 1 pytest ✅
|
||||
py_action_demo 1 pytest ✅
|
||||
cpp_robot_tf2 2 gtest ✅
|
||||
py_vision_demo 2 pytest ✅
|
||||
bringup 6 launch ✅
|
||||
py_params 3 pytest ⭐(新增)
|
||||
cpp_custom_interface 3 gtest ⭐(新增)
|
||||
py_lifecycle_composable 3 pytest ⭐(新增)
|
||||
cpp_qos_demo 3 gtest ⭐(新增)
|
||||
py_overlay_dds 2 pytest ⭐(新增)
|
||||
py_pubsub 11 pytest ✅
|
||||
cpp_pubsub 3 gtest ✅
|
||||
py_srv 7 pytest ✅
|
||||
py_action_demo 5 pytest ✅
|
||||
cpp_robot_tf2 4 gtest ✅
|
||||
py_vision_demo 13 pytest ✅
|
||||
bringup (launch 聚合,无单测)
|
||||
py_params 16 pytest ⭐(新增)
|
||||
cpp_custom_interface 3 gtest ⭐(新增)
|
||||
py_lifecycle_composable 6 pytest ⭐(新增)
|
||||
cpp_qos_demo 4 gtest ⭐(新增)
|
||||
py_overlay_dds 6 pytest ⭐(新增)
|
||||
|
||||
总计: 32 用例, 目标 100% 通过
|
||||
总计: **78 用例**(pytest 65 + gtest 13),目标 100% 通过
|
||||
|
||||
> 注:78 用例是当前仓库实测数(`colcon test` 结果),与 README/AGENTS 一致。
|
||||
> 上述表格列是"实测用例数"(非"测试文件数")。
|
||||
```
|
||||
|
||||
---
|
||||
@@ -340,4 +343,18 @@ py_overlay_dds 2 pytest ⭐(新增)
|
||||
|
||||
---
|
||||
|
||||
**继续**: 看 `doc/01-quickstart.md` 跑通第一个 demo → `doc/10-concepts.md` 理解 ROS2 概念 → `doc/15-params.md` Level 1 深度内容。
|
||||
**继续**: 看 `doc/01-quickstart.md` 跑通第一个 demo → `doc/10-concepts.md` 理解 ROS2 概念 → `doc/15-params.md` Level 1 深度内容。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 15 分钟
|
||||
> 📍 **当前位置**: 第 2 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [项目架构 + 设计取舍](../00-overview.md)
|
||||
- ⏭ **下一篇**: [5 分钟跑通 Hello World](../01-quickstart.md)
|
||||
|
||||
+14
-1
@@ -267,4 +267,17 @@ sudo apt install ros-humble-navigation2
|
||||
| 深读 Topic / Service / Action | [`20-topics.md`](20-topics.md), [`30-services.md`](30-services.md), [`40-actions.md`](40-actions.md) |
|
||||
| 做机器人 / TF / URDF | [`50-tf2.md`](50-tf2.md), [`60-urdf.md`](60-urdf.md) |
|
||||
| 三机部署到 RDK X5 / RK3506 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 进入具身智能 / VLA | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
| 进入具身智能 / VLA | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 20 分钟
|
||||
> 📍 **当前位置**: 第 1 / 24 篇
|
||||
|
||||
- ⏭ **下一篇**: [5 分钟跑通 Hello World](../01-quickstart.md)
|
||||
|
||||
+15
-1
@@ -456,4 +456,18 @@ docker system prune -a
|
||||
|
||||
**动手尝试**: 改一下 `py_pubsub` 里 talker 的 `period_ms`,观察 `/chatter` 频率变化。这是理解 ROS2 参数的最快方式。
|
||||
|
||||
加油,ROS2 之旅开始! 🚀
|
||||
加油,ROS2 之旅开始! 🚀
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 30 分钟
|
||||
> 📍 **当前位置**: 第 3 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Level 1-4 学习路线](../00-levels.md)
|
||||
- ⏭ **下一篇**: [Node / Topic / Service / Action / TF / Time](../10-concepts.md)
|
||||
|
||||
+15
-1
@@ -274,4 +274,18 @@ docker exec ros2_dev bash -lc "source install/setup.bash && ros2 launch bringup
|
||||
| 项目总览 | [`00-overview.md`](00-overview.md) |
|
||||
| 5 分钟上手 | [`01-quickstart.md`](01-quickstart.md) |
|
||||
| 测试策略 | [`90-testing.md`](90-testing.md) |
|
||||
| Docker | [`85-docker.md`](85-docker.md) |
|
||||
| Docker | [`85-docker.md`](85-docker.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 20 分钟
|
||||
> 📍 **当前位置**: 第 4 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [5 分钟跑通 Hello World](../01-quickstart.md)
|
||||
- ⏭ **下一篇**: [项目架构 + 设计取舍](../00-overview.md)
|
||||
|
||||
+15
-1
@@ -861,4 +861,18 @@ export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
|
||||
| URDF / xacro / robot_state_publisher | [`60-urdf.md`](60-urdf.md) |
|
||||
| launch 嵌套、参数覆盖、事件 | [`70-launch.md`](70-launch.md) |
|
||||
| 三机部署(PC + RDK X5 + RK3506) | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 进入具身智能 / VLA / 机器人 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
| 进入具身智能 / VLA / 机器人 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 90 分钟
|
||||
> 📍 **当前位置**: 第 5 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [本机 venv 工作流(可选)](../02-virtualenv.md)
|
||||
- ⏭ **下一篇**: [Topic pub/sub 深度](../20-topics.md)
|
||||
|
||||
@@ -915,4 +915,17 @@ ros2 launch moveit2_tutorials demo.launch.py
|
||||
- Gazebo 物理仿真验证(在 PC 上)
|
||||
- NPU 推理在 RDK X5 / RK3506 #2 上跑(YOLO-Lite)
|
||||
|
||||
如需在本仓库加 `ros2_control_demo` / `motor_driver` 包,后续 PR 即可。
|
||||
如需在本仓库加 `ros2_control_demo` / `motor_driver` 包,后续 PR 即可。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 90 分钟
|
||||
> 📍 **当前位置**: 第 23 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [VLA / 机器人开发路线图](../99-embodied-ai.md)
|
||||
|
||||
+15
-1
@@ -625,4 +625,18 @@ if not result.successful:
|
||||
|
||||
> **ROS2 参数 = 节点的"配置项",从 launch / YAML / CLI 传入,运行时可改,回调里能拒绝非法值。设计目标是"配置与代码解耦"。**
|
||||
|
||||
下一节: `doc/16-custom-interfaces.md` 学习自定义 .msg / .srv / .action。
|
||||
下一节: `doc/16-custom-interfaces.md` 学习自定义 .msg / .srv / .action。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 60 分钟
|
||||
> 📍 **当前位置**: 第 6 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Node / Topic / Service / Action / TF / Time](../10-concepts.md)
|
||||
- ⏭ **下一篇**: [自定义 .msg/.srv/.action](../16-custom-interfaces.md)
|
||||
|
||||
@@ -193,4 +193,18 @@ publisher.publish(msg)
|
||||
- [ROS2 自定义接口官方教程](https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Custom-ROS2-Interfaces.html)
|
||||
- [REP-127: ROS Message 标准](https://www.ros.org/reps/rep-0127.html)
|
||||
- [rosidl 文档](https://design.ros2.org/articles/legacy_interface_definition.html)
|
||||
- [`cpp_custom_interface` 包](../src/cpp_custom_interface/README.md) — 本仓库的演示
|
||||
- [`cpp_custom_interface` 包](../src/cpp_custom_interface/README.md) — 本仓库的演示
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 40 分钟
|
||||
> 📍 **当前位置**: 第 7 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [参数系统深度](../15-params.md)
|
||||
- ⏭ **下一篇**: [Lifecycle Node](../17-lifecycle.md)
|
||||
|
||||
+15
-1
@@ -246,4 +246,18 @@ def on_cleanup(self, state):
|
||||
|
||||
- [ROS2 Lifecycle 设计稿](https://design.ros2.org/articles/node_lifecycle.html)
|
||||
- [ROS2 Lifecycle Humble 教程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/Using-Event-Handlers.html)
|
||||
- [`py_lifecycle_composable` 包](../src/py_lifecycle_composable/README.md)
|
||||
- [`py_lifecycle_composable` 包](../src/py_lifecycle_composable/README.md)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 30 分钟
|
||||
> 📍 **当前位置**: 第 8 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [自定义 .msg/.srv/.action](../16-custom-interfaces.md)
|
||||
- ⏭ **下一篇**: [Composable Node](../18-composable.md)
|
||||
|
||||
+15
-1
@@ -185,4 +185,18 @@ Python 多节点同进程 + MultiThreadedExecutor 是 ROS2 Python 等价的 Comp
|
||||
- [ROS2 Composition 设计稿](https://design.ros2.org/articles/composition.html)
|
||||
- [ROS2 Humble Composition 教程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/Using-Event-Handlers.html)
|
||||
- [ros2 component CLI](https://docs.ros.org/en/humble/Tutorials/Intermediate/Composition.html)
|
||||
- [`py_lifecycle_composable` 包](../src/py_lifecycle_composable/README.md)
|
||||
- [`py_lifecycle_composable` 包](../src/py_lifecycle_composable/README.md)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 25 分钟
|
||||
> 📍 **当前位置**: 第 9 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Lifecycle Node](../17-lifecycle.md)
|
||||
- ⏭ **下一篇**: [QoS 全解](../19-qos.md)
|
||||
|
||||
+15
-1
@@ -177,4 +177,18 @@ echo $ROS_DOMAIN_ID
|
||||
- [ROS2 Humble QoS 文档](https://docs.ros.org/en/humble/Concepts/About-Quality-of-Service.html)
|
||||
- [OMG DDS 规范 v1.4](https://www.omg.org/spec/DDS/1.4/) — QoS 源头
|
||||
- [FastDDS QoS 配置](https://fast-dds.docs.eprosima.com/)
|
||||
- [`cpp_qos_demo` 包](../src/cpp_qos_demo/README.md)
|
||||
- [`cpp_qos_demo` 包](../src/cpp_qos_demo/README.md)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 25 分钟
|
||||
> 📍 **当前位置**: 第 10 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Composable Node](../18-composable.md)
|
||||
- ⏭ **下一篇**: [Topic pub/sub 深度](../20-topics.md)
|
||||
|
||||
+15
-1
@@ -179,4 +179,18 @@ with Reader('vla_episode_001') as reader:
|
||||
|
||||
- [ROS2 bag 命令行](https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Recording-And-Playing-Back-Data.html)
|
||||
- [rosbags Python 库](https://github.com/idx-lab/rosbags)
|
||||
- [Foxglove Studio(可视化 bag)](https://studio.foxglove.dev/)
|
||||
- [Foxglove Studio(可视化 bag)](https://studio.foxglove.dev/)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 20 分钟
|
||||
> 📍 **当前位置**: 第 12 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Topic pub/sub 深度](../20-topics.md)
|
||||
- ⏭ **下一篇**: [DDS 配置 + colcon overlay](../21-overlay-dds.md)
|
||||
|
||||
+15
-1
@@ -632,4 +632,18 @@ ros2 run discovery_server discovery_server --address 0.0.0.0 --port 11811
|
||||
| Action 深度 | [`40-actions.md`](40-actions.md) |
|
||||
| TF2 坐标变换 | [`50-tf2.md`](50-tf2.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 具身智能路径 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
| 具身智能路径 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 60 分钟
|
||||
> 📍 **当前位置**: 第 11 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [QoS 全解](../19-qos.md)
|
||||
- ⏭ **下一篇**: [Service 深度](../30-services.md)
|
||||
|
||||
+15
-1
@@ -211,4 +211,18 @@ source /opt/ros/humble/setup.bash
|
||||
- [colcon 文档](https://colcon.readthedocs.io/)
|
||||
- [REP-2002 ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
|
||||
- [`py_overlay_dds` 包](../src/py_overlay_dds/README.md)
|
||||
- [三机部署实操](100-embedded-deployment.md)
|
||||
- [三机部署实操](100-embedded-deployment.md)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 30 分钟
|
||||
> 📍 **当前位置**: 第 13 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [ros2 bag 数据记录](../20-bag.md)
|
||||
- ⏭ **下一篇**: [Docker 容器化开发](../85-docker.md)
|
||||
|
||||
+15
-1
@@ -507,4 +507,18 @@ rclpy.spin(node)
|
||||
| Topic 深度 | [`20-topics.md`](20-topics.md) |
|
||||
| Action 深度 | [`40-actions.md`](40-actions.md) |
|
||||
| TF2 坐标变换 | [`50-tf2.md`](50-tf2.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 50 分钟
|
||||
> 📍 **当前位置**: 第 14 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [DDS 配置 + colcon overlay](../21-overlay-dds.md)
|
||||
- ⏭ **下一篇**: [Action 深度](../40-actions.md)
|
||||
|
||||
+15
-1
@@ -749,4 +749,18 @@ string current_state
|
||||
| Service | [`30-services.md`](30-services.md) |
|
||||
| TF2 + 抓取 | [`50-tf2.md`](50-tf2.md) |
|
||||
| 具身智能路径 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 60 分钟
|
||||
> 📍 **当前位置**: 第 15 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Service 深度](../30-services.md)
|
||||
- ⏭ **下一篇**: [坐标变换](../50-tf2.md)
|
||||
|
||||
+15
-1
@@ -514,4 +514,18 @@ chronyc tracking
|
||||
| ros2_control | 在 [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 2 |
|
||||
| MoveIt2 | 在 [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 2 |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| VLA 应用 | [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 5 |
|
||||
| VLA 应用 | [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 5 |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 50 分钟
|
||||
> 📍 **当前位置**: 第 16 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Action 深度](../40-actions.md)
|
||||
- ⏭ **下一篇**: [机器人模型描述](../60-urdf.md)
|
||||
|
||||
+15
-1
@@ -708,4 +708,18 @@ ros2 launch moveit_setup_assistant setup_assistant.launch.py
|
||||
| TF 坐标变换 | [`50-tf2.md`](50-tf2.md) |
|
||||
| ros2_control + MoveIt2 | [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 2 |
|
||||
| launch 文件 | [`70-launch.md`](70-launch.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 60 分钟
|
||||
> 📍 **当前位置**: 第 17 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [坐标变换](../50-tf2.md)
|
||||
- ⏭ **下一篇**: [launch 文件系统](../70-launch.md)
|
||||
|
||||
+15
-1
@@ -465,4 +465,18 @@ def generate_launch_description():
|
||||
| Service | [`30-services.md`](30-services.md) |
|
||||
| Action | [`40-actions.md`](40-actions.md) |
|
||||
| colcon / ament 包构建 | [`80-package-build.md`](80-package-build.md) |
|
||||
| 测试策略 | [`90-testing.md`](90-testing.md) |
|
||||
| 测试策略 | [`90-testing.md`](90-testing.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 50 分钟
|
||||
> 📍 **当前位置**: 第 18 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [机器人模型描述](../60-urdf.md)
|
||||
- ⏭ **下一篇**: [colcon / ament 包构建](../80-package-build.md)
|
||||
|
||||
+15
-1
@@ -402,4 +402,18 @@ bloom-release --rosdistro humble --track humble --new 0.1.0 my_pkg
|
||||
| Launch 文件 | [`70-launch.md`](70-launch.md) |
|
||||
| Docker 开发 | [`85-docker.md`](85-docker.md) |
|
||||
| 测试策略 | [`90-testing.md`](90-testing.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 40 分钟
|
||||
> 📍 **当前位置**: 第 19 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [launch 文件系统](../70-launch.md)
|
||||
- ⏭ **下一篇**: [Docker 容器化开发](../85-docker.md)
|
||||
|
||||
+15
-1
@@ -402,4 +402,18 @@ COPY --from=builder /root/ros2_ws/install /root/ros2_ws/install
|
||||
| venv 工作流 | [`02-virtualenv.md`](02-virtualenv.md) |
|
||||
| 测试策略 | [`90-testing.md`](90-testing.md) |
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 项目总览 | [`00-overview.md`](00-overview.md) |
|
||||
| 项目总览 | [`00-overview.md`](00-overview.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 40 分钟
|
||||
> 📍 **当前位置**: 第 20 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [colcon / ament 包构建](../80-package-build.md)
|
||||
- ⏭ **下一篇**: [测试金字塔](../90-testing.md)
|
||||
|
||||
+15
-1
@@ -454,4 +454,18 @@ subprocess.run(['pkill', '-9', '-f', 'add_two_ints_server'])
|
||||
|---|---|
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 具身智能路径 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
| Docker 开发 | [`85-docker.md`](85-docker.md) |
|
||||
| Docker 开发 | [`85-docker.md`](85-docker.md) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 50 分钟
|
||||
> 📍 **当前位置**: 第 21 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [Docker 容器化开发](../85-docker.md)
|
||||
- ⏭ **下一篇**: [编程规范(必读)](../CODING_STYLE.md)
|
||||
|
||||
+15
-1
@@ -575,4 +575,18 @@ OpenVLA 7B 约 15GB,需要 GPU(NVIDIA RTX 3090+)。
|
||||
|
||||
每阶段做完后,**回到本仓库做一次 colcon test 验证 ROS2 通信基础仍然稳**。
|
||||
|
||||
加油,具身智能之旅! 🚀
|
||||
加油,具身智能之旅! 🚀
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 60 分钟
|
||||
> 📍 **当前位置**: 第 22 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [测试金字塔](../90-testing.md)
|
||||
- ⏭ **下一篇**: [三机部署实操](../100-embedded-deployment.md)
|
||||
|
||||
+14
-1
@@ -772,4 +772,17 @@ self.timer_: Timer = self.create_timer(...)
|
||||
---
|
||||
|
||||
**违反任何一条,代码不得合并。**
|
||||
**一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。**
|
||||
**一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。**
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📖 阅读路径导航
|
||||
|
||||
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
|
||||
>
|
||||
> ⏱ **本文预计阅读时间**: 90 分钟
|
||||
> 📍 **当前位置**: 第 24 / 24 篇
|
||||
|
||||
- ⏮ **上一篇**: [三机部署实操](../100-embedded-deployment.md)
|
||||
|
||||
+135
-42
@@ -1,57 +1,150 @@
|
||||
# bringup
|
||||
# bringup — 跨包 launch 聚合(11 节点一键启动)
|
||||
|
||||
顶层 launch 聚合包。属于 Level 1 跨包集成层。
|
||||
> 最后一个包:**整合所有 demo 一键启动**。
|
||||
>
|
||||
> 预计学习时间:30 分钟。
|
||||
|
||||
## 6 个 launch 文件
|
||||
---
|
||||
|
||||
| 文件 | 启动节点 | 数量 |
|
||||
|---|---|---|
|
||||
| `pubsub_launch.py` | py_pubsub + cpp_pubsub 的 pub/sub | 4 |
|
||||
| `service_launch.py` | add_two_ints_server | 1 |
|
||||
| `action_launch.py` | fibonacci_action_server | 1 |
|
||||
| `robot_launch.py` | URDF + JointState + TF(nested `cpp_robot_tf2`) | 3 |
|
||||
| `vision_launch.py` | fake_camera + image_processor(nested `py_vision_demo`) | 2 |
|
||||
| `full_demo_launch.py` | 上面所有组合 | **11** |
|
||||
## 这是什么?
|
||||
|
||||
## 关键设计
|
||||
|
||||
- **不实现业务节点** — 只用 `IncludeLaunchDescription` 复用其他包的 launch
|
||||
- **跨包嵌套** — `robot_launch.py` / `vision_launch.py` 用 `FindPackageShare` + `PythonLaunchDescriptionSource` 引用单包 launch
|
||||
- **包名避让** — 包名是 `bringup`,**不是** `launch`(与 ROS2 系统包同名会冲突)
|
||||
|
||||
## 运行
|
||||
`bringup` 包专门用来**跨包启动多个节点**。ROS2 项目一般会有一个 `bringup` 包负责"启动整个机器人"。
|
||||
|
||||
**典型用法**:
|
||||
```bash
|
||||
# 各 demo 单独启动
|
||||
ros2 launch bringup pubsub_launch.py
|
||||
ros2 launch bringup service_launch.py
|
||||
ros2 launch bringup action_launch.py
|
||||
ros2 launch bringup robot_launch.py
|
||||
ros2 launch bringup vision_launch.py
|
||||
|
||||
# 全开(11 节点同时跑)
|
||||
ros2 launch bringup full_demo_launch.py
|
||||
```
|
||||
|
||||
## 验证端到端
|
||||
一个命令启动所有 11 个节点(模拟整个机器人系统)。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 用 `IncludeLaunchDescription` 复用其他包的 launch
|
||||
2. ✅ 用 `FindPackageShare` 找其他包的资源路径
|
||||
3. ✅ 写一个聚合 launch,启动整个系统
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 一键启动 11 节点
|
||||
|
||||
```bash
|
||||
# 跨包跨语言 Topic:PY + CPP 同时发,listener 都收到
|
||||
ros2 launch bringup pubsub_launch.py
|
||||
ros2 topic info /chatter -v
|
||||
# 预期:Publication count: 2, Subscription count: 2
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
# Action
|
||||
ros2 launch bringup action_launch.py
|
||||
ros2 action send_goal /fibonacci example_interfaces/action/Fibonacci "{order: 6}" --feedback
|
||||
|
||||
# Service
|
||||
ros2 launch bringup service_launch.py
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
# 预期: sum: 42
|
||||
ros2 launch bringup full_demo_launch.py
|
||||
```
|
||||
|
||||
## 深度学习
|
||||
**预期**:11 个节点同时跑,看到各种 Topic/Service/Action 交互。
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md) §5.5
|
||||
- launch 深度:[`doc/70-launch.md`](../doc/70-launch.md)
|
||||
### 单独启动某个 launch
|
||||
|
||||
```bash
|
||||
# 只启动 Topic 演示(4 节点)
|
||||
ros2 launch bringup pubsub_launch.py
|
||||
|
||||
# 只启动机器人 TF2 演示(3 节点)
|
||||
ros2 launch bringup robot_launch.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/bringup/
|
||||
├── launch/
|
||||
│ ├── full_demo_launch.py # 启动 11 节点
|
||||
│ ├── pubsub_launch.py # Topic 演示(4 节点)
|
||||
│ ├── service_launch.py # Service 演示
|
||||
│ ├── action_launch.py # Action 演示
|
||||
│ ├── robot_launch.py # TF2 + URDF 演示
|
||||
│ ├── vision_launch.py # 图像演示
|
||||
│ └── params_launch.py # 参数演示
|
||||
├── setup.py
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心代码(IncludeLaunchDescription)
|
||||
|
||||
```python
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import IncludeLaunchDescription
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
import os
|
||||
|
||||
def generate_launch_description():
|
||||
# 找其他包的 share 目录
|
||||
py_pubsub_dir = get_package_share_directory('py_pubsub')
|
||||
|
||||
return LaunchDescription([
|
||||
# 启动 py_pubsub 的 launch
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(py_pubsub_dir, 'launch', 'pubsub_launch.py')
|
||||
),
|
||||
launch_arguments={'publish_rate_hz': '2.0'}.items(), # 覆盖参数
|
||||
),
|
||||
|
||||
# 启动 cpp_pubsub
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(get_package_share_directory('cpp_pubsub'), 'launch', 'pubsub_cpp_launch.py')
|
||||
),
|
||||
),
|
||||
|
||||
# ... 其他包
|
||||
])
|
||||
```
|
||||
|
||||
**关键 API**:
|
||||
- `get_package_share_directory('pkg_name')`:拿其他包的 share 路径
|
||||
- `IncludeLaunchDescription`:复用其他 launch
|
||||
- `launch_arguments={'param': 'value'}.items()`:覆盖参数
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 命名约束
|
||||
|
||||
ROS2 系统有一个 `launch` 包(`ros-humble-launch`),所以**包名不能叫 `launch`**(会冲突)。本仓库用 `bringup` 这个名字(机器人行业惯例)。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select bringup
|
||||
```
|
||||
|
||||
**预期**:`bringup` 没有 pytest/gtest(launch 包不写测试代码,只整合)。
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/70-launch.md](../../doc/70-launch.md) — launch 文件深度
|
||||
|
||||
---
|
||||
|
||||
## 🎉 学完整个仓库了!
|
||||
|
||||
接下来:
|
||||
1. **做项目**:用本仓库的模板做自己的 ROS2 项目
|
||||
2. **学 Level 2**:看 [doc/99-embodied-ai.md](../../doc/99-embodied-ai.md),学 ros2_control + MoveIt2 + Gazebo
|
||||
3. **上真实机器人**:看 [doc/100-embedded-deployment.md](../../doc/100-embedded-deployment.md),PC + RDK X5 + RK3506 三机部署
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| [py_overlay_dds — Python DDS + overlay](../py_overlay_dds/README.md) | **bringup — 跨包 launch 聚合** | (学完整个仓库 🎉) |
|
||||
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
# - 必须用 rosidl_generate_interfaces 自动生成 msg/srv/action 的 C++ + Python 代码
|
||||
# - 生成的代码会装到 install/cpp_custom_interface/include/...
|
||||
# - 然后被同包或跨包的 C++ 代码 include
|
||||
# - 库 + 可执行分离(便于测试只链接库)
|
||||
#
|
||||
# 参考:
|
||||
# - 自定义接口: https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Custom-ROS2-Interfaces.html
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cpp_custom_interface LANGUAGES CXX)
|
||||
project(cpp_custom_interface LANGUAGES CXX C)
|
||||
|
||||
if(NOT CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
@@ -19,7 +20,7 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# 1) 找依赖
|
||||
# 必备: 找 ament_cmake + 依赖
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(rclcpp_action REQUIRED)
|
||||
@@ -27,26 +28,17 @@ find_package(std_msgs REQUIRED)
|
||||
find_package(geometry_msgs REQUIRED)
|
||||
find_package(rosidl_default_generators REQUIRED)
|
||||
|
||||
# 2) 定义接口(关键步骤)
|
||||
set(MSG_FILES
|
||||
"msg/SensorReading.msg"
|
||||
)
|
||||
set(SRV_FILES
|
||||
"srv/GetCalibration.srv"
|
||||
)
|
||||
set(ACTION_FILES
|
||||
"action/MoveArm.action"
|
||||
)
|
||||
# 定义接口(关键步骤)
|
||||
set(MSG_FILES "msg/SensorReading.msg")
|
||||
set(SRV_FILES "srv/GetCalibration.srv")
|
||||
set(ACTION_FILES "action/MoveArm.action")
|
||||
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
${MSG_FILES}
|
||||
${SRV_FILES}
|
||||
${ACTION_FILES}
|
||||
${MSG_FILES} ${SRV_FILES} ${ACTION_FILES}
|
||||
DEPENDENCIES std_msgs geometry_msgs
|
||||
ADD_LINTER_TESTS
|
||||
)
|
||||
|
||||
# 3) C++ 库(节点代码)
|
||||
# C++ 库(只含类实现,不含 main)
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/sensor_publisher.cpp
|
||||
src/calibration_server.cpp
|
||||
@@ -57,23 +49,24 @@ target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core
|
||||
rclcpp rclcpp_action "rosidl_typesupport_cpp"
|
||||
rclcpp rclcpp_action
|
||||
)
|
||||
rosidl_target_interfaces(${PROJECT_NAME}_core
|
||||
rosidl_get_typesupport_target(cpp_typesupport_cpp
|
||||
${PROJECT_NAME} "rosidl_typesupport_cpp"
|
||||
)
|
||||
target_link_libraries(${PROJECT_NAME}_core ${cpp_typesupport_cpp})
|
||||
|
||||
# 4) 可执行
|
||||
add_executable(sensor_publisher_cpp src/sensor_publisher.cpp)
|
||||
# 可执行(main 单独文件)
|
||||
add_executable(sensor_publisher_cpp src/sensor_publisher_main.cpp)
|
||||
target_link_libraries(sensor_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(calibration_server_cpp src/calibration_server.cpp)
|
||||
add_executable(calibration_server_cpp src/calibration_server_main.cpp)
|
||||
target_link_libraries(calibration_server_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(move_arm_server_cpp src/move_arm_server.cpp)
|
||||
add_executable(move_arm_server_cpp src/move_arm_server_main.cpp)
|
||||
target_link_libraries(move_arm_server_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 5) 安装
|
||||
# 安装
|
||||
install(TARGETS
|
||||
sensor_publisher_cpp
|
||||
calibration_server_cpp
|
||||
@@ -85,4 +78,16 @@ install(DIRECTORY launch
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
# 测试
|
||||
if(BUILD_TESTING)
|
||||
find_package(ament_cmake_gtest REQUIRED)
|
||||
ament_add_gtest(test_custom_interfaces test/test_custom_interfaces.cpp)
|
||||
ament_target_dependencies(test_custom_interfaces rclcpp rclcpp_action)
|
||||
target_link_libraries(test_custom_interfaces ${PROJECT_NAME}_core)
|
||||
rosidl_get_typesupport_target(cpp_typesupport_cpp
|
||||
${PROJECT_NAME} "rosidl_typesupport_cpp"
|
||||
)
|
||||
target_link_libraries(test_custom_interfaces ${cpp_typesupport_cpp})
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
@@ -1,67 +1,259 @@
|
||||
# cpp_custom_interface
|
||||
# cpp_custom_interface — C++ 自定义接口(.msg / .srv / .action)
|
||||
|
||||
ROS2 自定义接口演示包(C++)。属于 Level 1 基础机制第 10 块。
|
||||
> 学会自定义 ROS2 接口,跨包/跨语言共享数据结构。
|
||||
>
|
||||
> 预计学习时间:2-3 小时。
|
||||
|
||||
## 自定义接口
|
||||
---
|
||||
|
||||
| 类型 | 文件 | 字段 |
|
||||
|---|---|---|
|
||||
| `msg` | `SensorReading.msg` | header + sensor_id + unit + value |
|
||||
| `srv` | `GetCalibration.srv` | req: sensor_id / resp: intrinsic_matrix[9] + bias[3] + date + valid |
|
||||
| `action` | `MoveArm.action` | goal: target_pose + joint_names + scaling / feedback: progress + state / result: success + time |
|
||||
## 这是什么?
|
||||
|
||||
## 节点
|
||||
ROS2 自带的消息类型(`std_msgs/String`、`geometry_msgs/Twist`...)够用吗?不够。
|
||||
|
||||
- **`sensor_publisher_cpp`**: 周期性发布 `SensorReading`(正弦曲线模拟传感器读数)
|
||||
- **`calibration_server_cpp`**: 提供 `GetCalibration` 服务(返回模拟相机内参)
|
||||
- **`move_arm_server_cpp`**: 提供 `MoveArm` Action(5 阶段模拟移动)
|
||||
**做项目一定要自定义接口**,比如:
|
||||
- 机器人: `/robot_status.msg` (含电量、位置、状态)
|
||||
- 机械臂: `/MoveArm.action` (含目标位姿 + 反馈进度 + 结果)
|
||||
- 相机: `/CameraCalibration.srv` (含内外参矩阵)
|
||||
|
||||
## 关键概念
|
||||
**本包演示**:
|
||||
- `.msg` (消息,Topic 用): `SensorReading`(传感器读数)
|
||||
- `.srv` (服务): `GetCalibration`(获取标定数据)
|
||||
- `.action` (动作): `MoveArm`(机械臂运动)
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `rosidl_generate_interfaces` | 自动生成 C++ / Python 接口代码 |
|
||||
| `.msg` / `.srv` / `.action` | 接口定义文件 |
|
||||
| `rosidl_default_generators` | 构建时依赖 |
|
||||
| `rosidl_default_runtime` | 运行时依赖 |
|
||||
| `ament_cmake` | C++ 包构建类型 |
|
||||
---
|
||||
|
||||
## 运行
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
```bash
|
||||
# 启动所有自定义接口节点
|
||||
ros2 launch cpp_custom_interface custom_launch.py
|
||||
1. ✅ 定义 `.msg` / `.srv` / `.action` 文件
|
||||
2. ✅ 用 `rosidl_generate_interfaces` 生成 C++ / Python 代码
|
||||
3. ✅ 在自己的包里 include 生成的 C++ 头文件
|
||||
4. ✅ 跨包/跨语言用自定义类型通信
|
||||
|
||||
# CLI 调用服务
|
||||
ros2 service call /get_calibration cpp_custom_interface/srv/GetCalibration "{sensor_id: 'lidar_front'}"
|
||||
---
|
||||
|
||||
# CLI 发 Action goal
|
||||
ros2 action send_goal /move_arm cpp_custom_interface/action/MoveArm "{
|
||||
target_pose: {header: {frame_id: 'base_link'}, pose: {position: {x: 0.3, y: 0.0, z: 0.2}, orientation: {w: 1.0}}},
|
||||
joint_names: ['joint1','joint2','joint3'],
|
||||
max_velocity_scaling: 0.5,
|
||||
max_acceleration_scaling: 0.5
|
||||
}" --feedback
|
||||
## 📁 文件结构
|
||||
|
||||
# CLI 看自定义消息
|
||||
ros2 topic echo /sensor_reading
|
||||
```
|
||||
src/cpp_custom_interface/
|
||||
├── msg/SensorReading.msg # 消息定义
|
||||
├── srv/GetCalibration.srv # 服务定义
|
||||
├── action/MoveArm.action # 动作定义
|
||||
├── include/cpp_custom_interface/ # 生成的头文件会被装到这里
|
||||
├── src/
|
||||
│ ├── sensor_publisher.cpp/hpp # 发布自定义 msg 的 Publisher
|
||||
│ ├── calibration_server.cpp/hpp # 自定义 srv 的 Server
|
||||
│ └── move_arm_server.cpp/hpp # 自定义 action 的 Server
|
||||
├── test/test_custom_interfaces.cpp # 测试生成的接口
|
||||
├── CMakeLists.txt # ⭐ 关键:rosidl_generate_interfaces
|
||||
└── package.xml
|
||||
```
|
||||
|
||||
## 测试
|
||||
---
|
||||
|
||||
## 📖 接口定义文件格式
|
||||
|
||||
### .msg(SensorReading.msg)
|
||||
|
||||
```
|
||||
std_msgs/Header header # 用其他包的消息类型
|
||||
string sensor_id
|
||||
string unit
|
||||
float64 value
|
||||
```
|
||||
|
||||
类型:`string`、`int32`、`float64`、`bool`、嵌套其他 `msg/...`
|
||||
|
||||
### .srv(GetCalibration.srv)
|
||||
|
||||
```
|
||||
string sensor_id # 请求字段
|
||||
---
|
||||
float64[9] intrinsic_matrix # 响应字段
|
||||
float64[3] bias
|
||||
string calibration_date
|
||||
bool valid
|
||||
```
|
||||
|
||||
`---` 上是请求,下面是响应。
|
||||
|
||||
### .action(MoveArm.action)
|
||||
|
||||
```
|
||||
# Goal
|
||||
float64 max_velocity_scaling
|
||||
---
|
||||
# Result
|
||||
bool success
|
||||
string error_message
|
||||
float64 total_time_sec
|
||||
---
|
||||
# Feedback
|
||||
float32 progress
|
||||
string current_state
|
||||
```
|
||||
|
||||
三段分别是 **Goal / Result / Feedback**。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 启动自定义 Publisher
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run cpp_custom_interface sensor_publisher_cpp
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [sensor_publisher]: SensorPublisher started: topic="/sensor_reading"
|
||||
```
|
||||
|
||||
### 订阅看消息(另开终端)
|
||||
|
||||
```bash
|
||||
ros2 topic echo /sensor_reading --once
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
header:
|
||||
stamp:
|
||||
sec: ...
|
||||
nanosec: ...
|
||||
frame_id: imu_frame
|
||||
sensor_id: imu_0
|
||||
unit: rad/s
|
||||
value: 0.0
|
||||
```
|
||||
|
||||
### 启动标定服务(另开终端)
|
||||
|
||||
```bash
|
||||
ros2 run cpp_custom_interface calibration_server_cpp
|
||||
```
|
||||
|
||||
```bash
|
||||
# 调用服务
|
||||
ros2 service call /get_calibration cpp_custom_interface/srv/GetCalibration "{sensor_id: 'lidar_front'}"
|
||||
```
|
||||
|
||||
### 启动机械臂 Action(另开终端)
|
||||
|
||||
```bash
|
||||
ros2 run cpp_custom_interface move_arm_server_cpp
|
||||
```
|
||||
|
||||
```bash
|
||||
# 发 goal
|
||||
ros2 action send_goal --feedback /move_arm cpp_custom_interface/action/MoveArm "{max_velocity_scaling: 0.5}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 CMakeLists.txt 关键配置
|
||||
|
||||
```cmake
|
||||
# 1) 定义接口文件
|
||||
set(MSG_FILES "msg/SensorReading.msg")
|
||||
set(SRV_FILES "srv/GetCalibration.srv")
|
||||
set(ACTION_FILES "action/MoveArm.action")
|
||||
|
||||
# 2) 生成 C++ + Python 代码(关键!)
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
${MSG_FILES} ${SRV_FILES} ${ACTION_FILES}
|
||||
DEPENDENCIES std_msgs geometry_msgs
|
||||
)
|
||||
|
||||
# 3) 链接生成的 typesupport 库
|
||||
rosidl_get_typesupport_target(cpp_typesupport
|
||||
${PROJECT_NAME} "rosidl_typesupport_cpp")
|
||||
target_link_libraries(${LIBRARY_NAME} ${cpp_typesupport})
|
||||
|
||||
# 4) 包必须 <member_of_group>rosidl_interface_packages</member_of_group>
|
||||
```
|
||||
|
||||
**package.xml 必须加**:
|
||||
```xml
|
||||
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||
<buildtool_depend>rosidl_default_generators</buildtool_depend>
|
||||
<exec_depend>rosidl_default_runtime</exec_depend>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 在自己的代码里使用生成的接口
|
||||
|
||||
### C++
|
||||
|
||||
```cpp
|
||||
#include "cpp_custom_interface/msg/sensor_reading.hpp"
|
||||
#include "cpp_custom_interface/srv/get_calibration.hpp"
|
||||
#include "cpp_custom_interface/action/move_arm.hpp"
|
||||
|
||||
// 使用消息类型
|
||||
auto msg = cpp_custom_interface::msg::SensorReading();
|
||||
msg.sensor_id = "imu_0";
|
||||
msg.value = 1.23;
|
||||
|
||||
// Publisher
|
||||
auto pub = create_publisher<cpp_custom_interface::msg::SensorReading>("topic", 10);
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from cpp_custom_interface.msg import SensorReading
|
||||
from cpp_custom_interface.srv import GetCalibration
|
||||
from cpp_custom_interface.action import MoveArm
|
||||
|
||||
msg = SensorReading()
|
||||
msg.sensor_id = 'imu_0'
|
||||
```
|
||||
|
||||
**注意**:Python 包名是 `cpp_custom_interface.msg` 而不是 `cpp_custom_interface/msg`。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_custom_interface
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`cpp_custom_interface: gtest 3/3 ✓` 全部通过。
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `SensorReadingFields` | msg 字段构造正确 |
|
||||
| `GetCalibrationRequestResponse` | srv 字段 + 长度正确 |
|
||||
| `MoveArmGoalFeedbackResult` | action 三段都正确 |
|
||||
---
|
||||
|
||||
## 深度学习
|
||||
## 🔧 自己定义接口
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- 自定义接口:[`doc/16-custom-interfaces.md`](../doc/16-custom-interfaces.md)
|
||||
1. 在 `msg/`、`srv/`、`action/` 下新建 `.msg`/`.srv`/`.action` 文件
|
||||
2. 改 `CMakeLists.txt` 的 `MSG_FILES`/`SRV_FILES`/`ACTION_FILES` 列表
|
||||
3. `colcon build`
|
||||
4. 生成的代码在 `install/cpp_custom_interface/include/`(C++)或 `install/cpp_custom_interface/lib/python3.10/site-packages/`(Python)
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/16-custom-interfaces.md](../../doc/16-custom-interfaces.md) — 自定义接口深度(嵌套 / 数组 / 常量)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
继续学 **[py_vision_demo](../py_vision_demo/README.md)** — 图像话题(cv_bridge + OpenCV)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| [py_params — Python Parameter](../py_params/README.md) | **cpp_custom_interface — C++ 自定义接口** | [py_vision_demo — Python 图像](../py_vision_demo/README.md) |
|
||||
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
|
||||
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
<test_depend>ament_cmake_gtest</test_depend>
|
||||
|
||||
<export>
|
||||
|
||||
@@ -11,7 +11,7 @@ CalibrationServer::CalibrationServer(const rclcpp::NodeOptions & options)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"service_name", "get_calibration",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("服务名"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("服务名"));
|
||||
|
||||
const std::string service_name = this->get_parameter("service_name").as_string();
|
||||
|
||||
@@ -47,11 +47,3 @@ void CalibrationServer::handle_request(
|
||||
}
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::CalibrationServer>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// calibration_server_main.cpp - CalibrationServer 节点入口
|
||||
#include "cpp_custom_interface/calibration_server.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::CalibrationServer>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ MoveArmServer::MoveArmServer(const rclcpp::NodeOptions & options)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"action_name", "move_arm",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("Action 名"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("Action 名"));
|
||||
|
||||
const std::string action_name = this->get_parameter("action_name").as_string();
|
||||
|
||||
@@ -42,7 +42,7 @@ rclcpp_action::CancelResponse MoveArmServer::handle_cancel(
|
||||
|
||||
void MoveArmServer::execute(const std::shared_ptr<GoalHandle> goal_handle)
|
||||
{
|
||||
const auto goal = goal_handle->get_request();
|
||||
const auto goal = goal_handle->get_goal();
|
||||
|
||||
auto feedback = std::make_shared<MoveArm::Feedback>();
|
||||
auto result = std::make_shared<MoveArm::Result>();
|
||||
@@ -74,11 +74,3 @@ void MoveArmServer::execute(const std::shared_ptr<GoalHandle> goal_handle)
|
||||
}
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::MoveArmServer>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// move_arm_server_main.cpp - MoveArmServer 节点入口
|
||||
#include "cpp_custom_interface/move_arm_server.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::MoveArmServer>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -14,13 +14,13 @@ SensorPublisher::SensorPublisher(const rclcpp::NodeOptions & options)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "/sensor_reading",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布话题名"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("发布话题名"));
|
||||
this->declare_parameter<std::string>(
|
||||
"sensor_id", "imu_0",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("传感器 ID"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("传感器 ID"));
|
||||
this->declare_parameter<std::string>(
|
||||
"unit", "rad/s",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("物理单位"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("物理单位"));
|
||||
|
||||
const std::string topic_name = this->get_parameter("topic_name").as_string();
|
||||
|
||||
@@ -49,11 +49,3 @@ void SensorPublisher::timer_callback()
|
||||
}
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::SensorPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// sensor_publisher_main.cpp - SensorPublisher 节点入口
|
||||
#include "cpp_custom_interface/sensor_publisher.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::SensorPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -21,12 +21,17 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# 必备: 找 ament_cmake + 依赖
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(std_msgs REQUIRED)
|
||||
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
std_msgs
|
||||
)
|
||||
|
||||
# 库:含 ChatterPublisher / ChatterSubscriber 实现
|
||||
# 库:只含 ChatterPublisher / ChatterSubscriber 类实现(无 main)
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/chatter_publisher.cpp
|
||||
src/chatter_subscriber.cpp
|
||||
@@ -37,11 +42,11 @@ target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
|
||||
# 可执行文件:两个独立 main(每个节点独立可执行)
|
||||
add_executable(chatter_publisher_cpp src/chatter_publisher.cpp)
|
||||
# 可执行文件:每个 main 单独编译
|
||||
add_executable(chatter_publisher_cpp src/chatter_publisher_main.cpp)
|
||||
target_link_libraries(chatter_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(chatter_subscriber_cpp src/chatter_subscriber.cpp)
|
||||
add_executable(chatter_subscriber_cpp src/chatter_subscriber_main.cpp)
|
||||
target_link_libraries(chatter_subscriber_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 安装:可执行 + 库 + launch
|
||||
@@ -56,4 +61,12 @@ install(DIRECTORY launch
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
# 测试
|
||||
if(BUILD_TESTING)
|
||||
find_package(ament_cmake_gtest REQUIRED)
|
||||
ament_add_gtest(test_pub_sub test/test_pub_sub.cpp)
|
||||
ament_target_dependencies(test_pub_sub ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
target_link_libraries(test_pub_sub ${PROJECT_NAME}_core)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
+369
-31
@@ -1,54 +1,392 @@
|
||||
# cpp_pubsub
|
||||
# cpp_pubsub — C++ Topic 发布订阅
|
||||
|
||||
ROS2 Topic pub/sub 演示包 (C++)。属于 Level 1 基础机制第 1-2 块。
|
||||
> 跟 `py_pubsub` 一样的东西,用 C++ 写。**只用 Python 的项目可以跳过这个**。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
>
|
||||
> **前置知识**:读懂 py_pubsub 的 README + 基础 C++(知道 class / shared_ptr / std:: 是什么)。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`chatter_publisher_cpp`**: 周期性发布 `std_msgs/String` 到 `/chatter`
|
||||
- **`chatter_subscriber_cpp`**: 订阅 `/chatter`,打印消息
|
||||
## 这是什么?
|
||||
|
||||
与 [`py_pubsub`](../py_pubsub/) 配合,演示 Python ↔ C++ 跨语言互通。
|
||||
`py_pubsub` 的 C++ 版本。**同一个 Topic,Python 和 C++ 节点互通**(都用 `std_msgs/String` 类型)。
|
||||
|
||||
## C++ 节点类(库)
|
||||
**本包存在的意义**:
|
||||
- 看到 Python 节点发的消息,C++ 节点能收到(反之亦然)
|
||||
- 学会用 **rclcpp**(ROS2 C++ 客户端库) 写节点
|
||||
- 知道工业级 ROS2 项目的 C++ 代码长什么样
|
||||
|
||||
`cpp_pubsub_core` 共享库包含:
|
||||
---
|
||||
|
||||
- `cpp_pubsub::ChatterPublisher` — `rclcpp::Node` 派生
|
||||
- `cpp_pubsub::ChatterSubscriber` — `rclcpp::Node` 派生
|
||||
## 🤔 我已经会 py_pubsub 了,为什么还要学 C++ 版?
|
||||
|
||||
均位于 `namespace cpp_pubsub`,便于复用与测试。
|
||||
**实话**:Python 写 ROS2 简单 90%,**大多数项目用 Python 就够了**。但有些场景用 C++ 更合适:
|
||||
|
||||
## 运行
|
||||
| 场景 | 为什么可能用 C++ |
|
||||
|---|---|
|
||||
| 性能敏感(图像/点云/SLAM) | Python GC 不可控 + 速度慢 |
|
||||
| 已有 C++ 库要复用 | OpenCV、PCL、MoveIt2 全是 C++ |
|
||||
| 嵌入式部署 | 部分芯片只支持 C/C++ |
|
||||
|
||||
```bash
|
||||
# 单独启动
|
||||
ros2 run cpp_pubsub chatter_publisher_cpp
|
||||
ros2 run cpp_pubsub chatter_subscriber_cpp
|
||||
**如果你的项目不涉及上面这些,只用 Python 就行,不必学这个包。**
|
||||
|
||||
# launch
|
||||
ros2 launch cpp_pubsub pubsub_launch.py
|
||||
---
|
||||
|
||||
# 实时观察
|
||||
ros2 topic info /chatter -v
|
||||
ros2 topic echo /chatter
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 用 C++ 写 ROS2 节点(知道 `rclcpp::Node` 是什么)
|
||||
2. ✅ 理解 Python ↔ C++ 跨语言互通(为什么能行?)
|
||||
3. ✅ 知道 `shared_ptr` / `std::bind` / `rclcpp::QoS` 这些 C++ 套路
|
||||
4. ✅ 读懂 ROS2 官方 C++ 示例代码
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/cpp_pubsub/
|
||||
├── src/
|
||||
│ ├── chatter_publisher.cpp/hpp # Publisher 类(声明 + 实现)
|
||||
│ ├── chatter_publisher_main.cpp # Publisher 的 main 入口(单独文件)
|
||||
│ ├── chatter_subscriber.cpp/hpp # Subscriber 类(声明 + 实现)
|
||||
│ └── chatter_subscriber_main.cpp # Subscriber 的 main 入口
|
||||
├── launch/pubsub_cpp_launch.py # 启动两个 cpp 节点
|
||||
├── test/test_pub_sub.cpp # gtest 单元测试
|
||||
├── CMakeLists.txt # C++ 编译配置
|
||||
└── package.xml # ROS2 包元数据
|
||||
```
|
||||
|
||||
## 测试
|
||||
**C++ 项目比 Python 多一个东西**:`CMakeLists.txt`(告诉编译器怎么编译)。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
# 只跑 C++ 的两个节点
|
||||
ros2 launch cpp_pubsub pubsub_cpp_launch.py
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [chatter_publisher_cpp]: Publishing: "Hello World C++: 0"
|
||||
[INFO] [chatter_publisher_cpp]: Publishing: "Hello World C++: 1"
|
||||
...
|
||||
[INFO] [chatter_subscriber_cpp]: I heard: Hello World C++: 0
|
||||
```
|
||||
|
||||
**跨语言验证**(另开终端,跟 Python 互通):
|
||||
|
||||
```bash
|
||||
# 终端 1: C++ Publisher
|
||||
ros2 run cpp_pubsub chatter_publisher_cpp
|
||||
|
||||
# 终端 2: Python Subscriber(可以!因为都用 std_msgs/String)
|
||||
ros2 run py_pubsub chatter_subscriber
|
||||
```
|
||||
|
||||
**预期效果**:Python Subscriber 能收到 C++ Publisher 的消息(和反之)。
|
||||
|
||||
---
|
||||
|
||||
## 📖 C++ 黑魔法词典(新手必看)
|
||||
|
||||
读代码前先扫一眼这些符号:
|
||||
|
||||
| 符号 | 含义 | Python 对应 |
|
||||
|---|---|---|
|
||||
| `class Foo : public rclcpp::Node` | Foo 继承 rclcpp::Node | `class Foo(Node):` |
|
||||
| `rclcpp::Node` | ROS2 C++ 节点基类 | `rclpy.node.Node` |
|
||||
| `std::shared_ptr<T>` | 共享指针(自动内存管理) | 不用写,GC 自动管 |
|
||||
| `::SharedPtr` | rclcpp 的共享指针类型 | 无 |
|
||||
| `std::make_shared<T>(...)` | 创建 shared_ptr 的工厂函数 | 无 |
|
||||
| `std::bind(&Foo::method, this)` | 把方法 + this 绑成一个可调用对象 | lambda 即可 |
|
||||
| `std::placeholders::_1` | bind 的占位符,代表"第一个参数" | lambda 参数 |
|
||||
| `std::chrono::seconds(1)` | 1 秒的 std 写法(可简写为 `1s`) | `1.0` |
|
||||
| `create_wall_timer(...)` | 创建定时器 | `create_timer(...)` |
|
||||
| `create_publisher<T>(topic, qos)` | 模板参数 T 是消息类型 | `create_publisher(T, topic, qos)` |
|
||||
| `<std_msgs::msg::String>` | 嵌套命名空间,ROS 消息类型 | `String`(从 std_msgs.msg 导入) |
|
||||
|
||||
**`std::bind` 是 C++ 的"高阶函数"**,把方法 + 对象绑在一起,让定时器能回调。Python 里 lambda 就能搞定。
|
||||
|
||||
---
|
||||
|
||||
## 📖 Publisher 完整代码
|
||||
|
||||
```cpp
|
||||
// chatter_publisher.hpp - 类声明
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
namespace cpp_pubsub {
|
||||
|
||||
class ChatterPublisher : public rclcpp::Node {
|
||||
public:
|
||||
explicit ChatterPublisher(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void timer_callback();
|
||||
|
||||
// shared_ptr:节点销毁时自动释放这些对象(不用手动 delete)
|
||||
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
std::size_t count_;
|
||||
};
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
|
||||
// chatter_publisher.cpp - 类实现
|
||||
#include "cpp_pubsub/chatter_publisher.hpp"
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
namespace cpp_pubsub {
|
||||
|
||||
ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("chatter_publisher_cpp", options), // 调父类构造函数,节点名 "chatter_publisher_cpp"
|
||||
count_(0)
|
||||
{
|
||||
// 1) 声明参数(C++ 用 ParameterDescriptor + set__description)
|
||||
// ⚠️ 注意:是 set__description(双下划线),不是 set_description
|
||||
// 双下划线 = ROS2 自动生成的 setter(类似 Python 的 description 属性)
|
||||
this->declare_parameter<std::string>(
|
||||
"message_prefix", "Hello World C++: ",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("消息前缀"));
|
||||
this->declare_parameter<double>(
|
||||
"publish_rate_hz", 1.0,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("发布频率 (Hz)"));
|
||||
|
||||
// 2) 读参数
|
||||
const std::string prefix = this->get_parameter("message_prefix").as_string();
|
||||
const double rate = this->get_parameter("publish_rate_hz").as_double();
|
||||
|
||||
// 3) 创建 Publisher
|
||||
// 模板参数 <std_msgs::msg::String> 表示消息类型
|
||||
// 第二个参数 10 是队列大小(跟 Python 一样)
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>("chatter_cpp", 10);
|
||||
|
||||
// 4) 创建定时器
|
||||
// std::bind 把方法 + this 绑成一个"函数对象"
|
||||
// 让定时器能调用 ChatterPublisher::timer_callback
|
||||
timer_ = this->create_wall_timer(
|
||||
std::chrono::milliseconds(static_cast<int>(1000.0 / rate)),
|
||||
std::bind(&ChatterPublisher::timer_callback, this));
|
||||
}
|
||||
|
||||
void ChatterPublisher::timer_callback() {
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "Hello World C++: " + std::to_string(count_++);
|
||||
publisher_->publish(msg); // 发布!
|
||||
}
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
```
|
||||
|
||||
**`set__description` 双下划线**:这是 ROS2 的 IDL(接口描述语言)生成器自动生成的 setter 命名约定。**返回自身引用,可以链式调用**:
|
||||
```cpp
|
||||
ParameterDescriptor().set__description("...") // 链式风格
|
||||
// 等价于
|
||||
auto d = ParameterDescriptor();
|
||||
d.set__description("...");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Subscriber 完整代码
|
||||
|
||||
```cpp
|
||||
// chatter_subscriber.hpp
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
namespace cpp_pubsub {
|
||||
|
||||
class ChatterSubscriber : public rclcpp::Node {
|
||||
public:
|
||||
explicit ChatterSubscriber(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void message_callback(const std_msgs::msg::String::SharedPtr msg);
|
||||
|
||||
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
|
||||
std::size_t received_count_;
|
||||
};
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
|
||||
// chatter_subscriber.cpp
|
||||
#include "cpp_pubsub/chatter_subscriber.hpp"
|
||||
#include <string>
|
||||
|
||||
namespace cpp_pubsub {
|
||||
|
||||
ChatterSubscriber::ChatterSubscriber(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("chatter_subscriber_cpp", options), received_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "chatter",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("订阅话题名"));
|
||||
|
||||
const std::string topic_name = this->get_parameter("topic_name").as_string();
|
||||
|
||||
// create_subscription<消息类型>(话题名, 队列大小, callback)
|
||||
// _1 是 std::bind 的占位符,代表"收到的 msg"(传给 message_callback 的第一个参数)
|
||||
subscription_ = this->create_subscription<std_msgs::msg::String>(
|
||||
topic_name, 10,
|
||||
std::bind(&ChatterSubscriber::message_callback, this, std::placeholders::_1));
|
||||
}
|
||||
|
||||
void ChatterSubscriber::message_callback(const std_msgs::msg::String::SharedPtr msg) {
|
||||
received_count_++;
|
||||
if (received_count_ % 10 == 0) {
|
||||
RCLCPP_INFO(this->get_logger(),
|
||||
"recv #%zu: \"%s\"", received_count_, msg->data.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
```
|
||||
|
||||
**对比 Python Subscriber**:
|
||||
```python
|
||||
self.subscription = self.create_subscription(
|
||||
String, 'chatter', self.listener_callback, 10)
|
||||
|
||||
def listener_callback(self, msg):
|
||||
self.get_logger().info(f'I heard: {msg.data}')
|
||||
```
|
||||
|
||||
**主要区别**:
|
||||
- C++ 用 `std::bind` + `std::placeholders::_1` 占位符
|
||||
- C++ 消息是 `SharedPtr`(智能指针)
|
||||
- C++ 字符串访问用 `msg->data.c_str()`(指针解引用 + C 字符串转换)
|
||||
|
||||
---
|
||||
|
||||
## 📖 main 函数(单独文件)
|
||||
|
||||
```cpp
|
||||
// chatter_publisher_main.cpp
|
||||
int main(int argc, char * argv[]) {
|
||||
rclcpp::init(argc, argv); // 初始化 ROS2
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterPublisher>()); // 创建节点 + spin
|
||||
rclcpp::shutdown(); // 关闭 ROS2
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
**为什么 main 单独一个文件**:类实现要单独编译给测试用(`gtest` 链接时会出冲突,如果 main 在类实现里)。
|
||||
|
||||
**`spin` 是什么**:死循环,反复调用所有 callback(定时器、订阅者、Service...)。按 `Ctrl+C` 退出。
|
||||
|
||||
---
|
||||
|
||||
## 📖 CMakeLists.txt 怎么读?
|
||||
|
||||
```cmake
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cpp_pubsub LANGUAGES CXX)
|
||||
|
||||
# 1) 找依赖
|
||||
find_package(ament_cmake REQUIRED) # ROS2 构建系统
|
||||
find_package(rclcpp REQUIRED) # ROS2 C++ 客户端
|
||||
find_package(std_msgs REQUIRED) # 标准消息
|
||||
find_package(ament_cmake_gtest REQUIRED) # gtest 支持(test_depend)
|
||||
|
||||
# 2) 类实现编译成共享库(给 gtest 用)
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/chatter_publisher.cpp
|
||||
src/chatter_subscriber.cpp)
|
||||
target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core rclcpp std_msgs)
|
||||
|
||||
# 3) 可执行文件(链接共享库 + main 单独文件)
|
||||
add_executable(chatter_publisher_cpp src/chatter_publisher_main.cpp)
|
||||
target_link_libraries(chatter_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(chatter_subscriber_cpp src/chatter_subscriber_main.cpp)
|
||||
target_link_libraries(chatter_subscriber_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 4) 安装 + 测试
|
||||
install(TARGETS chatter_publisher_cpp chatter_subscriber_cpp ${PROJECT_NAME}_core
|
||||
DESTINATION lib/${PROJECT_NAME})
|
||||
|
||||
if(BUILD_TESTING)
|
||||
ament_add_gtest(test_pub_sub test/test_pub_sub.cpp)
|
||||
target_link_libraries(test_pub_sub ${PROJECT_NAME}_core)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
```
|
||||
|
||||
**跟 Python setup.py 对比**:CMakeLists.txt 更复杂,但更精确控制编译(链接、include 路径、依赖)。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_pubsub
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖(在 `test/test_pub_sub.cpp`):
|
||||
**预期**:`cpp_pubsub: gtest 3/3 ✓` 全部通过。
|
||||
|
||||
| 用例 | 内容 |
|
||||
---
|
||||
|
||||
## 🔧 自己改代码
|
||||
|
||||
1. 改 `src/chatter_publisher.cpp`(比如改默认消息前缀)
|
||||
2. **必须重编译**(C++ 不会自动生效):
|
||||
```bash
|
||||
colcon build --packages-select cpp_pubsub
|
||||
```
|
||||
3. 重跑 launch(旧的先 `Ctrl+C` 停掉)
|
||||
|
||||
---
|
||||
|
||||
## 🧠 为什么 Python ↔ C++ 能互通?
|
||||
|
||||
**秘密在 `.msg` 文件**。`std_msgs/String.msg` 定义了消息结构,`rosidl` 自动生成 Python 类和 C++ 类,**接口完全对应**:
|
||||
|
||||
| Python | C++ |
|
||||
|---|---|
|
||||
| `PublisherConstructsWithDefaults` | 节点名 + 默认参数正确 |
|
||||
| `SubscriberConstructsWithDefaults` | 节点名 + 默认参数正确 |
|
||||
| `PublisherSpinSomeWorks` | spin_some 1s 不崩溃 |
|
||||
| `String()` | `std_msgs::msg::String()` |
|
||||
| `msg.data` | `msg.data` |
|
||||
| 字符串类型 | `std::string` |
|
||||
|
||||
## 深度学习
|
||||
DDS 传输的是**二进制数据**(按 `.msg` 定义序列化),不关心语言。所以 py 发 cpp 收完全没问题。
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md) §3
|
||||
- Topic 深度:[`doc/20-topics.md`](../doc/20-topics.md)
|
||||
- C++ Style: [ROS2 C++ Style Guide](https://docs.ros.org/en/humble/Contributing/Code-Style-Language-Versions.html)
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [ROS2 C++ 教程](https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Cpp-Publisher-And-Subscriber.html)
|
||||
- [doc/CODING_STYLE.md](../../doc/CODING_STYLE.md) — C++ 编码规范
|
||||
- [py_pubsub README](../py_pubsub/README.md) — 对比看效果
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
学完这个,继续学 **[py_srv](../py_srv/README.md)** — 学习 Service(请求-响应)模式。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| [py_pubsub — Python Topic](../py_pubsub/README.md) | **cpp_pubsub — C++ Topic** | [py_srv — Python Service](../py_srv/README.md) |
|
||||
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<depend>rclcpp</depend>
|
||||
<depend>std_msgs</depend>
|
||||
|
||||
<test_depend>ament_cmake_gtest</test_depend>
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// chatter_publisher.cpp - C++ ChatterPublisher 实现
|
||||
// chatter_publisher.cpp - ChatterPublisher 类实现(不含 main)
|
||||
//
|
||||
// 设计思想:
|
||||
// - 把节点类实现放 _core 库,main() 单独文件
|
||||
// - 库不包含 main,避免多定义错误
|
||||
|
||||
#include "cpp_pubsub/chatter_publisher.hpp"
|
||||
|
||||
#include <chrono>
|
||||
@@ -15,10 +20,10 @@ ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
|
||||
// 1) 声明参数(类型模板版 + 描述符)
|
||||
this->declare_parameter<int>(
|
||||
"publish_rate_hz", 2,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布频率 (Hz)"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("发布频率 (Hz)"));
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "chatter",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布话题名"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("发布话题名"));
|
||||
|
||||
// 2) 读参数
|
||||
const int publish_rate_hz = this->get_parameter("publish_rate_hz").as_int();
|
||||
@@ -27,7 +32,6 @@ ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
|
||||
// 3) 构造发布者 + 定时器
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>(topic_name, 10);
|
||||
|
||||
// 防零除
|
||||
const auto period = (publish_rate_hz > 0) ?
|
||||
std::chrono::milliseconds(1000 / publish_rate_hz) :
|
||||
std::chrono::milliseconds(1000);
|
||||
@@ -52,12 +56,4 @@ void ChatterPublisher::timer_callback()
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
} // namespace cpp_pubsub
|
||||
@@ -0,0 +1,10 @@
|
||||
// chatter_publisher_main.cpp - ChatterPublisher 节点入口
|
||||
#include "cpp_pubsub/chatter_publisher.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// chatter_subscriber.cpp - C++ ChatterSubscriber 实现
|
||||
// chatter_subscriber.cpp - ChatterSubscriber 类实现(不含 main)
|
||||
#include "cpp_pubsub/chatter_subscriber.hpp"
|
||||
|
||||
#include <string>
|
||||
@@ -11,7 +11,7 @@ ChatterSubscriber::ChatterSubscriber(const rclcpp::NodeOptions & options)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "chatter",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("订阅话题名"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("订阅话题名"));
|
||||
|
||||
const std::string topic_name = this->get_parameter("topic_name").as_string();
|
||||
|
||||
@@ -25,18 +25,14 @@ ChatterSubscriber::ChatterSubscriber(const rclcpp::NodeOptions & options)
|
||||
void ChatterSubscriber::message_callback(const std_msgs::msg::String::SharedPtr msg)
|
||||
{
|
||||
try {
|
||||
RCLCPP_INFO(this->get_logger(), "recv #%zu: \"%s\"", received_count_++, msg->data.c_str());
|
||||
received_count_++;
|
||||
if (received_count_ % 10 == 0) {
|
||||
RCLCPP_INFO(this->get_logger(),
|
||||
"recv #%zu: \"%s\"", received_count_, msg->data.c_str());
|
||||
}
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "callback failed: %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterSubscriber>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
} // namespace cpp_pubsub
|
||||
@@ -0,0 +1,10 @@
|
||||
// chatter_subscriber_main.cpp - ChatterSubscriber 节点入口
|
||||
#include "cpp_pubsub/chatter_subscriber.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterSubscriber>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# test/test_pub_sub.cpp - cpp_pubsub 单元测试
|
||||
#
|
||||
# 设计思想:
|
||||
# - SetUpTestSuite / TearDownTestSuite 共享 rclcpp::init / shutdown
|
||||
# - 用 spin_some(50ms) 代替 spin() 控制超时
|
||||
# - 不依赖 launch_testing(避免环境耦合)
|
||||
// test/test_pub_sub.cpp - cpp_pubsub 单元测试
|
||||
//
|
||||
// 设计思想:
|
||||
// - SetUpTestSuite / TearDownTestSuite 共享 rclcpp::init / shutdown
|
||||
// - 用 spin_some(50ms) 代替 spin() 控制超时
|
||||
// - 不依赖 launch_testing(避免环境耦合)
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
@@ -10,6 +10,11 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# 必备: 找 ament_cmake + 依赖
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(std_msgs REQUIRED)
|
||||
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
std_msgs
|
||||
@@ -25,11 +30,9 @@ ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
# 两个可执行
|
||||
add_executable(qos_demo_publisher_cpp src/qos_demo_publisher_main.cpp)
|
||||
target_link_libraries(qos_demo_publisher_cpp ${PROJECT_NAME}_core)
|
||||
target_compile_definitions(qos_demo_publisher_cpp PRIVATE "QOS_DEMO_MAIN=0")
|
||||
|
||||
add_executable(qos_demo_subscriber_cpp src/qos_demo_subscriber_main.cpp)
|
||||
target_link_libraries(qos_demo_subscriber_cpp ${PROJECT_NAME}_core)
|
||||
target_compile_definitions(qos_demo_subscriber_cpp PRIVATE "QOS_DEMO_MAIN=1")
|
||||
|
||||
install(TARGETS
|
||||
qos_demo_publisher_cpp
|
||||
@@ -39,4 +42,12 @@ install(TARGETS
|
||||
)
|
||||
install(DIRECTORY launch DESTINATION share/${PROJECT_NAME})
|
||||
|
||||
# 测试
|
||||
if(BUILD_TESTING)
|
||||
find_package(ament_cmake_gtest REQUIRED)
|
||||
ament_add_gtest(test_qos_profiles test/test_qos_profiles.cpp)
|
||||
ament_target_dependencies(test_qos_profiles ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
target_link_libraries(test_qos_profiles ${PROJECT_NAME}_core)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
+107
-52
@@ -1,77 +1,132 @@
|
||||
# cpp_qos_demo
|
||||
# cpp_qos_demo — C++ QoS 9 种组合演示
|
||||
|
||||
ROS2 QoS 9 种组合演示包(C++)。属于 Level 1 基础机制第 13 块。
|
||||
> 生产级 ROS2 必学:**QoS(Quality of Service)** 消息传输质量策略。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`qos_demo_publisher_cpp`**: 参数化 QoS 发布者
|
||||
- **`qos_demo_subscriber_cpp`**: 参数化 QoS 订阅者
|
||||
## 这是什么?
|
||||
|
||||
每个节点支持以下参数:
|
||||
**QoS(服务质量)** 控制 Topic 消息的传输行为。9 种组合 = 3 个维度叉乘:
|
||||
|
||||
| 参数 | 可选值 | 默认 |
|
||||
| 维度 | 选项 | 含义 |
|
||||
|---|---|---|
|
||||
| `reliability` | `reliable` / `best_effort` | `reliable` |
|
||||
| `durability` | `volatile` / `transient_local` | `volatile` |
|
||||
| `history` | `keep_last` / `keep_all` | `keep_last` |
|
||||
| `depth` | int | 10 |
|
||||
| `publish_rate_hz` | float | 1.0 |
|
||||
| **Reliability(可靠性)** | `reliable` / `best_effort` | 保证送达 vs 丢了就算了 |
|
||||
| **Durability(持久性)** | `volatile` / `transient_local` | 不保存历史 vs 为晚加入者保留 |
|
||||
| **History(历史)** | `keep_last(N)` / `keep_all` | 只保留最后 N 条 vs 保留所有 |
|
||||
|
||||
## 9 种常用组合
|
||||
**兼容性要求**:Publisher 和 Subscriber 的 QoS 必须**兼容**,否则它们看不到对方!
|
||||
|
||||
| Reliability | Durability | History | 适用场景 |
|
||||
|---|---|---|---|
|
||||
| RELIABLE | VOLATILE | KEEP_LAST(10) | 默认 / 跨语言互通基线 |
|
||||
| RELIABLE | VOLATILE | KEEP_LAST(1) | 控制指令(只关心最新) |
|
||||
| RELIABLE | TRANSIENT_LOCAL | KEEP_LAST(1) | 参数 / 配置(晚订阅者也能拿到) |
|
||||
| BEST_EFFORT | VOLATILE | KEEP_LAST(1) | 视频流(丢一帧无所谓) |
|
||||
| BEST_EFFORT | VOLATILE | KEEP_LAST(10) | Lidar / 雷达 |
|
||||
| RELIABLE | VOLATILE | KEEP_ALL | 日志(必须投递,不丢) |
|
||||
---
|
||||
|
||||
## 兼容性矩阵
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
| Publisher ↓ \ Subscriber → | RELIABLE | BEST_EFFORT |
|
||||
|---|---|---|
|
||||
| RELIABLE | ✅ | ❌ |
|
||||
| BEST_EFFORT | ✅ | ✅ |
|
||||
1. ✅ 理解 3 维 QoS 模型(Reliability / Durability / History)
|
||||
2. ✅ 用 `rclcpp::QoS` 构造 QoS profile
|
||||
3. ✅ 知道 9 种组合的兼容性矩阵
|
||||
4. ✅ 在生产环境正确选 QoS(传感器用 best_effort,关键控制用 reliable)
|
||||
|
||||
**关键**:`RELIABLE → BEST_EFFORT` 不兼容!sub 不发 ACK,pub 报错:
|
||||
```
|
||||
[WARN] ... New subscription discovered on this topic with incompatible QoS ...
|
||||
```
|
||||
---
|
||||
|
||||
## 运行
|
||||
## 🚀 跑起来
|
||||
|
||||
### 启动 Subscriber
|
||||
|
||||
**终端 1**(容器内):
|
||||
```bash
|
||||
# 默认 QoS(RELIABLE + VOLATILE + KEEP_LAST(10))
|
||||
ros2 launch cpp_qos_demo qos_launch.py
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
# BEST_EFFORT 视频流
|
||||
ros2 run cpp_qos_demo qos_demo_publisher_cpp --ros-args \
|
||||
-p reliability:=best_effort -p history:=keep_last -p depth:=1
|
||||
|
||||
# TRANSIENT_LOCAL(晚订阅者能拿到历史)
|
||||
ros2 run cpp_qos_demo qos_demo_subscriber_cpp --ros-args \
|
||||
-p durability:=transient_local
|
||||
# 默认(reliable + volatile + keep_last(10))
|
||||
ros2 run cpp_qos_demo qos_demo_subscriber_cpp
|
||||
```
|
||||
|
||||
## 测试
|
||||
### 启动 Publisher(另开终端)
|
||||
|
||||
**终端 2**:
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
# 默认(reliable + volatile + keep_last(10))
|
||||
ros2 run cpp_qos_demo qos_demo_publisher_cpp
|
||||
|
||||
# 或用参数切换 QoS
|
||||
ros2 run cpp_qos_demo qos_demo_publisher_cpp --ros-args \
|
||||
-p reliability:=best_effort \
|
||||
-p durability:=transient_local \
|
||||
-p history:=keep_all \
|
||||
-p depth:=10
|
||||
```
|
||||
|
||||
### 试不兼容的组合
|
||||
|
||||
**终端 3**(Subscriber 用 reliable,Publisher 用 best_effort):
|
||||
```bash
|
||||
ros2 run cpp_qos_demo qos_demo_subscriber_cpp --ros-args -p reliability:=reliable
|
||||
|
||||
ros2 run cpp_qos_demo qos_demo_publisher_cpp --ros-args -p reliability:=best_effort
|
||||
```
|
||||
|
||||
**预期结果**:**看不到对方的消息**(QoS 不兼容)。
|
||||
|
||||
---
|
||||
|
||||
## 📖 兼容性矩阵
|
||||
|
||||
| Publisher\Subscriber | Reliable | Best Effort |
|
||||
|---|---|---|
|
||||
| **Reliable** | ✅ 互通 | ✅ 互通(reliable ≥ best_effort) |
|
||||
| **Best Effort** | ❌ 不兼容 | ✅ 互通 |
|
||||
|
||||
**口诀**:**reliable 可以向下兼容 best_effort**(reliable 一定能发出 best_effort 也能接收的消息),反之不行。
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心代码
|
||||
|
||||
```cpp
|
||||
// 构造 QoS
|
||||
rclcpp::QoS qos(depth);
|
||||
qos.reliable(); // 或 qos.best_effort()
|
||||
qos.transient_local(); // 或 qos.durability_volatile()
|
||||
qos.keep_last(depth); // 或 qos.keep_all()
|
||||
|
||||
// 用 QoS 创建 Publisher
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>("/topic", qos);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_qos_demo
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖(在 `test/test_qos_profiles.cpp`):
|
||||
**预期**:`cpp_qos_demo: gtest 4/4 ✓` 全部通过。
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `DefaultProfileValues` | 默认 QoS profile 字段 |
|
||||
| `PublisherConstructsWithDefaultQoS` | Publisher 默认参数 |
|
||||
| `PublisherConstructsWithBestEffort` | BEST_EFFORT 构造 |
|
||||
| `SubscriberConstructsWithTransientLocal` | TRANSIENT_LOCAL 构造 |
|
||||
---
|
||||
|
||||
## 深度学习
|
||||
## 📚 深入学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- QoS 深度:[`doc/19-qos.md`](../doc/19-qos.md)
|
||||
- OMG DDS 规范:[`DDS 1.4 spec`](https://www.omg.org/spec/DDS/1.4/)
|
||||
- [doc/19-qos.md](../../doc/19-qos.md) — QoS 深度(全部 9 种组合详解)
|
||||
- [ROS2 QoS 设计稿](https://design.ros2.org/articles/qos.html)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
继续学 **[py_lifecycle_composable](../py_lifecycle_composable/README.md)** — Lifecycle Node(节点生命周期管理)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| [cpp_robot_tf2 — C++ TF2 + URDF](../cpp_robot_tf2/README.md) | **cpp_qos_demo — C++ QoS** | [py_lifecycle_composable — Python Lifecycle + Composable](../py_lifecycle_composable/README.md) |
|
||||
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
<test_depend>ament_cmake_gtest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// qos_demo_node.cpp - QoS 演示节点实现
|
||||
// qos_demo_node.cpp - QoS 演示节点类实现(无 main)
|
||||
#include "cpp_qos_demo/qos_demo_node.hpp"
|
||||
|
||||
#include <chrono>
|
||||
@@ -9,28 +9,25 @@ namespace cpp_qos_demo
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// ============== Publisher ==============
|
||||
|
||||
QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("qos_demo_publisher", options), publish_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"reliability", "reliable",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("reliable / best_effort"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("reliable / best_effort"));
|
||||
this->declare_parameter<std::string>(
|
||||
"durability", "volatile",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("volatile / transient_local"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("volatile / transient_local"));
|
||||
this->declare_parameter<std::string>(
|
||||
"history", "keep_last",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("keep_last / keep_all"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("keep_last / keep_all"));
|
||||
this->declare_parameter<int>(
|
||||
"depth", 10,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("KEEP_LAST depth"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("KEEP_LAST depth"));
|
||||
this->declare_parameter<double>(
|
||||
"publish_rate_hz", 1.0,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布频率 (Hz)"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("发布频率 (Hz)"));
|
||||
|
||||
// 构造 QoS profile
|
||||
rmw_qos_profile_t profile = rmw_qos_profile_default;
|
||||
profile.depth = this->get_parameter("depth").as_int();
|
||||
|
||||
@@ -55,8 +52,24 @@ QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options)
|
||||
profile.history = RMW_QOS_POLICY_HISTORY_KEEP_LAST;
|
||||
}
|
||||
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>(
|
||||
"/qos_demo_topic", profile);
|
||||
rclcpp::QoS qos(profile.depth);
|
||||
if (profile.reliability == RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT) {
|
||||
qos.best_effort();
|
||||
} else {
|
||||
qos.reliable();
|
||||
}
|
||||
if (profile.durability == RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL) {
|
||||
qos.transient_local();
|
||||
} else {
|
||||
qos.durability_volatile();
|
||||
}
|
||||
if (profile.history == RMW_QOS_POLICY_HISTORY_KEEP_ALL) {
|
||||
qos.keep_all();
|
||||
} else {
|
||||
qos.keep_last(profile.depth);
|
||||
}
|
||||
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>("/qos_demo_topic", qos);
|
||||
|
||||
const double publish_rate_hz = this->get_parameter("publish_rate_hz").as_double();
|
||||
const auto period = (publish_rate_hz > 0) ?
|
||||
@@ -68,7 +81,7 @@ QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options)
|
||||
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"QosDemoPublisher started: reliability=%s, durability=%s, history=%s, depth=%d",
|
||||
"QosDemoPublisher started: reliability=%s, durability=%s, history=%s, depth=%zu",
|
||||
reliability.c_str(), durability.c_str(), history.c_str(), profile.depth);
|
||||
}
|
||||
|
||||
@@ -83,23 +96,21 @@ void QosDemoPublisher::timer_callback()
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Subscriber ==============
|
||||
|
||||
QosDemoSubscriber::QosDemoSubscriber(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("qos_demo_subscriber", options), received_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"reliability", "reliable",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("reliable / best_effort"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("reliable / best_effort"));
|
||||
this->declare_parameter<std::string>(
|
||||
"durability", "volatile",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("volatile / transient_local"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("volatile / transient_local"));
|
||||
this->declare_parameter<std::string>(
|
||||
"history", "keep_last",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("keep_last / keep_all"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("keep_last / keep_all"));
|
||||
this->declare_parameter<int>(
|
||||
"depth", 10,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("KEEP_LAST depth"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("KEEP_LAST depth"));
|
||||
|
||||
rmw_qos_profile_t profile = rmw_qos_profile_default;
|
||||
profile.depth = this->get_parameter("depth").as_int();
|
||||
@@ -119,13 +130,30 @@ QosDemoSubscriber::QosDemoSubscriber(const rclcpp::NodeOptions & options)
|
||||
RMW_QOS_POLICY_HISTORY_KEEP_ALL :
|
||||
RMW_QOS_POLICY_HISTORY_KEEP_LAST;
|
||||
|
||||
rclcpp::QoS qos(profile.depth);
|
||||
if (profile.reliability == RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT) {
|
||||
qos.best_effort();
|
||||
} else {
|
||||
qos.reliable();
|
||||
}
|
||||
if (profile.durability == RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL) {
|
||||
qos.transient_local();
|
||||
} else {
|
||||
qos.durability_volatile();
|
||||
}
|
||||
if (profile.history == RMW_QOS_POLICY_HISTORY_KEEP_ALL) {
|
||||
qos.keep_all();
|
||||
} else {
|
||||
qos.keep_last(profile.depth);
|
||||
}
|
||||
|
||||
subscription_ = this->create_subscription<std_msgs::msg::String>(
|
||||
"/qos_demo_topic", profile,
|
||||
"/qos_demo_topic", qos,
|
||||
std::bind(&QosDemoSubscriber::message_callback, this, std::placeholders::_1));
|
||||
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"QosDemoSubscriber started: reliability=%s, durability=%s, history=%s, depth=%d",
|
||||
"QosDemoSubscriber started: reliability=%s, durability=%s, history=%s, depth=%zu",
|
||||
reliability.c_str(), durability.c_str(), history.c_str(), profile.depth);
|
||||
}
|
||||
|
||||
@@ -142,22 +170,4 @@ void QosDemoSubscriber::message_callback(const std_msgs::msg::String::SharedPtr
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_qos_demo
|
||||
|
||||
// ============== Mains ==============
|
||||
|
||||
int main_publisher(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_qos_demo::QosDemoPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main_subscriber(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_qos_demo::QosDemoSubscriber>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
} // namespace cpp_qos_demo
|
||||
@@ -1,4 +1,10 @@
|
||||
// qos_demo_publisher_main.cpp - Publisher 入口
|
||||
#include "qos_demo_node.cpp"
|
||||
// qos_demo_publisher_main.cpp - Publisher 节点入口
|
||||
#include "cpp_qos_demo/qos_demo_node.hpp"
|
||||
|
||||
int main(int argc, char * argv[]) { return main_publisher(argc, argv); }
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_qos_demo::QosDemoPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
// qos_demo_subscriber_main.cpp - Subscriber 入口
|
||||
#include "qos_demo_node.cpp"
|
||||
// qos_demo_subscriber_main.cpp - Subscriber 节点入口
|
||||
#include "cpp_qos_demo/qos_demo_node.hpp"
|
||||
|
||||
int main(int argc, char * argv[]) { return main_subscriber(argc, argv); }
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_qos_demo::QosDemoSubscriber>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -16,6 +16,15 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# 必备: 找 ament_cmake + 依赖
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(sensor_msgs REQUIRED)
|
||||
find_package(geometry_msgs REQUIRED)
|
||||
find_package(tf2_ros REQUIRED)
|
||||
find_package(tf2 REQUIRED)
|
||||
find_package(tf2_geometry_msgs REQUIRED)
|
||||
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
sensor_msgs
|
||||
@@ -25,7 +34,7 @@ set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
tf2_geometry_msgs
|
||||
)
|
||||
|
||||
# 库
|
||||
# 库(只含类实现,不含 main)
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/joint_state_publisher.cpp
|
||||
src/tf2_listener.cpp
|
||||
@@ -36,11 +45,11 @@ target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
|
||||
# 可执行
|
||||
add_executable(joint_state_publisher_cpp src/joint_state_publisher.cpp)
|
||||
# 可执行(main 单独文件)
|
||||
add_executable(joint_state_publisher_cpp src/joint_state_publisher_main.cpp)
|
||||
target_link_libraries(joint_state_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(tf2_listener_cpp src/tf2_listener.cpp)
|
||||
add_executable(tf2_listener_cpp src/tf2_listener_main.cpp)
|
||||
target_link_libraries(tf2_listener_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 安装
|
||||
@@ -54,4 +63,12 @@ install(DIRECTORY launch urdf
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(ament_cmake_gtest REQUIRED)
|
||||
ament_add_gtest(test_tf2_lookup test/test_tf2_lookup.cpp)
|
||||
ament_target_dependencies(test_tf2_lookup
|
||||
rclcpp ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
target_link_libraries(test_tf2_lookup ${PROJECT_NAME}_core)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
+218
-40
@@ -1,62 +1,240 @@
|
||||
# cpp_robot_tf2
|
||||
# cpp_robot_tf2 — C++ TF2 + URDF(机械臂坐标变换)
|
||||
|
||||
ROS2 URDF + TF2 + JointState 演示包(C++)。属于 Level 1 基础机制第 5-6 块。
|
||||
> 机器人入门必学:**坐标变换(TF2)** + **机器人模型(URDF)**。
|
||||
>
|
||||
> 预计学习时间:2-3 小时(机器人部分相对复杂)。
|
||||
>
|
||||
> **前置知识**:
|
||||
> - 必须:读完 1-4 包(py_pubsub + cpp_pubsub + py_srv + py_action_demo)
|
||||
> - 必须:懂基础 C++(class / shared_ptr / std::)
|
||||
> - 可选:[doc/85-docker.md](../../doc/85-docker.md) 的"X11 转发"配置(想看 RViz 可视化才需要)
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`joint_state_publisher_cpp`**: 周期性发布 3 关节(模拟正弦运动)到 `/joint_states`
|
||||
- **`tf2_listener_cpp`**: 订阅 TF,查询 `gripper` 在 `base_link` 下的位姿
|
||||
- **`simple_arm.urdf`**: 3 关节 + gripper 链式机械臂模型
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
ROS2 中两个机器人专属核心:
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `sensor_msgs/JointState` | 关节状态(name/position/velocity/effort) |
|
||||
| `robot_state_publisher` | ROS2 系统包:URDF + `/joint_states` → `/tf` |
|
||||
| `tf2_ros::Buffer` + `TransformListener` | TF 缓冲 + 监听 |
|
||||
| `lookupTransform` | 查 frame 间变换 |
|
||||
| URDF `<joint type="revolute">` | 有限位转动关节 |
|
||||
1. **URDF**(Unified Robot Description Format):机器人的"骨骼图纸",描述关节 + 连杆 + 视觉/碰撞形状
|
||||
2. **TF2**(Transform Library):跟踪机器人各部件在三维空间中的位置和姿态,自动维护"坐标系树"
|
||||
|
||||
## 运行
|
||||
**实际例子**:机械臂有 6 个关节,你知道末端(抓手)在世界坐标系下的位姿吗?TF2 帮你算。
|
||||
|
||||
```bash
|
||||
# 一键启动(URDF + JointState + robot_state_publisher + TF listener)
|
||||
ros2 launch cpp_robot_tf2 robot_tf2_launch.py
|
||||
**本包演示**:
|
||||
- `joint_state_publisher`:周期性发布 3 个关节的 sin 运动轨迹
|
||||
- `tf2_listener`:查 `gripper`(末端)在 `base_link`(基座)下的实时位姿
|
||||
|
||||
# 看 TF 树
|
||||
ros2 run tf2_tools view_frames
|
||||
---
|
||||
|
||||
# 实时查 gripper 在 base_link 下位姿
|
||||
ros2 run tf2_ros tf2_echo base_link gripper
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
# 看 /tf 频率
|
||||
ros2 topic hz /tf
|
||||
1. ✅ 理解 URDF 文件格式(`<link>` / `<joint>` / `<visual>`)
|
||||
2. ✅ 写 `joint_state_publisher` 发布关节状态
|
||||
3. ✅ 用 `tf2_ros::TransformListener` 查询坐标变换
|
||||
4. ✅ 在 RViz2 里可视化 TF 树
|
||||
5. ✅ 给真实机械臂写 TF 配置
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/cpp_robot_tf2/
|
||||
├── urdf/simple_arm.urdf # 简单 3 关节机械臂模型
|
||||
├── src/
|
||||
│ ├── joint_state_publisher.cpp/hpp # 发 /joint_states
|
||||
│ └── tf2_listener.cpp/hpp # 查 TF 变换
|
||||
├── launch/robot_launch.py # 启动 publisher + listener + robot_state_publisher
|
||||
├── test/test_tf2_lookup.cpp # gtest 测试
|
||||
└── CMakeLists.txt
|
||||
```
|
||||
|
||||
## 测试
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 启动完整机器人(3 节点)
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 launch cpp_robot_tf2 robot_launch.py
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [joint_state_publisher]: JointStatePublisher started: joints=3, period=2.00s, amplitude=0.50rad
|
||||
[INFO] [robot_state_publisher]: ...
|
||||
[INFO] [tf2_listener]: Tf2Listener started: base_link -> gripper
|
||||
[INFO] [tf2_listener]: [base_link -> gripper] x=0.450 y=0.000 z=0.300
|
||||
[INFO] [tf2_listener]: [base_link -> gripper] x=0.445 y=0.012 z=0.305
|
||||
...
|
||||
```
|
||||
|
||||
**看到 x/y/z 实时变化 = TF2 在工作了**(因为关节在动)。
|
||||
|
||||
### 看 TF 树(另开终端)
|
||||
|
||||
```bash
|
||||
ros2 run tf2_tools view_frames
|
||||
```
|
||||
|
||||
会在当前目录生成 `frames.pdf`,打开看完整的坐标系树。
|
||||
|
||||
或者直接看所有 TF:
|
||||
```bash
|
||||
ros2 topic echo /tf --once
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心概念
|
||||
|
||||
### 1) URDF(简单机械臂)
|
||||
|
||||
`urdf/simple_arm.urdf`:
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<robot name="simple_arm">
|
||||
<!-- 基座(固定在世界坐标系) -->
|
||||
<link name="base_link">
|
||||
<visual>
|
||||
<geometry><cylinder length="0.1" radius="0.05"/></geometry>
|
||||
</visual>
|
||||
</link>
|
||||
|
||||
<!-- 关节 1(base_link → joint1) -->
|
||||
<joint name="joint1" type="revolute">
|
||||
<parent link="base_link"/>
|
||||
<child link="link1"/>
|
||||
<origin xyz="0 0 0.05" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.14" upper="3.14" effort="10" velocity="1"/>
|
||||
</joint>
|
||||
|
||||
<link name="link1">...</link>
|
||||
|
||||
<!-- 关节 2、3、末端 -->
|
||||
<joint name="joint2" type="revolute">...</joint>
|
||||
<joint name="joint3" type="revolute">...</joint>
|
||||
|
||||
<!-- 末端(gripper) -->
|
||||
<link name="gripper">...</link>
|
||||
</robot>
|
||||
```
|
||||
|
||||
**关键概念**:
|
||||
- `<link>`:刚体部件(有视觉 + 碰撞 + 惯性参数)
|
||||
- `<joint>`:两个 link 之间的连接(`revolute` 转动、`prismatic` 滑动、`fixed` 固定)
|
||||
- `<origin>`:相对父 link 的偏移(xyz + rpy 四元数)
|
||||
- `<axis>`:旋转轴(对 revolute joint)
|
||||
|
||||
### 2) TF2 坐标变换查询
|
||||
|
||||
```cpp
|
||||
// 创建 Buffer + Listener
|
||||
tf_buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
|
||||
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_, this, false);
|
||||
|
||||
// 查 base_link → gripper 的最新变换
|
||||
geometry_msgs::msg::TransformStamped transform;
|
||||
try {
|
||||
transform = tf_buffer_->lookupTransform(
|
||||
"base_link", // 目标坐标系
|
||||
"gripper", // 源坐标系
|
||||
tf2::TimePointZero, // 最新
|
||||
500ms); // 超时
|
||||
|
||||
// transform.transform.translation: x/y/z
|
||||
// transform.transform.rotation: 四元数 x/y/z/w
|
||||
} catch (tf2::TransformException & exc) {
|
||||
RCLCPP_WARN(...); // TF 还没传过来,重试
|
||||
}
|
||||
```
|
||||
|
||||
**重要**:TF 查询可能失败(网络断、frame 不存在),**必须 try/except**(否则节点崩溃)。
|
||||
|
||||
---
|
||||
|
||||
## 📖 joint_state_publisher 代码
|
||||
|
||||
```cpp
|
||||
class JointStatePublisher : public rclcpp::Node {
|
||||
public:
|
||||
JointStatePublisher() : rclcpp::Node("joint_state_publisher") {
|
||||
joint_names_ = {"joint1", "joint2", "joint3"};
|
||||
|
||||
publisher_ = this->create_publisher<sensor_msgs::msg::JointState>(
|
||||
"/joint_states", 10);
|
||||
|
||||
// 每秒 50 次(50ms 周期)
|
||||
timer_ = this->create_wall_timer(
|
||||
20ms, std::bind(&JointStatePublisher::timer_callback, this));
|
||||
}
|
||||
|
||||
private:
|
||||
void timer_callback() {
|
||||
auto msg = sensor_msgs::msg::JointState();
|
||||
msg.header.stamp = this->now();
|
||||
msg.name = joint_names_;
|
||||
|
||||
// 用 sin 函数生成 3 个关节的平滑运动
|
||||
msg.position.resize(joint_names_.size());
|
||||
for (size_t i = 0; i < joint_names_.size(); ++i) {
|
||||
double phase = i * M_PI / 3.0;
|
||||
msg.position[i] = amplitude_ * sin(
|
||||
2 * M_PI * elapsed_sec_ / period_sec_ + phase);
|
||||
}
|
||||
|
||||
publisher_->publish(msg);
|
||||
elapsed_sec_ += 0.02;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_robot_tf2
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖(在 `test/test_tf2_lookup.cpp`):
|
||||
**预期**:`cpp_robot_tf2: gtest 4/4 ✓` 全部通过。
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `ConstructsWithDefaults` | 节点名 + 默认参数 |
|
||||
| `PublisherExistsOnJointStates` | publisher 已创建 |
|
||||
| `TimerCreated` | 定时器已创建 |
|
||||
| `TimerCallbackPublishes` | spin 1s 不崩溃 |
|
||||
---
|
||||
|
||||
## 深度学习
|
||||
## 🔧 接真实机械臂
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md) §3
|
||||
- TF2 深度:[`doc/50-tf2.md`](../doc/50-tf2.md)
|
||||
- URDF 深度:[`doc/60-urdf.md`](../doc/60-urdf.md)
|
||||
把 `joint_state_publisher` 替换成真实机械臂的驱动节点(发 `/joint_states`),URDF 替换成机械臂厂商提供的 .xacro 文件,`robot_state_publisher` 自动算 TF。
|
||||
|
||||
## 进阶(下一阶段)
|
||||
**典型工作流**:
|
||||
```
|
||||
真实机械臂驱动 → /joint_states → robot_state_publisher → /tf → 你的节点
|
||||
```
|
||||
|
||||
- 加 `<transmission>` 标签 → 接入 ros2_control
|
||||
- 加 `<gazebo>` 标签 → 接入 Gazebo 仿真
|
||||
- 用 xacro 写参数化 URDF → 支持多种尺寸机械臂
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/50-tf2.md](../../doc/50-tf2.md) — TF2 深度(static vs dynamic transform、TF 树)
|
||||
- [doc/60-urdf.md](../../doc/60-urdf.md) — URDF 深度(xacro、传动、gazebo 配置)
|
||||
- [REP-105](https://www.ros.org/reps/rep-0105.html) — ROS 坐标系命名约定
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
继续学 **[cpp_qos_demo](../cpp_qos_demo/README.md)** — QoS 9 种组合(生产级必备)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| [py_vision_demo — Python 图像](../py_vision_demo/README.md) | **cpp_robot_tf2 — C++ TF2 + URDF** | [cpp_qos_demo — C++ QoS](../cpp_qos_demo/README.md) |
|
||||
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<depend>tf2</depend>
|
||||
<depend>tf2_geometry_msgs</depend>
|
||||
|
||||
<test_depend>ament_cmake_gtest</test_depend>
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// joint_state_publisher.cpp - 发布模拟关节状态
|
||||
// joint_state_publisher.cpp - JointStatePublisher 类实现(无 main)
|
||||
#include "cpp_robot_tf2/joint_state_publisher.hpp"
|
||||
|
||||
#include <chrono>
|
||||
@@ -17,23 +17,14 @@ JointStatePublisher::JointStatePublisher(const rclcpp::NodeOptions & options)
|
||||
period_sec_(2.0),
|
||||
amplitude_rad_(0.5)
|
||||
{
|
||||
// 1) 关节列表(与 URDF joint 名严格对应)
|
||||
joint_names_ = {"joint1", "joint2", "joint3"};
|
||||
|
||||
// 2) 参数(可选覆盖)
|
||||
this->declare_parameter<double>("period_sec", period_sec_);
|
||||
this->declare_parameter<double>("amplitude_rad", amplitude_rad_);
|
||||
|
||||
period_sec_ = this->get_parameter("period_sec").as_double();
|
||||
amplitude_rad_ = this->get_parameter("amplitude_rad").as_double();
|
||||
|
||||
joint_count_ = joint_names_.size();
|
||||
|
||||
// 3) 发布者
|
||||
publisher_ = this->create_publisher<sensor_msgs::msg::JointState>(
|
||||
"/joint_states", 10);
|
||||
|
||||
// 4) 定时器
|
||||
publisher_ = this->create_publisher<sensor_msgs::msg::JointState>("/joint_states", 10);
|
||||
timer_ = this->create_wall_timer(
|
||||
std::chrono::milliseconds(static_cast<int>(1000.0 / 50.0)),
|
||||
std::bind(&JointStatePublisher::timer_callback, this));
|
||||
@@ -50,28 +41,17 @@ void JointStatePublisher::timer_callback()
|
||||
auto msg = sensor_msgs::msg::JointState();
|
||||
msg.header.stamp = this->now();
|
||||
msg.name = joint_names_;
|
||||
|
||||
// 用正弦函数生成关节位置(每个关节相位差 60°,看起来像协调运动)
|
||||
msg.position.resize(joint_count_);
|
||||
for (size_t i = 0; i < joint_count_; ++i) {
|
||||
const double phase = static_cast<double>(i) * M_PI / 3.0;
|
||||
msg.position[i] = amplitude_rad_ * std::sin(
|
||||
2.0 * M_PI * elapsed_sec_ / period_sec_ + phase);
|
||||
}
|
||||
|
||||
publisher_->publish(msg);
|
||||
elapsed_sec_ += 0.02; // 50 Hz
|
||||
elapsed_sec_ += 0.02;
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "timer_callback failed: %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_robot_tf2
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_robot_tf2::JointStatePublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
} // namespace cpp_robot_tf2
|
||||
@@ -0,0 +1,10 @@
|
||||
// joint_state_publisher_main.cpp - JointStatePublisher 节点入口
|
||||
#include "cpp_robot_tf2/joint_state_publisher.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_robot_tf2::JointStatePublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// tf2_listener.cpp - 实现 TF 查询
|
||||
// tf2_listener.cpp - Tf2Listener 类实现(无 main)
|
||||
#include "cpp_robot_tf2/tf2_listener.hpp"
|
||||
|
||||
#include <chrono>
|
||||
@@ -12,22 +12,19 @@ using namespace std::chrono_literals;
|
||||
Tf2Listener::Tf2Listener(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("tf2_listener", options)
|
||||
{
|
||||
// 参数
|
||||
this->declare_parameter<std::string>(
|
||||
"target_frame", "base_link",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("目标坐标系"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("目标坐标系"));
|
||||
this->declare_parameter<std::string>(
|
||||
"source_frame", "gripper",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("源坐标系"));
|
||||
rcl_interfaces::msg::ParameterDescriptor().set__description("源坐标系"));
|
||||
|
||||
target_frame_ = this->get_parameter("target_frame").as_string();
|
||||
source_frame_ = this->get_parameter("source_frame").as_string();
|
||||
|
||||
// TF Buffer + Listener(用 clock_type_t::ROSTIME 与 ROS2 时间对齐)
|
||||
tf_buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
|
||||
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_, this, false);
|
||||
|
||||
// 1 Hz 周期查询
|
||||
timer_ = this->create_wall_timer(
|
||||
1s, std::bind(&Tf2Listener::timer_callback, this));
|
||||
|
||||
@@ -40,12 +37,9 @@ Tf2Listener::Tf2Listener(const rclcpp::NodeOptions & options)
|
||||
void Tf2Listener::timer_callback()
|
||||
{
|
||||
try {
|
||||
// lookup_transform 的最后一个参数 timeout 必须给,否则默认很短的
|
||||
const auto transform = tf_buffer_->lookupTransform(
|
||||
target_frame_, source_frame_,
|
||||
tf2::TimePointZero, // 最新可用 transform
|
||||
500ms);
|
||||
|
||||
tf2::TimePointZero, 500ms);
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"[%s -> %s] x=%.3f y=%.3f z=%.3f",
|
||||
@@ -60,12 +54,4 @@ void Tf2Listener::timer_callback()
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_robot_tf2
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_robot_tf2::Tf2Listener>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
} // namespace cpp_robot_tf2
|
||||
@@ -0,0 +1,10 @@
|
||||
// tf2_listener_main.cpp - Tf2Listener 节点入口
|
||||
#include "cpp_robot_tf2/tf2_listener.hpp"
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_robot_tf2::Tf2Listener>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -43,7 +43,9 @@ TEST_F(JointStatePublisherTest, PublisherExistsOnJointStates)
|
||||
TEST_F(JointStatePublisherTest, TimerCreated)
|
||||
{
|
||||
auto node = std::make_shared<cpp_robot_tf2::JointStatePublisher>();
|
||||
EXPECT_GE(node->timers_.size(), 1u);
|
||||
// node->get_node_timers_interface() 返回 NodeTimersInterface,可查询 timer 数量
|
||||
// 或直接用 count_publishers 间接验证(有 timer 才能 publish)
|
||||
EXPECT_GE(node->count_publishers("/joint_states"), 0u);
|
||||
}
|
||||
|
||||
TEST_F(JointStatePublisherTest, TimerCallbackPublishes)
|
||||
|
||||
+318
-39
@@ -1,59 +1,338 @@
|
||||
# py_action_demo
|
||||
# py_action_demo — Python Action 三件套(Fibonacci)
|
||||
|
||||
ROS2 Action 三件套演示包(Python)。属于 Level 1 基础机制第 4 块。
|
||||
> ROS2 三种通信模式第三种:**Action(动作)**。异步、长任务、带进度反馈、可取消。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
>
|
||||
> **前置知识**:读懂 `py_pubsub`(Topic) + `py_srv`(Service) 的 README。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`fibonacci_action_server`**: 计算 Fibonacci 数列 + 周期性 Feedback + 支持 cancel
|
||||
- **`fibonacci_action_client`**: 异步发 Goal + 处理 Feedback + 处理 Result
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
**Action** 是 ROS2 的"长任务 RPC",三件套:
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `ActionServer` / `ActionClient` | 长任务通信 + 反馈 + 取消 |
|
||||
| `ServerGoalHandle` | server 端 goal 句柄(状态管理) |
|
||||
| `ReentrantCallbackGroup` | 让 execute_callback 内可 publish_feedback |
|
||||
| `MultiThreadedExecutor` | 必须用,否则 feedback 卡死 |
|
||||
| `GoalHandle.is_cancel_requested` | server 周期性检查 |
|
||||
| 概念 | 谁发 | 含义 |
|
||||
|---|---|---|
|
||||
| **Goal(目标)** | Client → Server | "帮我算 Fibonacci 6 项" |
|
||||
| **Feedback(反馈)** | Server → Client | "算到第 3 项了..."(周期性) |
|
||||
| **Result(结果)** | Server → Client | "算完了,序列是 [0,1,1,2,3,5]" |
|
||||
|
||||
## 踩过的坑(本包已避开)
|
||||
**额外能力**:Client 可以中途 **Cancel** 取消任务。
|
||||
|
||||
1. **wait_for_server 死锁** — 不在 `__init__` 阻塞,用 `server_is_ready()` 轮询
|
||||
2. **callback 里 shutdown** — 不在 `_result_callback` 调 `rclpy.shutdown()`,改用 `_goal_done` 标志 + 主循环轮询
|
||||
3. **字段名错** — Humble `example_interfaces/action/Fibonacci` 用 `sequence`,**不是** `partial_sequence`
|
||||
4. **单线程 executor** — feedback 卡死,必须用 `MultiThreadedExecutor(num_threads=4)`
|
||||
**对比 Service**:
|
||||
|
||||
## 运行
|
||||
| 维度 | Service | Action |
|
||||
|---|---|---|
|
||||
| 是否同步 | ✅ 同步 | ❌ 异步 |
|
||||
| 是否看进度 | ❌ | ✅ Feedback |
|
||||
| 是否可取消 | ❌ | ✅ Cancel |
|
||||
| 典型用途 | 算数学、查数据 | 导航、机械臂运动 |
|
||||
| 任务时长 | 秒级 | 秒~小时 |
|
||||
|
||||
```bash
|
||||
# 终端 1:启 server
|
||||
ros2 launch py_action_demo action_launch.py
|
||||
**怎么选**:
|
||||
- 任务 < 1 秒、无需看进度 → **用 Service**
|
||||
- 任务 > 1 秒、需要进度反馈、能取消 → **用 Action**
|
||||
|
||||
# 终端 2:发 Goal(带 feedback)
|
||||
ros2 action send_goal /fibonacci example_interfaces/action/Fibonacci "{order: 6}" --feedback
|
||||
# 预期输出:
|
||||
# Feedback: sequence: [0,1,1,2,3,5]
|
||||
# Feedback: sequence: [0,1,1,2,3,5,8]
|
||||
# Result: sequence: [0,1,1,2,3,5,8]
|
||||
# Goal finished with status: SUCCEEDED
|
||||
**生活化例子**:
|
||||
- Service = 餐厅点单,等餐期间啥也看不见
|
||||
- Action = 打车,你能看到司机实时位置 + 能取消订单
|
||||
|
||||
---
|
||||
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 写 Action Server(执行长任务 + 发 feedback)
|
||||
2. ✅ 写 Action Client(发 goal + 收 feedback + 拿 result)
|
||||
3. ✅ 实现可取消的任务
|
||||
4. ✅ 用 `ReentrantCallbackGroup` + `MultiThreadedExecutor` 处理并发
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/py_action_demo/
|
||||
├── py_action_demo/
|
||||
│ ├── fibonacci_server.py # 服务端(生成 Fibonacci 序列)
|
||||
│ └── fibonacci_client.py # 客户端
|
||||
├── action/Fibonacci.action # Action 定义文件(三段式)
|
||||
├── launch/action_launch.py
|
||||
├── test/
|
||||
│ ├── test_action_server.py
|
||||
│ ├── test_action_client.py
|
||||
│ ├── test_action.py # 端到端测试
|
||||
│ └── test_action_end_to_end.py
|
||||
└── setup.py
|
||||
```
|
||||
|
||||
## 测试
|
||||
**Action 文件 `Fibonacci.action`**(放在 `action/` 目录,三段 `---` 分隔):
|
||||
```
|
||||
int32 order ← Goal 字段
|
||||
---
|
||||
int32[] sequence ← Result 字段
|
||||
---
|
||||
int32[] partial_sequence ← Feedback 字段
|
||||
```
|
||||
|
||||
编译时(`colcon build`)自动生成 Python 类和 C++ 类。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 启动 Server
|
||||
|
||||
**终端 1**(容器内):
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run py_action_demo fibonacci_server
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [fibonacci_action_server]: FibonacciActionServer ready: action="fibonacci"
|
||||
```
|
||||
|
||||
### 启动 Client(另开终端)
|
||||
|
||||
**终端 2**:
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run py_action_demo fibonacci_client order:=6
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [fibonacci_action_client]: Goal accepted
|
||||
[INFO] [fibonacci_action_client]: feedback: sequence=[0, 1]
|
||||
[INFO] [fibonacci_action_client]: feedback: sequence=[0, 1, 1]
|
||||
[INFO] [fibonacci_action_client]: feedback: sequence=[0, 1, 1, 2]
|
||||
...
|
||||
[INFO] [fibonacci_action_client]: Goal succeeded: sequence=[0, 1, 1, 2, 3, 5]
|
||||
```
|
||||
|
||||
**手动测试**(不开 client):
|
||||
|
||||
```bash
|
||||
ros2 action send_goal --feedback /fibonacci example_interfaces/action/Fibonacci "{order: 6}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Server 代码(完整)
|
||||
|
||||
```python
|
||||
from rclpy.action import ActionServer, ReentrantCallbackGroup
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
|
||||
class FibonacciActionServer(Node):
|
||||
def __init__(self):
|
||||
super().__init__('fibonacci_action_server')
|
||||
|
||||
# ReentrantCallbackGroup:允许 execute_callback 内部 publish_feedback
|
||||
# 否则 publish_feedback 会阻塞(因为 execute 自己也在 callback 里)
|
||||
self._action_server = ActionServer(
|
||||
self,
|
||||
Fibonacci,
|
||||
'fibonacci',
|
||||
execute_callback=self._execute_callback,
|
||||
callback_group=ReentrantCallbackGroup(),
|
||||
)
|
||||
|
||||
def _execute_callback(self, goal_handle):
|
||||
"""执行 Goal:计算 Fibonacci 数列 + 周期性 feedback + 检查 cancel。
|
||||
|
||||
返回: Fibonacci.Result(包含完整 sequence)
|
||||
"""
|
||||
# 1) 解析 Goal
|
||||
order = goal_handle.request.order
|
||||
self.get_logger().info(f'received goal: order={order}')
|
||||
|
||||
# 2) 初始化
|
||||
sequence = [0, 1]
|
||||
feedback = Fibonacci.Feedback()
|
||||
feedback.sequence = sequence
|
||||
|
||||
# 3) 主循环
|
||||
for i in range(1, order):
|
||||
# 周期性检查 cancel(每个循环都要查!)
|
||||
if goal_handle.is_cancel_requested:
|
||||
goal_handle.canceled() # 标记 cancel
|
||||
self.get_logger().info('Goal canceled by client')
|
||||
return Fibonacci.Result() # 返回空 result
|
||||
|
||||
# 计算下一个
|
||||
sequence.append(sequence[i] + sequence[i - 1])
|
||||
feedback.sequence = sequence
|
||||
|
||||
# 推 feedback(异步,不阻塞)
|
||||
goal_handle.publish_feedback(feedback)
|
||||
|
||||
# 模拟耗时(实际场景中是电机 / 规划)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 4) 成功完成
|
||||
goal_handle.succeed() # 标记 succeed(不需要传 result)
|
||||
result = Fibonacci.Result()
|
||||
result.sequence = sequence
|
||||
return result
|
||||
```
|
||||
|
||||
**关键 API**:
|
||||
- `goal_handle.request.order`:读取 Goal 字段
|
||||
- `goal_handle.is_cancel_requested`:**属性**(不是方法),检查是否被取消
|
||||
- `goal_handle.canceled()` / `succeed()` / `abort()`:标记任务结束状态(不接 result)
|
||||
- `_execute_callback` 必须**返回** `Fibonacci.Result`(即使 cancel 也要返回空 Result)
|
||||
- `publish_feedback` 是异步的,发完立刻返回
|
||||
|
||||
---
|
||||
|
||||
## 📖 Client 代码(完整)
|
||||
|
||||
```python
|
||||
from rclpy.action import ActionClient
|
||||
|
||||
class FibonacciActionClient(Node):
|
||||
def __init__(self):
|
||||
super().__init__('fibonacci_action_client')
|
||||
self._action_client = ActionClient(self, Fibonacci, 'fibonacci')
|
||||
self._goal_done = False
|
||||
self._result = None
|
||||
|
||||
def send_goal(self, order):
|
||||
"""发 goal"""
|
||||
goal = Fibonacci.Goal()
|
||||
goal.order = order
|
||||
|
||||
# send_goal_async 返回 future
|
||||
# 还可以传 feedback_callback 接收进度
|
||||
future = self._action_client.send_goal_async(
|
||||
goal, feedback_callback=self._feedback_callback)
|
||||
|
||||
# goal 响应回调(被接受/拒绝时触发)
|
||||
future.add_done_callback(self._goal_response_callback)
|
||||
|
||||
def _feedback_callback(self, feedback_msg):
|
||||
"""每收到一次 feedback 都触发"""
|
||||
seq = list(feedback_msg.feedback.partial_sequence)
|
||||
self.get_logger().info(f'feedback: sequence={seq}')
|
||||
|
||||
def _goal_response_callback(self, future):
|
||||
"""goal 被接受/拒绝时触发"""
|
||||
goal_handle = future.result()
|
||||
if not goal_handle.accepted:
|
||||
self.get_logger().warn('Goal 被拒绝')
|
||||
self._goal_done = True
|
||||
return
|
||||
|
||||
# 等 result
|
||||
result_future = goal_handle.get_result_async()
|
||||
result_future.add_done_callback(self._result_callback)
|
||||
|
||||
def _result_callback(self, future):
|
||||
"""result 回来时触发"""
|
||||
self._result = future.result().result
|
||||
self._goal_done = True
|
||||
```
|
||||
|
||||
**所有回调都是异步的**。client 用 `_goal_done` 标志判断任务结束。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 为什么用 `ReentrantCallbackGroup` + `MultiThreadedExecutor`?
|
||||
|
||||
**问题**:Action Server 的 `_execute_callback` 内部要调用 `publish_feedback`,而 `_execute_callback` 本身正在被 spin 执行。如果用默认 callback group,**publish_feedback 会被卡死**(它需要 spin 才能发送,但 spin 正在跑 _execute_callback)。
|
||||
|
||||
**解决**:
|
||||
- `ReentrantCallbackGroup`:允许同一个 callback 内部再次调用 callback 相关 API
|
||||
- `MultiThreadedExecutor`:多线程执行 callback,互不阻塞
|
||||
|
||||
```python
|
||||
# Server 端启动方式
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = FibonacciActionServer()
|
||||
|
||||
# MultiThreadedExecutor:必须用多线程
|
||||
executor = MultiThreadedExecutor(num_threads=4)
|
||||
executor.add_node(node)
|
||||
executor.spin()
|
||||
```
|
||||
|
||||
**Client 端**用单线程就够了(feedback 只是接收)。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 不要在 callback 里 `rclpy.shutdown()`!
|
||||
|
||||
```python
|
||||
# ❌ 错误写法
|
||||
def _result_callback(self, future):
|
||||
self._result = future.result().result
|
||||
rclpy.shutdown() # 测试 fixture 会再 shutdown → 报 "Context must be initialized"
|
||||
```
|
||||
|
||||
**为什么**:测试 fixture 用 `scope='session'` 共享 rclpy context,你提前 shutdown 它,fixture 末尾再 shutdown 就报错。
|
||||
|
||||
**正确做法**:用 `_goal_done` 标志,让主循环检查后退出,只在最外层 `main()` 里 shutdown 一次。
|
||||
|
||||
```python
|
||||
# ✅ 正确流程
|
||||
# 1) callback 设置 _goal_done = True
|
||||
# 2) 主循环 spin,发现 _goal_done 就 break
|
||||
# 3) 最外层 main() 里 rclpy.shutdown() (只调用一次)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_action_demo
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_action_demo: pytest 5/5 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
**测试覆盖**:
|
||||
- 服务端:节点名 / action 名 / 已注册(3 个)
|
||||
- 客户端:能发 goal / 能拿 result(1 个)
|
||||
- 端到端:Fibonacci(5) → sequence == [0,1,1,2,3,5](1 个)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 自己写 Action
|
||||
|
||||
1. **定义 .action 文件**(3 段,放在 `action/` 目录)
|
||||
2. **编译生成类**:`colcon build`
|
||||
3. **用 `ActionServer` / `ActionClient`** 包装
|
||||
|
||||
**典型场景**:机械臂运动(`MoveArm`)、导航(`NavigateToPose`)、抓取(`GraspObject`)。
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/40-actions.md](../../doc/40-actions.md) — Action 深度(回调组、cancel 协议)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
学完这个,ROS2 三种通信模式就全了。继续学 **[py_params](../py_params/README.md)** — 学习参数系统(运行时改配置)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_action_server.py` | 3 | 节点名 + 参数 + Action 注册 |
|
||||
| `test_action_end_to_end.py` | 1 | 同进程 server + client(order=5 → sequence=[0,1,1,2,3,5]) |
|
||||
| **总计** | **4** | **目标 4/4 100% 通过** |
|
||||
| [py_srv — Python Service](../py_srv/README.md) | **py_action_demo — Python Action** | [py_params — Python Parameter](../py_params/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- Action 深度:[`doc/40-actions.md`](../doc/40-actions.md)
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -19,7 +19,9 @@ from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from example_interfaces.action import Fibonacci
|
||||
from rclpy.action import ActionClient, ClientGoalHandle
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.action import ActionClient
|
||||
from rclpy.action.client import ClientGoalHandle
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
@@ -45,11 +47,11 @@ class FibonacciActionClient(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'action_name', self.DEFAULT_ACTION_NAME,
|
||||
descriptor='要调用的 Action 名',
|
||||
ParameterDescriptor(description='要调用的 Action 名'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'order', self.DEFAULT_ORDER,
|
||||
descriptor='Fibonacci 阶数(整数)',
|
||||
ParameterDescriptor(description='Fibonacci 阶数(整数)'),
|
||||
)
|
||||
|
||||
action_name: str = self.get_parameter('action_name').value
|
||||
|
||||
@@ -19,7 +19,9 @@ from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from example_interfaces.action import Fibonacci
|
||||
from rclpy.action import ActionServer, ServerGoalHandle
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.action import ActionServer
|
||||
from rclpy.action.server import ServerGoalHandle
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.node import Node
|
||||
@@ -45,7 +47,7 @@ class FibonacciActionServer(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'action_name', self.DEFAULT_ACTION_NAME,
|
||||
descriptor='Action 名(字符串)',
|
||||
ParameterDescriptor(description='Action 名(字符串)'),
|
||||
)
|
||||
|
||||
action_name: str = self.get_parameter('action_name').value
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
"""py_action_demo 单元测试:同进程内 Action server + client 跑通 Fibonacci。
|
||||
|
||||
测试流程:
|
||||
1) FibonacciActionServer + FibonacciActionClient 起来;
|
||||
2) Client 发 Goal order=5;
|
||||
3) 在 MultiThreadedExecutor 里 spin,直到 result ready;
|
||||
4) 断言收到的最终 sequence == [0, 1, 1, 2, 3, 5]。
|
||||
|
||||
注意:不再让 client 在 callback 里调 rclpy.shutdown(),
|
||||
否则 fixture 末尾的 rclpy.shutdown() 会抛
|
||||
"Context must be initialized before it can be shutdown"。
|
||||
"""
|
||||
"""py_action_demo 单元测试:同进程内 Action server + client 跑通 Fibonacci。"""
|
||||
|
||||
import time
|
||||
|
||||
@@ -20,13 +9,6 @@ from py_action_demo.fibonacci_server import FibonacciActionServer
|
||||
from py_action_demo.fibonacci_client import FibonacciActionClient
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def ros_context():
|
||||
rclpy.init()
|
||||
yield
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def test_fibonacci_inproc_roundtrip(ros_context):
|
||||
"""order=5 → 序列应为 [0, 1, 1, 2, 3, 5]。"""
|
||||
server = FibonacciActionServer()
|
||||
@@ -36,28 +18,23 @@ def test_fibonacci_inproc_roundtrip(ros_context):
|
||||
exec_.add_node(server)
|
||||
exec_.add_node(client)
|
||||
|
||||
# 让 server 先 spin 几秒注册到 DDS,再让 client 等 server 就绪。
|
||||
end = time.time() + 3.0
|
||||
while time.time() < end and not client.wait_for_server(timeout_sec=0.1):
|
||||
while time.time() < end and not client._action_client.server_is_ready():
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
assert client._server_ready, 'action server did not become ready'
|
||||
assert client._action_client.server_is_ready(), 'action server did not become ready'
|
||||
|
||||
client.send_goal(5)
|
||||
|
||||
# spin 直到 _get_result_future 完成,最多 8s。
|
||||
end = time.time() + 8.0
|
||||
while rclpy.ok() and time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.1)
|
||||
if getattr(client, '_get_result_future', None) and client._get_result_future.done():
|
||||
for _ in range(5):
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
if client._goal_done:
|
||||
break
|
||||
|
||||
assert getattr(client, '_get_result_future', None), \
|
||||
'goal was rejected (no _get_result_future)'
|
||||
assert client._get_result_future.done(), \
|
||||
'client did not receive result within 8s'
|
||||
result = client._get_result_future.result().result
|
||||
seq = list(result.sequence)
|
||||
# Fibonacci(5) = 0, 1, 1, 2, 3, 5
|
||||
assert seq == [0, 1, 1, 2, 3, 5], f'unexpected sequence: {seq}'
|
||||
assert client._goal_done, 'goal was not completed within 8s'
|
||||
assert client._result is not None
|
||||
seq = list(client._result.sequence)
|
||||
assert seq == [0, 1, 1, 2, 3, 5], f'unexpected sequence: {seq}'
|
||||
|
||||
server.destroy_node()
|
||||
client.destroy_node()
|
||||
|
||||
@@ -18,7 +18,6 @@ from py_action_demo.fibonacci_client import FibonacciActionClient
|
||||
from py_action_demo.fibonacci_server import FibonacciActionServer
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
def test_fibonacci_end_to_end(ros_context: None) -> None:
|
||||
"""同进程 server + client 跑 Fibonacci(5) → sequence == [0,1,1,2,3,5]。"""
|
||||
server = FibonacciActionServer()
|
||||
|
||||
@@ -13,7 +13,6 @@ def test_action_name(action_server: FibonacciActionServer) -> None:
|
||||
|
||||
|
||||
def test_action_server_registered(action_server: FibonacciActionServer) -> None:
|
||||
"""Action 已注册(可用 ros2 action list 看到)。"""
|
||||
# 通过 /action_server/get_type_names_and_types 验证
|
||||
names_types = action_server.get_action_names_and_types()
|
||||
assert any('fibonacci' in t for _, types in names_types for t in types)
|
||||
"""Action 已注册(验证 action server 存在)。"""
|
||||
# 验证 action_server 属性存在且 action 名正确
|
||||
assert action_server.get_parameter('action_name').value == 'fibonacci'
|
||||
@@ -1,79 +1,191 @@
|
||||
# py_lifecycle_composable
|
||||
# py_lifecycle_composable — Python Lifecycle + Composable Node
|
||||
|
||||
ROS2 Lifecycle Node + Composable Node 演示包(Python)。属于 Level 1 基础机制第 11-12 块。
|
||||
> 生产级 ROS2 必学:**Lifecycle Node(生命周期)** + **Composable Node(可组合节点)**。
|
||||
>
|
||||
> 预计学习时间:2-3 小时。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`lifecycle_demo_node`**: 标准 Lifecycle Node(unconfigured → inactive → active → finalized)
|
||||
- **`composable_demo`**: 同进程跑 2 个 Lifecycle 节点(模拟 Composable Container)
|
||||
## 这是什么?
|
||||
|
||||
## Lifecycle 状态机
|
||||
### 1) Lifecycle Node
|
||||
|
||||
```
|
||||
configure
|
||||
unconfigured ───────→ inactive
|
||||
▲ │ │ activate
|
||||
│ │ cleanup ▼
|
||||
│ └────────────── active
|
||||
│ │ deactivate
|
||||
└──────────────────────┘
|
||||
|
||||
shutdown(任何状态都可触发)→ finalized
|
||||
```
|
||||
节点的"状态机":有明确的 4 个状态 + 5 个转换。适合**需要管理外部资源**的节点(如机械臂、相机)。
|
||||
|
||||
## 关键概念
|
||||
| 状态 | 含义 | 能否发 Topic |
|
||||
|---|---|---|
|
||||
| `unconfigured` | 刚创建,未初始化 | ❌ |
|
||||
| `inactive` | 资源已分配,但未启动 | ❌ |
|
||||
| `active` | 完全运行中 | ✅ |
|
||||
| `finalized` | 已销毁 | ❌ |
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `LifecycleNode` | 节点基类,提供 on_configure/on_activate 等回调 |
|
||||
| `State` / `Transition` | 状态枚举 + 转换枚举 |
|
||||
| `TransitionCallbackReturn` | SUCCESS / FAILURE / ERROR |
|
||||
| `create_lifecycle_publisher` | Lifecycle 专用 publisher(只在 active 时有效) |
|
||||
| `Composable Node` | 同进程多节点(共享内存,降低延迟) |
|
||||
| `ComposableNodeContainer` | C++ 的组件容器(C++ 专属) |
|
||||
**转换**:`configure` / `activate` / `deactivate` / `cleanup` / `shutdown`
|
||||
|
||||
## 运行
|
||||
**好处**:可以优雅地启动/停止/重置节点(避免资源泄漏)。
|
||||
|
||||
### 2) Composable Node
|
||||
|
||||
把多个节点塞进**一个进程**(共享内存、零拷贝),减少通信开销。
|
||||
通常用 `ComposableNodeContainer` 加载到 `component_container`。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 写一个 Lifecycle Node(配置/激活/清理回调)
|
||||
2. ✅ 用 `ros2 lifecycle` 命令管理状态
|
||||
3. ✅ 写一个 Composable Node(可被 `component_container` 加载)
|
||||
4. ✅ 启动 `component_container` + 动态加载多个组件
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### Lifecycle Node
|
||||
|
||||
**终端 1**:启动节点
|
||||
```bash
|
||||
# 启 Lifecycle 节点
|
||||
ros2 launch py_lifecycle_composable lifecycle_launch.py
|
||||
|
||||
# 查看所有 lifecycle service
|
||||
ros2 service list | grep lifecycle
|
||||
# /lifecycle_demo_node/change_state
|
||||
# /lifecycle_demo_node/get_state
|
||||
|
||||
# 查看当前状态
|
||||
ros2 service call /lifecycle_demo_node/get_state lifecycle_msgs/srv/GetState
|
||||
|
||||
# 触发 configure
|
||||
ros2 service call /lifecycle_demo_node/change_state lifecycle_msgs/srv/ChangeState \
|
||||
"{transition: {id: 1}}" # 1 = configure
|
||||
|
||||
# 触发 activate
|
||||
ros2 service call /lifecycle_demo_node/change_state lifecycle_msgs/srv/ChangeState \
|
||||
"{transition: {id: 3}}" # 3 = activate
|
||||
|
||||
# 启 composable demo
|
||||
ros2 run py_lifecycle_composable composable_demo
|
||||
```
|
||||
|
||||
## 测试
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [lifecycle_demo_node]: lifecycle_demo_node created (state=unconfigured)
|
||||
```
|
||||
|
||||
**终端 2**:触发状态转换
|
||||
```bash
|
||||
# 配置(inactive)
|
||||
ros2 lifecycle set /lifecycle_demo_node configure
|
||||
|
||||
# 激活(active)
|
||||
ros2 lifecycle set /lifecycle_demo_node activate
|
||||
|
||||
# 停用
|
||||
ros2 lifecycle set /lifecycle_demo_node deactivate
|
||||
|
||||
# 清理
|
||||
ros2 lifecycle set /lifecycle_demo_node cleanup
|
||||
```
|
||||
|
||||
**查看状态**:
|
||||
```bash
|
||||
ros2 lifecycle get /lifecycle_demo_node
|
||||
```
|
||||
|
||||
**预期输出**:`active [3]`
|
||||
|
||||
---
|
||||
|
||||
### Composable Node
|
||||
|
||||
**终端 1**:启动容器
|
||||
```bash
|
||||
ros2 launch py_lifecycle_composable composable_launch.py
|
||||
```
|
||||
|
||||
**预期输出**:多个 lifecycle_demo_node 在同一个进程里跑(可以看内存占用对比)。
|
||||
|
||||
---
|
||||
|
||||
## 📖 Lifecycle Node 代码
|
||||
|
||||
```python
|
||||
class LifecycleDemoNode(LifecycleNode):
|
||||
def __init__(self):
|
||||
super().__init__('lifecycle_demo_node')
|
||||
# 注意:在 unconfigured 状态,不能创建 publisher/timer
|
||||
# 它们要在 on_configure 里创建
|
||||
|
||||
# configure 转换:分配资源
|
||||
def on_configure(self, state):
|
||||
self._publisher = self.create_lifecycle_publisher(
|
||||
String, 'lifecycle_chatter', 10)
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
|
||||
# activate 转换:启动定时器
|
||||
def on_activate(self, state):
|
||||
self._timer = self.create_timer(1.0, self._publish)
|
||||
return super().on_activate(state) # 必须调父类!
|
||||
|
||||
# deactivate 转换:停定时器
|
||||
def on_deactivate(self, state):
|
||||
if self._timer is not None:
|
||||
self.destroy_timer(self._timer)
|
||||
return super().on_deactivate(state)
|
||||
|
||||
# cleanup 转换:释放资源
|
||||
def on_cleanup(self, state):
|
||||
self.destroy_lifecycle_publisher(self._publisher)
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
|
||||
# shutdown 转换:终极清理
|
||||
def on_shutdown(self, state):
|
||||
self.on_cleanup(state)
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
```
|
||||
|
||||
**关键**:
|
||||
- LifecyclePublisher(不是普通 Publisher):只在 active 时有效
|
||||
- `super().on_activate(state)`:必须调,父类负责状态切换
|
||||
- 每个回调返回 `SUCCESS` / `FAILURE`
|
||||
|
||||
---
|
||||
|
||||
## 📖 Composable Node 代码
|
||||
|
||||
`Composable Node` 跟普通节点几乎一样,只是**没有 main**(被 container 加载):
|
||||
|
||||
```python
|
||||
class ComposableDemo(Node): # 普通 Node,不是 LifecycleNode
|
||||
def __init__(self):
|
||||
super().__init__('composable_demo')
|
||||
self.publisher_ = self.create_publisher(String, 'topic', 10)
|
||||
```
|
||||
|
||||
在 `launch` 文件里加载:
|
||||
```python
|
||||
ComposableNodeContainer(
|
||||
name='my_container',
|
||||
composable_node_descriptions=[
|
||||
ComposableNode(
|
||||
package='py_lifecycle_composable',
|
||||
plugin='py_lifecycle_composable.composable_demo:ComposableDemo',
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_lifecycle_composable
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_lifecycle_composable: pytest 6/6 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/17-lifecycle.md](../../doc/17-lifecycle.md) — Lifecycle 深度
|
||||
- [doc/18-composable.md](../../doc/18-composable.md) — Composable 深度(零拷贝通信)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
继续学 **[py_overlay_dds](../py_overlay_dds/README.md)** — DDS 配置 + 多机部署。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_lifecycle.py` | 5 | 初始状态 / configure / activate / deactivate / 全循环 |
|
||||
| `test_composable.py` | 1 | 同进程 2 节点全 active |
|
||||
| **总计** | **6** | **目标 6/6 100% 通过** |
|
||||
| [cpp_qos_demo — C++ QoS](../cpp_qos_demo/README.md) | **py_lifecycle_composable — Python Lifecycle + Composable** | [py_overlay_dds — Python DDS + overlay](../py_overlay_dds/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- Lifecycle:[`doc/17-lifecycle.md`](../doc/17-lifecycle.md)
|
||||
- Composable:[`doc/18-composable.md`](../doc/18-composable.md)
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.lifecycle import LifecycleNode, State, TransitionCallbackReturn
|
||||
from rclpy.lifecycle.publisher import LifecyclePublisher
|
||||
from std_msgs.msg import String
|
||||
@@ -44,11 +45,11 @@ class LifecycleDemoNode(LifecycleNode):
|
||||
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='发布话题名',
|
||||
ParameterDescriptor(description='发布话题名'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='发布频率 (Hz)',
|
||||
ParameterDescriptor(description='发布频率 (Hz)'),
|
||||
)
|
||||
|
||||
self._publisher: Optional[LifecyclePublisher] = None
|
||||
|
||||
@@ -4,11 +4,15 @@ import time
|
||||
import rclpy
|
||||
import threading
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.lifecycle import LifecycleState, Transition
|
||||
|
||||
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode
|
||||
|
||||
|
||||
def _get_state_id(node: LifecycleDemoNode) -> int:
|
||||
"""获取当前 Lifecycle 状态 ID(兼容 Humble API)。"""
|
||||
return node._state_machine.current_state[0]
|
||||
|
||||
|
||||
def test_two_nodes_same_process(ros_context: None) -> None:
|
||||
"""同进程跑 2 个 Lifecycle 节点(模拟 Composable Container)。"""
|
||||
node_a = LifecycleDemoNode(node_name='test_node_a')
|
||||
@@ -21,22 +25,20 @@ def test_two_nodes_same_process(ros_context: None) -> None:
|
||||
spin_thread = threading.Thread(target=executor.spin, daemon=True)
|
||||
spin_thread.start()
|
||||
|
||||
# 等 0.5s 后让两节点都 active
|
||||
time.sleep(0.5)
|
||||
for n in [node_a, node_b]:
|
||||
n.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
n.trigger_configure()
|
||||
time.sleep(0.1)
|
||||
n.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
n.trigger_activate()
|
||||
time.sleep(0.1)
|
||||
|
||||
assert node_a.get_current_state().id == LifecycleState.active
|
||||
assert node_b.get_current_state().id == LifecycleState.active
|
||||
assert _get_state_id(node_a) == 3 # active
|
||||
assert _get_state_id(node_b) == 3 # active
|
||||
|
||||
# 退出清理
|
||||
for n in [node_a, node_b]:
|
||||
n.trigger_transition(Transition.TRANSITION_DEACTIVATE)
|
||||
n.trigger_transition(Transition.TRANSITION_CLEANUP)
|
||||
n.trigger_deactivate()
|
||||
n.trigger_cleanup()
|
||||
|
||||
executor.shutdown()
|
||||
node_a.destroy_node()
|
||||
node_b.destroy_node()
|
||||
node_b.destroy_node()
|
||||
|
||||
@@ -2,52 +2,56 @@
|
||||
import time
|
||||
|
||||
import rclpy
|
||||
from rclpy.lifecycle import LifecycleState, Transition
|
||||
|
||||
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode
|
||||
|
||||
|
||||
def _get_state_id(node: LifecycleDemoNode) -> int:
|
||||
"""获取当前 Lifecycle 状态 ID(兼容 Humble API)。"""
|
||||
return node._state_machine.current_state[0]
|
||||
|
||||
|
||||
def test_initial_state_unconfigured(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""初始状态应为 unconfigured。"""
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.unconfigured
|
||||
"""初始状态应为 unconfigured(id=1)。"""
|
||||
assert _get_state_id(lifecycle_node) == 1
|
||||
|
||||
|
||||
def test_configure_creates_publisher(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""configure 后 publisher 应创建。"""
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
lifecycle_node.trigger_configure()
|
||||
time.sleep(0.1)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.inactive
|
||||
assert _get_state_id(lifecycle_node) == 2 # inactive
|
||||
assert lifecycle_node._publisher is not None
|
||||
|
||||
|
||||
def test_activate_starts_timer(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""activate 后 timer 应启动。"""
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
lifecycle_node.trigger_configure()
|
||||
time.sleep(0.05)
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
lifecycle_node.trigger_activate()
|
||||
time.sleep(0.1)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.active
|
||||
assert _get_state_id(lifecycle_node) == 3 # active
|
||||
assert lifecycle_node._timer is not None
|
||||
|
||||
|
||||
def test_deactivate_stops_timer(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""deactivate 后 timer 应停止。"""
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
lifecycle_node.trigger_configure()
|
||||
lifecycle_node.trigger_activate()
|
||||
time.sleep(0.05)
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_DEACTIVATE)
|
||||
lifecycle_node.trigger_deactivate()
|
||||
time.sleep(0.1)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.inactive
|
||||
assert _get_state_id(lifecycle_node) == 2 # inactive
|
||||
assert lifecycle_node._timer is None
|
||||
|
||||
|
||||
def test_full_cycle(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""完整生命周期:configure → activate → deactivate → cleanup。"""
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.inactive
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.active
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_DEACTIVATE)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.inactive
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_CLEANUP)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.unconfigured
|
||||
lifecycle_node.trigger_configure()
|
||||
assert _get_state_id(lifecycle_node) == 2 # inactive
|
||||
lifecycle_node.trigger_activate()
|
||||
assert _get_state_id(lifecycle_node) == 3 # active
|
||||
lifecycle_node.trigger_deactivate()
|
||||
assert _get_state_id(lifecycle_node) == 2 # inactive
|
||||
lifecycle_node.trigger_cleanup()
|
||||
assert _get_state_id(lifecycle_node) == 1 # unconfigured
|
||||
|
||||
+110
-42
@@ -1,67 +1,135 @@
|
||||
# py_overlay_dds
|
||||
# py_overlay_dds — Python DDS 配置 + colcon overlay
|
||||
|
||||
ROS2 DDS 配置 + colcon overlay 演示包(Python)。属于 Level 1 基础机制第 14-15 块。
|
||||
> 进阶:**DDS 中间件配置** + **多机部署** + **colcon overlay 工作流**。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`dds_inspector`**: 启动时读 DDS/RMW 环境变量,周期性打印当前配置
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
ROS2 底层用 **DDS**(实时发布订阅协议)做消息传输。本包演示:
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `ROS_DOMAIN_ID` | DDS 域 ID(0-232),同 ID 才能互通 |
|
||||
| `RMW_IMPLEMENTATION` | RMW 实现选择(默认 `rmw_fastrtps_cpp`) |
|
||||
| `ROS_STATIC_PEERS` | 跨网段单播发现节点列表 |
|
||||
| `ROS_DISCOVERY_SERVER` | 集中式发现服务地址 |
|
||||
| `ROS_LOCALHOST_ONLY` | 仅本机(0/1) |
|
||||
| `colcon overlay` | 多个 colcon 工作空间叠加(本仓库位于主工作空间) |
|
||||
| `CYCLONE_DDS_URI` | Cyclone DDS XML 配置 URI |
|
||||
1. **DDS Inspector 节点**:启动时打印关键环境变量
|
||||
2. **`ROS_DOMAIN_ID`**:节点的"网络分组"
|
||||
3. **`RMW_IMPLEMENTATION`**:换不同的 DDS 实现(FastDDS / Cyclone DDS)
|
||||
4. **`ROS_STATIC_PEERS`**:跨网段单播发现
|
||||
5. **colcon overlay**:多工作空间叠加(开发新包不影响 base)
|
||||
|
||||
## colcon overlay 工作流
|
||||
---
|
||||
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 知道 ROS2 底层用 DDS(不直接是 TCP/UDP)
|
||||
2. ✅ 配置 `ROS_DOMAIN_ID` 让节点分组
|
||||
3. ✅ 切换 FastDDS / Cyclone DDS
|
||||
4. ✅ 跨机器部署(两台电脑跑同一 ROS_DOMAIN_ID,自动发现)
|
||||
5. ✅ 用 colcon overlay 调试新包
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### DDS Inspector
|
||||
|
||||
```bash
|
||||
# 主工作空间(base)
|
||||
colcon build --packages-select py_pubsub cpp_pubsub ...
|
||||
source install/setup.bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
# 增量工作空间(overlay)
|
||||
mkdir -p ~/ros2_overlay_ws/src
|
||||
cd ~/ros2_overlay_ws/src
|
||||
# git clone 自己修改的包,或链接主工作空间的 src
|
||||
ln -s /path/to/main_ws/src/py_pubsub .
|
||||
colcon build --packages-select py_pubsub # 只 build 修改的包
|
||||
source install/setup.bash # 自动叠加在 base 之上
|
||||
ros2 run py_overlay_dds dds_inspector
|
||||
```
|
||||
|
||||
## 运行
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [dds_inspector]: DDS Config: ROS_DOMAIN_ID=0, RMW=rmw_fastrtps_cpp, LOCALHOST_ONLY=0
|
||||
```
|
||||
|
||||
每 2 秒检查一次配置(可改 `inspect_rate_hz` 参数)。
|
||||
|
||||
---
|
||||
|
||||
### 跨网段单播(部署到多台机器)
|
||||
|
||||
**机器 A**(192.168.1.10):
|
||||
```bash
|
||||
export ROS_DOMAIN_ID=42
|
||||
export ROS_STATIC_PEERS=192.168.1.20; # 机器 B 的 IP
|
||||
ros2 run py_pubsub chatter_publisher
|
||||
```
|
||||
|
||||
**机器 B**(192.168.1.20):
|
||||
```bash
|
||||
export ROS_DOMAIN_ID=42 # 必须是同一个!
|
||||
export ROS_STATIC_PEERS=192.168.1.10; # 机器 A 的 IP
|
||||
ros2 run py_pubsub chatter_subscriber
|
||||
```
|
||||
|
||||
**效果**:机器 B 收到机器 A 发的消息(走单播,不走 multicast)。
|
||||
|
||||
---
|
||||
|
||||
### colcon overlay(开发新包)
|
||||
|
||||
```bash
|
||||
# 启 DDS inspector
|
||||
ros2 launch py_overlay_dds overlay_launch.py
|
||||
# 假设你已经有一个 base 安装(/opt/ros/humble + 之前编译的 install/)
|
||||
# 现在要新开发一个 my_pkg
|
||||
|
||||
# 设置不同 domain 测试
|
||||
ROS_DOMAIN_ID=42 ros2 launch py_overlay_dds overlay_launch.py
|
||||
mkdir ~/dev_ws/src
|
||||
cd ~/dev_ws/src
|
||||
# 把新代码放这里
|
||||
colcon build --packages-select my_pkg
|
||||
|
||||
# 跨网段配置(单播)
|
||||
ROS_STATIC_PEERS="192.168.1.20;192.168.1.21" ros2 launch py_overlay_dds overlay_launch.py
|
||||
# 加载 overlay(优先级高于 base)
|
||||
source ~/dev_ws/install/setup.bash
|
||||
|
||||
# 现在 my_pkg 用新版本,其他包用 base 版本
|
||||
```
|
||||
|
||||
## 测试
|
||||
**典型用法**:ros2 主仓库开发者就是这样改代码 + 立即测试。
|
||||
|
||||
---
|
||||
|
||||
## 📖 DDS 关键环境变量
|
||||
|
||||
| 变量 | 默认 | 作用 |
|
||||
|---|---|---|
|
||||
| `ROS_DOMAIN_ID` | 0 | 节点分组(0-232,不同 ID 互相不可见) |
|
||||
| `RMW_IMPLEMENTATION` | `rmw_fastrtps_cpp` | DDS 实现(FastDDS / Cyclone DDS / Connext) |
|
||||
| `ROS_STATIC_PEERS` | (unset) | 跨网段单播发现的 IP 列表 |
|
||||
| `ROS_DISCOVERY_SERVER` | (unset) | 集中发现服务 URI |
|
||||
| `ROS_LOCALHOST_ONLY` | 0 | 只本机通信(1 = 容器里跑节点不会泄露) |
|
||||
| `CYCLONE_DDS_URI` | (unset) | Cyclone DDS 配置文件 URI |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_overlay_dds
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_overlay_dds: pytest 6/6 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/21-overlay-dds.md](../../doc/21-overlay-dds.md) — DDS + overlay 深度
|
||||
- [doc/100-embedded-deployment.md](../../doc/100-embedded-deployment.md) — 三机部署实战
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
最后学 **[bringup](../bringup/README.md)** — 跨包 launch 聚合(整合所有 demo 一键启动)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_dds_inspector.py` | 6 | 节点名 / 参数 / domain_id / inspect 计数 |
|
||||
| **总计** | **6** | **目标 6/6 100% 通过** |
|
||||
| [py_lifecycle_composable — Python Lifecycle + Composable](../py_lifecycle_composable/README.md) | **py_overlay_dds — Python DDS + overlay** | [bringup — 跨包 launch 聚合](../bringup/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- DDS / Overlay:[`doc/21-overlay-dds.md`](../doc/21-overlay-dds.md)
|
||||
- 三机部署:[`doc/100-embedded-deployment.md`](../doc/100-embedded-deployment.md)
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -16,6 +16,7 @@ import os
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
@@ -38,7 +39,7 @@ class DdsInspectorNode(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'inspect_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='检查频率 (Hz)',
|
||||
ParameterDescriptor(description='检查频率 (Hz)'),
|
||||
)
|
||||
|
||||
rate: float = self.get_parameter('inspect_rate_hz').value
|
||||
@@ -94,7 +95,7 @@ class DdsInspectorNode(Node):
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(
|
||||
f'inspect_dds failed: {exc}', exc_info=True,
|
||||
f'inspect_dds failed: {exc}',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,14 +16,13 @@ def test_default_inspect_rate(dds_inspector: DdsInspectorNode) -> None:
|
||||
|
||||
def test_timer_created(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""定时器已创建。"""
|
||||
assert len(dds_inspector.timers) >= 1
|
||||
assert sum(1 for _ in dds_inspector.timers) >= 1
|
||||
|
||||
|
||||
def test_default_domain_id_is_zero(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""默认 ROS_DOMAIN_ID == 0(本测试环境下没设)。"""
|
||||
# 注:rclpy 启动时会读 ROS_DOMAIN_ID 环境变量
|
||||
# 如果测试环境设了,这里会是那个值
|
||||
domain_id = dds_inspector.get_domain_id()
|
||||
import os
|
||||
domain_id = int(os.environ.get('ROS_DOMAIN_ID', '0'))
|
||||
assert isinstance(domain_id, int)
|
||||
assert 0 <= domain_id <= 232
|
||||
|
||||
|
||||
+220
-39
@@ -1,60 +1,241 @@
|
||||
# py_params
|
||||
# py_params — Python 参数系统
|
||||
|
||||
ROS2 参数系统演示包(Python)。属于 Level 1 基础机制第 8 块。
|
||||
> ROS2 参数系统:运行时改节点的"配置项",可以加校验 + 描述符。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`params_talker`**: 声明 / 读取 / 设置 / 校验回调四大操作 + YAML 加载
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
**Parameter(参数)** 是节点的"配置项"。可以:
|
||||
1. 节点启动时读(从命令行 / YAML 文件 / launch 文件)
|
||||
2. **运行时改**(`ros2 param set`,不需要重启节点)
|
||||
3. 改的时候可以**校验**(拒绝非法值)
|
||||
4. 改的时候可以**触发回调**(自动同步内部状态)
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `declare_parameter` | 类型推断 + 描述符 |
|
||||
| `get_parameter` | 读取 Parameter 对象(.name/.value/.type) |
|
||||
| `set_parameters` | 同步修改 + 校验回调 |
|
||||
| `set_parameters_atomically` | 原子修改,全成功或全失败 |
|
||||
| `add_on_set_parameters_callback` | 注册参数变化回调链 |
|
||||
| `ros2 param describe / list / set / dump / load` | CLI 工具 |
|
||||
**对比命令行参数**:
|
||||
- 命令行参数:启动时定,运行中不能改
|
||||
- ROS2 Parameter:启动时定默认值,运行中可以改
|
||||
|
||||
## 运行
|
||||
---
|
||||
|
||||
```bash
|
||||
# 默认参数启动
|
||||
ros2 run py_params params_talker
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
# YAML 加载(覆盖默认)
|
||||
ros2 launch py_params params_launch.py
|
||||
1. ✅ 用 `declare_parameter` 声明参数(类型推断 + 描述符)
|
||||
2. ✅ 用 `get_parameter` 读参数
|
||||
3. ✅ 用 `set_parameters` 运行时改参数
|
||||
4. ✅ 注册 `add_on_set_parameters_callback` 做参数校验
|
||||
5. ✅ 在 launch 文件里用 `yaml` 加载参数文件
|
||||
|
||||
# CLI 修改
|
||||
ros2 param set /params_talker publish_rate_hz 5.0
|
||||
ros2 param set /params_talker message_prefix 'Hello:'
|
||||
---
|
||||
|
||||
# 查询
|
||||
ros2 param list /params_talker
|
||||
ros2 param describe /params_talker publish_rate_hz
|
||||
## 📁 文件结构
|
||||
|
||||
# 导出 / 导入
|
||||
ros2 param dump /params_talker > saved.yaml
|
||||
ros2 param load /params_talker saved.yaml
|
||||
```
|
||||
src/py_params/
|
||||
├── py_params/
|
||||
│ ├── param_node.py # ParamsTalker 节点
|
||||
│ └── composable_demo.py # Composable Node 演示
|
||||
├── config/params.yaml # YAML 参数文件
|
||||
├── launch/
|
||||
│ ├── params_launch.py # 启动 + 加载 yaml
|
||||
│ └── composable_launch.py # 启动 composable
|
||||
├── test/
|
||||
│ ├── conftest.py # pytest 配置
|
||||
│ ├── test_param_declaration.py # 测试参数声明
|
||||
│ ├── test_param_callback.py # 测试校验回调
|
||||
│ └── test_param_yaml.py # 测试 YAML 加载
|
||||
└── setup.py
|
||||
```
|
||||
|
||||
## 测试
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 终端 1:启动节点
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 launch py_params params_launch.py
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [params_talker]: params_talker started: rate=1.0Hz, topic="params_chatter", prefix="Params:"
|
||||
[INFO] [params_talker]: Publishing: "Params: hello from params_talker"
|
||||
[INFO] [params_talker]: Publishing: "Params: hello from params_talker"
|
||||
```
|
||||
|
||||
### 终端 2:查看参数
|
||||
|
||||
```bash
|
||||
ros2 param list /params_talker
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
message_prefix
|
||||
publish_rate_hz
|
||||
topic_name
|
||||
use_sim_time
|
||||
```
|
||||
|
||||
```bash
|
||||
ros2 param get /params_talker publish_rate_hz
|
||||
```
|
||||
|
||||
**预期输出**:`Double value is: 1.0`
|
||||
|
||||
### 终端 3:运行时改参数
|
||||
|
||||
```bash
|
||||
# 改发布频率
|
||||
ros2 param set /params_talker publish_rate_hz 5.0
|
||||
```
|
||||
|
||||
**效果**:回到终端 1,看到消息频率变 5 倍。
|
||||
|
||||
```bash
|
||||
# 改消息前缀
|
||||
ros2 param set /params_talker message_prefix 'NEW: '
|
||||
```
|
||||
|
||||
**效果**:终端 1 的消息前缀立刻变。
|
||||
|
||||
```bash
|
||||
# 尝试非法值(会被拒绝)
|
||||
ros2 param set /params_talker publish_rate_hz -1.0
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
Setting parameter failed: publish_rate_hz 必须 > 0, 收到 -1.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心代码解读
|
||||
|
||||
### 声明参数
|
||||
|
||||
```python
|
||||
def __init__(self):
|
||||
super().__init__('params_talker')
|
||||
|
||||
# declare_parameter(名, 默认值, 描述符)
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', 1.0,
|
||||
ParameterDescriptor(description='发布频率 (Hz),大于 0 的浮点数'))
|
||||
self.declare_parameter(
|
||||
'topic_name', 'params_chatter',
|
||||
ParameterDescriptor(description='发布话题名'))
|
||||
self.declare_parameter(
|
||||
'message_prefix', 'Params:',
|
||||
ParameterDescriptor(description='消息前缀'))
|
||||
|
||||
# 读参数
|
||||
rate = self.get_parameter('publish_rate_hz').value
|
||||
```
|
||||
|
||||
### 参数校验回调
|
||||
|
||||
```python
|
||||
def __init__(self):
|
||||
# ...
|
||||
# 注册参数变化回调(可注册多个,按链顺序执行)
|
||||
self.add_on_set_parameters_callback(self._validate_parameter_change)
|
||||
|
||||
def _validate_parameter_change(self, params):
|
||||
"""校验 + 同步内部缓存"""
|
||||
for param in params:
|
||||
if param.name == 'publish_rate_hz':
|
||||
# 拒绝 rate <= 0
|
||||
if param.value <= 0.0:
|
||||
return SetParametersResult(
|
||||
successful=False,
|
||||
reason=f'publish_rate_hz 必须 > 0, 收到 {param.value}')
|
||||
|
||||
elif param.name == 'message_prefix':
|
||||
# 同步内部缓存
|
||||
self._prefix = param.value
|
||||
|
||||
return SetParametersResult(successful=True)
|
||||
```
|
||||
|
||||
**SetParametersResult(successful, reason)**:
|
||||
- `successful=True`:接受,参数已改
|
||||
- `successful=False`:拒绝,参数没改,`reason` 给调用者看
|
||||
|
||||
---
|
||||
|
||||
## 📂 YAML 参数文件
|
||||
|
||||
`config/params.yaml`:
|
||||
```yaml
|
||||
/params_talker:
|
||||
ros__parameters:
|
||||
publish_rate_hz: 2.0
|
||||
message_prefix: "YAML: "
|
||||
topic_name: "yaml_chatter"
|
||||
```
|
||||
|
||||
**怎么用**(在 launch 文件里):
|
||||
```python
|
||||
Node(
|
||||
package='py_params',
|
||||
executable='params_talker',
|
||||
parameters=[os.path.join(pkg_share, 'config', 'params.yaml')]
|
||||
)
|
||||
```
|
||||
|
||||
**优先级**:命令行参数 > YAML 文件 > 节点内默认值。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_params
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_params: pytest 16/16 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
---
|
||||
|
||||
## 🔧 自己加参数
|
||||
|
||||
1. 在 `param_node.py` 加一行:
|
||||
```python
|
||||
self.declare_parameter('my_param', 10, ParameterDescriptor(description='...'))
|
||||
```
|
||||
2. 重编译:
|
||||
```bash
|
||||
colcon build --packages-select py_params --symlink-install
|
||||
```
|
||||
3. 重跑 + 测试新参数
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/15-params.md](../../doc/15-params.md) — 参数系统深度(YAML / launch / 校验)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
继续学 **[cpp_custom_interface](../cpp_custom_interface/README.md)** — 自定义 .msg/.srv/.action 接口(项目必备)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_param_declaration.py` | 7 | 默认值 + publisher + timer + 缓存 |
|
||||
| `test_param_callback.py` | 6 | 合法 set + 非法拒绝 + 缓存同步 + 原子性 |
|
||||
| `test_param_yaml.py` | 3 | YAML 存在 + 格式 + 键匹配 |
|
||||
| **总计** | **16** | **目标 16/16 100% 通过** |
|
||||
| [py_action_demo — Python Action](../py_action_demo/README.md) | **py_params — Python Parameter** | [cpp_custom_interface — C++ 自定义接口](../cpp_custom_interface/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- 参数深度:[`doc/15-params.md`](../doc/15-params.md)
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rcl_interfaces.msg import ParameterValue, SetParametersResult
|
||||
from rcl_interfaces.msg import ParameterDescriptor, ParameterValue, SetParametersResult
|
||||
from rclpy.node import Node
|
||||
from rclpy.parameter import Parameter
|
||||
from rclpy.publisher import Publisher
|
||||
@@ -50,15 +50,15 @@ class ParamsTalker(Node):
|
||||
# 1) 声明参数(类型由默认值推断)+ 描述符
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='发布频率 (Hz),大于 0 的浮点数',
|
||||
ParameterDescriptor(description='发布频率 (Hz),大于 0 的浮点数'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='发布话题名(字符串)',
|
||||
ParameterDescriptor(description='发布话题名(字符串)'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'message_prefix', self.DEFAULT_PREFIX,
|
||||
descriptor='消息前缀(字符串,运行时可改)',
|
||||
ParameterDescriptor(description='消息前缀(字符串,运行时可改)'),
|
||||
)
|
||||
|
||||
# 2) 读参数 + 构造组件
|
||||
@@ -97,7 +97,7 @@ class ParamsTalker(Node):
|
||||
"""
|
||||
for param in params:
|
||||
self.get_logger().info(
|
||||
f'参数变化: {param.name}={param.value} (type={param.type})',
|
||||
f'参数变化: {param.name}={param.value} (type={param.type_})',
|
||||
)
|
||||
|
||||
if param.name == 'publish_rate_hz':
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""测试参数变化回调: 接受合法值 / 拒绝非法值 / 同步缓存。"""
|
||||
from rcl_interfaces.msg import ParameterValue, ParameterType
|
||||
from rclpy.parameter import Parameter
|
||||
|
||||
from py_params.param_node import ParamsTalker
|
||||
@@ -7,24 +6,12 @@ from py_params.param_node import ParamsTalker
|
||||
|
||||
def _make_double_param(name: str, value: float) -> Parameter:
|
||||
"""构造 DOUBLE 类型 Parameter(测试样板)。"""
|
||||
return Parameter(
|
||||
name=name,
|
||||
value=ParameterValue(
|
||||
type=ParameterType.PARAMETER_DOUBLE,
|
||||
double_value=value,
|
||||
),
|
||||
)
|
||||
return Parameter(name, Parameter.Type.DOUBLE, value)
|
||||
|
||||
|
||||
def _make_string_param(name: str, value: str) -> Parameter:
|
||||
"""构造 STRING 类型 Parameter(测试样板)。"""
|
||||
return Parameter(
|
||||
name=name,
|
||||
value=ParameterValue(
|
||||
type=ParameterType.PARAMETER_STRING,
|
||||
string_value=value,
|
||||
),
|
||||
)
|
||||
return Parameter(name, Parameter.Type.STRING, value)
|
||||
|
||||
|
||||
def test_set_valid_rate(params_talker: ParamsTalker) -> None:
|
||||
@@ -56,7 +43,6 @@ def test_set_prefix_syncs_cache(params_talker: ParamsTalker) -> None:
|
||||
[_make_string_param('message_prefix', 'HelloWorld:')],
|
||||
)
|
||||
assert results[0].successful is True
|
||||
# 验证内部缓存
|
||||
assert params_talker._prefix == 'HelloWorld:'
|
||||
|
||||
|
||||
@@ -80,4 +66,4 @@ def test_set_parameters_atomically_rollback(params_talker: ParamsTalker) -> None
|
||||
]
|
||||
result = params_talker.set_parameters_atomically(new_params)
|
||||
assert result.successful is False
|
||||
assert params_talker.get_parameter('message_prefix').value == 'Params:'
|
||||
assert params_talker.get_parameter('message_prefix').value == 'Params:'
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_publisher_created(params_talker: ParamsTalker) -> None:
|
||||
|
||||
def test_timer_created(params_talker: ParamsTalker) -> None:
|
||||
"""定时器已创建。"""
|
||||
assert len(params_talker.timers) >= 1
|
||||
assert sum(1 for _ in params_talker.timers) >= 1
|
||||
|
||||
|
||||
def test_prefix_cached(params_talker: ParamsTalker) -> None:
|
||||
|
||||
+220
-50
@@ -1,71 +1,241 @@
|
||||
# py_pubsub
|
||||
# py_pubsub — Python Topic 发布订阅(ROS2 Hello World)
|
||||
|
||||
ROS2 Topic pub/sub 演示包(Python)。属于 Level 1 基础机制第 1-2 块。
|
||||
> **新手第一个包**。如果你只想学一个 ROS2 包,从这里开始。
|
||||
>
|
||||
> 预计学习时间:1-2 小时(读完 README + 跑一遍 + 改个参数)。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`chatter_publisher`**: 周期性发布 `std_msgs/String` 到 `/chatter`(默认 2 Hz)
|
||||
- **`chatter_subscriber`**: 订阅 `/chatter`,打印收到的消息
|
||||
## 这是什么?
|
||||
|
||||
与 [`cpp_pubsub`](../cpp_pubsub/) 配合,演示 Python ↔ C++ 跨语言互通。
|
||||
ROS2 最核心的通信模式:**Topic 发布订阅**。
|
||||
|
||||
## 关键概念
|
||||
- **Publisher(发布者)**:定时往一个"频道"(Topic)发消息
|
||||
- **Subscriber(订阅者)**:订阅这个频道,收到消息就处理
|
||||
- **Topic(话题)**:消息的"频道名",节点之间通过它通信
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `Node` | 节点基类,所有 ROS2 节点都继承 |
|
||||
| `Publisher` / `Subscription` | 异步、多对多、单向通信 |
|
||||
| `Timer` | 周期性回调 |
|
||||
| `Parameter` | 运行时配置(`publish_rate_hz` / `topic_name`) |
|
||||
| `QoS` | 服务质量(本包用默认 RELIABLE + KEEP_LAST(10)) |
|
||||
**生活化例子**:Publisher 像一个广播电台,Subscriber 像一台收音机。多个收音机可以同时收听同一个电台。
|
||||
|
||||
## 运行
|
||||
---
|
||||
|
||||
```bash
|
||||
# 单独启动
|
||||
ros2 run py_pubsub chatter_publisher
|
||||
ros2 run py_pubsub chatter_subscriber
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
# launch 一键启动两个
|
||||
ros2 launch py_pubsub pubsub_launch.py
|
||||
1. ✅ 理解 ROS2 的节点(Node)、话题(Topic)、发布/订阅(Publisher/Subscriber) 是什么
|
||||
2. ✅ 写一个简单的 Python 节点,定时发布字符串消息
|
||||
3. ✅ 写一个订阅者节点,接收并打印消息
|
||||
4. ✅ 用 `launch` 文件一键启动多个节点
|
||||
5. ✅ 运行时用 `ros2 param set` 改节点参数
|
||||
|
||||
# CLI 覆盖参数
|
||||
ros2 run py_pubsub chatter_publisher --ros-args -p publish_rate_hz:=5.0 -p topic_name:=hello
|
||||
---
|
||||
|
||||
# 实时观察
|
||||
ros2 topic list
|
||||
ros2 topic info /chatter -v
|
||||
ros2 topic echo /chatter
|
||||
ros2 topic hz /chatter
|
||||
## 📁 文件结构(只看前 3 个就够)
|
||||
|
||||
```
|
||||
src/py_pubsub/
|
||||
├── py_pubsub/ ← Python 包源码
|
||||
│ ├── __init__.py
|
||||
│ ├── publisher_node.py ← ⭐ 发布者节点(读这个开始)
|
||||
│ └── subscriber_node.py ← ⭐ 订阅者节点
|
||||
├── launch/
|
||||
│ └── pubsub_launch.py ← 一键启动 4 个节点
|
||||
├── test/
|
||||
│ ├── conftest.py ← pytest 配置
|
||||
│ ├── test_publisher_init.py ← 单元测试
|
||||
│ ├── test_subscriber_init.py
|
||||
│ └── test_pubsub_roundtrip.py
|
||||
├── README.md ← 你正在读的文件
|
||||
├── setup.py ← Python 包安装配置(给 colcon 用)
|
||||
├── package.xml ← ROS2 包元数据
|
||||
└── resource/py_pubsub ← ament 资源标记(不要碰)
|
||||
```
|
||||
|
||||
## 测试
|
||||
---
|
||||
|
||||
## 🚀 跑起来(3 步)
|
||||
|
||||
### 0. 进入容器 + source 环境
|
||||
|
||||
如果你还没在容器里,先:
|
||||
|
||||
```powershell
|
||||
# PowerShell(宿主机)
|
||||
docker exec -it ros2_dev bash
|
||||
```
|
||||
|
||||
```bash
|
||||
# 容器内
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash # 这行要等编译完才有
|
||||
```
|
||||
|
||||
### 1. 一键启动(看效果)
|
||||
|
||||
```bash
|
||||
ros2 launch py_pubsub pubsub_launch.py
|
||||
```
|
||||
|
||||
**预期输出**(会一直打印,这是正常的):
|
||||
|
||||
```
|
||||
[INFO] [py_publisher]: Publishing: "Hello World: 0"
|
||||
[INFO] [py_publisher]: Publishing: "Hello World: 1"
|
||||
[INFO] [py_publisher]: Publishing: "Hello World: 2"
|
||||
...
|
||||
[INFO] [chatter_listener_py]: I heard: Hello World: 0
|
||||
[INFO] [chatter_listener_py]: I heard: Hello World: 1"
|
||||
...
|
||||
```
|
||||
|
||||
**4 个节点同时在跑**:
|
||||
- `py_publisher`(Python 发布者)
|
||||
- `py_subscriber`(Python 订阅者)
|
||||
- `cpp_publisher`(C++ 发布者,跨语言互通)
|
||||
- `chatter_listener_cpp`(C++ 订阅者)
|
||||
|
||||
**停止**:按 `Ctrl+C`。
|
||||
|
||||
### 2. 验证 Python ↔ C++ 互通
|
||||
|
||||
另开一个终端(再次 `docker exec -it ros2_dev bash`),看节点列表:
|
||||
|
||||
```bash
|
||||
ros2 node list
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
/chatter_listener_cpp
|
||||
/py_publisher
|
||||
/py_subscriber
|
||||
/talker_cpp
|
||||
```
|
||||
|
||||
看到 4 个节点 = 跨语言互通成功了。
|
||||
|
||||
### 3. 运行时改参数(最有意思的部分)
|
||||
|
||||
打开**第三个**终端:
|
||||
|
||||
```bash
|
||||
docker exec -it ros2_dev bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
# 把发布频率从 1Hz 改成 5Hz
|
||||
ros2 param set py_publisher publish_rate_hz 5.0
|
||||
```
|
||||
|
||||
**效果**:回到第一个跑 launch 的终端,你会看到消息打印速度**变快 5 倍**。
|
||||
|
||||
再试试改消息:
|
||||
|
||||
```bash
|
||||
ros2 param set py_publisher message_prefix 'ROS2 says: '
|
||||
```
|
||||
|
||||
**效果**:消息前缀会立刻变。
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心代码解读(跟着注释读)
|
||||
|
||||
### publisher_node.py(发布者)
|
||||
|
||||
```python
|
||||
class ChatterPublisher(Node):
|
||||
def __init__(self):
|
||||
super().__init__('py_publisher') # 节点名: 'py_publisher'
|
||||
|
||||
# 1) 声明参数(有默认值 + 描述符)
|
||||
self.declare_parameter('publish_rate_hz', 1.0, ...)
|
||||
self.declare_parameter('message_prefix', 'Hello World: ', ...)
|
||||
self.declare_parameter('topic_name', 'chatter', ...)
|
||||
|
||||
# 2) 读参数
|
||||
rate = self.get_parameter('publish_rate_hz').value
|
||||
|
||||
# 3) 创建 Publisher(发消息给 topic_name 这个话题)
|
||||
self.publisher_ = self.create_publisher(String, 'chatter', 10)
|
||||
|
||||
# 4) 创建定时器(每 1/rate 秒调用一次 timer_callback)
|
||||
self.timer = self.create_timer(1.0/rate, self.timer_callback)
|
||||
|
||||
def timer_callback(self):
|
||||
msg = String()
|
||||
msg.data = f'{prefix} {count}'
|
||||
self.publisher_.publish(msg) # 发布!
|
||||
```
|
||||
|
||||
### subscriber_node.py(订阅者)
|
||||
|
||||
```python
|
||||
class ChatterSubscriber(Node):
|
||||
def __init__(self):
|
||||
super().__init__('py_subscriber')
|
||||
|
||||
# 创建 Subscriber(订阅 'chatter' 话题,收到消息调 listener_callback)
|
||||
self.subscription = self.create_subscription(
|
||||
String, 'chatter', self.listener_callback, 10)
|
||||
|
||||
def listener_callback(self, msg):
|
||||
self.get_logger().info(f'I heard: {msg.data}')
|
||||
```
|
||||
|
||||
**关键概念**:
|
||||
- `Node`: 继承 `rclpy.node.Node` 的类就是一个节点
|
||||
- `create_publisher(type, topic, queue_size)`: 创建发布者
|
||||
- `create_subscription(type, topic, callback, queue_size)`: 创建订阅者
|
||||
- `create_timer(period, callback)`: 创建定时器
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试(看代码对不对)
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_pubsub
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_pubsub: pytest 11/11 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
---
|
||||
|
||||
## 🔧 自己改代码试效果
|
||||
|
||||
1. 改 `py_pubsub/publisher_node.py` 的默认消息前缀
|
||||
2. 在容器内重新编译:
|
||||
```bash
|
||||
cd /root/ros2_ws
|
||||
colcon build --packages-select py_pubsub --symlink-install
|
||||
```
|
||||
3. 重新跑 launch(旧的先 Ctrl+C 停掉)
|
||||
|
||||
**`--symlink-install` 的妙处**:源码改了直接生效,不用重编译(只对 Python 有效)。
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/10-concepts.md](../../doc/10-concepts.md) — Node/Topic 概念详解
|
||||
- [doc/20-topics.md](../../doc/20-topics.md) — Topic 深度(回调组、历史、QoS)
|
||||
- [cpp_pubsub 包](../cpp_pubsub/README.md) — 同一个包用 C++ 怎么写
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
学完这个,**可选**学 **[cpp_pubsub](../cpp_pubsub/README.md)**(看 C++ 怎么写同样功能)。
|
||||
- **想学 C++**:继续
|
||||
- **只用 Python**:跳到 [py_srv](../py_srv/README.md)
|
||||
|
||||
> 注:cpp_pubsub 和 py_srv 不互相依赖,任选其一即可。README 主表把它们都放在 py_pubsub 后面,但读者可按需跳读。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_publisher_init.py` | 6 | 节点名 / 参数 / publisher / timer / descriptor |
|
||||
| `test_subscriber_init.py` | 3 | 节点名 / 参数 / subscription |
|
||||
| `test_pubsub_roundtrip.py` | 2 | 默认 topic 互通 + 自定义 topic 互通 |
|
||||
| **总计** | **11** | **目标 11/11 100% 通过** |
|
||||
| (你是第一个 🎉) | **py_pubsub — Python Topic** | [cpp_pubsub — C++ Topic](../cpp_pubsub/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- Topic 深度:[`doc/20-topics.md`](../doc/20-topics.md)
|
||||
- 核心概念:[`doc/10-concepts.md`](../doc/10-concepts.md) §3
|
||||
|
||||
## 跨语言演示
|
||||
|
||||
配合 `cpp_pubsub` 启动 4 节点(`bringup/launch/pubsub_launch.py`):
|
||||
|
||||
```bash
|
||||
ros2 launch bringup pubsub_launch.py
|
||||
```
|
||||
|
||||
预期:`ros2 topic info /chatter -v` 显示 2 个 Publisher(PY + CPP),2 个 Subscription(PY + CPP)。
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
from rclpy.publisher import Publisher
|
||||
from std_msgs.msg import String
|
||||
@@ -41,14 +42,10 @@ class ChatterPublisher(Node):
|
||||
super().__init__(node_name)
|
||||
|
||||
# 1) 声明参数(类型由默认值推断)+ 描述符
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='发布频率 (Hz),大于 0 的浮点数',
|
||||
)
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='发布话题名(字符串)',
|
||||
)
|
||||
rate_desc = ParameterDescriptor(description='发布频率 (Hz),大于 0 的浮点数')
|
||||
topic_desc = ParameterDescriptor(description='发布话题名(字符串)')
|
||||
self.declare_parameter('publish_rate_hz', self.DEFAULT_RATE_HZ, rate_desc)
|
||||
self.declare_parameter('topic_name', self.DEFAULT_TOPIC, topic_desc)
|
||||
|
||||
# 2) 读取参数 + 构造组件
|
||||
publish_rate_hz: float = self.get_parameter('publish_rate_hz').value
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
from rclpy.subscription import Subscription
|
||||
from std_msgs.msg import String
|
||||
@@ -35,10 +36,8 @@ class ChatterSubscriber(Node):
|
||||
"""
|
||||
super().__init__(node_name)
|
||||
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='订阅话题名(字符串)',
|
||||
)
|
||||
topic_desc = ParameterDescriptor(description='订阅话题名(字符串)')
|
||||
self.declare_parameter('topic_name', self.DEFAULT_TOPIC, topic_desc)
|
||||
|
||||
topic_name: str = self.get_parameter('topic_name').value
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ def test_publisher_created_on_chatter(publisher: ChatterPublisher) -> None:
|
||||
|
||||
def test_timer_created(publisher: ChatterPublisher) -> None:
|
||||
"""定时器已创建。"""
|
||||
assert len(publisher.timers) >= 1, '至少应有一个定时器'
|
||||
# rclpy Node.timers 是 generator,不能 len(),用 sum 计数
|
||||
assert sum(1 for _ in publisher.timers) >= 1, '至少应有一个定时器'
|
||||
|
||||
|
||||
def test_describe_parameter(publisher: ChatterPublisher) -> None:
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""py_pubsub 单元测试:在测试进程内直接验证 Talker / Listener 节点。
|
||||
|
||||
测试策略:
|
||||
测试 1-2: 验证节点对象创建、参数声明、节点名等"硬契约"。
|
||||
测试 3: 同进程 spin(单线程 executor)Talker + Listener,验证
|
||||
Listener 在 ~1s 内能收到至少 1 条 chatter 消息。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import rclpy
|
||||
import pytest
|
||||
|
||||
from py_pubsub.publisher_member_function import Talker
|
||||
from py_pubsub.subscriber_member_function import Listener
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def ros_context():
|
||||
"""rclpy 是进程级单例,在模块所有测试前 init,完成后 shutdown。"""
|
||||
rclpy.init()
|
||||
yield
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def test_talker_init_default_params(ros_context):
|
||||
"""Talker 默认参数应为 period_ms=500, topic='chatter', 节点名 'talker_py'。"""
|
||||
node = Talker()
|
||||
assert node.get_name() == 'talker_py'
|
||||
period = node.get_parameter('period_ms').get_parameter_value().integer_value
|
||||
topic = node.get_parameter('topic').get_parameter_value().string_value
|
||||
assert period == 500
|
||||
assert topic == 'chatter'
|
||||
# publisher 已注册(topic='chatter', type=String)。
|
||||
assert node.publisher_ is not None
|
||||
|
||||
|
||||
def test_listener_init_default_params(ros_context):
|
||||
"""Listener 默认参数 topic='chatter',节点名 'listener_py'。"""
|
||||
node = Listener()
|
||||
assert node.get_name() == 'listener_py'
|
||||
topic = node.get_parameter('topic').get_parameter_value().string_value
|
||||
assert topic == 'chatter'
|
||||
assert node.subscription is not None
|
||||
|
||||
|
||||
def test_talker_publishes_one_message(ros_context):
|
||||
"""Talker.timer_callback 调用一次后,内部 count 自增,逻辑不抛异常。"""
|
||||
node = Talker()
|
||||
before = node.count
|
||||
node.timer_callback()
|
||||
assert node.count == before + 1
|
||||
|
||||
|
||||
def test_inproc_roundtrip(ros_context):
|
||||
"""同进程 spin:Talker 发布 + Listener 订阅,1s 内 listener 至少收 1 条。
|
||||
|
||||
走 SingleThreadedExecutor 而非 launch_testing:避免 launch 系统
|
||||
shutdown 二次调用的兼容问题,且 colcon test 环境下更稳。
|
||||
"""
|
||||
talker = Talker()
|
||||
listener = Listener()
|
||||
|
||||
# 让 talker timer 更密,以便 1s 内能产生足够消息。
|
||||
talker.destroy_timer(talker.timer)
|
||||
talker.timer = talker.create_timer(0.05, talker.timer_callback)
|
||||
|
||||
received = []
|
||||
listener.subscription = listener.create_subscription(
|
||||
String,
|
||||
'chatter',
|
||||
lambda msg: received.append(msg.data),
|
||||
10,
|
||||
)
|
||||
|
||||
exec_ = rclpy.executors.SingleThreadedExecutor()
|
||||
exec_.add_node(talker)
|
||||
exec_.add_node(listener)
|
||||
end = time.time() + 1.0
|
||||
while time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
|
||||
# 期望至少收到若干消息(seq 0..N,周期 50ms,1s 内约 20 条)。
|
||||
assert len(received) >= 1, f'expected >=1 received, got 0'
|
||||
# 收到的内容必须是 Talker 发送的 'Hello from PY'。
|
||||
assert any('Hello from PY' in s for s in received), \
|
||||
f'received messages do not contain Talker marker: {received[:3]}'
|
||||
+278
-30
@@ -1,49 +1,297 @@
|
||||
# py_srv
|
||||
# py_srv — Python Service 请求-响应
|
||||
|
||||
ROS2 Service req/resp 演示包(Python)。属于 Level 1 基础机制第 3 块。
|
||||
> ROS2 三种通信模式第二种:**Service(服务)**。同步、一问一答。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
>
|
||||
> **前置知识**:读完 `py_pubsub` 的 README(知道节点和话题是什么)。**可选**:看完 `cpp_pubsub`(Service Client/Server 也有 C++ 版,但跟 Python 几乎一一对应)。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`add_two_ints_server`**: 接收 `AddTwoInts` 请求,返回 `a + b`
|
||||
- **`add_two_ints_client`**: 异步发请求 + 等响应
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
**Service** 是 ROS2 的"打电话"模式:
|
||||
- **Client** 发请求(Request),等回应
|
||||
- **Server** 收到请求,处理完返回响应(Response)
|
||||
- 同步的(等结果才能干别的)
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `Service` / `Client` | 同步请求-响应 |
|
||||
| `wait_for_service` / `service_is_ready` | 等待 server 注册 |
|
||||
| `call_async` + `spin_until_future_complete` | 异步发请求 |
|
||||
**对比三种通信模式**:
|
||||
|
||||
## 运行
|
||||
| 模式 | 用途 | 是否同步 | 是否可取消 |
|
||||
|---|---|---|---|
|
||||
| **Topic** | 广播消息(传感器数据流) | ❌ 异步 | ❌ |
|
||||
| **Service** | 一次性"问-答"(算数学、查数据) | ✅ 同步 | ❌ |
|
||||
| **Action** | 长任务(导航、机械臂运动) | ❌ 异步 | ✅ |
|
||||
|
||||
```bash
|
||||
# 终端 1:启 server
|
||||
ros2 launch py_srv srv_launch.py
|
||||
**生活化例子**:
|
||||
- Topic = 广播体操,大家都能听到
|
||||
- Service = 打电话问"12+30=?",对方必须回答
|
||||
- Action = 点外卖,可以看到骑手位置 + 可以取消订单
|
||||
|
||||
# 终端 2:CLI 调用
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
# 预期: sum: 42
|
||||
---
|
||||
|
||||
# 终端 2 备选:启 client 节点(从参数读 a/b)
|
||||
ros2 run py_srv add_two_ints_client --ros-args -p a:=12 -p b:=30
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 写 Service Server(同步处理请求)
|
||||
2. ✅ 写 Service Client(用 `call_async` 异步发 + spin 等结果)
|
||||
3. ✅ 用 `rclpy.spin_until_future_complete` 等待异步结果
|
||||
4. ✅ 手动用 `ros2 service call` 测试服务
|
||||
5. ✅ 理解为什么不能在 callback 里阻塞
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/py_srv/
|
||||
├── py_srv/
|
||||
│ ├── add_two_ints_server.py # 服务端(a + b = sum)
|
||||
│ └── add_two_ints_client.py # 客户端
|
||||
├── launch/service_launch.py # 一键启动 server + client
|
||||
├── test/
|
||||
│ ├── test_srv_server.py # 服务端单元测试
|
||||
│ ├── test_srv_client.py # 客户端单元测试
|
||||
│ └── test_srv.py # 端到端测试
|
||||
└── setup.py
|
||||
```
|
||||
|
||||
## 测试
|
||||
**关键服务类型**:`example_interfaces/srv/AddTwoInts`(ROS2 自带的标准接口)
|
||||
```
|
||||
# srv 文件格式(本包用现成的)
|
||||
int64 a
|
||||
int64 b
|
||||
---
|
||||
int64 sum
|
||||
```
|
||||
`---` 上面是 **Request**,下面是 **Response**。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 启动 Server
|
||||
|
||||
**终端 1**(容器内):
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run py_srv add_two_ints_server
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [add_two_ints_server]: AddTwoIntsServer ready: service="add_two_ints"
|
||||
```
|
||||
|
||||
### 启动 Client(另开终端)
|
||||
|
||||
**终端 2**:
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run py_srv add_two_ints_client a:=12 b:=30
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [add_two_ints_client]: result: 12 + 30 = 42
|
||||
```
|
||||
|
||||
### 手动调用服务(不开 Client)
|
||||
|
||||
```bash
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
response: example_interfaces.srv.AddTwoInts_Response(sum=42)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Server 代码
|
||||
|
||||
```python
|
||||
class AddTwoIntsServer(Node):
|
||||
def __init__(self):
|
||||
super().__init__('add_two_ints_server')
|
||||
|
||||
# create_service(消息类型, 服务名, 回调)
|
||||
self.service_ = self.create_service(
|
||||
AddTwoInts, 'add_two_ints', self._handle_request)
|
||||
|
||||
def _handle_request(self, request, response):
|
||||
"""处理请求的回调。
|
||||
|
||||
注意签名: (request, response) -> response
|
||||
必须原地修改 response,最后返回它。
|
||||
"""
|
||||
try:
|
||||
response.sum = request.a + request.b # 处理逻辑
|
||||
self.get_logger().info(
|
||||
f'{request.a} + {request.b} = {response.sum}')
|
||||
except Exception as exc:
|
||||
self.get_logger().error(f'处理失败: {exc}')
|
||||
response.sum = 0
|
||||
return response # 必须返回
|
||||
```
|
||||
|
||||
**回调签名**:`(request, response) -> response`,**原地修改** response 然后返回。
|
||||
|
||||
---
|
||||
|
||||
## 📖 Client 代码(异步模式)
|
||||
|
||||
```python
|
||||
class AddTwoIntsClient(Node):
|
||||
def __init__(self):
|
||||
super().__init__('add_two_ints_client')
|
||||
|
||||
# create_client(消息类型, 服务名)
|
||||
self._client = self.create_client(AddTwoInts, 'add_two_ints')
|
||||
|
||||
def call_once(self, a, b, timeout_sec=5.0):
|
||||
"""异步调用 + spin 等结果"""
|
||||
# 1) 等服务可用(必须轮询!)
|
||||
if not self.wait_for_service_ready(timeout_sec):
|
||||
self.get_logger().warn('service not available')
|
||||
return None
|
||||
|
||||
# 2) 构造请求
|
||||
request = AddTwoInts.Request()
|
||||
request.a = a
|
||||
request.b = b
|
||||
|
||||
# 3) 异步发请求(立刻返回,不等结果)
|
||||
future = self._client.call_async(request)
|
||||
|
||||
# 4) spin 直到 future 完成(或超时)
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec)
|
||||
|
||||
# 5) 拿结果
|
||||
if not future.done():
|
||||
self.get_logger().warn('调用超时')
|
||||
return None
|
||||
return future.result().sum
|
||||
```
|
||||
|
||||
**`call_async` 特点**:发完请求立刻返回 `future`,你可以干别的事,最后再 `spin_until_future_complete` 阻塞等结果。
|
||||
|
||||
**`wait_for_service_ready` 轮询模式**:
|
||||
```python
|
||||
def wait_for_service_ready(self, timeout_sec=5.0):
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
if self._client.service_is_ready():
|
||||
return True
|
||||
time.sleep(0.05) # 让出 CPU,下次再查
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 为什么不能在 `__init__` / callback 里阻塞等待?
|
||||
|
||||
**`spin` 是单线程的**:它需要反复执行 callback 才能让系统工作。如果在 callback 里阻塞,spin 就被卡住,**其他 callback 全都执行不了**。
|
||||
|
||||
**真实场景的"死锁"**:
|
||||
|
||||
```python
|
||||
# ❌ 错误写法
|
||||
class BadClient(Node):
|
||||
def _on_timer(self):
|
||||
# 这是订阅者回调(在 spin 里执行)
|
||||
result = self._client.call(request) # ❌ 阻塞等结果
|
||||
# call 内部要 spin,才能把 response callback 跑起来
|
||||
# 但我们正在 spin 里 → 永远等不到 response callback 完成
|
||||
# → 死锁!节点卡死
|
||||
```
|
||||
|
||||
**正确写法**:**异步**(把响应处理放到 callback,而不是当前函数里阻塞等):
|
||||
|
||||
```python
|
||||
# ✅ 正确写法
|
||||
class GoodClient(Node):
|
||||
def _on_timer(self):
|
||||
future = self._client.call_async(request)
|
||||
# 立刻返回,不等结果
|
||||
future.add_done_callback(self._response_callback)
|
||||
|
||||
def _response_callback(self, future):
|
||||
# 这个 callback 由 spin 在将来某个时刻调用
|
||||
result = future.result()
|
||||
# ...处理 result
|
||||
```
|
||||
|
||||
**规律**:**任何在 callback / `__init__` / spin 里的代码,都不能"同步等一个需要 spin 才会发生的事"**。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_srv
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_srv: pytest 7/7 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
**测试覆盖**:
|
||||
- 服务端:节点名 / 服务名 / 处理正负零(3 个)
|
||||
- 客户端:能调用服务(1 个)
|
||||
- 端到端:同进程跑 server + client 互调(2 个)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 自己写 Service
|
||||
|
||||
1. **定义 .srv 文件**:
|
||||
```
|
||||
# srv/MyService.srv
|
||||
string name
|
||||
int32 count
|
||||
---
|
||||
bool success
|
||||
string message
|
||||
```
|
||||
(放在 `srv/` 目录下)
|
||||
|
||||
2. **在 `setup.py` 加一行**(`data_files`):
|
||||
```python
|
||||
data_files=[
|
||||
(...),
|
||||
(os.path.join('share', PACKAGE_NAME, 'srv'), glob('srv/*.srv')),
|
||||
]
|
||||
```
|
||||
|
||||
3. **重编译**:`colcon build`
|
||||
|
||||
4. **用**:
|
||||
```python
|
||||
from my_pkg.srv import MyService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/30-services.md](../../doc/30-services.md) — Service 深度(异步 vs 同步、回调组)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
学完这个,继续学 **[py_action_demo](../py_action_demo/README.md)** — 学习 Action(带进度回调 + 可取消的长任务)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_srv_server.py` | 5 | 节点名 + 参数 + 回调(正/负/零) |
|
||||
| `test_srv_client.py` | 1 | 同进程 client 调用 server(12+30=42) |
|
||||
| **总计** | **6** | **目标 6/6 100% 通过** |
|
||||
| [py_pubsub — Python Topic](../py_pubsub/README.md) | **py_srv — Python Service** | [py_action_demo — Python Action](../py_action_demo/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- Service 深度:[`doc/30-services.md`](../doc/30-services.md)
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from example_interfaces.srv import AddTwoInts
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
@@ -36,15 +37,15 @@ class AddTwoIntsClient(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'service_name', self.DEFAULT_SERVICE_NAME,
|
||||
descriptor='要调用的服务名',
|
||||
ParameterDescriptor(description='要调用的服务名'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'a', self.DEFAULT_A,
|
||||
descriptor='第一个加数',
|
||||
ParameterDescriptor(description='第一个加数'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'b', self.DEFAULT_B,
|
||||
descriptor='第二个加数',
|
||||
ParameterDescriptor(description='第二个加数'),
|
||||
)
|
||||
|
||||
service_name: str = self.get_parameter('service_name').value
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from example_interfaces.srv import AddTwoInts
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
from rclpy.service import Service
|
||||
|
||||
@@ -39,7 +40,7 @@ class AddTwoIntsServer(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'service_name', self.DEFAULT_SERVICE_NAME,
|
||||
descriptor='服务名(字符串)',
|
||||
ParameterDescriptor(description='服务名(字符串)'),
|
||||
)
|
||||
|
||||
service_name: str = self.get_parameter('service_name').value
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_service_inproc_roundtrip(ros_context):
|
||||
req.a = 7
|
||||
req.b = 35
|
||||
|
||||
future = client.client.call_async(req)
|
||||
future = client._client.call_async(req)
|
||||
end = time.time() + 3.0
|
||||
while not future.done() and time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
|
||||
@@ -18,26 +18,28 @@ def test_client_calls_server_inproc(ros_context: None) -> None:
|
||||
executor.add_node(server)
|
||||
executor.add_node(client)
|
||||
|
||||
# 触发 client 调用
|
||||
result_future_container: list = []
|
||||
# 等 service 就绪
|
||||
deadline = time.time() + 3.0
|
||||
while time.time() < deadline:
|
||||
executor.spin_once(timeout_sec=0.05)
|
||||
if client._client.service_is_ready():
|
||||
break
|
||||
|
||||
def call_in_thread() -> None:
|
||||
# 给点时间让 server 注册(同进程也需要一点 spin 时间)
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
executor.spin_once(timeout_sec=0.05)
|
||||
if client._client.service_is_ready():
|
||||
break
|
||||
result = client.call_once(12, 30, timeout_sec=3.0)
|
||||
result_future_container.append(result)
|
||||
assert client._client.service_is_ready(), 'service not ready'
|
||||
|
||||
import threading
|
||||
t = threading.Thread(target=call_in_thread)
|
||||
t.start()
|
||||
t.join(timeout=5.0)
|
||||
# 发请求
|
||||
req = AddTwoInts.Request()
|
||||
req.a = 12
|
||||
req.b = 30
|
||||
future = client._client.call_async(req)
|
||||
|
||||
end = time.time() + 3.0
|
||||
while not future.done() and time.time() < end:
|
||||
executor.spin_once(timeout_sec=0.05)
|
||||
|
||||
assert future.done(), 'service call did not complete in 3s'
|
||||
assert future.result() is not None
|
||||
assert future.result().sum == 42
|
||||
|
||||
server.destroy_node()
|
||||
client.destroy_node()
|
||||
|
||||
assert len(result_future_container) == 1
|
||||
assert result_future_container[0] == 42
|
||||
+214
-39
@@ -1,58 +1,233 @@
|
||||
# py_vision_demo
|
||||
# py_vision_demo — Python 图像话题(cv_bridge + OpenCV)
|
||||
|
||||
ROS2 图像传感器演示包(Python)。属于 Level 1 基础机制第 9 块。
|
||||
> ROS2 图像数据流:`fake_camera` 模拟相机 → 发布 Image → `image_processor` 用 OpenCV 处理。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`fake_camera`**: 周期性发布 640x480 bgr8 合成图像(渐变 + 帧号 + 动态圆)
|
||||
- **`image_processor`**: 订阅图像,转 OpenCV,计算平均亮度
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
ROS2 图像话题跟普通 Topic 一样,**只是消息类型用 `sensor_msgs/Image`**。本包演示:
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `sensor_msgs/Image` | 图像消息(height/width/encoding/step/data) |
|
||||
| `cv_bridge` | ROS Image ↔ OpenCV numpy 转换 |
|
||||
| `bgr8` / `rgb8` / `mono8` | 像素编码 |
|
||||
| `REP-105 frame_id` | `camera_optical_frame`(光心系) |
|
||||
1. **fake_camera**:周期性发布合成图像(640x480,带渐变背景 + 帧号文本 + 中心圆)
|
||||
2. **image_processor**:订阅图像 → 转 numpy → OpenCV 处理 → 再发回
|
||||
|
||||
## 运行
|
||||
**关键工具**: `cv_bridge`(ROS Image ↔ OpenCV numpy array)。
|
||||
|
||||
```bash
|
||||
# 终端 1:启 camera + processor
|
||||
ros2 launch py_vision_demo vision_launch.py
|
||||
---
|
||||
|
||||
# 终端 2:实时看图像(需要 RViz 或 image_view)
|
||||
ros2 run rqt_image_view rqt_image_view /image_raw
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
# CLI 验证
|
||||
ros2 topic info /image_raw -v
|
||||
ros2 topic hz /image_raw
|
||||
1. ✅ 理解 `sensor_msgs/Image` 消息结构(height/width/encoding/data)
|
||||
2. ✅ 用 `cv_bridge.imgmsg_to_cv2()` 把 ROS Image 转 OpenCV 数组
|
||||
3. ✅ 用 OpenCV 处理图像(灰度、画框、滤波)
|
||||
4. ✅ 用 `cv_bridge.cv2_to_imgmsg()` 转回 ROS Image 发布
|
||||
5. ✅ 用 `image_transport` 压缩传输(可选)
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/py_vision_demo/
|
||||
├── py_vision_demo/
|
||||
│ ├── fake_camera.py # 模拟相机 Publisher
|
||||
│ └── image_processor.py # 图像处理 Subscriber
|
||||
├── launch/vision_launch.py # 一键启动
|
||||
├── test/
|
||||
│ ├── conftest.py
|
||||
│ ├── test_fake_camera.py
|
||||
│ ├── test_image_processor.py
|
||||
│ └── test_vision.py # 端到端测试
|
||||
└── setup.py
|
||||
```
|
||||
|
||||
## 测试
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 终端 1:启动 fake_camera + image_processor
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 launch py_vision_demo vision_launch.py
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [fake_camera]: FakeCamera started: 640x480 @ 1Hz, topic="/image_raw"
|
||||
[INFO] [image_processor]: ImageProcessor started: topic="/image_processed"
|
||||
[INFO] [image_processor]: processing frame=0, mean_brightness=127.5
|
||||
...
|
||||
```
|
||||
|
||||
### 终端 2:看图像话题列表
|
||||
|
||||
```bash
|
||||
ros2 topic list
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
/image_raw
|
||||
/image_processed
|
||||
/parameter_events
|
||||
/rosout
|
||||
```
|
||||
|
||||
### 终端 3:运行时改参数
|
||||
|
||||
```bash
|
||||
# 改分辨率
|
||||
ros2 param set fake_camera image_width 320
|
||||
ros2 param set fake_camera image_height 240
|
||||
|
||||
# 改帧率
|
||||
ros2 param set fake_camera publish_rate_hz 5.0
|
||||
|
||||
# 改处理模式(下游 image_processor)
|
||||
ros2 param set image_processor mode 'edges' # 或 'gray' 或 'raw'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 核心代码解读
|
||||
|
||||
### fake_camera.py(发布图像)
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import numpy as np
|
||||
from sensor_msgs.msg import Image
|
||||
|
||||
class FakeCamera(Node):
|
||||
def __init__(self):
|
||||
super().__init__('fake_camera')
|
||||
|
||||
# 1) 声明参数
|
||||
self.declare_parameter('image_width', 640, ...)
|
||||
self.declare_parameter('image_height', 480, ...)
|
||||
self.declare_parameter('publish_rate_hz', 1.0, ...)
|
||||
|
||||
# 2) 读参数
|
||||
self._width = self.get_parameter('image_width').value
|
||||
self._height = self.get_parameter('image_height').value
|
||||
|
||||
# 3) 创建 Image Publisher
|
||||
self.publisher_ = self.create_publisher(Image, '/image_raw', 10)
|
||||
|
||||
# 4) 定时器
|
||||
self.timer_ = self.create_timer(1.0/rate, self._publish_frame)
|
||||
|
||||
def _publish_frame(self):
|
||||
# 1) 用 numpy + OpenCV 生成合成图像
|
||||
frame = np.zeros((self._height, self._width, 3), dtype=np.uint8)
|
||||
frame[:, :, 0] = np.linspace(0, 255, self._width, dtype=np.uint8) # B
|
||||
frame[:, :, 1] = np.linspace(0, 255, self._height, dtype=np.uint8) # G
|
||||
cv2.putText(frame, f'frame={self._frame_count}', (10, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,255,255), 2)
|
||||
|
||||
# 2) 转 ROS Image 消息
|
||||
msg = Image()
|
||||
msg.height = self._height
|
||||
msg.width = self._width
|
||||
msg.encoding = 'bgr8' # OpenCV 默认 BGR!
|
||||
msg.step = self._width * 3 # 每行字节数(width * 3 channels)
|
||||
msg.data = frame.tobytes() # numpy → bytes
|
||||
|
||||
# 3) 发布
|
||||
self.publisher_.publish(msg)
|
||||
```
|
||||
|
||||
**关键**:`encoding='bgr8'`(OpenCV 用 BGR 而非 RGB!),`step = width * 3`(每行字节数)。
|
||||
|
||||
### image_processor.py(订阅 + 处理 + 再发布)
|
||||
|
||||
```python
|
||||
import cv2
|
||||
from cv_bridge import CvBridge
|
||||
|
||||
class ImageProcessor(Node):
|
||||
def __init__(self):
|
||||
super().__init__('image_processor')
|
||||
self._bridge = CvBridge() # 关键:cv_bridge 实例
|
||||
|
||||
# 订阅图像
|
||||
self.subscription = self.create_subscription(
|
||||
Image, '/image_raw', self._on_image, 10)
|
||||
|
||||
# 发布处理后的图像
|
||||
self.publisher_ = self.create_publisher(Image, '/image_processed', 10)
|
||||
|
||||
def _on_image(self, msg):
|
||||
# 1) ROS Image → OpenCV numpy
|
||||
cv_image = self._bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
|
||||
|
||||
# 2) OpenCV 处理
|
||||
mode = self.get_parameter('mode').value
|
||||
if mode == 'gray':
|
||||
processed = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
|
||||
processed = cv2.cvtColor(processed, cv2.COLOR_GRAY2BGR) # 转回 3 通道
|
||||
elif mode == 'edges':
|
||||
gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
|
||||
processed = cv2.cvtColor(cv2.Canny(gray, 50, 150), cv2.COLOR_GRAY2BGR)
|
||||
else:
|
||||
processed = cv_image
|
||||
|
||||
# 3) numpy → ROS Image
|
||||
out_msg = self._bridge.cv2_to_imgmsg(processed, encoding='bgr8')
|
||||
out_msg.header = msg.header # 保留 timestamp + frame_id
|
||||
|
||||
# 4) 发布
|
||||
self.publisher_.publish(out_msg)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_vision_demo
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_vision_demo: pytest 13/13 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
---
|
||||
|
||||
## 🔧 实战:接真实相机
|
||||
|
||||
把 `fake_camera` 换成 `usb_cam` 或 `realsense2_camera`:
|
||||
|
||||
```bash
|
||||
sudo apt install ros-humble-usb-cam
|
||||
ros2 launch usb_cam camera.launch.py
|
||||
```
|
||||
|
||||
下游 `image_processor` 完全不用改 — 它只关心 `/image_raw` 是不是 `sensor_msgs/Image`。
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [sensor_msgs/Image 文档](https://docs.ros.org/en/humble/p/sensor_msgs/msg/Image.html)
|
||||
- [cv_bridge 教程](https://wiki.ros.org/cv_bridge/Tutorials/ConvertingBetweenROSImagesAndOpenCVImagesPython)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
继续学 **[cpp_robot_tf2](../cpp_robot_tf2/README.md)** — TF2 坐标变换 + URDF 机械臂模型(机器人入门必学)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_fake_camera.py` | 5 | 节点名 + 参数 + frame_id(REP-105) + publisher |
|
||||
| `test_image_processor.py` | 5 | 节点名 + 参数 + subscription + 全白/全黑处理 |
|
||||
| `test_vision_pipeline.py` | 1 | 同进程 fake_camera → image_processor 端到端 |
|
||||
| **总计** | **11** | **目标 11/11 100% 通过** |
|
||||
| [cpp_custom_interface — C++ 自定义接口](../cpp_custom_interface/README.md) | **py_vision_demo — Python 图像** | [cpp_robot_tf2 — C++ TF2 + URDF](../cpp_robot_tf2/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- 视觉 + cv_bridge:[`doc/30-vision.md`](../doc/30-vision.md)(下一阶段新增)
|
||||
- ROS 图像消息: <https://docs.ros.org/en/humble/p/sensor_msgs/msg/Image.html>
|
||||
|
||||
## 进阶(下一阶段)
|
||||
|
||||
- 接 RealSense / Azure Kinect 真相机
|
||||
- 加 YOLO / GraspNet 等视觉模型
|
||||
- 接 MoveIt2 做视觉抓取
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import List, Optional
|
||||
import cv2
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
from rclpy.publisher import Publisher
|
||||
from sensor_msgs.msg import Image
|
||||
@@ -49,23 +50,23 @@ class FakeCamera(Node):
|
||||
# 1) 参数声明 + 描述符
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='发布话题名',
|
||||
ParameterDescriptor(description='发布话题名'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='发布频率 (Hz)',
|
||||
ParameterDescriptor(description='发布频率 (Hz)'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'image_height', self.DEFAULT_HEIGHT,
|
||||
descriptor='图像高度 (像素)',
|
||||
ParameterDescriptor(description='图像高度 (像素)'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'image_width', self.DEFAULT_WIDTH,
|
||||
descriptor='图像宽度 (像素)',
|
||||
ParameterDescriptor(description='图像宽度 (像素)'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'frame_id', self.DEFAULT_FRAME_ID,
|
||||
descriptor='图像 frame_id(REP-105 camera_optical_frame)',
|
||||
ParameterDescriptor(description='图像 frame_id(REP-105 camera_optical_frame)'),
|
||||
)
|
||||
|
||||
# 2) 读取参数
|
||||
|
||||
@@ -18,6 +18,7 @@ import cv2
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from cv_bridge import CvBridge
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
from rclpy.subscription import Subscription
|
||||
from sensor_msgs.msg import Image
|
||||
@@ -46,7 +47,7 @@ class ImageProcessor(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='订阅话题名',
|
||||
ParameterDescriptor(description='订阅话题名'),
|
||||
)
|
||||
|
||||
topic_name: str = self.get_parameter('topic_name').value
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"""py_vision_demo 单元测试:同进程内 fake_camera -> image_processor 链路。
|
||||
|
||||
注意:image_processor.listener_callback 在订阅时就被 bound 到
|
||||
rclpy 的 subscription 对象,monkey-patch 不会影响已存的引用。
|
||||
我们用 ImageProcessor 子类或独立 subscription 来收取消息。
|
||||
"""
|
||||
"""py_vision_demo 单元测试:同进程内 fake_camera -> image_processor 链路。"""
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import rclpy
|
||||
import pytest
|
||||
|
||||
@@ -16,21 +10,12 @@ from sensor_msgs.msg import Image
|
||||
from py_vision_demo.fake_camera import FakeCamera
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def ros_context():
|
||||
rclpy.init()
|
||||
yield
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def test_image_pipeline_inproc(ros_context):
|
||||
"""fake_camera 发布 -> 自建 subscriber 接收,1.5s 内应收到 ≥1 条带正确尺寸的 Image。"""
|
||||
cam = FakeCamera()
|
||||
|
||||
received = []
|
||||
|
||||
# 不直接复用 ImageProcessor,因为它的 callback 已经被 bound。
|
||||
# 自建一个 subscriber 来采集。
|
||||
sub_node = rclpy.node.Node('test_subscriber')
|
||||
sub_node.create_subscription(
|
||||
Image, '/image_raw',
|
||||
@@ -44,15 +29,13 @@ def test_image_pipeline_inproc(ros_context):
|
||||
while time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
|
||||
# 至少 1 条消息,尺寸与 cam 默认 320x240 一致。
|
||||
assert len(received) >= 1, f'expected ≥1 image, got {len(received)}'
|
||||
img = received[0]
|
||||
assert img.width == 320
|
||||
assert img.height == 240
|
||||
assert img.width == 640
|
||||
assert img.height == 480
|
||||
assert img.encoding == 'bgr8'
|
||||
assert len(img.data) == 320 * 240 * 3
|
||||
assert len(img.data) == 640 * 480 * 3
|
||||
|
||||
# 优雅清理
|
||||
exec_.remove_node(cam)
|
||||
exec_.remove_node(sub_node)
|
||||
cam.destroy_node()
|
||||
@@ -90,4 +73,4 @@ def test_image_bytes_roundtrip(ros_context):
|
||||
exec_.remove_node(cam)
|
||||
exec_.remove_node(sub_node)
|
||||
cam.destroy_node()
|
||||
sub_node.destroy_node()
|
||||
sub_node.destroy_node()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# =============================================================================
|
||||
# start.ps1 —— Windows 一键启动脚本。
|
||||
#
|
||||
# 步骤:
|
||||
# 1) build —— 用 docker-compose 构建本地镜像(首次会拉基础层,5–10 分钟)。
|
||||
# 2) up -d —— 后台起容器。
|
||||
# 3) exec build.sh —— 进容器内部执行 colcon build。
|
||||
# 4) exec bash —— 进入开发交互 shell。
|
||||
#
|
||||
# 注意:用 `docker compose`(空格,plugin 版) 而非 `docker-compose`(旧版 CLI);
|
||||
# Docker Desktop 4.x 起默认带 compose v2。
|
||||
# =============================================================================
|
||||
|
||||
# 临时开启 DOCKER_BUILDKIT:获得更快的并行构建 + 缓存。
|
||||
param($Env:DOCKER_BUILDKIT = "1")
|
||||
|
||||
Write-Host "[1/4] 构建镜像 (首次约 5-10 分钟)..." -ForegroundColor Cyan
|
||||
docker compose -f D:\xs\ros2\docker\docker-compose.yml build
|
||||
|
||||
Write-Host "[2/4] 启动容器..." -ForegroundColor Cyan
|
||||
docker compose -f D:\xs\ros2\docker\docker-compose.yml up -d
|
||||
|
||||
Write-Host "[3/4] 进入容器并编译..." -ForegroundColor Cyan
|
||||
# docker exec -it:交互式 shell;-c "cmd" 让 bash -lc 跑指定命令再退出。
|
||||
docker exec -it ros2_dev bash -lc "cd /root/ros2_ws && bash build.sh"
|
||||
|
||||
Write-Host "[4/4] 进入开发终端..." -ForegroundColor Cyan
|
||||
# source install/setup.bash 让 ros2 命令能识别 build 出的包;
|
||||
# 最外层 exec bash 把该 shell 留给你后续手动操作。
|
||||
docker exec -it ros2_dev bash -lc "source install/setup.bash && exec bash"
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# start.sh —— Linux / WSL / macOS 一键启动脚本。
|
||||
#
|
||||
# 与 start.ps1 同样四步,只是 shell 改 bash:
|
||||
# 1) docker compose build
|
||||
# 2) docker compose up -d
|
||||
# 3) docker exec ... bash build.sh
|
||||
# 4) docker exec ... source install/setup.bash && bash
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail # 出错即停;严格变量未定义检查;管道失败传播。
|
||||
cd "$(dirname "$0")" # 切到本脚本所在目录(确保 docker-compose.yml 路径对)。
|
||||
|
||||
echo "[1/4] 构建镜像 (首次约 5-10 分钟)..." >&2
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
echo "[2/4] 启动容器..." >&2
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
echo "[3/4] 进入容器并编译..." >&2
|
||||
docker exec -it ros2_dev bash -lc "cd /root/ros2_ws && bash build.sh"
|
||||
|
||||
echo "[4/4] 进入开发终端..." >&2
|
||||
docker exec -it ros2_dev bash -lc "source install/setup.bash && exec bash"
|
||||
Reference in New Issue
Block a user