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