Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/ci-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
MPLBACKEND: Agg
# 原生崩溃(0xC0000005 访问违例)时打印 Python 调用栈,便于定位是哪个设备模块 import 崩溃
PYTHONFAULTHANDLER: "1"
UNILABOS_README_EXAMPLES_ROOT: ${{ github.workspace }}/readme-examples

defaults:
run:
Expand All @@ -28,6 +29,22 @@ jobs:
with:
fetch-depth: 0

# README 中列出的外部设备示例也要随主仓库接口一起验证。
# 固定到已验证提交,避免外部仓库更新让当前 PR 的 CI 无故变化。
- name: Checkout README LAN device example
uses: actions/checkout@v6
with:
repository: Xuwznln/LabDeviceLanDemo
ref: 2f98f55015b47816e0a08731ca115903d6ea5161
path: readme-examples/LabDeviceLanDemo

- name: Checkout README workstation device example
uses: actions/checkout@v6
with:
repository: Xuwznln/LabDeviceWorkstationDemo
ref: ad5b43fc7c64ca02b0020f69aa2ed15bdb0e8089
path: readme-examples/LabDeviceWorkstationDemo

- name: Setup Miniforge
uses: conda-incubator/setup-miniconda@v4
with:
Expand Down Expand Up @@ -68,12 +85,21 @@ jobs:
uv pip install pytest
uv pip install .

- name: Validate README example device packages
run: |
call conda activate check-env
call install\unilabos_msgs\setup.bat
echo Checking LabDeviceLanDemo registry...
python -m unilabos --check_mode --skip_env_check --devices "%GITHUB_WORKSPACE%\readme-examples\LabDeviceLanDemo\lan_demo" --external_devices_only
echo Checking LabDeviceWorkstationDemo registry...
python -m unilabos --check_mode --skip_env_check --devices "%GITHUB_WORKSPACE%\readme-examples\LabDeviceWorkstationDemo\workstation_demo" --external_devices_only

- name: Run HostLink networking tests
run: |
call conda activate check-env
call install\unilabos_msgs\setup.bat
echo Running HostLink, ROS2 domain and networking runtime tests...
python -m pytest -q tests\hostlink tests\networking tests\ros\test_domain_init.py -p no:launch_testing -p no:launch_ros
python -m pytest -q tests\hostlink tests\networking tests\basic tests\device_runtime tests\app\test_backend_selection.py tests\registry\test_backend_metadata.py tests\ros\test_domain_init.py tests\ros\test_device_node_contract.py -p no:launch_testing -p no:launch_ros

- name: Run check mode (AST registry validation)
# check_mode 会真实 import 所有设备类(连带 matplotlib/opencv 等原生库)。
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ pip install -e .
uv pip install -r unilabos/utils/requirements.txt

# Run with a device graph
unilab --graph <graph.json> --config <config.py> --backend ros
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
unilab --graph <graph.json> --config <config.py> --backend ros2
unilab --graph <graph.json> --config <config.py> --backend basic # no ROS2 runtime

# Common CLI flags
unilab --app_bridges websocket fastapi # communication bridges
Expand All @@ -36,7 +36,7 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si

### Startup Flow

