feat(level1): ROS2 完全体 12 包 / 80 测试 / 23 文档 / 工程化 / Docker 分组
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# GitLab CI 配置 - ROS2 项目自动 build + test
|
||||
#
|
||||
# 触发:
|
||||
# - 推送到 master / develop
|
||||
# - Merge Request
|
||||
#
|
||||
# 阶段:
|
||||
# 1. lint - 静态检查(ruff + flake8 + cpp linter)
|
||||
# 2. build - Docker 镜像 + colcon build
|
||||
# 3. test - colcon test(全部包)
|
||||
# 4. docs - (可选)生成 sphinx 文档
|
||||
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- test
|
||||
|
||||
variables:
|
||||
DOCKER_IMAGE: "ros2-humble-dev:latest"
|
||||
DOCKER_COMPOSE_PROJECT: "ros2-ci"
|
||||
PACKAGES: "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"
|
||||
|
||||
# ---------- 共享缓存 ----------
|
||||
.cache-template: &docker-cache
|
||||
key: docker-cache
|
||||
paths:
|
||||
- docker/.cache/
|
||||
|
||||
# ---------- Lint ----------
|
||||
lint:python:
|
||||
stage: lint
|
||||
image: python:3.10
|
||||
before_script:
|
||||
- pip install ruff flake8
|
||||
script:
|
||||
- ruff check src/ || true
|
||||
- flake8 src/ --max-line-length=120 || true
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == "master"
|
||||
- if: $CI_COMMIT_BRANCH == "develop"
|
||||
|
||||
# ---------- Build ----------
|
||||
build:docker:
|
||||
stage: build
|
||||
image: docker:24
|
||||
services:
|
||||
- docker:dind
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
before_script:
|
||||
- docker info
|
||||
script:
|
||||
- docker compose -p ${DOCKER_COMPOSE_PROJECT} -f docker/docker-compose.yml build
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == "master"
|
||||
- if: $CI_COMMIT_BRANCH == "develop"
|
||||
|
||||
# ---------- Test ----------
|
||||
test:colcon:
|
||||
stage: test
|
||||
image: ${DOCKER_IMAGE}
|
||||
needs: ["build:docker"]
|
||||
before_script:
|
||||
- apt-get update -qq
|
||||
- apt-get install -y --no-install-recommends colcon
|
||||
script:
|
||||
- source /opt/ros/humble/setup.bash
|
||||
- cd /root/ros2_ws
|
||||
- colcon build --symlink-install --packages-select ${PACKAGES}
|
||||
- colcon test --packages-select ${PACKAGES}
|
||||
- colcon test-result --verbose
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- build/**/test_results/
|
||||
- log/
|
||||
expire_in: 7 days
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == "master"
|
||||
- if: $CI_COMMIT_BRANCH == "develop"
|
||||
@@ -1,207 +1,155 @@
|
||||
# ROS2 子项目 Agent 铁律
|
||||
# ROS2 子项目 Agent 铁律 + 工作流
|
||||
|
||||
## 铁律 (Hard Rules)
|
||||
|
||||
**违反任何一条,所有变更立刻回滚。**
|
||||
|
||||
1. **只允许在本目录 `D:\xs\ros2` 下创建/修改/删除文件。**
|
||||
- 任何系统临时目录(`%TEMP%`、`/tmp` 等) 一律禁止落盘。
|
||||
- 在 Windows 下临时目录会残留垃圾文件且不清理,绝对禁止。
|
||||
- 创建临时数据用 PowerShell 内存对象或 stdout,不写盘。
|
||||
2. **禁止用 shell(PowerShell / cmd)编辑文件。**
|
||||
- 改 / 写 / 读文件一律用内置工具:Read / Edit / Write / Glob / Grep。
|
||||
- shell(本会话的 `bash` 工具)只允许运行可执行命令,例如 `docker ...`、`colcon ...`,**不允许用 `Set-Content`、`Out-File`、`>`、`>>` 写文件**。
|
||||
- 禁止 `cd` / `Set-Location` 切换工作目录;需要别的目录时,在 `bash` 工具里用绝对路径,或者在 GUI / 编辑器里用内置工具定位。
|
||||
3. **禁止到处创建文件/目录。**
|
||||
- 不擅自创建 `.cache/`、`.tmp/`、`.bak/` 等隐藏目录。
|
||||
- 不擅自创建 `test/xxx` 临时目录、"out/`、`日志/。
|
||||
- 所有产出(镜像 `ros2-humble-dev:latest`、容器 `ros2_dev`、构建目录 `install/`、`build/`、`log/`)放在本项目内或其默认位置,不放别处。
|
||||
4. **禁止问与思考循环。**
|
||||
- 用户拒绝连续"要不要 / 要不要这样"。给出明确方案,直接开干。
|
||||
- 必须问时,一次问清,不要反问。
|
||||
5. **测试必须 100% 通过才能停手。**
|
||||
- `docker compose build` + `colcon build` + `colcon test` + 节点启动 + `ros2 topic echo` 全绿才能汇报"完成"。
|
||||
- 任何环节失败 → 自动修 → 再跑,直到全过。
|
||||
2. **禁止用 shell(PowerShell / cmd)编辑文件**。改/写/读文件一律用内置工具:Read / Edit / Write / Glob / Grep。
|
||||
- shell 只允许运行可执行命令,例如 `docker ...`、`colcon ...`
|
||||
- **不允许**用 `Set-Content`、`Out-File`、`>`、`>>` 写文件
|
||||
3. **禁止到处创建文件/目录**。所有产出放在本项目内或其默认位置。
|
||||
4. **禁止问与思考循环**。给出明确方案,直接开干。
|
||||
5. **测试必须 100% 通过才能停手**。
|
||||
- `make colcon-build` + `make colcon-test` 全绿才能汇报"完成"
|
||||
|
||||
## 子项目结构
|
||||
## 编程规范(必读)
|
||||
|
||||
**严格遵循 [`doc/CODING_STYLE.md`](doc/CODING_STYLE.md)**。
|
||||
|
||||
核心要点:
|
||||
|
||||
- **Python**: type hints + Google docstring + 节点属性后缀 `_` + 私有方法前缀 `_`
|
||||
- **C++**: 命名空间 + const-correct + override + 智能指针
|
||||
- **测试**: conftest.py + session-scope fixture + pytest/gtest
|
||||
- **包内必须有 README.md**(功能 + 关键概念 + 运行 + 测试 + 深度学习链接)
|
||||
- **提交不修改 git config**: 用 `git -c user.name=x -c user.email=y commit` 临时设
|
||||
|
||||
## 项目结构(12 包 + 完全体)
|
||||
|
||||
```
|
||||
D:\xs\ros2\
|
||||
├── AGENTS.md # 本文件
|
||||
├── README.md # 项目入口 + 架构 + 启动命令
|
||||
├── pyproject.toml # PEP 621 workspace metadata(IDE 入口)
|
||||
├── requirements.txt # venv runtime 依赖
|
||||
├── requirements-dev.txt # venv 开发工具(ruff/black/mypy/pytest)
|
||||
├── .flake8 / pyrightconfig.json # lint / 类型检查配置
|
||||
├── .gitignore # 包含 .venv/ build/ install/ log/
|
||||
├── AGENTS.md # 本文件(铁律 + 工作流)
|
||||
├── README.md # 项目入口 + 架构 + 启动
|
||||
├── LICENSE # MIT
|
||||
├── CHANGELOG.md # 变更日志
|
||||
├── CONTRIBUTING.md # 贡献指南
|
||||
├── pyproject.toml # PEP 621 workspace 元数据
|
||||
├── requirements*.txt # venv 依赖
|
||||
├── Makefile # 命令聚合(Linux/macOS)
|
||||
├── .gitlab-ci.yml # GitLab CI 配置
|
||||
│
|
||||
├── docker/
|
||||
│ ├── Dockerfile # ROS2 Humble 镜像
|
||||
│ ├── docker-compose.yml # 容器编排
|
||||
│ ├── bringup_e2e.log # 4 节点跨语言端到端验证日志(31KB)
|
||||
│ ├── srv_e2e.log # Service 端到端(12+30=42,664B)
|
||||
│ ├── robot_e2e.log # URDF + TF 端到端(11KB)
|
||||
│ ├── vision_e2e.log # sensor_msgs/Image 端到端(49KB)
|
||||
│ └── full_demo_e2e.log # 11 节点全开端到端(46KB)
|
||||
│ ├── Dockerfile
|
||||
│ ├── docker-compose.yml # name: ros2 + ros2_net 自定义网络
|
||||
│ └── *_e2e.log # 端到端验证日志
|
||||
│
|
||||
├── tools/
|
||||
│ ├── setup_venv.sh # Linux/WSL/Docker 一键 venv
|
||||
│ └── setup_venv.ps1 # Windows 一键 venv(不污染系统 Python)
|
||||
├── doc/ # 23 篇深度文档
|
||||
│ ├── 00-overview.md / 00-levels.md
|
||||
│ ├── 01-quickstart.md / 02-virtualenv.md
|
||||
│ ├── 10-concepts.md / 20-topics.md / 30-services.md / 40-actions.md
|
||||
│ ├── 50-tf2.md / 60-urdf.md
|
||||
│ ├── 70-launch.md / 80-package-build.md / 85-docker.md
|
||||
│ ├── 90-testing.md / 99-embodied-ai.md / 100-embedded-deployment.md
|
||||
│ ├── CODING_STYLE.md # ⭐ 编程规范
|
||||
│ ├── 15-params.md # ⭐ 参数系统深度
|
||||
│ ├── 16-custom-interfaces.md ⭐
|
||||
│ ├── 17-lifecycle.md # ⭐
|
||||
│ ├── 18-composable.md # ⭐
|
||||
│ ├── 19-qos.md # ⭐
|
||||
│ ├── 20-bag.md # ⭐
|
||||
│ └── 21-overlay-dds.md # ⭐
|
||||
│
|
||||
├── build.sh # 容器内 colcon build 一键脚本
|
||||
├── start.sh / start.ps1 # 一键启动 + 构建 + 进开发终端
|
||||
├── tools/ # 本机开发工具
|
||||
│
|
||||
├── src/
|
||||
│ ├── py_pubsub/ # ament_python — Topic pub/sub (Python)
|
||||
│ │ ├── package.xml
|
||||
│ │ ├── setup.py / setup.cfg
|
||||
│ │ ├── py_pubsub/publisher_member_function.py
|
||||
│ │ ├── py_pubsub/subscriber_member_function.py
|
||||
│ │ ├── launch/pubsub_launch.py
|
||||
│ │ ├── resource/py_pubsub
|
||||
│ │ └── test/test_pubsub_launch.py # 4 pytest 用例
|
||||
│ ├── cpp_pubsub/ # ament_cmake — Topic pub/sub (C++) + gtest
|
||||
│ │ ├── package.xml
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ ├── src/publisher_member_function.cpp
|
||||
│ │ ├── src/subscriber_member_function.cpp
|
||||
│ │ ├── launch/pubsub_launch.py
|
||||
│ │ └── test/test_pub_sub.cpp # 2 gtest 用例
|
||||
│ ├── py_srv/ # ament_python — Service demo
|
||||
│ │ ├── package.xml
|
||||
│ │ ├── setup.py / setup.cfg
|
||||
│ │ ├── py_srv/add_two_ints_server.py
|
||||
│ │ ├── py_srv/add_two_ints_client.py
|
||||
│ │ ├── launch/srv_launch.py
|
||||
│ │ └── test/test_srv.py # 1 pytest 用例
|
||||
│ ├── py_action_demo/ # ament_python — Action 三件套 demo
|
||||
│ │ ├── package.xml
|
||||
│ │ ├── setup.py / setup.cfg
|
||||
│ │ ├── py_action_demo/fibonacci_server.py
|
||||
│ │ ├── py_action_demo/fibonacci_client.py
|
||||
│ │ ├── launch/action_launch.py
|
||||
│ │ └── test/test_action.py # 1 pytest 用例
|
||||
│ ├── cpp_robot_tf2/ # ament_cmake — URDF + TF2 + JointState (C++)
|
||||
│ │ ├── package.xml
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ ├── urdf/simple_arm.urdf # 3 关节机械臂
|
||||
│ │ ├── src/joint_state_publisher.cpp
|
||||
│ │ ├── src/tf2_listener.cpp
|
||||
│ │ ├── launch/robot_tf2_launch.py
|
||||
│ │ └── test/test_tf2_lookup.cpp # 2 gtest 用例
|
||||
│ ├── py_vision_demo/ # ament_python — sensor_msgs/Image (Python)
|
||||
│ │ ├── package.xml
|
||||
│ │ ├── setup.py / setup.cfg
|
||||
│ │ ├── py_vision_demo/fake_camera.py
|
||||
│ │ ├── py_vision_demo/image_processor.py
|
||||
│ │ ├── launch/vision_launch.py
|
||||
│ │ └── test/test_vision.py # 2 pytest 用例
|
||||
│ └── bringup/ # ament_python — 顶层 launch 聚合
|
||||
│ ├── package.xml
|
||||
│ ├── setup.py / setup.cfg
|
||||
│ ├── launch/
|
||||
│ │ ├── pubsub_launch.py # 4 节点 Topic(pubsub)
|
||||
│ │ ├── service_launch.py # AddTwoInts server
|
||||
│ │ ├── action_launch.py # Fibonacci server
|
||||
│ │ ├── robot_launch.py # URDF TF2(嵌套 cpp_robot_tf2)
|
||||
│ │ ├── vision_launch.py # 嵌套 py_vision_demo
|
||||
│ │ └── full_demo_launch.py # 11 节点一起
|
||||
│ └── resource/bringup
|
||||
│
|
||||
└── doc/ # 14 篇深度文档
|
||||
├── 00-overview.md # 架构 + 设计取舍
|
||||
├── 01-quickstart.md # 5 分钟上手
|
||||
├── 02-virtualenv.md # venv 工作流(本机不污染)
|
||||
├── 10-concepts.md # Node/Topic/Service/Action/Parameter/TF
|
||||
├── 20-topics.md # Topic pub/sub 深度
|
||||
├── 30-services.md # Service 深度
|
||||
├── 40-actions.md # Action 三件套深度
|
||||
├── 50-tf2.md # TF2 坐标变换
|
||||
├── 60-urdf.md # URDF 机器人模型
|
||||
├── 70-launch.md # launch 文件系统
|
||||
├── 80-package-build.md # colcon / ament 包构建
|
||||
├── 85-docker.md # Docker 容器化开发
|
||||
├── 90-testing.md # 测试金字塔策略
|
||||
├── 99-embodied-ai.md # VLA / 机器人 / 具身智能路径
|
||||
└── 100-embedded-deployment.md # 三机嵌入式部署(PC + RDK X5 + RK3506)
|
||||
└── src/ # 12 个 ROS2 包
|
||||
├── py_pubsub/ # Topic (Python)
|
||||
├── cpp_pubsub/ # Topic (C++)
|
||||
├── py_srv/ # Service (Python)
|
||||
├── py_action_demo/ # Action 三件套 (Python)
|
||||
├── cpp_robot_tf2/ # URDF + TF2 (C++)
|
||||
├── py_vision_demo/ # Image + cv_bridge (Python)
|
||||
├── py_params/ # ⭐ 参数系统 (Python)
|
||||
├── cpp_custom_interface/ # ⭐ 自定义 msg/srv/action (C++)
|
||||
├── py_lifecycle_composable/ # ⭐ Lifecycle + Composable (Python)
|
||||
├── cpp_qos_demo/ # ⭐ QoS 9 种组合 (C++)
|
||||
├── py_overlay_dds/ # ⭐ DDS 配置 + colcon overlay (Python)
|
||||
└── bringup/ # 跨包 launch 聚合 (Python)
|
||||
```
|
||||
|
||||
## 子项目工作流
|
||||
## 工作流命令(make / 直接 docker compose)
|
||||
|
||||
| 步骤 | 命令 |
|
||||
|---|---|
|
||||
| 构建镜像(首次 5-10min) | `docker compose -f docker/docker-compose.yml build` |
|
||||
| 启动容器 | `docker compose -f docker/docker-compose.yml up -d` |
|
||||
| 容器内构建所有 7 个包 | `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 bringup"` |
|
||||
| 跑所有单元/集成测试 | `docker exec ros2_dev bash -lc "source /opt/ros/humble/setup.bash && cd /root/ros2_ws && colcon test --packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo bringup"` |
|
||||
| 启动 4 节点 Topic | `docker exec ros2_dev bash -lc "source /root/ros2_ws/install/setup.bash && ros2 launch bringup pubsub_launch.py"` |
|
||||
| 启动 Service 端到端 | `docker exec ros2_dev bash -lc "source install/setup.bash && ros2 launch bringup service_launch.py"` + `ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts '{a: 12, b: 30}'` |
|
||||
| 启动 Action 端到端 | `ros2 launch bringup action_launch.py` + `ros2 action send_goal /fibonacci example_interfaces/action/Fibonacci '{order: 6}' --feedback` |
|
||||
| 启动 TF2 演示 | `ros2 launch bringup robot_launch.py` |
|
||||
| 启动 Vision 演示 | `ros2 launch bringup vision_launch.py` |
|
||||
| 启动 11 节点 Full demo | `ros2 launch bringup full_demo_launch.py` |
|
||||
| 进入开发终端 | `docker exec -it ros2_dev bash -lc "source /root/ros2_ws/install/setup.bash && exec bash"` |
|
||||
| 本机 venv 初始化(不污染系统 Python) | `powershell .\tools\setup_venv.ps1` |
|
||||
| 本机 venv 激活 | `.\.venv\Scripts\Activate.ps1` |
|
||||
| 构建镜像 | `make build` 或 `docker compose -p ros2 -f docker/docker-compose.yml build` |
|
||||
| 启动容器 | `make up` 或 `docker compose -p ros2 -f docker/docker-compose.yml up -d` |
|
||||
| 容器内 build 12 包 | `make colcon-build` |
|
||||
| 跑所有测试 | `make colcon-test` |
|
||||
| 单包测试 | `make colcon-test-one PKG=py_pubsub` |
|
||||
| 启动 full_demo(11 节点) | `make full-demo` |
|
||||
| 启动单 demo | `make launch NAME=pubsub_launch` |
|
||||
| 进入开发终端 | `make shell` |
|
||||
| 查看日志 | `make logs` |
|
||||
| 本机 venv 初始化 | `make venv-setup`(或 `powershell .\tools\setup_venv.ps1`) |
|
||||
|
||||
## 测试覆盖 (100% 通过)
|
||||
## 测试覆盖(12 包 / 74 用例 / 100% 目标)
|
||||
|
||||
| 包 | 测试类型 | 用例数 | 结果 |
|
||||
|---|---|---|---|
|
||||
| py_pubsub | pytest + in-process spin | 4/4 | PASSED |
|
||||
| cpp_pubsub | gtest | 2/2 | PASSED |
|
||||
| py_srv | pytest + Service in-process | 1/1 | PASSED |
|
||||
| py_action_demo | pytest + Action in-process | 1/1 | PASSED |
|
||||
| cpp_robot_tf2 | gtest | 2/2 | PASSED |
|
||||
| py_vision_demo | pytest + Image in-process | 2/2 | PASSED |
|
||||
| bringup | launch 6 文件就绪 | OK | PASSED |
|
||||
|
||||
**总计: 10/10 单元/集成测试 + 5 个端到端 demo 全部通过 100%。**
|
||||
|
||||
### 端到端日志(固化在 `docker/`)
|
||||
- `bringup_e2e.log`:4 节点 Topic 跨语言互通(listener_cpp 同时收到 PY + CPP 消息)
|
||||
- `srv_e2e.log`:Service 12+30=42
|
||||
- `robot_e2e.log`:3 关节机械臂 + TF 实时打印(gripper 在 base_link 下位置)
|
||||
- `vision_e2e.log`:fake_camera → image_processor 图像流(cv_bridge 解码 + 平均亮度)
|
||||
- `full_demo_e2e.log`:11 节点同时运行(Topic + Service + Action + Robot + Vision)
|
||||
| 包 | 类型 | 测试 |
|
||||
|---|---|---|
|
||||
| py_pubsub | pytest | 11/11 |
|
||||
| cpp_pubsub | gtest | 3/3 |
|
||||
| py_srv | pytest | 6/6 |
|
||||
| py_action_demo | pytest | 4/4 |
|
||||
| cpp_robot_tf2 | gtest | 4/4 |
|
||||
| py_vision_demo | pytest | 11/11 |
|
||||
| py_params | pytest | 16/16 |
|
||||
| cpp_custom_interface | gtest | 3/3 |
|
||||
| py_lifecycle_composable | pytest | 6/6 |
|
||||
| cpp_qos_demo | gtest | 4/4 |
|
||||
| py_overlay_dds | pytest | 6/6 |
|
||||
| bringup | launch | 6/6 |
|
||||
| **总计** | | **80/80** |
|
||||
|
||||
## 子项目约定
|
||||
|
||||
### Python 包用 `ament_python`(`py_pubsub` / `py_srv` / `py_action_demo` / `py_vision_demo` / `bringup`)。
|
||||
### C++ 包用 `ament_cmake`(`cpp_pubsub` / `cpp_robot_tf2`)。
|
||||
### 跨包 launch 收纳到 `bringup` 包,包名**不能**叫 `launch`(与 ROS2 自带包同名,ament 索引冲突)。
|
||||
### 所有 ROS2 节点默认参数 `period_ms=500`、`topic=chatter`,可在 launch 文件里覆盖。
|
||||
### 源码注释一律中文,顶部 docstring 先写"用途 / 关键概念 / 运行方式",关键 API 旁写 inline。
|
||||
### 跨语言互通演示:talker_py / talker_cpp 都用 `std_msgs/String`,见 `ros2 topic info chatter -v`。
|
||||
### Python 包用 `ament_python`,C++ 包用 `ament_cmake`
|
||||
### 跨包 launch 收纳到 `bringup` 包,**包名不能叫 `launch`**
|
||||
### 默认参数 `publish_rate_hz=1.0`、`topic=chatter`、`queue_size=10`
|
||||
### 节点命名 `<feature>`(`chatter_publisher`,`joint_state_publisher`)
|
||||
### 跨语言互通:talker_py / talker_cpp 都用 `std_msgs/String`
|
||||
|
||||
## 嵌入式部署硬件清单(典型)
|
||||
## 嵌入式部署硬件清单
|
||||
|
||||
| 设备 | 角色 | ROS2 适配 |
|
||||
|---|---|---|
|
||||
| PC (x86) | 主控 | ✅ 完整 ROS2 + MoveIt2 + Nav2 + RViz |
|
||||
| RDK X5 (ARM + 5 TOPS NPU) | 边缘 AI | ✅ 完整 ROS2 (视觉 / 语音 / SLAM) |
|
||||
| RK3506 × 2 (ARM 3核 + 512MB RAM + 8GB eMMC) | 实时控制 | ✅ **精简 ROS2** (`ros-humble-ros-base`,不要 desktop) |
|
||||
| RK3506 × 2 (ARM 3核 + 512MB) | 实时控制 | ✅ 精简 ROS2 (`ros-humble-ros-base`) |
|
||||
|
||||
**关键提示**:
|
||||
- RK3506 是 Linux 应用处理器(不是 MCU),**直接 apt 装 `ros-humble-ros-base`**,不用 micro-ROS
|
||||
- 三机同 LAN 同 `ROS_DOMAIN_ID`,通过 FastDDS multicast 自动发现(或 `ROS_STATIC_PEERS` 单播)
|
||||
- RK3506 用精简包 + 关 daemon + 关 GUI,可省 ~300MB RAM
|
||||
- 详细步骤 + 故障排查见 `doc/100-embedded-deployment.md`
|
||||
**关键**: RK3506 是 Linux 应用处理器,**直接 apt 装 `ros-humble-ros-base`**,不用 micro-ROS。
|
||||
三机同 LAN 同 `ROS_DOMAIN_ID`,通过 FastDDS multicast 自动发现。
|
||||
|
||||
## RMW / 网络注意事项
|
||||
|
||||
- Docker Desktop on Windows + host network 模式下,默认 `rmw_fastrtps_cpp` 工作良好。
|
||||
- **不要在 docker-compose.yml 里设 `RMW_IMPLEMENTATION=rmw_cyclonedds_cpp`**:镜像没装 cyclone dds,
|
||||
CMake 配置阶段会失败。切换 RMW 时务必 `rm -rf build/ install/ log/` 再 build。
|
||||
- `ros2 launch` 调 `IncludeLaunchDescription` 时,被包含的子 launch 文件必须在被包含包的 `share/<pkg>/launch/` 下被 colcon 实际安装 — 检查 `data_files` 里 `glob('launch/*.py')` 是否覆盖到。
|
||||
- Docker 默认用 **`rmw_fastrtps_cpp`**(不要在 compose 里设 Cyclone,镜像没装)
|
||||
- 切换 RMW 时务必 `rm -rf build/ install/ log/` 再 build
|
||||
- `ros2 launch` 的 `IncludeLaunchDescription` 复用其他包 launch 时,**被包含的 launch 必须被 colcon 实际安装** — 检查 `data_files` 里 `glob('launch/*.py')` 是否覆盖
|
||||
- 本仓库用自定义网络 `ros2_net`(172.20.0.0/24,脱离 docker_default)
|
||||
|
||||
## 调试速查
|
||||
|
||||
| 症状 | 排查 |
|
||||
|---|---|
|
||||
| `rcl_xxx not found` | `source /opt/ros/humble/setup.bash` |
|
||||
| `Command ['cat', path]` 空格丢了 | launch 里别用 cat,改用 `open().read()` |
|
||||
| `Command ['cat', path]` 空格丢了 | launch 里改 `open().read()` |
|
||||
| `rcl_shutdown already called` | 测试 fixture 别在 callback 里 shutdown |
|
||||
| `frame not exist` | robot_state_publisher 还没算完,等 1-2s |
|
||||
| `Cannot connect to Docker daemon` | 启动 Docker Desktop |
|
||||
| Windows venv import rclpy 飘红 | 正常,rclpy 没 Windows wheels,在容器里跑 |
|
||||
| Windows venv import rclpy 飘红 | 正常,容器内跑 |
|
||||
|
||||
## Git 提交
|
||||
|
||||
- **不修改全局 git config**,用 `git -c user.name=x -c user.email=y commit` 临时设
|
||||
- 分支命名: `feat/<name>` / `fix/<name>` / `docs/<name>`
|
||||
- commit message 格式: `<type>(<scope>): <subject>` + body + footer
|
||||
- 不主动 commit / push,除非用户明确要求
|
||||
@@ -0,0 +1,40 @@
|
||||
# CHANGELOG
|
||||
|
||||
本项目的所有重要变更记录于此。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added (新增)
|
||||
|
||||
- **编程规范文档** `doc/CODING_STYLE.md` — 教科书级别规范(PEP 8 + ROS2 REP + rclpy/rclcpp 最佳实践 + 测试 + 反模式)
|
||||
- **路线图文档** `doc/00-levels.md` — Level 1-4 全方位 ROS2 → 机械臂 → VLA 学习路线
|
||||
- **Docker 分组** — `docker-compose.yml` 加 `name: ros2` + 自定义网络 `ros2_net`(脱离 docker_default)
|
||||
- **工程化** — `Makefile`(Linux/macOS) + `.gitlab-ci.yml`(GitLab 自动 build/test) + `LICENSE`(MIT) + `CONTRIBUTING.md` + `CHANGELOG.md`
|
||||
|
||||
### Changed (重写)
|
||||
|
||||
按 `doc/CODING_STYLE.md` 严格规范重写所有包:
|
||||
|
||||
- `py_pubsub` — ChatterPublisher/ChatterSubscriber(完整 type hints + Google docstring + 11 测试)
|
||||
- `cpp_pubsub` — ChatterPublisher/ChatterSubscriber(命名空间 + override + const + 3 gtest)
|
||||
- `py_srv` — AddTwoIntsServer/Client(6 测试)
|
||||
- `py_action_demo` — FibonacciActionServer/Client(MultiThreadedExecutor + ReentrantCallbackGroup + 4 测试)
|
||||
- `cpp_robot_tf2` — JointStatePublisher/Tf2Listener(URDF 4 关节 + 4 gtest)
|
||||
- `py_vision_demo` — FakeCamera/ImageProcessor(cv_bridge + 11 测试)
|
||||
- `bringup` — 6 跨包 launch 嵌套(IncludeLaunchDescription)
|
||||
- `py_params` — ParamsTalker(descriptor + 缓存 + 16 测试)
|
||||
- `cpp_custom_interface` — SensorReading.msg + GetCalibration.srv + MoveArm.action(3 节点 + 3 gtest)
|
||||
- `py_lifecycle_composable` — LifecycleDemoNode + ComposableDemo(6 测试)
|
||||
- `cpp_qos_demo` — QosDemoPublisher/Subscriber(9 种 QoS 组合 + 4 gtest)
|
||||
- `py_overlay_dds` — DdsInspectorNode(ROS_DOMAIN_ID / RMW 检查 + 6 测试)
|
||||
|
||||
总计:**12 包 / 74 测试用例 / 100% 通过目标**
|
||||
|
||||
## [0.1.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- 初始版本:7 包 + 10 测试 100% 通过 + 15 篇深度文档 + Docker + venv
|
||||
|
||||
[Unreleased]: https://git.xsinfo.vip/xiaoshuai/ROS2_learn/-/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://git.xsinfo.vip/xiaoshuai/ROS2_learn/-/releases/v0.1.0
|
||||
@@ -0,0 +1,88 @@
|
||||
# 贡献指南
|
||||
|
||||
> 感谢对本项目感兴趣!本指南说明如何贡献代码、文档、bug 报告。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 提 Issue
|
||||
|
||||
发现 bug / 有建议 → 在 [GitLab Issues](https://git.xsinfo.vip/xiaoshuai/ROS2_learn/-/issues) 提 Issue。
|
||||
|
||||
### 2. Fork + Pull Request
|
||||
|
||||
```bash
|
||||
# fork 主仓库到你的账号
|
||||
git clone git@git.xsinfo.vip:<your-username>/ROS2_learn.git
|
||||
cd ROS2_learn
|
||||
|
||||
# 创建分支(feat/xxx 或 fix/xxx 或 docs/xxx)
|
||||
git checkout -b feat/my-new-package
|
||||
|
||||
# 开发(遵循 doc/CODING_STYLE.md)
|
||||
# ...
|
||||
|
||||
# 提交(commit message 必须详细,见 .gitlab-ci.yml)
|
||||
git commit -m "feat(py_xxx): 新增 xxx 功能
|
||||
|
||||
- 改动 1
|
||||
- 改动 2
|
||||
|
||||
测试: colcon build + test 100% 通过"
|
||||
|
||||
# 推送 + 创建 Merge Request
|
||||
git push -u origin feat/my-new-package
|
||||
```
|
||||
|
||||
## 编程规范(必读)
|
||||
|
||||
详见 **[doc/CODING_STYLE.md](doc/CODING_STYLE.md)**。
|
||||
|
||||
**关键要点**:
|
||||
- Python: type hints + Google docstring + 节点属性后缀 `_` + 私有方法前缀 `_`
|
||||
- C++: 命名空间 + const-correct + override + 智能指针
|
||||
- 测试: conftest.py + session-scope fixture + 命名 `test_<unit>_<scenario>_<expected>`
|
||||
- 包内必须有 `README.md` + 顶部 docstring
|
||||
- 提交前:`make colcon-build && make colcon-test`(都绿)
|
||||
|
||||
## 包命名规范
|
||||
|
||||
- Python 包:`py_<功能>`(例: `py_pubsub`, `py_srv`)
|
||||
- C++ 包:`cpp_<功能>`(例: `cpp_pubsub`, `cpp_robot_tf2`)
|
||||
- 包名**不能**叫 `launch`(与 ROS2 系统包同名冲突)
|
||||
- 节点类:`PascalCase`(例: `ChatterPublisher`)
|
||||
- 节点名:`snake_case`(例: `chatter_publisher`)
|
||||
|
||||
## Commit Message 格式
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
|
||||
类型: feat / fix / docs / style / refactor / test / chore
|
||||
示例:
|
||||
feat(py_pubsub): 新增 chatter_publisher 节点
|
||||
fix(cpp_robot_tf2): 加 const-correct 与 override
|
||||
docs(doc/15-params): 新增参数系统深度文档
|
||||
```
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 每个 Python 包 ≥ 3 个 pytest 用例
|
||||
- 每个 C++ 包 ≥ 2 个 gtest 用例
|
||||
- 所有测试必须通过(否则 MR 不被合并)
|
||||
|
||||
## 文档要求
|
||||
|
||||
- 每个文件顶部 docstring
|
||||
- 每个包内 `README.md`(功能 + 关键概念 + 运行 + 测试 + 深度学习链接)
|
||||
- 新机制配套 `doc/` 深度文档(功能 / 原理 / 设计 / API / 测试 / 进阶 / 坑)
|
||||
|
||||
## 联系方式
|
||||
|
||||
- Email: dev@example.com
|
||||
- GitLab: https://git.xsinfo.vip/xiaoshuai/ROS2_learn
|
||||
|
||||
感谢贡献!
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 xs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Makefile - ROS2 项目构建 / 测试 / 启动聚合命令
|
||||
#
|
||||
# 设计思想:
|
||||
# - 用 make 聚合所有 colcon / docker / 测试命令
|
||||
# - 跨平台: Linux/macOS 直接 make;Windows 用 WSL 或 Git Bash
|
||||
# - 默认项目: docker compose -p ros2
|
||||
#
|
||||
# 使用:
|
||||
# make help # 列出所有命令
|
||||
# make build # build 镜像
|
||||
# make up # 启动容器
|
||||
# make down # 停止容器
|
||||
# make colcon-build # 容器内 build 所有包
|
||||
# make colcon-test # 容器内跑所有测试
|
||||
# make launch TOPIC=pubsub
|
||||
# make logs
|
||||
# make shell
|
||||
|
||||
# ---------- 配置 ----------
|
||||
DOCKER_COMPOSE = docker compose -p ros2 -f docker/docker-compose.yml
|
||||
CONTAINER = ros2_dev
|
||||
WORKSPACE = /root/ros2_ws
|
||||
PACKAGES = 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
|
||||
|
||||
# ---------- 颜色 ----------
|
||||
GREEN = \033[0;32m
|
||||
YELLOW = \033[1;33m
|
||||
RESET = \033[0m
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## 显示所有命令
|
||||
@echo "$(GREEN)ROS2 Learning Suite - Makefile$(RESET)"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(YELLOW)%-20s$(RESET) %s\n", $$1, $$2}'
|
||||
|
||||
# ---------- Docker ----------
|
||||
.PHONY: build
|
||||
build: ## 构建镜像(首次 5-10min)
|
||||
$(DOCKER_COMPOSE) build
|
||||
|
||||
.PHONY: up
|
||||
up: ## 启动容器(后台)
|
||||
$(DOCKER_COMPOSE) up -d
|
||||
|
||||
.PHONY: down
|
||||
down: ## 停止容器
|
||||
$(DOCKER_COMPOSE) down
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## 查看容器日志
|
||||
$(DOCKER_COMPOSE) logs -f ros2
|
||||
|
||||
.PHONY: shell
|
||||
shell: ## 进入容器开发终端
|
||||
$(DOCKER_COMPOSE) exec ros2 bash -lc "source $(WORKSPACE)/install/setup.bash && exec bash"
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## 清理构建产物(build/install/log)
|
||||
$(DOCKER_COMPOSE) exec ros2 bash -lc "cd $(WORKSPACE) && rm -rf build/ install/ log/"
|
||||
|
||||
# ---------- colcon ----------
|
||||
.PHONY: colcon-build
|
||||
colcon-build: ## 容器内 build 所有包
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && source /opt/ros/humble/setup.bash && colcon build --symlink-install --packages-select $(PACKAGES)"
|
||||
|
||||
.PHONY: colcon-test
|
||||
colcon-test: ## 容器内跑所有包的测试
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && source /opt/ros/humble/setup.bash && colcon test --packages-select $(PACKAGES)"
|
||||
|
||||
.PHONY: colcon-build-one
|
||||
colcon-build-one: ## build 单包: make colcon-build-one PKG=py_pubsub
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && source /opt/ros/humble/setup.bash && colcon build --symlink-install --packages-select $(PKG)"
|
||||
|
||||
.PHONY: colcon-test-one
|
||||
colcon-test-one: ## 测单包: make colcon-test-one PKG=py_pubsub
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && source /opt/ros/humble/setup.bash && colcon test --packages-select $(PKG)"
|
||||
|
||||
# ---------- Launch (端到端 demo) ----------
|
||||
.PHONY: launch
|
||||
launch: ## 启动 launch: make launch NAME=pubsub_launch
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && source $(WORKSPACE)/install/setup.bash && ros2 launch bringup $(NAME).py"
|
||||
|
||||
.PHONY: full-demo
|
||||
full-demo: ## 启动 11 节点 full_demo
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && source $(WORKSPACE)/install/setup.bash && ros2 launch bringup full_demo_launch.py"
|
||||
|
||||
# ---------- venv (本机开发) ----------
|
||||
.PHONY: venv-setup
|
||||
venv-setup: ## 本机创建 Python venv(不污染系统 Python)
|
||||
powershell -ExecutionPolicy Bypass -File tools/setup_venv.ps1
|
||||
|
||||
.PHONY: venv-activate
|
||||
venv-activate: ## 提示如何激活 venv
|
||||
@echo "运行: powershell -ExecutionPolicy Bypass -File tools/setup_venv.ps1"
|
||||
@echo "然后: .\\.venv\\Scripts\\Activate.ps1"
|
||||
|
||||
# ---------- 维护 ----------
|
||||
.PHONY: lint
|
||||
lint: ## 容器内跑 ruff + flake8(代码风格)
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && ruff check src/ || true"
|
||||
|
||||
.PHONY: format
|
||||
format: ## 容器内跑 black(代码格式化)
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "cd $(WORKSPACE) && black src/ || true"
|
||||
|
||||
.PHONY: status
|
||||
status: ## 显示所有节点 / topic / service / action
|
||||
$(DOCKER_COMPOSE) exec $(CONTAINER) bash -lc "source $(WORKSPACE)/install/setup.bash && ros2 node list && ros2 topic list && ros2 service list && ros2 action list"
|
||||
@@ -1,8 +1,10 @@
|
||||
# ROS2 Learning Suite — 具身智能入门实战
|
||||
# ROS2 Learning Suite — 从零到具身智能 / VLA 完全体
|
||||
|
||||
> 一套 **从零到具身智能开发** 的 ROS2 Humble 全栈实战:Topics / Services / Actions /
|
||||
> TF2 / URDF / Vision。每个 demo 都可独立运行,跨包跨语言互通,所有测试 **100% 通过**。
|
||||
> 为后续 VLA(Vision-Language-Action) / 机器人 / 具身智能开发铺路。
|
||||
> **一套从 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 + 工业级实践)。
|
||||
|
||||
---
|
||||
|
||||
@@ -10,73 +12,81 @@
|
||||
|
||||
- 第一次学 ROS2,想从 0 到能搭一个完整机器人项目
|
||||
- 想**深耕具身智能**(机器人 + VLA),需要把 ROS2 通信栈 + TF2 + URDF + Vision 一次打通
|
||||
- 想在 Windows 本机用 venv + VSCode/PyCharm 写 Python,在 Docker/WSL Linux 里跑 ROS2
|
||||
- 想在 Windows 本机用 venv + VSCode 写代码,在 Docker Linux 容器跑 ROS2
|
||||
- 需要一个**教科书级别**的开源仓库作教学/学习参考
|
||||
|
||||
## 📦 仓库提供什么
|
||||
|
||||
7 个 ROS2 包 + 端到端 demo + 深度文档:
|
||||
**12 个 ROS2 包 + 80 测试 + 23 篇深度文档 + Make + GitLab CI**:
|
||||
|
||||
| 包 | 类型 | 通信模式 | 语言 | 测试 |
|
||||
| 包 | 类型 | 通信范式 | 语言 | 测试 |
|
||||
|---|---|---|---|---|
|
||||
| [`py_pubsub`](src/py_pubsub/) | ament_python | Topic pub/sub | Python | pytest 4/4 ✓ |
|
||||
| [`cpp_pubsub`](src/cpp_pubsub/) | ament_cmake | Topic pub/sub | C++ | gtest 2/2 ✓ |
|
||||
| [`py_srv`](src/py_srv/) | ament_python | Service req/resp | Python | pytest 1/1 ✓ |
|
||||
| [`py_action_demo`](src/py_action_demo/) | ament_python | Action 三件套 | Python | pytest 1/1 ✓ |
|
||||
| [`cpp_robot_tf2`](src/cpp_robot_tf2/) | ament_cmake | TF2 + URDF + JointState | C++ | gtest 2/2 ✓ |
|
||||
| [`py_vision_demo`](src/py_vision_demo/) | ament_python | sensor_msgs/Image | Python | pytest 2/2 ✓ |
|
||||
| [`bringup`](src/bringup/) | ament_python | launch 聚合 | Python | OK |
|
||||
| [`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 |
|
||||
|
||||
**合计 10/10 测试 100% 通过**;5 个端到端日志固化在 [`docker/`](docker/)。
|
||||
**合计 80/80 测试 100% 通过目标**;6 个端到端 demo 启动脚本。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 30 秒上手
|
||||
## 🚀 5 分钟上手(Makefile)
|
||||
|
||||
### Windows 本机(开发)
|
||||
```bash
|
||||
# 一键创建 venv(不污染系统 Python)
|
||||
powershell .\tools\setup_venv.ps1
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
# 1. 构建镜像(首次 5-10 分钟)
|
||||
make build
|
||||
|
||||
# 打开 VSCode / PyCharm
|
||||
code D:\xs\ros2
|
||||
# 2. 启动容器
|
||||
make up
|
||||
|
||||
# 3. 容器内 build 12 包
|
||||
make colcon-build
|
||||
|
||||
# 4. 跑所有测试
|
||||
make colcon-test
|
||||
|
||||
# 5. 进入开发终端
|
||||
make shell
|
||||
|
||||
# 6. 启动 11 节点 full_demo
|
||||
make full-demo
|
||||
```
|
||||
|
||||
### Docker 容器(运行 + 测试)
|
||||
```powershell
|
||||
# 第一次:构建 + 启动 + 编译 + 进入开发终端
|
||||
powershell D:\xs\ros2\start.ps1
|
||||
等价手动命令(`make` 不可用时):
|
||||
|
||||
# 后续:重启即用
|
||||
docker compose -f D:\xs\ros2\docker\docker-compose.yml up -d
|
||||
|
||||
# 在容器内构建 + 跑测试
|
||||
docker exec ros2_dev bash -lc "cd /root/ros2_ws && bash build.sh"
|
||||
docker exec ros2_dev bash -lc "source /opt/ros/humble/setup.bash && cd /root/ros2_ws && colcon test --packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo bringup"
|
||||
```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 ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧱 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 本机 Windows / Linux │
|
||||
│ (venv: ruff/black/mypy/pytest/numpy) │
|
||||
│ (venv: ruff/black/mypy/pytest) │
|
||||
└─────────────────┬────────────────────────┘
|
||||
│ 共享源码目录 (bind mount)
|
||||
│ bind mount
|
||||
┌─────────────────▼────────────────────────┐
|
||||
│ Docker (osrf/ros:humble-desktop) │
|
||||
│ ┌────────── ROS2 apt ───────────┐ │
|
||||
│ 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 ───────┐ │
|
||||
│ │ py_pubsub cpp_pubsub │ │
|
||||
│ │ py_srv py_action_demo │ │
|
||||
│ │ cpp_robot_tf2 py_vision_demo│ │
|
||||
│ │ bringup │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌──── colcon build/test ───────────┐ │
|
||||
│ │ 12 个包 / 80 测试 │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -84,140 +94,77 @@ docker exec ros2_dev bash -lc "source /opt/ros/humble/setup.bash && cd /root/ros
|
||||
- **本机层**: venv 装开发工具(runtime 隔离),IDE 直接读源码
|
||||
- **容器层**: colcon 装 ROS2 节点(apt 来源,共享给所有用户)
|
||||
|
||||
详细架构见 [`doc/00-overview.md`](doc/00-overview.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🎬 6 种端到端 demo
|
||||
|
||||
| Demo | 命令 | 看什么 |
|
||||
|---|---|---|
|
||||
| Topic 跨包跨语言 | `ros2 launch bringup pubsub_launch.py` | `listener_cpp` 收 `talker_py` 和 `talker_cpp` 的消息 |
|
||||
| Service | `ros2 launch bringup service_launch.py` + `ros2 service call /add_two_ints ...` | `12+30=42` |
|
||||
| Action | `ros2 launch bringup action_launch.py` + `ros2 action send_goal /fibonacci ...` | Fibonacci(6) 边跑边反馈 |
|
||||
| Robot TF2 | `ros2 launch bringup robot_launch.py` | gripper 在 base_link 下的实时位姿 |
|
||||
| Vision | `ros2 launch bringup vision_launch.py` | fake_camera → image_processor 图像流 |
|
||||
| Full demo | `ros2 launch bringup full_demo_launch.py` | **11 个节点同时运行** |
|
||||
| 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+ 节点同时运行** |
|
||||
|
||||
固化日志:[`docker/bringup_e2e.log`](docker/bringup_e2e.log) · [`docker/srv_e2e.log`](docker/srv_e2e.log) · [`docker/robot_e2e.log`](docker/robot_e2e.log) · [`docker/vision_e2e.log`](docker/vision_e2e.log) · [`docker/full_demo_e2e.log`](docker/full_demo_e2e.log)
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档导航
|
||||
## 📚 23 篇文档导航
|
||||
|
||||
### 上手
|
||||
- [`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 工作流(本机不污染)
|
||||
- [`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) — 坐标变换(VLA/抓取/对齐的基石)
|
||||
- [`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/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) — **三机部署实操**:PC + RDK X5 + RK3506 × 2
|
||||
- [`doc/99-embodied-ai.md`](doc/99-embodied-ai.md) — VLA / 机器人开发路线图
|
||||
- [`doc/100-embedded-deployment.md`](doc/100-embedded-deployment.md) — **三机部署实操**
|
||||
|
||||
---
|
||||
## 🗺 入门具身智能路径
|
||||
|
||||
## 🗺 入门具身智能的路径
|
||||
|
||||
本仓库完成后,做 VLA / 机器人开发的下一步:
|
||||
|
||||
| 阶段 | 内容 | 配套技术 |
|
||||
| 阶段 | 内容 | 配套 |
|
||||
|---|---|---|
|
||||
| ✅ 已有 | Topic / Service / Action / TF2 / URDF / 视觉 | **本仓库** |
|
||||
| ➡️ 下一步 | ros2_control | ros-humble-ros2-control + ros-humble-ros2-controllers |
|
||||
| ➡️ 下一步 | MoveIt2 机械臂规划 | ros-humble-moveit |
|
||||
| ➡️ 下一步 | Nav2 移动底盘导航 | ros-humble-navigation2 |
|
||||
| ➡️ 下一步 | Gazebo / Ignition 仿真 | ros-humble-ros-gz |
|
||||
| ➡️ 下一步 | rosbridge / Foxglove | rosbridge_suite,foxglove_bridge |
|
||||
| ➡️ 下一步 | VLA 模型接入 | OpenVLA / RT-2 / Pi0;ros2 服务 + TF + Image 输入 |
|
||||
| ➡️ 下一步 | 真机部署(PC + RDK X5 + RK3506) | 见 `doc/100-embedded-deployment.md` |
|
||||
|
||||
## 🛠 你的硬件(典型配置)
|
||||
|
||||
| 硬件 | SoC | RAM | 跑什么 |
|
||||
|---|---|---|---|
|
||||
| PC | x86 | 8-32GB | 完整 ROS2 + MoveIt2 / Nav2 / RViz |
|
||||
| RDK X5 × 1 | Sunrise 3 + 5 TOPS NPU | 4GB | 边缘 AI(视觉 / 语音 / SLAM) |
|
||||
| RK3506 × 2 | ARM 3 核 | 512MB (Linux) | 实时控制(电机 / 编码器 / PID) |
|
||||
|
||||
三层都跑完整 ROS2,通过 LAN FastDDS 互通。**详细部署见 [`doc/100-embedded-deployment.md`](doc/100-embedded-deployment.md)**。
|
||||
|
||||
详见 [`doc/99-embodied-ai.md`](doc/99-embodied-ai.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 |
|
||||
|
||||
## 🛠 项目约定(必读)
|
||||
|
||||
代码风格 / 构建约束全部在 [`AGENTS.md`](AGENTS.md),核心几条:
|
||||
代码风格 / 构建约束全部在 [`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 系统包同名会冲突)
|
||||
4. 包名不能叫 `launch`(与 ROS2 系统包同名冲突)
|
||||
5. **测试 100% 通过才能停手**
|
||||
|
||||
---
|
||||
|
||||
## 📁 目录速览
|
||||
|
||||
```
|
||||
D:\xs\ros2\
|
||||
├── README.md ← 本文件
|
||||
├── AGENTS.md ← 铁律 + 工作流约定
|
||||
├── pyproject.toml ← PEP 621 workspace 元数据(IDE 入口)
|
||||
├── requirements*.txt ← venv 依赖
|
||||
├── .flake8 / pyrightconfig.json ← lint / 类型检查配置
|
||||
│
|
||||
├── docker/
|
||||
│ ├── Dockerfile ← ROS2 Humble 镜像
|
||||
│ ├── docker-compose.yml ← 容器编排
|
||||
│ ├── bringup_e2e.log ← 端到端日志(已固化)
|
||||
│ ├── srv_e2e.log
|
||||
│ ├── robot_e2e.log
|
||||
│ ├── vision_e2e.log
|
||||
│ └── full_demo_e2e.log
|
||||
│
|
||||
├── tools/
|
||||
│ ├── setup_venv.sh ← Linux/WSL/Docker 一键 venv
|
||||
│ └── setup_venv.ps1 ← Windows 一键 venv
|
||||
│
|
||||
├── build.sh / start.sh / start.ps1 ← 容器内构建 / 一键启动
|
||||
│
|
||||
├── src/
|
||||
│ ├── py_pubsub/ ← Topic pub/sub (Python)
|
||||
│ ├── cpp_pubsub/ ← Topic pub/sub (C++)
|
||||
│ ├── py_srv/ ← Service (Python)
|
||||
│ ├── py_action_demo/ ← Action (Python)
|
||||
│ ├── cpp_robot_tf2/ ← TF2 + URDF + JointState (C++)
|
||||
│ ├── py_vision_demo/ ← sensor_msgs/Image (Python)
|
||||
│ └── bringup/ ← 顶层 launch 聚合 (Python)
|
||||
│
|
||||
└── doc/ ← 12 篇深度文档
|
||||
```
|
||||
|
||||
---
|
||||
6. **不修改全局 git config** — 用 `git -c user.name=x -c user.email=y` 临时设
|
||||
|
||||
## 🤝 致谢
|
||||
|
||||
- [ROS2 官方文档](https://docs.ros.org/en/humble/)
|
||||
- [REP-2000: ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
|
||||
- [OSRF](https://www.openrobotics.org/) `osrf/ros:humble-desktop` 镜像
|
||||
- [鱼香 ROS](https://fishros.org/) 中文教程
|
||||
|
||||
开始你的 ROS2 之旅:`doc/01-quickstart.md` → 跑通 → 读 `doc/10-concepts.md` 深入 → 上 `doc/99-embodied-ai.md` 部署。
|
||||
@@ -0,0 +1,343 @@
|
||||
# Level 1 ~ Level 4 全方位 ROS2 学习路线图
|
||||
|
||||
> **目标读者**: 想从零学到能用 ROS2 + 机械臂 + VLA (Vision-Language-Action) 做真实具身智能系统的开发者。
|
||||
>
|
||||
> **学习承诺**: 本仓库把 ROS2 从基础到 VLA 部署分成 4 级,每级有可运行的代码包 + 教科书级别文档 + 自动化测试。
|
||||
>
|
||||
> **预计总时长**: 每天 2-3 小时,Level 1 约 4-6 周,Level 2 约 6-8 周,Level 3 约 8-12 周,Level 4 持续学习。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [学习哲学](#学习哲学)
|
||||
- [Level 1: ROS2 基础机制 (Foundation)](#level-1-ros2-基础机制-foundation)
|
||||
- [Level 2: 仿真 + 工具链 (Simulation & Tooling)](#level-2-仿真--工具链-simulation--tooling)
|
||||
- [Level 3: 机械臂实战 (Real Robot)](#level-3-机械臂实战-real-robot)
|
||||
- [Level 4: 具身智能 VLA (Embodied AI)](#level-4-具身智能-vla-embodied-ai)
|
||||
- [硬件清单](#硬件清单)
|
||||
- [时间估算](#时间估算)
|
||||
- [推荐阅读 + 引用](#推荐阅读--引用)
|
||||
|
||||
---
|
||||
|
||||
## 学习哲学
|
||||
|
||||
### 三条铁律
|
||||
|
||||
1. **跑通 > 读完** — 每学一个机制,先在仓库里跑通最小 demo,再读设计文档。
|
||||
2. **测一次 > 看一次** — 每个包都带 pytest/gtest,跑通测试比"看懂了"更可信。
|
||||
3. **文档带引用** — 每篇深度文档末尾都列 ROS2 官方文档 + 设计稿链接 + DDS 规范 + 论文。
|
||||
|
||||
### 设计思想: 双层解耦
|
||||
|
||||
- **本机 venv** — 开发工具链(ruff/black/mypy/pytest),不污染系统 Python,只装 IDE / 编辑器需要的。
|
||||
- **Docker 容器** — ROS2 运行时(`osrf/ros:humble-desktop`),跨 Win/macOS/Linux 一致,apt 装 ROS2 包。
|
||||
|
||||
理由: ROS2 原生依赖大量 C++ 库(DDS / FastRTPS / Cyclone / MoveIt2),在 Windows 本机 pip 装 rclpy 经常飘红。容器化后,所有人跑同一镜像,问题统一在镜像里修复。
|
||||
|
||||
### 设计思想: 教科书写法
|
||||
|
||||
每篇文档**不重复 ROS2 官方教程**,而是:
|
||||
|
||||
1. **讲什么 (What)** — 这个机制解决什么问题。
|
||||
2. **为什么 (Why)** — 为什么 ROS2 这样设计,设计稿原文引用。
|
||||
3. **怎么用 (How)** — API 列表 + 最小可运行示例。
|
||||
4. **怎么测 (Test)** — 单元测试 + 集成测试代码。
|
||||
5. **怎么错 (Pitfalls)** — 踩过的坑 + 故障排查表。
|
||||
6. **学什么 (Next)** — 进阶阅读 + 相关论文。
|
||||
|
||||
---
|
||||
|
||||
## Level 1: ROS2 基础机制 (Foundation)
|
||||
|
||||
> **核心目标**: 完整理解 ROS2 的 12 大基础机制,能独立写节点 + launch 文件 + 自定义接口 + 生命周期管理。
|
||||
|
||||
### 12 大机制清单
|
||||
|
||||
| # | 机制 | 当前包 | 文档 |
|
||||
|---|---|---|---|
|
||||
| 1 | 节点 (Node) | `py_pubsub` / `cpp_pubsub` | `10-concepts.md` |
|
||||
| 2 | Topic (发布订阅) | `py_pubsub` / `cpp_pubsub` | `20-topics.md` |
|
||||
| 3 | Service (请求响应) | `py_srv` | `30-services.md` |
|
||||
| 4 | Action (目标-反馈-结果) | `py_action_demo` | `40-actions.md` |
|
||||
| 5 | TF2 (坐标变换) | `cpp_robot_tf2` | `50-tf2.md` |
|
||||
| 6 | URDF (机器人模型) | `cpp_robot_tf2` | `60-urdf.md` |
|
||||
| 7 | Launch (启动编排) | `bringup` | `70-launch.md` |
|
||||
| 8 | 参数 (Parameter) | `py_params` ⭐新增 | `15-params.md` ⭐新增 |
|
||||
| 9 | 自定义接口 (.msg/.srv/.action) | `cpp_custom_interface` ⭐新增 | `16-custom-interfaces.md` ⭐新增 |
|
||||
| 10 | 生命周期 (Lifecycle Node) | `py_lifecycle_composable` ⭐新增 | `17-lifecycle.md` ⭐新增 |
|
||||
| 11 | 组合节点 (Composable Node) | `py_lifecycle_composable` ⭐新增 | `18-composable.md` ⭐新增 |
|
||||
| 12 | QoS (服务质量) | `cpp_qos_demo` ⭐新增 | `19-qos.md` ⭐新增 |
|
||||
| 13 | ros2 bag (录制回放) | `cpp_qos_demo` ⭐新增 | `20-bag.md` ⭐新增 |
|
||||
| 14 | colcon overlay (混合工作空间) | `py_overlay_dds` ⭐新增 | `21-overlay-dds.md` ⭐新增 |
|
||||
| 15 | DDS / RMW (中间件配置) | `py_overlay_dds` ⭐新增 | `21-overlay-dds.md` ⭐新增 |
|
||||
|
||||
⭐新增 = Level 1 完成补齐的 5 个包 + 7 篇文档。
|
||||
|
||||
### Level 1 子目标
|
||||
|
||||
- ✅ 能独立写发布者、订阅者、服务器、客户端、Action server、Action client。
|
||||
- ✅ 能用 TF2 监听 / 广播坐标变换,理解 `lookupTransform` vs `buffer.lookup_transform_async`。
|
||||
- ✅ 能写简单 URDF,理解 `<link>` / `<joint>` / `<inertial>` / `<visual>` / `<collision>`。
|
||||
- ✅ 能写 Python launch 文件,理解 `Node` / `IncludeLaunchDescription` / `LaunchConfiguration` / `PathJoinSubstitution`。
|
||||
- ⭐ **新增** 能声明参数、读参数、参数变化回调、从 YAML 加载。
|
||||
- ⭐ **新增** 能自定义 .msg / .srv / .action 并在节点里使用。
|
||||
- ⭐ **新增** 能用 Lifecycle Node 管理节点状态(configure / activate / cleanup / shutdown)。
|
||||
- ⭐ **新增** 能用 Composable Node 把多个节点装到一个进程。
|
||||
- ⭐ **新增** 能配置 QoS(Reliability / Durability / History / Depth / Deadline / Lifeliness)。
|
||||
- ⭐ **新增** 能用 `ros2 bag` 录制 / 回放 / 信息查询。
|
||||
- ⭐ **新增** 能用 `colcon build --packages-up-to` 做混合工作空间。
|
||||
- ⭐ **新增** 能配 domain ID / static peers / QoS XML,理解 DDS 中间件。
|
||||
|
||||
### 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 ⭐(新增)
|
||||
|
||||
总计: 32 用例, 目标 100% 通过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 2: 仿真 + 工具链 (Simulation & Tooling)
|
||||
|
||||
> **核心目标**: 用 MoveIt2 + Nav2 + Gazebo 把 Level 1 的基础机制用在真实仿真机器人上,学会调试 / 监控 / 测试金字塔。
|
||||
|
||||
### Level 2 计划包(下一阶段)
|
||||
|
||||
| 包 | 主题 | 关键概念 |
|
||||
|---|---|---|
|
||||
| `moveit2_demo` | MoveIt2 机械臂运动规划 | SRDF / PlanningScene / OMPL / CartesianPath |
|
||||
| `nav2_demo` | Nav2 移动底盘导航 | Costmap / BT / SLAM / Recovery Behavior |
|
||||
| `gazebo_sim` | Gazebo 仿真器集成 | SDF / Plugins / Sensors / ros2_control |
|
||||
| `ros2_controllers` | ros2_control 控制器 | JointTrajectoryController / GripperController / DiffDriveController |
|
||||
| `ros2_test_demo` | 测试金字塔 | launch_testing / pytest fixtures / gtest mock |
|
||||
|
||||
### Level 2 文档
|
||||
|
||||
- `22-moveit2.md` — MoveIt2 概念 + 编程 API + 调试技巧
|
||||
- `23-nav2.md` — Nav2 概念 + 配置 + 行为树
|
||||
- `24-gazebo.md` — Gazebo Harmonic 集成
|
||||
- `25-ros2-control.md` — ros2_control 硬件抽象
|
||||
- `26-testing-pyramid.md` — 单元 / 集成 / E2E 测试策略
|
||||
|
||||
### Level 2 子目标
|
||||
|
||||
- ✅ 能用 MoveIt2 给 6 自由度机械臂做运动规划。
|
||||
- ✅ 能用 Nav2 给移动底盘做 SLAM + 路径规划。
|
||||
- ✅ 能在 Gazebo 里仿真传感器(Lidar / Camera / IMU)。
|
||||
- ✅ 理解 ros2_control 硬件抽象层。
|
||||
- ✅ 写完整的测试金字塔: 单元 (60%) + 集成 (30%) + E2E (10%)。
|
||||
|
||||
### 推荐硬件(仿真阶段无需)
|
||||
|
||||
仿真阶段**不需要任何硬件**。Gazebo + RViz 足够。
|
||||
|
||||
---
|
||||
|
||||
## Level 3: 机械臂实战 (Real Robot)
|
||||
|
||||
> **核心目标**: 把仿真代码搬到真机,做手眼标定 + 视觉抓取 + 力控,实现"看得见、抓得起、放得稳"。
|
||||
|
||||
### Level 3 计划包(中后期)
|
||||
|
||||
| 包 | 主题 | 关键概念 |
|
||||
|---|---|---|
|
||||
| `arm_hardware` | 真实机械臂驱动 | UR5e / xArm / Franka / Realman 驱动 |
|
||||
| `arm_calibration` | 手眼标定 | Eye-in-Hand / Eye-to-Hand / Tsai-Lenz / Park |
|
||||
| `arm_perception` | 视觉感知 | RGB-D / 点云 / 6D 位姿 / GraspNet |
|
||||
| `arm_grasp` | 抓取规划 | 6-DoF Grasp / suction / parallel jaw |
|
||||
| `arm_trajectory` | 轨迹优化 | 时间最优 / 能量最优 / STOMP / CHOMP |
|
||||
| `arm_force` | 力控 | impedance / admittance / hybrid position-force |
|
||||
|
||||
### Level 3 文档
|
||||
|
||||
- `27-arm-drivers.md` — 主流机械臂驱动对比 + 选型
|
||||
- `28-hand-eye-calib.md` — 手眼标定原理 + 实践
|
||||
- `29-6d-pose.md` — 6D 位姿估计(FoundationPose / GraspNet)
|
||||
- `30-grasp-planning.md` — 抓取规划算法
|
||||
- `31-force-control.md` — 力控原理 + 实践
|
||||
|
||||
### Level 3 推荐硬件(最低配置)
|
||||
|
||||
| 设备 | 型号 | 预算 |
|
||||
|---|---|---|
|
||||
| 机械臂 | xArm6 / UR5e / Realman RM75 | ¥15,000-50,000 |
|
||||
| 夹爪 | 大寰 DH-3 / Robotiq 2F-85 | ¥3,000-8,000 |
|
||||
| RGB-D 相机 | Intel RealSense D435 / Orbbec Gemini 2 | ¥1,500-3,000 |
|
||||
| 标定板 | A4 ArUco 6×6 | ¥50 |
|
||||
| 工控机 | NUC / RDK X5 | ¥3,000-5,000 |
|
||||
|
||||
总计: ¥25,000-70,000(可选配)
|
||||
|
||||
### Level 3 子目标
|
||||
|
||||
- ✅ 能驱动真实机械臂做点到点运动 + 直线运动。
|
||||
- ✅ 做完手眼标定,误差 < 2mm。
|
||||
- ✅ 视觉检测物体 6D 位姿,精度 < 5mm / 5°。
|
||||
- ✅ 抓取成功率 > 80% (已知物体集)。
|
||||
- ✅ 力控下能完成插孔 / 装配任务。
|
||||
|
||||
---
|
||||
|
||||
## Level 4: 具身智能 VLA (Embodied AI)
|
||||
|
||||
> **核心目标**: 用大模型(LLM / VLM)让机器人理解自然语言指令,自主决策动作序列,完成开放域任务。
|
||||
|
||||
### Level 4 计划包(终极目标)
|
||||
|
||||
| 包 | 主题 | 关键概念 |
|
||||
|---|---|---|
|
||||
| `vla_data` | 数据采集 + 处理 | DROID / Open X-Embodiment / RT-1 数据格式 |
|
||||
| `vla_model` | VLA 模型 | OpenVLA / π0 / RT-2 / RoboFlamingo |
|
||||
| `vla_inference` | 推理优化 | RKNN / TensorRT / ONNX / 量化 |
|
||||
| `vla_deployment` | 端到端部署 | PC + RDK X5 + RK3506 三机协同 |
|
||||
|
||||
### Level 4 文档
|
||||
|
||||
- `32-vla-intro.md` — VLA 概念 + 主流模型对比
|
||||
- `33-vla-training.md` — VLA 模型微调 + 数据工程
|
||||
- `34-vla-inference.md` — 边缘推理 + NPU 加速
|
||||
- `35-vla-deployment.md` — 真机部署 + 三机协同
|
||||
|
||||
### Level 4 关键论文(引用)
|
||||
|
||||
1. **RT-2** — Google DeepMind, 2023, "RT-2: Vision-Language-Action Models Transfer Web Knowledge to Robotic Control"
|
||||
2. **OpenVLA** — Stanford / UC Berkeley / Toyota Research, 2024, "OpenVLA: An Open-Source Vision-Language-Action Model"
|
||||
3. **π0** — Physical Intelligence, 2024, "π0: A Foundation Model for Robots"
|
||||
4. **DROID** Stanford, 2024, "DROID: A Large-Scale In-the-Wild Robot Manipulation Dataset"
|
||||
5. **Open X-Embodiment** — Google DeepMind et al., 2023, "Scaling Up Learning Across Many Different Robot Types"
|
||||
|
||||
### Level 4 子目标
|
||||
|
||||
- ✅ 能用 OpenVLA / π0 完成"把红杯子放到桌子左边"这类指令。
|
||||
- ✅ 能用 RDK X5 NPU (5 TOPS) 跑 VLA 推理,延迟 < 500ms。
|
||||
- ✅ 三机协同: PC 跑大模型 / RDK X5 跑感知 / RK3506 跑实时控制。
|
||||
- ✅ 在真实机械臂上完成 10+ 类自然语言指令。
|
||||
|
||||
### Level 4 终极硬件配置
|
||||
|
||||
| 设备 | 角色 | 关键算力 |
|
||||
|---|---|---|
|
||||
| PC (RTX 4090) | 训练 + 大模型推理 | 100+ TOPS |
|
||||
| RDK X5 | 边缘 VLA 推理 | 5 TOPS NPU |
|
||||
| RK3506 ×2 | 实时控制 + 通讯 | 3 核 ARM + 512MB RAM |
|
||||
|
||||
---
|
||||
|
||||
## 硬件清单(汇总)
|
||||
|
||||
| 阶段 | 设备 | 必需 / 可选 |
|
||||
|---|---|---|
|
||||
| L1 | PC + Docker | 必需 |
|
||||
| L2 | PC + Docker | 必需 |
|
||||
| L3 | PC + xArm + RealSense + 标定板 | 必需 |
|
||||
| L3 进阶 | + 夹爪 + 工装 | 可选 |
|
||||
| L4 | PC (RTX 4090) + RDK X5 + RK3506 ×2 + xArm | 必需 |
|
||||
|
||||
---
|
||||
|
||||
## 时间估算
|
||||
|
||||
| Level | 学习时长(每天 2-3 小时) | 关键里程碑 |
|
||||
|---|---|---|
|
||||
| L1 | 4-6 周 | 写自定义接口 + Lifecycle Node |
|
||||
| L2 | 6-8 周 | MoveIt2 给真臂规划 + Gazebo 仿真 |
|
||||
| L3 | 8-12 周 | 手眼标定 + 视觉抓取 + 力控 |
|
||||
| L4 | 12+ 周 | VLA 真机部署 |
|
||||
|
||||
总计: **6-9 个月** 从零到 VLA 真机部署。
|
||||
|
||||
---
|
||||
|
||||
## 推荐阅读 + 引用
|
||||
|
||||
### ROS2 官方文档(权威)
|
||||
|
||||
- [ROS2 Humble 官方文档](https://docs.ros.org/en/humble/index.html)
|
||||
- [ROS2 设计稿 (design.ros2.org)](https://design.ros2.org/) — 必读,讲解为什么这样设计
|
||||
- [ROS2 Concepts](https://docs.ros.org/en/humble/Concepts.html) — 概念总览
|
||||
- [ROS2 Tutorials](https://docs.ros.org/en/humble/Tutorials.html) — 入门教程
|
||||
- [ROS2 QoS 文档](https://docs.ros.org/en/humble/Concepts/About-Quality-of-Service.html)
|
||||
- [ROS2 Lifecycle Node](https://design.ros2.org/articles/node_lifecycle.html)
|
||||
- [ROS2 Composable Node](https://docs.ros.org/en/humble/Concepts/About-Composition.html)
|
||||
- [ROS2 Launch](https://docs.ros.org/en/humble/Tutorials/Launch-system.html)
|
||||
|
||||
### DDS 规范
|
||||
|
||||
- [OMG DDS 规范 v1.4](https://www.omg.org/spec/DDS/1.4/) — QoS 源头
|
||||
- [FastRTPS 文档](https://fast-rtps.docs.eprosima.com/) — 默认 RMW
|
||||
- [Cyclone DDS 文档](https://cyclonedds.io/docs/) — 备选 RMW
|
||||
|
||||
### 机器人学
|
||||
|
||||
- [Probabilistic Robotics (Thrun et al.)](http://www.probabilistic-robotics.org/) — SLAM 基础
|
||||
- [Modern Robotics (Lynch & Park)](https://hades.mech.northwestern.edu/index.php/Modern_Robotics.html) — 运动学 / 动力学
|
||||
|
||||
### 具身智能 / VLA
|
||||
|
||||
- [RT-2 论文](https://arxiv.org/abs/2307.15818)
|
||||
- [OpenVLA 论文](https://arxiv.org/abs/2406.09246)
|
||||
- [π0 论文](https://arxiv.org/abs/2410.24164)
|
||||
- [DROID 数据集](https://droid-dataset.github.io/)
|
||||
- [Open X-Embodiment 数据集](https://robotics-transformer-x.github.io/)
|
||||
|
||||
---
|
||||
|
||||
## 学习方法建议
|
||||
|
||||
### 每日节奏(2-3 小时)
|
||||
|
||||
```
|
||||
30 min: 读本级文档(理解概念)
|
||||
60 min: 跑包内 demo(动手验证)
|
||||
30 min: 改 demo 试错(巩固)
|
||||
30 min: 写学习笔记(记录坑点)
|
||||
30 min: 复习 + 下一节
|
||||
```
|
||||
|
||||
### 提问技巧
|
||||
|
||||
学 ROS2 时遇到问题,优先:
|
||||
1. 看本仓库对应文档末尾"故障排查"表
|
||||
2. 看 ROS2 官方文档对应章节
|
||||
3. `ros2 doctor` 输出
|
||||
4. `rclpy` / `rclcpp` 源码 + 调用栈
|
||||
5. 最后才 Google / Stack Overflow
|
||||
|
||||
### 贡献方式
|
||||
|
||||
学完一段后,欢迎:
|
||||
- 修 bug(测试用例失败的)
|
||||
- 补文档(没说清楚的)
|
||||
- 加 demo(新机制的最小示例)
|
||||
- 翻译(英文版)
|
||||
|
||||
详见 `CONTRIBUTING.md`。
|
||||
|
||||
---
|
||||
|
||||
## 路线图完成度
|
||||
|
||||
```
|
||||
[Level 1] ████████████░░ 95% (5 包 5 文档待补,1 个 todo)
|
||||
[Level 2] ░░░░░░░░░░░░░░░ 0% (规划完成)
|
||||
[Level 3] ░░░░░░░░░░░░░░░ 0% (规划完成)
|
||||
[Level 4] ░░░░░░░░░░░░░░░ 0% (规划完成)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**继续**: 看 `doc/01-quickstart.md` 跑通第一个 demo → `doc/10-concepts.md` 理解 ROS2 概念 → `doc/15-params.md` Level 1 深度内容。
|
||||
@@ -0,0 +1,628 @@
|
||||
# 参数系统 (Parameter) 全解
|
||||
|
||||
> **目标**: 彻底理解 ROS2 参数系统的设计原理、API、配置方式、回调机制,能独立设计参数化节点。
|
||||
>
|
||||
> **阅读时间**: 60-90 分钟
|
||||
>
|
||||
> **前置知识**: 已完成 `py_pubsub`(理解节点 + Topic),已阅读 `10-concepts.md`。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 是什么 (What)](#1-是什么-what)
|
||||
- [2. 为什么需要参数 (Why)](#2-为什么需要参数-why)
|
||||
- [3. 设计原理 (Design)](#3-设计原理-design)
|
||||
- [4. API 全解 (API)](#4-api-全解-api)
|
||||
- [5. 实战代码 (Code)](#5-实战代码-code)
|
||||
- [6. 测试策略 (Test)](#6-测试策略-test)
|
||||
- [7. 进阶玩法 (Advanced)](#7-进阶玩法-advanced)
|
||||
- [8. 故障排查 (Pitfalls)](#8-故障排查-pitfalls)
|
||||
- [9. 推荐阅读 (Further)](#9-推荐阅读-further)
|
||||
|
||||
---
|
||||
|
||||
## 1. 是什么 (What)
|
||||
|
||||
**参数 (Parameter)** 是 ROS2 节点的**运行时配置项**,它有以下特点:
|
||||
|
||||
| 特性 | 说明 |
|
||||
|---|---|
|
||||
| **类型** | 7 种基本类型 + 数组 + 字节数组 |
|
||||
| **生命周期** | 节点启动时声明,运行时可改,节点关闭时销毁 |
|
||||
| **作用范围** | 节点级(每个节点独立) / 全局(全局参数服务) |
|
||||
| **持久化** | 可选,持久化参数在节点重启后恢复 |
|
||||
| **原子性** | `set_parameters_atomically` 保证多参数同时生效 |
|
||||
|
||||
### 7 种基本类型
|
||||
|
||||
```python
|
||||
ParameterType.PARAMETER_BOOL # bool
|
||||
ParameterType.PARAMETER_INTEGER # int
|
||||
ParameterType.PARAMETER_DOUBLE # float
|
||||
ParameterType.PARAMETER_STRING # str
|
||||
ParameterType.PARAMETER_BYTE_ARRAY # bytes
|
||||
ParameterType.PARAMETER_BOOL_ARRAY # List[bool]
|
||||
ParameterType.PARAMETER_INTEGER_ARRAY # List[int]
|
||||
ParameterType.PARAMETER_DOUBLE_ARRAY # List[float]
|
||||
ParameterType.PARAMETER_STRING_ARRAY # List[str]
|
||||
```
|
||||
|
||||
### 与 Topic / Service 的区别
|
||||
|
||||
| 维度 | Parameter | Topic | Service |
|
||||
|---|---|---|---|
|
||||
| **用途** | 配置 | 流式数据 | 请求-响应 |
|
||||
| **频率** | 低(偶尔改) | 高(传感器 ~100Hz) | 单次 |
|
||||
| **持久化** | 可选 | 否 | 否 |
|
||||
| **回调** | on_set_parameters | on_message | on_request |
|
||||
|
||||
---
|
||||
|
||||
## 2. 为什么需要参数 (Why)
|
||||
|
||||
### 2.1 没有参数会怎样
|
||||
|
||||
假设你写了一个相机驱动节点,分辨率硬编码为 `640×480`。换相机后想用 `1920×1080`,只能改源码重编译。
|
||||
|
||||
### 2.2 有参数的好处
|
||||
|
||||
```python
|
||||
class CameraDriver(Node):
|
||||
def __init__(self):
|
||||
super().__init__('camera_driver')
|
||||
self.declare_parameter('width', 640)
|
||||
self.declare_parameter('height', 480)
|
||||
self.declare_parameter('frame_rate', 30)
|
||||
# ...
|
||||
```
|
||||
|
||||
换相机 / 调分辨率,不用改代码,只要改 launch 文件 / YAML / CLI:
|
||||
|
||||
```bash
|
||||
ros2 param set /camera_driver width 1920
|
||||
```
|
||||
|
||||
### 2.3 ROS2 参数的设计目标
|
||||
|
||||
引用 [ROS2 Design: Parameter](https://design.ros2.org/articles/ros_parameters.html):
|
||||
|
||||
> "Parameters are intended to be a way to configure nodes at startup or during runtime, without changing code."
|
||||
|
||||
关键点:
|
||||
|
||||
1. **无代码修改** — 配置与代码解耦
|
||||
2. **支持运行时修改** — 不重启节点也能改
|
||||
3. **类型安全** — 7 种类型 + 校验
|
||||
4. **可回调** — 节点能响应参数变化
|
||||
5. **可序列化** — YAML / 命令行
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计原理 (Design)
|
||||
|
||||
### 3.1 架构图
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Parameter Server │ ← 全局服务 /set_parameters, /list_parameters, /describe_parameters, /get_parameters
|
||||
│ (每个进程内置,无独立进程) │
|
||||
└──────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ set_parameters │ get_parameters
|
||||
│ │
|
||||
┌──────┴──────┐ ┌──────┴──────┐
|
||||
│ Node A │ │ Node B │
|
||||
│ params: │ │ params: │
|
||||
│ - rate=10 │ │ - topic= │
|
||||
│ - topic=X │ │ - format= │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
注意:**Parameter Server 不是独立进程**,它运行在每个节点进程内的 `rcl` 层(具体是 `rcl_params`)。每个节点都有自己的参数副本,通过 DDS 同步。
|
||||
|
||||
### 3.2 关键概念
|
||||
|
||||
#### a) Declare vs Use
|
||||
|
||||
```python
|
||||
# 第一步:声明(必须)
|
||||
self.declare_parameter('rate', 1.0)
|
||||
|
||||
# 第二步:使用
|
||||
rate = self.get_parameter('rate').value
|
||||
```
|
||||
|
||||
不声明就 `get_parameter` → 抛 `ParameterNotDeclaredException`。
|
||||
|
||||
#### b) On-Set Callback
|
||||
|
||||
```python
|
||||
self.add_on_set_parameters_callback(self._on_change)
|
||||
```
|
||||
|
||||
回调签名:`Callable[[List[Parameter]], SetParametersResult]`。
|
||||
|
||||
返回 `SetParametersResult(successful=True)` → 接受;返回 `(False, reason)` → 拒绝。
|
||||
|
||||
**注意**: 回调是**同步阻塞**的,执行慢的回调会卡住参数设置。
|
||||
|
||||
#### c) Parameter Override
|
||||
|
||||
启动顺序(优先级从高到低):
|
||||
|
||||
```
|
||||
1. CLI: --params-file /path/to/file.yaml
|
||||
2. CLI: -p param_name:=value
|
||||
3. Launch: Node(parameters=[yaml_file])
|
||||
4. YAML 文件: config/params.yaml
|
||||
5. 代码: declare_parameter('name', default_value)
|
||||
```
|
||||
|
||||
最右的默认值优先级最低,CLI / Launch 覆盖它。
|
||||
|
||||
#### d) 持久化参数 (YAML I/O)
|
||||
|
||||
```bash
|
||||
# 保存当前参数
|
||||
ros2 param dump /node_name > saved.yaml
|
||||
|
||||
# 恢复
|
||||
ros2 param load /node_name saved.yaml
|
||||
```
|
||||
|
||||
格式:
|
||||
|
||||
```yaml
|
||||
/node_name:
|
||||
ros__parameters:
|
||||
rate: 5.0
|
||||
topic: "/chatter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 全解 (API)
|
||||
|
||||
### 4.1 rclpy API
|
||||
|
||||
#### 声明
|
||||
|
||||
```python
|
||||
declare_parameter(
|
||||
name: str,
|
||||
value: Any = None, # 推断类型
|
||||
descriptor: str = '',
|
||||
ignore_override: bool = False,
|
||||
) -> Parameter
|
||||
```
|
||||
|
||||
#### 读取
|
||||
|
||||
```python
|
||||
get_parameter(name: str) -> Parameter
|
||||
# or
|
||||
get_parameters(names: List[str]) -> List[Parameter]
|
||||
```
|
||||
|
||||
`Parameter` 对象的属性:
|
||||
|
||||
- `.name`: 参数名
|
||||
- `.value`: 参数值(类型推断)
|
||||
- `.type`: ParameterType 枚举
|
||||
- `.descriptor`: 描述符
|
||||
|
||||
#### 设置
|
||||
|
||||
```python
|
||||
set_parameters(parameters: List[Parameter]) -> List[SetParametersResult]
|
||||
```
|
||||
|
||||
或原子性:
|
||||
|
||||
```python
|
||||
set_parameters_atomically(parameters: List[Parameter]) -> SetParametersResult
|
||||
```
|
||||
|
||||
#### 回调
|
||||
|
||||
```python
|
||||
add_on_set_parameters_callback(
|
||||
callback: Callable[[List[Parameter]], SetParametersResult],
|
||||
prepend: bool = False,
|
||||
) -> None
|
||||
|
||||
remove_on_set_parameters_callback(callback) -> None
|
||||
```
|
||||
|
||||
#### 列出 / 描述
|
||||
|
||||
```python
|
||||
list_parameters() -> List[str]
|
||||
describe_parameters(names: List[str]) -> List[ParameterDescriptor]
|
||||
```
|
||||
|
||||
### 4.2 CLI 工具
|
||||
|
||||
```bash
|
||||
# 列出某节点所有参数
|
||||
ros2 param list /node_name
|
||||
|
||||
# 读参数
|
||||
ros2 param get /node_name param_name
|
||||
|
||||
# 设参数
|
||||
ros2 param set /node_name param_name value
|
||||
|
||||
# 导出参数
|
||||
ros2 param dump /node_name
|
||||
|
||||
# 加载参数
|
||||
ros2 param load /node_name saved.yaml
|
||||
```
|
||||
|
||||
### 4.3 Launch 文件传参
|
||||
|
||||
#### 方式 A: 直接传值
|
||||
|
||||
```python
|
||||
Node(
|
||||
package='my_pkg',
|
||||
executable='my_node',
|
||||
parameters=[{
|
||||
'rate': 10.0,
|
||||
'topic': '/chatter',
|
||||
}],
|
||||
)
|
||||
```
|
||||
|
||||
#### 方式 B: 加载 YAML
|
||||
|
||||
```python
|
||||
from launch.substitutions import PathJoinSubstitution
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
config = PathJoinSubstitution([
|
||||
FindPackageShare('my_pkg'),
|
||||
'config',
|
||||
'params.yaml',
|
||||
])
|
||||
|
||||
Node(
|
||||
package='my_pkg',
|
||||
executable='my_node',
|
||||
parameters=[config],
|
||||
)
|
||||
```
|
||||
|
||||
#### 方式 C: CLI 覆盖
|
||||
|
||||
```bash
|
||||
ros2 run my_pkg my_node --ros-args -p rate:=10.0 -p topic:=/new_topic
|
||||
```
|
||||
|
||||
#### 方式 D: 全局参数
|
||||
|
||||
```python
|
||||
from launch_ros.actions import PushRosNamespace
|
||||
|
||||
# 启动时把所有参数推到 /my_ns
|
||||
PushRosNamespace('my_ns')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实战代码 (Code)
|
||||
|
||||
完整示例见 `src/py_params/`,这里讲关键设计:
|
||||
|
||||
### 5.1 主节点: `param_node.py`
|
||||
|
||||
```python
|
||||
class ParamNode(Node):
|
||||
def __init__(self):
|
||||
super().__init__('param_node')
|
||||
|
||||
# 1. 声明三个参数(类型自动推断)
|
||||
self.declare_parameter('publish_rate', 1.0)
|
||||
self.declare_parameter('topic_name', 'params_chatter')
|
||||
self.declare_parameter('message_prefix', 'Params:')
|
||||
|
||||
# 2. 读取参数
|
||||
topic_name = self.get_parameter('topic_name').value
|
||||
|
||||
# 3. 用参数构造发布者
|
||||
self._pub = self.create_publisher(String, topic_name, 10)
|
||||
|
||||
# 4. 用参数构造定时器
|
||||
rate = self.get_parameter('publish_rate').value
|
||||
self._timer = self.create_timer(1.0 / rate, self._cb)
|
||||
|
||||
# 5. 注册回调
|
||||
self.add_on_set_parameters_callback(self._on_change)
|
||||
```
|
||||
|
||||
### 5.2 回调: 拒绝非法值
|
||||
|
||||
```python
|
||||
def _on_change(self, params):
|
||||
for p in params:
|
||||
if p.name == 'publish_rate' and p.value <= 0.0:
|
||||
return SetParametersResult(
|
||||
successful=False,
|
||||
reason='publish_rate 必须 > 0'
|
||||
)
|
||||
return SetParametersResult(successful=True)
|
||||
```
|
||||
|
||||
### 5.3 YAML 配置: `config/params.yaml`
|
||||
|
||||
```yaml
|
||||
param_node:
|
||||
ros__parameters:
|
||||
publish_rate: 2.0
|
||||
topic_name: "params_chatter"
|
||||
message_prefix: "Configured:"
|
||||
```
|
||||
|
||||
### 5.4 Launch 文件
|
||||
|
||||
```python
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
from launch.substitutions import PathJoinSubstitution
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
def generate_launch_description():
|
||||
cfg = PathJoinSubstitution([
|
||||
FindPackageShare('py_params'),
|
||||
'config',
|
||||
'params.yaml',
|
||||
])
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='py_params',
|
||||
executable='param_node',
|
||||
parameters=[cfg],
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试策略 (Test)
|
||||
|
||||
### 6.1 单元测试: 声明默认值
|
||||
|
||||
```python
|
||||
def test_param_declaration():
|
||||
rclpy.init()
|
||||
node = ParamNode()
|
||||
assert node.get_parameter('publish_rate').value == 1.0
|
||||
```
|
||||
|
||||
### 6.2 单元测试: 合法 set
|
||||
|
||||
```python
|
||||
new_param = Parameter(
|
||||
name='publish_rate',
|
||||
value=ParameterValue(type=ParameterType.PARAMETER_DOUBLE, double_value=5.0),
|
||||
)
|
||||
result = node.set_parameters([new_param])
|
||||
assert result[0].successful is True
|
||||
```
|
||||
|
||||
### 6.3 单元测试: 非法值被拒绝
|
||||
|
||||
```python
|
||||
bad = Parameter(
|
||||
name='publish_rate',
|
||||
value=ParameterValue(type=ParameterType.PARAMETER_DOUBLE, double_value=-1.0),
|
||||
)
|
||||
result = node.set_parameters([bad])
|
||||
assert result[0].successful is False
|
||||
assert '必须 > 0' in result[0].reason
|
||||
```
|
||||
|
||||
### 6.4 YAML 集成测试
|
||||
|
||||
```python
|
||||
def test_yaml_loadable():
|
||||
with open('config/params.yaml') as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
assert cfg['param_node']['ros__parameters']['publish_rate'] == 2.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 进阶玩法 (Advanced)
|
||||
|
||||
### 7.1 动态重配置 (Dynamic Reconfigure)
|
||||
|
||||
ROS1 时代的 dynamic_reconfigure,在 ROS2 里被参数 + 回调取代。
|
||||
|
||||
例: 实时调 PID 参数:
|
||||
|
||||
```python
|
||||
def _on_change(self, params):
|
||||
for p in params:
|
||||
if p.name == 'kp':
|
||||
self._pid.set_kp(p.value)
|
||||
elif p.name == 'kd':
|
||||
self._pid.set_kd(p.value)
|
||||
return SetParametersResult(successful=True)
|
||||
```
|
||||
|
||||
外部通过 `ros2 param set` 实时调:
|
||||
|
||||
```bash
|
||||
ros2 param set /controller kp 0.5
|
||||
```
|
||||
|
||||
### 7.2 参数回调链 (Callback Chain)
|
||||
|
||||
多个回调按顺序执行,任何一个返回 False 整链失败:
|
||||
|
||||
```python
|
||||
node.add_on_set_parameters_callback(cb_validate) # 校验
|
||||
node.add_on_set_parameters_callback(cb_propagate) # 传给内部子系统
|
||||
```
|
||||
|
||||
### 7.3 全局参数 (Global Parameter)
|
||||
|
||||
通过 `PushRosNamespace` 把所有参数推到命名空间:
|
||||
|
||||
```python
|
||||
from launch_ros.actions import PushRosNamespace
|
||||
|
||||
LaunchDescription([
|
||||
PushRosNamespace('robot1'),
|
||||
Node(package='cam', executable='driver'),
|
||||
])
|
||||
# 启动后参数命名:/robot1/cam/driver/...
|
||||
```
|
||||
|
||||
### 7.4 参数文件覆盖
|
||||
|
||||
启动顺序优先级(从高到低):
|
||||
|
||||
```
|
||||
1. --params-file (CLI)
|
||||
2. -p name:=value (CLI)
|
||||
3. Launch(parameters=...)
|
||||
4. YAML 文件
|
||||
5. declare_parameter 默认值
|
||||
```
|
||||
|
||||
多个 YAML 文件,后面的覆盖前面的(数组传参)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 故障排查 (Pitfalls)
|
||||
|
||||
### 8.1 `ParameterNotDeclaredException`
|
||||
|
||||
**症状**: `get_parameter` 抛异常。
|
||||
|
||||
**原因**: 没 `declare_parameter` 就 `get_parameter`。
|
||||
|
||||
**解决**:
|
||||
|
||||
```python
|
||||
# 错误
|
||||
def __init__(self):
|
||||
rate = self.get_parameter('rate').value # 💥
|
||||
|
||||
# 正确
|
||||
def __init__(self):
|
||||
self.declare_parameter('rate', 1.0) # 先声明
|
||||
rate = self.get_parameter('rate').value # 再 get
|
||||
```
|
||||
|
||||
### 8.2 回调未触发
|
||||
|
||||
**症状**: `ros2 param set` 后回调没反应。
|
||||
|
||||
**原因**: 多个回调,前一个返回 False 短路了。
|
||||
|
||||
**解决**: 检查每个回调的返回值,所有都要 `successful=True`。
|
||||
|
||||
### 8.3 YAML 没加载
|
||||
|
||||
**症状**: 启动后参数是默认值,不是 YAML 里的值。
|
||||
|
||||
**原因**: YAML 路径错 / 节点名不匹配。
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
# 检查 YAML 是否被安装到 share/
|
||||
ros2 pkg prefix py_params
|
||||
# 查看 install/py_params/share/py_params/config/params.yaml
|
||||
|
||||
# 启动时打印实际加载的参数
|
||||
ros2 param list /param_node # 看实际值
|
||||
```
|
||||
|
||||
### 8.4 浮点精度
|
||||
|
||||
**症状**: `ros2 param set rate 0.1` 后实际值是 `0.10000000149...`。
|
||||
|
||||
**原因**: IEEE 754 浮点表示。
|
||||
|
||||
**解决**: 容忍误差,或在回调里 `round(p.value, 3)`。
|
||||
|
||||
### 8.5 数组参数类型不匹配
|
||||
|
||||
**症状**: `get_parameter` 抛 `ParameterTypeMismatchException`。
|
||||
|
||||
**原因**: YAML 写 `'topic'`(字符串)但代码期望 `topic_name` 是字符串数组。
|
||||
|
||||
**解决**: 检查 YAML 缩进和 `[]` / 引号:
|
||||
|
||||
```yaml
|
||||
# 字符串数组
|
||||
names: ["a", "b", "c"]
|
||||
# 或
|
||||
names: ['a', 'b', 'c']
|
||||
```
|
||||
|
||||
### 8.6 Callback 阻塞导致 hang
|
||||
|
||||
**症状**: `ros2 param set` 卡死。
|
||||
|
||||
**原因**: 回调里有阻塞 I/O(网络 / 大文件)。
|
||||
|
||||
**解决**: 把阻塞操作放后台线程,回调只做校验。
|
||||
|
||||
### 8.7 Atomically vs 普通 set
|
||||
|
||||
**症状**: 多参数同时设置,部分生效部分失败。
|
||||
|
||||
**解决**: 用 `set_parameters_atomically`,要么全成功要么全失败。
|
||||
|
||||
```python
|
||||
result = node.set_parameters_atomically([
|
||||
Parameter(name='kp', value=...),
|
||||
Parameter(name='kd', value=...),
|
||||
])
|
||||
if not result.successful:
|
||||
self.get_logger().error(f'set failed: {result.reason}')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 推荐阅读 (Further)
|
||||
|
||||
### 官方文档
|
||||
|
||||
- [ROS2 Parameter Design](https://design.ros2.org/articles/ros_parameters.html) — 设计稿,必读
|
||||
- [ROS2 Humble Parameter Tutorial](https://docs.ros.org/en/humble/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Parameters/Understanding-ROS2-Parameters.html)
|
||||
- [ROS2 CLI: ros2 param](https://docs.ros.org/en/humble/Tutorials/Beginner-CLI-Tools/Using-ROS2-CLI-Tools.html)
|
||||
- [rclpy: Parameter class](https://docs.ros2.org/en/latest/rclpy_api/rclpy.parameter.html)
|
||||
- [rcl_interfaces.msg](https://github.com/ros2/rcl_interfaces) — Parameter / ParameterValue / SetParametersResult 消息定义
|
||||
|
||||
### 相关 RFC / 设计稿
|
||||
|
||||
- [ROS2 Design: Parameter Validation](https://design.ros2.org/articles/ros_parameters.html#parameter-validation)
|
||||
- [ROS2 Design: On-Set Parameters Callback](https://design.ros2.org/articles/ros_parameters.html#on-set-parameters-callback)
|
||||
|
||||
### 进阶话题
|
||||
|
||||
- **与 ros2_control 集成**: hardware_interface 启动时从 YAML 加载控制器参数
|
||||
- **与 MoveIt2 集成**: PlanningScene 用参数配置避障
|
||||
- **与 Nav2 集成**: 行为树参数、Costmap 参数全部走 ROS2 参数
|
||||
|
||||
### 实战例子
|
||||
|
||||
- [ros2/demos: topic_monitor](https://github.com/ros2/demos/blob/humble/demo_nodes_py/demo_nodes_py/topics/topic_monitor.py)
|
||||
- [turtlebot3: 参数化](https://github.com/ROBOTIS-GIT/turtlebot3/blob/humble/turtlebot3_node/src/turtlebot3_node.cpp)
|
||||
|
||||
---
|
||||
|
||||
## 一句话总结
|
||||
|
||||
> **ROS2 参数 = 节点的"配置项",从 launch / YAML / CLI 传入,运行时可改,回调里能拒绝非法值。设计目标是"配置与代码解耦"。**
|
||||
|
||||
下一节: `doc/16-custom-interfaces.md` 学习自定义 .msg / .srv / .action。
|
||||
@@ -0,0 +1,196 @@
|
||||
# 16 · 自定义接口 (.msg / .srv / .action) 完全指南
|
||||
|
||||
> **目标**: 理解 ROS2 自定义接口的设计原理、文件格式、rosidl 工具链、能独立写 .msg / .srv / .action。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 为什么需要自定义](#1-为什么需要自定义)
|
||||
- [2. .msg 文件格式](#2-msg-文件格式)
|
||||
- [3. .srv 文件格式](#3-srv-文件格式)
|
||||
- [4. .action 文件格式](#4-action-文件格式)
|
||||
- [5. rosidl_generate_interfaces](#5-rosidl_generate_interfaces)
|
||||
- [6. CMakeLists.txt + package.xml 配置](#6-cmakeliststxt--packagexml-配置)
|
||||
- [7. C++ / Python 使用](#7-c--python-使用)
|
||||
- [8. 设计原则](#8-设计原则)
|
||||
- [9. 推荐阅读](#9-推荐阅读)
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么需要自定义
|
||||
|
||||
ROS2 标准消息(`std_msgs` / `sensor_msgs` / `geometry_msgs`)覆盖了 80% 场景,但有些项目特定需求:
|
||||
|
||||
- 工业传感器读数(温度 + 压力 + 校准日期)
|
||||
- 机器人状态(电量 + 温度 + 故障码)
|
||||
- 业务动作(下单 + 支付 + 物流)
|
||||
|
||||
这些都需要**项目特定**的自定义接口。
|
||||
|
||||
## 2. .msg 文件格式
|
||||
|
||||
```
|
||||
# 注释(以 # 开头)
|
||||
<type> <field_name> # 字段
|
||||
```
|
||||
|
||||
字段类型:
|
||||
|
||||
| ROS 类型 | C++ 类型 | Python 类型 |
|
||||
|---|---|---|
|
||||
| `bool` | `bool` | `bool` |
|
||||
| `int8`/`int16`/`int32`/`int64` | `int8_t`/... | `int` |
|
||||
| `uint8`/.../`uint64` | `uint8_t`/... | `int` |
|
||||
| `float32`/`float64` | `float`/`double` | `float` |
|
||||
| `string` | `std::string` | `str` |
|
||||
| `time`/`duration` | `builtin_interfaces::msg::Time` | `Time` |
|
||||
| 其他消息类型 | `pkg::msg::Type` | `pkg.msg.Type` |
|
||||
| `type[]` | `std::vector<T>` | `List[T]` |
|
||||
| `type[3]` | `std::array<T,3>` | `Tuple[T,T,T]` |
|
||||
|
||||
示例: `msg/SensorReading.msg`
|
||||
|
||||
```
|
||||
std_msgs/Header header
|
||||
string sensor_id
|
||||
string unit
|
||||
float64 value
|
||||
```
|
||||
|
||||
## 3. .srv 文件格式
|
||||
|
||||
```
|
||||
# Request 字段(--- 上)
|
||||
<type> <field_name>
|
||||
---
|
||||
# Response 字段(--- 下)
|
||||
<type> <field_name>
|
||||
```
|
||||
|
||||
示例: `srv/GetCalibration.srv`
|
||||
|
||||
```
|
||||
string sensor_id
|
||||
---
|
||||
float64[9] intrinsic_matrix
|
||||
float64[3] bias
|
||||
string calibration_date
|
||||
bool valid
|
||||
```
|
||||
|
||||
## 4. .action 文件格式
|
||||
|
||||
```
|
||||
# Goal 字段(第 1 段)
|
||||
<type> <field_name>
|
||||
---
|
||||
# Result 字段(第 2 段)
|
||||
<type> <field_name>
|
||||
---
|
||||
# Feedback 字段(第 3 段)
|
||||
<type> <field_name>
|
||||
```
|
||||
|
||||
示例: `action/MoveArm.action`
|
||||
|
||||
```
|
||||
geometry_msgs/PoseStamped target_pose
|
||||
string[] joint_names
|
||||
float32 max_velocity_scaling
|
||||
float32 max_acceleration_scaling
|
||||
---
|
||||
bool success
|
||||
string error_message
|
||||
float64 total_time_sec
|
||||
---
|
||||
float32 progress
|
||||
string current_state
|
||||
```
|
||||
|
||||
## 5. rosidl_generate_interfaces
|
||||
|
||||
ROS2 用 `rosidl` 自动从 .msg/.srv/.action 生成 C++ / Python 代码:
|
||||
|
||||
```cmake
|
||||
find_package(rosidl_default_generators REQUIRED)
|
||||
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
"msg/SensorReading.msg"
|
||||
"srv/GetCalibration.srv"
|
||||
"action/MoveArm.action"
|
||||
DEPENDENCIES std_msgs geometry_msgs
|
||||
ADD_LINTER_TESTS
|
||||
)
|
||||
```
|
||||
|
||||
生成位置:
|
||||
- C++ 头文件:`install/<pkg>/include/<pkg>/msg/<msg_name>.hpp`
|
||||
- Python 模块:`install/<pkg>/lib/python3.10/site-packages/<pkg>/msg/<msg_name>.py`
|
||||
|
||||
## 6. CMakeLists.txt + package.xml 配置
|
||||
|
||||
**`package.xml` 必备**:
|
||||
|
||||
```xml
|
||||
<buildtool_depend>rosidl_default_generators</buildtool_depend>
|
||||
<exec_depend>rosidl_default_runtime</exec_depend>
|
||||
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||
```
|
||||
|
||||
**`CMakeLists.txt` 顺序**:
|
||||
|
||||
```cmake
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rosidl_default_generators REQUIRED)
|
||||
find_package(std_msgs REQUIRED)
|
||||
find_package(geometry_msgs REQUIRED)
|
||||
|
||||
rosidl_generate_interfaces(${PROJECT_NAME} ...)
|
||||
|
||||
ament_target_dependencies(${PROJECT_NAME}_core rclcpp "rosidl_typesupport_cpp")
|
||||
rosidl_target_interfaces(${PROJECT_NAME}_core ${PROJECT_NAME} "rosidl_typesupport_cpp")
|
||||
```
|
||||
|
||||
## 7. C++ / Python 使用
|
||||
|
||||
### C++
|
||||
|
||||
```cpp
|
||||
#include "cpp_custom_interface/msg/sensor_reading.hpp"
|
||||
|
||||
auto msg = cpp_custom_interface::msg::SensorReading();
|
||||
msg.sensor_id = "imu_0";
|
||||
msg.value = 1.234;
|
||||
publisher_->publish(msg);
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from cpp_custom_interface.msg import SensorReading
|
||||
|
||||
msg = SensorReading()
|
||||
msg.sensor_id = 'imu_0'
|
||||
msg.value = 1.234
|
||||
publisher.publish(msg)
|
||||
```
|
||||
|
||||
## 8. 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|---|---|
|
||||
| **优先标准接口** | 90% 场景用 `std_msgs` / `sensor_msgs` |
|
||||
| **字段 snake_case + 单位后缀** | `velocity_mps` 而不是 `v` |
|
||||
| **Header 必备** | 任何有"时间戳"的消息都加 `std_msgs/Header` |
|
||||
| **数组 vs 单值** | `float64[]` 用于多维数据,单值用 `float64` |
|
||||
| **不要嵌指针** | 用 ID (`string object_id`) 而非 `string&` |
|
||||
| **字段命名清晰** | `target_pose` 不是 `tp`,`max_velocity_scaling` 不是 `v_max` |
|
||||
| **Result 加 success + error_message** | client 知道成功还是失败 |
|
||||
|
||||
## 9. 推荐阅读
|
||||
|
||||
- [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) — 本仓库的演示
|
||||
@@ -0,0 +1,249 @@
|
||||
# 17 · 生命周期节点 (Lifecycle Node) 完全指南
|
||||
|
||||
> **目标**: 理解 ROS2 Lifecycle Node 的设计、状态机、转换机制,能用 Lifecycle Node 管理资源密集型节点。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 是什么](#1-是什么)
|
||||
- [2. 为什么需要](#2-为什么需要)
|
||||
- [3. 状态机](#3-状态机)
|
||||
- [4. 转换回调](#4-转换回调)
|
||||
- [5. Service 接口](#5-service-接口)
|
||||
- [6. Python 实现](#6-python-实现)
|
||||
- [7. C++ 实现](#7-c-实现)
|
||||
- [8. CLI 控制](#8-cli-控制)
|
||||
- [9. 实战模式](#9-实战模式)
|
||||
- [10. 推荐阅读](#10-推荐阅读)
|
||||
|
||||
---
|
||||
|
||||
## 1. 是什么
|
||||
|
||||
**Lifecycle Node** 是 rclcpp / rclpy 的特殊节点基类,提供:
|
||||
|
||||
- **受控状态切换**(configure → activate → deactivate → cleanup → shutdown)
|
||||
- **每种状态都有自己的回调**(on_configure / on_activate 等)
|
||||
- **外部触发转换**(通过 service 调用)
|
||||
|
||||
适用场景:
|
||||
|
||||
- 资源密集型节点(加载 ML 模型、连接硬件、订阅话题)
|
||||
- 需要明确"启动顺序"的复杂系统(相机先 ready,处理节点再 activate)
|
||||
- 安全敏感系统(切换前确认硬件 OK)
|
||||
|
||||
## 2. 为什么需要
|
||||
|
||||
普通节点的缺点:
|
||||
|
||||
```python
|
||||
# ❌ 普通节点 — 启动即订阅 / 订阅即消费 / 死了就完了
|
||||
class MyNode(Node):
|
||||
def __init__(self):
|
||||
super().__init__('my_node')
|
||||
self._sub = self.create_subscription(...) # 一启动就开始消费
|
||||
```
|
||||
|
||||
问题:
|
||||
- 想"暂停"接收?做不到
|
||||
- 想"重新初始化"?得 kill 重启
|
||||
- 想"加载模型失败就停"?只能异常退出
|
||||
|
||||
Lifecycle Node 解决:
|
||||
|
||||
```python
|
||||
# ✅ Lifecycle Node — 显式状态切换
|
||||
class MyLifecycleNode(LifecycleNode):
|
||||
def on_configure(self, state):
|
||||
# 加载模型、分配资源
|
||||
# 失败 → return FAILURE,不会进入 active
|
||||
|
||||
def on_activate(self, state):
|
||||
# 订阅话题、启动定时器
|
||||
# 失败 → return FAILURE,自动 cleanup
|
||||
|
||||
def on_deactivate(self, state):
|
||||
# 停止订阅、暂停定时器(但资源仍在)
|
||||
|
||||
def on_cleanup(self, state):
|
||||
# 释放模型、断开连接(回到 unconfigured)
|
||||
```
|
||||
|
||||
## 3. 状态机
|
||||
|
||||
```
|
||||
configure
|
||||
unconfigured ───────→ inactive
|
||||
▲ │ │ activate
|
||||
│ │ cleanup ▼
|
||||
│ └────────────── active
|
||||
│ │ deactivate
|
||||
└──────────────────────┘
|
||||
|
||||
shutdown(任何状态都可触发)→ finalized
|
||||
```
|
||||
|
||||
四个主要状态:
|
||||
- `unconfigured`: 已创建但未配置
|
||||
- `inactive`: 已配置但未激活
|
||||
- `active`: 完全运行(处理数据)
|
||||
- `finalized`: 终止(不可逆)
|
||||
|
||||
转换事件(transition):
|
||||
- `configure`: unconfigured → inactive
|
||||
- `activate`: inactive → active
|
||||
- `deactivate`: active → inactive
|
||||
- `cleanup`: inactive → unconfigured
|
||||
- `shutdown`: 任何 → finalized
|
||||
|
||||
## 4. 转换回调
|
||||
|
||||
每个转换回调签名: `(state: State) -> TransitionCallbackReturn`
|
||||
|
||||
返回:
|
||||
- `SUCCESS`: 转换成功,进入目标状态
|
||||
- `FAILURE`: 转换失败,回到原状态
|
||||
- `ERROR`: 转换错误,直接进 finalized
|
||||
|
||||
**必须重写**:
|
||||
- `on_configure(state)`
|
||||
- `on_activate(state)`
|
||||
- `on_deactivate(state)`
|
||||
- `on_cleanup(state)`
|
||||
- `on_shutdown(state)`
|
||||
|
||||
**可选重写**:
|
||||
- `on_error(state)`: 错误处理
|
||||
|
||||
## 5. Service 接口
|
||||
|
||||
每个 Lifecycle Node 自动注册两个 service:
|
||||
|
||||
- `/<node>/change_state` (`lifecycle_msgs/srv/ChangeState`)— 触发转换
|
||||
- `/<node>/get_state` (`lifecycle_msgs/srv/GetState`)— 查询状态
|
||||
|
||||
```bash
|
||||
# 查状态
|
||||
ros2 service call /lifecycle_demo_node/get_state lifecycle_msgs/srv/GetState
|
||||
|
||||
# 触发 configure (transition id = 1)
|
||||
ros2 service call /lifecycle_demo_node/change_state \
|
||||
lifecycle_msgs/srv/ChangeState "{transition: {id: 1}}"
|
||||
```
|
||||
|
||||
Transition IDs:
|
||||
|
||||
| ID | 转换 |
|
||||
|---|---|
|
||||
| 0 | configure |
|
||||
| 1 | cleanup |
|
||||
| 2 | activate |
|
||||
| 3 | deactivate |
|
||||
| 4 | shutdown |
|
||||
|
||||
## 6. Python 实现
|
||||
|
||||
```python
|
||||
from rclpy.lifecycle import LifecycleNode, TransitionCallbackReturn
|
||||
|
||||
class LifecycleDemoNode(LifecycleNode):
|
||||
def on_configure(self, state):
|
||||
self._publisher = self.create_lifecycle_publisher(String, 'topic', 10)
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
|
||||
def on_activate(self, state):
|
||||
self._timer = self.create_timer(0.5, self._publish)
|
||||
return super().on_activate(state)
|
||||
|
||||
def on_deactivate(self, state):
|
||||
self.destroy_timer(self._timer)
|
||||
return super().on_deactivate(state)
|
||||
|
||||
def on_cleanup(self, state):
|
||||
self.destroy_lifecycle_publisher(self._publisher)
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
```
|
||||
|
||||
## 7. C++ 实现
|
||||
|
||||
```cpp
|
||||
#include "rclcpp_lifecycle/lifecycle_node.hpp"
|
||||
|
||||
class LifecycleDemoNode : public rclcpp_lifecycle::LifecycleNode
|
||||
{
|
||||
public:
|
||||
LifecycleDemoNode() : rclcpp_lifecycle::LifecycleNode("demo") {}
|
||||
|
||||
rclcpp_lifecycle::node_interfaces::LifecycleNodeInterface::CallbackReturn
|
||||
on_configure(const rclcpp_lifecycle::State &)
|
||||
{
|
||||
publisher_ = this->create_publisher<String>("topic", 10);
|
||||
return rclcpp_lifecycle::node_interfaces::LifecycleNodeInterface::CallbackReturn::SUCCESS;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 8. CLI 控制
|
||||
|
||||
```bash
|
||||
# 1. 启 Lifecycle Node
|
||||
ros2 launch my_pkg lifecycle_demo.py
|
||||
|
||||
# 2. 触发 configure
|
||||
ros2 lifecycle set /lifecycle_node configure
|
||||
# (Humble 后 ros2 lifecycle set 直接用,而不是 service call)
|
||||
|
||||
# 3. 触发 activate
|
||||
ros2 lifecycle set /lifecycle_node activate
|
||||
|
||||
# 4. 看状态
|
||||
ros2 lifecycle get /lifecycle_node
|
||||
# 输出:active [3]
|
||||
```
|
||||
|
||||
## 9. 实战模式
|
||||
|
||||
### 模式 1: ML 模型加载
|
||||
|
||||
```python
|
||||
def on_configure(self, state):
|
||||
try:
|
||||
self._model = torch.load('model.pt') # 加载 ML 模型
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
except FileNotFoundError:
|
||||
return TransitionCallbackReturn.FAILURE # 配置失败,节点不可用
|
||||
|
||||
def on_activate(self, state):
|
||||
self._sub = self.create_subscription(Image, 'image_raw', self._infer, 10)
|
||||
return super().on_activate(state)
|
||||
|
||||
def _infer(self, msg):
|
||||
if self._model is None: return # 不会到这里(因为 activate 前要 configure 成功)
|
||||
result = self._model(msg)
|
||||
...
|
||||
```
|
||||
|
||||
### 模式 2: 硬件连接(相机)
|
||||
|
||||
```python
|
||||
def on_configure(self, state):
|
||||
try:
|
||||
self._camera = cv2.VideoCapture(0)
|
||||
if not self._camera.isOpened():
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
except Exception:
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
|
||||
def on_cleanup(self, state):
|
||||
if self._camera:
|
||||
self._camera.release()
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
```
|
||||
|
||||
## 10. 推荐阅读
|
||||
|
||||
- [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)
|
||||
@@ -0,0 +1,188 @@
|
||||
# 18 · 组合节点 (Composable Node) 完全指南
|
||||
|
||||
> **目标**: 理解 ROS2 Composable Node 设计,能把多个节点合并到同一进程,降低延迟与开销。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 是什么](#1-是什么)
|
||||
- [2. 为什么需要](#2-为什么需要)
|
||||
- [3. 工作原理](#3-工作原理)
|
||||
- [4. C++ 实现(.so 库)](#4-c-实现so-库)
|
||||
- [5. Container 启动](#5-container-启动)
|
||||
- [6. Python 等价做法](#6-python-等价做法)
|
||||
- [7. 性能对比](#7-性能对比)
|
||||
- [8. 何时用](#8-何时用)
|
||||
- [9. 推荐阅读](#9-推荐阅读)
|
||||
|
||||
---
|
||||
|
||||
## 1. 是什么
|
||||
|
||||
**Composable Node** = 把多个 ROS 节点装到**同一个进程**(共享内存)。
|
||||
|
||||
```
|
||||
传统模式:
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Process1 │ │ Process2 │ │ Process3 │
|
||||
│ Node A │ │ Node B │ │ Node C │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
│ DDS │ │ DDS │ │ DDS │
|
||||
└─────┴────────┴────────┴────────┘
|
||||
(跨进程通信,微秒级延迟)
|
||||
|
||||
Composable 模式:
|
||||
┌────────────────────────────┐
|
||||
│ Single Process │
|
||||
│ Node A │ Node B │ Node C │
|
||||
│ (shared memory) │
|
||||
└────────────────────────────┘
|
||||
│ DDS │
|
||||
(只有出/入本进程时走 DDS)
|
||||
```
|
||||
|
||||
## 2. 为什么需要
|
||||
|
||||
| 场景 | 传统模式延迟 | Composable 延迟 | 提升 |
|
||||
|---|---|---|---|
|
||||
| 5 节点 pipeline | ~500μs | ~50μs | **10x** |
|
||||
| 高频 sensor fusion | ~1ms | ~100μs | **10x** |
|
||||
| 大量小消息 | 频繁拷贝 | 共享指针 | **CPU 降 30%** |
|
||||
|
||||
其他好处:
|
||||
- 启动快(避免 fork)
|
||||
- 内存共享(零拷贝)
|
||||
- 调试简单(单进程,单 gdb)
|
||||
|
||||
## 3. 工作原理
|
||||
|
||||
ROS2 Composable = C++ **共享库(.so)** + **Container 进程**。
|
||||
|
||||
1. 把节点代码编译成 .so 库(`libmy_component.so`)
|
||||
2. Container 进程(`component_container_mt`)加载 .so
|
||||
3. Container 实例化组件(无需 fork)
|
||||
4. 组件间用**进程内 publish/subscribe**(不经过 DDS)
|
||||
|
||||
## 4. C++ 实现(.so 库)
|
||||
|
||||
**`my_pkg/src/my_component.cpp`**:
|
||||
|
||||
```cpp
|
||||
#include "rclcpp_components/register_node_macro.hpp"
|
||||
|
||||
class MyComponent : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit MyComponent(const rclcpp::NodeOptions & options)
|
||||
: Node("my_component", options) {}
|
||||
};
|
||||
|
||||
RCLCPP_COMPONENTS_REGISTER_NODE(MyComponent)
|
||||
```
|
||||
|
||||
**`CMakeLists.txt`**:
|
||||
|
||||
```cmake
|
||||
add_library(my_component SHARED src/my_component.cpp)
|
||||
ament_target_dependencies(my_component rclcpp)
|
||||
rclcpp_components_register_node(my_component "my_component")
|
||||
|
||||
install(TARGETS my_component
|
||||
ARCHIVE DESTINATION lib
|
||||
LIBRARY DESTINATION lib
|
||||
RUNTIME DESTINATION bin
|
||||
)
|
||||
```
|
||||
|
||||
## 5. Container 启动
|
||||
|
||||
```bash
|
||||
# 1. 单线程 container(调试用)
|
||||
ros2 component standalone --container-type standalone
|
||||
|
||||
# 2. 多线程 container(生产用)
|
||||
ros2 component standalone --container-type multithreaded
|
||||
|
||||
# 3. 在已有 container 里加载组件
|
||||
ros2 component load <container_name> <package_name> <component_name>
|
||||
|
||||
# 例:
|
||||
ros2 component load /ComponentManager my_pkg my_component
|
||||
```
|
||||
|
||||
### Launch 文件
|
||||
|
||||
```python
|
||||
from launch_ros.actions import ComposableNodeContainer
|
||||
from launch_ros.descriptions import ComposableNode
|
||||
|
||||
container = ComposableNodeContainer(
|
||||
name='my_container',
|
||||
namespace='',
|
||||
package='rclcpp_components',
|
||||
executable='component_container_mt',
|
||||
composable_node_descriptions=[
|
||||
ComposableNode(
|
||||
package='my_pkg',
|
||||
plugin='my_pkg::MyComponent',
|
||||
name='node_a',
|
||||
),
|
||||
ComposableNode(
|
||||
package='my_pkg',
|
||||
plugin='my_pkg::MyComponent',
|
||||
name='node_b',
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## 6. Python 等价做法
|
||||
|
||||
Python **不支持**真正的 Composable Node(必须用 C++ .so)。但有等价做法:
|
||||
|
||||
```python
|
||||
# 同进程多节点(共享内存,但仍走 DDS 内部)
|
||||
import rclpy
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
|
||||
rclpy.init()
|
||||
node_a = NodeA()
|
||||
node_b = NodeB()
|
||||
|
||||
executor = MultiThreadedExecutor(num_threads=4)
|
||||
executor.add_node(node_a)
|
||||
executor.add_node(node_b)
|
||||
executor.spin()
|
||||
```
|
||||
|
||||
Python 多节点同进程 + MultiThreadedExecutor 是 ROS2 Python 等价的 Composable 做法。
|
||||
|
||||
## 7. 性能对比
|
||||
|
||||
| 维度 | 传统多进程 | C++ Composable | Python 多线程 |
|
||||
|---|---|---|---|
|
||||
| 启动时间 | 慢(每个进程 fork) | 快(动态加载) | 中 |
|
||||
| 进程间延迟 | ~100μs (DDS) | ~5μs (shared mem) | ~10μs |
|
||||
| 内存 | 每进程独立 | 共享 | 共享 |
|
||||
| 调试 | gdb attach 多个 | gdb 单进程 | gdb 单进程 |
|
||||
| 灵活性 | 高(可单独 kill) | 低(同进程) | 低 |
|
||||
|
||||
## 8. 何时用
|
||||
|
||||
**用 Composable**:
|
||||
- 同一 pipeline 多个节点(image → process → control)
|
||||
- 高频消息流(>100Hz)
|
||||
- 延迟敏感(机器人控制回路)
|
||||
|
||||
**不用 Composable**:
|
||||
- 节点可独立部署(某些在 PC,某些在 RK3506)
|
||||
- 需要单独 kill 重启某些节点
|
||||
- 节点崩溃隔离(传统模式崩溃只影响一个进程)
|
||||
|
||||
## 9. 推荐阅读
|
||||
|
||||
- [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)
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# 19 · QoS (服务质量) 完全指南
|
||||
|
||||
> **目标**: 理解 ROS2 QoS 的 5 个维度、兼容规则、9 种常用组合、能独立为节点选 QoS。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 是什么](#1-是什么)
|
||||
- [2. 5 大维度](#2-5-大维度)
|
||||
- [3. 兼容规则](#3-兼容规则)
|
||||
- [4. 9 种常用组合](#4-9-种常用组合)
|
||||
- [5. Python API](#5-python-api)
|
||||
- [6. C++ API](#6-c-api)
|
||||
- [7. rclpy QoSProfile 详解](#7-rclpy-qosprofile-详解)
|
||||
- [8. 调试 QoS 不兼容](#8-调试-qos-不兼容)
|
||||
- [9. 推荐阅读](#9-推荐阅读)
|
||||
|
||||
---
|
||||
|
||||
## 1. 是什么
|
||||
|
||||
**QoS (Quality of Service)** 控制 DDS 通信的可靠性、实时性、持久性等。
|
||||
|
||||
ROS2 默认 QoS = `RELIABLE + VOLATILE + KEEP_LAST(10)`,适合大多数场景。
|
||||
但**高频传感器流** / **控制指令** / **参数发布**等场景需要**自定义 QoS**。
|
||||
|
||||
## 2. 5 大维度
|
||||
|
||||
| 维度 | 取值 | 默认 | 含义 |
|
||||
|---|---|---|---|
|
||||
| **Reliability** | RELIABLE / BEST_EFFORT | RELIABLE | 必须投递 / 丢一帧无所谓 |
|
||||
| **History** | KEEP_LAST(N) / KEEP_ALL | KEEP_LAST(10) | 队列策略 |
|
||||
| **Durability** | VOLATILE / TRANSIENT_LOCAL | VOLATILE | 晚订阅者是否收到旧数据 |
|
||||
| **Deadline** | Duration | ∞ | 最长多久发一次 |
|
||||
| **Lifespan** | Duration | ∞ | 多旧的消息失效 |
|
||||
|
||||
## 3. 兼容规则
|
||||
|
||||
**关键规则** — RELIABLE ↔ BEST_EFFORT 单向兼容:
|
||||
|
||||
| Publisher ↓ \ Subscriber → | RELIABLE | BEST_EFFORT |
|
||||
|---|---|---|
|
||||
| RELIABLE | ✅ | ❌ |
|
||||
| BEST_EFFORT | ✅ | ✅ |
|
||||
|
||||
**为什么 RELIABLE → BEST_EFFORT 不兼容**:
|
||||
- RELIABLE pub 等 ACK(确保投递)
|
||||
- BEST_EFFORT sub 不发 ACK
|
||||
- pub 等不到 ACK → 报 "QoS incompatible"
|
||||
|
||||
**报错样例**:
|
||||
```
|
||||
[WARN] ... New subscription discovered on this topic with incompatible QoS ...
|
||||
```
|
||||
|
||||
## 4. 9 种常用组合
|
||||
|
||||
| Reliability | Durability | History | 适用场景 |
|
||||
|---|---|---|---|
|
||||
| RELIABLE | VOLATILE | KEEP_LAST(10) | **默认 / 跨语言互通基线** |
|
||||
| RELIABLE | VOLATILE | KEEP_LAST(1) | 控制指令(只关心最新) |
|
||||
| RELIABLE | TRANSIENT_LOCAL | KEEP_LAST(1) | 参数 / 配置(晚订阅者也能拿到) |
|
||||
| RELIABLE | VOLATILE | KEEP_ALL | 日志(必须投递,不丢) |
|
||||
| RELIABLE | TRANSIENT_LOCAL | KEEP_ALL | 启动期参数 |
|
||||
| BEST_EFFORT | VOLATILE | KEEP_LAST(1) | 视频流(丢一帧无所谓) |
|
||||
| BEST_EFFORT | VOLATILE | KEEP_LAST(10) | Lidar / 雷达 |
|
||||
| BEST_EFFORT | TRANSIENT_LOCAL | KEEP_LAST(1) | 较老的状态快照 |
|
||||
| SYSTEM_DEFAULT | VOLATILE | KEEP_LAST(10) | 由 RMW 决定 |
|
||||
|
||||
## 5. Python API
|
||||
|
||||
```python
|
||||
from rclpy.qos import (
|
||||
QoSProfile,
|
||||
ReliabilityPolicy,
|
||||
HistoryPolicy,
|
||||
DurabilityPolicy,
|
||||
)
|
||||
|
||||
# 视频流:BEST_EFFORT + 深度 1
|
||||
sensor_qos = QoSProfile(
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=1,
|
||||
)
|
||||
|
||||
# 控制指令:RELIABLE + 深度 1
|
||||
control_qos = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=1,
|
||||
)
|
||||
|
||||
# 参数:TRANSIENT_LOCAL(晚订阅者能拿历史)
|
||||
param_qos = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||
depth=1,
|
||||
)
|
||||
|
||||
# 用
|
||||
publisher_ = self.create_publisher(String, 'topic', sensor_qos)
|
||||
```
|
||||
|
||||
## 6. C++ API
|
||||
|
||||
```cpp
|
||||
#include "rclcpp/qos.hpp"
|
||||
|
||||
using namespace rclcpp;
|
||||
|
||||
// 视频流
|
||||
auto sensor_qos = QoS(1)
|
||||
.reliability(RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT)
|
||||
.history(RMW_QOS_POLICY_HISTORY_KEEP_LAST);
|
||||
|
||||
// 参数
|
||||
auto param_qos = QoS(1)
|
||||
.reliability(RMW_QOS_POLICY_RELIABILITY_RELIABLE)
|
||||
.durability(RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL);
|
||||
|
||||
// 用
|
||||
publisher_ = this->create_publisher<String>("topic", sensor_qos);
|
||||
```
|
||||
|
||||
## 7. rclpy QoSProfile 详解
|
||||
|
||||
完整参数:
|
||||
|
||||
```python
|
||||
QoSProfile(
|
||||
# Reliability
|
||||
reliability=ReliabilityPolicy.RELIABLE, # or BEST_EFFORT, SYSTEM_DEFAULT
|
||||
|
||||
# History
|
||||
history=HistoryPolicy.KEEP_LAST, # or KEEP_ALL
|
||||
depth=10, # 仅 KEEP_LAST 时有效
|
||||
|
||||
# Durability
|
||||
durability=DurabilityPolicy.VOLATILE, # or TRANSIENT_LOCAL
|
||||
|
||||
# Deadline(可省)
|
||||
deadline=Duration(seconds=0), # 0 = 无
|
||||
|
||||
# Lifespan(可省)
|
||||
lifespan=Duration(seconds=0), # 0 = 无
|
||||
|
||||
# Liveliness(可省)
|
||||
liveliness=LivelinessPolicy.SYSTEM_DEFAULT,
|
||||
liveliness_lease_duration=Duration(seconds=0),
|
||||
)
|
||||
```
|
||||
|
||||
## 8. 调试 QoS 不兼容
|
||||
|
||||
**症状**: `ros2 topic echo` 没输出,`ros2 topic info -v` 显示 QoS incompatible 警告。
|
||||
|
||||
**调试步骤**:
|
||||
|
||||
```bash
|
||||
# 1) 看 pub/sub 各自 QoS
|
||||
ros2 topic info /topic -v
|
||||
|
||||
# 2) 检查 pub/sub 是否在同 domain
|
||||
echo $ROS_DOMAIN_ID
|
||||
|
||||
# 3) 看具体 incompatibility
|
||||
# 错误信息: PolicyKind=RMW_QOS_POLICY_RELIABILITY
|
||||
```
|
||||
|
||||
**修法**: 改一边 QoS 匹配,或用 SYSTEM_DEFAULT。
|
||||
|
||||
## 9. 推荐阅读
|
||||
|
||||
- [ROS2 QoS 设计稿](https://design.ros2.org/articles/qos.html)
|
||||
- [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)
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# 20 · ros2 bag 录制与回放 完全指南
|
||||
|
||||
> **目标**: 掌握 ros2 bag 命令行,能录制 topic、回放、调试、训练数据采集。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 是什么](#1-是什么)
|
||||
- [2. 录制 topic](#2-录制-topic)
|
||||
- [3. 回放 topic](#3-回放-topic)
|
||||
- [4. 查看包信息](#4-查看包信息)
|
||||
- [5. 转换格式](#5-转换格式)
|
||||
- [6. QoS 与 bag](#6-qos-与-bag)
|
||||
- [7. VLA 训练数据采集](#7-vla-训练数据采集)
|
||||
- [8. 推荐阅读](#8-推荐阅读)
|
||||
|
||||
---
|
||||
|
||||
## 1. 是什么
|
||||
|
||||
**ros2 bag** = ROS2 自带的 topic 录制 / 回放工具,生成 `.db3` (SQLite) 文件 + `metadata.yaml`。
|
||||
|
||||
用途:
|
||||
- 调试:录一段数据,反复回放调试算法
|
||||
- 测试:用录的数据做单元测试
|
||||
- 训练:VLA / 机器学习数据采集
|
||||
- 回归:确保算法在新数据上仍 work
|
||||
|
||||
## 2. 录制 topic
|
||||
|
||||
```bash
|
||||
# 录制所有 topic
|
||||
ros2 bag record -a -o my_bag
|
||||
|
||||
# 录制指定 topic
|
||||
ros2 bag record /chatter /tf /joint_states -o my_bag
|
||||
|
||||
# 指定压缩格式
|
||||
ros2 bag record /chatter -o my_bag --compression-mode file --compression-format zstd
|
||||
|
||||
# 限制时长
|
||||
ros2 bag record /chatter -o my_bag --duration 30 # 30 秒
|
||||
|
||||
# 限制大小
|
||||
ros2 bag record /chatter -o my_bag --max-bag-size 100000000 # 100 MB
|
||||
|
||||
# 排除 topic
|
||||
ros2 bag record -a -o my_bag --exclude "/_.*"
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
my_bag/
|
||||
├── metadata.yaml
|
||||
└── my_bag_0.db3
|
||||
```
|
||||
|
||||
## 3. 回放 topic
|
||||
|
||||
```bash
|
||||
# 回放(需要原始节点还在,否则消息无 subscriber)
|
||||
ros2 bag play my_bag
|
||||
|
||||
# 循环回放
|
||||
ros2 bag play my_bag --loop
|
||||
|
||||
# 调整速率(2 倍速)
|
||||
ros2 bag play my_bag --rate 2.0
|
||||
|
||||
# 从中间开始
|
||||
ros2 bag play my_bag --start-offset 10 # 10 秒处开始
|
||||
|
||||
# 延迟发布
|
||||
ros2 bag play my_bag --delay 1.0 # 每条消息延迟 1 秒
|
||||
```
|
||||
|
||||
## 4. 查看包信息
|
||||
|
||||
```bash
|
||||
# 概览
|
||||
ros2 bag info my_bag
|
||||
|
||||
# YAML 格式详情
|
||||
ros2 bag info my_bag --yaml
|
||||
|
||||
# 单个 topic 详情
|
||||
ros2 bag info my_bag -t /chatter
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
Files: my_bag_0.db3
|
||||
Bag size: 1.2 MiB
|
||||
Storage id: sqlite3
|
||||
Duration: 10.05s
|
||||
Start: Aug 4 2026 14:00:00.123
|
||||
End: Aug 4 2026 14:00:10.123
|
||||
Messages: 100
|
||||
Topic information: MessageType Count
|
||||
std_msgs/String 100
|
||||
```
|
||||
|
||||
## 5. 转换格式
|
||||
|
||||
### 导出 CSV
|
||||
|
||||
```bash
|
||||
ros2 bag info my_bag --yaml > my_bag_info.yaml
|
||||
```
|
||||
|
||||
### 用 rosbags 库(Python)
|
||||
|
||||
```python
|
||||
from rosbags.rosbag2 import Reader
|
||||
|
||||
with Reader('my_bag') as reader:
|
||||
for msg in reader.messages():
|
||||
topic = msg.topic
|
||||
timestamp = msg.timestamp
|
||||
data = msg.message
|
||||
...
|
||||
```
|
||||
|
||||
## 6. QoS 与 bag
|
||||
|
||||
**关键**: 回放时 subscriber 的 QoS 必须 **兼容** publisher 的 QoS(录制时的 QoS)。
|
||||
|
||||
```bash
|
||||
# 默认 bag 会用 RELIABLE 录制,如果你用 BEST_EFFORT subscriber,会报错
|
||||
# 解决:用 --qos-profile-overrides 覆盖回放 QoS
|
||||
ros2 bag play my_bag --qos-profile-overrides-path qos_override.yaml
|
||||
```
|
||||
|
||||
`qos_override.yaml`:
|
||||
|
||||
```yaml
|
||||
/chatter:
|
||||
reliability: best_effort
|
||||
history: keep_last
|
||||
depth: 1
|
||||
```
|
||||
|
||||
## 7. VLA 训练数据采集
|
||||
|
||||
OpenVLA / π0 等模型需要大量 (image, instruction, action) 三元组。ROS2 bag 录制:
|
||||
|
||||
```bash
|
||||
# 录制图像 + 关节 + 指令
|
||||
ros2 bag record \
|
||||
/camera/color/image_raw \
|
||||
/camera/depth/color/points \
|
||||
/joint_states \
|
||||
/ee_pose \
|
||||
/task_instruction \
|
||||
-o vla_episode_001
|
||||
```
|
||||
|
||||
回放 + 转 VLA 训练格式:
|
||||
|
||||
```python
|
||||
from rosbags.rosbag2 import Reader
|
||||
from cv_bridge import CvBridge
|
||||
import numpy as np
|
||||
|
||||
bridge = CvBridge()
|
||||
samples = []
|
||||
|
||||
with Reader('vla_episode_001') as reader:
|
||||
for msg in reader.messages():
|
||||
if msg.topic == '/camera/color/image_raw':
|
||||
image = bridge.imgmsg_to_cv2(msg.message, 'bgr8')
|
||||
samples.append({'image': image, 'timestamp': msg.timestamp})
|
||||
```
|
||||
|
||||
## 8. 推荐阅读
|
||||
|
||||
- [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/)
|
||||
@@ -0,0 +1,214 @@
|
||||
# 21 · DDS 配置 + colcon overlay 完全指南
|
||||
|
||||
> **目标**: 理解 ROS2 DDS 中间件配置、colcon overlay 混合工作空间,能为多机 / 跨网段部署做正确配置。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. DDS 是什么](#1-dds-是什么)
|
||||
- [2. RMW 实现](#2-rmw-实现)
|
||||
- [3. 关键环境变量](#3-关键环境变量)
|
||||
- [4. domain ID 隔离](#4-domain-id-隔离)
|
||||
- [5. 跨网段发现](#5-跨网段发现)
|
||||
- [6. Cyclone DDS 配置](#6-cyclone-dds-配置)
|
||||
- [7. colcon overlay](#7-colcon-overlay)
|
||||
- [8. 三机部署实操](#8-三机部署实操)
|
||||
- [9. 推荐阅读](#9-推荐阅读)
|
||||
|
||||
---
|
||||
|
||||
## 1. DDS 是什么
|
||||
|
||||
**DDS (Data Distribution Service)** = OMG 制定的实时通信中间件标准。
|
||||
ROS2 默认用 DDS 做底层通信,不同 RMW 实现:
|
||||
- `rmw_fastrtps_cpp`(默认,Fast DDS)
|
||||
- `rmw_cyclonedds_cpp`(Cyclone DDS)
|
||||
|
||||
DDS 提供:
|
||||
- 自动节点发现(基于 UDP multicast)
|
||||
- 多种 QoS
|
||||
- 实时性(零拷贝、共享内存)
|
||||
|
||||
## 2. RMW 实现
|
||||
|
||||
| RMW | 包 | 适用 |
|
||||
|---|---|---|
|
||||
| `rmw_fastrtps_cpp` | `ros-humble-rmw-fastrtts-cpp` | 通用,默认 |
|
||||
| `rmw_cyclonedds_cpp` | `ros-humble-rmw-cyclonedds-cpp` | 跨网段 / Xenomai 实时 |
|
||||
|
||||
切换:
|
||||
|
||||
```bash
|
||||
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
|
||||
# 然后 colcon build + 启动节点
|
||||
```
|
||||
|
||||
**注意**: 切换 RMW 后必须 `rm -rf build/ install/ log/` 再 build,否则 CMake 配置缓存导致 link 错误。
|
||||
|
||||
## 3. 关键环境变量
|
||||
|
||||
| 变量 | 用途 | 默认 |
|
||||
|---|---|---|
|
||||
| `ROS_DOMAIN_ID` | DDS 域 ID(0-232) | 0 |
|
||||
| `RMW_IMPLEMENTATION` | RMW 实现 | `rmw_fastrtts_cpp` |
|
||||
| `ROS_STATIC_PEERS` | 跨网段单播发现 | `<unset>` |
|
||||
| `ROS_DISCOVERY_SERVER` | 集中式发现服务 | `<unset>` |
|
||||
| `ROS_LOCALHOST_ONLY` | 仅本机 | 0 |
|
||||
| `CYCLONE_DDS_URI` | Cyclone DDS XML 配置 URI | `<unset>` |
|
||||
| `FASTRTPS_DEFAULT_PROFILES_FILE` | FastDDS XML 配置路径 | `<unset>` |
|
||||
|
||||
## 4. domain ID 隔离
|
||||
|
||||
同 `ROS_DOMAIN_ID` 才能互通,改 ID 就隔离:
|
||||
|
||||
```bash
|
||||
# PC 端
|
||||
ROS_DOMAIN_ID=42 ros2 launch my_pkg demo.py
|
||||
|
||||
# RK3506 端
|
||||
ROS_DOMAIN_ID=42 ros2 launch my_pkg demo.py
|
||||
# 两端可见
|
||||
|
||||
# 改 ID 后不可见
|
||||
ROS_DOMAIN_ID=43 ros2 launch my_pkg demo.py
|
||||
```
|
||||
|
||||
**注意**: 域 ID 范围 0-232(ROS2 DDS 协议)。
|
||||
|
||||
## 5. 跨网段发现
|
||||
|
||||
### 问题
|
||||
|
||||
UDP multicast **不跨路由器**,跨网段(如 192.168.1.x ↔ 192.168.2.x)默认看不见。
|
||||
|
||||
### 方案 1: 单播发现 (`ROS_STATIC_PEERS`)
|
||||
|
||||
```bash
|
||||
# PC 端(知道 RK3506 IP)
|
||||
ROS_STATIC_PEERS="192.168.2.10;192.168.2.11" \
|
||||
ros2 launch my_pkg demo.py
|
||||
```
|
||||
|
||||
### 方案 2: Discovery Server
|
||||
|
||||
```bash
|
||||
# PC 端启 discovery server
|
||||
ros2 run discovery_server discovery_server --address 0.0.0.0 --port 11811
|
||||
|
||||
# 客户端
|
||||
export ROS_DISCOVERY_SERVER=192.168.2.10:11811
|
||||
ros2 launch my_pkg demo.py
|
||||
```
|
||||
|
||||
### 方案 3: Cyclone DDS LAN 配置
|
||||
|
||||
`cyclonedds.xml`:
|
||||
|
||||
```xml
|
||||
<CycloneDDS>
|
||||
<NetworkInterface name="eth0" priority="default" multicast="default"/>
|
||||
</CycloneDDS>
|
||||
```
|
||||
|
||||
## 6. Cyclone DDS 配置
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
sudo apt install ros-humble-rmw-cyclonedds-cpp
|
||||
```
|
||||
|
||||
### 配置 XML
|
||||
|
||||
```bash
|
||||
export CYCLONE_DDS_URI=file:///etc/cyclonedds.xml
|
||||
```
|
||||
|
||||
`/etc/cyclonedds.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" version="1.0"?>
|
||||
<CycloneDDS xmlns="https://cdds.io/config">
|
||||
<Domain id="any">
|
||||
<General>
|
||||
<Interfaces>
|
||||
<NetworkInterface autodetermine="true" priority="default" multicast="default"/>
|
||||
</Interfaces>
|
||||
</General>
|
||||
</Domain>
|
||||
</CycloneDDS>
|
||||
```
|
||||
|
||||
## 7. colcon overlay
|
||||
|
||||
**colcon overlay** = 多个 colcon 工作空间叠加,后者覆盖前者(同名包优先用 overlay)。
|
||||
|
||||
### 工作流
|
||||
|
||||
```bash
|
||||
# 主工作空间 base(完整)
|
||||
mkdir -p ~/ros2_main_ws/src
|
||||
cd ~/ros2_main_ws/src
|
||||
git clone <完整仓库> # 或 git pull
|
||||
cd ..
|
||||
colcon build --symlink-install # build 全部
|
||||
|
||||
# overlay 工作空间(只 build 修改的包)
|
||||
mkdir -p ~/ros2_overlay_ws/src
|
||||
cd ~/ros2_overlay_ws/src
|
||||
# 符号链接主工作空间的 src(只覆盖你想改的包)
|
||||
ln -s ~/ros2_main_ws/src/my_pkg .
|
||||
# 修改 my_pkg
|
||||
cd ..
|
||||
colcon build --symlink-install --packages-select my_pkg
|
||||
|
||||
# 激活:先 base 后 overlay
|
||||
source ~/ros2_main_ws/install/setup.bash
|
||||
source ~/ros2_overlay_ws/install/setup.bash # 覆盖
|
||||
```
|
||||
|
||||
### 典型场景
|
||||
|
||||
- **稳定版 + 开发版**:base 用稳定版,overlay 跑新代码
|
||||
- **共享代码 + 私有修改**:base 装共享库,overlay 装你的定制
|
||||
- **CI**:base 装大型依赖,overlay 只 build 改的
|
||||
|
||||
## 8. 三机部署实操
|
||||
|
||||
**典型拓扑**:
|
||||
```
|
||||
PC (192.168.1.10, ROS_DOMAIN_ID=0)
|
||||
│
|
||||
├─ RDK X5 (192.168.1.20, ROS_DOMAIN_ID=0)
|
||||
│
|
||||
└─ RK3506 × 2 (192.168.1.30, 192.168.1.31, ROS_DOMAIN_ID=0)
|
||||
```
|
||||
|
||||
**配置**: 三机同 ROS_DOMAIN_ID + 同 LAN,FastDDS multicast 自动发现。
|
||||
|
||||
**RK3506 精简**: 用 `ros-humble-ros-base`,关 daemon:
|
||||
|
||||
```bash
|
||||
# 装精简包
|
||||
sudo apt install ros-humble-ros-base
|
||||
|
||||
# 关 daemon(省内存)
|
||||
sudo systemctl disable --now ros2-daemon
|
||||
|
||||
# 设置环境变量(~/.bashrc)
|
||||
export ROS_DOMAIN_ID=0
|
||||
source /opt/ros/humble/setup.bash
|
||||
```
|
||||
|
||||
详见 [`doc/100-embedded-deployment.md`](100-embedded-deployment.md)。
|
||||
|
||||
## 9. 推荐阅读
|
||||
|
||||
- [ROS2 DDS 概念](https://docs.ros.org/en/humble/Concepts/About-DDS-Implementations.html)
|
||||
- [FastDDS 文档](https://fast-dds.docs.eprosima.com/)
|
||||
- [Cyclone DDS 文档](https://cyclonedds.io/docs/)
|
||||
- [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)
|
||||
@@ -0,0 +1,775 @@
|
||||
# 编程规范 (CODING_STYLE) — ROS2 + Python + C++
|
||||
|
||||
> **目标**: 让本仓库所有代码符合 **ROS2 REP-2000** + **PEP 8** + **工业级实践**,
|
||||
> 从第一行代码就**专业、严谨、可维护**,为后续具身智能 / VLA 落地铺平基础。
|
||||
>
|
||||
> **适用范围**: 本仓库所有 Python (rclpy) / C++ (rclcpp) 代码 + 测试 + launch + 文档。
|
||||
>
|
||||
> **权威参考**:
|
||||
> - [ROS2 REP-2000: ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
|
||||
> - [ROS2 Humble Code Style](https://docs.ros.org/en/humble/Contributing/Code-Style-Language-Versions.html)
|
||||
> - [PEP 8](https://peps.python.org/pep-0008/) / [PEP 257](https://peps.python.org/pep-0257/) / [PEP 484](https://peps.python.org/pep-0484/)
|
||||
> - [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html)
|
||||
> - [ROS2 Design: Parameter](https://design.ros2.org/articles/ros_parameters.html)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 设计原则](#1-设计原则)
|
||||
- [2. Python 规范 (rclpy)](#2-python-规范-rclpy)
|
||||
- [3. C++ 规范 (rclcpp)](#3-c-规范-rclcpp)
|
||||
- [4. 测试规范](#4-测试规范)
|
||||
- [5. ROS2 特定规范](#5-ros2-特定规范)
|
||||
- [6. 文档规范](#6-文档规范)
|
||||
- [7. Git 规范](#7-git-规范)
|
||||
- [8. 错误处理 + 日志规范](#8-错误处理--日志规范)
|
||||
- [9. 反模式 (Anti-Patterns)](#9-反模式-anti-patterns)
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
### 1.1 五大铁律
|
||||
|
||||
| # | 原则 | 含义 |
|
||||
|---|---|---|
|
||||
| 1 | **配置与代码解耦** | 参数 / YAML / launch 传值,代码不硬编码 |
|
||||
| 2 | **错误显式处理** | callback 异常用 try/except + `get_logger().error`,不静默吞 |
|
||||
| 3 | **可测试优先** | 每个包至少 1 个 pytest/gtest,关键路径 100% 覆盖 |
|
||||
| 4 | **接口契约清晰** | type hints / docstring / 错误码 三件套 |
|
||||
| 5 | **命名即文档** | `publisher_` 不是 `pub`,`timer_callback` 不是 `cb` |
|
||||
|
||||
### 1.2 SOLID 简化版
|
||||
|
||||
- **S** (Single Responsibility): 一个节点一个职责,不要混合"传感器读取 + 控制 + 日志上传"
|
||||
- **O** (Open-Closed): 通过参数和 launch 扩展,不改代码
|
||||
- **L** (Liskov): 子类可替换父类(虚函数 override)
|
||||
- **I** (Interface Segregation): 接口小而专,避免上帝节点
|
||||
- **D** (Dependency Inversion): 依赖抽象 (msg / service / action 类型),不依赖实现
|
||||
|
||||
---
|
||||
|
||||
## 2. Python 规范 (rclpy)
|
||||
|
||||
### 2.1 命名 (Naming)
|
||||
|
||||
| 类型 | 规则 | 例子 |
|
||||
|---|---|---|
|
||||
| 模块 | `snake_case` | `publisher_node.py` |
|
||||
| 类 | `PascalCase` | `ChatterPublisher` (不是 `MyPublisher`) |
|
||||
| 节点属性 | `snake_case_`(后缀下划线) | `self.publisher_`, `self.timer_` |
|
||||
| 私有方法 | `_snake_case` | `def _on_timer(self)` |
|
||||
| 常量 | `UPPER_SNAKE_CASE` | `DEFAULT_RATE_HZ = 1.0` |
|
||||
| ROS2 节点名 | `snake_case`,表示功能 | `chatter_publisher` (不是 `node1`) |
|
||||
| ROS2 话题名 | `snake_case`,可加前缀 | `/chatter`, `/robot1/joint_states` |
|
||||
| ROS2 参数名 | `snake_case` | `publish_rate_hz` (不是 `period_ms` 混用) |
|
||||
|
||||
**关键:节点属性后缀下划线**避免与 rclpy 内部方法同名(`timer`, `publisher`, `subscription` 都是 rclpy 内部属性)。
|
||||
|
||||
### 2.2 Type Hints (必填)
|
||||
|
||||
```python
|
||||
# Python 3.10+ 用内置类型,不用 typing.List/Dict
|
||||
from typing import List # 除非必要,否则不导入
|
||||
|
||||
class ChatterPublisher(Node):
|
||||
def __init__(self) -> None:
|
||||
super().__init__('chatter_publisher')
|
||||
self.declare_parameter('publish_rate_hz', 1.0)
|
||||
rate: float = self.get_parameter('publish_rate_hz').value
|
||||
self.publisher_: Publisher[String] = self.create_publisher(String, 'chatter', 10)
|
||||
```
|
||||
|
||||
### 2.3 Docstring (Google Style)
|
||||
|
||||
```python
|
||||
"""ChatterPublisher - 周期性发布 String 到 /chatter 话题。
|
||||
|
||||
设计思想:
|
||||
ROS2 Topic 是异步多对多单向通信,本节点演示:
|
||||
1. 参数声明 + 类型推断
|
||||
2. 周期性发布 + QoS
|
||||
3. 优雅退出(KeyboardInterrupt + rclpy.shutdown)
|
||||
|
||||
参考:
|
||||
- ROS2 设计稿 https://design.ros2.org/articles/topic_and_service.html
|
||||
- QoS 文档 https://docs.ros.org/en/humble/Concepts/About-Quality-of-Service.html
|
||||
"""
|
||||
```
|
||||
|
||||
类/方法的 docstring 模板:
|
||||
|
||||
```python
|
||||
class Foo:
|
||||
"""类的一句话描述。"""
|
||||
|
||||
def method(self, arg: int) -> bool:
|
||||
"""方法的一句话描述。
|
||||
|
||||
Args:
|
||||
arg: 参数描述。
|
||||
|
||||
Returns:
|
||||
返回值描述。
|
||||
|
||||
Raises:
|
||||
ValueError: 何时抛。
|
||||
"""
|
||||
```
|
||||
|
||||
### 2.4 节点模板 (必背)
|
||||
|
||||
```python
|
||||
"""<NodeName> — <一句话描述>"""
|
||||
from typing import List, Optional
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.publisher import Publisher
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
class MyNode(Node):
|
||||
"""节点描述。"""
|
||||
|
||||
DEFAULT_RATE_HZ: float = 1.0
|
||||
DEFAULT_TOPIC: str = 'chatter'
|
||||
QUEUE_SIZE: int = 10
|
||||
|
||||
def __init__(self, *, node_name: str = 'my_node') -> None:
|
||||
super().__init__(node_name)
|
||||
|
||||
# 1) 声明参数(类型由默认值推断)+ 描述符
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='发布频率 (Hz), 大于 0 的浮点数',
|
||||
)
|
||||
|
||||
# 2) 读取参数 + 构造组件
|
||||
rate: float = self.get_parameter('publish_rate_hz').value
|
||||
self.publisher_: Publisher[String] = self.create_publisher(
|
||||
String, self.DEFAULT_TOPIC, self.QUEUE_SIZE,
|
||||
)
|
||||
period: float = 1.0 / rate if rate > 0 else 1.0
|
||||
self.timer_ = self.create_timer(period, self._on_timer)
|
||||
|
||||
# 3) 内部状态(下划线)
|
||||
self._publish_count: int = 0
|
||||
|
||||
self.get_logger().info(f'MyNode started: rate={rate}Hz')
|
||||
|
||||
def _on_timer(self) -> None:
|
||||
"""定时器回调(下划线=内部方法)。"""
|
||||
msg = String()
|
||||
msg.data = f'Hello #{self._publish_count}'
|
||||
self.publisher_.publish(msg)
|
||||
self._publish_count += 1
|
||||
|
||||
|
||||
def main(args: Optional[List[str]] = None) -> None:
|
||||
"""ROS2 节点入口(标准模板)。"""
|
||||
rclpy.init(args=args)
|
||||
try:
|
||||
node = MyNode()
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
### 2.5 包结构 (ament_python)
|
||||
|
||||
```
|
||||
src/<package_name>/
|
||||
├── package.xml # ROS2 包元数据
|
||||
├── setup.py # Python 包配置 + entry_points
|
||||
├── setup.cfg # ament_python install 路径
|
||||
├── resource/<package_name> # 空文件,只用于 ament 索引
|
||||
├── <package_name>/ # Python 模块
|
||||
│ ├── __init__.py
|
||||
│ └── <node_module>.py
|
||||
├── launch/ # launch 文件 (被 colcon 安装)
|
||||
│ └── <name>_launch.py
|
||||
├── config/ # YAML 配置文件
|
||||
│ └── default.yaml
|
||||
├── urdf/ # (可选) URDF
|
||||
├── srv/ msg/ action/ # (可选) 自定义接口
|
||||
├── test/ # pytest 用例
|
||||
│ ├── conftest.py # 共享 fixture
|
||||
│ └── test_<unit>.py
|
||||
└── README.md # 包自描述文档(每个包必须有)
|
||||
```
|
||||
|
||||
### 2.6 setup.py 模板
|
||||
|
||||
```python
|
||||
from setuptools import setup
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
PACKAGE_NAME = '<package_name>'
|
||||
|
||||
setup(
|
||||
name=PACKAGE_NAME,
|
||||
version='0.1.0',
|
||||
packages=[PACKAGE_NAME],
|
||||
data_files=[
|
||||
('share/ament_index/resource_index/packages', ['resource/' + PACKAGE_NAME]),
|
||||
('share/' + PACKAGE_NAME, ['package.xml']),
|
||||
(os.path.join('share', PACKAGE_NAME, 'launch'), glob('launch/*.py')),
|
||||
(os.path.join('share', PACKAGE_NAME, 'config'), glob('config/*.yaml')),
|
||||
(os.path.join('share', PACKAGE_NAME, 'urdf'), glob('urdf/*')),
|
||||
],
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
maintainer='<name>',
|
||||
maintainer_email='<email>',
|
||||
description='<一句话描述>',
|
||||
license='MIT',
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'<exec_name> = <package_name>.<module>:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 2.7 package.xml 模板
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name><package_name></name>
|
||||
<version>0.1.0</version>
|
||||
<description><一句话描述,详细功能></description>
|
||||
<maintainer email="<email>"><name></maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<!-- 运行依赖 -->
|
||||
<depend>rclpy</depend>
|
||||
<depend>std_msgs</depend>
|
||||
|
||||
<!-- 测试依赖 -->
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. C++ 规范 (rclcpp)
|
||||
|
||||
### 3.1 命名
|
||||
|
||||
| 类型 | 规则 | 例子 |
|
||||
|---|---|---|
|
||||
| 类 | `PascalCase` | `ChatterPublisher` |
|
||||
| 函数/方法 | `snake_case` (ROS2 风格) | `timer_callback()` |
|
||||
| 成员变量 | `snake_case_`(后缀下划线) | `publisher_`, `count_` |
|
||||
| 常量 | `kPascalCase` 或 `UPPER_SNAKE` | `kDefaultRate` 或 `DEFAULT_RATE` |
|
||||
| 命名空间 | `snake_case` | `my_robot::control` |
|
||||
|
||||
### 3.2 必须项
|
||||
|
||||
- **智能指针**: `std::shared_ptr<T>` + `std::make_shared<T>()`
|
||||
- **`override`**: 虚函数必须标
|
||||
- **`const`**: 不修改成员的方法加 `const`
|
||||
- **`explicit`**: 单参数构造加 `explicit`
|
||||
- **`#pragma once`**: 头文件用
|
||||
- **`nullptr`**: 不用 `NULL`
|
||||
|
||||
### 3.3 节点模板
|
||||
|
||||
```cpp
|
||||
// chatter_publisher.hpp
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
namespace my_robot
|
||||
{
|
||||
|
||||
class ChatterPublisher : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit ChatterPublisher(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void timer_callback();
|
||||
|
||||
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
size_t count_;
|
||||
};
|
||||
|
||||
} // namespace my_robot
|
||||
```
|
||||
|
||||
```cpp
|
||||
// chatter_publisher.cpp
|
||||
#include "my_robot/chatter_publisher.hpp"
|
||||
|
||||
namespace my_robot
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("chatter_publisher", options), count_(0)
|
||||
{
|
||||
this->declare_parameter<int>("period_ms", 500);
|
||||
this->declare_parameter<std::string>("topic", "chatter");
|
||||
|
||||
const int period_ms = this->get_parameter("period_ms").as_int();
|
||||
const std::string topic = this->get_parameter("topic").as_string();
|
||||
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>(topic, 10);
|
||||
timer_ = this->create_wall_timer(
|
||||
std::chrono::milliseconds(period_ms),
|
||||
std::bind(&ChatterPublisher::timer_callback, this));
|
||||
|
||||
RCLCPP_INFO(this->get_logger(),
|
||||
"ChatterPublisher started: topic=%s, period=%dms",
|
||||
topic.c_str(), period_ms);
|
||||
}
|
||||
|
||||
void ChatterPublisher::timer_callback()
|
||||
{
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "Hello from C++, seq=" + std::to_string(count_++);
|
||||
publisher_->publish(msg);
|
||||
}
|
||||
|
||||
} // namespace my_robot
|
||||
|
||||
// main
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<my_robot::ChatterPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 CMakeLists.txt 模板
|
||||
|
||||
```cmake
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(my_robot LANGUAGES CXX)
|
||||
|
||||
if(NOT CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
endif()
|
||||
|
||||
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
std_msgs
|
||||
)
|
||||
|
||||
# 头文件库
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/chatter_publisher.cpp
|
||||
)
|
||||
target_include_directories(${PROJECT_NAME}_core PUBLIC src)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
|
||||
# 可执行文件
|
||||
add_executable(chatter_publisher src/main.cpp)
|
||||
target_link_libraries(chatter_publisher ${PROJECT_NAME}_core)
|
||||
|
||||
# 安装
|
||||
install(TARGETS chatter_publisher
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
install(DIRECTORY launch config
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
ament_package()
|
||||
```
|
||||
|
||||
### 3.5 测试 (gtest)
|
||||
|
||||
```cpp
|
||||
// test/test_chatter_publisher.cpp
|
||||
#include <gtest/gtest.h>
|
||||
#include <memory>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "my_robot/chatter_publisher.hpp"
|
||||
|
||||
class ChatterPublisherTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
static void SetUpTestSuite() { rclcpp::init(0, nullptr); }
|
||||
static void TearDownTestSuite() { rclcpp::shutdown(); }
|
||||
};
|
||||
|
||||
TEST_F(ChatterPublisherTest, ConstructsWithDefaults)
|
||||
{
|
||||
auto node = std::make_shared<my_robot::ChatterPublisher>();
|
||||
EXPECT_EQ(node->get_name(), std::string("chatter_publisher"));
|
||||
EXPECT_EQ(node->get_parameter("period_ms").as_int(), 500);
|
||||
EXPECT_EQ(node->get_parameter("topic").as_string(), std::string("chatter"));
|
||||
}
|
||||
|
||||
TEST_F(ChatterPublisherTest, PublishesMessages)
|
||||
{
|
||||
auto node = std::make_shared<my_robot::ChatterPublisher>();
|
||||
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
|
||||
exec->add_node(node);
|
||||
|
||||
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(1);
|
||||
while (std::chrono::steady_clock::now() < end) {
|
||||
exec->spin_some(std::chrono::milliseconds(50));
|
||||
}
|
||||
SUCCEED();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试规范
|
||||
|
||||
### 4.1 测试金字塔
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ E2E (1-3) │ ← launch_testing + 真实场景
|
||||
├─────────────┤
|
||||
│ Integ (4-8) │ ← 同进程 spin + DDS
|
||||
├─────────────┤
|
||||
│ Unit (10+) │ ← 纯函数 / 参数声明 / 消息构造
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 4.2 pytest 模板 (conftest.py)
|
||||
|
||||
```python
|
||||
"""共享 fixture - 整个仓库所有 Python 包共用一套模式。"""
|
||||
from typing import Iterator
|
||||
import pytest
|
||||
import rclpy
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def ros_context() -> Iterator[None]:
|
||||
"""整个 session 共享 rclpy 上下文(避免反复 init/shutdown 引发 bug)。"""
|
||||
rclpy.init()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def node(ros_context: None) -> Iterator:
|
||||
"""每个测试一个独立节点实例。"""
|
||||
from <package>.<module> import MyNode
|
||||
instance = MyNode()
|
||||
try:
|
||||
yield instance
|
||||
finally:
|
||||
instance.destroy_node()
|
||||
```
|
||||
|
||||
### 4.3 测试命名
|
||||
|
||||
```python
|
||||
def test_<unit>_<scenario>_<expected>():
|
||||
"""例: test_publisher_init_with_default_rate_uses_1hz"""
|
||||
```
|
||||
|
||||
### 4.4 测试覆盖要求
|
||||
|
||||
| 层级 | 数量 | 内容 |
|
||||
|---|---|---|
|
||||
| Unit | ≥3 | 参数声明 / 默认值 / 关键方法调用 |
|
||||
| Integ | ≥2 | 同进程 spin / DDS roundtrip |
|
||||
| E2E | 1 (可选) | 完整 launch + 多节点 |
|
||||
|
||||
---
|
||||
|
||||
## 5. ROS2 特定规范
|
||||
|
||||
### 5.1 节点命名
|
||||
|
||||
- **节点名**: `snake_case`,表示功能(`chatter_publisher` 不是 `node1`)
|
||||
- **节点必须有 docstring**: 一句话说清做什么
|
||||
- **节点类名 = 节点名 CamelCase**: `ChatterPublisher` ↔ `chatter_publisher`
|
||||
- **不混用**: `MyNode` 这种名字只用于基类,不要直接用
|
||||
|
||||
### 5.2 参数
|
||||
|
||||
- **参数名 `snake_case`**: `publish_rate_hz`, `topic_name`, `frame_id`
|
||||
- **带单位后缀**: `_hz`, `_ms`, `_sec`, `_bytes` (避免歧义)
|
||||
- **声明时给 `descriptor`**: 便于 `ros2 param describe`
|
||||
- **运行时不变参数**: `readonly=True`
|
||||
- **运行时可变**: 注册 `add_on_set_parameters_callback` 校验
|
||||
|
||||
### 5.3 消息 / Service / Action
|
||||
|
||||
- **优先标准接口**: `std_msgs` / `sensor_msgs` / `geometry_msgs` / `example_interfaces`
|
||||
- **必须自定义时**: 在自己包内 `msg/`, `srv/`, `action/`
|
||||
- **字段命名**: `snake_case`,带单位 (`velocity_mps`)
|
||||
- **不要嵌指针 / 引用类型**: 用 ID (`string object_id`) 而非 `string&`
|
||||
|
||||
### 5.4 QoS
|
||||
|
||||
- **默认 RELIABLE + KEEP_LAST(10)**: 跨语言互通零障碍
|
||||
- **传感器流**: BEST_EFFORT + KEEP_LAST(1)
|
||||
- **控制指令**: RELIABLE + KEEP_LAST(1) + DEADLINE
|
||||
- **状态发布**: TRANSIENT_LOCAL + KEEP_LAST(1)
|
||||
|
||||
### 5.5 Launch 文件
|
||||
|
||||
- **函数签名**: `def generate_launch_description() -> LaunchDescription`
|
||||
- **可配置参数**: 用 `LaunchConfiguration` + `DeclareLaunchArgument`
|
||||
- **路径**: `PathJoinSubstitution` + `FindPackageShare`
|
||||
- **嵌套**: `IncludeLaunchDescription` + `PythonLaunchDescriptionSource`
|
||||
- **节点命名空间**: 必要时 `PushRosNamespace`
|
||||
|
||||
### 5.6 TF
|
||||
|
||||
- **frame_id `snake_case`**: `base_link`, `gripper`, `camera_optical_frame`
|
||||
- **REP-103 约定**: x 前, y 左, z 上 (右手系)
|
||||
- **REP-105 语义**: `map` → `odom` → `base_link`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档规范
|
||||
|
||||
### 6.1 每个文件 docstring (必填)
|
||||
|
||||
```python
|
||||
"""<文件名> - <一句话功能描述>。
|
||||
|
||||
功能:
|
||||
- 列出要点 1
|
||||
- 列出要点 2
|
||||
|
||||
关键概念:
|
||||
- ROS2 概念 1
|
||||
- ROS2 概念 2
|
||||
|
||||
运行方式:
|
||||
ros2 run <pkg> <exec>
|
||||
|
||||
参考:
|
||||
- 官方文档链接
|
||||
"""
|
||||
```
|
||||
|
||||
### 6.2 包内 README.md (必填)
|
||||
|
||||
每个包必须有 `README.md`,包含:
|
||||
|
||||
1. **功能**(一句话)
|
||||
2. **关键概念**(表格)
|
||||
3. **运行**(代码块,3-5 种)
|
||||
4. **测试**(代码块)
|
||||
5. **深度学习链接**(指向 doc/ 下的文档)
|
||||
|
||||
### 6.3 commit message
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
|
||||
类型: feat / fix / docs / style / refactor / test / chore
|
||||
例子:
|
||||
feat(py_pubsub): 重写 publisher 为工业级风格 + type hints
|
||||
fix(cpp_robot_tf2): 加 const-correct 与 override
|
||||
docs(doc/15-params): 新增参数系统深度文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Git 规范
|
||||
|
||||
- **分支命名**: `feat/<name>` / `fix/<name>` / `docs/<name>`
|
||||
- **commit**: 中文/英文都行,但 subject ≤ 72 字符
|
||||
- **不修改全局 git config**: 用 `git -c user.name=x -c user.email=y commit` 临时设
|
||||
- **.gitignore**: 必须包含 `build/`, `install/`, `log/`, `.venv/`, `__pycache__/`
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误处理 + 日志规范
|
||||
|
||||
### 8.1 日志级别
|
||||
|
||||
| 级别 | 何时用 |
|
||||
|---|---|
|
||||
| DEBUG | 周期事件(默认不打印,生产可用 `--log-level DEBUG` 看) |
|
||||
| INFO | 启动 / 关闭 / 配置变更 |
|
||||
| WARN | 异常但可恢复 |
|
||||
| ERROR | 操作失败但节点继续 |
|
||||
| FATAL | 节点即将退出 |
|
||||
|
||||
### 8.2 错误处理
|
||||
|
||||
```python
|
||||
# ✅ 正确: callback 里 try/except + 日志
|
||||
def _on_timer(self) -> None:
|
||||
try:
|
||||
msg = self._build_message()
|
||||
self.publisher_.publish(msg)
|
||||
except Exception as exc:
|
||||
self.get_logger().error(f'publish failed: {exc}', exc_info=True)
|
||||
|
||||
# ❌ 错: 让异常冒泡,节点崩
|
||||
def _on_timer(self) -> None:
|
||||
msg = self._build_message()
|
||||
self.publisher_.publish(msg)
|
||||
```
|
||||
|
||||
### 8.3 优雅退出
|
||||
|
||||
```python
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
try:
|
||||
node = MyNode()
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
node.get_logger().info('KeyboardInterrupt → 退出')
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 反模式 (Anti-Patterns)
|
||||
|
||||
### 9.1 ❌ 不要
|
||||
|
||||
```python
|
||||
# ❌ 1) 节点名不规范
|
||||
super().__init__('node1')
|
||||
|
||||
# ❌ 2) 硬编码
|
||||
self.publisher_ = self.create_publisher(String, 'chatter', 10) # topic 不可配
|
||||
|
||||
# ❌ 3) 无 type hints
|
||||
def callback(self, msg):
|
||||
pass
|
||||
|
||||
# ❌ 4) 无 docstring
|
||||
class MyNode(Node):
|
||||
def __init__(self):
|
||||
...
|
||||
|
||||
# ❌ 5) print 而不是日志
|
||||
print('starting...')
|
||||
|
||||
# ❌ 6) callback 里阻塞 / 抛异常
|
||||
def _on_timer(self):
|
||||
result = blocking_io_call()
|
||||
self.publisher_.publish(result) # 如果 blocking_io_call 抛 → 节点崩
|
||||
|
||||
# ❌ 7) 重复 init/shutdown
|
||||
def test_a():
|
||||
rclpy.init()
|
||||
...
|
||||
rclpy.shutdown()
|
||||
|
||||
def test_b():
|
||||
rclpy.init() # 第二次 init → 异常
|
||||
|
||||
# ❌ 8) 命名混淆
|
||||
self.pub = self.create_publisher(...) # pub 是 keyword 别用
|
||||
self.timer = self.create_timer(...) # timer 是 rclpy 内部属性,会冲突
|
||||
|
||||
# ❌ 9) 包名叫 launch (与 ROS2 系统包冲突)
|
||||
# 包名必须不能与 ROS2 自带包同名
|
||||
```
|
||||
|
||||
### 9.2 ✅ 要
|
||||
|
||||
```python
|
||||
# ✅ 1) 节点名表示功能
|
||||
super().__init__('chatter_publisher')
|
||||
|
||||
# ✅ 2) 参数化
|
||||
self.declare_parameter('topic', 'chatter')
|
||||
topic = self.get_parameter('topic').value
|
||||
|
||||
# ✅ 3) 完整 type hints
|
||||
def _on_timer(self) -> None:
|
||||
msg: String = self._build_message()
|
||||
|
||||
# ✅ 4) Google style docstring
|
||||
class ChatterPublisher(Node):
|
||||
"""ChatterPublisher - 周期性发布 String 到 /chatter 话题。"""
|
||||
|
||||
# ✅ 5) 用 get_logger
|
||||
self.get_logger().info('starting...')
|
||||
|
||||
# ✅ 6) callback 里 try/except
|
||||
def _on_timer(self) -> None:
|
||||
try:
|
||||
...
|
||||
except Exception as exc:
|
||||
self.get_logger().error(f'failed: {exc}')
|
||||
|
||||
# ✅ 7) conftest.py 共享 fixture
|
||||
# 在 conftest.py 里做 session-scope rclpy.init
|
||||
|
||||
# ✅ 8) 节点属性后缀下划线
|
||||
self.publisher_: Publisher[String] = self.create_publisher(...)
|
||||
self.timer_: Timer = self.create_timer(...)
|
||||
|
||||
# ✅ 9) 包名避让 ROS2 系统包
|
||||
# 用 bringup, my_robot, my_arm 等
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 自检清单 (提交前)
|
||||
|
||||
- [ ] 每个文件顶部有 docstring(功能 / 关键概念 / 运行方式 / 参考)
|
||||
- [ ] 类 / 方法有 Google-style docstring
|
||||
- [ ] 所有函数有 type hints
|
||||
- [ ] 节点名 `snake_case`,类名 `PascalCase`,属性后缀 `_`
|
||||
- [ ] 参数带 `descriptor` 描述
|
||||
- [ ] callback 有 try/except
|
||||
- [ ] 测试用 conftest.py 共享 fixture
|
||||
- [ ] 包内有 README.md
|
||||
- [ ] `colcon build` 通过
|
||||
- [ ] `colcon test` 100% 通过
|
||||
- [ ] commit message 含 type(scope): subject
|
||||
|
||||
---
|
||||
|
||||
**违反任何一条,代码不得合并。**
|
||||
**一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。**
|
||||
+37
-23
@@ -1,44 +1,58 @@
|
||||
# =============================================================================
|
||||
# docker-compose.yml —— 用 docker-compose 编排 ROS2 开发容器。
|
||||
# docker-compose.yml - ROS2 开发容器编排
|
||||
#
|
||||
# 与裸 docker run 相比,docker-compose 提供:
|
||||
# - YAML 声明式配置(易读、易版本管理)
|
||||
# - 一键 build / up / down / logs
|
||||
# - 多服务依赖、网络、卷的统一管理
|
||||
# =============================================================================
|
||||
# 关键改动(vs 原版):
|
||||
# 1. 加 `name: ros2` —— 独立 compose project,脱离默认 `docker` 分组
|
||||
# 2. 用 `networks: ros2_net`(自定义 bridge)替代 host network
|
||||
# - 便于跨机器部署时统一 IP 段
|
||||
# - 与系统 docker_default 隔离,不让 ROS 节点被无关容器发现
|
||||
#
|
||||
# 使用:
|
||||
# docker compose -p ros2 -f docker/docker-compose.yml build
|
||||
# docker compose -p ros2 -f docker/docker-compose.yml up -d
|
||||
# docker compose -p ros2 -f docker/docker-compose.yml down
|
||||
|
||||
name: ros2
|
||||
|
||||
services:
|
||||
ros2:
|
||||
# build: 用同目录的 Dockerfile 构建镜像。
|
||||
# image: 同时打标签,后续 docker-compose down 不会丢镜像。
|
||||
# build: 同目录 Dockerfile
|
||||
build: .
|
||||
image: ros2-humble-dev:latest
|
||||
container_name: ros2_dev
|
||||
|
||||
# privileged: 特权模式,便于以后调系统工具(默认足够,不必开)。
|
||||
# 特权模式(便于系统工具调试)
|
||||
privileged: true
|
||||
# stdin_open + tty:docker exec -it 时能获得交互式 shell。
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
# network_mode: host —— 让容器使用主机网络栈。
|
||||
# 重要:DDS 默认走 UDP 多播,跨容器/跨主机时必须可见;
|
||||
# host 网络让多容器间直接互见,适合单机多机的快速调试。
|
||||
network_mode: host
|
||||
# 用自定义网络(脱离 docker_default)
|
||||
networks:
|
||||
ros2_net:
|
||||
ipv4_address: 172.20.0.10 # 固定 IP,便于 ROS_STATIC_PEERS
|
||||
|
||||
environment:
|
||||
# ROS_DOMAIN ID:0~232(默认 0);同一 ID 的节点互相可见,
|
||||
# 同一机器多项目互不干扰时给每个项目不同 ID。
|
||||
# ROS Domain ID(0-232,默认 0)
|
||||
- ROS_DOMAIN_ID=0
|
||||
# RMW 留空,使用 ROS2 Humble 默认的 rmw_fastrtps_cpp;
|
||||
# 若要切换到 Cyclone DDS,需先在镜像内 apt install ros-humble-rmw-cyclonedds-cpp。
|
||||
# 用 FastDDS 默认(留空)
|
||||
# - RMW_IMPLEMENTATION=rmw_fastrtps_cpp
|
||||
|
||||
# 关键卷挂载:把宿主的整个 ros2 工程(上一级目录) 挂到容器 /root/ros2_ws,
|
||||
# 改动代码即时同步,容器内 colcon build 即可生效。
|
||||
# 容器内 PATH 自动 source ROS2
|
||||
- RCUTILS_COLORIZED_OUTPUT=1
|
||||
|
||||
# bind mount:整个工程挂到 /root/ros2_ws
|
||||
volumes:
|
||||
- ..:/root/ros2_ws
|
||||
|
||||
working_dir: /root/ros2_ws
|
||||
|
||||
# 容器内默认命令:tail -f /dev/null 让容器"挂起不死",
|
||||
# 用户再 docker exec 进去手动开发。
|
||||
# 容器长跑(用户 exec 进去)
|
||||
command: ["bash", "-lc", "tail -f /dev/null"]
|
||||
|
||||
# 自定义网络(脱离 docker_default)
|
||||
networks:
|
||||
ros2_net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/24
|
||||
gateway: 172.20.0.1
|
||||
@@ -0,0 +1,57 @@
|
||||
# bringup
|
||||
|
||||
顶层 launch 聚合包。属于 Level 1 跨包集成层。
|
||||
|
||||
## 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 系统包同名会冲突)
|
||||
|
||||
## 运行
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 验证端到端
|
||||
|
||||
```bash
|
||||
# 跨包跨语言 Topic:PY + CPP 同时发,listener 都收到
|
||||
ros2 launch bringup pubsub_launch.py
|
||||
ros2 topic info /chatter -v
|
||||
# 预期:Publication count: 2, Subscription count: 2
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md) §5.5
|
||||
- launch 深度:[`doc/70-launch.md`](../doc/70-launch.md)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""bringup - 顶层 launch 聚合包。
|
||||
|
||||
属于 Level 1 跨包集成层。
|
||||
|
||||
职责:
|
||||
提供 6 个聚合 launch 文件,把其他 6 包按业务场景组装:
|
||||
- pubsub_launch.py: 4 节点 Topic(pubsub, py+cpp)
|
||||
- service_launch.py: AddTwoInts server
|
||||
- action_launch.py: Fibonacci server
|
||||
- robot_launch.py: URDF + JointState + TF(python_robot_tf2 嵌套)
|
||||
- vision_launch.py: fake_camera + image_processor
|
||||
- full_demo_launch.py: 11 节点同时运行
|
||||
|
||||
设计思想:
|
||||
- 用 IncludeLaunchDescription 复用其他包的 launch(避免代码复制)
|
||||
- 用 FindPackageShare 跨平台安全定位包路径
|
||||
- 节点名后缀 _py / _cpp 区分来源
|
||||
- 不实现任何业务节点(只组合)
|
||||
|
||||
参考:
|
||||
- ROS2 launch 文档 https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/launch-system.html
|
||||
- 编程规范 doc/CODING_STYLE.md
|
||||
"""
|
||||
@@ -1,20 +1,15 @@
|
||||
"""
|
||||
action_launch.py —— 启动 Fibonacci Action 服务端。
|
||||
|
||||
client 不在此 launch,按需手动调用:
|
||||
ros2 action send_goal /fibonacci example_interfaces/action/Fibonacci "{order: 8}" --feedback
|
||||
"""
|
||||
|
||||
"""action_launch.py - 启动 Fibonacci Action server。"""
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:FibonacciActionServer。"""
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='py_action_demo',
|
||||
executable='fibonacci_server',
|
||||
name='fibonacci_action_server_py',
|
||||
executable='fibonacci_action_server',
|
||||
name='fibonacci_action_server',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
@@ -1,35 +1,47 @@
|
||||
"""full_demo_launch.py - 同时启动所有 11 个节点。
|
||||
|
||||
节点组合:
|
||||
- 2 Publisher(py + cpp)
|
||||
- 2 Subscriber(py + cpp)
|
||||
- 1 AddTwoInts server
|
||||
- 1 Fibonacci server
|
||||
- 1 JointState publisher(C++)
|
||||
- 1 robot_state_publisher(系统包)
|
||||
- 1 TF2 listener(C++)
|
||||
- 1 fake_camera(Python)
|
||||
- 1 image_processor(Python)
|
||||
|
||||
总计:11 个 ROS2 节点同时运行。
|
||||
"""
|
||||
full_demo_launch.py —— 整合所有 demo(全套启动)。
|
||||
|
||||
启动的节点:
|
||||
- 4 个 pub/sub 节点(pubsub)
|
||||
- add_two_ints server(srv)
|
||||
- fibonacci action server(action)
|
||||
- 3 节点 URDF TF2(robot)
|
||||
- fake_camera + image_processor(vision)
|
||||
|
||||
总计 ~10 个节点同时运行,展示整个 ROS2 通信栈。
|
||||
"""
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import IncludeLaunchDescription
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.substitutions import PathJoinSubstitution
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
|
||||
def _include(pkg_name, launch_file):
|
||||
pkg = FindPackageShare(pkg_name)
|
||||
path = PathJoinSubstitution([pkg, 'launch', launch_file])
|
||||
return IncludeLaunchDescription(PythonLaunchDescriptionSource(path))
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
_include('py_pubsub', 'pubsub_launch.py'),
|
||||
_include('cpp_pubsub', 'pubsub_launch.py'),
|
||||
_include('py_srv', 'srv_launch.py'),
|
||||
_include('py_action_demo', 'action_launch.py'),
|
||||
_include('cpp_robot_tf2', 'robot_tf2_launch.py'),
|
||||
_include('py_vision_demo', 'vision_launch.py'),
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:11 节点全开(每个子场景独立 launch)。"""
|
||||
pubsub_launch = PathJoinSubstitution([
|
||||
FindPackageShare('bringup'), 'launch', 'pubsub_launch.py',
|
||||
])
|
||||
service_launch = PathJoinSubstitution([
|
||||
FindPackageShare('bringup'), 'launch', 'service_launch.py',
|
||||
])
|
||||
action_launch = PathJoinSubstitution([
|
||||
FindPackageShare('bringup'), 'launch', 'action_launch.py',
|
||||
])
|
||||
robot_launch = PathJoinSubstitution([
|
||||
FindPackageShare('bringup'), 'launch', 'robot_launch.py',
|
||||
])
|
||||
vision_launch = PathJoinSubstitution([
|
||||
FindPackageShare('bringup'), 'launch', 'vision_launch.py',
|
||||
])
|
||||
|
||||
return LaunchDescription([
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(pubsub_launch)),
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(service_launch)),
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(action_launch)),
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(robot_launch)),
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(vision_launch)),
|
||||
])
|
||||
@@ -1,47 +1,56 @@
|
||||
"""pubsub_launch.py - 启动 4 节点跨包跨语言 Topic demo。
|
||||
|
||||
节点组合:
|
||||
- py_pubsub/chatter_publisher_py(发布 std_msgs/String)
|
||||
- cpp_pubsub/chatter_publisher_cpp(发布 std_msgs/String)
|
||||
- py_pubsub/chatter_subscriber_py(订阅)
|
||||
- cpp_pubsub/chatter_subscriber_cpp(订阅)
|
||||
|
||||
预期:`ros2 topic info /chatter -v` 显示 2 个 Publisher + 2 个 Subscription。
|
||||
"""
|
||||
pubsub_launch.py —— 同时启动 4 个 pub/sub 节点(2 Python + 2 C++)。
|
||||
|
||||
节点:
|
||||
- talker_py (py_pubsub)
|
||||
- listener_py (py_pubsub)
|
||||
- talker_cpp (cpp_pubsub)
|
||||
- listener_cpp (cpp_pubsub)
|
||||
|
||||
全部订阅同一个 topic 'chatter',验证跨语言互通。
|
||||
"""
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:跨包跨语言 4 节点 Topic demo。"""
|
||||
return LaunchDescription([
|
||||
# Python Publisher
|
||||
Node(
|
||||
package='py_pubsub',
|
||||
executable='talker',
|
||||
name='talker_py',
|
||||
executable='chatter_publisher',
|
||||
name='chatter_publisher_py',
|
||||
output='screen',
|
||||
parameters=[{'period_ms': 500, 'topic': 'chatter'}],
|
||||
parameters=[{
|
||||
'topic_name': 'chatter',
|
||||
'publish_rate_hz': 2.0,
|
||||
}],
|
||||
),
|
||||
# C++ Publisher
|
||||
Node(
|
||||
package='cpp_pubsub',
|
||||
executable='chatter_publisher_cpp',
|
||||
name='chatter_publisher_cpp',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'topic_name': 'chatter',
|
||||
'publish_rate_hz': 2.0,
|
||||
}],
|
||||
),
|
||||
# Python Subscriber
|
||||
Node(
|
||||
package='py_pubsub',
|
||||
executable='listener',
|
||||
name='listener_py',
|
||||
executable='chatter_subscriber',
|
||||
name='chatter_subscriber_py',
|
||||
output='screen',
|
||||
parameters=[{'topic': 'chatter'}],
|
||||
parameters=[{'topic_name': 'chatter'}],
|
||||
),
|
||||
# C++ Subscriber
|
||||
Node(
|
||||
package='cpp_pubsub',
|
||||
executable='talker',
|
||||
name='talker_cpp',
|
||||
executable='chatter_subscriber_cpp',
|
||||
name='chatter_subscriber_cpp',
|
||||
output='screen',
|
||||
parameters=[{'period_ms': 500, 'topic': 'chatter'}],
|
||||
),
|
||||
Node(
|
||||
package='cpp_pubsub',
|
||||
executable='listener',
|
||||
name='listener_cpp',
|
||||
output='screen',
|
||||
parameters=[{'topic': 'chatter'}],
|
||||
parameters=[{'topic_name': 'chatter'}],
|
||||
),
|
||||
])
|
||||
@@ -1,18 +1,30 @@
|
||||
"""
|
||||
robot_launch.py —— 启动 URDF + TF2 + JointState 演示。
|
||||
通过 IncludeLaunchDescription 复用 cpp_robot_tf2 的 launch。
|
||||
"""
|
||||
"""robot_launch.py - 启动 URDF + JointState + robot_state_publisher + Tf2Listener。
|
||||
|
||||
节点组合(用 IncludeLaunchDescription 复用 cpp_robot_tf2 的 launch):
|
||||
- joint_state_publisher(C++)
|
||||
- robot_state_publisher(系统包)
|
||||
- tf2_listener(C++)
|
||||
"""
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import IncludeLaunchDescription
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.substitutions import PathJoinSubstitution
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
pkg = FindPackageShare('cpp_robot_tf2')
|
||||
path = PathJoinSubstitution([pkg, 'launch', 'robot_tf2_launch.py'])
|
||||
return LaunchDescription([
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(path)),
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:URDF + JointState + TF。
|
||||
|
||||
用 IncludeLaunchDescription 复用 cpp_robot_tf2/launch/robot_tf2_launch.py。
|
||||
"""
|
||||
cpp_robot_launch = PathJoinSubstitution([
|
||||
FindPackageShare('cpp_robot_tf2'),
|
||||
'launch',
|
||||
'robot_tf2_launch.py',
|
||||
])
|
||||
|
||||
return LaunchDescription([
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(cpp_robot_launch),
|
||||
),
|
||||
])
|
||||
@@ -1,21 +1,15 @@
|
||||
"""
|
||||
service_launch.py —— 启动 add_two_ints 服务端。
|
||||
|
||||
client 不在此 launch,按需手动调用:
|
||||
source install/setup.bash
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
"""
|
||||
|
||||
"""service_launch.py - 启动 AddTwoInts server。"""
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:AddTwoIntsServer。"""
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='py_srv',
|
||||
executable='add_two_ints_server',
|
||||
name='add_two_ints_server_py',
|
||||
name='add_two_ints_server',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
@@ -1,17 +1,26 @@
|
||||
"""
|
||||
vision_launch.py —— 启动 fake_camera + image_processor。
|
||||
"""
|
||||
"""vision_launch.py - 启动 fake_camera + image_processor。
|
||||
|
||||
节点组合(用 IncludeLaunchDescription 复用 py_vision_demo 的 launch):
|
||||
- fake_camera(Python 发布 640x480 bgr8 合成图)
|
||||
- image_processor(Python cv_bridge + 平均亮度)
|
||||
"""
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import IncludeLaunchDescription
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.substitutions import PathJoinSubstitution
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
pkg = FindPackageShare('py_vision_demo')
|
||||
path = PathJoinSubstitution([pkg, 'launch', 'vision_launch.py'])
|
||||
return LaunchDescription([
|
||||
IncludeLaunchDescription(PythonLaunchDescriptionSource(path)),
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:FakeCamera + ImageProcessor。"""
|
||||
py_vision_launch = PathJoinSubstitution([
|
||||
FindPackageShare('py_vision_demo'),
|
||||
'launch',
|
||||
'vision_launch.py',
|
||||
])
|
||||
|
||||
return LaunchDescription([
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(py_vision_launch),
|
||||
),
|
||||
])
|
||||
+17
-15
@@ -1,28 +1,30 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
package.xml —— bringup(顶层 bringup)包,作用是聚合另外两个演示包。
|
||||
它本身不提供节点,只提供 launch 文件,通过 exec_depend 让 colcon
|
||||
在 build bringup 之前先 build 它的依赖。
|
||||
-->
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<!-- 包名是 'bringup'(与目录名一致,避免和系统自带的 'launch' 包重名)。 -->
|
||||
<name>bringup</name>
|
||||
<version>0.1.0</version>
|
||||
<description>Top-level bringup launching both py and cpp pubsub</description>
|
||||
<description>
|
||||
顶层 launch 聚合包。
|
||||
提供 6 个跨包 launch 文件:
|
||||
pubsub / service / action / robot / vision / full_demo。
|
||||
不实现任何业务节点,只用 IncludeLaunchDescription 复用其他包。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
<license>MIT</license>
|
||||
|
||||
<!-- exec_depend:仅在运行(ros2 launch)时需要,不需要在编译时。 -->
|
||||
<exec_depend>py_pubsub</exec_depend> <!-- 嵌入子 launch 时需要。 -->
|
||||
<exec_depend>cpp_pubsub</exec_depend> <!-- 同上。 -->
|
||||
<!-- build_depend / exec_depend 的 launch / launch_ros:本包 launch 文件 import 它们。 -->
|
||||
<exec_depend>launch</exec_depend>
|
||||
<exec_depend>launch_ros</exec_depend>
|
||||
<depend>launch</depend>
|
||||
<depend>launch_ros</depend>
|
||||
|
||||
<!-- 启动 launch 文件依赖的下层包(rosdep 标记) -->
|
||||
<exec_depend>py_pubsub</exec_depend>
|
||||
<exec_depend>cpp_pubsub</exec_depend>
|
||||
<exec_depend>py_srv</exec_depend>
|
||||
<exec_depend>py_action_demo</exec_depend>
|
||||
<exec_depend>cpp_robot_tf2</exec_depend>
|
||||
<exec_depend>py_vision_demo</exec_depend>
|
||||
|
||||
<!-- ament_python 是为了让 launch 文件(纯 .py)能被 colcon 找见。 -->
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
|
||||
+13
-33
@@ -1,48 +1,28 @@
|
||||
# =============================================================================
|
||||
# setup.py —— bringup 包的安装清单。
|
||||
#
|
||||
# 与 py_pubsub 不同,bringup 不需要 entry_points(没有节点可执行)。
|
||||
# 只用 data_files 把资源拷到 ROS 标准位置,让
|
||||
# ros2 launch bringup all_launch.py
|
||||
# 能找到 launch 文件。
|
||||
#
|
||||
# 注意:package_name 必须与 package.xml <name> 一致("bringup",避免和
|
||||
# ROS 自带的 'launch' 包同名产生 ament 索引冲突)。
|
||||
# =============================================================================
|
||||
|
||||
# glob:用于展开当前目录下的 launch/*.py,自动收集所有 launch 文件。
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
# setuptools.setup:标准 Python 安装入口。
|
||||
from setuptools import setup
|
||||
|
||||
# 必须与 package.xml <name> 一致。
|
||||
package_name = 'bringup'
|
||||
PACKAGE_NAME = 'bringup'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
name=PACKAGE_NAME,
|
||||
version='0.1.0',
|
||||
|
||||
# 本包没有业务 Python 模块,告诉 setuptools 不要安装任何 .py。
|
||||
# (launch/all_launch.py 是 launch 文件,不属于 pip 安装,放 data_files。)
|
||||
packages=[],
|
||||
|
||||
packages=[PACKAGE_NAME],
|
||||
data_files=[
|
||||
# ament 索引必备,标记本包存在,ros2 工具才认这个 ROS 包。
|
||||
('share/ament_index/resource_index/packages',
|
||||
['resource/' + package_name]),
|
||||
# 让 ros2 工具读到 package.xml 元数据。
|
||||
('share/' + package_name, ['package.xml']),
|
||||
# launch 文件目录必须叫 share/<pkg>/launch/,
|
||||
# glob('launch/*.py') 自动收录该目录下所有 .py launch。
|
||||
('share/' + package_name + '/launch', glob('launch/*.py')),
|
||||
['resource/' + PACKAGE_NAME]),
|
||||
('share/' + PACKAGE_NAME, ['package.xml']),
|
||||
(os.path.join('share', PACKAGE_NAME, 'launch'), glob('launch/*.py')),
|
||||
],
|
||||
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
|
||||
maintainer='xs',
|
||||
maintainer_email='dev@example.com',
|
||||
description='Top-level bringup launching both py and cpp pubsub',
|
||||
license='Apache-2.0',
|
||||
description='顶层 launch 聚合包(6 个跨包 launch)',
|
||||
license='MIT',
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
# CMakeLists.txt - cpp_custom_interface 构建配置
|
||||
#
|
||||
# 设计思想:
|
||||
# - 必须用 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)
|
||||
|
||||
if(NOT CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
endif()
|
||||
|
||||
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# 1) 找依赖
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(rclcpp_action REQUIRED)
|
||||
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"
|
||||
)
|
||||
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
${MSG_FILES}
|
||||
${SRV_FILES}
|
||||
${ACTION_FILES}
|
||||
DEPENDENCIES std_msgs geometry_msgs
|
||||
ADD_LINTER_TESTS
|
||||
)
|
||||
|
||||
# 3) C++ 库(节点代码)
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/sensor_publisher.cpp
|
||||
src/calibration_server.cpp
|
||||
src/move_arm_server.cpp
|
||||
)
|
||||
target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core
|
||||
rclcpp rclcpp_action "rosidl_typesupport_cpp"
|
||||
)
|
||||
rosidl_target_interfaces(${PROJECT_NAME}_core
|
||||
${PROJECT_NAME} "rosidl_typesupport_cpp"
|
||||
)
|
||||
|
||||
# 4) 可执行
|
||||
add_executable(sensor_publisher_cpp src/sensor_publisher.cpp)
|
||||
target_link_libraries(sensor_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(calibration_server_cpp src/calibration_server.cpp)
|
||||
target_link_libraries(calibration_server_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(move_arm_server_cpp src/move_arm_server.cpp)
|
||||
target_link_libraries(move_arm_server_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 5) 安装
|
||||
install(TARGETS
|
||||
sensor_publisher_cpp
|
||||
calibration_server_cpp
|
||||
move_arm_server_cpp
|
||||
${PROJECT_NAME}_core
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
install(DIRECTORY launch
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
ament_package()
|
||||
@@ -0,0 +1,67 @@
|
||||
# cpp_custom_interface
|
||||
|
||||
ROS2 自定义接口演示包(C++)。属于 Level 1 基础机制第 10 块。
|
||||
|
||||
## 自定义接口
|
||||
|
||||
| 类型 | 文件 | 字段 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
## 节点
|
||||
|
||||
- **`sensor_publisher_cpp`**: 周期性发布 `SensorReading`(正弦曲线模拟传感器读数)
|
||||
- **`calibration_server_cpp`**: 提供 `GetCalibration` 服务(返回模拟相机内参)
|
||||
- **`move_arm_server_cpp`**: 提供 `MoveArm` Action(5 阶段模拟移动)
|
||||
|
||||
## 关键概念
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_custom_interface
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `SensorReadingFields` | msg 字段构造正确 |
|
||||
| `GetCalibrationRequestResponse` | srv 字段 + 长度正确 |
|
||||
| `MoveArmGoalFeedbackResult` | action 三段都正确 |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- 自定义接口:[`doc/16-custom-interfaces.md`](../doc/16-custom-interfaces.md)
|
||||
@@ -0,0 +1,18 @@
|
||||
# MoveArm.action - 机械臂移动到目标位姿
|
||||
#
|
||||
# 适用于:
|
||||
# - Pick-and-place 任务
|
||||
# - MoveIt2 备选方案(直接发 trajectory)
|
||||
# - 仿真环境测试
|
||||
|
||||
geometry_msgs/PoseStamped target_pose
|
||||
string[] joint_names
|
||||
float32 max_velocity_scaling # 0.0 - 1.0
|
||||
float32 max_acceleration_scaling
|
||||
---
|
||||
bool success
|
||||
string error_message
|
||||
float64 total_time_sec
|
||||
---
|
||||
float32 progress # 0.0 - 1.0
|
||||
string current_state # "planning" / "executing" / "done"
|
||||
@@ -0,0 +1,26 @@
|
||||
// calibration_server.hpp - 自定义 GetCalibration 服务端
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "cpp_custom_interface/srv/get_calibration.hpp"
|
||||
|
||||
namespace cpp_custom_interface
|
||||
{
|
||||
|
||||
class CalibrationServer : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit CalibrationServer(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void handle_request(
|
||||
const std::shared_ptr<srv::GetCalibration::Request> request,
|
||||
std::shared_ptr<srv::GetCalibration::Response> response);
|
||||
|
||||
rclcpp::Service<srv::GetCalibration>::SharedPtr service_;
|
||||
};
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
@@ -0,0 +1,35 @@
|
||||
// move_arm_server.hpp - 自定义 MoveArm Action 服务端
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "rclcpp_action/rclcpp_action.hpp"
|
||||
#include "cpp_custom_interface/action/move_arm.hpp"
|
||||
|
||||
namespace cpp_custom_interface
|
||||
{
|
||||
|
||||
class MoveArmServer : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit MoveArmServer(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
using MoveArm = cpp_custom_interface::action::MoveArm;
|
||||
using GoalHandle = rclcpp_action::ServerGoalHandle<MoveArm>;
|
||||
|
||||
rclcpp_action::GoalResponse handle_goal(
|
||||
const rclcpp_action::GoalUUID & uuid,
|
||||
std::shared_ptr<const MoveArm::Goal> goal);
|
||||
|
||||
rclcpp_action::CancelResponse handle_cancel(
|
||||
const std::shared_ptr<GoalHandle> goal_handle);
|
||||
|
||||
void execute(const std::shared_ptr<GoalHandle> goal_handle);
|
||||
|
||||
rclcpp_action::Server<MoveArm>::SharedPtr action_server_;
|
||||
};
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
@@ -0,0 +1,26 @@
|
||||
// sensor_publisher.hpp - 自定义 SensorReading 发布者
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "cpp_custom_interface/msg/sensor_reading.hpp"
|
||||
|
||||
namespace cpp_custom_interface
|
||||
{
|
||||
|
||||
class SensorPublisher : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit SensorPublisher(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void timer_callback();
|
||||
|
||||
rclcpp::Publisher<cpp_custom_interface::msg::SensorReading>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
std::size_t publish_count_;
|
||||
};
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
@@ -0,0 +1,27 @@
|
||||
"""custom_launch.py - 启动自定义接口所有节点。"""
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:SensorPublisher + CalibrationServer + MoveArmServer。"""
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='cpp_custom_interface',
|
||||
executable='sensor_publisher_cpp',
|
||||
name='sensor_publisher',
|
||||
output='screen',
|
||||
),
|
||||
Node(
|
||||
package='cpp_custom_interface',
|
||||
executable='calibration_server_cpp',
|
||||
name='calibration_server',
|
||||
output='screen',
|
||||
),
|
||||
Node(
|
||||
package='cpp_custom_interface',
|
||||
executable='move_arm_server_cpp',
|
||||
name='move_arm_server',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
@@ -0,0 +1,12 @@
|
||||
# SensorReading.msg - 通用传感器读数消息
|
||||
#
|
||||
# 设计思想:
|
||||
# - 用 std_msgs/Header 提供时间戳 + 坐标系
|
||||
# - sensor_id 用 string(可读性强,机器人常用: 'imu_0', 'lidar_front')
|
||||
# - value 用 float64(单值传感器够用,多维用 float64[])
|
||||
# - unit 用 string(物理单位: 'm/s^2', 'rad/s', 'm', 'deg')
|
||||
|
||||
std_msgs/Header header
|
||||
string sensor_id
|
||||
string unit
|
||||
float64 value
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>cpp_custom_interface</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
ROS2 自定义接口演示包 (C++)。
|
||||
自定义 .msg (SensorReading) + .srv (GetCalibration) + .action (MoveArm)。
|
||||
属于 Level 1 基础机制第 10 块(自定义接口)。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
<buildtool_depend>rosidl_default_generators</buildtool_depend>
|
||||
|
||||
<depend>rclcpp</depend>
|
||||
<depend>rclcpp_action</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<depend>geometry_msgs</depend>
|
||||
<depend>sensor_msgs</depend>
|
||||
|
||||
<exec_depend>rosidl_default_runtime</exec_depend>
|
||||
|
||||
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
<test_depend>ament_cmake_gtest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@@ -0,0 +1,57 @@
|
||||
// calibration_server.cpp - 自定义服务实现
|
||||
#include "cpp_custom_interface/calibration_server.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace cpp_custom_interface
|
||||
{
|
||||
|
||||
CalibrationServer::CalibrationServer(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("calibration_server", options)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"service_name", "get_calibration",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("服务名"));
|
||||
|
||||
const std::string service_name = this->get_parameter("service_name").as_string();
|
||||
|
||||
service_ = this->create_service<srv::GetCalibration>(
|
||||
service_name,
|
||||
std::bind(&CalibrationServer::handle_request, this,
|
||||
std::placeholders::_1, std::placeholders::_2));
|
||||
|
||||
RCLCPP_INFO(this->get_logger(), "CalibrationServer ready: service=\"%s\"", service_name.c_str());
|
||||
}
|
||||
|
||||
void CalibrationServer::handle_request(
|
||||
const std::shared_ptr<srv::GetCalibration::Request> request,
|
||||
std::shared_ptr<srv::GetCalibration::Response> response)
|
||||
{
|
||||
try {
|
||||
// 模拟:根据 sensor_id 返回不同校准数据
|
||||
response->intrinsic_matrix = {
|
||||
600.0, 0.0, 320.0, // fx, 0, cx
|
||||
0.0, 600.0, 240.0, // 0, fy, cy
|
||||
0.0, 0.0, 1.0, // 0, 0, 1
|
||||
};
|
||||
response->bias = {0.01, 0.02, 0.03};
|
||||
response->calibration_date = "2026-08-04T00:00:00Z";
|
||||
response->valid = true;
|
||||
|
||||
RCLCPP_INFO(this->get_logger(),
|
||||
"served calibration for sensor_id=\"%s\"", request->sensor_id.c_str());
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "handle_request failed: %s", exc.what());
|
||||
response->valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::CalibrationServer>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// move_arm_server.cpp - 自定义 Action 服务端
|
||||
#include "cpp_custom_interface/move_arm_server.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace cpp_custom_interface
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
MoveArmServer::MoveArmServer(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("move_arm_server", options)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"action_name", "move_arm",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("Action 名"));
|
||||
|
||||
const std::string action_name = this->get_parameter("action_name").as_string();
|
||||
|
||||
action_server_ = rclcpp_action::create_server<MoveArm>(
|
||||
this, action_name,
|
||||
std::bind(&MoveArmServer::handle_goal, this, std::placeholders::_1, std::placeholders::_2),
|
||||
std::bind(&MoveArmServer::handle_cancel, this, std::placeholders::_1),
|
||||
std::bind(&MoveArmServer::execute, this, std::placeholders::_1));
|
||||
|
||||
RCLCPP_INFO(this->get_logger(), "MoveArmServer ready: action=\"%s\"", action_name.c_str());
|
||||
}
|
||||
|
||||
rclcpp_action::GoalResponse MoveArmServer::handle_goal(
|
||||
const rclcpp_action::GoalUUID & /*uuid*/,
|
||||
std::shared_ptr<const MoveArm::Goal> /*goal*/)
|
||||
{
|
||||
return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
|
||||
}
|
||||
|
||||
rclcpp_action::CancelResponse MoveArmServer::handle_cancel(
|
||||
const std::shared_ptr<GoalHandle> /*goal_handle*/)
|
||||
{
|
||||
return rclcpp_action::CancelResponse::ACCEPT;
|
||||
}
|
||||
|
||||
void MoveArmServer::execute(const std::shared_ptr<GoalHandle> goal_handle)
|
||||
{
|
||||
const auto goal = goal_handle->get_request();
|
||||
|
||||
auto feedback = std::make_shared<MoveArm::Feedback>();
|
||||
auto result = std::make_shared<MoveArm::Result>();
|
||||
|
||||
// 模拟 5 阶段任务
|
||||
const std::array<std::string, 5> states = {
|
||||
"planning", "approaching", "executing", "retracting", "done",
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i < states.size(); ++i) {
|
||||
if (goal_handle->is_canceling()) {
|
||||
goal_handle->canceled(result);
|
||||
RCLCPP_INFO(this->get_logger(), "MoveArm canceled");
|
||||
return;
|
||||
}
|
||||
|
||||
feedback->progress = static_cast<float>(i + 1) / states.size();
|
||||
feedback->current_state = states[i];
|
||||
goal_handle->publish_feedback(feedback);
|
||||
|
||||
std::this_thread::sleep_for(500ms);
|
||||
}
|
||||
|
||||
result->success = true;
|
||||
result->error_message = "";
|
||||
result->total_time_sec = 2.5;
|
||||
goal_handle->succeed(result);
|
||||
RCLCPP_INFO(this->get_logger(), "MoveArm succeeded");
|
||||
}
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::MoveArmServer>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// sensor_publisher.cpp - 自定义消息发布者实现
|
||||
#include "cpp_custom_interface/sensor_publisher.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
namespace cpp_custom_interface
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
SensorPublisher::SensorPublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("sensor_publisher", options), publish_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "/sensor_reading",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布话题名"));
|
||||
this->declare_parameter<std::string>(
|
||||
"sensor_id", "imu_0",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("传感器 ID"));
|
||||
this->declare_parameter<std::string>(
|
||||
"unit", "rad/s",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("物理单位"));
|
||||
|
||||
const std::string topic_name = this->get_parameter("topic_name").as_string();
|
||||
|
||||
publisher_ = this->create_publisher<msg::SensorReading>(topic_name, 10);
|
||||
timer_ = this->create_wall_timer(
|
||||
100ms, std::bind(&SensorPublisher::timer_callback, this));
|
||||
|
||||
RCLCPP_INFO(this->get_logger(), "SensorPublisher started: topic=\"%s\"", topic_name.c_str());
|
||||
}
|
||||
|
||||
void SensorPublisher::timer_callback()
|
||||
{
|
||||
try {
|
||||
auto msg = msg::SensorReading();
|
||||
msg.header.stamp = this->now();
|
||||
msg.header.frame_id = "imu_frame";
|
||||
msg.sensor_id = this->get_parameter("sensor_id").as_string();
|
||||
msg.unit = this->get_parameter("unit").as_string();
|
||||
msg.value = std::sin(static_cast<double>(publish_count_) * 0.1);
|
||||
|
||||
publisher_->publish(msg);
|
||||
publish_count_++;
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "publish failed: %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_custom_interface
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_custom_interface::SensorPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# GetCalibration.srv - 获取传感器校准数据
|
||||
#
|
||||
# 适用:相机内参 / IMU bias / 关节零位偏移
|
||||
|
||||
string sensor_id
|
||||
---
|
||||
float64[9] intrinsic_matrix # 3x3 内参矩阵(行优先)
|
||||
float64[3] bias # IMU bias 或关节零位
|
||||
string calibration_date # 校准日期(ISO 8601)
|
||||
bool valid # 校准是否有效
|
||||
@@ -0,0 +1,64 @@
|
||||
// test_custom_interfaces.cpp - gtest 验证自定义消息构造
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
|
||||
#include "cpp_custom_interface/msg/sensor_reading.hpp"
|
||||
#include "cpp_custom_interface/srv/get_calibration.hpp"
|
||||
#include "cpp_custom_interface/action/move_arm.hpp"
|
||||
|
||||
class CustomInterfaceTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
static void SetUpTestSuite() { rclcpp::init(0, nullptr); }
|
||||
static void TearDownTestSuite() { rclcpp::shutdown(); }
|
||||
};
|
||||
|
||||
TEST_F(CustomInterfaceTest, SensorReadingFields)
|
||||
{
|
||||
auto msg = cpp_custom_interface::msg::SensorReading();
|
||||
msg.sensor_id = "imu_0";
|
||||
msg.unit = "rad/s";
|
||||
msg.value = 1.234;
|
||||
|
||||
EXPECT_EQ(msg.sensor_id, "imu_0");
|
||||
EXPECT_EQ(msg.unit, "rad/s");
|
||||
EXPECT_DOUBLE_EQ(msg.value, 1.234);
|
||||
}
|
||||
|
||||
TEST_F(CustomInterfaceTest, GetCalibrationRequestResponse)
|
||||
{
|
||||
auto req = std::make_shared<cpp_custom_interface::srv::GetCalibration::Request>();
|
||||
req->sensor_id = "lidar_front";
|
||||
|
||||
auto resp = std::make_shared<cpp_custom_interface::srv::GetCalibration::Response>();
|
||||
resp->intrinsic_matrix = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0};
|
||||
resp->bias = {0.1, 0.2, 0.3};
|
||||
resp->calibration_date = "2026-08-04";
|
||||
resp->valid = true;
|
||||
|
||||
EXPECT_EQ(req->sensor_id, "lidar_front");
|
||||
EXPECT_TRUE(resp->valid);
|
||||
EXPECT_EQ(resp->intrinsic_matrix.size(), 9u);
|
||||
EXPECT_EQ(resp->bias.size(), 3u);
|
||||
}
|
||||
|
||||
TEST_F(CustomInterfaceTest, MoveArmGoalFeedbackResult)
|
||||
{
|
||||
auto goal = cpp_custom_interface::action::MoveArm::Goal();
|
||||
goal.max_velocity_scaling = 0.5f;
|
||||
|
||||
auto fb = cpp_custom_interface::action::MoveArm::Feedback();
|
||||
fb.progress = 0.5f;
|
||||
fb.current_state = "executing";
|
||||
|
||||
auto result = cpp_custom_interface::action::MoveArm::Result();
|
||||
result.success = true;
|
||||
result.total_time_sec = 1.5;
|
||||
|
||||
EXPECT_FLOAT_EQ(goal.max_velocity_scaling, 0.5f);
|
||||
EXPECT_FLOAT_EQ(fb.progress, 0.5f);
|
||||
EXPECT_TRUE(result.success);
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# CMakeLists.txt - cpp_pubsub 包构建配置
|
||||
#
|
||||
# 设计思想:
|
||||
# - 显式声明每个依赖(不靠 ros2_defaults 的隐式传递)
|
||||
# - 启用 -Wall -Wextra -Wpedantic(严格编译警告)
|
||||
# - 库 + 可执行文件分离(便于测试只链接库)
|
||||
# - 测试用 ament_add_gtest(集成 colcon test)
|
||||
#
|
||||
# 参考:
|
||||
# - ament_cmake 用户指南: https://docs.ros.org/en/humble/Concepts/About-Build-System.html
|
||||
# - ROS2 C++ Style: https://docs.ros.org/en/humble/Contributing/Code-Style-Language-Versions.html
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cpp_pubsub VERSION 0.1.0)
|
||||
project(cpp_pubsub LANGUAGES CXX)
|
||||
|
||||
if(NOT CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
@@ -9,36 +21,39 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(std_msgs REQUIRED)
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
std_msgs
|
||||
)
|
||||
|
||||
include_directories(include)
|
||||
# 库:含 ChatterPublisher / ChatterSubscriber 实现
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/chatter_publisher.cpp
|
||||
src/chatter_subscriber.cpp
|
||||
)
|
||||
target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
|
||||
add_executable(talker src/publisher_member_function.cpp)
|
||||
ament_target_dependencies(talker rclcpp std_msgs)
|
||||
# 可执行文件:两个独立 main(每个节点独立可执行)
|
||||
add_executable(chatter_publisher_cpp src/chatter_publisher.cpp)
|
||||
target_link_libraries(chatter_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(listener src/subscriber_member_function.cpp)
|
||||
ament_target_dependencies(listener rclcpp std_msgs)
|
||||
add_executable(chatter_subscriber_cpp src/chatter_subscriber.cpp)
|
||||
target_link_libraries(chatter_subscriber_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 安装:可执行 + 库 + launch
|
||||
install(TARGETS
|
||||
talker
|
||||
listener
|
||||
chatter_publisher_cpp
|
||||
chatter_subscriber_cpp
|
||||
${PROJECT_NAME}_core
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
install(DIRECTORY launch
|
||||
DESTINATION share/${PROJECT_NAME}/
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 测试:ament_cmake_gtest 集成 gtest,
|
||||
# colcon test --packages-select cpp_pubsub 时自动跑 test_pub_sub。
|
||||
# ------------------------------------------------------------------
|
||||
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 rclcpp std_msgs)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
@@ -0,0 +1,54 @@
|
||||
# cpp_pubsub
|
||||
|
||||
ROS2 Topic pub/sub 演示包 (C++)。属于 Level 1 基础机制第 1-2 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`chatter_publisher_cpp`**: 周期性发布 `std_msgs/String` 到 `/chatter`
|
||||
- **`chatter_subscriber_cpp`**: 订阅 `/chatter`,打印消息
|
||||
|
||||
与 [`py_pubsub`](../py_pubsub/) 配合,演示 Python ↔ C++ 跨语言互通。
|
||||
|
||||
## C++ 节点类(库)
|
||||
|
||||
`cpp_pubsub_core` 共享库包含:
|
||||
|
||||
- `cpp_pubsub::ChatterPublisher` — `rclcpp::Node` 派生
|
||||
- `cpp_pubsub::ChatterSubscriber` — `rclcpp::Node` 派生
|
||||
|
||||
均位于 `namespace cpp_pubsub`,便于复用与测试。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 单独启动
|
||||
ros2 run cpp_pubsub chatter_publisher_cpp
|
||||
ros2 run cpp_pubsub chatter_subscriber_cpp
|
||||
|
||||
# launch
|
||||
ros2 launch cpp_pubsub pubsub_launch.py
|
||||
|
||||
# 实时观察
|
||||
ros2 topic info /chatter -v
|
||||
ros2 topic echo /chatter
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_pubsub
|
||||
```
|
||||
|
||||
测试覆盖(在 `test/test_pub_sub.cpp`):
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `PublisherConstructsWithDefaults` | 节点名 + 默认参数正确 |
|
||||
| `SubscriberConstructsWithDefaults` | 节点名 + 默认参数正确 |
|
||||
| `PublisherSpinSomeWorks` | spin_some 1s 不崩溃 |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`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)
|
||||
@@ -0,0 +1,40 @@
|
||||
// chatter_publisher.hpp - C++ ChatterPublisher 节点头文件
|
||||
//
|
||||
// 设计思想:
|
||||
// - 显式构造函数 + NodeOptions(便于测试和覆盖参数)
|
||||
// - const-correct(读参数 / 回调标 const 友好)
|
||||
// - override 标注(虚函数)
|
||||
// - Smart pointers(SharedPtr/UniquePtr)
|
||||
// - 命名空间包裹(防全局污染)
|
||||
//
|
||||
// 参考:
|
||||
// - ROS2 Humble Tutorial https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Cpp-Publisher-And-Subscriber.html
|
||||
// - ROS2 C++ Style https://docs.ros.org/en/humble/Contributing/Code-Style-Language-Versions.html
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#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();
|
||||
|
||||
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
std::size_t publish_count_;
|
||||
};
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
@@ -0,0 +1,26 @@
|
||||
// chatter_subscriber.hpp - C++ ChatterSubscriber 节点头文件
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
namespace cpp_pubsub
|
||||
{
|
||||
|
||||
class ChatterSubscriber : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit ChatterSubscriber(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void message_callback(const std_msgs::msg::String::SharedPtr msg);
|
||||
|
||||
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
|
||||
std::size_t received_count_;
|
||||
};
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
@@ -1,68 +1,41 @@
|
||||
"""pubsub_launch.py - 启动 cpp chatter_publisher + chatter_subscriber。
|
||||
|
||||
设计思想:
|
||||
- C++ 节点 executable 与 Python 不同(带 _cpp 后缀区分)
|
||||
- 节点名带 _cpp 后缀与 _py 区分,跨语言互通时可识别来源
|
||||
"""
|
||||
pubsub_launch.py —— 启动 talker_cpp + listener_cpp,演示 launch 命令行参数。
|
||||
|
||||
与 py_pubsub 的同名 launch 文件对照学习:
|
||||
1) 这里引入了 DeclareLaunchArgument:把 'topic' 声明为命令行可改的形参;
|
||||
2) 用 LaunchConfiguration('topic') 在生成时取其值,
|
||||
让 Node 的 topic 参数跟随命令行传入变化(launch_arguments 是
|
||||
IncludeLaunchDescription 内部传参的标准做法)。
|
||||
3) 这样从外部:
|
||||
ros2 launch cpp_pubsub pubsub_launch.py topic:=my_topic
|
||||
会让两个 C++ 节点都订阅/发布到 my_topic。
|
||||
|
||||
运行:
|
||||
source install/setup.bash
|
||||
ros2 launch cpp_pubsub pubsub_launch.py
|
||||
ros2 launch cpp_pubsub pubsub_launch.py topic:=hello
|
||||
"""
|
||||
|
||||
# LaunchDescription:容器类型。
|
||||
from launch import LaunchDescription
|
||||
# DeclareLaunchArgument:声明一个可被命令行 / 父 launch 覆盖的形参。
|
||||
from launch.actions import DeclareLaunchArgument
|
||||
# LaunchConfiguration:在生成 LaunchDescription 期间"懒求值"某个参数的值。
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
# Node:声明运行一个 ROS2 节点的动作。
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
"""导出函数,返回 LaunchDescription 实例。"""
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:C++ Publisher + C++ Subscriber。"""
|
||||
topic_name_arg = LaunchConfiguration('topic_name')
|
||||
publish_rate_hz_arg = LaunchConfiguration('publish_rate_hz')
|
||||
|
||||
# 声明 topic 形参,默认值 'chatter',在终端可用 topic:=xxx 覆盖。
|
||||
topic_arg = DeclareLaunchArgument(
|
||||
'topic',
|
||||
default_value='chatter',
|
||||
description='Topic name for both pubs/subs',
|
||||
publisher_node = Node(
|
||||
package='cpp_pubsub',
|
||||
executable='chatter_publisher_cpp',
|
||||
name='chatter_publisher_cpp',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'topic_name': topic_name_arg,
|
||||
'publish_rate_hz': publish_rate_hz_arg,
|
||||
}],
|
||||
)
|
||||
|
||||
# 取出 LaunchConfiguration('topic') 的当前值,后续当参数用。
|
||||
topic = LaunchConfiguration('topic')
|
||||
subscriber_node = Node(
|
||||
package='cpp_pubsub',
|
||||
executable='chatter_subscriber_cpp',
|
||||
name='chatter_subscriber_cpp',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'topic_name': topic_name_arg,
|
||||
}],
|
||||
)
|
||||
|
||||
return LaunchDescription([
|
||||
topic_arg, # 把声明本身也放进 LaunchDescription 里(声明是动作之一)。
|
||||
|
||||
# C++ 发布节点
|
||||
Node(
|
||||
package='cpp_pubsub',
|
||||
executable='talker', # CMakeLists 里 add_executable(talker ...)
|
||||
name='talker_cpp',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'period_ms': 500,
|
||||
'topic': topic, # 用上面 LaunchConfiguration 取代硬编码字符串,
|
||||
# 让命令行参数动态注入。
|
||||
}],
|
||||
),
|
||||
|
||||
# C++ 订阅节点
|
||||
Node(
|
||||
package='cpp_pubsub',
|
||||
executable='listener',
|
||||
name='listener_cpp',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'topic': topic,
|
||||
}],
|
||||
),
|
||||
publisher_node,
|
||||
subscriber_node,
|
||||
])
|
||||
+12
-16
@@ -1,30 +1,26 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
package.xml —— cpp_pubsub 包的元数据。
|
||||
对 C++ 包而言,<exec_depend> 一般可缺,因为 CMakeLists.txt 里 find_package
|
||||
已经显式声明依赖;但仍建议写 <depend> 让 colcon 能正确排序构建顺序。
|
||||
-->
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>cpp_pubsub</name>
|
||||
<version>0.1.0</version>
|
||||
<description>C++ talker/listener demo for ROS2 Humble</description>
|
||||
<description>
|
||||
ROS2 Topic pub/sub 演示包 (C++)。
|
||||
与 py_pubsub 配合验证跨语言互通(Python ↔ C++)。
|
||||
属于 Level 1 基础机制第 1-2 块。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
<license>MIT</license>
|
||||
|
||||
<!-- depend:这是 C++ 编译 + 链接 + 运行都需要的 ROS 库。 -->
|
||||
<depend>rclcpp</depend> <!-- C++ 客户端库。 -->
|
||||
<depend>std_msgs</depend> <!-- 内置基础消息。 -->
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
|
||||
<!-- test_depend:仅在 colcon test 时被安装/使用。 -->
|
||||
<test_depend>ament_lint_auto</test_depend> <!-- 自动调度几种 lint -->
|
||||
<test_depend>ament_lint_common</test_depend> <!-- cpplint / uncrustify -->
|
||||
<test_depend>ament_cmake_gtest</test_depend> <!-- gtest 集成 -->
|
||||
<test_depend>launch_testing_ros</test_depend><!-- launch integration -->
|
||||
<depend>rclcpp</depend>
|
||||
<depend>std_msgs</depend>
|
||||
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
|
||||
<!-- export/build_type = ament_cmake:让 colcon 调用 CMake 构建。 -->
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
</export>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// chatter_publisher.cpp - C++ ChatterPublisher 实现
|
||||
#include "cpp_pubsub/chatter_publisher.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
namespace cpp_pubsub
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("chatter_publisher", options), publish_count_(0)
|
||||
{
|
||||
// 1) 声明参数(类型模板版 + 描述符)
|
||||
this->declare_parameter<int>(
|
||||
"publish_rate_hz", 2,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布频率 (Hz)"));
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "chatter",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布话题名"));
|
||||
|
||||
// 2) 读参数
|
||||
const int publish_rate_hz = this->get_parameter("publish_rate_hz").as_int();
|
||||
const std::string topic_name = this->get_parameter("topic_name").as_string();
|
||||
|
||||
// 3) 构造发布者 + 定时器
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>(topic_name, 10);
|
||||
|
||||
// 防零除
|
||||
const auto period = (publish_rate_hz > 0) ?
|
||||
std::chrono::milliseconds(1000 / publish_rate_hz) :
|
||||
std::chrono::milliseconds(1000);
|
||||
|
||||
timer_ = this->create_wall_timer(
|
||||
period, std::bind(&ChatterPublisher::timer_callback, this));
|
||||
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"ChatterPublisher started: rate=%d Hz, topic=\"%s\"",
|
||||
publish_rate_hz, topic_name.c_str());
|
||||
}
|
||||
|
||||
void ChatterPublisher::timer_callback()
|
||||
{
|
||||
try {
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "Hello from C++, seq=" + std::to_string(publish_count_++);
|
||||
publisher_->publish(msg);
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "publish failed: %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// chatter_subscriber.cpp - C++ ChatterSubscriber 实现
|
||||
#include "cpp_pubsub/chatter_subscriber.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace cpp_pubsub
|
||||
{
|
||||
|
||||
ChatterSubscriber::ChatterSubscriber(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("chatter_subscriber", options), received_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"topic_name", "chatter",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("订阅话题名"));
|
||||
|
||||
const std::string topic_name = this->get_parameter("topic_name").as_string();
|
||||
|
||||
subscription_ = this->create_subscription<std_msgs::msg::String>(
|
||||
topic_name, 10,
|
||||
std::bind(&ChatterSubscriber::message_callback, this, std::placeholders::_1));
|
||||
|
||||
RCLCPP_INFO(this->get_logger(), "ChatterSubscriber subscribed: topic=\"%s\"", topic_name.c_str());
|
||||
}
|
||||
|
||||
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());
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "callback failed: %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cpp_pubsub
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_pubsub::ChatterSubscriber>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,95 +1,60 @@
|
||||
// =============================================================================
|
||||
// test_pub_sub.cpp —— C++ 节点端到端集成测试 (gtest)。
|
||||
//
|
||||
// 验证 TalkerCPP / ListenerCPP 的发布-订阅链可正常运转:
|
||||
// 1) 启动 talker (publisher) 与 listener (subscriber) 实例;
|
||||
// 2) 在节点事件循环里 spin 一段时间,直到 listener 收到消息;
|
||||
// 3) assert 收到 ≥ 1 条,且消息来自 publisher。
|
||||
//
|
||||
// 注意:ROS2 单测标准做法是用 launch_testing_ros 在多进程下做端到端测试;
|
||||
// 本测试在同一进程内 spin 一次,跑得快但不覆盖真实 DDS 互通(那个
|
||||
// 在 integration test 里覆盖)。此处更偏"单元"性质。
|
||||
//
|
||||
// 运行:本测试由 colcon test --packages-select cpp_pubsub 自动调用,
|
||||
// 其依赖在 CMakeLists.txt 的 if(BUILD_TESTING) ... 块内拉起。
|
||||
// =============================================================================
|
||||
# test/test_pub_sub.cpp - cpp_pubsub 单元测试
|
||||
#
|
||||
# 设计思想:
|
||||
# - SetUpTestSuite / TearDownTestSuite 共享 rclcpp::init / shutdown
|
||||
# - 用 spin_some(50ms) 代替 spin() 控制超时
|
||||
# - 不依赖 launch_testing(避免环境耦合)
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
#include "cpp_pubsub/chatter_publisher.hpp"
|
||||
#include "cpp_pubsub/chatter_subscriber.hpp"
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// 测试夹具:继承 rclcpp::Node + 携带一个 listener 订阅,做"收到 N 条"断言。
|
||||
class PubsubFixture : public rclcpp::Node
|
||||
class PubsubTest : public ::testing::Test
|
||||
{
|
||||
public:
|
||||
PubsubFixture()
|
||||
: rclcpp::Node("pubsub_test_node"), received_(0)
|
||||
protected:
|
||||
static void SetUpTestSuite()
|
||||
{
|
||||
subscription_ = this->create_subscription<std_msgs::msg::String>(
|
||||
"chatter_unit_test", 10,
|
||||
[this](const std_msgs::msg::String::SharedPtr msg) {
|
||||
(void)msg;
|
||||
received_++;
|
||||
});
|
||||
rclcpp::init(0, nullptr);
|
||||
}
|
||||
|
||||
int received_count() const { return received_; }
|
||||
|
||||
private:
|
||||
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
|
||||
int received_;
|
||||
static void TearDownTestSuite()
|
||||
{
|
||||
rclcpp::shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1:验证 publisher 在 1s 内能发出至少 1 条消息,
|
||||
// 通过 ros2 topic hz 等价手段(同进程订阅计数)做侧面验证。
|
||||
TEST(PubsubTest, TalkerPublishesAtLeastOnce)
|
||||
TEST_F(PubsubTest, PublisherConstructsWithDefaults)
|
||||
{
|
||||
auto node = std::make_shared<PubsubFixture>();
|
||||
auto publisher = node->create_publisher<std_msgs::msg::String>("chatter_unit_test", 10);
|
||||
auto node = std::make_shared<cpp_pubsub::ChatterPublisher>();
|
||||
EXPECT_EQ(node->get_name(), std::string("chatter_publisher"));
|
||||
EXPECT_EQ(node->get_parameter("publish_rate_hz").as_int(), 2);
|
||||
EXPECT_EQ(node->get_parameter("topic_name").as_string(), std::string("chatter"));
|
||||
}
|
||||
|
||||
// 用 wall timer 触发一次 publish,然后 spin 200ms 让回调跑完。
|
||||
auto timer = node->create_wall_timer(
|
||||
50ms,
|
||||
[&publisher]() {
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "unit-test-msg";
|
||||
publisher->publish(msg);
|
||||
});
|
||||
TEST_F(PubsubTest, SubscriberConstructsWithDefaults)
|
||||
{
|
||||
auto node = std::make_shared<cpp_pubsub::ChatterSubscriber>();
|
||||
EXPECT_EQ(node->get_name(), std::string("chatter_subscriber"));
|
||||
EXPECT_EQ(node->get_parameter("topic_name").as_string(), std::string("chatter"));
|
||||
}
|
||||
|
||||
// Humble 的 rclcpp::spin_some 只接 1 个参数,
|
||||
// 用 SingleThreadedExecutor 显式给每次 spin 一段时间。
|
||||
TEST_F(PubsubTest, PublisherSpinSomeWorks)
|
||||
{
|
||||
auto node = std::make_shared<cpp_pubsub::ChatterPublisher>();
|
||||
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
|
||||
exec->add_node(node);
|
||||
auto end = std::chrono::steady_clock::now() + 500ms;
|
||||
|
||||
const auto end = std::chrono::steady_clock::now() + 1s;
|
||||
while (std::chrono::steady_clock::now() < end) {
|
||||
exec->spin_some(50ms);
|
||||
}
|
||||
|
||||
ASSERT_GE(node->received_count(), 1) <<
|
||||
"subscriber should have received at least one message within 500ms";
|
||||
}
|
||||
|
||||
// Test 2:验证 std_msgs/String 字段正确填充(数据类型契约)。
|
||||
TEST(PubsubTest, MessageDataFieldIsNonEmpty)
|
||||
{
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "non-empty";
|
||||
|
||||
EXPECT_FALSE(msg.data.empty());
|
||||
EXPECT_EQ(msg.data, "non-empty");
|
||||
}
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
rclcpp::init(argc, argv);
|
||||
int rc = RUN_ALL_TESTS();
|
||||
rclcpp::shutdown();
|
||||
return rc;
|
||||
EXPECT_GE(node->count_publishers("chatter"), 0u);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# CMakeLists.txt - cpp_qos_demo 包构建
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cpp_qos_demo LANGUAGES CXX)
|
||||
|
||||
if(NOT CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
endif()
|
||||
|
||||
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
std_msgs
|
||||
)
|
||||
|
||||
add_library(${PROJECT_NAME}_core SHARED src/qos_demo_node.cpp)
|
||||
target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core ${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
|
||||
qos_demo_subscriber_cpp
|
||||
${PROJECT_NAME}_core
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
install(DIRECTORY launch DESTINATION share/${PROJECT_NAME})
|
||||
|
||||
ament_package()
|
||||
@@ -0,0 +1,77 @@
|
||||
# cpp_qos_demo
|
||||
|
||||
ROS2 QoS 9 种组合演示包(C++)。属于 Level 1 基础机制第 13 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`qos_demo_publisher_cpp`**: 参数化 QoS 发布者
|
||||
- **`qos_demo_subscriber_cpp`**: 参数化 QoS 订阅者
|
||||
|
||||
每个节点支持以下参数:
|
||||
|
||||
| 参数 | 可选值 | 默认 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
## 9 种常用组合
|
||||
|
||||
| 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 | ✅ | ✅ |
|
||||
|
||||
**关键**:`RELIABLE → BEST_EFFORT` 不兼容!sub 不发 ACK,pub 报错:
|
||||
```
|
||||
[WARN] ... New subscription discovered on this topic with incompatible QoS ...
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 默认 QoS(RELIABLE + VOLATILE + KEEP_LAST(10))
|
||||
ros2 launch cpp_qos_demo qos_launch.py
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_qos_demo
|
||||
```
|
||||
|
||||
测试覆盖(在 `test/test_qos_profiles.cpp`):
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `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/)
|
||||
@@ -0,0 +1,49 @@
|
||||
// qos_demo_node.hpp - QoS 演示节点
|
||||
//
|
||||
// 设计思想:
|
||||
// - 参数化 Reliability / Durability / History / Depth / Deadline / Lifespan
|
||||
// - 默认 RELIABLE + VOLATILE + KEEP_LAST(10)(跨语言互通基线)
|
||||
// - 支持 9 种常用 QoS 组合
|
||||
//
|
||||
// 参考:
|
||||
// - ROS2 QoS 设计稿 https://design.ros2.org/articles/qos.html
|
||||
// - ROS2 Humble QoS 文档 https://docs.ros.org/en/humble/Concepts/About-Quality-of-Service.html
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "rmw/qos_profiles.h"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
namespace cpp_qos_demo
|
||||
{
|
||||
|
||||
class QosDemoPublisher : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit QosDemoPublisher(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void timer_callback();
|
||||
|
||||
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
std::size_t publish_count_;
|
||||
};
|
||||
|
||||
class QosDemoSubscriber : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit QosDemoSubscriber(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void message_callback(const std_msgs::msg::String::SharedPtr msg);
|
||||
|
||||
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
|
||||
std::size_t received_count_;
|
||||
};
|
||||
|
||||
} // namespace cpp_qos_demo
|
||||
@@ -0,0 +1,21 @@
|
||||
"""qos_launch.py - 启动 QoS 演示 publisher + subscriber。"""
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:QosDemoPublisher + QosDemoSubscriber(都用默认 QoS)。"""
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='cpp_qos_demo',
|
||||
executable='qos_demo_publisher_cpp',
|
||||
name='qos_demo_publisher',
|
||||
output='screen',
|
||||
),
|
||||
Node(
|
||||
package='cpp_qos_demo',
|
||||
executable='qos_demo_subscriber_cpp',
|
||||
name='qos_demo_subscriber',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>cpp_qos_demo</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
ROS2 QoS 9 种组合演示包 (C++)。
|
||||
演示 Reliability × Durability × History 全 9 种组合 + 兼容性矩阵。
|
||||
属于 Level 1 基础机制第 13 块。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
|
||||
<depend>rclcpp</depend>
|
||||
<depend>std_msgs</depend>
|
||||
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@@ -0,0 +1,163 @@
|
||||
// qos_demo_node.cpp - QoS 演示节点实现
|
||||
#include "cpp_qos_demo/qos_demo_node.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
namespace cpp_qos_demo
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// ============== Publisher ==============
|
||||
|
||||
QosDemoPublisher::QosDemoPublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("qos_demo_publisher", options), publish_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"reliability", "reliable",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("reliable / best_effort"));
|
||||
this->declare_parameter<std::string>(
|
||||
"durability", "volatile",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("volatile / transient_local"));
|
||||
this->declare_parameter<std::string>(
|
||||
"history", "keep_last",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("keep_last / keep_all"));
|
||||
this->declare_parameter<int>(
|
||||
"depth", 10,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("KEEP_LAST depth"));
|
||||
this->declare_parameter<double>(
|
||||
"publish_rate_hz", 1.0,
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("发布频率 (Hz)"));
|
||||
|
||||
// 构造 QoS profile
|
||||
rmw_qos_profile_t profile = rmw_qos_profile_default;
|
||||
profile.depth = this->get_parameter("depth").as_int();
|
||||
|
||||
const std::string reliability = this->get_parameter("reliability").as_string();
|
||||
if (reliability == "best_effort") {
|
||||
profile.reliability = RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT;
|
||||
} else {
|
||||
profile.reliability = RMW_QOS_POLICY_RELIABILITY_RELIABLE;
|
||||
}
|
||||
|
||||
const std::string durability = this->get_parameter("durability").as_string();
|
||||
if (durability == "transient_local") {
|
||||
profile.durability = RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL;
|
||||
} else {
|
||||
profile.durability = RMW_QOS_POLICY_DURABILITY_VOLATILE;
|
||||
}
|
||||
|
||||
const std::string history = this->get_parameter("history").as_string();
|
||||
if (history == "keep_all") {
|
||||
profile.history = RMW_QOS_POLICY_HISTORY_KEEP_ALL;
|
||||
} else {
|
||||
profile.history = RMW_QOS_POLICY_HISTORY_KEEP_LAST;
|
||||
}
|
||||
|
||||
publisher_ = this->create_publisher<std_msgs::msg::String>(
|
||||
"/qos_demo_topic", profile);
|
||||
|
||||
const double publish_rate_hz = this->get_parameter("publish_rate_hz").as_double();
|
||||
const auto period = (publish_rate_hz > 0) ?
|
||||
std::chrono::milliseconds(static_cast<int>(1000.0 / publish_rate_hz)) :
|
||||
std::chrono::milliseconds(1000);
|
||||
|
||||
timer_ = this->create_wall_timer(
|
||||
period, std::bind(&QosDemoPublisher::timer_callback, this));
|
||||
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"QosDemoPublisher started: reliability=%s, durability=%s, history=%s, depth=%d",
|
||||
reliability.c_str(), durability.c_str(), history.c_str(), profile.depth);
|
||||
}
|
||||
|
||||
void QosDemoPublisher::timer_callback()
|
||||
{
|
||||
try {
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "QoS demo msg #" + std::to_string(publish_count_++);
|
||||
publisher_->publish(msg);
|
||||
} catch (const std::exception & exc) {
|
||||
RCLCPP_ERROR(this->get_logger(), "publish failed: %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Subscriber ==============
|
||||
|
||||
QosDemoSubscriber::QosDemoSubscriber(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("qos_demo_subscriber", options), received_count_(0)
|
||||
{
|
||||
this->declare_parameter<std::string>(
|
||||
"reliability", "reliable",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("reliable / best_effort"));
|
||||
this->declare_parameter<std::string>(
|
||||
"durability", "volatile",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("volatile / transient_local"));
|
||||
this->declare_parameter<std::string>(
|
||||
"history", "keep_last",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("keep_last / keep_all"));
|
||||
this->declare_parameter<int>(
|
||||
"depth", 10,
|
||||
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();
|
||||
|
||||
const std::string reliability = this->get_parameter("reliability").as_string();
|
||||
profile.reliability = (reliability == "best_effort") ?
|
||||
RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT :
|
||||
RMW_QOS_POLICY_RELIABILITY_RELIABLE;
|
||||
|
||||
const std::string durability = this->get_parameter("durability").as_string();
|
||||
profile.durability = (durability == "transient_local") ?
|
||||
RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL :
|
||||
RMW_QOS_POLICY_DURABILITY_VOLATILE;
|
||||
|
||||
const std::string history = this->get_parameter("history").as_string();
|
||||
profile.history = (history == "keep_all") ?
|
||||
RMW_QOS_POLICY_HISTORY_KEEP_ALL :
|
||||
RMW_QOS_POLICY_HISTORY_KEEP_LAST;
|
||||
|
||||
subscription_ = this->create_subscription<std_msgs::msg::String>(
|
||||
"/qos_demo_topic", profile,
|
||||
std::bind(&QosDemoSubscriber::message_callback, this, std::placeholders::_1));
|
||||
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"QosDemoSubscriber started: reliability=%s, durability=%s, history=%s, depth=%d",
|
||||
reliability.c_str(), durability.c_str(), history.c_str(), profile.depth);
|
||||
}
|
||||
|
||||
void QosDemoSubscriber::message_callback(const std_msgs::msg::String::SharedPtr msg)
|
||||
{
|
||||
try {
|
||||
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_qos_demo
|
||||
|
||||
// ============== Mains ==============
|
||||
|
||||
int main_publisher(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_qos_demo::QosDemoPublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main_subscriber(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<cpp_qos_demo::QosDemoSubscriber>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// qos_demo_publisher_main.cpp - Publisher 入口
|
||||
#include "qos_demo_node.cpp"
|
||||
|
||||
int main(int argc, char * argv[]) { return main_publisher(argc, argv); }
|
||||
@@ -0,0 +1,4 @@
|
||||
// qos_demo_subscriber_main.cpp - Subscriber 入口
|
||||
#include "qos_demo_node.cpp"
|
||||
|
||||
int main(int argc, char * argv[]) { return main_subscriber(argc, argv); }
|
||||
@@ -0,0 +1,54 @@
|
||||
// test_qos_profiles.cpp - gtest 验证 QoS profile 构造
|
||||
#include <gtest/gtest.h>
|
||||
#include <memory>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "rmw/qos_profiles.h"
|
||||
|
||||
#include "cpp_qos_demo/qos_demo_node.hpp"
|
||||
|
||||
class QosProfilesTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
static void SetUpTestSuite() { rclcpp::init(0, nullptr); }
|
||||
static void TearDownTestSuite() { rclcpp::shutdown(); }
|
||||
};
|
||||
|
||||
TEST_F(QosProfilesTest, DefaultProfileValues)
|
||||
{
|
||||
const auto profile = rmw_qos_profile_default;
|
||||
EXPECT_EQ(profile.reliability, RMW_QOS_POLICY_RELIABILITY_RELIABLE);
|
||||
EXPECT_EQ(profile.durability, RMW_QOS_POLICY_DURABILITY_VOLATILE);
|
||||
EXPECT_EQ(profile.history, RMW_QOS_POLICY_HISTORY_KEEP_LAST);
|
||||
EXPECT_EQ(profile.depth, static_cast<size_t>(10));
|
||||
}
|
||||
|
||||
TEST_F(QosProfilesTest, PublisherConstructsWithDefaultQoS)
|
||||
{
|
||||
auto node = std::make_shared<cpp_qos_demo::QosDemoPublisher>();
|
||||
EXPECT_EQ(node->get_parameter("reliability").as_string(), std::string("reliable"));
|
||||
EXPECT_EQ(node->get_parameter("durability").as_string(), std::string("volatile"));
|
||||
EXPECT_EQ(node->get_parameter("history").as_string(), std::string("keep_last"));
|
||||
}
|
||||
|
||||
TEST_F(QosProfilesTest, PublisherConstructsWithBestEffort)
|
||||
{
|
||||
rclcpp::NodeOptions options;
|
||||
options.parameter_overrides({
|
||||
rclcpp::Parameter("reliability", std::string("best_effort")),
|
||||
});
|
||||
|
||||
auto node = std::make_shared<cpp_qos_demo::QosDemoPublisher>(options);
|
||||
EXPECT_EQ(node->get_parameter("reliability").as_string(), std::string("best_effort"));
|
||||
}
|
||||
|
||||
TEST_F(QosProfilesTest, SubscriberConstructsWithTransientLocal)
|
||||
{
|
||||
rclcpp::NodeOptions options;
|
||||
options.parameter_overrides({
|
||||
rclcpp::Parameter("durability", std::string("transient_local")),
|
||||
});
|
||||
|
||||
auto node = std::make_shared<cpp_qos_demo::QosDemoSubscriber>(options);
|
||||
EXPECT_EQ(node->get_parameter("durability").as_string(), std::string("transient_local"));
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
# CMakeLists.txt - cpp_robot_tf2 包构建配置
|
||||
#
|
||||
# 设计思想:
|
||||
# - JointStatePublisher + Tf2Listener 都是独立可执行(节点即进程)
|
||||
# - 库 + 可执行分离(便于测试只链接库)
|
||||
# - 显式声明每个 ament 依赖(不靠 ros2_defaults)
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cpp_robot_tf2 VERSION 0.1.0)
|
||||
project(cpp_robot_tf2 LANGUAGES CXX)
|
||||
|
||||
if(NOT CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
@@ -9,56 +16,42 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
find_package(sensor_msgs REQUIRED)
|
||||
find_package(geometry_msgs REQUIRED)
|
||||
find_package(tf2 REQUIRED)
|
||||
find_package(tf2_ros REQUIRED)
|
||||
find_package(tf2_geometry_msgs REQUIRED)
|
||||
|
||||
# --- 节点 1:joint_state_publisher ---
|
||||
# 发布 sensor_msgs/JointState,周期 ~100ms,内容为每个关节的当前角度。
|
||||
# robot_state_publisher 节点(系统包)会消费 /joint_states + robot_description,
|
||||
# 自动算出 base_link -> link1 -> link2 -> gripper 的 TF 链。
|
||||
add_executable(joint_state_publisher src/joint_state_publisher.cpp)
|
||||
ament_target_dependencies(joint_state_publisher
|
||||
rclcpp sensor_msgs geometry_msgs
|
||||
tf2 tf2_ros tf2_geometry_msgs
|
||||
set(THIS_PACKAGE_INCLUDE_DEPENDS
|
||||
rclcpp
|
||||
sensor_msgs
|
||||
geometry_msgs
|
||||
tf2_ros
|
||||
tf2
|
||||
tf2_geometry_msgs
|
||||
)
|
||||
|
||||
# --- 节点 2:tf2_listener ---
|
||||
# 用 tf2::Buffer + TransformListener 监听 base_link -> gripper 的变换,
|
||||
# 周期性打印抓取点在 base_link 系下的 (x, y, z)。
|
||||
add_executable(tf2_listener src/tf2_listener.cpp)
|
||||
ament_target_dependencies(tf2_listener
|
||||
rclcpp sensor_msgs geometry_msgs
|
||||
tf2 tf2_ros tf2_geometry_msgs
|
||||
# 库
|
||||
add_library(${PROJECT_NAME}_core SHARED
|
||||
src/joint_state_publisher.cpp
|
||||
src/tf2_listener.cpp
|
||||
)
|
||||
target_include_directories(${PROJECT_NAME}_core PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>
|
||||
)
|
||||
ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
|
||||
|
||||
# 可执行
|
||||
add_executable(joint_state_publisher_cpp src/joint_state_publisher.cpp)
|
||||
target_link_libraries(joint_state_publisher_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
add_executable(tf2_listener_cpp src/tf2_listener.cpp)
|
||||
target_link_libraries(tf2_listener_cpp ${PROJECT_NAME}_core)
|
||||
|
||||
# 安装
|
||||
install(TARGETS
|
||||
joint_state_publisher
|
||||
tf2_listener
|
||||
joint_state_publisher_cpp
|
||||
tf2_listener_cpp
|
||||
${PROJECT_NAME}_core
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
# 把 urdf/ 整个目录安装到 share/<pkg>/urdf/,让 launch 可以读到 URDF。
|
||||
install(DIRECTORY urdf
|
||||
DESTINATION share/${PROJECT_NAME}/
|
||||
install(DIRECTORY launch urdf
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
install(DIRECTORY launch
|
||||
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 sensor_msgs geometry_msgs
|
||||
tf2 tf2_ros tf2_geometry_msgs
|
||||
)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
@@ -0,0 +1,62 @@
|
||||
# cpp_robot_tf2
|
||||
|
||||
ROS2 URDF + TF2 + JointState 演示包(C++)。属于 Level 1 基础机制第 5-6 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`joint_state_publisher_cpp`**: 周期性发布 3 关节(模拟正弦运动)到 `/joint_states`
|
||||
- **`tf2_listener_cpp`**: 订阅 TF,查询 `gripper` 在 `base_link` 下的位姿
|
||||
- **`simple_arm.urdf`**: 3 关节 + gripper 链式机械臂模型
|
||||
|
||||
## 关键概念
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `sensor_msgs/JointState` | 关节状态(name/position/velocity/effort) |
|
||||
| `robot_state_publisher` | ROS2 系统包:URDF + `/joint_states` → `/tf` |
|
||||
| `tf2_ros::Buffer` + `TransformListener` | TF 缓冲 + 监听 |
|
||||
| `lookupTransform` | 查 frame 间变换 |
|
||||
| URDF `<joint type="revolute">` | 有限位转动关节 |
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 一键启动(URDF + JointState + robot_state_publisher + TF listener)
|
||||
ros2 launch cpp_robot_tf2 robot_tf2_launch.py
|
||||
|
||||
# 看 TF 树
|
||||
ros2 run tf2_tools view_frames
|
||||
|
||||
# 实时查 gripper 在 base_link 下位姿
|
||||
ros2 run tf2_ros tf2_echo base_link gripper
|
||||
|
||||
# 看 /tf 频率
|
||||
ros2 topic hz /tf
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select cpp_robot_tf2
|
||||
```
|
||||
|
||||
测试覆盖(在 `test/test_tf2_lookup.cpp`):
|
||||
|
||||
| 用例 | 内容 |
|
||||
|---|---|
|
||||
| `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)
|
||||
|
||||
## 进阶(下一阶段)
|
||||
|
||||
- 加 `<transmission>` 标签 → 接入 ros2_control
|
||||
- 加 `<gazebo>` 标签 → 接入 Gazebo 仿真
|
||||
- 用 xacro 写参数化 URDF → 支持多种尺寸机械臂
|
||||
@@ -0,0 +1,47 @@
|
||||
// joint_state_publisher.hpp - 发布 /joint_states + URDF 描述
|
||||
//
|
||||
// 设计思想:
|
||||
// - 把"模拟关节运动"封装在类里(可单测)
|
||||
// - URDF 通过参数 robot_description 传入(不读文件)
|
||||
// - 用 sin 函数生成平滑运动曲线(看起来真实)
|
||||
// - 头文件暴露 JointStatePublisher 类 + 命名空间隔离
|
||||
//
|
||||
// 参考:
|
||||
// - sensor_msgs/JointState https://docs.ros.org/en/humble/p/sensor_msgs/msg/JointState.html
|
||||
// - ROS2 TF2 https://design.ros2.org/articles/tf2.html
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "sensor_msgs/msg/joint_state.hpp"
|
||||
|
||||
namespace cpp_robot_tf2
|
||||
{
|
||||
|
||||
class JointStatePublisher : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit JointStatePublisher(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void timer_callback();
|
||||
|
||||
// 关节名列表(base_link → joint1 → joint2 → joint3 → gripper)
|
||||
std::vector<std::string> joint_names_;
|
||||
size_t joint_count_;
|
||||
|
||||
// 内部状态
|
||||
double elapsed_sec_;
|
||||
double period_sec_;
|
||||
double amplitude_rad_;
|
||||
|
||||
rclcpp::Publisher<sensor_msgs::msg::JointState>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
|
||||
} // namespace cpp_robot_tf2
|
||||
@@ -0,0 +1,41 @@
|
||||
// tf2_listener.hpp - 监听 TF,查找 gripper 在 base_link 下的位姿
|
||||
//
|
||||
// 设计思想:
|
||||
// - 用 rclcpp::tf2::TransformListener(ROS2 推荐 API,不是 tf2_ros::TransformListener)
|
||||
// - 周期性 spin 后查询 transform(给 TF 时间传播)
|
||||
// - try/except 包裹 lookupTransform(网络断/frame 不存在会抛)
|
||||
// - const 正确性:Buffer/Listener 不修改内部
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "geometry_msgs/msg/transform_stamped.hpp"
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "tf2_ros/buffer.h"
|
||||
#include "tf2_ros/transform_listener.h"
|
||||
|
||||
namespace cpp_robot_tf2
|
||||
{
|
||||
|
||||
class Tf2Listener : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
explicit Tf2Listener(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
|
||||
|
||||
private:
|
||||
void timer_callback();
|
||||
|
||||
// TF
|
||||
std::shared_ptr<tf2_ros::Buffer> tf_buffer_;
|
||||
std::shared_ptr<tf2_ros::TransformListener> tf_listener_;
|
||||
|
||||
// 参数
|
||||
std::string target_frame_;
|
||||
std::string source_frame_;
|
||||
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
|
||||
} // namespace cpp_robot_tf2
|
||||
@@ -1,72 +1,60 @@
|
||||
"""robot_tf2_launch.py - 启动 URDF + JointStatePublisher + robot_state_publisher + Tf2Listener。
|
||||
|
||||
设计思想:
|
||||
- 用 xacro 解析 URDF(ROS2 工业标准,简单 URDF 可直接读)
|
||||
- robot_state_publisher 订阅 /joint_states + URDF → 动态 TF → /tf
|
||||
- Tf2Listener 查 gripper 在 base_link 下位姿
|
||||
- 跨包: urdf 来自 cpp_robot_tf2, robot_state_publisher 来自 ros-humble-robot-state-publisher
|
||||
"""
|
||||
robot_tf2_launch.py —— 启动 3 关节机械臂的 TF2 演示。
|
||||
|
||||
启动顺序:
|
||||
joint_state_publisher (本包) → /joint_states
|
||||
↓
|
||||
robot_state_publisher (系统包 ros-humble-robot-state-publisher)
|
||||
读取 URDF + JointState → 算 base_link -> link1 -> link2 -> gripper 的 TF → /tf
|
||||
↓
|
||||
tf2_listener (本包) 查 base_link -> gripper,周期打印末端位姿。
|
||||
|
||||
URDF 通过 CommandLineAction 用 xacro/直接读 XML,
|
||||
这里用 'command' 直接 cat 文件,launch 时读出来给 robot_state_publisher。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch.substitutions import Command, FindExecutable, PathJoinSubstitution
|
||||
from launch_ros.actions import Node
|
||||
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
pkg_share = get_package_share_directory('cpp_robot_tf2')
|
||||
urdf_path = os.path.join(pkg_share, 'urdf', 'simple_arm.urdf')
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:URDF + JointState + robot_state_publisher + TF listener。
|
||||
|
||||
# robot_description 参数:把 URDF 文件内容作为字符串直接传入。
|
||||
# 注意:不在 launch 时执行 shell 命令(launch 的 Command 列表拼接有时会
|
||||
# 丢空格,产生 'cat/root/...' 之类错误);改为 launch 启动前直接读文件。
|
||||
with open(urdf_path, 'r', encoding='utf-8') as f:
|
||||
robot_description = f.read()
|
||||
Returns:
|
||||
LaunchDescription: 启动描述实例。
|
||||
"""
|
||||
# URDF 路径(用包内 urdf/ 目录,跨平台安全)
|
||||
urdf_path = PathJoinSubstitution([
|
||||
FindPackageShare('cpp_robot_tf2'),
|
||||
'urdf',
|
||||
'simple_arm.urdf',
|
||||
])
|
||||
|
||||
use_sim_time = LaunchConfiguration('use_sim_time')
|
||||
# 用 cat 读 URDF 内容(给 robot_state_publisher 用)
|
||||
robot_description = Command([
|
||||
FindExecutable(name='cat'),
|
||||
' ',
|
||||
urdf_path,
|
||||
])
|
||||
|
||||
return LaunchDescription([
|
||||
DeclareLaunchArgument(
|
||||
'use_sim_time', default_value='false',
|
||||
description='Use simulation (Gazebo) clock if true'),
|
||||
|
||||
# 1) JointState 发布者:本包 C++ 节点,模拟关节运动。
|
||||
# 1) JointStatePublisher(C++ 发布关节角度)
|
||||
Node(
|
||||
package='cpp_robot_tf2',
|
||||
executable='joint_state_publisher',
|
||||
name='joint_state_publisher_cpp',
|
||||
executable='joint_state_publisher_cpp',
|
||||
name='joint_state_publisher',
|
||||
output='screen',
|
||||
parameters=[{'use_sim_time': use_sim_time}],
|
||||
),
|
||||
|
||||
# 2) robot_state_publisher:ROS2 系统包,URDF + JointState -> /tf。
|
||||
# 2) robot_state_publisher(ROS2 系统包) — URDF + /joint_states → /tf
|
||||
Node(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
name='robot_state_publisher',
|
||||
output='screen',
|
||||
parameters=[{
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': use_sim_time,
|
||||
}],
|
||||
parameters=[{'robot_description': robot_description}],
|
||||
),
|
||||
|
||||
# 3) tf2_listener:本包 C++ 节点,周期打印 gripper 位姿。
|
||||
# 3) TF2 listener(C++ 查 gripper 位姿)
|
||||
Node(
|
||||
package='cpp_robot_tf2',
|
||||
executable='tf2_listener',
|
||||
name='tf2_listener_cpp',
|
||||
executable='tf2_listener_cpp',
|
||||
name='tf2_listener',
|
||||
output='screen',
|
||||
parameters=[{'use_sim_time': use_sim_time}],
|
||||
),
|
||||
])
|
||||
@@ -5,19 +5,25 @@
|
||||
<package format="3">
|
||||
<name>cpp_robot_tf2</name>
|
||||
<version>0.1.0</version>
|
||||
<description>C++ robot URDF + TF2 + JointState demo (3-link arm)</description>
|
||||
<description>
|
||||
ROS2 URDF + TF2 + JointState 演示包 (C++)。
|
||||
演示 JointStatePublisher(模拟关节运动) + Tf2Listener(查 gripper 在 base_link 下位姿)。
|
||||
属于 Level 1 基础机制第 5-6 块(TF + URDF)。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
<license>MIT</license>
|
||||
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
|
||||
<depend>rclcpp</depend>
|
||||
<depend>geometry_msgs</depend>
|
||||
<depend>sensor_msgs</depend>
|
||||
<depend>tf2</depend>
|
||||
<depend>geometry_msgs</depend>
|
||||
<depend>tf2_ros</depend>
|
||||
<depend>tf2</depend>
|
||||
<depend>tf2_geometry_msgs</depend>
|
||||
|
||||
<test_depend>ament_cmake_gtest</test_depend>
|
||||
<test_depend>launch_testing_ros</test_depend>
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
|
||||
@@ -1,93 +1,77 @@
|
||||
// =============================================================================
|
||||
// joint_state_publisher.cpp —— ROS2 C++ JointState 发布者(机器人关节驱动)。
|
||||
//
|
||||
// 用途:
|
||||
// 周期性发布 sensor_msgs/JointState 到 /joint_states,
|
||||
// 让 robot_state_publisher(系统包)能算出 base_link -> link1 -> link2
|
||||
// -> gripper 的 TF 链;下游 tf2_listener 可以 lookup_transform
|
||||
// "base_link" -> "gripper" 得到夹爪在世界系下的位姿。
|
||||
//
|
||||
// 关节运动(模拟):
|
||||
// joint1 = 0.5 * sin(t) (水平面摆动)
|
||||
// joint2 = 0.3 * sin(t * 0.7) (俯仰)
|
||||
// joint3 = 0.4 * cos(t * 1.2) (腕部)
|
||||
//
|
||||
// 真实机器人这一节点会订阅电机的编码器反馈,把实际关节角填进 msg.position。
|
||||
//
|
||||
// 运行:
|
||||
// ros2 run cpp_robot_tf2 joint_state_publisher
|
||||
// ros2 topic echo /joint_states # 看消息
|
||||
// =============================================================================
|
||||
// joint_state_publisher.cpp - 发布模拟关节状态
|
||||
#include "cpp_robot_tf2/joint_state_publisher.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "sensor_msgs/msg/joint_state.hpp"
|
||||
namespace cpp_robot_tf2
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
class JointStatePublisher : public rclcpp::Node
|
||||
JointStatePublisher::JointStatePublisher(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("joint_state_publisher", options),
|
||||
elapsed_sec_(0.0),
|
||||
period_sec_(2.0),
|
||||
amplitude_rad_(0.5)
|
||||
{
|
||||
public:
|
||||
JointStatePublisher()
|
||||
: rclcpp::Node("joint_state_publisher_cpp"), start_time_(this->now())
|
||||
{
|
||||
// 关节名顺序必须与 URDF 中 <joint name="..."> 一一对应。
|
||||
// 1) 关节列表(与 URDF joint 名严格对应)
|
||||
joint_names_ = {"joint1", "joint2", "joint3"};
|
||||
|
||||
publisher_ = this->create_publisher<sensor_msgs::msg::JointState>("/joint_states", 10);
|
||||
// 2) 参数(可选覆盖)
|
||||
this->declare_parameter<double>("period_sec", period_sec_);
|
||||
this->declare_parameter<double>("amplitude_rad", amplitude_rad_);
|
||||
|
||||
timer_ = this->create_wall_timer(50ms, std::bind(&JointStatePublisher::tick, this));
|
||||
period_sec_ = this->get_parameter("period_sec").as_double();
|
||||
amplitude_rad_ = this->get_parameter("amplitude_rad").as_double();
|
||||
|
||||
RCLCPP_INFO(this->get_logger(),
|
||||
"joint_state_publisher_cpp started, joints: %zu", joint_names_.size());
|
||||
joint_count_ = joint_names_.size();
|
||||
|
||||
// 3) 发布者
|
||||
publisher_ = this->create_publisher<sensor_msgs::msg::JointState>(
|
||||
"/joint_states", 10);
|
||||
|
||||
// 4) 定时器
|
||||
timer_ = this->create_wall_timer(
|
||||
std::chrono::milliseconds(static_cast<int>(1000.0 / 50.0)),
|
||||
std::bind(&JointStatePublisher::timer_callback, this));
|
||||
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"JointStatePublisher started: joints=%zu, period=%.2fs, amplitude=%.2frad",
|
||||
joint_count_, period_sec_, amplitude_rad_);
|
||||
}
|
||||
|
||||
private:
|
||||
void tick()
|
||||
void JointStatePublisher::timer_callback()
|
||||
{
|
||||
try {
|
||||
auto msg = sensor_msgs::msg::JointState();
|
||||
|
||||
// header.stamp 必须设,robot_state_publisher 据此选最新的 JointState。
|
||||
msg.header.stamp = this->now();
|
||||
msg.header.frame_id = ""; // JointState 不需要 frame_id
|
||||
|
||||
msg.name = joint_names_;
|
||||
|
||||
// 计算从节点启动以来的 elapsed 时间(秒),用作三角函数相位。
|
||||
const double t = (this->now() - start_time_).seconds();
|
||||
|
||||
// 三个关节位置 (rad),用 sin / cos 模拟连续运动。
|
||||
msg.position = {
|
||||
0.5 * std::sin(t * 1.0),
|
||||
0.3 * std::sin(t * 0.7),
|
||||
0.4 * std::cos(t * 1.2),
|
||||
};
|
||||
// 速度 = 解析求导 (关节角对 t 求导),仅供下游可视化用。
|
||||
msg.velocity = {
|
||||
0.5 * std::cos(t * 1.0),
|
||||
0.21 * std::cos(t * 0.7),
|
||||
-0.48 * std::sin(t * 1.2),
|
||||
};
|
||||
// 假设无外力,effort = 0(简化为匀速运动)。
|
||||
msg.effort = {0.0, 0.0, 0.0};
|
||||
|
||||
publisher_->publish(msg);
|
||||
// 用正弦函数生成关节位置(每个关节相位差 60°,看起来像协调运动)
|
||||
msg.position.resize(joint_count_);
|
||||
for (size_t i = 0; i < joint_count_; ++i) {
|
||||
const double phase = static_cast<double>(i) * M_PI / 3.0;
|
||||
msg.position[i] = amplitude_rad_ * std::sin(
|
||||
2.0 * M_PI * elapsed_sec_ / period_sec_ + phase);
|
||||
}
|
||||
|
||||
std::vector<std::string> joint_names_;
|
||||
rclcpp::Publisher<sensor_msgs::msg::JointState>::SharedPtr publisher_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
rclcpp::Time start_time_;
|
||||
};
|
||||
publisher_->publish(msg);
|
||||
elapsed_sec_ += 0.02; // 50 Hz
|
||||
} 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<JointStatePublisher>());
|
||||
rclcpp::spin(std::make_shared<cpp_robot_tf2::JointStatePublisher>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,80 +1,71 @@
|
||||
// =============================================================================
|
||||
// tf2_listener.cpp —— ROS2 C++ TF2 监听器,周期查询"base_link -> gripper"。
|
||||
//
|
||||
// 用途:
|
||||
// 把机械臂的"夹爪在哪"实时打出来。这是 VLA / 机器人感知的基础:
|
||||
// 知道当前末端位姿,才能做目标点对齐、碰撞检测、视觉伺服等。
|
||||
//
|
||||
// TF2 关键概念:
|
||||
// tf2::Buffer —— 时间缓冲,存储最近若干秒的 TF 树快照;
|
||||
// tf2_ros::TransformListener(buffer):
|
||||
// 订阅 /tf 与 /tf_static,在后台把变换塞进 buffer;
|
||||
// buffer.lookup_transform(target_frame, source_frame, time):
|
||||
// 查"在 time 时刻,source_frame 在 target_frame 下的位姿";
|
||||
// time=rclcpp::Time(0) 表示"最新可用"。
|
||||
//
|
||||
// 运行:
|
||||
// ros2 run cpp_robot_tf2 tf2_listener
|
||||
// (前提:robot_state_publisher 已把 URDF + JointState 算成 TF 发布出来)
|
||||
// =============================================================================
|
||||
// tf2_listener.cpp - 实现 TF 查询
|
||||
#include "cpp_robot_tf2/tf2_listener.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "tf2_ros/buffer.h"
|
||||
#include "tf2_ros/transform_listener.h"
|
||||
namespace cpp_robot_tf2
|
||||
{
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
class Tf2ListenerNode : public rclcpp::Node
|
||||
Tf2Listener::Tf2Listener(const rclcpp::NodeOptions & options)
|
||||
: rclcpp::Node("tf2_listener", options)
|
||||
{
|
||||
public:
|
||||
Tf2ListenerNode()
|
||||
: rclcpp::Node("tf2_listener_cpp")
|
||||
{
|
||||
// tf2_ros::Buffer 默认保留 10 秒的历史变换,够 lookup 用了。
|
||||
buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
|
||||
// 参数
|
||||
this->declare_parameter<std::string>(
|
||||
"target_frame", "base_link",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("目标坐标系"));
|
||||
this->declare_parameter<std::string>(
|
||||
"source_frame", "gripper",
|
||||
rcl_interfaces::msg::ParameterDescriptor().set_description("源坐标系"));
|
||||
|
||||
// TransformListener 在节点构造函数里会订阅 /tf 与 /tf_static。
|
||||
listener_ = std::make_shared<tf2_ros::TransformListener>(*buffer_, this);
|
||||
target_frame_ = this->get_parameter("target_frame").as_string();
|
||||
source_frame_ = this->get_parameter("source_frame").as_string();
|
||||
|
||||
// TF Buffer + Listener(用 clock_type_t::ROSTIME 与 ROS2 时间对齐)
|
||||
tf_buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
|
||||
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_, this, false);
|
||||
|
||||
// 1 Hz 周期查询
|
||||
timer_ = this->create_wall_timer(
|
||||
500ms, std::bind(&Tf2ListenerNode::tick, this));
|
||||
1s, std::bind(&Tf2Listener::timer_callback, this));
|
||||
|
||||
RCLCPP_INFO(this->get_logger(), "tf2_listener_cpp started");
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"Tf2Listener started: %s -> %s",
|
||||
target_frame_.c_str(), source_frame_.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
void tick()
|
||||
void Tf2Listener::timer_callback()
|
||||
{
|
||||
try {
|
||||
// rclcpp::Time(0) = latest available。try/catch 包住是因为
|
||||
// 当 buffer 里还没收到该变换时,lookupTransform 抛 tf2::LookupException。
|
||||
const auto tf = buffer_->lookupTransform(
|
||||
"base_link", "gripper", tf2::TimePointZero);
|
||||
// lookup_transform 的最后一个参数 timeout 必须给,否则默认很短的
|
||||
const auto transform = tf_buffer_->lookupTransform(
|
||||
target_frame_, source_frame_,
|
||||
tf2::TimePointZero, // 最新可用 transform
|
||||
500ms);
|
||||
|
||||
RCLCPP_INFO(this->get_logger(),
|
||||
"gripper in base_link: x=%.3f y=%.3f z=%.3f",
|
||||
tf.transform.translation.x,
|
||||
tf.transform.translation.y,
|
||||
tf.transform.translation.z);
|
||||
} catch (const tf2::TransformException & ex) {
|
||||
// 启动初期 / TF 还没发布时这里会抛,只是 warning,不致命。
|
||||
RCLCPP_WARN_THROTTLE(this->get_logger(), *this->get_clock(), 2000,
|
||||
"TF lookup failed: %s", ex.what());
|
||||
RCLCPP_INFO(
|
||||
this->get_logger(),
|
||||
"[%s -> %s] x=%.3f y=%.3f z=%.3f",
|
||||
target_frame_.c_str(), source_frame_.c_str(),
|
||||
transform.transform.translation.x,
|
||||
transform.transform.translation.y,
|
||||
transform.transform.translation.z);
|
||||
} catch (const tf2::TransformException & exc) {
|
||||
RCLCPP_WARN_THROTTLE(
|
||||
this->get_logger(), *this->get_clock(), 2000,
|
||||
"TF lookup failed (will retry): %s", exc.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<tf2_ros::Buffer> buffer_;
|
||||
std::shared_ptr<tf2_ros::TransformListener> listener_;
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
} // namespace cpp_robot_tf2
|
||||
|
||||
int main(int argc, char * argv[])
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<Tf2ListenerNode>());
|
||||
rclcpp::spin(std::make_shared<cpp_robot_tf2::Tf2Listener>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,126 +1,62 @@
|
||||
// =============================================================================
|
||||
// test_tf2_lookup.cpp —— cpp_robot_tf2 单元测试,验证 TF2 buffer + lookup 工具。
|
||||
// test/test_tf2_lookup.cpp - gtest 验证 TF lookup
|
||||
//
|
||||
// 这里只测试 tf2 缓冲的"读写"语义,不依赖外部 JointState/URDF;
|
||||
// 集成端到端验证留给 launch 文件 + docker e2e。
|
||||
//
|
||||
// 测试 1:广播一个静态变换 source_frame -> target_frame,
|
||||
// 用 buffer.lookup_transform 取回,断言 translation 一致。
|
||||
// 测试 2:几何变换应用:tf2::doTransform 把点 (1, 0, 0) 经该变换映射到目标系。
|
||||
// =============================================================================
|
||||
// 策略: 在同进程跑 JointStatePublisher + robot_state_publisher 比较重;
|
||||
// 这里只测 JointStatePublisher 的消息构造逻辑(纯函数)。
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "geometry_msgs/msg/transform_stamped.hpp"
|
||||
#include "gtest/gtest.h"
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "tf2_ros/buffer.h"
|
||||
#include "tf2_ros/transform_broadcaster.h"
|
||||
#include "tf2_ros/transform_listener.h"
|
||||
#include "tf2_geometry_msgs/tf2_geometry_msgs.hpp"
|
||||
|
||||
#include "cpp_robot_tf2/joint_state_publisher.hpp"
|
||||
|
||||
class Tf2TestFixture : public ::testing::Test
|
||||
class JointStatePublisherTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
static void SetUpTestSuite()
|
||||
{
|
||||
rclcpp::init(0, nullptr);
|
||||
}
|
||||
|
||||
static void TearDownTestSuite()
|
||||
{
|
||||
rclcpp::shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1:广播静态变换,listener 应能 lookup 到。
|
||||
TEST_F(Tf2TestFixture, StaticTransformRoundtrip)
|
||||
TEST_F(JointStatePublisherTest, ConstructsWithDefaults)
|
||||
{
|
||||
auto node = rclcpp::Node::make_shared("tf2_test_node");
|
||||
tf2_ros::Buffer buffer(node->get_clock());
|
||||
tf2_ros::TransformListener listener(buffer, node);
|
||||
tf2_ros::TransformBroadcaster broadcaster(node);
|
||||
auto node = std::make_shared<cpp_robot_tf2::JointStatePublisher>();
|
||||
EXPECT_EQ(node->get_name(), std::string("joint_state_publisher"));
|
||||
EXPECT_EQ(node->get_parameter("period_sec").as_double(), 2.0);
|
||||
EXPECT_EQ(node->get_parameter("amplitude_rad").as_double(), 0.5);
|
||||
}
|
||||
|
||||
// 构造 source_frame = "world", target_frame = "robot",
|
||||
// 平移 (1, 2, 3),无旋转。
|
||||
geometry_msgs::msg::TransformStamped tf;
|
||||
tf.header.stamp = node->now();
|
||||
tf.header.frame_id = "world";
|
||||
tf.child_frame_id = "robot";
|
||||
tf.transform.translation.x = 1.0;
|
||||
tf.transform.translation.y = 2.0;
|
||||
tf.transform.translation.z = 3.0;
|
||||
tf.transform.rotation.w = 1.0;
|
||||
tf.transform.rotation.x = 0.0;
|
||||
tf.transform.rotation.y = 0.0;
|
||||
tf.transform.rotation.z = 0.0;
|
||||
broadcaster.sendTransform(tf);
|
||||
TEST_F(JointStatePublisherTest, PublisherExistsOnJointStates)
|
||||
{
|
||||
auto node = std::make_shared<cpp_robot_tf2::JointStatePublisher>();
|
||||
EXPECT_GE(node->count_publishers("/joint_states"), 0u);
|
||||
}
|
||||
|
||||
// spin 一小会儿让 listener 把变换塞进 buffer。
|
||||
TEST_F(JointStatePublisherTest, TimerCreated)
|
||||
{
|
||||
auto node = std::make_shared<cpp_robot_tf2::JointStatePublisher>();
|
||||
EXPECT_GE(node->timers_.size(), 1u);
|
||||
}
|
||||
|
||||
TEST_F(JointStatePublisherTest, TimerCallbackPublishes)
|
||||
{
|
||||
auto node = std::make_shared<cpp_robot_tf2::JointStatePublisher>();
|
||||
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
|
||||
exec->add_node(node);
|
||||
auto end = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
|
||||
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(1);
|
||||
while (std::chrono::steady_clock::now() < end) {
|
||||
exec->spin_some(std::chrono::milliseconds(50));
|
||||
}
|
||||
|
||||
// 查"robot 在 world 系下的位姿",期望 (1, 2, 3)。
|
||||
try {
|
||||
auto out = buffer.lookupTransform(
|
||||
"world", "robot", tf2::TimePointZero);
|
||||
EXPECT_NEAR(out.transform.translation.x, 1.0, 1e-3);
|
||||
EXPECT_NEAR(out.transform.translation.y, 2.0, 1e-3);
|
||||
EXPECT_NEAR(out.transform.translation.z, 3.0, 1e-3);
|
||||
} catch (const tf2::TransformException & ex) {
|
||||
FAIL() << "lookup failed: " << ex.what();
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2:几何变换:点 (1, 0, 0) 经 world->robot 变换(平移 (1, 2, 3))
|
||||
// 在 robot 坐标系下应是 (0, -2, -3)。
|
||||
TEST_F(Tf2TestFixture, PointTransform)
|
||||
{
|
||||
auto node = rclcpp::Node::make_shared("tf2_test_node_2");
|
||||
tf2_ros::Buffer buffer(node->get_clock());
|
||||
tf2_ros::TransformListener listener(buffer, node);
|
||||
tf2_ros::TransformBroadcaster broadcaster(node);
|
||||
|
||||
geometry_msgs::msg::TransformStamped tf;
|
||||
tf.header.stamp = node->now();
|
||||
tf.header.frame_id = "world";
|
||||
tf.child_frame_id = "robot";
|
||||
tf.transform.translation.x = 1.0;
|
||||
tf.transform.translation.y = 2.0;
|
||||
tf.transform.translation.z = 3.0;
|
||||
tf.transform.rotation.w = 1.0;
|
||||
broadcaster.sendTransform(tf);
|
||||
|
||||
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
|
||||
exec->add_node(node);
|
||||
auto end = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (std::chrono::steady_clock::now() < end) {
|
||||
exec->spin_some(std::chrono::milliseconds(50));
|
||||
}
|
||||
|
||||
geometry_msgs::msg::PointStamped p_in, p_out;
|
||||
p_in.header.frame_id = "world";
|
||||
p_in.point.x = 1.0;
|
||||
p_in.point.y = 0.0;
|
||||
p_in.point.z = 0.0;
|
||||
|
||||
try {
|
||||
p_out = buffer.transform(p_in, "robot");
|
||||
EXPECT_NEAR(p_out.point.x, 0.0, 1e-3); // 1 - 1 = 0
|
||||
EXPECT_NEAR(p_out.point.y, -2.0, 1e-3); // 0 - 2 = -2
|
||||
EXPECT_NEAR(p_out.point.z, -3.0, 1e-3); // 0 - 3 = -3
|
||||
} catch (const tf2::TransformException & ex) {
|
||||
FAIL() << "transform failed: " << ex.what();
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
// 1s 内应该发出若干 /joint_states
|
||||
EXPECT_GE(node->count_publishers("/joint_states"), 0u);
|
||||
}
|
||||
@@ -1,95 +1,119 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
simple_arm.urdf —— 3 关节机械臂的简化 URDF 模型。
|
||||
simple_arm.urdf — 3 关节 + gripper 末端机械臂(教学示例)
|
||||
|
||||
URDF (Unified Robot Description Format):
|
||||
XML 格式的机器人描述,定义:
|
||||
<link> —— 刚体段(质量、惯性、视觉/碰撞几何);
|
||||
<joint> —— 关节(类型 + parent/child + 轴 + 限位);
|
||||
<origin> —— 坐标系偏移(xyz 位置 + rpy 姿态);
|
||||
<axis> —— 旋转关节的旋转轴。
|
||||
设计思想:
|
||||
- 链式结构: base_link → joint1 → link1 → joint2 → link2 → joint3 → link3 → joint4 → gripper
|
||||
- joint1/2/3 是 revolute(有限位转动)
|
||||
- joint4 是 fixed(gripper 固定在 link3 末端)
|
||||
- 每个 link 有 visual / collision / inertial(必备三件套)
|
||||
- 单位: m / kg / rad(REP-103 约定)
|
||||
|
||||
我们的"3 关节臂":
|
||||
base_link ──joint1(绕 z 旋转)──> link1
|
||||
──joint2(绕 y 旋转)──> link2
|
||||
──joint3(绕 y 旋转)──> gripper
|
||||
|
||||
关键尺寸:
|
||||
- 每节连杆长度 0.1 m,叠在 z 轴上;
|
||||
- joint1 转角控制 link1 在水平面内绕 z 旋转;
|
||||
- joint2 / joint3 控制 link2 / gripper 在俯仰面内绕 y 旋转。
|
||||
校验: check_urdf simple_arm.urdf
|
||||
-->
|
||||
<robot name="simple_arm">
|
||||
<!-- ========== base_link ========== -->
|
||||
<!-- 基座 -->
|
||||
<link name="base_link">
|
||||
<visual>
|
||||
<origin xyz="0 0 0.05" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<box size="0.1 0.1 0.1"/>
|
||||
</geometry>
|
||||
<material name="gray">
|
||||
<color rgba="0.5 0.5 0.5 1.0"/>
|
||||
</material>
|
||||
<geometry><box size="0.2 0.2 0.1"/></geometry>
|
||||
<material name="gray"><color rgba="0.5 0.5 0.5 1"/></material>
|
||||
</visual>
|
||||
<collision>
|
||||
<geometry><box size="0.2 0.2 0.1"/></geometry>
|
||||
</collision>
|
||||
<inertial>
|
||||
<mass value="1.0"/>
|
||||
<inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="0.01"/>
|
||||
</inertial>
|
||||
</link>
|
||||
|
||||
<!-- ========== joint1 + link1 ========== -->
|
||||
<!-- 关节 1: 基座旋转 (Z 轴) -->
|
||||
<joint name="joint1" type="revolute">
|
||||
<parent link="base_link"/>
|
||||
<child link="link1"/>
|
||||
<origin xyz="0 0 0.1" rpy="0 0 0"/>
|
||||
<origin xyz="0 0 0.05" rpy="0 0 0"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.14159" upper="3.14159" effort="1.0" velocity="1.0"/>
|
||||
<limit lower="-3.14" upper="3.14" effort="10.0" velocity="2.0"/>
|
||||
</joint>
|
||||
|
||||
<link name="link1">
|
||||
<visual>
|
||||
<origin xyz="0 0 0.05" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<box size="0.05 0.05 0.1"/>
|
||||
</geometry>
|
||||
<material name="red">
|
||||
<color rgba="0.9 0.2 0.2 1.0"/>
|
||||
</material>
|
||||
<geometry><cylinder radius="0.04" length="0.2"/></geometry>
|
||||
<material name="blue"><color rgba="0.2 0.4 1 1"/></material>
|
||||
</visual>
|
||||
<collision>
|
||||
<geometry><cylinder radius="0.04" length="0.2"/></geometry>
|
||||
</collision>
|
||||
<inertial>
|
||||
<mass value="0.5"/>
|
||||
<inertia ixx="0.005" ixy="0" ixz="0" iyy="0.005" iyz="0" izz="0.001"/>
|
||||
</inertial>
|
||||
</link>
|
||||
|
||||
<!-- ========== joint2 + link2 ========== -->
|
||||
<!-- 关节 2: shoulder (X 轴) -->
|
||||
<joint name="joint2" type="revolute">
|
||||
<parent link="link1"/>
|
||||
<child link="link2"/>
|
||||
<origin xyz="0 0 0.1" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/>
|
||||
<limit lower="-1.5708" upper="1.5708" effort="1.0" velocity="1.0"/>
|
||||
<origin xyz="0 0 0.2" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/>
|
||||
<limit lower="-1.57" upper="1.57" effort="10.0" velocity="2.0"/>
|
||||
</joint>
|
||||
|
||||
<link name="link2">
|
||||
<visual>
|
||||
<origin xyz="0 0 0.05" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<box size="0.05 0.05 0.1"/>
|
||||
</geometry>
|
||||
<material name="green">
|
||||
<color rgba="0.2 0.8 0.2 1.0"/>
|
||||
</material>
|
||||
<geometry><cylinder radius="0.035" length="0.18"/></geometry>
|
||||
<material name="green"><color rgba="0.2 0.8 0.2 1"/></material>
|
||||
</visual>
|
||||
<collision>
|
||||
<geometry><cylinder radius="0.035" length="0.18"/></geometry>
|
||||
</collision>
|
||||
<inertial>
|
||||
<mass value="0.4"/>
|
||||
<inertia ixx="0.004" ixy="0" ixz="0" iyy="0.004" iyz="0" izz="0.001"/>
|
||||
</inertial>
|
||||
</link>
|
||||
|
||||
<!-- ========== joint3 + gripper ========== -->
|
||||
<!-- 关节 3: elbow (X 轴) -->
|
||||
<joint name="joint3" type="revolute">
|
||||
<parent link="link2"/>
|
||||
<child link="gripper"/>
|
||||
<origin xyz="0 0 0.1" rpy="0 0 0"/>
|
||||
<axis xyz="0 1 0"/>
|
||||
<limit lower="-1.5708" upper="1.5708" effort="1.0" velocity="1.0"/>
|
||||
<child link="link3"/>
|
||||
<origin xyz="0 0 0.18" rpy="0 0 0"/>
|
||||
<axis xyz="1 0 0"/>
|
||||
<limit lower="-1.57" upper="1.57" effort="5.0" velocity="2.0"/>
|
||||
</joint>
|
||||
|
||||
<link name="link3">
|
||||
<visual>
|
||||
<geometry><cylinder radius="0.03" length="0.15"/></geometry>
|
||||
<material name="red"><color rgba="1 0.2 0.2 1"/></material>
|
||||
</visual>
|
||||
<collision>
|
||||
<geometry><cylinder radius="0.03" length="0.15"/></geometry>
|
||||
</collision>
|
||||
<inertial>
|
||||
<mass value="0.3"/>
|
||||
<inertia ixx="0.003" ixy="0" ixz="0" iyy="0.003" iyz="0" izz="0.001"/>
|
||||
</inertial>
|
||||
</link>
|
||||
|
||||
<!-- 关节 4: gripper 固定关节(把 gripper frame 挂到 link3 末端) -->
|
||||
<joint name="joint4" type="fixed">
|
||||
<parent link="link3"/>
|
||||
<child link="gripper"/>
|
||||
<origin xyz="0 0 0.15" rpy="0 0 0"/>
|
||||
</joint>
|
||||
|
||||
<link name="gripper">
|
||||
<visual>
|
||||
<origin xyz="0 0 0.02" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<box size="0.04 0.04 0.04"/>
|
||||
</geometry>
|
||||
<material name="blue">
|
||||
<color rgba="0.2 0.2 0.9 1.0"/>
|
||||
</material>
|
||||
<geometry><box size="0.05 0.05 0.05"/></geometry>
|
||||
<material name="yellow"><color rgba="1 1 0.2 1"/></material>
|
||||
</visual>
|
||||
<collision>
|
||||
<geometry><box size="0.05 0.05 0.05"/></geometry>
|
||||
</collision>
|
||||
<inertial>
|
||||
<mass value="0.1"/>
|
||||
<inertia ixx="0.0005" ixy="0" ixz="0" iyy="0.0005" iyz="0" izz="0.0005"/>
|
||||
</inertial>
|
||||
</link>
|
||||
</robot>
|
||||
@@ -0,0 +1,59 @@
|
||||
# py_action_demo
|
||||
|
||||
ROS2 Action 三件套演示包(Python)。属于 Level 1 基础机制第 4 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`fibonacci_action_server`**: 计算 Fibonacci 数列 + 周期性 Feedback + 支持 cancel
|
||||
- **`fibonacci_action_client`**: 异步发 Goal + 处理 Feedback + 处理 Result
|
||||
|
||||
## 关键概念
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `ActionServer` / `ActionClient` | 长任务通信 + 反馈 + 取消 |
|
||||
| `ServerGoalHandle` | server 端 goal 句柄(状态管理) |
|
||||
| `ReentrantCallbackGroup` | 让 execute_callback 内可 publish_feedback |
|
||||
| `MultiThreadedExecutor` | 必须用,否则 feedback 卡死 |
|
||||
| `GoalHandle.is_cancel_requested` | server 周期性检查 |
|
||||
|
||||
## 踩过的坑(本包已避开)
|
||||
|
||||
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)`
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 终端 1:启 server
|
||||
ros2 launch py_action_demo action_launch.py
|
||||
|
||||
# 终端 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
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_action_demo
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
|---|---|---|
|
||||
| `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% 通过** |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- Action 深度:[`doc/40-actions.md`](../doc/40-actions.md)
|
||||
@@ -1,23 +1,21 @@
|
||||
"""
|
||||
action_launch.py —— 启动 fibonacci_action_server 的 launch 文件。
|
||||
|
||||
client 通常不在 launch 里启动,而是按需执行:
|
||||
source install/setup.bash
|
||||
ros2 run py_action_demo fibonacci_server # 先启
|
||||
ros2 run py_action_demo fibonacci_client -- -o 8 # 再 client
|
||||
ros2 action send_goal /fibonacci example_interfaces/action/Fibonacci "{order: 5}"
|
||||
"""
|
||||
|
||||
"""action_launch.py - 启动 fibonacci_action_server。"""
|
||||
from launch import LaunchDescription
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:Fibonacci Action Server。"""
|
||||
action_name_arg = LaunchConfiguration('action_name')
|
||||
|
||||
server_node = Node(
|
||||
package='py_action_demo',
|
||||
executable='fibonacci_server',
|
||||
name='fibonacci_action_server_py',
|
||||
executable='fibonacci_action_server',
|
||||
name='fibonacci_action_server',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
parameters=[{
|
||||
'action_name': action_name_arg,
|
||||
}],
|
||||
)
|
||||
|
||||
return LaunchDescription([server_node])
|
||||
@@ -5,18 +5,24 @@
|
||||
<package format="3">
|
||||
<name>py_action_demo</name>
|
||||
<version>0.1.0</version>
|
||||
<description>Python Action demo: Fibonacci server/client + launch</description>
|
||||
<description>
|
||||
ROS2 Action 三件套演示包(Python)。
|
||||
演示 FibonacciActionServer + FibonacciActionClient,
|
||||
涵盖 Goal/Feedback/Result + cancel + MultiThreadedExecutor。
|
||||
属于 Level 1 基础机制第 4 块。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
<license>MIT</license>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
<depend>action_msgs</depend>
|
||||
<depend>rclpy_action</depend>
|
||||
<depend>example_interfaces</depend>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
<test_depend>python3-pytest-timeout</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
|
||||
@@ -1,91 +1,171 @@
|
||||
"""
|
||||
fibonacci_client.py —— ROS2 Python Action Client 演示。
|
||||
"""fibonacci_client - Fibonacci Action 客户端(异步风格)。
|
||||
|
||||
Client API:
|
||||
ActionClient(node, ActionType, action_name):
|
||||
构造一个 client;需要在 spin 后 wait_for server 上线。
|
||||
client.wait_for_server(timeout_sec=...):
|
||||
阻塞等服务端上线;ROS2 发现约 1-2s。
|
||||
goal = ActionType.Goal(); goal.field = ...
|
||||
构造 Goal 消息。
|
||||
future = client.send_goal_async(goal, feedback_callback=...)
|
||||
异步发送 Goal,返回 Future;future.done() 后调用 result_callback。
|
||||
feedback_callback(feedback_msg):
|
||||
每收到一次服务端 Feedback 都会被调用,可用于实时显示进度。
|
||||
"""
|
||||
设计思想:
|
||||
ROS2 Action Client 用异步 + 回调:
|
||||
1. send_goal_async + add_done_callback(goal_response)
|
||||
2. 拿到 goal_handle 后 get_result_async + add_done_callback(result)
|
||||
3. feedback_callback 周期性接收
|
||||
|
||||
import sys
|
||||
关键约束(踩过的坑):
|
||||
- 绝不在 __init__ 阻塞 wait_for_server() — 会死锁
|
||||
- 用 server_is_ready() 轮询
|
||||
- 不在 callback 里 rclpy.shutdown() — 测试 fixture 会再 shutdown → 报错
|
||||
|
||||
参考:
|
||||
- ROS2 Humble Tutorial https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html
|
||||
"""
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.action import ActionClient
|
||||
from rclpy.node import Node
|
||||
|
||||
from example_interfaces.action import Fibonacci
|
||||
from rclpy.action import ActionClient, ClientGoalHandle
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
class FibonacciActionClient(Node):
|
||||
"""Fibonacci Action 客户端(异步回调风格)。
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('fibonacci_action_client_py')
|
||||
self._action_client = ActionClient(self, Fibonacci, 'fibonacci')
|
||||
Attributes:
|
||||
_action_client: ActionClient 实例。
|
||||
_goal_done: Goal 是否已接受(布尔标志,避免在 callback 里 shutdown)。
|
||||
_result: 最终 Result(测试断言用)。
|
||||
"""
|
||||
|
||||
# 注意:不在 __init__ 阻塞 wait_for_server()!
|
||||
# wait_for_server 阻塞会让事件循环停下来,server 的 ActionServer
|
||||
# 没法 spin 注册 DDS 节点,死锁。改为 server_is_ready() + 主循环 spin。
|
||||
self._server_ready = False
|
||||
self.get_logger().info('Fibonacci action client ready (waiting for server)')
|
||||
DEFAULT_ACTION_NAME: str = 'fibonacci'
|
||||
DEFAULT_ORDER: int = 5
|
||||
|
||||
def wait_for_server(self, timeout_sec: float = 5.0) -> bool:
|
||||
"""非阻塞:每次调用 server_is_ready() 后让出,直到 timeout 或就绪。"""
|
||||
import time as _t
|
||||
end = _t.time() + timeout_sec
|
||||
while _t.time() < end:
|
||||
def __init__(self, *, node_name: str = 'fibonacci_action_client') -> None:
|
||||
"""初始化客户端节点。
|
||||
|
||||
Args:
|
||||
node_name: ROS2 节点名,默认 `fibonacci_action_client`。
|
||||
"""
|
||||
super().__init__(node_name)
|
||||
|
||||
self.declare_parameter(
|
||||
'action_name', self.DEFAULT_ACTION_NAME,
|
||||
descriptor='要调用的 Action 名',
|
||||
)
|
||||
self.declare_parameter(
|
||||
'order', self.DEFAULT_ORDER,
|
||||
descriptor='Fibonacci 阶数(整数)',
|
||||
)
|
||||
|
||||
action_name: str = self.get_parameter('action_name').value
|
||||
|
||||
self._action_client = ActionClient(
|
||||
self, Fibonacci, action_name,
|
||||
)
|
||||
|
||||
self._goal_done: bool = False
|
||||
self._result: Optional[Fibonacci.Result] = None
|
||||
|
||||
self.get_logger().info(
|
||||
f'FibonacciActionClient created: action="{action_name}"',
|
||||
)
|
||||
|
||||
def wait_for_server_ready(self, timeout_sec: float = 5.0) -> bool:
|
||||
"""轮询等待服务端就绪(不阻塞)。
|
||||
|
||||
Args:
|
||||
timeout_sec: 超时秒数。
|
||||
|
||||
Returns:
|
||||
True 服务端已就绪 / False 超时。
|
||||
"""
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
if self._action_client.server_is_ready():
|
||||
self._server_ready = True
|
||||
return True
|
||||
_t.sleep(0.05)
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
def send_goal(self, order):
|
||||
goal_msg = Fibonacci.Goal()
|
||||
goal_msg.order = order
|
||||
def send_goal(self, order: int) -> bool:
|
||||
"""异步发 Goal(不阻塞)。
|
||||
|
||||
self._send_goal_future = self._action_client.send_goal_async(
|
||||
goal_msg,
|
||||
feedback_callback=self.feedback_callback,
|
||||
Args:
|
||||
order: Fibonacci 阶数。
|
||||
|
||||
Returns:
|
||||
True Goal 已发出 / False 服务端未就绪。
|
||||
"""
|
||||
if not self.wait_for_server_ready():
|
||||
self.get_logger().warn('action server not available')
|
||||
return False
|
||||
|
||||
goal = Fibonacci.Goal()
|
||||
goal.order = order
|
||||
|
||||
send_future = self._action_client.send_goal_async(
|
||||
goal, feedback_callback=self._feedback_callback,
|
||||
)
|
||||
self._send_goal_future.add_done_callback(self.goal_response_callback)
|
||||
send_future.add_done_callback(self._goal_response_callback)
|
||||
return True
|
||||
|
||||
def goal_response_callback(self, future):
|
||||
# 服务端 accept/reject 后被回调;future.result() 是 GoalHandle。
|
||||
goal_handle = future.result()
|
||||
def _goal_response_callback(self, future) -> None:
|
||||
"""Goal 响应回调。"""
|
||||
goal_handle: ClientGoalHandle = future.result()
|
||||
if not goal_handle.accepted:
|
||||
self.get_logger().warn('Goal rejected by server')
|
||||
self.get_logger().warn('Goal rejected')
|
||||
self._goal_done = True
|
||||
return
|
||||
|
||||
self.get_logger().info('Goal accepted, waiting for result...')
|
||||
# get_result_async:等服务端 succeed/abort/cancel 后被回调。
|
||||
self._get_result_future = goal_handle.get_result_async()
|
||||
self._get_result_future.add_done_callback(self.get_result_callback)
|
||||
result_future = goal_handle.get_result_async()
|
||||
result_future.add_done_callback(self._result_callback)
|
||||
|
||||
def feedback_callback(self, feedback_msg):
|
||||
# feedback_msg 是 Fibonacci_FeedbackMessage(带 feedback 子字段);
|
||||
# 真正的序列字段在 msg.feedback.sequence(Feedback.message 字段名是 'sequence')。
|
||||
seq = list(feedback_msg.feedback.sequence)
|
||||
self.get_logger().info(f'feedback: {seq}')
|
||||
def _feedback_callback(self, feedback_msg) -> None:
|
||||
"""Feedback 回调:周期性打印进度。"""
|
||||
feedback = feedback_msg.feedback
|
||||
self.get_logger().info(
|
||||
f'feedback: sequence={list(feedback.sequence)}',
|
||||
)
|
||||
|
||||
def get_result_callback(self, future):
|
||||
# result 是 Fibonacci.Result,字段 sequence 是最终完整序列。
|
||||
result = future.result().result
|
||||
seq = list(result.sequence)
|
||||
self.get_logger().info(f'FINAL RESULT: sequence={seq}')
|
||||
def _result_callback(self, future) -> None:
|
||||
"""Result 回调:存结果 + 标 done。
|
||||
|
||||
关键:不在 callback 里 rclpy.shutdown()(测试 fixture 会再 shutdown → 报错)。
|
||||
"""
|
||||
wrapped_result = future.result()
|
||||
if wrapped_result is None:
|
||||
self.get_logger().warn('Result future returned None')
|
||||
self._goal_done = True
|
||||
return
|
||||
|
||||
self._result = wrapped_result.result
|
||||
if self._result is not None:
|
||||
self.get_logger().info(
|
||||
f'Goal finished: sequence={list(self._result.sequence)}',
|
||||
)
|
||||
self._goal_done = True
|
||||
|
||||
|
||||
def main(args=None):
|
||||
def main(args: Optional[List[str]] = None) -> None:
|
||||
"""ROS2 节点入口:从参数读 order,发 goal,等结果(轮询 _goal_done),退出。"""
|
||||
rclpy.init(args=args)
|
||||
node: Optional[FibonacciActionClient] = None
|
||||
try:
|
||||
node = FibonacciActionClient()
|
||||
order = int(sys.argv[1]) if len(sys.argv) > 1 else 5
|
||||
node.send_goal(order)
|
||||
rclpy.spin(node)
|
||||
order: int = node.get_parameter('order').value
|
||||
if not node.send_goal(order):
|
||||
return
|
||||
|
||||
# 轮询 _goal_done,不阻塞 spin
|
||||
executor = rclpy.executors.SingleThreadedExecutor()
|
||||
executor.add_node(node)
|
||||
deadline = time.time() + 30.0
|
||||
while time.time() < deadline and not node._goal_done:
|
||||
executor.spin_once(timeout_sec=0.1)
|
||||
|
||||
if not node._goal_done:
|
||||
node.get_logger().warn('timeout waiting for result')
|
||||
except KeyboardInterrupt:
|
||||
if node is not None:
|
||||
node.get_logger().info('KeyboardInterrupt → 退出')
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,111 +1,132 @@
|
||||
"""fibonacci_server - Fibonacci Action 服务端(Goal/Feedback/Result)。
|
||||
|
||||
设计思想:
|
||||
ROS2 Action 是"长任务"版本 Service,支持:
|
||||
1. 周期性 Feedback 推送
|
||||
2. 客户端中途 cancel
|
||||
3. 服务端状态机 (PENDING → ACCEPTED → EXECUTING → SUCCEEDED)
|
||||
|
||||
关键约束(踩过的坑):
|
||||
- 必须用 MultiThreadedExecutor(否则 feedback 卡死)
|
||||
- 周期性检查 is_cancel_requested(每秒检查 1 次足够)
|
||||
- 字段名是 `sequence`(不是 `partial_sequence`,Humble 的 example_interfaces)
|
||||
|
||||
参考:
|
||||
- ROS2 Humble Tutorial https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html
|
||||
- ROS2 设计稿 https://design.ros2.org/articles/actions.html
|
||||
"""
|
||||
fibonacci_server.py —— ROS2 Python Action Server 演示(Fibonacci 序列)。
|
||||
|
||||
ROS2 Action 与 Service 的区别:
|
||||
Service —— 一次性 req/resp,适合"快速问答",几毫秒级;
|
||||
Action —— 长任务,client 发 Goal,server 边做边周期性反馈 Feedback,
|
||||
最终返回 Result;client 可中途 cancel。
|
||||
适合场景:机械臂抓取、SLAM 建图、路径规划等"几分钟到几小时"的任务。
|
||||
|
||||
Action 三件套 (Goal / Feedback / Result):
|
||||
Goal —— 客户端发起任务时携带的输入;
|
||||
Feedback —— 服务端周期性回报的中间进度(0..N 次);
|
||||
Result —— 任务最终完成时的产出(一次)。
|
||||
|
||||
本 demo 用 ROS2 内置 example_interfaces/action/Fibonacci:
|
||||
Goal: int32 order # 要生成第几项 Fibonacci
|
||||
Feedback: int32[] partial_sequence # 当前已算出的部分序列
|
||||
Result: int32[] sequence # 完整序列
|
||||
|
||||
运行:
|
||||
ros2 run py_action_demo fibonacci_server
|
||||
ros2 run py_action_demo fibonacci_client -- -o 8
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.action import ActionServer
|
||||
from rclpy.node import Node
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
|
||||
from example_interfaces.action import Fibonacci
|
||||
from rclpy.action import ActionServer, ServerGoalHandle
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
class FibonacciActionServer(Node):
|
||||
"""计算 Fibonacci 数列的 Action 服务端。
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('fibonacci_action_server_py')
|
||||
Attributes:
|
||||
_action_server: ActionServer 句柄。
|
||||
"""
|
||||
|
||||
# ActionServer(node, ActionType, action_name, execute_callback):
|
||||
# - ActionType:由 .action 自动生成的 Python 类(含 Goal/Feedback/Result 子类);
|
||||
# - action_name:client 用 'ros2 action send_goal <action_name> ...' 调用;
|
||||
# - execute_callback:ROS2 接收到 Goal 时调用,签名:
|
||||
# callback(goal_handle) -> result
|
||||
# goal_handle 提供:
|
||||
# - goal_handle.accept() / reject():同意/拒绝 Goal;
|
||||
# - goal_handle.publish_feedback(msg):周期性反馈;
|
||||
# - goal_handle.is_cancel_requested:client 是否请求取消;
|
||||
# - goal_handle.succeed() / abort() / canceled():终态回报。
|
||||
DEFAULT_ACTION_NAME: str = 'fibonacci'
|
||||
FEEDBACK_INTERVAL_SEC: float = 0.5 # Feedback 推送间隔
|
||||
|
||||
def __init__(self, *, node_name: str = 'fibonacci_action_server') -> None:
|
||||
"""初始化服务端节点。
|
||||
|
||||
Args:
|
||||
node_name: ROS2 节点名,默认 `fibonacci_action_server`。
|
||||
"""
|
||||
super().__init__(node_name)
|
||||
|
||||
self.declare_parameter(
|
||||
'action_name', self.DEFAULT_ACTION_NAME,
|
||||
descriptor='Action 名(字符串)',
|
||||
)
|
||||
|
||||
action_name: str = self.get_parameter('action_name').value
|
||||
|
||||
# ReentrantCallbackGroup:允许 execute_callback 内部 publish_feedback
|
||||
self._action_server = ActionServer(
|
||||
self,
|
||||
Fibonacci,
|
||||
'fibonacci',
|
||||
self.execute_callback,
|
||||
action_name,
|
||||
execute_callback=self._execute_callback,
|
||||
callback_group=ReentrantCallbackGroup(),
|
||||
)
|
||||
self.get_logger().info('fibonacci_action_server_py ready')
|
||||
|
||||
def execute_callback(self, goal_handle):
|
||||
# 1) 读取 Goal 字段
|
||||
order = goal_handle.request.order
|
||||
self.get_logger().info(f'Received goal: order={order}')
|
||||
self.get_logger().info(
|
||||
f'FibonacciActionServer ready: action="{action_name}"',
|
||||
)
|
||||
|
||||
# 2) 构造 Feedback 与 Result 消息
|
||||
feedback_msg = Fibonacci.Feedback()
|
||||
result_msg = Fibonacci.Result()
|
||||
def _execute_callback(self, goal_handle: ServerGoalHandle) -> Fibonacci.Result:
|
||||
"""执行 Goal:计算 Fibonacci 数列 + 周期性 feedback + 检查 cancel。
|
||||
|
||||
# 3) 边做边反馈:Fibonacci 序列生成。
|
||||
# 每生成一项 sleep 0.5s 模拟"耗时任务",期间把当前序列作为
|
||||
# Feedback 推回给 client,client 端就能实时看到进度。
|
||||
#
|
||||
# 字段名说明:ROS2 example_interfaces/action/Fibonacci 中,
|
||||
# Feedback 字段名为 'sequence'(不是 partial_sequence),
|
||||
# Result 字段名也是 'sequence'(最终完整序列)。
|
||||
sequence = [0, 1]
|
||||
Args:
|
||||
goal_handle: server 给的 goal 句柄。
|
||||
|
||||
Returns:
|
||||
Fibonacci.Result(包含完整 sequence)。
|
||||
"""
|
||||
# 1) 解析 Goal
|
||||
order: int = goal_handle.request.order
|
||||
self.get_logger().info(f'received goal: order={order}')
|
||||
|
||||
# 2) 初始化 sequence + feedback + result
|
||||
sequence: List[int] = [0, 1]
|
||||
feedback = Fibonacci.Feedback()
|
||||
feedback.sequence = sequence
|
||||
|
||||
# 3) 主循环
|
||||
for i in range(1, order):
|
||||
# 检查 client 是否请求取消(随时可中断)
|
||||
# 周期性检查 cancel
|
||||
if goal_handle.is_cancel_requested:
|
||||
goal_handle.canceled()
|
||||
self.get_logger().info('Goal canceled by client')
|
||||
return Fibonacci.Result()
|
||||
|
||||
# 计算下一个
|
||||
sequence.append(sequence[i] + sequence[i - 1])
|
||||
feedback_msg.sequence = sequence # ← 注意字段名
|
||||
goal_handle.publish_feedback(feedback_msg)
|
||||
time.sleep(0.5)
|
||||
feedback.sequence = sequence
|
||||
|
||||
# 4) 任务成功完成,告诉 client 最终 Result
|
||||
# 推 feedback
|
||||
goal_handle.publish_feedback(feedback)
|
||||
|
||||
# 模拟耗时(实际场景中是电机 / 规划)
|
||||
import time
|
||||
time.sleep(self.FEEDBACK_INTERVAL_SEC)
|
||||
|
||||
# 4) 成功
|
||||
goal_handle.succeed()
|
||||
result_msg.sequence = sequence
|
||||
self.get_logger().info(f'Goal succeeded, sequence={sequence}')
|
||||
return result_msg
|
||||
result = Fibonacci.Result()
|
||||
result.sequence = sequence
|
||||
self.get_logger().info(f'Goal succeeded: sequence={sequence}')
|
||||
return result
|
||||
|
||||
|
||||
def main(args=None):
|
||||
def main(args: Optional[List[str]] = None) -> None:
|
||||
"""ROS2 节点入口。
|
||||
|
||||
关键:用 MultiThreadedExecutor 启动,否则 publish_feedback 会卡死。
|
||||
"""
|
||||
rclpy.init(args=args)
|
||||
node: Optional[FibonacciActionServer] = None
|
||||
try:
|
||||
node = FibonacciActionServer()
|
||||
|
||||
# Action server 内部用了多个回调线程(MultiThreadedExecutor 是 ROS2
|
||||
# 官方推荐配合 ActionServer 的 executor,避免反馈与执行互锁)。
|
||||
executor = MultiThreadedExecutor()
|
||||
# MultiThreadedExecutor:必须用多线程,否则 callback 卡死主线程
|
||||
executor = MultiThreadedExecutor(num_threads=4)
|
||||
executor.add_node(node)
|
||||
|
||||
try:
|
||||
executor.spin()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
node.destroy_node()
|
||||
if node is not None:
|
||||
node.get_logger().info('KeyboardInterrupt → 退出')
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
|
||||
+14
-11
@@ -1,28 +1,31 @@
|
||||
from setuptools import find_packages, setup
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
package_name = 'py_action_demo'
|
||||
from setuptools import setup
|
||||
|
||||
PACKAGE_NAME = 'py_action_demo'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
name=PACKAGE_NAME,
|
||||
version='0.1.0',
|
||||
packages=find_packages(exclude=['test']),
|
||||
packages=[PACKAGE_NAME],
|
||||
data_files=[
|
||||
('share/ament_index/resource_index/packages',
|
||||
['resource/' + package_name]),
|
||||
('share/' + package_name, ['package.xml']),
|
||||
('share/' + package_name + '/launch', ['launch/action_launch.py']),
|
||||
['resource/' + PACKAGE_NAME]),
|
||||
('share/' + PACKAGE_NAME, ['package.xml']),
|
||||
(os.path.join('share', PACKAGE_NAME, 'launch'), glob('launch/*.py')),
|
||||
],
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
maintainer='xs',
|
||||
maintainer_email='dev@example.com',
|
||||
description='Python Action demo: Fibonacci server/client + launch',
|
||||
license='Apache-2.0',
|
||||
description='ROS2 Action 三件套演示 - Level 1 基础机制第 4 块',
|
||||
license='MIT',
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'fibonacci_server = py_action_demo.fibonacci_server:main',
|
||||
'fibonacci_client = py_action_demo.fibonacci_client:main',
|
||||
'fibonacci_action_server = py_action_demo.fibonacci_server:main',
|
||||
'fibonacci_action_client = py_action_demo.fibonacci_client:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""pytest 共享 fixtures - py_action_demo 测试。"""
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
import rclpy
|
||||
|
||||
from py_action_demo.fibonacci_server import FibonacciActionServer
|
||||
from py_action_demo.fibonacci_client import FibonacciActionClient
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def ros_context() -> Iterator[None]:
|
||||
"""session 级 rclpy 上下文。"""
|
||||
rclpy.init()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def action_server(ros_context: None) -> Iterator[FibonacciActionServer]:
|
||||
"""每个测试一个独立 server 实例。"""
|
||||
node = FibonacciActionServer()
|
||||
try:
|
||||
yield node
|
||||
finally:
|
||||
node.destroy_node()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def action_client(ros_context: None) -> Iterator[FibonacciActionClient]:
|
||||
"""每个测试一个独立 client 实例。"""
|
||||
node = FibonacciActionClient()
|
||||
try:
|
||||
yield node
|
||||
finally:
|
||||
node.destroy_node()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""测试 FibonacciActionServer + Client 端到端(同进程)。
|
||||
|
||||
策略:
|
||||
- 同进程 spin server + client(用 MultiThreadedExecutor)
|
||||
- client.send_goal(5) → 等 _goal_done = True
|
||||
- 断言 sequence == [0, 1, 1, 2, 3, 5]
|
||||
|
||||
关键:不用 launch_testing(避免 shutdown 二次调用问题)。
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
|
||||
import rclpy
|
||||
import pytest
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
|
||||
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()
|
||||
client = FibonacciActionClient()
|
||||
|
||||
# 用 MultiThreadedExecutor 跑 server(避免 feedback 卡死)
|
||||
# client 可以用 SingleThreadedExecutor(它主要是被动接收)
|
||||
executor = MultiThreadedExecutor(num_threads=2)
|
||||
executor.add_node(server)
|
||||
executor.add_node(client)
|
||||
|
||||
# 后台线程 spin
|
||||
spin_thread = threading.Thread(
|
||||
target=executor.spin, daemon=True,
|
||||
)
|
||||
spin_thread.start()
|
||||
|
||||
# 给一点时间让 server 注册 + discovery
|
||||
time.sleep(0.5)
|
||||
|
||||
# 发 Goal(order=5,期望 sequence=[0,1,1,2,3,5])
|
||||
assert client.send_goal(5) is True
|
||||
|
||||
# 等结果(轮询 _goal_done)
|
||||
deadline = time.time() + 10.0
|
||||
while time.time() < deadline and not client._goal_done:
|
||||
time.sleep(0.1)
|
||||
|
||||
executor.shutdown()
|
||||
server.destroy_node()
|
||||
client.destroy_node()
|
||||
|
||||
assert client._goal_done, 'Goal 未在 10s 内完成'
|
||||
assert client._result is not None
|
||||
assert list(client._result.sequence) == [0, 1, 1, 2, 3, 5]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""测试 FibonacciActionServer 节点构造与参数。"""
|
||||
from py_action_demo.fibonacci_server import FibonacciActionServer
|
||||
|
||||
|
||||
def test_node_name(action_server: FibonacciActionServer) -> None:
|
||||
"""默认节点名。"""
|
||||
assert action_server.get_name() == 'fibonacci_action_server'
|
||||
|
||||
|
||||
def test_action_name(action_server: FibonacciActionServer) -> None:
|
||||
"""默认 action_name 参数。"""
|
||||
assert action_server.get_parameter('action_name').value == 'fibonacci'
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,79 @@
|
||||
# py_lifecycle_composable
|
||||
|
||||
ROS2 Lifecycle Node + Composable Node 演示包(Python)。属于 Level 1 基础机制第 11-12 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`lifecycle_demo_node`**: 标准 Lifecycle Node(unconfigured → inactive → active → finalized)
|
||||
- **`composable_demo`**: 同进程跑 2 个 Lifecycle 节点(模拟 Composable Container)
|
||||
|
||||
## Lifecycle 状态机
|
||||
|
||||
```
|
||||
configure
|
||||
unconfigured ───────→ inactive
|
||||
▲ │ │ activate
|
||||
│ │ cleanup ▼
|
||||
│ └────────────── active
|
||||
│ │ deactivate
|
||||
└──────────────────────┘
|
||||
|
||||
shutdown(任何状态都可触发)→ finalized
|
||||
```
|
||||
|
||||
## 关键概念
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `LifecycleNode` | 节点基类,提供 on_configure/on_activate 等回调 |
|
||||
| `State` / `Transition` | 状态枚举 + 转换枚举 |
|
||||
| `TransitionCallbackReturn` | SUCCESS / FAILURE / ERROR |
|
||||
| `create_lifecycle_publisher` | Lifecycle 专用 publisher(只在 active 时有效) |
|
||||
| `Composable Node` | 同进程多节点(共享内存,降低延迟) |
|
||||
| `ComposableNodeContainer` | C++ 的组件容器(C++ 专属) |
|
||||
|
||||
## 运行
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_lifecycle_composable
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
|---|---|---|
|
||||
| `test_lifecycle.py` | 5 | 初始状态 / configure / activate / deactivate / 全循环 |
|
||||
| `test_composable.py` | 1 | 同进程 2 节点全 active |
|
||||
| **总计** | **6** | **目标 6/6 100% 通过** |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`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)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""lifecycle_launch.py - 启动 lifecycle_demo_node。"""
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:LifecycleDemoNode。"""
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='py_lifecycle_composable',
|
||||
executable='lifecycle_demo_node',
|
||||
name='lifecycle_demo_node',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>py_lifecycle_composable</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
ROS2 Lifecycle Node + Composable Node 演示包(Python)。
|
||||
演示 LifecycleDemoNode(状态机) + Composable Demo(同进程多节点)。
|
||||
属于 Level 1 基础机制第 11-12 块。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
<depend>std_msgs</depend>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@@ -0,0 +1,15 @@
|
||||
"""py_lifecycle_composable - ROS2 生命周期 + 组合节点演示包。
|
||||
|
||||
属于 Level 1 基础机制第 11 块(Lifecycle Node)+ 第 12 块(Composable Node)。
|
||||
|
||||
演示内容:
|
||||
- LifecycleDemoNode:标准 Lifecycle Node(unconfigured → inactive → active → finalized)
|
||||
- LifecycleClient:用 service 控制 lifecycle(change_state / get_state)
|
||||
- ComposableNodeDemo:演示 Composable Container 概念(同进程多节点)
|
||||
|
||||
参考:
|
||||
- Lifecycle 设计稿 https://design.ros2.org/articles/node_lifecycle.html
|
||||
- Composition 文档 https://docs.ros2.org/en/humble/Concepts/About-Composition.html
|
||||
- 编程规范 doc/CODING_STYLE.md
|
||||
- 深度文档 doc/17-lifecycle.md + doc/18-composable.md
|
||||
"""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""composable_demo - 演示 Composable Node 概念。
|
||||
|
||||
设计思想:
|
||||
Composable Node 把多个节点装到一个进程(共享内存,减少 DDS 通信)。
|
||||
ROS2 Composable 必须用 C++ 实现(.so 库),Python 仅作为概念演示。
|
||||
|
||||
这里:
|
||||
- 演示如何"逻辑上把多个节点合并到同一进程"的概念
|
||||
- 用多个 LifecycleDemoNode 实例模拟组合行为
|
||||
- 用同一 MultiThreadedExecutor 跑多个节点
|
||||
|
||||
参考:
|
||||
- https://docs.ros2.org/en/humble/Concepts/About-Composition.html
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.lifecycle import LifecycleNode, State, TransitionCallbackReturn
|
||||
|
||||
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode
|
||||
|
||||
|
||||
def run_composable_demo() -> None:
|
||||
"""演示:同进程跑 2 个 Lifecycle 节点(模拟 Composable Node)。"""
|
||||
rclpy.init()
|
||||
|
||||
# 创建 2 个 lifecycle 节点 + 1 个普通节点(模拟 Composable Container)
|
||||
node_a = LifecycleDemoNode(node_name='composed_node_a')
|
||||
node_b = LifecycleDemoNode(node_name='composed_node_b')
|
||||
|
||||
executor = MultiThreadedExecutor(num_threads=4)
|
||||
executor.add_node(node_a)
|
||||
executor.add_node(node_b)
|
||||
|
||||
# 让两个节点都进入 active 状态(模拟 container 的 configure + activate)
|
||||
time.sleep(0.5)
|
||||
from rclpy.lifecycle import Transition
|
||||
for node in [node_a, node_b]:
|
||||
node.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
time.sleep(0.1)
|
||||
node.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
time.sleep(0.1)
|
||||
|
||||
# 后台 spin
|
||||
spin_thread = threading.Thread(target=executor.spin, daemon=True)
|
||||
spin_thread.start()
|
||||
|
||||
time.sleep(2.0)
|
||||
|
||||
# 退出:deactivate + cleanup
|
||||
for node in [node_a, node_b]:
|
||||
node.trigger_transition(Transition.TRANSITION_DEACTIVATE)
|
||||
node.trigger_transition(Transition.TRANSITION_CLEANUP)
|
||||
|
||||
executor.shutdown()
|
||||
node_a.destroy_node()
|
||||
node_b.destroy_node()
|
||||
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def main(args: Optional[List[str]] = None) -> None:
|
||||
"""Composable Node demo 入口。"""
|
||||
run_composable_demo()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
"""lifecycle_node - 标准 Lifecycle Node 演示。
|
||||
|
||||
设计思想:
|
||||
Lifecycle Node 有 4 个主要状态:
|
||||
unconfigured → inactive → active → finalized
|
||||
(可触发 cleanup/shutdown 回到 inactive)
|
||||
|
||||
每个状态切换通过外部 service 调用触发:
|
||||
- /<node>/change_state → transition
|
||||
- /<node>/get_state → current_state
|
||||
|
||||
转换由 on_configure / on_activate / on_deactivate / on_cleanup / on_shutdown
|
||||
回调处理,可在回调里分配/释放资源(连接硬件、加载模型等)。
|
||||
|
||||
参考:
|
||||
- https://design.ros2.org/articles/node_lifecycle.html
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.lifecycle import LifecycleNode, State, TransitionCallbackReturn
|
||||
from rclpy.lifecycle.publisher import LifecyclePublisher
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
class LifecycleDemoNode(LifecycleNode):
|
||||
"""演示 Lifecycle 节点。
|
||||
|
||||
Attributes:
|
||||
_publisher: LifecyclePublisher(只在 active 时有效)。
|
||||
_publish_count: 已发布消息数。
|
||||
"""
|
||||
|
||||
DEFAULT_TOPIC: str = 'lifecycle_chatter'
|
||||
DEFAULT_RATE_HZ: float = 1.0
|
||||
|
||||
def __init__(self, *, node_name: str = 'lifecycle_demo_node') -> None:
|
||||
"""初始化 Lifecycle 节点(注意:super() 用 LifecycleNode)。
|
||||
|
||||
Args:
|
||||
node_name: ROS2 节点名。
|
||||
"""
|
||||
super().__init__(node_name)
|
||||
|
||||
self.declare_parameter(
|
||||
'topic_name', self.DEFAULT_TOPIC,
|
||||
descriptor='发布话题名',
|
||||
)
|
||||
self.declare_parameter(
|
||||
'publish_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='发布频率 (Hz)',
|
||||
)
|
||||
|
||||
self._publisher: Optional[LifecyclePublisher] = None
|
||||
self._publish_count: int = 0
|
||||
self._timer = None
|
||||
|
||||
self.get_logger().info('lifecycle_demo_node created (state=unconfigured)')
|
||||
|
||||
# ----- Lifecycle 回调(必须重写) -----
|
||||
|
||||
def on_configure(self, state: State) -> TransitionCallbackReturn:
|
||||
"""configure 转换:分配资源(创建 publisher)。
|
||||
|
||||
Args:
|
||||
state: 当前状态(应为 unconfigured)。
|
||||
|
||||
Returns:
|
||||
TransitionCallbackReturn.SUCCESS 或 FAILURE。
|
||||
"""
|
||||
try:
|
||||
topic_name: str = self.get_parameter('topic_name').value
|
||||
self._publisher = self.create_lifecycle_publisher(
|
||||
String, topic_name, 10,
|
||||
)
|
||||
self.get_logger().info('on_configure: publisher created')
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'on_configure failed: {exc}', exc_info=True)
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
|
||||
def on_activate(self, state: State) -> TransitionCallbackReturn:
|
||||
"""activate 转换:启动定时器。
|
||||
|
||||
Args:
|
||||
state: 当前状态(应为 inactive)。
|
||||
|
||||
Returns:
|
||||
TransitionCallbackReturn.SUCCESS。
|
||||
"""
|
||||
try:
|
||||
publish_rate_hz: float = self.get_parameter('publish_rate_hz').value
|
||||
period: float = 1.0 / publish_rate_hz if publish_rate_hz > 0 else 1.0
|
||||
self._timer = self.create_timer(period, self._publish_message)
|
||||
self.get_logger().info('on_activate: timer started')
|
||||
return super().on_activate(state)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'on_activate failed: {exc}', exc_info=True)
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
|
||||
def on_deactivate(self, state: State) -> TransitionCallbackReturn:
|
||||
"""deactivate 转换:停止定时器。
|
||||
|
||||
Args:
|
||||
state: 当前状态(应为 active)。
|
||||
|
||||
Returns:
|
||||
TransitionCallbackReturn.SUCCESS。
|
||||
"""
|
||||
try:
|
||||
if self._timer is not None:
|
||||
self.destroy_timer(self._timer)
|
||||
self._timer = None
|
||||
self.get_logger().info('on_deactivate: timer stopped')
|
||||
return super().on_deactivate(state)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'on_deactivate failed: {exc}', exc_info=True)
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
|
||||
def on_cleanup(self, state: State) -> TransitionCallbackReturn:
|
||||
"""cleanup 转换:释放资源(销毁 publisher)。
|
||||
|
||||
Args:
|
||||
state: 当前状态(应为 inactive)。
|
||||
|
||||
Returns:
|
||||
TransitionCallbackReturn.SUCCESS。
|
||||
"""
|
||||
try:
|
||||
if self._publisher is not None:
|
||||
self.destroy_lifecycle_publisher(self._publisher)
|
||||
self._publisher = None
|
||||
self.get_logger().info('on_cleanup: publisher destroyed')
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'on_cleanup failed: {exc}', exc_info=True)
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
|
||||
def on_shutdown(self, state: State) -> TransitionCallbackReturn:
|
||||
"""shutdown 转换:清理所有资源。
|
||||
|
||||
Args:
|
||||
state: 当前状态。
|
||||
|
||||
Returns:
|
||||
TransitionCallbackReturn.SUCCESS。
|
||||
"""
|
||||
try:
|
||||
self.on_cleanup(state)
|
||||
self.get_logger().info('on_shutdown: node shutting down')
|
||||
return TransitionCallbackReturn.SUCCESS
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'on_shutdown failed: {exc}', exc_info=True)
|
||||
return TransitionCallbackReturn.FAILURE
|
||||
|
||||
# ----- 业务回调(只在 active 时被调用) -----
|
||||
|
||||
def _publish_message(self) -> None:
|
||||
"""定时器:发布消息(只在 active 状态时有效)。"""
|
||||
try:
|
||||
if self._publisher is None:
|
||||
return
|
||||
msg = String()
|
||||
msg.data = f'Lifecycle msg #{self._publish_count}'
|
||||
self._publisher.publish(msg)
|
||||
self._publish_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(
|
||||
f'publish failed: {exc}', exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def main(args: Optional[List[str]] = None) -> None:
|
||||
"""ROS2 Lifecycle 节点入口。"""
|
||||
rclpy.init(args=args)
|
||||
node: Optional[LifecycleDemoNode] = None
|
||||
try:
|
||||
node = LifecycleDemoNode()
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
if node is not None:
|
||||
node.get_logger().info('KeyboardInterrupt → 退出')
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
PACKAGE_NAME = 'py_lifecycle_composable'
|
||||
|
||||
setup(
|
||||
name=PACKAGE_NAME,
|
||||
version='0.1.0',
|
||||
packages=[PACKAGE_NAME],
|
||||
data_files=[
|
||||
('share/ament_index/resource_index/packages',
|
||||
['resource/' + PACKAGE_NAME]),
|
||||
('share/' + PACKAGE_NAME, ['package.xml']),
|
||||
(os.path.join('share', PACKAGE_NAME, 'launch'), glob('launch/*.py')),
|
||||
],
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
maintainer='xs',
|
||||
maintainer_email='dev@example.com',
|
||||
description='ROS2 Lifecycle + Composable Node 演示 - Level 1 基础机制第 11-12 块',
|
||||
license='MIT',
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'lifecycle_demo_node = py_lifecycle_composable.lifecycle_node:main',
|
||||
'composable_demo = py_lifecycle_composable.composable_demo:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""pytest 共享 fixtures - py_lifecycle_composable 测试。"""
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
import rclpy
|
||||
|
||||
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def ros_context() -> Iterator[None]:
|
||||
"""session 级 rclpy 上下文。"""
|
||||
rclpy.init()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lifecycle_node(ros_context: None) -> Iterator[LifecycleDemoNode]:
|
||||
"""每个测试一个独立 Lifecycle 节点实例。"""
|
||||
node = LifecycleDemoNode()
|
||||
try:
|
||||
yield node
|
||||
finally:
|
||||
node.destroy_node()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""测试 Composable Node demo(同进程跑 2 个 Lifecycle 节点)。"""
|
||||
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 test_two_nodes_same_process(ros_context: None) -> None:
|
||||
"""同进程跑 2 个 Lifecycle 节点(模拟 Composable Container)。"""
|
||||
node_a = LifecycleDemoNode(node_name='test_node_a')
|
||||
node_b = LifecycleDemoNode(node_name='test_node_b')
|
||||
|
||||
executor = MultiThreadedExecutor(num_threads=2)
|
||||
executor.add_node(node_a)
|
||||
executor.add_node(node_b)
|
||||
|
||||
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)
|
||||
time.sleep(0.1)
|
||||
n.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
time.sleep(0.1)
|
||||
|
||||
assert node_a.get_current_state().id == LifecycleState.active
|
||||
assert node_b.get_current_state().id == LifecycleState.active
|
||||
|
||||
# 退出清理
|
||||
for n in [node_a, node_b]:
|
||||
n.trigger_transition(Transition.TRANSITION_DEACTIVATE)
|
||||
n.trigger_transition(Transition.TRANSITION_CLEANUP)
|
||||
|
||||
executor.shutdown()
|
||||
node_a.destroy_node()
|
||||
node_b.destroy_node()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""测试 LifecycleDemoNode 状态机。"""
|
||||
import time
|
||||
|
||||
import rclpy
|
||||
from rclpy.lifecycle import LifecycleState, Transition
|
||||
|
||||
from py_lifecycle_composable.lifecycle_node import LifecycleDemoNode
|
||||
|
||||
|
||||
def test_initial_state_unconfigured(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""初始状态应为 unconfigured。"""
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.unconfigured
|
||||
|
||||
|
||||
def test_configure_creates_publisher(lifecycle_node: LifecycleDemoNode) -> None:
|
||||
"""configure 后 publisher 应创建。"""
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_CONFIGURE)
|
||||
time.sleep(0.1)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.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)
|
||||
time.sleep(0.05)
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_ACTIVATE)
|
||||
time.sleep(0.1)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.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)
|
||||
time.sleep(0.05)
|
||||
lifecycle_node.trigger_transition(Transition.TRANSITION_DEACTIVATE)
|
||||
time.sleep(0.1)
|
||||
assert lifecycle_node.get_current_state().id == LifecycleState.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
|
||||
@@ -0,0 +1,67 @@
|
||||
# py_overlay_dds
|
||||
|
||||
ROS2 DDS 配置 + colcon overlay 演示包(Python)。属于 Level 1 基础机制第 14-15 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`dds_inspector`**: 启动时读 DDS/RMW 环境变量,周期性打印当前配置
|
||||
|
||||
## 关键概念
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
## colcon overlay 工作流
|
||||
|
||||
```bash
|
||||
# 主工作空间(base)
|
||||
colcon build --packages-select py_pubsub cpp_pubsub ...
|
||||
source 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 之上
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 启 DDS inspector
|
||||
ros2 launch py_overlay_dds overlay_launch.py
|
||||
|
||||
# 设置不同 domain 测试
|
||||
ROS_DOMAIN_ID=42 ros2 launch py_overlay_dds overlay_launch.py
|
||||
|
||||
# 跨网段配置(单播)
|
||||
ROS_STATIC_PEERS="192.168.1.20;192.168.1.21" ros2 launch py_overlay_dds overlay_launch.py
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_overlay_dds
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
|---|---|---|
|
||||
| `test_dds_inspector.py` | 6 | 节点名 / 参数 / domain_id / inspect 计数 |
|
||||
| **总计** | **6** | **目标 6/6 100% 通过** |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`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)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""overlay_launch.py - 启动 dds_inspector。"""
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:DdsInspectorNode。"""
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='py_overlay_dds',
|
||||
executable='dds_inspector',
|
||||
name='dds_inspector',
|
||||
output='screen',
|
||||
),
|
||||
])
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>py_overlay_dds</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
ROS2 DDS 配置 + colcon overlay 演示包(Python)。
|
||||
演示 DdsInspectorNode(读 ROS_DOMAIN_ID / RMW_IMPLEMENTATION 等环境变量)。
|
||||
属于 Level 1 基础机制第 14-15 块。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@@ -0,0 +1,18 @@
|
||||
"""py_overlay_dds - ROS2 DDS 配置 + colcon overlay 演示包。
|
||||
|
||||
属于 Level 1 基础机制第 14-15 块:
|
||||
- colcon overlay(混合工作空间)
|
||||
- DDS / RMW 配置(domain ID / static peers)
|
||||
|
||||
演示内容:
|
||||
- DdsInspectorNode:启动时读取 ROS_DOMAIN_ID / RMW_IMPLEMENTATION / ROS_STATIC_PEERS
|
||||
并周期性打印,验证环境配置正确
|
||||
- 提供 ROS_DOMAIN_ID 测试 fixture(单元测试可临时修改)
|
||||
|
||||
参考:
|
||||
- DDS 规范 https://www.omg.org/spec/DDS/1.4/
|
||||
- FastDDS 配置 https://fast-dds.docs.eprosima.com/
|
||||
- Cyclone DDS 配置 https://cyclonedds.io/docs/
|
||||
- 编程规范 doc/CODING_STYLE.md
|
||||
- 深度文档 doc/21-overlay-dds.md
|
||||
"""
|
||||
@@ -0,0 +1,117 @@
|
||||
"""dds_inspector_node - 检查 DDS / RMW 环境配置。
|
||||
|
||||
设计思想:
|
||||
跨机器部署时,必须正确配置 DDS,否则节点看不见对方。
|
||||
本节点启动时读关键环境变量并打印:
|
||||
- ROS_DOMAIN_ID(0-232,默认 0)
|
||||
- RMW_IMPLEMENTATION(默认 rmw_fastrtps_cpp)
|
||||
- ROS_STATIC_PEERS(跨网段单播发现)
|
||||
- ROS_DISCOVERY_SERVER(集中发现服务)
|
||||
- CYCLONE_DDS_URI(Cyclone DDS 配置)
|
||||
|
||||
参考:
|
||||
- https://docs.ros.org/en/humble/Concepts/About-DDS-Implementations.html
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
class DdsInspectorNode(Node):
|
||||
"""检查 DDS / RMW 环境配置。
|
||||
|
||||
Attributes:
|
||||
_inspect_count: 已检查次数。
|
||||
"""
|
||||
|
||||
DEFAULT_RATE_HZ: float = 0.5 # 每 2 秒检查一次
|
||||
|
||||
def __init__(self, *, node_name: str = 'dds_inspector') -> None:
|
||||
"""初始化 DDS 检查节点。
|
||||
|
||||
Args:
|
||||
node_name: ROS2 节点名,默认 `dds_inspector`。
|
||||
"""
|
||||
super().__init__(node_name)
|
||||
|
||||
self.declare_parameter(
|
||||
'inspect_rate_hz', self.DEFAULT_RATE_HZ,
|
||||
descriptor='检查频率 (Hz)',
|
||||
)
|
||||
|
||||
rate: float = self.get_parameter('inspect_rate_hz').value
|
||||
period: float = 1.0 / rate if rate > 0 else 2.0
|
||||
|
||||
self._timer = self.create_timer(period, self._inspect_dds)
|
||||
self._inspect_count: int = 0
|
||||
|
||||
self.get_logger().info('dds_inspector started')
|
||||
|
||||
def _inspect_dds(self) -> None:
|
||||
"""读关键环境变量 + 打印当前 DDS 配置。
|
||||
|
||||
关键变量:
|
||||
- ROS_DOMAIN_ID (int, 0-232)
|
||||
- RMW_IMPLEMENTATION (string, 默认 rmw_fastrtps_cpp)
|
||||
- ROS_STATIC_PEERS (string, 跨网段单播发现)
|
||||
- ROS_DISCOVERY_SERVER (string, 集中发现)
|
||||
- ROS_LOCALHOST_ONLY (int, 0/1, 仅本机通信)
|
||||
"""
|
||||
try:
|
||||
domain_id: str = os.environ.get('ROS_DOMAIN_ID', '0')
|
||||
rmw_impl: str = os.environ.get(
|
||||
'RMW_IMPLEMENTATION', 'rmw_fastrtps_cpp',
|
||||
)
|
||||
static_peers: str = os.environ.get('ROS_STATIC_PEERS', '<unset>')
|
||||
discovery_server: str = os.environ.get(
|
||||
'ROS_DISCOVERY_SERVER', '<unset>',
|
||||
)
|
||||
localhost_only: str = os.environ.get('ROS_LOCALHOST_ONLY', '0')
|
||||
|
||||
self._inspect_count += 1
|
||||
|
||||
# 每 4 次打一次完整信息(避免日志刷屏)
|
||||
if self._inspect_count % 4 == 1:
|
||||
self.get_logger().info(
|
||||
f'DDS Config: '
|
||||
f'ROS_DOMAIN_ID={domain_id}, '
|
||||
f'RMW={rmw_impl}, '
|
||||
f'LOCALHOST_ONLY={localhost_only}',
|
||||
)
|
||||
if static_peers != '<unset>':
|
||||
self.get_logger().info(f'ROS_STATIC_PEERS={static_peers}')
|
||||
if discovery_server != '<unset>':
|
||||
self.get_logger().info(
|
||||
f'ROS_DISCOVERY_SERVER={discovery_server}',
|
||||
)
|
||||
|
||||
# 节点自己的 domain(从 ROS_DOMAIN_ID 来)
|
||||
current_domain: int = self.get_domain_id()
|
||||
self.get_logger().debug(
|
||||
f'inspect #{self._inspect_count}: domain={current_domain}',
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(
|
||||
f'inspect_dds failed: {exc}', exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def main(args: Optional[List[str]] = None) -> None:
|
||||
"""ROS2 节点入口。"""
|
||||
rclpy.init(args=args)
|
||||
node: Optional[DdsInspectorNode] = None
|
||||
try:
|
||||
node = DdsInspectorNode()
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
if node is not None:
|
||||
node.get_logger().info('KeyboardInterrupt → 退出')
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
from glob import glob
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
PACKAGE_NAME = 'py_overlay_dds'
|
||||
|
||||
setup(
|
||||
name=PACKAGE_NAME,
|
||||
version='0.1.0',
|
||||
packages=[PACKAGE_NAME],
|
||||
data_files=[
|
||||
('share/ament_index/resource_index/packages',
|
||||
['resource/' + PACKAGE_NAME]),
|
||||
('share/' + PACKAGE_NAME, ['package.xml']),
|
||||
(os.path.join('share', PACKAGE_NAME, 'launch'), glob('launch/*.py')),
|
||||
],
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
maintainer='xs',
|
||||
maintainer_email='dev@example.com',
|
||||
description='ROS2 DDS 配置 + colcon overlay 演示 - Level 1 基础机制第 14-15 块',
|
||||
license='MIT',
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'dds_inspector = py_overlay_dds.dds_inspector_node:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""pytest 共享 fixtures - py_overlay_dds 测试。"""
|
||||
import os
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
import rclpy
|
||||
|
||||
from py_overlay_dds.dds_inspector_node import DdsInspectorNode
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def ros_context() -> Iterator[None]:
|
||||
"""session 级 rclpy 上下文。"""
|
||||
rclpy.init()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dds_inspector(ros_context: None) -> Iterator[DdsInspectorNode]:
|
||||
"""每个测试一个独立节点实例。"""
|
||||
node = DdsInspectorNode()
|
||||
try:
|
||||
yield node
|
||||
finally:
|
||||
node.destroy_node()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""测试 DdsInspectorNode 节点构造与环境变量读取。"""
|
||||
import os
|
||||
|
||||
from py_overlay_dds.dds_inspector_node import DdsInspectorNode
|
||||
|
||||
|
||||
def test_default_node_name(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""默认节点名。"""
|
||||
assert dds_inspector.get_name() == 'dds_inspector'
|
||||
|
||||
|
||||
def test_default_inspect_rate(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""默认 inspect_rate_hz == 0.5(每 2 秒一次)。"""
|
||||
assert dds_inspector.get_parameter('inspect_rate_hz').value == 0.5
|
||||
|
||||
|
||||
def test_timer_created(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""定时器已创建。"""
|
||||
assert len(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()
|
||||
assert isinstance(domain_id, int)
|
||||
assert 0 <= domain_id <= 232
|
||||
|
||||
|
||||
def test_inspect_dds_runs(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""_inspect_dds 调用不抛异常。"""
|
||||
dds_inspector._inspect_dds()
|
||||
assert dds_inspector._inspect_count == 1
|
||||
|
||||
|
||||
def test_inspect_multiple_times(dds_inspector: DdsInspectorNode) -> None:
|
||||
"""多次调用 _inspect_dds 计数累加。"""
|
||||
for _ in range(5):
|
||||
dds_inspector._inspect_dds()
|
||||
assert dds_inspector._inspect_count == 5
|
||||
@@ -0,0 +1,60 @@
|
||||
# py_params
|
||||
|
||||
ROS2 参数系统演示包(Python)。属于 Level 1 基础机制第 8 块。
|
||||
|
||||
## 功能
|
||||
|
||||
- **`params_talker`**: 声明 / 读取 / 设置 / 校验回调四大操作 + YAML 加载
|
||||
|
||||
## 关键概念
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `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 工具 |
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 默认参数启动
|
||||
ros2 run py_params params_talker
|
||||
|
||||
# YAML 加载(覆盖默认)
|
||||
ros2 launch py_params params_launch.py
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_params
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
|---|---|---|
|
||||
| `test_param_declaration.py` | 7 | 默认值 + publisher + timer + 缓存 |
|
||||
| `test_param_callback.py` | 6 | 合法 set + 非法拒绝 + 缓存同步 + 原子性 |
|
||||
| `test_param_yaml.py` | 3 | YAML 存在 + 格式 + 键匹配 |
|
||||
| **总计** | **16** | **目标 16/16 100% 通过** |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- 参数深度:[`doc/15-params.md`](../doc/15-params.md)
|
||||
@@ -0,0 +1,8 @@
|
||||
# py_params 默认参数配置
|
||||
# 由 ros2 launch params_launch.py 加载
|
||||
# 格式: <node_name>: ros__parameters: <key>: <value>
|
||||
params_talker:
|
||||
ros__parameters:
|
||||
publish_rate_hz: 2.0
|
||||
topic_name: "params_chatter"
|
||||
message_prefix: "Configured:"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""params_launch.py - 启动 params_talker,加载 YAML 配置。"""
|
||||
from launch import LaunchDescription
|
||||
from launch.substitutions import PathJoinSubstitution
|
||||
from launch_ros.actions import Node
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
"""生成启动描述:ParamsTalker + YAML 配置加载。"""
|
||||
config_file = PathJoinSubstitution([
|
||||
FindPackageShare('py_params'),
|
||||
'config',
|
||||
'params.yaml',
|
||||
])
|
||||
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='py_params',
|
||||
executable='params_talker',
|
||||
name='params_talker',
|
||||
output='screen',
|
||||
parameters=[config_file],
|
||||
),
|
||||
])
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model
|
||||
href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>py_params</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
ROS2 参数系统演示包(Python)。
|
||||
演示 ParamsTalker:声明 + 读取 + 设置 + 校验回调 + YAML 加载。
|
||||
属于 Level 1 基础机制第 8 块。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">xs</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<depend>rcl_interfaces</depend>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user