feat(level1): ROS2 完全体 12 包 / 80 测试 / 23 文档 / 工程化 / Docker 分组

This commit is contained in:
xs
2026-08-04 10:19:47 +08:00
parent 5ef38ab508
commit 549d6b337e
141 changed files with 8949 additions and 1594 deletions
+49
View File
@@ -0,0 +1,49 @@
# py_srv
ROS2 Service req/resp 演示包(Python)。属于 Level 1 基础机制第 3 块。
## 功能
- **`add_two_ints_server`**: 接收 `AddTwoInts` 请求,返回 `a + b`
- **`add_two_ints_client`**: 异步发请求 + 等响应
## 关键概念
| 概念 | 用途 |
|---|---|
| `Service` / `Client` | 同步请求-响应 |
| `wait_for_service` / `service_is_ready` | 等待 server 注册 |
| `call_async` + `spin_until_future_complete` | 异步发请求 |
## 运行
```bash
# 终端 1:启 server
ros2 launch py_srv srv_launch.py
# 终端 2:CLI 调用
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
# 预期: sum: 42
# 终端 2 备选:启 client 节点(从参数读 a/b)
ros2 run py_srv add_two_ints_client --ros-args -p a:=12 -p b:=30
```
## 测试
```bash
colcon test --packages-select py_srv
```
测试覆盖:
| 文件 | 用例数 | 内容 |
|---|---|---|
| `test_srv_server.py` | 5 | 节点名 + 参数 + 回调(正/负/零) |
| `test_srv_client.py` | 1 | 同进程 client 调用 server(12+30=42) |
| **总计** | **6** | **目标 6/6 100% 通过** |
## 深度学习
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
- Service 深度:[`doc/30-services.md`](../doc/30-services.md)
+17 -19
View File
@@ -1,23 +1,21 @@
"""
srv_launch.py —— 启动 add_two_ints 服务端的 launch 文件。
注意:client 不在这里启动,因它是"一次性调用",通常在终端或脚本里跑。
client 用法:
source install/setup.bash
ros2 run py_srv add_two_ints_client 1 2 # 默认参数
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 3, b: 5}"
"""
"""srv_launch.py - 启动 add_two_ints_server。"""
from launch import LaunchDescription
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='py_srv',
executable='add_two_ints_server',
name='add_two_ints_server_py',
output='screen',
),
])
def generate_launch_description() -> LaunchDescription:
"""生成启动描述:AddTwoIntsServer。"""
service_name_arg = LaunchConfiguration('service_name')
server_node = Node(
package='py_srv',
executable='add_two_ints_server',
name='add_two_ints_server',
output='screen',
parameters=[{
'service_name': service_name_arg,
}],
)
return LaunchDescription([server_node])
+8 -4
View File
@@ -1,13 +1,17 @@
<?xml version="1.0"?>
<?xml-model
href="http://download.ros.org/schema/package_format3.xsd"
schematypens="http://www.w3.org/2001/XMLSchema"?>
href="http://download.ros.org/schema/package_format3.xsd"
schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>py_srv</name>
<version>0.1.0</version>
<description>Python service demo: AddTwoInts server + client</description>
<description>
ROS2 Service req/resp 演示包(Python)。
演示 AddTwoIntsServer + AddTwoIntClient,验证同步请求-响应。
属于 Level 1 基础机制第 3 块。
</description>
<maintainer email="dev@example.com">xs</maintainer>
<license>Apache-2.0</license>
<license>MIT</license>
<depend>rclpy</depend>
<depend>example_interfaces</depend>
+13
View File
@@ -0,0 +1,13 @@
"""py_srv - ROS2 Service req/resp 演示包。
属于 Level 1 基础机制第 3 块:Service 同步请求-响应。
演示内容:
- AddTwoIntsServer: 提供 a + b 同步求和服务
- AddTwoIntsClient: 客户端发请求,接响应(可重试)
参考:
- ROS2 设计稿 https://design.ros2.org/articles/topic_and_service.html
- 编程规范 doc/CODING_STYLE.md
- 深度文档 doc/30-services.md
"""
+112 -47
View File
@@ -1,63 +1,128 @@
"""
add_two_ints_client.py —— ROS2 Python 服务端调用方(Service Client)。
"""add_two_ints_client - 同步求和服务客户端。
Client API:
Client = node.create_client(srv_type, srv_name):
创建一个"客户端",绑定到指定 srv_name 的服务端
client.wait_for_service(timeout_sec=...) ->
阻塞等服务端上线;ROS2 网络发现需要 1-2s。
request = srv_type.Request():
构造请求对象,填写字段。
future = client.call_async(request) -> Future:
异步调用,返回 Future;不阻塞主线程。
rclpy.spin_until_future_complete(node, future, timeout_sec=...):
在节点事件循环里等 future 完成(或超时),取回 response。
"""
设计思想:
ROS2 Client 用异步 future + spin_until_future_complete 等结果。
wait_for_service 必须轮询(不能在 __init__ 阻塞 — 死锁)
import sys
参考:
- ROS2 Humble Tutorial https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Service-And-Client.html
"""
import time
from typing import List, Optional
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts
from rclpy.node import Node
class AddTwoIntsClient(Node):
"""同步求和服务客户端(只发一次请求)。
def __init__(self):
super().__init__('add_two_ints_client_py')
self.client = self.create_client(AddTwoInts, 'add_two_ints')
# wait_for_service:阻塞到服务端上线或超时(秒)。
# 这里给 5 秒,通常 1-2s 内 ROS2 完成 DDS discovery。
while not self.client.wait_for_service(timeout_sec=1.0):
self.get_logger().info('waiting for add_two_ints service...')
Attributes:
_client: rclpy.Client 实例。
"""
def call(self, a, b):
req = AddTwoInts.Request()
req.a = a
req.b = b
# call_async 返回 Future,由 spin_until_future_complete 触发回调。
future = self.client.call_async(req)
# spin_until_future_complete:在节点事件循环里"短轮询"等到 future 完成。
rclpy.spin_until_future_complete(self, future, timeout_sec=5.0)
if future.result() is not None:
return future.result().sum
self.get_logger().error('service call failed')
return None
DEFAULT_SERVICE_NAME: str = 'add_two_ints'
DEFAULT_A: int = 1
DEFAULT_B: int = 2
def __init__(self, *, node_name: str = 'add_two_ints_client') -> None:
"""初始化客户端节点。
Args:
node_name: ROS2 节点名,默认 `add_two_ints_client`。
"""
super().__init__(node_name)
self.declare_parameter(
'service_name', self.DEFAULT_SERVICE_NAME,
descriptor='要调用的服务名',
)
self.declare_parameter(
'a', self.DEFAULT_A,
descriptor='第一个加数',
)
self.declare_parameter(
'b', self.DEFAULT_B,
descriptor='第二个加数',
)
service_name: str = self.get_parameter('service_name').value
self._client = self.create_client(AddTwoInts, service_name)
self.get_logger().info(
f'AddTwoIntsClient created: service="{service_name}"',
)
def wait_for_service_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._client.service_is_ready():
return True
time.sleep(0.05)
return False
def call_once(self, a: int, b: int, timeout_sec: float = 5.0) -> Optional[int]:
"""异步发请求 + 等结果。
Args:
a: 第一个加数。
b: 第二个加数。
timeout_sec: 等结果超时秒数。
Returns:
响应 sum;失败/超时返回 None。
"""
if not self.wait_for_service_ready(timeout_sec=timeout_sec):
self.get_logger().warn('service not available')
return None
request = AddTwoInts.Request()
request.a = a
request.b = b
future = self._client.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec)
if not future.done():
self.get_logger().warn('call timed out')
return None
result = future.result()
if result is None:
self.get_logger().warn('call failed')
return None
self.get_logger().info(f'result: {a} + {b} = {result.sum}')
return result.sum
def main(args=None):
def main(args: Optional[List[str]] = None) -> None:
"""ROS2 节点入口:从参数读 a/b,调一次服务,打印结果,退出。"""
rclpy.init(args=args)
node = AddTwoIntsClient()
# 从命令行参数读取两个整数;默认 1 + 2。
a = int(sys.argv[1]) if len(sys.argv) > 1 else 1
b = int(sys.argv[2]) if len(sys.argv) > 2 else 2
result = node.call(a, b)
if result is not None:
node.get_logger().info(f'result: {a} + {b} = {result}')
print(f'RESULT: {a} + {b} = {result}')
node.destroy_node()
rclpy.shutdown()
node: Optional[AddTwoIntsClient] = None
try:
node = AddTwoIntsClient()
a = node.get_parameter('a').value
b = node.get_parameter('b').value
result = node.call_once(a, b)
if result is None:
node.get_logger().error('call failed')
except KeyboardInterrupt:
if node is not None:
node.get_logger().info('KeyboardInterrupt → 退出')
finally:
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
+77 -35
View File
@@ -1,56 +1,98 @@
"""
add_two_ints_server.py —— ROS2 Python 服务端(Service Server)。
"""add_two_ints_server - 同步求和服务端。
ROS2 Service 是另一种通信模式,与 Topic 的区别:
Topic — 多对多、无连接、异步、单向(pub/sub)
Service — 一对一、有连接、同步(可异步)、双向(request/response)。
适合"一次性调用 + 等结果"的场景,如拍照、开关机械臂、计算查询等。
设计思想:
ROS2 Service 是同步、一对一、双向的请求-响应
本节点演示:
1. create_service 注册回调
2. 回调签名 cb(request, response) -> response
3. callback 内 try/except + 日志
4. 计数器统计已处理请求数
关键 API:
Service[T_Request, T_Response]:
节点上的"服务句柄",由 create_service(...) 构造。
T_Request / T_Response:由 .srv 文件自动生成的 Python 类型;
本 demo 用 example_interfaces/srv/AddTwoInts,
含 a, b(int64) 与 sum(int64) 三个字段。
回调签名:
handler(req, response) -> response
handler 内部填 response 字段,ROS2 把它发回给 client。
参考:
- ROS2 Humble Tutorial https://docs.ros.org/en/humble/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Service-And-Client.html
"""
from typing import List, Optional
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts
from rclpy.node import Node
from rclpy.service import Service
class AddTwoIntsServer(Node):
"""提供 a + b 求和的同步服务。
def __init__(self):
super().__init__('add_two_ints_server_py')
# create_service(srv_type, srv_name, callback):
# - srv_type:服务接口类(本例 AddTwoInts);
# - srv_name:客户端调用时使用的服务名;
# - callback:收到请求时由 ROS2 事件循环调用,
# 返回值就是发回 client 的 response。
self.srv = self.create_service(
AddTwoInts, 'add_two_ints', self.add_two_ints_callback)
self.get_logger().info('add_two_ints_server_py ready, waiting for requests...')
Attributes:
service_: AddTwoInts 服务句柄。
_handled_count: 已处理请求数(下划线=内部状态)。
"""
def add_two_ints_callback(self, request, response):
# request.a / request.b 是 AddTwoInts.Request 的 int64 字段。
response.sum = request.a + request.b
self.get_logger().info(f'incoming: a={request.a}, b={request.b} -> sum={response.sum}')
DEFAULT_SERVICE_NAME: str = 'add_two_ints'
def __init__(self, *, node_name: str = 'add_two_ints_server') -> None:
"""初始化服务端节点。
Args:
node_name: ROS2 节点名,默认 `add_two_ints_server`。
"""
super().__init__(node_name)
self.declare_parameter(
'service_name', self.DEFAULT_SERVICE_NAME,
descriptor='服务名(字符串)',
)
service_name: str = self.get_parameter('service_name').value
self.service_: Service = self.create_service(
AddTwoInts, service_name, self._handle_request,
)
self._handled_count: int = 0
self.get_logger().info(
f'AddTwoIntsServer ready: service="{service_name}"',
)
def _handle_request(
self,
request: AddTwoInts.Request,
response: AddTwoInts.Response,
) -> AddTwoInts.Response:
"""处理客户端请求:a + b。
Args:
request: 包含 a、b 两个 int64 字段的请求。
response: 包含 sum 字段的响应(原地修改,必须返回)。
Returns:
填好 sum 字段的 response。
"""
try:
response.sum = request.a + request.b
self._handled_count += 1
self.get_logger().info(
f'request #{self._handled_count}: {request.a} + {request.b} = {response.sum}',
)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'handle_request failed: {exc}', exc_info=True)
response.sum = 0
return response
def main(args=None):
def main(args: Optional[List[str]] = None) -> None:
"""ROS2 节点入口。"""
rclpy.init(args=args)
node = AddTwoIntsServer()
node: Optional[AddTwoIntsServer] = None
try:
node = AddTwoIntsServer()
rclpy.spin(node)
except KeyboardInterrupt:
pass
node.destroy_node()
rclpy.shutdown()
if node is not None:
node.get_logger().info('KeyboardInterrupt → 退出')
finally:
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
+12 -9
View File
@@ -1,23 +1,26 @@
from setuptools import find_packages, setup
import os
from glob import glob
package_name = 'py_srv'
from setuptools import setup
PACKAGE_NAME = 'py_srv'
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/srv_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 service demo: AddTwoInts server + client',
license='Apache-2.0',
description='ROS2 Service req/resp 演示 - Level 1 基础机制第 3 块',
license='MIT',
tests_require=['pytest'],
entry_points={
'console_scripts': [
+39
View File
@@ -0,0 +1,39 @@
"""pytest 共享 fixtures - py_srv 测试。"""
from typing import Iterator
import pytest
import rclpy
from py_srv.add_two_ints_client import AddTwoIntsClient
from py_srv.add_two_ints_server import AddTwoIntsServer
@pytest.fixture(scope='session')
def ros_context() -> Iterator[None]:
"""session 级 rclpy 上下文。"""
rclpy.init()
try:
yield
finally:
if rclpy.ok():
rclpy.shutdown()
@pytest.fixture
def server(ros_context: None) -> Iterator[AddTwoIntsServer]:
"""每个测试一个独立 server 实例。"""
node = AddTwoIntsServer()
try:
yield node
finally:
node.destroy_node()
@pytest.fixture
def client(ros_context: None) -> Iterator[AddTwoIntsClient]:
"""每个测试一个独立 client 实例。"""
node = AddTwoIntsClient()
try:
yield node
finally:
node.destroy_node()
+43
View File
@@ -0,0 +1,43 @@
"""测试 AddTwoIntsServiceClient 客户端调用服务端(同进程)。"""
import time
import rclpy
import pytest
from example_interfaces.srv import AddTwoInts
from py_srv.add_two_ints_server import AddTwoIntsServer
from py_srv.add_two_ints_client import AddTwoIntsClient
def test_client_calls_server_inproc(ros_context: None) -> None:
"""同进程 spin:server + client,验证 12+30=42。"""
server = AddTwoIntsServer()
client = AddTwoIntsClient()
executor = rclpy.executors.SingleThreadedExecutor()
executor.add_node(server)
executor.add_node(client)
# 触发 client 调用
result_future_container: list = []
def call_in_thread() -> None:
# 给点时间让 server 注册(同进程也需要一点 spin 时间)
deadline = time.time() + 2.0
while time.time() < deadline:
executor.spin_once(timeout_sec=0.05)
if client._client.service_is_ready():
break
result = client.call_once(12, 30, timeout_sec=3.0)
result_future_container.append(result)
import threading
t = threading.Thread(target=call_in_thread)
t.start()
t.join(timeout=5.0)
server.destroy_node()
client.destroy_node()
assert len(result_future_container) == 1
assert result_future_container[0] == 42
+45
View File
@@ -0,0 +1,45 @@
"""测试 AddTwoIntsServiceRequest 服务端处理逻辑。"""
from py_srv.add_two_ints_server import AddTwoIntsServer
def test_server_node_name(server: AddTwoIntsServer) -> None:
"""默认节点名正确。"""
assert server.get_name() == 'add_two_ints_server'
def test_server_service_name(server: AddTwoIntsServer) -> None:
"""默认 service_name 参数正确。"""
assert server.get_parameter('service_name').value == 'add_two_ints'
def test_server_handle_positive(server: AddTwoIntsServer) -> None:
"""正数加法正确。"""
from example_interfaces.srv import AddTwoInts
req = AddTwoInts.Request()
req.a = 12
req.b = 30
resp = AddTwoInts.Response()
out = server._handle_request(req, resp)
assert out.sum == 42
def test_server_handle_negative(server: AddTwoIntsServer) -> None:
"""负数加法正确。"""
from example_interfaces.srv import AddTwoInts
req = AddTwoInts.Request()
req.a = -5
req.b = 3
resp = AddTwoInts.Response()
out = server._handle_request(req, resp)
assert out.sum == -2
def test_server_handle_zero(server: AddTwoIntsServer) -> None:
"""零加法正确。"""
from example_interfaces.srv import AddTwoInts
req = AddTwoInts.Request()
req.a = 0
req.b = 0
resp = AddTwoInts.Response()
out = server._handle_request(req, resp)
assert out.sum == 0