`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts the selected backend (`basic`/`ros2`/`dora`) → starts only the bridges supported by that backend.

### Core Layers

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ with `--devices <pkg> --external_devices_only`, and read it when writing your ow
Each repository README ships a step-by-step launch tutorial with verified output. For the underlying
communication-sharing mechanism see [Best Practice Guide §11.5](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html);
to write a new driver from scratch see [Add Device](https://deepmodeling.github.io/Uni-Lab-OS/developer_guide/add_device.html).
The main repository CI validates pinned revisions of both packages. It also runs the LAN demo shape
as separate HostLink Host/Slave processes over loopback and an available non-loopback LAN IPv4,
covering cross-device `@subscribe` followed by a remote device action.

## Message Format

Expand Down
6 changes: 3 additions & 3 deletions docs/advanced_usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class WSConfig:
class HTTPConfig:
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址

# Host/Slave ROS2 组网控制通道
# Host/Slave 组网控制通道;hostlink backend 也承载设备动作、JSON Topic、状态和物料树同步
class HostLinkConfig:
enable = True
host = "" # Slave 的 HostNode IP;推荐用 --host-node-ip 覆盖
Expand Down Expand Up @@ -199,12 +199,12 @@ Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵
| `HostLinkConfig` | `heartbeat_interval` | `--hostlink-heartbeat-interval` | Slave 心跳周期 |
| `HostLinkConfig` | `heartbeat_timeout` | `--hostlink-heartbeat-timeout` | Host 离线判定时间 |
| `HostLinkConfig` | `connect_timeout` | `--hostlink-connect-timeout` | TCP 连接/握手超时 |
| `HostLinkConfig` | `request_timeout` | `--hostlink-request-timeout` | 控制请求超时 |
| `HostLinkConfig` | `request_timeout` | `--hostlink-request-timeout` | 控制请求/设备 RPC 超时 |
| `HostLinkConfig` | `ros_domain_id`| `--ros-domain-id` | Host 发布或 Slave 本地兜底 domain |
| `HostLinkConfig` | `ros_discovery_range` | `--ros-discovery-range` | ROS 自动发现范围 |
| `HostLinkConfig` | `ros_static_peers` | `--ros-static-peers` | 分号分隔的静态对端 |
| `HostLinkConfig` | `ros_discovery_server` | `--ros-discovery-server` | 外部 Fast DDS Server |
| `HostLinkConfig` | `ros_assist_apply` | `--no-ros-assist` | 不应用 Host 下发的 ROS 环境 |
| `HostLinkConfig` | `ros_assist_apply` | `--no-ros-assist` | ROS2 Slave 不应用 Host 下发的 ROS 环境 |

### 特殊命令行参数

Expand Down
86 changes: 30 additions & 56 deletions docs/developer_guide/add_device.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,16 @@ async def async_operation(self, duration: float) -> Dict[str, Any]:
Args:
duration: 持续时间(秒)
"""
# 使用 self.sleep 而不是 asyncio.sleep(ROS2 异步机制)
await self.sleep(duration)
# node 由 post_init 注入;该写法兼容 ROS 与 HostLink/simple backend
await self._node.sleep(duration)
return {"success": True}
```

**特点**:

- 普通方法或 async 方法
- 返回 Dict 类型的结果
- 自动注册为 ROS2 Action
- 自动注册为设备 Action;ROS backend 使用 ROS2,HostLink 使用 TCP RPC
- 支持参数和返回值

### 返回值设计指南
Expand Down Expand Up @@ -802,83 +802,57 @@ async def long_operation(self, duration: float) -> Dict[str, Any]:
"""长时间运行的操作"""
self._status = "running"

# 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep)
await self.sleep(duration)
# 使用通用 DeviceNode,驱动无需判断当前 backend
await self._node.sleep(duration)

# 可以在过程中发送feedback
# 需要配合ROS2 Action的feedback机制
# ActionContext 可在 ROS 和 HostLink 中发送 feedback、接收取消

self._status = "idle"
return {"success": True, "duration": duration}
```

> **⚠️ 重要提示:ROS2 异步机制 vs Python asyncio**
> **异步驱动的兼容写法**
>
> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。
> 驱动通过 `post_init(node)` 保存通用 `DeviceNode`,不要继承或导入
> `BaseROS2DeviceNode`。`node.sleep()`、`node.create_task()`、Topic 和设备 Action
> API 会由当前 backend 实现。
>
> **不能使用的 asyncio 功能:**
>
> - ❌ `asyncio.sleep()` - 会导致 ROS2 事件循环阻塞
> - ❌ `asyncio.create_task()` - 任务不会被 ROS2 正确调度
> - ❌ `asyncio.gather()` - 无法与 ROS2 集成
> - ❌ 其他 asyncio 标准库函数
>
> **应该使用的方法(继承自 BaseROS2DeviceNode):**
>
> - ✅ `await self.sleep(seconds)` - ROS2 兼容的睡眠
> - ✅ `await self.create_task(func, **kwargs)` - ROS2 兼容的任务创建
> - ✅ ROS2 的 Action/Service 回调机制
> - HostLink/simple backend 使用标准 Python `asyncio` 事件循环,可使用
> `asyncio.gather()`、`asyncio.wait_for()` 等原生能力。
> - ROS backend 由 rclpy executor 调度。需要同时支持两类 backend 的驱动,优先使用
> `DeviceNode` 提供的 `sleep()` 和 `create_task()`。
> - 同步动作调用使用 `node.call_device_action(...)`;异步动作中使用
> `await node.call_device_action_async(...)`,不要用同步调用阻塞当前事件循环。
>
> **示例:**
>
> ```python
> async def complex_operation(self, duration: float) -> Dict[str, Any]:
> """正确使用 ROS2 异步方法"""
> """同一份驱动可在 ROS 和 HostLink/simple backend 中运行。"""
> self._status = "processing"
>
> # ✅ 正确:使用 self.sleep
> await self.sleep(duration)
> await self._node.sleep(duration)
>
> # ✅ 正确:创建并发任务
> task = await self.create_task(self._background_work)
>
> # ❌ 错误:不要使用 asyncio
> # await asyncio.sleep(duration) # 这会导致问题!
> # task = asyncio.create_task(...) # 这也不行!
> result = await self._node.call_device_action_async(
> "heater-2",
> "set_temperature",
> {"temperature": 60.0},
> )
>
> self._status = "idle"
> return {"success": True}
> return {"success": True, "peer_result": result}
>
> async def _background_work(self):
> """后台任务"""
> await self.sleep(1.0)
> self.lab_logger().info("Background work completed")
> await self._node.sleep(1.0)
> self._node.lab_logger().info("Background work completed")
> ```
>
> **为什么不能混用?**
>
> ROS2 使用 `rclpy` 的事件循环来管理所有异步操作。如果使用 `asyncio` 的函数,这些操作会在不同的事件循环中运行,导致:
>
> - ROS2 回调无法正确执行
> - 任务可能永远不会完成
> - 程序可能死锁或崩溃
> **后端差异:**
>
> **参考实现:**
>
> `BaseROS2DeviceNode` 提供的方法定义(`base_device_node.py:563-572`):
>
> ```python
> async def sleep(self, rel_time: float, callback_group=None):
> """ROS2 兼容的异步睡眠"""
> if callback_group is None:
> callback_group = self.callback_group
> await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group)
>
> @classmethod
> async def create_task(cls, func, trace_error=True, **kwargs) -> Task:
> """ROS2 兼容的任务创建"""
> return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs)
> ```
> HostLink/simple 为每个设备维护标准 Python 事件循环;ROS backend 使用 rclpy
> executor。只在 HostLink/simple 中运行的驱动可以直接使用 Python `asyncio`;需要在
> 两类 backend 中运行时,通过 `DeviceNode` 调度即可,不需要在驱动中写 backend 判断。

## 错误处理

Expand Down
60 changes: 48 additions & 12 deletions docs/developer_guide/networking_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,52 @@ ros2 topic list
ros2 action list
```

