docs(nav): 阅读路径导航 + 数字统一

This commit is contained in:
xs
2026-08-04 16:10:27 +08:00
parent 549d6b337e
commit 6338f3d36a
95 changed files with 3838 additions and 1260 deletions
+2
View File
@@ -36,7 +36,9 @@ htmlcov/
# 临时文件(本地调试用,不进 git) # 临时文件(本地调试用,不进 git)
*.tmp *.tmp
*.bak *.bak
*.xml
.cache/ .cache/
.logs/
# OS / 杂项 # OS / 杂项
Thumbs.db Thumbs.db
+5 -1
View File
@@ -12,6 +12,10 @@
4. **禁止问与思考循环**。给出明确方案,直接开干。 4. **禁止问与思考循环**。给出明确方案,直接开干。
5. **测试必须 100% 通过才能停手** 5. **测试必须 100% 通过才能停手**
- `make colcon-build` + `make colcon-test` 全绿才能汇报"完成" - `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 自定义网络 │ ├── docker-compose.yml # name: ros2 + ros2_net 自定义网络
│ └── *_e2e.log # 端到端验证日志 │ └── *_e2e.log # 端到端验证日志
├── doc/ # 23 篇深度文档 ├── doc/ # 24 篇深度文档
│ ├── 00-overview.md / 00-levels.md │ ├── 00-overview.md / 00-levels.md
│ ├── 01-quickstart.md / 02-virtualenv.md │ ├── 01-quickstart.md / 02-virtualenv.md
│ ├── 10-concepts.md / 20-topics.md / 30-services.md / 40-actions.md │ ├── 10-concepts.md / 20-topics.md / 30-services.md / 40-actions.md
+472 -130
View File
@@ -1,165 +1,493 @@
# ROS2 Learning Suite — 从零到具身智能 / VLA 完全体 # ROS2 学习套件 — 从零到具身智能 / VLA 完全体
> **一套从 ROS2 基础到机械臂 + VLA (Vision-Language-Action) 落地的完整实战仓库**: > **如果你是 ROS2 完全的新手,不知道怎么开始 → [从零开始指南](#-从零开始-30-分钟跑通-hello-world)**
> 12 包 + 80 测试 100% 通过 + 23 篇深度文档 + Docker + Make + GitLab CI + 跨机部署。
> 为后续具身智能 / 机器人 / VLA 开发铺平第一公里。
>
> **学习承诺**: 每行代码遵循 [`doc/CODING_STYLE.md`](doc/CODING_STYLE.md)(PEP 8 + ROS2 REP-2000 + 工业级实践)。
--- ---
## 🎯 适合谁 ## 🆘 从零开始:30 分钟跑通 Hello World
- 第一次学 ROS2,想从 0 到能搭一个完整机器人项目 **如果你从来没接触过 ROS2,不知道"Docker 是什么"、"make 命令在哪"、不知道怎么开终端,按下面一步一步来。**
- 想**深耕具身智能**(机器人 + VLA),需要把 ROS2 通信栈 + TF2 + URDF + Vision 一次打通
- 想在 Windows 本机用 venv + VSCode 写代码,在 Docker Linux 容器跑 ROS2
- 需要一个**教科书级别**的开源仓库作教学/学习参考
## 📦 仓库提供什么 ### 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 |
| 包 | 类型 | 通信范式 | 语言 | 测试 | **你不需要装**:Python、ROS2、Ubuntu、虚拟机、Linux。
|---|---|---|---|---|
| [`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 |
**合计 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 ```bash
# 1. 构建镜像(首次 5-10 分钟) source /opt/ros/humble/setup.bash # 加载 ROS2 环境(每次新终端都要)
make build source /root/ros2_ws/install/setup.bash # 加载本项目编译产物(同上)
# 2. 启动容器 ros2 run <package> <executable> # 跑节点,例:ros2 run py_pubsub chatter_publisher
make up ros2 launch <package> <launch.py> # 跑 launch 文件
ros2 topic list # 列出所有话题
# 3. 容器内 build 12 包 ros2 topic echo /chatter # 订阅看消息(按 Ctrl+C 退出)
make colcon-build ros2 node list # 列出所有节点
ros2 node info <node_name> # 看某个节点的详细信息(话题/服务/参数)
# 4. 跑所有测试 ros2 param list <node_name> # 看节点参数
make colcon-test ros2 param set <node> <param> <value> # 改参数(运行时,例:ros2 param set py_publisher publish_rate_hz 5.0)
ros2 service list # 列出所有服务
# 5. 进入开发终端 ros2 service call <service_name> <req> # 手动调用服务
make shell ros2 action list # 列出所有 Action
ros2 bag record -a -o my_bag # 录制所有话题数据
# 6. 启动 11 节点 full_demo ros2 bag play my_bag # 回放数据
make full-demo
``` ```
等价手动命令(`make` 不可用时): **退出容器**:`exit``Ctrl+D`。**容器还在跑,下次直接 `docker exec -it ros2_dev bash` 再进。**
```bash **停容器**:
docker compose -p ros2 -f docker/docker-compose.yml build ```powershell
docker compose -p ros2 -f docker/docker-compose.yml up -d docker stop ros2_dev # 停止(不删除,下次 docker start)
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 rm -f ros2_dev # 删除(下次要从头 docker run)
docker exec ros2_dev bash -lc "cd /root/ros2_ws && colcon test --packages-select ..."
``` ```
## 🧱 架构 ---
## ✅ L1 基础完成清单(打勾用)
每学完一个包,把 `[ ]` 改成 `[x]`,4 个维度独立勾:
``` ```
┌──────────────────────────────────────────┐ [ ] py_pubsub — 跑通 / 改过参数 / 改过代码 / 测过测试
│ 本机 Windows / Linux │ [ ] cpp_pubsub — 跑通 / 改过参数 / 改过代码 / 测过测试
│ (venv: ruff/black/mypy/pytest) │ [ ] py_srv — 跑通 / 改过参数 / 改过代码 / 测过测试
└─────────────────┬────────────────────────┘ [ ] py_action_demo — 跑通 / 改过参数 / 改过代码 / 测过测试
│ bind mount [ ] py_params — 跑通 / 改过参数 / 改过代码 / 测过测试
┌─────────────────▼────────────────────────┐ [ ] cpp_custom_interface — 跑通 / 改过参数 / 改过代码 / 测过测试
│ Docker compose project: ros2 │ [ ] py_vision_demo — 跑通 / 改过参数 / 改过代码 / 测过测试
│ 自定义网络: ros2_net (172.20.0.0/24) │ [ ] cpp_robot_tf2 — 跑通 / 改过参数 / 改过代码 / 测过测试
│ ┌──────── ROS2 Humble 镜像 ────────┐ │ [ ] cpp_qos_demo — 跑通 / 改过参数 / 改过代码 / 测过测试
│ │ rclcpp rclpy tf2 cv_bridge │ │ [ ] py_lifecycle_composable — 跑通 / 改过参数 / 改过代码 / 测过测试
│ │ ros-humble-desktop-full │ │ [ ] py_overlay_dds — 跑通 / 改过参数 / 改过代码 / 测过测试
│ └───────────────────────────────────┘ │ [ ] bringup — 跑通 / 改过参数 / 改过代码 / 测过测试
│ ┌──── colcon build/test ───────────┐ │
│ │ 12 个包 / 80 测试 │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
``` ```
**两层解耦**: **判定 "L1 完成"**: 12 × 4 = **48 个勾** ≥ 36 个(75%)。
- **本机层**: venv 装开发工具(runtime 隔离),IDE 直接读源码
- **容器层**: colcon 装 ROS2 节点(apt 来源,共享给所有用户)
## 🎬 6 种端到端 demo ---
| Demo | 命令 | 看什么 | ## 🗺 学完之后下一步做什么?
| 阶段 | 内容 | 学完后能 |
|---|---|---| |---|---|---|
| Topic 跨包跨语言 | `make launch NAME=pubsub_launch` | 4 节点(py+cpp)互通 | | ✅ L1 基础(本仓库) | 12 包 + 78 测试 + 23 文档 | 自己设计 ROS2 项目 |
| Service | `make launch NAME=service_launch` + `ros2 service call ...` | `12+30=42` | | ➡️ L2 进阶 | ros2_control + MoveIt2 + Gazebo 仿真 | 控制真实机械臂 / 用仿真调参 |
| Action | `make launch NAME=action_launch` + `ros2 action send_goal ...` | Fibonacci(6) 边跑边反馈 | | ➡️ L3 真实机器人 | xArm / UR / Franka 驱动 | 上工业机械臂 |
| Robot TF2 | `make launch NAME=robot_launch` | gripper 在 base_link 下实时位姿 | | ➡️ L4 具身智能 / VLA | OpenVLA / π0 / RKNN NPU 推理 | 让机器人理解自然语言指令 |
| Vision | `make launch NAME=vision_launch` | fake_camera → image_processor 图像流 |
| Full demo | `make full-demo` | **11+ 节点同时运行** |
## 📚 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 文档 | **本仓库** | | **Node(节点)** | 一个独立的运行程序(进程) | 像手机里的每个 App |
| ➡️ L2 进阶 | ros2_control + MoveIt2 + Gazebo | `ros-humble-*` apt | | **Topic(话题)** | 节点之间传递消息的"频道"(单向) | 像广播电台,谁都可以订阅 |
| ➡️ L3 机械臂 | 真实机械臂驱动 + 手眼标定 + 抓取 | xArm / UR / Franka | | **Service(服务)** | 节点之间的"一问一答"调用(双向) | 像打电话,问完必须等回答 |
| ➡️ L4 VLA | OpenVLA / π0 / RKNN NPU 推理 | PC + RDK X5 + RK3506 | | **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) - [REP-2000: ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
- [OSRF](https://www.openrobotics.org/) `osrf/ros:humble-desktop` 镜像 - [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) 开始学。
-85
View File
@@ -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
+32 -15
View File
@@ -51,9 +51,9 @@
## Level 1: ROS2 基础机制 (Foundation) ## Level 1: ROS2 基础机制 (Foundation)
> **核心目标**: 完整理解 ROS2 的 12 大基础机制,能独立写节点 + launch 文件 + 自定义接口 + 生命周期管理。 > **核心目标**: 完整理解 ROS2 的 15 大基础机制,能独立写节点 + launch 文件 + 自定义接口 + 生命周期管理。
### 12 大机制清单 ### 15 大机制清单
| # | 机制 | 当前包 | 文档 | | # | 机制 | 当前包 | 文档 |
|---|---|---|---| |---|---|---|---|
@@ -93,20 +93,23 @@
### Level 1 测试覆盖 ### Level 1 测试覆盖
``` ```
py_pubsub 4 pytest py_pubsub 11 pytest ✅
cpp_pubsub 2 gtest cpp_pubsub 3 gtest ✅
py_srv 1 pytest py_srv 7 pytest ✅
py_action_demo 1 pytest py_action_demo 5 pytest ✅
cpp_robot_tf2 2 gtest cpp_robot_tf2 4 gtest ✅
py_vision_demo 2 pytest py_vision_demo 13 pytest ✅
bringup 6 launch bringup (launch 聚合,无单测)
py_params 3 pytest ⭐(新增) py_params 16 pytest ⭐(新增)
cpp_custom_interface 3 gtest ⭐(新增) cpp_custom_interface 3 gtest ⭐(新增)
py_lifecycle_composable 3 pytest ⭐(新增) py_lifecycle_composable 6 pytest ⭐(新增)
cpp_qos_demo 3 gtest ⭐(新增) cpp_qos_demo 4 gtest ⭐(新增)
py_overlay_dds 2 pytest ⭐(新增) py_overlay_dds 6 pytest ⭐(新增)
总计: 32 用例, 目标 100% 通过 总计: **78 用例**(pytest 65 + gtest 13),目标 100% 通过
> 注:78 用例是当前仓库实测数(`colcon test` 结果),与 README/AGENTS 一致。
> 上述表格列是"实测用例数"(非"测试文件数")。
``` ```
--- ---
@@ -341,3 +344,17 @@ 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)
+13
View File
@@ -268,3 +268,16 @@ sudo apt install ros-humble-navigation2
| 做机器人 / TF / URDF | [`50-tf2.md`](50-tf2.md), [`60-urdf.md`](60-urdf.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) | | 三机部署到 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)
+14
View File
@@ -457,3 +457,17 @@ docker system prune -a
**动手尝试**: 改一下 `py_pubsub` 里 talker 的 `period_ms`,观察 `/chatter` 频率变化。这是理解 ROS2 参数的最快方式。 **动手尝试**: 改一下 `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)
+14
View File
@@ -275,3 +275,17 @@ docker exec ros2_dev bash -lc "source install/setup.bash && ros2 launch bringup
| 5 分钟上手 | [`01-quickstart.md`](01-quickstart.md) | | 5 分钟上手 | [`01-quickstart.md`](01-quickstart.md) |
| 测试策略 | [`90-testing.md`](90-testing.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)
+14
View File
@@ -862,3 +862,17 @@ export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
| launch 嵌套、参数覆盖、事件 | [`70-launch.md`](70-launch.md) | | launch 嵌套、参数覆盖、事件 | [`70-launch.md`](70-launch.md) |
| 三机部署(PC + RDK X5 + RK3506) | [`100-embedded-deployment.md`](100-embedded-deployment.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)
+13
View File
@@ -916,3 +916,16 @@ ros2 launch moveit2_tutorials demo.launch.py
- NPU 推理在 RDK X5 / RK3506 #2 上跑(YOLO-Lite) - 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)
+14
View File
@@ -626,3 +626,17 @@ if not result.successful:
> **ROS2 参数 = 节点的"配置项",从 launch / YAML / CLI 传入,运行时可改,回调里能拒绝非法值。设计目标是"配置与代码解耦"。** > **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)
+14
View File
@@ -194,3 +194,17 @@ publisher.publish(msg)
- [REP-127: ROS Message 标准](https://www.ros.org/reps/rep-0127.html) - [REP-127: ROS Message 标准](https://www.ros.org/reps/rep-0127.html)
- [rosidl 文档](https://design.ros2.org/articles/legacy_interface_definition.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)
+14
View File
@@ -247,3 +247,17 @@ def on_cleanup(self, state):
- [ROS2 Lifecycle 设计稿](https://design.ros2.org/articles/node_lifecycle.html) - [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) - [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)
+14
View File
@@ -186,3 +186,17 @@ Python 多节点同进程 + MultiThreadedExecutor 是 ROS2 Python 等价的 Comp
- [ROS2 Humble Composition 教程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/Using-Event-Handlers.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) - [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)
+14
View File
@@ -178,3 +178,17 @@ echo $ROS_DOMAIN_ID
- [OMG DDS 规范 v1.4](https://www.omg.org/spec/DDS/1.4/) — QoS 源头 - [OMG DDS 规范 v1.4](https://www.omg.org/spec/DDS/1.4/) — QoS 源头
- [FastDDS QoS 配置](https://fast-dds.docs.eprosima.com/) - [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)
+14
View File
@@ -180,3 +180,17 @@ 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) - [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) - [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)
+14
View File
@@ -633,3 +633,17 @@ ros2 run discovery_server discovery_server --address 0.0.0.0 --port 11811
| TF2 坐标变换 | [`50-tf2.md`](50-tf2.md) | | TF2 坐标变换 | [`50-tf2.md`](50-tf2.md) |
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.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)
+14
View File
@@ -212,3 +212,17 @@ source /opt/ros/humble/setup.bash
- [REP-2002 ROS 2 Design](https://www.ros.org/reps/rep-2002.html) - [REP-2002 ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
- [`py_overlay_dds` 包](../src/py_overlay_dds/README.md) - [`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)
+14
View File
@@ -508,3 +508,17 @@ rclpy.spin(node)
| Action 深度 | [`40-actions.md`](40-actions.md) | | Action 深度 | [`40-actions.md`](40-actions.md) |
| TF2 坐标变换 | [`50-tf2.md`](50-tf2.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)
+14
View File
@@ -750,3 +750,17 @@ string current_state
| TF2 + 抓取 | [`50-tf2.md`](50-tf2.md) | | TF2 + 抓取 | [`50-tf2.md`](50-tf2.md) |
| 具身智能路径 | [`99-embodied-ai.md`](99-embodied-ai.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)
+14
View File
@@ -515,3 +515,17 @@ chronyc tracking
| MoveIt2 | 在 [`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) | | 三机部署 | [`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)
+14
View File
@@ -709,3 +709,17 @@ ros2 launch moveit_setup_assistant setup_assistant.launch.py
| ros2_control + MoveIt2 | [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 2 | | ros2_control + MoveIt2 | [`99-embodied-ai.md`](99-embodied-ai.md) 阶段 2 |
| launch 文件 | [`70-launch.md`](70-launch.md) | | 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)
+14
View File
@@ -466,3 +466,17 @@ def generate_launch_description():
| Action | [`40-actions.md`](40-actions.md) | | Action | [`40-actions.md`](40-actions.md) |
| colcon / ament 包构建 | [`80-package-build.md`](80-package-build.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)
+14
View File
@@ -403,3 +403,17 @@ bloom-release --rosdistro humble --track humble --new 0.1.0 my_pkg
| Docker 开发 | [`85-docker.md`](85-docker.md) | | Docker 开发 | [`85-docker.md`](85-docker.md) |
| 测试策略 | [`90-testing.md`](90-testing.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)
+14
View File
@@ -403,3 +403,17 @@ COPY --from=builder /root/ros2_ws/install /root/ros2_ws/install
| 测试策略 | [`90-testing.md`](90-testing.md) | | 测试策略 | [`90-testing.md`](90-testing.md) |
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.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)
+14
View File
@@ -455,3 +455,17 @@ subprocess.run(['pkill', '-9', '-f', 'add_two_ints_server'])
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.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) |
| 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)
+14
View File
@@ -576,3 +576,17 @@ OpenVLA 7B 约 15GB,需要 GPU(NVIDIA RTX 3090+)。
每阶段做完后,**回到本仓库做一次 colcon test 验证 ROS2 通信基础仍然稳**。 每阶段做完后,**回到本仓库做一次 colcon test 验证 ROS2 通信基础仍然稳**。
加油,具身智能之旅! 🚀 加油,具身智能之旅! 🚀
---
---
## 📖 阅读路径导航
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
>
> ⏱ **本文预计阅读时间**: 60 分钟
> 📍 **当前位置**: 第 22 / 24 篇
-**上一篇**: [测试金字塔](../90-testing.md)
-**下一篇**: [三机部署实操](../100-embedded-deployment.md)
+13
View File
@@ -773,3 +773,16 @@ self.timer_: Timer = self.create_timer(...)
**违反任何一条,代码不得合并。** **违反任何一条,代码不得合并。**
**一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。** **一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。**
---
---
## 📖 阅读路径导航
> 💡 这是仓库 `doc/` 下所有文档的推荐阅读顺序。[返回 README 总导航](../README.md#-23-篇文档怎么读)
>
> ⏱ **本文预计阅读时间**: 90 分钟
> 📍 **当前位置**: 第 24 / 24 篇
-**上一篇**: [三机部署实操](../100-embedded-deployment.md)
+135 -42
View File
@@ -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** |
## 关键设计 `bringup` 包专门用来**跨包启动多个节点**。ROS2 项目一般会有一个 `bringup` 包负责"启动整个机器人"。
- **不实现业务节点** — 只用 `IncludeLaunchDescription` 复用其他包的 launch
- **跨包嵌套** — `robot_launch.py` / `vision_launch.py``FindPackageShare` + `PythonLaunchDescriptionSource` 引用单包 launch
- **包名避让** — 包名是 `bringup`,**不是** `launch`(与 ROS2 系统包同名会冲突)
## 运行
**典型用法**:
```bash ```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 ros2 launch bringup full_demo_launch.py
``` ```
## 验证端到端 一个命令启动所有 11 个节点(模拟整个机器人系统)。
---
## 🎯 学完之后你能做什么?
1. ✅ 用 `IncludeLaunchDescription` 复用其他包的 launch
2. ✅ 用 `FindPackageShare` 找其他包的资源路径
3. ✅ 写一个聚合 launch,启动整个系统
---
## 🚀 跑起来
### 一键启动 11 节点
```bash ```bash
# 跨包跨语言 Topic:PY + CPP 同时发,listener 都收到 source /opt/ros/humble/setup.bash
ros2 launch bringup pubsub_launch.py source /root/ros2_ws/install/setup.bash
ros2 topic info /chatter -v
# 预期:Publication count: 2, Subscription count: 2
# Action ros2 launch bringup full_demo_launch.py
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
``` ```
## 深度学习 **预期**:11 个节点同时跑,看到各种 Topic/Service/Action 交互。
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md) §5.5 ### 单独启动某个 launch
- launch 深度:[`doc/70-launch.md`](../doc/70-launch.md)
```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-包推荐学习顺序)
+29 -24
View File
@@ -4,12 +4,13 @@
# - 必须用 rosidl_generate_interfaces 自动生成 msg/srv/action 的 C++ + Python 代码 # - 必须用 rosidl_generate_interfaces 自动生成 msg/srv/action 的 C++ + Python 代码
# - 生成的代码会装到 install/cpp_custom_interface/include/... # - 生成的代码会装到 install/cpp_custom_interface/include/...
# - 然后被同包或跨包的 C++ 代码 include # - 然后被同包或跨包的 C++ 代码 include
# - 库 + 可执行分离(便于测试只链接库)
# #
# 参考: # 参考:
# - 自定义接口: https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Custom-ROS2-Interfaces.html # - 自定义接口: https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Custom-ROS2-Interfaces.html
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(cpp_custom_interface LANGUAGES CXX) project(cpp_custom_interface LANGUAGES CXX C)
if(NOT CMAKE_CXX_STANDARD) if(NOT CMAKE_CXX_STANDARD)
set(CMAKE_CXX_STANDARD 17) 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) add_compile_options(-Wall -Wextra -Wpedantic)
endif() endif()
# 1) 找依赖 # 必备: 找 ament_cmake + 依赖
find_package(ament_cmake REQUIRED) find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED) find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED) find_package(rclcpp_action REQUIRED)
@@ -27,26 +28,17 @@ find_package(std_msgs REQUIRED)
find_package(geometry_msgs REQUIRED) find_package(geometry_msgs REQUIRED)
find_package(rosidl_default_generators REQUIRED) find_package(rosidl_default_generators REQUIRED)
# 2) 定义接口(关键步骤) # 定义接口(关键步骤)
set(MSG_FILES set(MSG_FILES "msg/SensorReading.msg")
"msg/SensorReading.msg" set(SRV_FILES "srv/GetCalibration.srv")
) set(ACTION_FILES "action/MoveArm.action")
set(SRV_FILES
"srv/GetCalibration.srv"
)
set(ACTION_FILES
"action/MoveArm.action"
)
rosidl_generate_interfaces(${PROJECT_NAME} rosidl_generate_interfaces(${PROJECT_NAME}
${MSG_FILES} ${MSG_FILES} ${SRV_FILES} ${ACTION_FILES}
${SRV_FILES}
${ACTION_FILES}
DEPENDENCIES std_msgs geometry_msgs DEPENDENCIES std_msgs geometry_msgs
ADD_LINTER_TESTS
) )
# 3) C++ 库(节点代码) # C++ 库(只含类实现,不含 main)
add_library(${PROJECT_NAME}_core SHARED add_library(${PROJECT_NAME}_core SHARED
src/sensor_publisher.cpp src/sensor_publisher.cpp
src/calibration_server.cpp src/calibration_server.cpp
@@ -57,23 +49,24 @@ target_include_directories(${PROJECT_NAME}_core PUBLIC
$<INSTALL_INTERFACE:include/${PROJECT_NAME}> $<INSTALL_INTERFACE:include/${PROJECT_NAME}>
) )
ament_target_dependencies(${PROJECT_NAME}_core 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" ${PROJECT_NAME} "rosidl_typesupport_cpp"
) )
target_link_libraries(${PROJECT_NAME}_core ${cpp_typesupport_cpp})
# 4) 可执行 # 可执行(main 单独文件)
add_executable(sensor_publisher_cpp src/sensor_publisher.cpp) add_executable(sensor_publisher_cpp src/sensor_publisher_main.cpp)
target_link_libraries(sensor_publisher_cpp ${PROJECT_NAME}_core) 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) 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) target_link_libraries(move_arm_server_cpp ${PROJECT_NAME}_core)
# 5) 安装 # 安装
install(TARGETS install(TARGETS
sensor_publisher_cpp sensor_publisher_cpp
calibration_server_cpp calibration_server_cpp
@@ -85,4 +78,16 @@ install(DIRECTORY launch
DESTINATION share/${PROJECT_NAME} 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() ament_package()
+237 -45
View File
@@ -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` 服务(返回模拟相机内参) - 机器人: `/robot_status.msg` (含电量、位置、状态)
- **`move_arm_server_cpp`**: 提供 `MoveArm` Action(5 阶段模拟移动) - 机械臂: `/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 1. ✅ 定义 `.msg` / `.srv` / `.action` 文件
# 启动所有自定义接口节点 2. ✅ 用 `rosidl_generate_interfaces` 生成 C++ / Python 代码
ros2 launch cpp_custom_interface custom_launch.py 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 ```bash
colcon test --packages-select cpp_custom_interface 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) 1.`msg/``srv/``action/` 下新建 `.msg`/`.srv`/`.action` 文件
- 自定义接口:[`doc/16-custom-interfaces.md`](../doc/16-custom-interfaces.md) 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-包推荐学习顺序)
-2
View File
@@ -26,8 +26,6 @@
<member_of_group>rosidl_interface_packages</member_of_group> <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> <test_depend>ament_cmake_gtest</test_depend>
<export> <export>
@@ -11,7 +11,7 @@ CalibrationServer::CalibrationServer(const rclcpp::NodeOptions & options)
{ {
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"service_name", "get_calibration", "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(); const std::string service_name = this->get_parameter("service_name").as_string();
@@ -47,11 +47,3 @@ void CalibrationServer::handle_request(
} }
} // namespace cpp_custom_interface } // 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>( this->declare_parameter<std::string>(
"action_name", "move_arm", "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(); 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) 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 feedback = std::make_shared<MoveArm::Feedback>();
auto result = std::make_shared<MoveArm::Result>(); 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 } // 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>( this->declare_parameter<std::string>(
"topic_name", "/sensor_reading", "topic_name", "/sensor_reading",
rcl_interfaces::msg::ParameterDescriptor().set_description("发布话题名")); rcl_interfaces::msg::ParameterDescriptor().set__description("发布话题名"));
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"sensor_id", "imu_0", "sensor_id", "imu_0",
rcl_interfaces::msg::ParameterDescriptor().set_description("传感器 ID")); rcl_interfaces::msg::ParameterDescriptor().set__description("传感器 ID"));
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"unit", "rad/s", "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(); const std::string topic_name = this->get_parameter("topic_name").as_string();
@@ -49,11 +49,3 @@ void SensorPublisher::timer_callback()
} }
} // namespace cpp_custom_interface } // 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;
}
+17 -4
View File
@@ -21,12 +21,17 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
endif() endif()
# 必备: 找 ament_cmake + 依赖
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
set(THIS_PACKAGE_INCLUDE_DEPENDS set(THIS_PACKAGE_INCLUDE_DEPENDS
rclcpp rclcpp
std_msgs std_msgs
) )
# 库:含 ChatterPublisher / ChatterSubscriber 实现 # 库:含 ChatterPublisher / ChatterSubscriber 实现(无 main)
add_library(${PROJECT_NAME}_core SHARED add_library(${PROJECT_NAME}_core SHARED
src/chatter_publisher.cpp src/chatter_publisher.cpp
src/chatter_subscriber.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}) ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
# 可执行文件:两个独立 main(每个节点独立可执行) # 可执行文件:每个 main 单独编译
add_executable(chatter_publisher_cpp src/chatter_publisher.cpp) add_executable(chatter_publisher_cpp src/chatter_publisher_main.cpp)
target_link_libraries(chatter_publisher_cpp ${PROJECT_NAME}_core) 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) target_link_libraries(chatter_subscriber_cpp ${PROJECT_NAME}_core)
# 安装:可执行 + 库 + launch # 安装:可执行 + 库 + launch
@@ -56,4 +61,12 @@ install(DIRECTORY launch
DESTINATION share/${PROJECT_NAME} 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() ament_package()
+369 -31
View File
@@ -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` 派生 ## 🤔 我已经会 py_pubsub 了,为什么还要学 C++ 版?
- `cpp_pubsub::ChatterSubscriber``rclcpp::Node` 派生
均位于 `namespace cpp_pubsub`,便于复用与测试。 **实话**:Python 写 ROS2 简单 90%,**大多数项目用 Python 就够了**。但有些场景用 C++ 更合适:
## 运行 | 场景 | 为什么可能用 C++ |
|---|---|
| 性能敏感(图像/点云/SLAM) | Python GC 不可控 + 速度慢 |
| 已有 C++ 库要复用 | OpenCV、PCL、MoveIt2 全是 C++ |
| 嵌入式部署 | 部分芯片只支持 C/C++ |
```bash **如果你的项目不涉及上面这些,只用 Python 就行,不必学这个包。**
# 单独启动
ros2 run cpp_pubsub chatter_publisher_cpp
ros2 run cpp_pubsub chatter_subscriber_cpp
# 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 ```bash
colcon test --packages-select cpp_pubsub 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` | 节点名 + 默认参数正确 | | `String()` | `std_msgs::msg::String()` |
| `SubscriberConstructsWithDefaults` | 节点名 + 默认参数正确 | | `msg.data` | `msg.data` |
| `PublisherSpinSomeWorks` | spin_some 1s 不崩溃 | | 字符串类型 | `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-包推荐学习顺序)
+1
View File
@@ -18,6 +18,7 @@
<depend>rclcpp</depend> <depend>rclcpp</depend>
<depend>std_msgs</depend> <depend>std_msgs</depend>
<test_depend>ament_cmake_gtest</test_depend>
<test_depend>ament_lint_auto</test_depend> <test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend> <test_depend>ament_lint_common</test_depend>
+8 -12
View File
@@ -1,4 +1,9 @@
// chatter_publisher.cpp - C++ ChatterPublisher 实现 // chatter_publisher.cpp - ChatterPublisher 实现(不含 main)
//
// 设计思想:
// - 把节点类实现放 _core 库,main() 单独文件
// - 库不包含 main,避免多定义错误
#include "cpp_pubsub/chatter_publisher.hpp" #include "cpp_pubsub/chatter_publisher.hpp"
#include <chrono> #include <chrono>
@@ -15,10 +20,10 @@ ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
// 1) 声明参数(类型模板版 + 描述符) // 1) 声明参数(类型模板版 + 描述符)
this->declare_parameter<int>( this->declare_parameter<int>(
"publish_rate_hz", 2, "publish_rate_hz", 2,
rcl_interfaces::msg::ParameterDescriptor().set_description("发布频率 (Hz)")); rcl_interfaces::msg::ParameterDescriptor().set__description("发布频率 (Hz)"));
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"topic_name", "chatter", "topic_name", "chatter",
rcl_interfaces::msg::ParameterDescriptor().set_description("发布话题名")); rcl_interfaces::msg::ParameterDescriptor().set__description("发布话题名"));
// 2) 读参数 // 2) 读参数
const int publish_rate_hz = this->get_parameter("publish_rate_hz").as_int(); const int publish_rate_hz = this->get_parameter("publish_rate_hz").as_int();
@@ -27,7 +32,6 @@ ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
// 3) 构造发布者 + 定时器 // 3) 构造发布者 + 定时器
publisher_ = this->create_publisher<std_msgs::msg::String>(topic_name, 10); publisher_ = this->create_publisher<std_msgs::msg::String>(topic_name, 10);
// 防零除
const auto period = (publish_rate_hz > 0) ? const auto period = (publish_rate_hz > 0) ?
std::chrono::milliseconds(1000 / publish_rate_hz) : std::chrono::milliseconds(1000 / publish_rate_hz) :
std::chrono::milliseconds(1000); std::chrono::milliseconds(1000);
@@ -53,11 +57,3 @@ void ChatterPublisher::timer_callback()
} }
} // namespace cpp_pubsub } // 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;
}
@@ -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;
}
+7 -11
View File
@@ -1,4 +1,4 @@
// chatter_subscriber.cpp - C++ ChatterSubscriber 实现 // chatter_subscriber.cpp - ChatterSubscriber 实现(不含 main)
#include "cpp_pubsub/chatter_subscriber.hpp" #include "cpp_pubsub/chatter_subscriber.hpp"
#include <string> #include <string>
@@ -11,7 +11,7 @@ ChatterSubscriber::ChatterSubscriber(const rclcpp::NodeOptions & options)
{ {
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"topic_name", "chatter", "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(); 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) void ChatterSubscriber::message_callback(const std_msgs::msg::String::SharedPtr msg)
{ {
try { 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) { } catch (const std::exception & exc) {
RCLCPP_ERROR(this->get_logger(), "callback failed: %s", exc.what()); RCLCPP_ERROR(this->get_logger(), "callback failed: %s", exc.what());
} }
} }
} // namespace cpp_pubsub } // 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;
}
@@ -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;
}
+6 -6
View File
@@ -1,9 +1,9 @@
# test/test_pub_sub.cpp - cpp_pubsub 单元测试 // test/test_pub_sub.cpp - cpp_pubsub 单元测试
# //
# 设计思想: // 设计思想:
# - SetUpTestSuite / TearDownTestSuite 共享 rclcpp::init / shutdown // - SetUpTestSuite / TearDownTestSuite 共享 rclcpp::init / shutdown
# - 用 spin_some(50ms) 代替 spin() 控制超时 // - 用 spin_some(50ms) 代替 spin() 控制超时
# - 不依赖 launch_testing(避免环境耦合) // - 不依赖 launch_testing(避免环境耦合)
#include <chrono> #include <chrono>
#include <memory> #include <memory>
+13 -2
View File
@@ -10,6 +10,11 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
endif() endif()
# 必备: 找 ament_cmake + 依赖
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
set(THIS_PACKAGE_INCLUDE_DEPENDS set(THIS_PACKAGE_INCLUDE_DEPENDS
rclcpp rclcpp
std_msgs 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) add_executable(qos_demo_publisher_cpp src/qos_demo_publisher_main.cpp)
target_link_libraries(qos_demo_publisher_cpp ${PROJECT_NAME}_core) 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) add_executable(qos_demo_subscriber_cpp src/qos_demo_subscriber_main.cpp)
target_link_libraries(qos_demo_subscriber_cpp ${PROJECT_NAME}_core) target_link_libraries(qos_demo_subscriber_cpp ${PROJECT_NAME}_core)
target_compile_definitions(qos_demo_subscriber_cpp PRIVATE "QOS_DEMO_MAIN=1")
install(TARGETS install(TARGETS
qos_demo_publisher_cpp qos_demo_publisher_cpp
@@ -39,4 +42,12 @@ install(TARGETS
) )
install(DIRECTORY launch DESTINATION share/${PROJECT_NAME}) 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() ament_package()
+107 -52
View File
@@ -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` | | **Reliability(可靠性)** | `reliable` / `best_effort` | 保证送达 vs 丢了就算了 |
| `durability` | `volatile` / `transient_local` | `volatile` | | **Durability(持久性)** | `volatile` / `transient_local` | 不保存历史 vs 为晚加入者保留 |
| `history` | `keep_last` / `keep_all` | `keep_last` | | **History(历史)** | `keep_last(N)` / `keep_all` | 只保留最后 N 条 vs 保留所有 |
| `depth` | int | 10 |
| `publish_rate_hz` | float | 1.0 |
## 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 | 1. ✅ 理解 3 维 QoS 模型(Reliability / Durability / History)
|---|---|---| 2. ✅ 用 `rclcpp::QoS` 构造 QoS profile
| RELIABLE | ✅ | ❌ | 3. ✅ 知道 9 种组合的兼容性矩阵
| BEST_EFFORT | ✅ | ✅ | 4. ✅ 在生产环境正确选 QoS(传感器用 best_effort,关键控制用 reliable)
**关键**:`RELIABLE → BEST_EFFORT` 不兼容!sub 不发 ACK,pub 报错: ---
```
[WARN] ... New subscription discovered on this topic with incompatible QoS ...
```
## 运行 ## 🚀 跑起来
### 启动 Subscriber
**终端 1**(容器内):
```bash ```bash
# 默认 QoS(RELIABLE + VOLATILE + KEEP_LAST(10)) source /opt/ros/humble/setup.bash
ros2 launch cpp_qos_demo qos_launch.py source /root/ros2_ws/install/setup.bash
# BEST_EFFORT 视频流 # 默认(reliable + volatile + keep_last(10))
ros2 run cpp_qos_demo qos_demo_publisher_cpp --ros-args \ ros2 run cpp_qos_demo qos_demo_subscriber_cpp
-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
``` ```
## 测试 ### 启动 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 ```bash
colcon test --packages-select cpp_qos_demo 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) - [doc/19-qos.md](../../doc/19-qos.md) — QoS 深度(全部 9 种组合详解)
- QoS 深度:[`doc/19-qos.md`](../doc/19-qos.md) - [ROS2 QoS 设计稿](https://design.ros2.org/articles/qos.html)
- OMG DDS 规范:[`DDS 1.4 spec`](https://www.omg.org/spec/DDS/1.4/)
---
## ⏭️ 下一个包
继续学 **[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-包推荐学习顺序)
+1
View File
@@ -20,6 +20,7 @@
<test_depend>ament_lint_auto</test_depend> <test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend> <test_depend>ament_lint_common</test_depend>
<test_depend>ament_cmake_gtest</test_depend>
<export> <export>
<build_type>ament_cmake</build_type> <build_type>ament_cmake</build_type>
+48 -38
View File
@@ -1,4 +1,4 @@
// qos_demo_node.cpp - QoS 演示节点实现 // qos_demo_node.cpp - QoS 演示节点实现(无 main)
#include "cpp_qos_demo/qos_demo_node.hpp" #include "cpp_qos_demo/qos_demo_node.hpp"
#include <chrono> #include <chrono>
@@ -9,28 +9,25 @@ namespace cpp_qos_demo
using namespace std::chrono_literals; using namespace std::chrono_literals;
// ============== Publisher ==============
QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options) QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options)
: rclcpp::Node("qos_demo_publisher", options), publish_count_(0) : rclcpp::Node("qos_demo_publisher", options), publish_count_(0)
{ {
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"reliability", "reliable", "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>( this->declare_parameter<std::string>(
"durability", "volatile", "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>( this->declare_parameter<std::string>(
"history", "keep_last", "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>( this->declare_parameter<int>(
"depth", 10, "depth", 10,
rcl_interfaces::msg::ParameterDescriptor().set_description("KEEP_LAST depth")); rcl_interfaces::msg::ParameterDescriptor().set__description("KEEP_LAST depth"));
this->declare_parameter<double>( this->declare_parameter<double>(
"publish_rate_hz", 1.0, "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; rmw_qos_profile_t profile = rmw_qos_profile_default;
profile.depth = this->get_parameter("depth").as_int(); 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; profile.history = RMW_QOS_POLICY_HISTORY_KEEP_LAST;
} }
publisher_ = this->create_publisher<std_msgs::msg::String>( rclcpp::QoS qos(profile.depth);
"/qos_demo_topic", profile); 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 double publish_rate_hz = this->get_parameter("publish_rate_hz").as_double();
const auto period = (publish_rate_hz > 0) ? const auto period = (publish_rate_hz > 0) ?
@@ -68,7 +81,7 @@ QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options)
RCLCPP_INFO( RCLCPP_INFO(
this->get_logger(), 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); 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) QosDemoSubscriber::QosDemoSubscriber(const rclcpp::NodeOptions & options)
: rclcpp::Node("qos_demo_subscriber", options), received_count_(0) : rclcpp::Node("qos_demo_subscriber", options), received_count_(0)
{ {
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"reliability", "reliable", "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>( this->declare_parameter<std::string>(
"durability", "volatile", "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>( this->declare_parameter<std::string>(
"history", "keep_last", "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>( this->declare_parameter<int>(
"depth", 10, "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; rmw_qos_profile_t profile = rmw_qos_profile_default;
profile.depth = this->get_parameter("depth").as_int(); 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_ALL :
RMW_QOS_POLICY_HISTORY_KEEP_LAST; 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>( 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)); std::bind(&QosDemoSubscriber::message_callback, this, std::placeholders::_1));
RCLCPP_INFO( RCLCPP_INFO(
this->get_logger(), 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); reliability.c_str(), durability.c_str(), history.c_str(), profile.depth);
} }
@@ -143,21 +171,3 @@ void QosDemoSubscriber::message_callback(const std_msgs::msg::String::SharedPtr
} }
} // namespace cpp_qos_demo } // 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;
}
@@ -1,4 +1,10 @@
// qos_demo_publisher_main.cpp - Publisher 入口 // qos_demo_publisher_main.cpp - Publisher 节点入口
#include "qos_demo_node.cpp" #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 入口 // qos_demo_subscriber_main.cpp - Subscriber 节点入口
#include "qos_demo_node.cpp" #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;
}
+21 -4
View File
@@ -16,6 +16,15 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
endif() 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 set(THIS_PACKAGE_INCLUDE_DEPENDS
rclcpp rclcpp
sensor_msgs sensor_msgs
@@ -25,7 +34,7 @@ set(THIS_PACKAGE_INCLUDE_DEPENDS
tf2_geometry_msgs tf2_geometry_msgs
) )
# 库 # 库(只含类实现,不含 main)
add_library(${PROJECT_NAME}_core SHARED add_library(${PROJECT_NAME}_core SHARED
src/joint_state_publisher.cpp src/joint_state_publisher.cpp
src/tf2_listener.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}) ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
# 可执行 # 可执行(main 单独文件)
add_executable(joint_state_publisher_cpp src/joint_state_publisher.cpp) add_executable(joint_state_publisher_cpp src/joint_state_publisher_main.cpp)
target_link_libraries(joint_state_publisher_cpp ${PROJECT_NAME}_core) 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) target_link_libraries(tf2_listener_cpp ${PROJECT_NAME}_core)
# 安装 # 安装
@@ -54,4 +63,12 @@ install(DIRECTORY launch urdf
DESTINATION share/${PROJECT_NAME} 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() ament_package()
+218 -40
View File
@@ -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 中两个机器人专属核心:
| 概念 | 用途 | 1. **URDF**(Unified Robot Description Format):机器人的"骨骼图纸",描述关节 + 连杆 + 视觉/碰撞形状
|---|---| 2. **TF2**(Transform Library):跟踪机器人各部件在三维空间中的位置和姿态,自动维护"坐标系树"
| `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">` | 有限位转动关节 |
## 运行 **实际例子**:机械臂有 6 个关节,你知道末端(抓手)在世界坐标系下的位姿吗?TF2 帮你算。
```bash **本包演示**:
# 一键启动(URDF + JointState + robot_state_publisher + TF listener) - `joint_state_publisher`:周期性发布 3 个关节的 sin 运动轨迹
ros2 launch cpp_robot_tf2 robot_tf2_launch.py - `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 频率 1. ✅ 理解 URDF 文件格式(`<link>` / `<joint>` / `<visual>`)
ros2 topic hz /tf 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 ```bash
colcon test --packages-select cpp_robot_tf2 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 `joint_state_publisher` 替换成真实机械臂的驱动节点(发 `/joint_states`),URDF 替换成机械臂厂商提供的 .xacro 文件,`robot_state_publisher` 自动算 TF。
- TF2 深度:[`doc/50-tf2.md`](../doc/50-tf2.md)
- URDF 深度:[`doc/60-urdf.md`](../doc/60-urdf.md)
## 进阶(下一阶段) **典型工作流**:
```
真实机械臂驱动 → /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-包推荐学习顺序)
+1
View File
@@ -22,6 +22,7 @@
<depend>tf2</depend> <depend>tf2</depend>
<depend>tf2_geometry_msgs</depend> <depend>tf2_geometry_msgs</depend>
<test_depend>ament_cmake_gtest</test_depend>
<test_depend>ament_lint_auto</test_depend> <test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</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 "cpp_robot_tf2/joint_state_publisher.hpp"
#include <chrono> #include <chrono>
@@ -17,23 +17,14 @@ JointStatePublisher::JointStatePublisher(const rclcpp::NodeOptions & options)
period_sec_(2.0), period_sec_(2.0),
amplitude_rad_(0.5) amplitude_rad_(0.5)
{ {
// 1) 关节列表(与 URDF joint 名严格对应)
joint_names_ = {"joint1", "joint2", "joint3"}; joint_names_ = {"joint1", "joint2", "joint3"};
// 2) 参数(可选覆盖)
this->declare_parameter<double>("period_sec", period_sec_); this->declare_parameter<double>("period_sec", period_sec_);
this->declare_parameter<double>("amplitude_rad", amplitude_rad_); this->declare_parameter<double>("amplitude_rad", amplitude_rad_);
period_sec_ = this->get_parameter("period_sec").as_double(); period_sec_ = this->get_parameter("period_sec").as_double();
amplitude_rad_ = this->get_parameter("amplitude_rad").as_double(); amplitude_rad_ = this->get_parameter("amplitude_rad").as_double();
joint_count_ = joint_names_.size(); joint_count_ = joint_names_.size();
// 3) 发布者 publisher_ = this->create_publisher<sensor_msgs::msg::JointState>("/joint_states", 10);
publisher_ = this->create_publisher<sensor_msgs::msg::JointState>(
"/joint_states", 10);
// 4) 定时器
timer_ = this->create_wall_timer( timer_ = this->create_wall_timer(
std::chrono::milliseconds(static_cast<int>(1000.0 / 50.0)), std::chrono::milliseconds(static_cast<int>(1000.0 / 50.0)),
std::bind(&JointStatePublisher::timer_callback, this)); std::bind(&JointStatePublisher::timer_callback, this));
@@ -50,28 +41,17 @@ void JointStatePublisher::timer_callback()
auto msg = sensor_msgs::msg::JointState(); auto msg = sensor_msgs::msg::JointState();
msg.header.stamp = this->now(); msg.header.stamp = this->now();
msg.name = joint_names_; msg.name = joint_names_;
// 用正弦函数生成关节位置(每个关节相位差 60°,看起来像协调运动)
msg.position.resize(joint_count_); msg.position.resize(joint_count_);
for (size_t i = 0; i < joint_count_; ++i) { for (size_t i = 0; i < joint_count_; ++i) {
const double phase = static_cast<double>(i) * M_PI / 3.0; const double phase = static_cast<double>(i) * M_PI / 3.0;
msg.position[i] = amplitude_rad_ * std::sin( msg.position[i] = amplitude_rad_ * std::sin(
2.0 * M_PI * elapsed_sec_ / period_sec_ + phase); 2.0 * M_PI * elapsed_sec_ / period_sec_ + phase);
} }
publisher_->publish(msg); publisher_->publish(msg);
elapsed_sec_ += 0.02; // 50 Hz elapsed_sec_ += 0.02;
} catch (const std::exception & exc) { } catch (const std::exception & exc) {
RCLCPP_ERROR(this->get_logger(), "timer_callback failed: %s", exc.what()); RCLCPP_ERROR(this->get_logger(), "timer_callback failed: %s", exc.what());
} }
} }
} // namespace cpp_robot_tf2 } // 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;
}
@@ -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;
}
+4 -18
View File
@@ -1,4 +1,4 @@
// tf2_listener.cpp - 实现 TF 查询 // tf2_listener.cpp - Tf2Listener 类实现(无 main)
#include "cpp_robot_tf2/tf2_listener.hpp" #include "cpp_robot_tf2/tf2_listener.hpp"
#include <chrono> #include <chrono>
@@ -12,22 +12,19 @@ using namespace std::chrono_literals;
Tf2Listener::Tf2Listener(const rclcpp::NodeOptions & options) Tf2Listener::Tf2Listener(const rclcpp::NodeOptions & options)
: rclcpp::Node("tf2_listener", options) : rclcpp::Node("tf2_listener", options)
{ {
// 参数
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"target_frame", "base_link", "target_frame", "base_link",
rcl_interfaces::msg::ParameterDescriptor().set_description("目标坐标系")); rcl_interfaces::msg::ParameterDescriptor().set__description("目标坐标系"));
this->declare_parameter<std::string>( this->declare_parameter<std::string>(
"source_frame", "gripper", "source_frame", "gripper",
rcl_interfaces::msg::ParameterDescriptor().set_description("源坐标系")); rcl_interfaces::msg::ParameterDescriptor().set__description("源坐标系"));
target_frame_ = this->get_parameter("target_frame").as_string(); target_frame_ = this->get_parameter("target_frame").as_string();
source_frame_ = this->get_parameter("source_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_buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_, this, false); tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_, this, false);
// 1 Hz 周期查询
timer_ = this->create_wall_timer( timer_ = this->create_wall_timer(
1s, std::bind(&Tf2Listener::timer_callback, this)); 1s, std::bind(&Tf2Listener::timer_callback, this));
@@ -40,12 +37,9 @@ Tf2Listener::Tf2Listener(const rclcpp::NodeOptions & options)
void Tf2Listener::timer_callback() void Tf2Listener::timer_callback()
{ {
try { try {
// lookup_transform 的最后一个参数 timeout 必须给,否则默认很短的
const auto transform = tf_buffer_->lookupTransform( const auto transform = tf_buffer_->lookupTransform(
target_frame_, source_frame_, target_frame_, source_frame_,
tf2::TimePointZero, // 最新可用 transform tf2::TimePointZero, 500ms);
500ms);
RCLCPP_INFO( RCLCPP_INFO(
this->get_logger(), this->get_logger(),
"[%s -> %s] x=%.3f y=%.3f z=%.3f", "[%s -> %s] x=%.3f y=%.3f z=%.3f",
@@ -61,11 +55,3 @@ void Tf2Listener::timer_callback()
} }
} // namespace cpp_robot_tf2 } // 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;
}
@@ -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;
}
+3 -1
View File
@@ -43,7 +43,9 @@ TEST_F(JointStatePublisherTest, PublisherExistsOnJointStates)
TEST_F(JointStatePublisherTest, TimerCreated) TEST_F(JointStatePublisherTest, TimerCreated)
{ {
auto node = std::make_shared<cpp_robot_tf2::JointStatePublisher>(); 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) TEST_F(JointStatePublisherTest, TimerCallbackPublishes)
+318 -39
View File
@@ -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` | 长任务通信 + 反馈 + 取消 | | **Goal(目标)** | Client → Server | "帮我算 Fibonacci 6 项" |
| `ServerGoalHandle` | server 端 goal 句柄(状态管理) | | **Feedback(反馈)** | Server → Client | "算到第 3 项了..."(周期性) |
| `ReentrantCallbackGroup` | 让 execute_callback 内可 publish_feedback | | **Result(结果)** | Server → Client | "算完了,序列是 [0,1,1,2,3,5]" |
| `MultiThreadedExecutor` | 必须用,否则 feedback 卡死 |
| `GoalHandle.is_cancel_requested` | server 周期性检查 |
## 踩过的坑(本包已避开) **额外能力**:Client 可以中途 **Cancel** 取消任务。
1. **wait_for_server 死锁** — 不在 `__init__` 阻塞,用 `server_is_ready()` 轮询 **对比 Service**:
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 | Action |
|---|---|---|
| 是否同步 | ✅ 同步 | ❌ 异步 |
| 是否看进度 | ❌ | ✅ Feedback |
| 是否可取消 | ❌ | ✅ Cancel |
| 典型用途 | 算数学、查数据 | 导航、机械臂运动 |
| 任务时长 | 秒级 | 秒~小时 |
```bash **怎么选**:
# 终端 1:启 server - 任务 < 1 秒、无需看进度 → **用 Service**
ros2 launch py_action_demo action_launch.py - 任务 > 1 秒、需要进度反馈、能取消 → **用 Action**
# 终端 2:发 Goal(带 feedback) **生活化例子**:
ros2 action send_goal /fibonacci example_interfaces/action/Fibonacci "{order: 6}" --feedback - Service = 餐厅点单,等餐期间啥也看不见
# 预期输出: - Action = 打车,你能看到司机实时位置 + 能取消订单
# 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 ## 🎯 学完之后你能做什么?
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 ```bash
colcon test --packages-select py_action_demo 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 注册 | | [py_srv — Python Service](../py_srv/README.md) | **py_action_demo — Python Action** | [py_params — Python Parameter](../py_params/README.md) |
| `test_action_end_to_end.py` | 1 | 同进程 server + client(order=5 → sequence=[0,1,1,2,3,5]) |
| **总计** | **4** | **目标 4/4 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
- Action 深度:[`doc/40-actions.md`](../doc/40-actions.md)
@@ -19,7 +19,9 @@ from typing import List, Optional
import rclpy import rclpy
from example_interfaces.action import Fibonacci 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 from rclpy.node import Node
@@ -45,11 +47,11 @@ class FibonacciActionClient(Node):
self.declare_parameter( self.declare_parameter(
'action_name', self.DEFAULT_ACTION_NAME, 'action_name', self.DEFAULT_ACTION_NAME,
descriptor='要调用的 Action 名', ParameterDescriptor(description='要调用的 Action 名'),
) )
self.declare_parameter( self.declare_parameter(
'order', self.DEFAULT_ORDER, 'order', self.DEFAULT_ORDER,
descriptor='Fibonacci 阶数(整数)', ParameterDescriptor(description='Fibonacci 阶数(整数)'),
) )
action_name: str = self.get_parameter('action_name').value action_name: str = self.get_parameter('action_name').value
@@ -19,7 +19,9 @@ from typing import List, Optional
import rclpy import rclpy
from example_interfaces.action import Fibonacci 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.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node from rclpy.node import Node
@@ -45,7 +47,7 @@ class FibonacciActionServer(Node):
self.declare_parameter( self.declare_parameter(
'action_name', self.DEFAULT_ACTION_NAME, 'action_name', self.DEFAULT_ACTION_NAME,
descriptor='Action 名(字符串)', ParameterDescriptor(description='Action 名(字符串)'),
) )
action_name: str = self.get_parameter('action_name').value action_name: str = self.get_parameter('action_name').value
+10 -33
View File
@@ -1,15 +1,4 @@
"""py_action_demo 单元测试:同进程内 Action server + client 跑通 Fibonacci。 """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"
"""
import time import time
@@ -20,13 +9,6 @@ from py_action_demo.fibonacci_server import FibonacciActionServer
from py_action_demo.fibonacci_client import FibonacciActionClient 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): def test_fibonacci_inproc_roundtrip(ros_context):
"""order=5 → 序列应为 [0, 1, 1, 2, 3, 5]。""" """order=5 → 序列应为 [0, 1, 1, 2, 3, 5]。"""
server = FibonacciActionServer() server = FibonacciActionServer()
@@ -36,28 +18,23 @@ def test_fibonacci_inproc_roundtrip(ros_context):
exec_.add_node(server) exec_.add_node(server)
exec_.add_node(client) exec_.add_node(client)
# 让 server 先 spin 几秒注册到 DDS,再让 client 等 server 就绪。
end = time.time() + 3.0 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) 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) client.send_goal(5)
# spin 直到 _get_result_future 完成,最多 8s。
end = time.time() + 8.0 end = time.time() + 8.0
while rclpy.ok() and time.time() < end: while rclpy.ok() and time.time() < end:
exec_.spin_once(timeout_sec=0.1) exec_.spin_once(timeout_sec=0.1)
if getattr(client, '_get_result_future', None) and client._get_result_future.done(): if client._goal_done:
for _ in range(5):
exec_.spin_once(timeout_sec=0.05)
break break
assert getattr(client, '_get_result_future', None), \ assert client._goal_done, 'goal was not completed within 8s'
'goal was rejected (no _get_result_future)' assert client._result is not None
assert client._get_result_future.done(), \ seq = list(client._result.sequence)
'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 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 from py_action_demo.fibonacci_server import FibonacciActionServer
@pytest.mark.timeout(15)
def test_fibonacci_end_to_end(ros_context: None) -> None: def test_fibonacci_end_to_end(ros_context: None) -> None:
"""同进程 server + client 跑 Fibonacci(5) → sequence == [0,1,1,2,3,5]。""" """同进程 server + client 跑 Fibonacci(5) → sequence == [0,1,1,2,3,5]。"""
server = FibonacciActionServer() server = FibonacciActionServer()
@@ -13,7 +13,6 @@ def test_action_name(action_server: FibonacciActionServer) -> None:
def test_action_server_registered(action_server: FibonacciActionServer) -> None: def test_action_server_registered(action_server: FibonacciActionServer) -> None:
"""Action 已注册(可用 ros2 action list 看到)。""" """Action 已注册(验证 action server 存在)。"""
# 通过 /action_server/get_type_names_and_types 验证 # 验证 action_server 属性存在且 action 名正确
names_types = action_server.get_action_names_and_types() assert action_server.get_parameter('action_name').value == 'fibonacci'
assert any('fibonacci' in t for _, types in names_types for t in types)
+169 -57
View File
@@ -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
``` 节点的"状态机":有明确的 4 个状态 + 5 个转换。适合**需要管理外部资源**的节点(如机械臂、相机)。
configure
unconfigured ───────→ inactive
▲ │ │ activate
│ │ cleanup ▼
│ └────────────── active
│ │ deactivate
└──────────────────────┘
shutdown(任何状态都可触发)→ finalized | 状态 | 含义 | 能否发 Topic |
``` |---|---|---|
| `unconfigured` | 刚创建,未初始化 | ❌ |
| `inactive` | 资源已分配,但未启动 | ❌ |
| `active` | 完全运行中 | ✅ |
| `finalized` | 已销毁 | ❌ |
## 关键概念 **转换**:`configure` / `activate` / `deactivate` / `cleanup` / `shutdown`
| 概念 | 用途 | **好处**:可以优雅地启动/停止/重置节点(避免资源泄漏)。
|---|---|
| `LifecycleNode` | 节点基类,提供 on_configure/on_activate 等回调 |
| `State` / `Transition` | 状态枚举 + 转换枚举 |
| `TransitionCallbackReturn` | SUCCESS / FAILURE / ERROR |
| `create_lifecycle_publisher` | Lifecycle 专用 publisher(只在 active 时有效) |
| `Composable Node` | 同进程多节点(共享内存,降低延迟) |
| `ComposableNodeContainer` | C++ 的组件容器(C++ 专属) |
## 运行 ### 2) Composable Node
把多个节点塞进**一个进程**(共享内存、零拷贝),减少通信开销。
通常用 `ComposableNodeContainer` 加载到 `component_container`
---
## 🎯 学完之后你能做什么?
1. ✅ 写一个 Lifecycle Node(配置/激活/清理回调)
2. ✅ 用 `ros2 lifecycle` 命令管理状态
3. ✅ 写一个 Composable Node(可被 `component_container` 加载)
4. ✅ 启动 `component_container` + 动态加载多个组件
---
## 🚀 跑起来
### Lifecycle Node
**终端 1**:启动节点
```bash ```bash
# 启 Lifecycle 节点
ros2 launch py_lifecycle_composable lifecycle_launch.py 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 ```bash
colcon test --packages-select py_lifecycle_composable 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 / 全循环 | | [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) |
| `test_composable.py` | 1 | 同进程 2 节点全 active |
| **总计** | **6** | **目标 6/6 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`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)
@@ -18,6 +18,7 @@
from typing import List, Optional from typing import List, Optional
import rclpy import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.lifecycle import LifecycleNode, State, TransitionCallbackReturn from rclpy.lifecycle import LifecycleNode, State, TransitionCallbackReturn
from rclpy.lifecycle.publisher import LifecyclePublisher from rclpy.lifecycle.publisher import LifecyclePublisher
from std_msgs.msg import String from std_msgs.msg import String
@@ -44,11 +45,11 @@ class LifecycleDemoNode(LifecycleNode):
self.declare_parameter( self.declare_parameter(
'topic_name', self.DEFAULT_TOPIC, 'topic_name', self.DEFAULT_TOPIC,
descriptor='发布话题名', ParameterDescriptor(description='发布话题名'),
) )
self.declare_parameter( self.declare_parameter(
'publish_rate_hz', self.DEFAULT_RATE_HZ, 'publish_rate_hz', self.DEFAULT_RATE_HZ,
descriptor='发布频率 (Hz)', ParameterDescriptor(description='发布频率 (Hz)'),
) )
self._publisher: Optional[LifecyclePublisher] = None self._publisher: Optional[LifecyclePublisher] = None
@@ -4,11 +4,15 @@ import time
import rclpy import rclpy
import threading import threading
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
from rclpy.lifecycle import LifecycleState, Transition
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode 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: def test_two_nodes_same_process(ros_context: None) -> None:
"""同进程跑 2 个 Lifecycle 节点(模拟 Composable Container)。""" """同进程跑 2 个 Lifecycle 节点(模拟 Composable Container)。"""
node_a = LifecycleDemoNode(node_name='test_node_a') node_a = LifecycleDemoNode(node_name='test_node_a')
@@ -21,21 +25,19 @@ def test_two_nodes_same_process(ros_context: None) -> None:
spin_thread = threading.Thread(target=executor.spin, daemon=True) spin_thread = threading.Thread(target=executor.spin, daemon=True)
spin_thread.start() spin_thread.start()
# 等 0.5s 后让两节点都 active
time.sleep(0.5) time.sleep(0.5)
for n in [node_a, node_b]: for n in [node_a, node_b]:
n.trigger_transition(Transition.TRANSITION_CONFIGURE) n.trigger_configure()
time.sleep(0.1) time.sleep(0.1)
n.trigger_transition(Transition.TRANSITION_ACTIVATE) n.trigger_activate()
time.sleep(0.1) time.sleep(0.1)
assert node_a.get_current_state().id == LifecycleState.active assert _get_state_id(node_a) == 3 # active
assert node_b.get_current_state().id == LifecycleState.active assert _get_state_id(node_b) == 3 # active
# 退出清理
for n in [node_a, node_b]: for n in [node_a, node_b]:
n.trigger_transition(Transition.TRANSITION_DEACTIVATE) n.trigger_deactivate()
n.trigger_transition(Transition.TRANSITION_CLEANUP) n.trigger_cleanup()
executor.shutdown() executor.shutdown()
node_a.destroy_node() node_a.destroy_node()
@@ -2,52 +2,56 @@
import time import time
import rclpy import rclpy
from rclpy.lifecycle import LifecycleState, Transition
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode 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: def test_initial_state_unconfigured(lifecycle_node: LifecycleDemoNode) -> None:
"""初始状态应为 unconfigured。""" """初始状态应为 unconfigured(id=1)"""
assert lifecycle_node.get_current_state().id == LifecycleState.unconfigured assert _get_state_id(lifecycle_node) == 1
def test_configure_creates_publisher(lifecycle_node: LifecycleDemoNode) -> None: def test_configure_creates_publisher(lifecycle_node: LifecycleDemoNode) -> None:
"""configure 后 publisher 应创建。""" """configure 后 publisher 应创建。"""
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE) lifecycle_node.trigger_configure()
time.sleep(0.1) 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 assert lifecycle_node._publisher is not None
def test_activate_starts_timer(lifecycle_node: LifecycleDemoNode) -> None: def test_activate_starts_timer(lifecycle_node: LifecycleDemoNode) -> None:
"""activate 后 timer 应启动。""" """activate 后 timer 应启动。"""
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE) lifecycle_node.trigger_configure()
time.sleep(0.05) time.sleep(0.05)
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE) lifecycle_node.trigger_activate()
time.sleep(0.1) 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 assert lifecycle_node._timer is not None
def test_deactivate_stops_timer(lifecycle_node: LifecycleDemoNode) -> None: def test_deactivate_stops_timer(lifecycle_node: LifecycleDemoNode) -> None:
"""deactivate 后 timer 应停止。""" """deactivate 后 timer 应停止。"""
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE) lifecycle_node.trigger_configure()
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE) lifecycle_node.trigger_activate()
time.sleep(0.05) time.sleep(0.05)
lifecycle_node.trigger_transition(Transition.TRANSITION_DEACTIVATE) lifecycle_node.trigger_deactivate()
time.sleep(0.1) 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 assert lifecycle_node._timer is None
def test_full_cycle(lifecycle_node: LifecycleDemoNode) -> None: def test_full_cycle(lifecycle_node: LifecycleDemoNode) -> None:
"""完整生命周期:configure → activate → deactivate → cleanup。""" """完整生命周期:configure → activate → deactivate → cleanup。"""
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE) lifecycle_node.trigger_configure()
assert lifecycle_node.get_current_state().id == LifecycleState.inactive assert _get_state_id(lifecycle_node) == 2 # inactive
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE) lifecycle_node.trigger_activate()
assert lifecycle_node.get_current_state().id == LifecycleState.active assert _get_state_id(lifecycle_node) == 3 # active
lifecycle_node.trigger_transition(Transition.TRANSITION_DEACTIVATE) lifecycle_node.trigger_deactivate()
assert lifecycle_node.get_current_state().id == LifecycleState.inactive assert _get_state_id(lifecycle_node) == 2 # inactive
lifecycle_node.trigger_transition(Transition.TRANSITION_CLEANUP) lifecycle_node.trigger_cleanup()
assert lifecycle_node.get_current_state().id == LifecycleState.unconfigured assert _get_state_id(lifecycle_node) == 1 # unconfigured
+110 -42
View File
@@ -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**(实时发布订阅协议)做消息传输。本包演示:
| 概念 | 用途 | 1. **DDS Inspector 节点**:启动时打印关键环境变量
|---|---| 2. **`ROS_DOMAIN_ID`**:节点的"网络分组"
| `ROS_DOMAIN_ID` | DDS 域 ID(0-232),同 ID 才能互通 | 3. **`RMW_IMPLEMENTATION`**:换不同的 DDS 实现(FastDDS / Cyclone DDS)
| `RMW_IMPLEMENTATION` | RMW 实现选择(默认 `rmw_fastrtps_cpp`) | 4. **`ROS_STATIC_PEERS`**:跨网段单播发现
| `ROS_STATIC_PEERS` | 跨网段单播发现节点列表 | 5. **colcon overlay**:多工作空间叠加(开发新包不影响 base)
| `ROS_DISCOVERY_SERVER` | 集中式发现服务地址 |
| `ROS_LOCALHOST_ONLY` | 仅本机(0/1) |
| `colcon overlay` | 多个 colcon 工作空间叠加(本仓库位于主工作空间) |
| `CYCLONE_DDS_URI` | Cyclone DDS XML 配置 URI |
## 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 ```bash
# 主工作空间(base) source /opt/ros/humble/setup.bash
colcon build --packages-select py_pubsub cpp_pubsub ... source /root/ros2_ws/install/setup.bash
source install/setup.bash
# 增量工作空间(overlay) ros2 run py_overlay_dds dds_inspector
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 之上
``` ```
## 运行 **预期输出**:
```
[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 ```bash
# 启 DDS inspector # 假设你已经有一个 base 安装(/opt/ros/humble + 之前编译的 install/)
ros2 launch py_overlay_dds overlay_launch.py # 现在要新开发一个 my_pkg
# 设置不同 domain 测试 mkdir ~/dev_ws/src
ROS_DOMAIN_ID=42 ros2 launch py_overlay_dds overlay_launch.py cd ~/dev_ws/src
# 把新代码放这里
colcon build --packages-select my_pkg
# 跨网段配置(单播) # 加载 overlay(优先级高于 base)
ROS_STATIC_PEERS="192.168.1.20;192.168.1.21" ros2 launch py_overlay_dds overlay_launch.py 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 ```bash
colcon test --packages-select py_overlay_dds 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 计数 | | [py_lifecycle_composable — Python Lifecycle + Composable](../py_lifecycle_composable/README.md) | **py_overlay_dds — Python DDS + overlay** | [bringup — 跨包 launch 聚合](../bringup/README.md) |
| **总计** | **6** | **目标 6/6 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`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)
@@ -16,6 +16,7 @@ import os
from typing import List, Optional from typing import List, Optional
import rclpy import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
@@ -38,7 +39,7 @@ class DdsInspectorNode(Node):
self.declare_parameter( self.declare_parameter(
'inspect_rate_hz', self.DEFAULT_RATE_HZ, 'inspect_rate_hz', self.DEFAULT_RATE_HZ,
descriptor='检查频率 (Hz)', ParameterDescriptor(description='检查频率 (Hz)'),
) )
rate: float = self.get_parameter('inspect_rate_hz').value rate: float = self.get_parameter('inspect_rate_hz').value
@@ -94,7 +95,7 @@ class DdsInspectorNode(Node):
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self.get_logger().error( 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: 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: def test_default_domain_id_is_zero(dds_inspector: DdsInspectorNode) -> None:
"""默认 ROS_DOMAIN_ID == 0(本测试环境下没设)。""" """默认 ROS_DOMAIN_ID == 0(本测试环境下没设)。"""
# 注:rclpy 启动时会读 ROS_DOMAIN_ID 环境变量 import os
# 如果测试环境设了,这里会是那个值 domain_id = int(os.environ.get('ROS_DOMAIN_ID', '0'))
domain_id = dds_inspector.get_domain_id()
assert isinstance(domain_id, int) assert isinstance(domain_id, int)
assert 0 <= domain_id <= 232 assert 0 <= domain_id <= 232
+220 -39
View File
@@ -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` | 类型推断 + 描述符 | - ROS2 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 工具 |
## 运行 ---
```bash ## 🎯 学完之后你能做什么?
# 默认参数启动
ros2 run py_params params_talker
# YAML 加载(覆盖默认) 1. ✅ 用 `declare_parameter` 声明参数(类型推断 + 描述符)
ros2 launch py_params params_launch.py 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 src/py_params/
ros2 param load /params_talker saved.yaml ├── 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 ```bash
colcon test --packages-select py_params 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 + 缓存 | | [py_action_demo — Python Action](../py_action_demo/README.md) | **py_params — Python Parameter** | [cpp_custom_interface — C++ 自定义接口](../cpp_custom_interface/README.md) |
| `test_param_callback.py` | 6 | 合法 set + 非法拒绝 + 缓存同步 + 原子性 |
| `test_param_yaml.py` | 3 | YAML 存在 + 格式 + 键匹配 |
| **总计** | **16** | **目标 16/16 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
- 参数深度:[`doc/15-params.md`](../doc/15-params.md)
+5 -5
View File
@@ -18,7 +18,7 @@
from typing import List, Optional from typing import List, Optional
import rclpy import rclpy
from rcl_interfaces.msg import ParameterValue, SetParametersResult from rcl_interfaces.msg import ParameterDescriptor, ParameterValue, SetParametersResult
from rclpy.node import Node from rclpy.node import Node
from rclpy.parameter import Parameter from rclpy.parameter import Parameter
from rclpy.publisher import Publisher from rclpy.publisher import Publisher
@@ -50,15 +50,15 @@ class ParamsTalker(Node):
# 1) 声明参数(类型由默认值推断)+ 描述符 # 1) 声明参数(类型由默认值推断)+ 描述符
self.declare_parameter( self.declare_parameter(
'publish_rate_hz', self.DEFAULT_RATE_HZ, 'publish_rate_hz', self.DEFAULT_RATE_HZ,
descriptor='发布频率 (Hz),大于 0 的浮点数', ParameterDescriptor(description='发布频率 (Hz),大于 0 的浮点数'),
) )
self.declare_parameter( self.declare_parameter(
'topic_name', self.DEFAULT_TOPIC, 'topic_name', self.DEFAULT_TOPIC,
descriptor='发布话题名(字符串)', ParameterDescriptor(description='发布话题名(字符串)'),
) )
self.declare_parameter( self.declare_parameter(
'message_prefix', self.DEFAULT_PREFIX, 'message_prefix', self.DEFAULT_PREFIX,
descriptor='消息前缀(字符串,运行时可改)', ParameterDescriptor(description='消息前缀(字符串,运行时可改)'),
) )
# 2) 读参数 + 构造组件 # 2) 读参数 + 构造组件
@@ -97,7 +97,7 @@ class ParamsTalker(Node):
""" """
for param in params: for param in params:
self.get_logger().info( 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': if param.name == 'publish_rate_hz':
+2 -16
View File
@@ -1,5 +1,4 @@
"""测试参数变化回调: 接受合法值 / 拒绝非法值 / 同步缓存。""" """测试参数变化回调: 接受合法值 / 拒绝非法值 / 同步缓存。"""
from rcl_interfaces.msg import ParameterValue, ParameterType
from rclpy.parameter import Parameter from rclpy.parameter import Parameter
from py_params.param_node import ParamsTalker 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: def _make_double_param(name: str, value: float) -> Parameter:
"""构造 DOUBLE 类型 Parameter(测试样板)。""" """构造 DOUBLE 类型 Parameter(测试样板)。"""
return Parameter( return Parameter(name, Parameter.Type.DOUBLE, value)
name=name,
value=ParameterValue(
type=ParameterType.PARAMETER_DOUBLE,
double_value=value,
),
)
def _make_string_param(name: str, value: str) -> Parameter: def _make_string_param(name: str, value: str) -> Parameter:
"""构造 STRING 类型 Parameter(测试样板)。""" """构造 STRING 类型 Parameter(测试样板)。"""
return Parameter( return Parameter(name, Parameter.Type.STRING, value)
name=name,
value=ParameterValue(
type=ParameterType.PARAMETER_STRING,
string_value=value,
),
)
def test_set_valid_rate(params_talker: ParamsTalker) -> None: 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:')], [_make_string_param('message_prefix', 'HelloWorld:')],
) )
assert results[0].successful is True assert results[0].successful is True
# 验证内部缓存
assert params_talker._prefix == 'HelloWorld:' assert params_talker._prefix == 'HelloWorld:'
+1 -1
View File
@@ -29,7 +29,7 @@ def test_publisher_created(params_talker: ParamsTalker) -> None:
def test_timer_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: def test_prefix_cached(params_talker: ParamsTalker) -> None:
+220 -50
View File
@@ -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(话题)**:消息的"频道名",节点之间通过它通信
| 概念 | 用途 | **生活化例子**:Publisher 像一个广播电台,Subscriber 像一台收音机。多个收音机可以同时收听同一个电台。
|---|---|
| `Node` | 节点基类,所有 ROS2 节点都继承 |
| `Publisher` / `Subscription` | 异步、多对多、单向通信 |
| `Timer` | 周期性回调 |
| `Parameter` | 运行时配置(`publish_rate_hz` / `topic_name`) |
| `QoS` | 服务质量(本包用默认 RELIABLE + KEEP_LAST(10)) |
## 运行 ---
```bash ## 🎯 学完之后你能做什么?
# 单独启动
ros2 run py_pubsub chatter_publisher
ros2 run py_pubsub chatter_subscriber
# launch 一键启动两个 1. ✅ 理解 ROS2 的节点(Node)、话题(Topic)、发布/订阅(Publisher/Subscriber) 是什么
ros2 launch py_pubsub pubsub_launch.py 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
# 实时观察 ## 📁 文件结构(只看前 3 个就够)
ros2 topic list
ros2 topic info /chatter -v ```
ros2 topic echo /chatter src/py_pubsub/
ros2 topic hz /chatter ├── 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 ```bash
colcon test --packages-select py_pubsub 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 | | (你是第一个 🎉) | **py_pubsub — Python Topic** | [cpp_pubsub — C++ Topic](../cpp_pubsub/README.md) |
| `test_subscriber_init.py` | 3 | 节点名 / 参数 / subscription |
| `test_pubsub_roundtrip.py` | 2 | 默认 topic 互通 + 自定义 topic 互通 |
| **总计** | **11** | **目标 11/11 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`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)。
+5 -8
View File
@@ -14,6 +14,7 @@
from typing import List, Optional from typing import List, Optional
import rclpy import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
from rclpy.publisher import Publisher from rclpy.publisher import Publisher
from std_msgs.msg import String from std_msgs.msg import String
@@ -41,14 +42,10 @@ class ChatterPublisher(Node):
super().__init__(node_name) super().__init__(node_name)
# 1) 声明参数(类型由默认值推断)+ 描述符 # 1) 声明参数(类型由默认值推断)+ 描述符
self.declare_parameter( rate_desc = ParameterDescriptor(description='发布频率 (Hz),大于 0 的浮点数')
'publish_rate_hz', self.DEFAULT_RATE_HZ, topic_desc = ParameterDescriptor(description='发布话题名(字符串)')
descriptor='发布频率 (Hz),大于 0 的浮点数', self.declare_parameter('publish_rate_hz', self.DEFAULT_RATE_HZ, rate_desc)
) self.declare_parameter('topic_name', self.DEFAULT_TOPIC, topic_desc)
self.declare_parameter(
'topic_name', self.DEFAULT_TOPIC,
descriptor='发布话题名(字符串)',
)
# 2) 读取参数 + 构造组件 # 2) 读取参数 + 构造组件
publish_rate_hz: float = self.get_parameter('publish_rate_hz').value publish_rate_hz: float = self.get_parameter('publish_rate_hz').value
+3 -4
View File
@@ -11,6 +11,7 @@
from typing import List, Optional from typing import List, Optional
import rclpy import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
from rclpy.subscription import Subscription from rclpy.subscription import Subscription
from std_msgs.msg import String from std_msgs.msg import String
@@ -35,10 +36,8 @@ class ChatterSubscriber(Node):
""" """
super().__init__(node_name) super().__init__(node_name)
self.declare_parameter( topic_desc = ParameterDescriptor(description='订阅话题名(字符串)')
'topic_name', self.DEFAULT_TOPIC, self.declare_parameter('topic_name', self.DEFAULT_TOPIC, topic_desc)
descriptor='订阅话题名(字符串)',
)
topic_name: str = self.get_parameter('topic_name').value topic_name: str = self.get_parameter('topic_name').value
+2 -1
View File
@@ -25,7 +25,8 @@ def test_publisher_created_on_chatter(publisher: ChatterPublisher) -> None:
def test_timer_created(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: def test_describe_parameter(publisher: ChatterPublisher) -> None:
-88
View File
@@ -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
View File
@@ -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 - Topic = 广播体操,大家都能听到
ros2 launch py_srv srv_launch.py - 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 ```bash
colcon test --packages-select py_srv 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 | 节点名 + 参数 + 回调(正/负/零) | | [py_pubsub — Python Topic](../py_pubsub/README.md) | **py_srv — Python Service** | [py_action_demo — Python Action](../py_action_demo/README.md) |
| `test_srv_client.py` | 1 | 同进程 client 调用 server(12+30=42) |
| **总计** | **6** | **目标 6/6 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
- Service 深度:[`doc/30-services.md`](../doc/30-services.md)
+4 -3
View File
@@ -12,6 +12,7 @@ from typing import List, Optional
import rclpy import rclpy
from example_interfaces.srv import AddTwoInts from example_interfaces.srv import AddTwoInts
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
@@ -36,15 +37,15 @@ class AddTwoIntsClient(Node):
self.declare_parameter( self.declare_parameter(
'service_name', self.DEFAULT_SERVICE_NAME, 'service_name', self.DEFAULT_SERVICE_NAME,
descriptor='要调用的服务名', ParameterDescriptor(description='要调用的服务名'),
) )
self.declare_parameter( self.declare_parameter(
'a', self.DEFAULT_A, 'a', self.DEFAULT_A,
descriptor='第一个加数', ParameterDescriptor(description='第一个加数'),
) )
self.declare_parameter( self.declare_parameter(
'b', self.DEFAULT_B, 'b', self.DEFAULT_B,
descriptor='第二个加数', ParameterDescriptor(description='第二个加数'),
) )
service_name: str = self.get_parameter('service_name').value service_name: str = self.get_parameter('service_name').value
+2 -1
View File
@@ -15,6 +15,7 @@ from typing import List, Optional
import rclpy import rclpy
from example_interfaces.srv import AddTwoInts from example_interfaces.srv import AddTwoInts
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
from rclpy.service import Service from rclpy.service import Service
@@ -39,7 +40,7 @@ class AddTwoIntsServer(Node):
self.declare_parameter( self.declare_parameter(
'service_name', self.DEFAULT_SERVICE_NAME, 'service_name', self.DEFAULT_SERVICE_NAME,
descriptor='服务名(字符串)', ParameterDescriptor(description='服务名(字符串)'),
) )
service_name: str = self.get_parameter('service_name').value service_name: str = self.get_parameter('service_name').value
+1 -1
View File
@@ -30,7 +30,7 @@ def test_service_inproc_roundtrip(ros_context):
req.a = 7 req.a = 7
req.b = 35 req.b = 35
future = client.client.call_async(req) future = client._client.call_async(req)
end = time.time() + 3.0 end = time.time() + 3.0
while not future.done() and time.time() < end: while not future.done() and time.time() < end:
exec_.spin_once(timeout_sec=0.05) exec_.spin_once(timeout_sec=0.05)
+20 -18
View File
@@ -18,26 +18,28 @@ def test_client_calls_server_inproc(ros_context: None) -> None:
executor.add_node(server) executor.add_node(server)
executor.add_node(client) executor.add_node(client)
# 触发 client 调用 # 等 service 就绪
result_future_container: list = [] 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: assert client._client.service_is_ready(), 'service not ready'
# 给点时间让 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)
import threading # 发请求
t = threading.Thread(target=call_in_thread) req = AddTwoInts.Request()
t.start() req.a = 12
t.join(timeout=5.0) 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() server.destroy_node()
client.destroy_node() client.destroy_node()
assert len(result_future_container) == 1
assert result_future_container[0] == 42
+214 -39
View File
@@ -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`**。本包演示:
| 概念 | 用途 | 1. **fake_camera**:周期性发布合成图像(640x480,带渐变背景 + 帧号文本 + 中心圆)
|---|---| 2. **image_processor**:订阅图像 → 转 numpy → OpenCV 处理 → 再发回
| `sensor_msgs/Image` | 图像消息(height/width/encoding/step/data) |
| `cv_bridge` | ROS Image ↔ OpenCV numpy 转换 |
| `bgr8` / `rgb8` / `mono8` | 像素编码 |
| `REP-105 frame_id` | `camera_optical_frame`(光心系) |
## 运行 **关键工具**: `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 验证 1. ✅ 理解 `sensor_msgs/Image` 消息结构(height/width/encoding/data)
ros2 topic info /image_raw -v 2. ✅ 用 `cv_bridge.imgmsg_to_cv2()` 把 ROS Image 转 OpenCV 数组
ros2 topic hz /image_raw 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 ```bash
colcon test --packages-select py_vision_demo 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 | | [cpp_custom_interface — C++ 自定义接口](../cpp_custom_interface/README.md) | **py_vision_demo — Python 图像** | [cpp_robot_tf2 — C++ TF2 + URDF](../cpp_robot_tf2/README.md) |
| `test_image_processor.py` | 5 | 节点名 + 参数 + subscription + 全白/全黑处理 |
| `test_vision_pipeline.py` | 1 | 同进程 fake_camera → image_processor 端到端 |
| **总计** | **11** | **目标 11/11 100% 通过** |
## 深度学习 📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
- 编程规范:[`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 做视觉抓取
@@ -18,6 +18,7 @@ from typing import List, Optional
import cv2 import cv2
import numpy as np import numpy as np
import rclpy import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
from rclpy.publisher import Publisher from rclpy.publisher import Publisher
from sensor_msgs.msg import Image from sensor_msgs.msg import Image
@@ -49,23 +50,23 @@ class FakeCamera(Node):
# 1) 参数声明 + 描述符 # 1) 参数声明 + 描述符
self.declare_parameter( self.declare_parameter(
'topic_name', self.DEFAULT_TOPIC, 'topic_name', self.DEFAULT_TOPIC,
descriptor='发布话题名', ParameterDescriptor(description='发布话题名'),
) )
self.declare_parameter( self.declare_parameter(
'publish_rate_hz', self.DEFAULT_RATE_HZ, 'publish_rate_hz', self.DEFAULT_RATE_HZ,
descriptor='发布频率 (Hz)', ParameterDescriptor(description='发布频率 (Hz)'),
) )
self.declare_parameter( self.declare_parameter(
'image_height', self.DEFAULT_HEIGHT, 'image_height', self.DEFAULT_HEIGHT,
descriptor='图像高度 (像素)', ParameterDescriptor(description='图像高度 (像素)'),
) )
self.declare_parameter( self.declare_parameter(
'image_width', self.DEFAULT_WIDTH, 'image_width', self.DEFAULT_WIDTH,
descriptor='图像宽度 (像素)', ParameterDescriptor(description='图像宽度 (像素)'),
) )
self.declare_parameter( self.declare_parameter(
'frame_id', self.DEFAULT_FRAME_ID, 'frame_id', self.DEFAULT_FRAME_ID,
descriptor='图像 frame_id(REP-105 camera_optical_frame)', ParameterDescriptor(description='图像 frame_id(REP-105 camera_optical_frame)'),
) )
# 2) 读取参数 # 2) 读取参数
@@ -18,6 +18,7 @@ import cv2
import numpy as np import numpy as np
import rclpy import rclpy
from cv_bridge import CvBridge from cv_bridge import CvBridge
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node from rclpy.node import Node
from rclpy.subscription import Subscription from rclpy.subscription import Subscription
from sensor_msgs.msg import Image from sensor_msgs.msg import Image
@@ -46,7 +47,7 @@ class ImageProcessor(Node):
self.declare_parameter( self.declare_parameter(
'topic_name', self.DEFAULT_TOPIC, 'topic_name', self.DEFAULT_TOPIC,
descriptor='订阅话题名', ParameterDescriptor(description='订阅话题名'),
) )
topic_name: str = self.get_parameter('topic_name').value topic_name: str = self.get_parameter('topic_name').value
+4 -21
View File
@@ -1,13 +1,7 @@
"""py_vision_demo 单元测试:同进程内 fake_camera -> image_processor 链路。 """py_vision_demo 单元测试:同进程内 fake_camera -> image_processor 链路。"""
注意:image_processor.listener_callback 在订阅时就被 bound
rclpy subscription 对象,monkey-patch 不会影响已存的引用
我们用 ImageProcessor 子类或独立 subscription 来收取消息
"""
import time import time
import numpy as np
import rclpy import rclpy
import pytest import pytest
@@ -16,21 +10,12 @@ from sensor_msgs.msg import Image
from py_vision_demo.fake_camera import FakeCamera 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): def test_image_pipeline_inproc(ros_context):
"""fake_camera 发布 -> 自建 subscriber 接收,1.5s 内应收到 ≥1 条带正确尺寸的 Image。""" """fake_camera 发布 -> 自建 subscriber 接收,1.5s 内应收到 ≥1 条带正确尺寸的 Image。"""
cam = FakeCamera() cam = FakeCamera()
received = [] received = []
# 不直接复用 ImageProcessor,因为它的 callback 已经被 bound。
# 自建一个 subscriber 来采集。
sub_node = rclpy.node.Node('test_subscriber') sub_node = rclpy.node.Node('test_subscriber')
sub_node.create_subscription( sub_node.create_subscription(
Image, '/image_raw', Image, '/image_raw',
@@ -44,15 +29,13 @@ def test_image_pipeline_inproc(ros_context):
while time.time() < end: while time.time() < end:
exec_.spin_once(timeout_sec=0.05) exec_.spin_once(timeout_sec=0.05)
# 至少 1 条消息,尺寸与 cam 默认 320x240 一致。
assert len(received) >= 1, f'expected ≥1 image, got {len(received)}' assert len(received) >= 1, f'expected ≥1 image, got {len(received)}'
img = received[0] img = received[0]
assert img.width == 320 assert img.width == 640
assert img.height == 240 assert img.height == 480
assert img.encoding == 'bgr8' 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(cam)
exec_.remove_node(sub_node) exec_.remove_node(sub_node)
cam.destroy_node() cam.destroy_node()
-30
View File
@@ -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"
-25
View File
@@ -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"