### HostLink 组网控制通道
### HostLink 组网控制通道与无 ROS backend

Host 运行 ROS backend 时会在 TCP `7302` 监听 HostLink。Slave 通过
`--host-node-ip <host-ip>[:port]` 建立控制连接,在 `rclpy.init` 前完成两件事
Host 运行 ROS2 或 HostLink backend 时会在 TCP `7302` 监听 HostLink。Slave 通过
`--host-node-ip <host-ip>[:port]` 建立控制连接并完成

- 上报启动图中的设备 ID,供 Host 发现 Slave 及其设备归属;
- 接收并应用 Host 的 `ROS_DOMAIN_ID`、发现范围、静态对端和外部 Fast DDS
Discovery Server 地址。
- ROS2 模式在 `rclpy.init` 前接收并应用 Host 的 `ROS_DOMAIN_ID`、发现范围、
静态对端和外部 Fast DDS Discovery Server 地址。

在 `--backend ros2` 下,HostLink 只辅助组网,设备 Action、节点注册和资源同步仍
走 ROS2。`--backend hostlink` 则完全不导入 ROS:Host 与 Slave 都使用 BasicRuntime
加载本地纯 Python 驱动。Slave 在 HELLO 中发布设备动作、状态字段和设备 UUID;驱动
通过通用节点发布的状态通知会立即发送,心跳还会定期补发完整状态。Host 与 Slave 可以双向调用设备动作,
动作带独立 ID,支持反馈和协作取消。通用节点还提供与 ROS 相同形状的
`create_publisher(...).publish(...)` 和 `create_subscription(...)`:Basic 在本进程分发,
HostLink 由 Host 按绝对 Topic 名称转发,消息会转换为 JSON 可传输的 Python 值。
Slave 启动时会把本地设备物料树同步给 Host,
后续 `update_resource` 和 `get_resource` 也由 Host 保存和查询,不要求启动 ROS service
或 Web API。

HostLink 只辅助 ROS2 组网。设备 Action、节点注册和资源同步仍走现有 ROS2
接口;本阶段没有通过 HostLink 提供物料查询或无 ROS backend。
```bash
# 无 ROS Host
unilab -g host.json --backend hostlink --hostlink-port 7302

# 无 ROS Slave
unilab -g slave.json --backend hostlink --is-slave \
--host-node-ip 192.168.1.10 --hostlink-port 7302
```

驱动通过 `post_init(node)` 获得通用 `DeviceNode`,可使用日志、异步等待、任务调度、
状态通知、Topic 发布/订阅、物料更新/查询和跨设备动作调用。相对 Topic 名称会按
`/devices/<device_id>/<topic>` 解析;设备状态也会发布到这个路径。注册表可用
`class.supported_backends: [basic, hostlink, ros2]` 明确声明可运行的 backend;
`class.type: ros2` 默认只允许 ROS2。注册表设备动作可以在 HostLink 上传递目标、反馈、取消和结果;
驱动调用时携带的 `action_type` 只作为兼容信息,实际按动作名和字典参数执行。
直接操作外部 ROS 图的 MoveIt ActionClient、规划场景/图像等 ROS 专用 Topic,
以及工作站跨设备物料搬运仍使用 ROS2。这些驱动已标记为 `[ros2]`,HostLink 启动时会
直接提示该驱动不支持,而不是在导入过程中报缺少 `rclpy`。

设备动作在每台设备内串行执行;不同 Slave/设备可以并行。取消是协作式的:驱动需
接收 `ActionContext` 并在长操作中检查取消状态,已经进入的阻塞硬件调用不会被强制
终止。连接断开时设备在 `heartbeat_timeout` 后离线,客户端会指数退避重连,但不会
自动重放动作。HostLink 的物料树保存在 Host 进程内,目前不会自动上传云端。

当前 HostLink 是面向可信实验室局域网的明文 TCP 协议,尚未提供 TLS 或双方身份认证。
部署时应通过防火墙限制 `7302` 的来源;跨不可信网络使用时应先接入 VPN/安全隧道。

#### 端口与前端归属

Expand All @@ -139,16 +174,16 @@ HostLink 只辅助 ROS2 组网。设备 Action、节点注册和资源同步仍
| `--hostlink-port` | Host + Slave | `7302` | HostLink TCP 监听/连接端口;优先于 `--host-node-ip` 中的端口 |
| `--hostlink-bind` | Host | `0.0.0.0` | HostLink 监听网卡 |
| `--hostlink-advertise-ip` | Host | 自动探测 | 多网卡时发布给 Slave 的可达 IP |
| `--disable-hostlink` | Host + Slave | 否 | 禁用 HostLink,回退原 ROS2 发现 |
| `--disable-hostlink` | Host + Slave | 否 | 仅 ROS2 可用:禁用 HostLink 并回退原 ROS2 发现;不能和 `--backend hostlink` 同用 |
| `--hostlink-heartbeat-interval` | Slave | `5` 秒 | 心跳发送间隔 |
| `--hostlink-heartbeat-timeout` | Host | `15` 秒 | Slave 离线判定时间 |
| `--hostlink-connect-timeout` | Slave | `5` 秒 | 单次 TCP 连接和握手超时 |
| `--hostlink-request-timeout` | Slave | `10` 秒 | 控制请求超时 |
| `--hostlink-request-timeout` | Host + Slave | `10` 秒 | 控制请求/设备 RPC 超时 |
| `--ros-domain-id` | Host + Slave | 环境值 | Host 下发给 Slave;Slave 本地值仅作连接前兜底 |
| `--ros-discovery-range` | Host | 环境值 | `SYSTEM_DEFAULT/SUBNET/LOCALHOST/OFF` |
| `--ros-static-peers` | Host | 自动加入 Host IP | 分号分隔的静态发现对端 |
| `--ros-discovery-server` | Host | 环境值 | 外部 Fast DDS `host:port`;`off` 清除继承值 |
| `--no-ros-assist` | Slave | 否 | 保留 HostLink 心跳/设备发现,但不应用 Host ROS 参数 |
| `--no-ros-assist` | ROS2 Slave | 否 | 保留 HostLink 心跳/设备发现,但不应用 Host ROS 参数 |

本切片没有启动 Fast DDS Discovery Server 进程,因此没有
`--ros-discovery-port`;该参数应与托管 Discovery Server 功能一并引入,不能成为
Expand Down Expand Up @@ -434,8 +469,9 @@ unilab -g host.json --ros-domain-id 42 \
**建议做法**:

HostLink 需要 Slave 能访问 Host 的 TCP `7302`(若在 `--host-node-ip` 中指定
其他端口,则开放对应端口)。该端口只承载组网握手、心跳和设备 ID,不承载设备
动作或物料数据。
其他端口,则开放对应端口)。ROS2 backend 下该端口只承载组网控制;HostLink
backend 下还承载设备描述、状态、JSON Topic、动作 RPC 和物料树同步,
但不承载浏览器流量。

为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。

Expand Down
2 changes: 1 addition & 1 deletion docs/user_guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ unilab --help

```
usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH]
[--working_dir WORKING_DIR] [--backend {ros,simple,automancer}]
[--working_dir WORKING_DIR] [--backend {basic,hostlink,ros2,dora}]
...
```

Expand Down
Loading
Loading