Compare commits
61 Commits
d266d21104
...
v0.10.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.10
|
||||
version: 0.10.13
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
recursive-include unilabos/test *
|
||||
recursive-include unilabos/registry *.yaml
|
||||
recursive-include unilabos/app/web/static *
|
||||
recursive-include unilabos/app/web/templates *
|
||||
|
||||
@@ -39,7 +39,9 @@ Uni-Lab-OS recommends using `mamba` for environment management. Choose the appro
|
||||
|
||||
```bash
|
||||
# Create new environment
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## Install Dev Uni-Lab-OS
|
||||
|
||||
@@ -41,7 +41,9 @@ Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适
|
||||
|
||||
```bash
|
||||
# 创建新环境
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
mamba create -n unilab python=3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. 安装开发版 Uni-Lab-OS:
|
||||
|
||||
726
docs/advanced_usage/configuration.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
本文档详细介绍 Uni-Lab 配置文件的结构、配置项、命令行覆盖和环境变量的使用方法。
|
||||
|
||||
## 配置文件概述
|
||||
|
||||
Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_data/local_config.py`。配置文件采用类属性的方式定义各种配置项,比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
## 获取实验室密钥
|
||||
|
||||
在配置文件或启动命令中,您需要提供实验室的访问密钥(ak)和私钥(sk)。
|
||||
|
||||
**获取方式:**
|
||||
|
||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
|
||||

|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 默认配置示例
|
||||
|
||||
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`:
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码
|
||||
sk = "" # 实验室网页给您提供的sk代码
|
||||
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
您可以根据需要添加更多配置选项:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
# 基础配置
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室访问密钥
|
||||
sk = "" # 实验室私钥
|
||||
working_dir = "" # 工作目录(通常自动设置)
|
||||
config_path = "" # 配置文件路径(自动设置)
|
||||
is_host_mode = True # 是否为主站模式
|
||||
slave_no_host = False # 从站模式下是否跳过等待主机服务
|
||||
upload_registry = False # 是否上传注册表
|
||||
machine_name = "undefined" # 机器名称(自动获取)
|
||||
vis_2d_enable = False # 是否启用2D可视化
|
||||
enable_resource_load = True # 是否启用资源加载
|
||||
communication_protocol = "websocket" # 通信协议
|
||||
log_level = "DEBUG" # 日志级别:TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
] # 需要加载的ROS模块
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
配置项的生效优先级从高到低为:
|
||||
|
||||
1. **命令行参数**:最高优先级
|
||||
2. **环境变量**:中等优先级
|
||||
3. **配置文件**:基础优先级
|
||||
|
||||
这意味着命令行参数会覆盖环境变量和配置文件,环境变量会覆盖配置文件。
|
||||
|
||||
## 推荐配置方式
|
||||
|
||||
根据参数特性,不同配置项有不同的推荐配置方式:
|
||||
|
||||
### 建议通过命令行指定的参数(不需要写入配置文件)
|
||||
|
||||
以下参数推荐通过命令行或环境变量指定,**一般不需要在配置文件中配置**:
|
||||
|
||||
| 参数 | 命令行参数 | 原因 |
|
||||
| ----------------- | ------------------- | ------------------------------------ |
|
||||
| `ak` / `sk` | `--ak` / `--sk` | **安全考虑**:避免敏感信息泄露 |
|
||||
| `working_dir` | `--working_dir` | **灵活性**:不同环境可能使用不同目录 |
|
||||
| `is_host_mode` | `--is_slave` | **运行模式**:由启动场景决定,不固定 |
|
||||
| `slave_no_host` | `--slave_no_host` | **运行模式**:从站特殊配置,按需使用 |
|
||||
| `upload_registry` | `--upload_registry` | **临时操作**:仅首次启动或更新时需要 |
|
||||
| `vis_2d_enable` | `--2d_vis` | **调试功能**:按需临时启用 |
|
||||
| `remote_addr` | `--addr` | **环境切换**:测试/生产环境快速切换 |
|
||||
|
||||
**推荐用法示例:**
|
||||
|
||||
```bash
|
||||
# 标准启动命令(所有必要参数通过命令行指定)
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 测试环境
|
||||
unilab --addr test --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 从站模式
|
||||
unilab --is_slave --ak your_ak --sk your_sk
|
||||
|
||||
# 首次启动上传注册表
|
||||
unilab --ak your_ak --sk your_sk -g graph.json --upload_registry
|
||||
```
|
||||
|
||||
### 适合在配置文件中配置的参数
|
||||
|
||||
以下参数适合在配置文件中配置,通常不会频繁更改:
|
||||
|
||||
| 参数 | 配置类 | 说明 |
|
||||
| ------------------------ | ----------- | ---------------------- |
|
||||
| `log_level` | BasicConfig | 日志级别配置 |
|
||||
| `reconnect_interval` | WSConfig | WebSocket 重连间隔 |
|
||||
| `max_reconnect_attempts` | WSConfig | WebSocket 最大重连次数 |
|
||||
| `ping_interval` | WSConfig | WebSocket 心跳间隔 |
|
||||
| `modules` | ROSConfig | ROS 模块列表 |
|
||||
|
||||
**配置文件示例(推荐最小配置):**
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
log_level = "INFO" # 生产环境建议 INFO,调试时用 DEBUG
|
||||
|
||||
# WebSocket配置,一般保持默认即可
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
**注意:** `ak` 和 `sk` 不建议写在配置文件中,始终通过命令行参数或环境变量传递。
|
||||
|
||||
## 命令行参数覆盖配置
|
||||
|
||||
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。
|
||||
|
||||
### 支持命令行覆盖的配置项
|
||||
|
||||
| 配置类 | 配置字段 | 命令行参数 | 说明 |
|
||||
| ------------- | ----------------- | ------------------- | -------------------------------- |
|
||||
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
|
||||
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
|
||||
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
|
||||
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
|
||||
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
|
||||
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
|
||||
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
|
||||
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
|
||||
|
||||
### 特殊命令行参数
|
||||
|
||||
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `--config` | 指定配置文件路径 |
|
||||
| `--port` | Web 服务端口(不影响配置文件) |
|
||||
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
|
||||
| `--visual` | 可视化工具选择(不影响配置文件) |
|
||||
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
|
||||
|
||||
### 命令行覆盖使用示例
|
||||
|
||||
```bash
|
||||
# 通过命令行覆盖认证信息
|
||||
unilab --ak "new_access_key" --sk "new_secret_key" -g graph.json
|
||||
|
||||
# 覆盖服务器地址
|
||||
unilab --ak ak --sk sk --addr "https://custom.server.com/api/v1" -g graph.json
|
||||
|
||||
# 启用从站模式并跳过等待主机
|
||||
unilab --is_slave --slave_no_host --ak ak --sk sk
|
||||
|
||||
# 启用上传注册表和2D可视化
|
||||
unilab --upload_registry --2d_vis --ak ak --sk sk -g graph.json
|
||||
|
||||
# 组合使用多个覆盖参数
|
||||
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g graph.json
|
||||
```
|
||||
|
||||
### 预设环境地址
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
## 配置选项详解
|
||||
|
||||
### 1. BasicConfig - 基础配置
|
||||
|
||||
基础配置包含了系统运行的核心参数:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------------- | ------------------------------------------ |
|
||||
| `ak` | str | `""` | 实验室访问密钥(必需) |
|
||||
| `sk` | str | `""` | 实验室私钥(必需) |
|
||||
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
|
||||
| `config_path` | str | `""` | 配置文件路径,自动设置 |
|
||||
| `is_host_mode` | bool | `True` | 是否为主站模式 |
|
||||
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
|
||||
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
|
||||
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
|
||||
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
|
||||
| `enable_resource_load` | bool | `True` | 是否启用资源加载 |
|
||||
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
|
||||
| `log_level` | str | `"DEBUG"` | 日志级别 |
|
||||
|
||||
#### 日志级别选项
|
||||
|
||||
- `TRACE` - 追踪级别(最详细)
|
||||
- `DEBUG` - 调试级别(默认)
|
||||
- `INFO` - 信息级别
|
||||
- `WARNING` - 警告级别
|
||||
- `ERROR` - 错误级别
|
||||
- `CRITICAL` - 严重错误级别(最简略)
|
||||
|
||||
#### 认证配置(ak / sk)
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
- **配置文件**:在 `BasicConfig` 类中设置(不推荐,安全风险)
|
||||
3. **安全注意**:请妥善保管您的密钥信息,不要提交到版本控制
|
||||
|
||||
**推荐做法**:
|
||||
|
||||
- **开发环境**:使用命令行参数或环境变量
|
||||
- **生产环境**:使用环境变量
|
||||
- **临时测试**:使用命令行参数
|
||||
|
||||
### 2. WSConfig - WebSocket 配置
|
||||
|
||||
WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------ | ------------------ |
|
||||
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
|
||||
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
|
||||
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
|
||||
|
||||
### 3. HTTPConfig - HTTP 配置
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### 4. ROSConfig - ROS 配置
|
||||
|
||||
配置 ROS 消息转换器需要加载的模块:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
| --------- | ---- | ---------- | ------------ |
|
||||
| `modules` | list | 见下方示例 | ROS 模块列表 |
|
||||
|
||||
**默认模块列表:**
|
||||
|
||||
```python
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg", # 标准消息类型
|
||||
"geometry_msgs.msg", # 几何消息类型
|
||||
"control_msgs.msg", # 控制消息类型
|
||||
"control_msgs.action", # 控制动作类型
|
||||
"nav2_msgs.action", # 导航动作类型
|
||||
"unilabos_msgs.msg", # UniLab 自定义消息类型
|
||||
"unilabos_msgs.action", # UniLab 自定义动作类型
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据实际使用的设备和功能添加其他 ROS 模块。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。
|
||||
|
||||
### 环境变量命名规则
|
||||
|
||||
```
|
||||
UNILABOS_<配置类名>_<配置项名>
|
||||
```
|
||||
|
||||
**注意:**
|
||||
|
||||
- 环境变量名不区分大小写
|
||||
- 配置类名和配置项名都会转换为大写进行匹配
|
||||
|
||||
### 设置环境变量
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
# 临时设置(当前终端)
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
|
||||
# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc)
|
||||
echo 'export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
#### Windows (cmd)
|
||||
|
||||
```cmd
|
||||
# 临时设置
|
||||
set UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
set UNILABOS_BASICCONFIG_AK=your_access_key
|
||||
|
||||
# 永久设置(系统环境变量)
|
||||
setx UNILABOS_BASICCONFIG_LOG_LEVEL INFO
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 临时设置
|
||||
$env:UNILABOS_BASICCONFIG_LOG_LEVEL="INFO"
|
||||
$env:UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
|
||||
# 永久设置
|
||||
[Environment]::SetEnvironmentVariable("UNILABOS_BASICCONFIG_LOG_LEVEL", "INFO", "User")
|
||||
```
|
||||
|
||||
### 环境变量类型转换
|
||||
|
||||
系统会根据配置项的原始类型自动转换环境变量值:
|
||||
|
||||
| 原始类型 | 转换规则 |
|
||||
| -------- | --------------------------------------- |
|
||||
| `bool` | "true", "1", "yes" → True;其他 → False |
|
||||
| `int` | 转换为整数 |
|
||||
| `float` | 转换为浮点数 |
|
||||
| `str` | 直接使用字符串值 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 布尔值
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE=true # 将设置为 True
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE=false # 将设置为 False
|
||||
|
||||
# 整数
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 # 将设置为 10
|
||||
|
||||
# 字符串
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO # 将设置为 "INFO"
|
||||
```
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```bash
|
||||
# 设置基础配置
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
|
||||
|
||||
# 设置WebSocket配置
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
## 配置文件使用方法
|
||||
|
||||
### 1. 使用默认配置文件(推荐)
|
||||
|
||||
系统会自动查找并加载配置文件:
|
||||
|
||||
```bash
|
||||
# 直接启动,使用默认的 unilabos_data/local_config.py
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
查找顺序:
|
||||
|
||||
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
|
||||
2. 工作目录下的 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 2. 指定配置文件启动
|
||||
|
||||
```bash
|
||||
# 使用指定配置文件启动
|
||||
unilab --config /path/to/your/config.py --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
### 3. 配置文件验证
|
||||
|
||||
系统启动时会自动验证配置文件:
|
||||
|
||||
- **语法检查**:确保 Python 语法正确
|
||||
- **类型检查**:验证配置项类型是否匹配
|
||||
- **加载确认**:控制台输出加载成功信息
|
||||
|
||||
## 常用配置场景
|
||||
|
||||
### 场景 1:调整日志级别
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class BasicConfig:
|
||||
log_level = "INFO" # 生产环境建议使用 INFO 或 WARNING
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
unilab --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
**命令行方式**(需要配置文件已包含):
|
||||
|
||||
```bash
|
||||
# 配置文件无直接命令行参数,需通过环境变量
|
||||
UNILABOS_BASICCONFIG_LOG_LEVEL=INFO unilab --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
### 场景 2:配置 WebSocket 重连
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class WSConfig:
|
||||
reconnect_interval = 10 # 增加重连间隔到 10 秒
|
||||
max_reconnect_attempts = 100 # 减少最大重连次数到 100 次
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
||||
```
|
||||
|
||||
### 场景 3:切换服务器环境
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
```bash
|
||||
unilab --addr test --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
### 场景 4:从站模式配置
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class BasicConfig:
|
||||
is_host_mode = False # 从站模式
|
||||
slave_no_host = True # 不等待主机服务
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
```bash
|
||||
unilab --is_slave --slave_no_host --ak your_ak --sk your_sk
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全配置
|
||||
|
||||
**不要在配置文件中存储敏感信息**
|
||||
|
||||
- ❌ **不推荐**:在配置文件中明文存储 ak/sk
|
||||
- ✅ **推荐**:使用环境变量或命令行参数
|
||||
|
||||
```bash
|
||||
# 生产环境 - 使用环境变量(推荐)
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
unilab -g graph.json
|
||||
|
||||
# 或使用命令行参数
|
||||
unilab --ak "your_access_key" --sk "your_secret_key" -g graph.json
|
||||
```
|
||||
|
||||
**其他安全建议:**
|
||||
|
||||
- 不要将包含密钥的配置文件提交到版本控制系统
|
||||
- 限制配置文件权限:`chmod 600 local_config.py`
|
||||
- 定期更换访问密钥
|
||||
- 使用 `.gitignore` 排除配置文件
|
||||
|
||||
### 2. 多环境配置
|
||||
|
||||
为不同环境创建不同的配置文件:
|
||||
|
||||
```
|
||||
configs/
|
||||
├── base_config.py # 基础配置(非敏感)
|
||||
├── dev_config.py # 开发环境
|
||||
├── test_config.py # 测试环境
|
||||
├── prod_config.py # 生产环境
|
||||
└── example_config.py # 示例配置
|
||||
```
|
||||
|
||||
**环境切换示例**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境
|
||||
unilab --config configs/dev_config.py --addr local --ak ak --sk sk -g graph.json
|
||||
|
||||
# 测试环境
|
||||
unilab --config configs/test_config.py --addr test --ak ak --sk sk --upload_registry -g graph.json
|
||||
|
||||
# 生产环境
|
||||
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -g graph.json
|
||||
```
|
||||
|
||||
### 3. 配置管理
|
||||
|
||||
**配置文件最佳实践:**
|
||||
|
||||
- 保持配置文件简洁,只包含需要修改的配置项
|
||||
- 为配置项添加注释说明其作用
|
||||
- 定期检查和更新配置文件
|
||||
- 版本控制仅保存示例配置,不包含实际密钥
|
||||
|
||||
**命令行参数优先使用场景:**
|
||||
|
||||
- 临时测试不同配置
|
||||
- CI/CD 流水线中的动态配置
|
||||
- 不同环境间快速切换
|
||||
- 敏感信息的安全传递
|
||||
|
||||
### 4. 灵活配置策略
|
||||
|
||||
**基础配置文件 + 命令行覆盖**的推荐方式:
|
||||
|
||||
```python
|
||||
# base_config.py - 基础配置(非敏感信息)
|
||||
class BasicConfig:
|
||||
# 非敏感配置写在文件中
|
||||
is_host_mode = True
|
||||
upload_registry = False
|
||||
vis_2d_enable = False
|
||||
log_level = "INFO"
|
||||
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动时通过命令行覆盖关键参数
|
||||
unilab --config base_config.py \
|
||||
--ak "$AK" \
|
||||
--sk "$SK" \
|
||||
--addr "test" \
|
||||
--upload_registry \
|
||||
--2d_vis \
|
||||
-g graph.json
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 配置文件加载失败
|
||||
|
||||
**错误信息**:`[ENV] 配置文件 xxx 不存在`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认配置文件路径正确
|
||||
- 检查文件权限是否可读
|
||||
- 确保配置文件是 `.py` 格式
|
||||
- 使用绝对路径或相对于当前目录的路径
|
||||
|
||||
### 2. 语法错误
|
||||
|
||||
**错误信息**:`[ENV] 加载配置文件 xxx 失败`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 检查 Python 语法是否正确
|
||||
- 确认类名和字段名拼写正确
|
||||
- 验证缩进是否正确(使用空格而非制表符)
|
||||
- 确保字符串使用引号包裹
|
||||
|
||||
### 3. 认证失败
|
||||
|
||||
**错误信息**:`后续运行必须拥有一个实验室`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认 `ak` 和 `sk` 已正确配置
|
||||
- 检查密钥是否有效(未过期或撤销)
|
||||
- 确认网络连接正常
|
||||
- 验证密钥是否来自正确的实验室
|
||||
|
||||
### 4. 环境变量不生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认环境变量名格式正确(`UNILABOS_<类名>_<字段名>`)
|
||||
- 检查环境变量是否已正确设置(`echo $VARIABLE_NAME`)
|
||||
- 重启终端或重新加载环境变量
|
||||
- 确认环境变量值的类型正确
|
||||
|
||||
### 5. 命令行参数不生效
|
||||
|
||||
**错误现象**:设置了命令行参数但配置没有生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`)
|
||||
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
|
||||
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
|
||||
- 查看启动日志确认参数是否被正确解析
|
||||
- 检查是否有配置文件或环境变量与之冲突
|
||||
|
||||
### 6. 配置优先级混淆
|
||||
|
||||
**错误现象**:不确定哪个配置生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 记住优先级:**命令行参数 > 环境变量 > 配置文件**
|
||||
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息:"传入了 ak 参数,优先采用传入参数!"
|
||||
- 检查启动日志中的配置加载信息
|
||||
- 临时移除低优先级配置来测试高优先级配置是否生效
|
||||
- 使用 `printenv | grep UNILABOS` 查看所有相关环境变量
|
||||
|
||||
## 配置验证
|
||||
|
||||
### 检查配置是否生效
|
||||
|
||||
启动 Uni-Lab 时,控制台会输出配置加载信息:
|
||||
|
||||
```
|
||||
[ENV] 配置文件 /path/to/config.py 加载成功
|
||||
[ENV] 设置 BasicConfig.log_level = INFO
|
||||
传入了ak参数,优先采用传入参数!
|
||||
传入了sk参数,优先采用传入参数!
|
||||
```
|
||||
|
||||
### 常见配置错误
|
||||
|
||||
1. **配置文件格式错误**
|
||||
|
||||
```
|
||||
[ENV] 加载配置文件 /path/to/config.py 失败
|
||||
```
|
||||
|
||||
**解决方案**:检查 Python 语法,确保配置类定义正确
|
||||
|
||||
2. **环境变量格式错误**
|
||||
|
||||
```
|
||||
[ENV] 环境变量格式不正确:UNILABOS_INVALID_VAR
|
||||
```
|
||||
|
||||
**解决方案**:确保环境变量遵循 `UNILABOS_<类名>_<字段名>` 格式
|
||||
|
||||
3. **类或字段不存在**
|
||||
```
|
||||
[ENV] 未找到类:UNKNOWNCONFIG
|
||||
[ENV] 类 BasicConfig 中未找到字段:UNKNOWN_FIELD
|
||||
```
|
||||
**解决方案**:检查配置类名和字段名是否正确
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [工作目录详解](working_directory.md)
|
||||
- [启动参数详解](../user_guide/launch.md)
|
||||
- [快速安装指南](../user_guide/quick_install_guide.md)
|
||||
BIN
docs/advanced_usage/image/copy_aksk.gif
Normal file
|
After Width: | Height: | Size: 526 KiB |
218
docs/advanced_usage/working_directory.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 工作目录详解
|
||||
|
||||
本文档详细介绍 Uni-Lab 工作目录(`working_dir`)的判断逻辑和详细用法。
|
||||
|
||||
## 什么是工作目录
|
||||
|
||||
工作目录是 Uni-Lab 存储配置文件、日志和运行数据的目录。默认情况下,工作目录为 `当前目录/unilabos_data`。
|
||||
|
||||
## 工作目录判断逻辑
|
||||
|
||||
系统按以下决策树自动确定工作目录:
|
||||
|
||||
### 第一步:初始判断
|
||||
|
||||
```python
|
||||
# 检查当前目录
|
||||
if 当前目录以 "unilabos_data" 结尾:
|
||||
working_dir = 当前目录的绝对路径
|
||||
else:
|
||||
working_dir = 当前目录/unilabos_data
|
||||
```
|
||||
|
||||
**解释:**
|
||||
- 如果您已经在 `unilabos_data` 目录内启动,系统直接使用当前目录
|
||||
- 否则,系统会在当前目录下创建或使用 `unilabos_data` 子目录
|
||||
|
||||
### 第二步:处理 `--working_dir` 参数
|
||||
|
||||
如果用户指定了 `--working_dir` 参数:
|
||||
|
||||
```python
|
||||
working_dir = 用户指定的路径
|
||||
```
|
||||
|
||||
此时还会检查配置文件:
|
||||
- 如果同时指定了 `--config` 但该文件不存在
|
||||
- 系统会尝试在 `working_dir/local_config.py` 查找
|
||||
- 如果仍未找到,报错退出
|
||||
|
||||
### 第三步:处理 `--config` 参数
|
||||
|
||||
如果用户指定了 `--config` 且文件存在:
|
||||
|
||||
```python
|
||||
# 工作目录改为配置文件所在目录
|
||||
working_dir = config_path 的父目录
|
||||
```
|
||||
|
||||
**重要:** 这意味着配置文件的位置会影响工作目录的判断。
|
||||
|
||||
## 使用场景示例
|
||||
|
||||
### 场景 1:默认场景(推荐)
|
||||
|
||||
```bash
|
||||
# 当前目录:/home/user/project
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /home/user/project/unilabos_data
|
||||
# config_path = /home/user/project/unilabos_data/local_config.py
|
||||
```
|
||||
|
||||
### 场景 2:在 unilabos_data 目录内启动
|
||||
|
||||
```bash
|
||||
cd /home/user/project/unilabos_data
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /home/user/project/unilabos_data
|
||||
# config_path = /home/user/project/unilabos_data/local_config.py
|
||||
```
|
||||
|
||||
### 场景 3:手动指定工作目录
|
||||
|
||||
```bash
|
||||
unilab --working_dir /custom/path --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /custom/path
|
||||
# config_path = /custom/path/local_config.py (如果存在)
|
||||
```
|
||||
|
||||
### 场景 4:通过配置文件路径推断工作目录
|
||||
|
||||
```bash
|
||||
unilab --config /data/lab_a/local_config.py --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /data/lab_a
|
||||
# config_path = /data/lab_a/local_config.py
|
||||
```
|
||||
|
||||
## 高级用法:管理多个实验室配置
|
||||
|
||||
### 方法 1:使用不同的工作目录
|
||||
|
||||
```bash
|
||||
# 实验室 A
|
||||
unilab --working_dir ~/labs/lab_a --ak ak_a --sk sk_a -g graph_a.json
|
||||
|
||||
# 实验室 B
|
||||
unilab --working_dir ~/labs/lab_b --ak ak_b --sk sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
### 方法 2:使用不同的配置文件
|
||||
|
||||
```bash
|
||||
# 实验室 A
|
||||
unilab --config ~/labs/lab_a/config.py --ak ak_a --sk sk_a -g graph_a.json
|
||||
|
||||
# 实验室 B
|
||||
unilab --config ~/labs/lab_b/config.py --ak ak_b --sk sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
### 方法 3:使用shell脚本管理
|
||||
|
||||
创建 `start_lab_a.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd ~/labs/lab_a
|
||||
unilab --ak your_ak_a --sk your_sk_a -g graph_a.json
|
||||
```
|
||||
|
||||
创建 `start_lab_b.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd ~/labs/lab_b
|
||||
unilab --ak your_ak_b --sk your_sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
## 完整决策流程图
|
||||
|
||||
```
|
||||
开始
|
||||
↓
|
||||
判断当前目录是否以 unilabos_data 结尾?
|
||||
├─ 是 → working_dir = 当前目录
|
||||
└─ 否 → working_dir = 当前目录/unilabos_data
|
||||
↓
|
||||
用户是否指定 --working_dir?
|
||||
└─ 是 → working_dir = 指定路径
|
||||
↓
|
||||
用户是否指定 --config 且文件存在?
|
||||
└─ 是 → working_dir = config 文件所在目录
|
||||
↓
|
||||
检查 working_dir/local_config.py 是否存在?
|
||||
├─ 是 → 加载配置文件 → 继续启动
|
||||
└─ 否 → 询问是否首次使用
|
||||
├─ 是 → 创建目录和配置文件 → 继续启动
|
||||
└─ 否 → 退出程序
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 如何查看当前使用的工作目录?
|
||||
|
||||
启动 Uni-Lab 时,系统会在控制台输出:
|
||||
|
||||
```
|
||||
当前工作目录为 /path/to/working_dir
|
||||
```
|
||||
|
||||
### 2. 可以在同一台机器上运行多个实验室吗?
|
||||
|
||||
可以。使用不同的工作目录或配置文件即可:
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
unilab --working_dir ~/lab1 --ak ak1 --sk sk1 -g graph1.json
|
||||
|
||||
# 终端 2
|
||||
unilab --working_dir ~/lab2 --ak ak2 --sk sk2 -g graph2.json
|
||||
```
|
||||
|
||||
### 3. 工作目录中存储了什么?
|
||||
|
||||
- `local_config.py` - 配置文件
|
||||
- 日志文件
|
||||
- 临时运行数据
|
||||
- 缓存文件
|
||||
|
||||
### 4. 可以删除工作目录吗?
|
||||
|
||||
可以,但会丢失:
|
||||
- 配置文件(需要重新创建)
|
||||
- 历史日志
|
||||
- 缓存数据
|
||||
|
||||
建议定期备份配置文件。
|
||||
|
||||
### 5. 如何迁移到新的工作目录?
|
||||
|
||||
```bash
|
||||
# 1. 复制旧的工作目录
|
||||
cp -r ~/old_path/unilabos_data ~/new_path/unilabos_data
|
||||
|
||||
# 2. 在新位置启动
|
||||
cd ~/new_path
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用默认工作目录**:对于单一实验室,使用默认的 `./unilabos_data` 即可
|
||||
2. **组织多实验室**:为每个实验室创建独立的目录结构
|
||||
3. **版本控制**:将配置文件纳入版本控制,但排除日志和缓存
|
||||
4. **备份配置**:定期备份 `local_config.py` 文件
|
||||
5. **使用脚本**:为不同实验室创建启动脚本,简化操作
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [配置文件指南](configuration.md)
|
||||
- [启动参数详解](../user_guide/launch.md)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(instructions)=
|
||||
# 设备抽象、指令集与通信中间件
|
||||
|
||||
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
Uni-Lab-OS的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
|
||||
## 设备间通信模式
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# 添加新设备
|
||||
# 添加设备:编写驱动
|
||||
|
||||
在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
|
||||
在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的这三种机制映射到 Uni-Lab 标准指令集上。
|
||||
|
||||
能被 Uni-Lab 添加的驱动程序类型有以下种类:
|
||||
> **💡 提示:** 本文档介绍如何使用已有的设备驱动(SDK)。若设备没有现成的驱动程序,需要自己开发驱动,请参考 {doc}`add_old_device`。
|
||||
|
||||
1. Python Class,如
|
||||
## 支持的驱动类型
|
||||
|
||||
Uni-Lab 支持以下两种驱动程序:
|
||||
|
||||
### 1. Python Class(推荐)
|
||||
|
||||
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,无需额外编译。
|
||||
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
class MockGripper:
|
||||
@@ -31,12 +39,11 @@ class MockGripper:
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
@status.setter
|
||||
def status(self, target):
|
||||
self._status = target
|
||||
|
||||
# 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
def push_to(self, position: float, torque: float, velocity: float = 0.0):
|
||||
self._status = "Running"
|
||||
current_pos = self.position
|
||||
@@ -53,9 +60,11 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
```
|
||||
|
||||
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。
|
||||
### 2. C# Class
|
||||
|
||||
2. C# Class,如
|
||||
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用(仅需一次)。
|
||||
|
||||
**示例:**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
@@ -84,7 +93,7 @@ public class MockGripper
|
||||
position = currentPos + (Position - currentPos) / 20 * (i + 1);
|
||||
torque = Torque / (20 - i);
|
||||
velocity = Velocity;
|
||||
await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds
|
||||
await Task.Delay((int)(moveTime * 1000 / 20));
|
||||
}
|
||||
torque = Torque;
|
||||
status = "Idle";
|
||||
@@ -92,12 +101,16 @@ public class MockGripper
|
||||
}
|
||||
```
|
||||
|
||||
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
|
||||
---
|
||||
|
||||
## 快速开始:使用注册表编辑器(推荐)
|
||||
## 快速开始:两种方式添加设备
|
||||
|
||||
### 方式 1:使用注册表编辑器(推荐)
|
||||
|
||||
推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置:
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 启动 Uni-Lab-OS
|
||||
2. 在浏览器中打开"注册表编辑器"页面
|
||||
3. 选择您的 Python 设备驱动文件
|
||||
@@ -106,13 +119,18 @@ C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能
|
||||
6. 点击"生成注册表",复制生成的内容
|
||||
7. 保存到 `devices/` 目录下
|
||||
|
||||
---
|
||||
**优点:**
|
||||
|
||||
## 手动编写注册表(简化版)
|
||||
- 自动识别设备属性和方法
|
||||
- 可视化界面,易于操作
|
||||
- 自动生成完整配置
|
||||
- 减少手动配置错误
|
||||
|
||||
### 方式 2:手动编写注册表(简化版)
|
||||
|
||||
如果需要手动编写,只需要提供两个必需字段,系统会自动补全其余内容:
|
||||
|
||||
### 最小配置示例
|
||||
**最小配置示例:**
|
||||
|
||||
```yaml
|
||||
my_device: # 设备唯一标识符
|
||||
@@ -121,22 +139,22 @@ my_device: # 设备唯一标识符
|
||||
type: python # 驱动类型
|
||||
```
|
||||
|
||||
### 注册表文件位置
|
||||
**注册表文件位置:**
|
||||
|
||||
- 默认路径:`unilabos/registry/devices`
|
||||
- 自定义路径:启动时使用 `--registry` 参数指定
|
||||
- 可将多个设备写在同一个 yaml 文件中
|
||||
- 自定义路径:启动时使用 `--registry_path` 参数指定
|
||||
- 可将多个设备写在同一个 YAML 文件中
|
||||
|
||||
### 系统自动生成的内容
|
||||
**系统自动生成的内容:**
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `get_*` 方法自动识别状态属性
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
|
||||
### 完整结构概览
|
||||
**完整结构概览:**
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
@@ -151,4 +169,848 @@ my_device:
|
||||
schema: {} # 自动生成
|
||||
```
|
||||
|
||||
详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 <add_yaml>`。
|
||||
> 💡 **提示:** 详细的注册表编写指南和高级配置,请参考 {doc}`03_add_device_registry`。
|
||||
|
||||
---
|
||||
|
||||
## Python 类结构要求
|
||||
|
||||
Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
说明设备的功能、连接方式等
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""初始化设备
|
||||
|
||||
Args:
|
||||
config: 配置字典,来自图文件或注册表
|
||||
"""
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
self.baudrate = config.get('baudrate', 9600)
|
||||
self._status = "idle"
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""设备状态(会自动广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
"""执行动作
|
||||
|
||||
Args:
|
||||
param: 参数说明
|
||||
|
||||
Returns:
|
||||
{"success": True, "result": ...}
|
||||
"""
|
||||
# 执行设备操作
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property)
|
||||
|
||||
状态属性会被自动识别并定期广播:
|
||||
|
||||
```python
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
**特点**:
|
||||
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
|
||||
```python
|
||||
def start_heating(self, target_temp: float, rate: float = 1.0) -> Dict[str, Any]:
|
||||
"""开始加热
|
||||
|
||||
Args:
|
||||
target_temp: 目标温度(°C)
|
||||
rate: 升温速率(°C/min)
|
||||
|
||||
Returns:
|
||||
{"success": bool, "message": str}
|
||||
"""
|
||||
self._status = "heating"
|
||||
self._target_temp = target_temp
|
||||
# 发送命令到硬件
|
||||
return {"success": True, "message": f"Heating to {target_temp}°C"}
|
||||
|
||||
async def async_operation(self, duration: float) -> Dict[str, Any]:
|
||||
"""异步操作(长时间运行)
|
||||
|
||||
Args:
|
||||
duration: 持续时间(秒)
|
||||
"""
|
||||
# 使用 self.sleep 而不是 asyncio.sleep(ROS2 异步机制)
|
||||
await self.sleep(duration)
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
**特点**:
|
||||
|
||||
- 普通方法或 async 方法
|
||||
- 返回 Dict 类型的结果
|
||||
- 自动注册为 ROS2 Action
|
||||
- 支持参数和返回值
|
||||
|
||||
### 返回值设计指南
|
||||
|
||||
> **⚠️ 重要:返回值会自动显示在前端**
|
||||
>
|
||||
> 动作方法的返回值(字典)会自动显示在 Web 界面的工作流执行结果中。因此,**强烈建议**设计结构化、可读的返回值字典。
|
||||
|
||||
**推荐的返回值结构:**
|
||||
|
||||
```python
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
"""执行操作"""
|
||||
try:
|
||||
# 执行操作...
|
||||
result = self._do_something(param)
|
||||
|
||||
return {
|
||||
"success": True, # 必需:操作是否成功
|
||||
"message": "操作完成", # 推荐:用户友好的消息
|
||||
"result": result, # 可选:具体结果数据
|
||||
"param_used": param, # 可选:记录使用的参数
|
||||
# 其他有用的信息...
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "操作失败"
|
||||
}
|
||||
```
|
||||
|
||||
**最佳实践示例(参考 `host_node.test_latency`):**
|
||||
|
||||
```python
|
||||
def test_latency(self) -> Dict[str, Any]:
|
||||
"""测试网络延迟
|
||||
|
||||
返回值会在前端显示,包含详细的测试结果
|
||||
"""
|
||||
# 执行测试...
|
||||
avg_rtt_ms = 25.5
|
||||
avg_time_diff_ms = 10.2
|
||||
test_count = 5
|
||||
|
||||
# 返回结构化的测试结果
|
||||
return {
|
||||
"status": "success", # 状态标识
|
||||
"avg_rtt_ms": avg_rtt_ms, # 平均往返时间
|
||||
"avg_time_diff_ms": avg_time_diff_ms, # 平均时间差
|
||||
"max_time_error_ms": 5.3, # 最大误差
|
||||
"task_delay_ms": 15.7, # 任务延迟
|
||||
"test_count": test_count, # 测试次数
|
||||
}
|
||||
```
|
||||
|
||||
**前端显示效果:**
|
||||
|
||||
当用户在 Web 界面执行工作流时,返回的字典会以 JSON 格式显示在结果面板中:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"avg_rtt_ms": 25.5,
|
||||
"avg_time_diff_ms": 10.2,
|
||||
"max_time_error_ms": 5.3,
|
||||
"task_delay_ms": 15.7,
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**返回值设计建议:**
|
||||
|
||||
1. **始终包含 `success` 字段**:布尔值,表示操作是否成功
|
||||
2. **包含 `message` 字段**:字符串,提供用户友好的描述
|
||||
3. **使用有意义的键名**:使用描述性的键名(如 `avg_rtt_ms` 而不是 `v1`)
|
||||
4. **包含单位**:在键名中包含单位(如 `_ms`、`_ml`、`_celsius`)
|
||||
5. **记录重要参数**:返回使用的关键参数值,便于追溯
|
||||
6. **错误信息详细**:失败时包含 `error` 字段和详细的错误描述
|
||||
7. **避免返回大数据**:不要返回大型数组或二进制数据,这会影响前端性能
|
||||
|
||||
**错误处理示例:**
|
||||
|
||||
```python
|
||||
def risky_operation(self, param: float) -> Dict[str, Any]:
|
||||
"""可能失败的操作"""
|
||||
if param < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "参数不能为负数",
|
||||
"message": f"无效参数: {param}",
|
||||
"param": param
|
||||
}
|
||||
|
||||
try:
|
||||
result = self._execute(param)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "操作成功",
|
||||
"result": result,
|
||||
"param": param
|
||||
}
|
||||
except IOError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "通信错误",
|
||||
"message": str(e),
|
||||
"device_status": self._status
|
||||
}
|
||||
```
|
||||
|
||||
## 特殊参数类型:ResourceSlot 和 DeviceSlot
|
||||
|
||||
Uni-Lab 提供特殊的参数类型,用于在方法中声明需要选择资源或设备。
|
||||
|
||||
### 导入类型
|
||||
|
||||
```python
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from typing import List
|
||||
```
|
||||
|
||||
### ResourceSlot - 资源选择
|
||||
|
||||
用于需要选择物料资源的场景:
|
||||
|
||||
```python
|
||||
def pipette_liquid(
|
||||
self,
|
||||
source: ResourceSlot, # 单个源容器
|
||||
target: ResourceSlot, # 单个目标容器
|
||||
volume: float
|
||||
) -> Dict[str, Any]:
|
||||
"""从源容器吸取液体到目标容器
|
||||
|
||||
Args:
|
||||
source: 源容器(前端会显示资源选择下拉框)
|
||||
target: 目标容器(前端会显示资源选择下拉框)
|
||||
volume: 体积(μL)
|
||||
"""
|
||||
print(f"Pipetting {volume}μL from {source.id} to {target.id}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
**多选示例**:
|
||||
|
||||
```python
|
||||
def mix_multiple(
|
||||
self,
|
||||
containers: List[ResourceSlot], # 多个容器选择
|
||||
speed: float
|
||||
) -> Dict[str, Any]:
|
||||
"""混合多个容器
|
||||
|
||||
Args:
|
||||
containers: 容器列表(前端会显示多选下拉框)
|
||||
speed: 混合速度
|
||||
"""
|
||||
for container in containers:
|
||||
print(f"Mixing {container.name}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
### DeviceSlot - 设备选择
|
||||
|
||||
用于需要选择其他设备的场景:
|
||||
|
||||
```python
|
||||
def coordinate_with_device(
|
||||
self,
|
||||
other_device: DeviceSlot, # 单个设备选择
|
||||
command: str
|
||||
) -> Dict[str, Any]:
|
||||
"""与另一个设备协同工作
|
||||
|
||||
Args:
|
||||
other_device: 协同设备(前端会显示设备选择下拉框)
|
||||
command: 命令
|
||||
"""
|
||||
print(f"Coordinating with {other_device.name}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
**多设备示例**:
|
||||
|
||||
```python
|
||||
def sync_devices(
|
||||
self,
|
||||
devices: List[DeviceSlot], # 多个设备选择
|
||||
sync_signal: str
|
||||
) -> Dict[str, Any]:
|
||||
"""同步多个设备
|
||||
|
||||
Args:
|
||||
devices: 设备列表(前端会显示多选下拉框)
|
||||
sync_signal: 同步信号
|
||||
"""
|
||||
for dev in devices:
|
||||
print(f"Syncing {dev.name}")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
### 完整示例:液体处理工作站
|
||||
|
||||
```python
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from typing import List, Dict, Any
|
||||
|
||||
class LiquidHandler:
|
||||
"""液体处理工作站"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.simulation = config.get('simulation', False)
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
def transfer_liquid(
|
||||
self,
|
||||
source: ResourceSlot, # 源容器选择
|
||||
target: ResourceSlot, # 目标容器选择
|
||||
volume: float,
|
||||
tip: ResourceSlot = None # 可选的枪头选择
|
||||
) -> Dict[str, Any]:
|
||||
"""转移液体
|
||||
|
||||
前端效果:
|
||||
- source: 下拉框,列出所有可用容器
|
||||
- target: 下拉框,列出所有可用容器
|
||||
- volume: 数字输入框
|
||||
- tip: 下拉框(可选),列出所有枪头
|
||||
"""
|
||||
self._status = "transferring"
|
||||
|
||||
# source和target会被解析为实际的资源对象
|
||||
print(f"Transferring {volume}μL")
|
||||
print(f" From: {source.id} ({source.name})")
|
||||
print(f" To: {target.id} ({target.name})")
|
||||
|
||||
if tip:
|
||||
print(f" Using tip: {tip.id}")
|
||||
|
||||
# 执行实际的液体转移
|
||||
# ...
|
||||
|
||||
self._status = "idle"
|
||||
return {
|
||||
"success": True,
|
||||
"volume_transferred": volume,
|
||||
"source_id": source.id,
|
||||
"target_id": target.id
|
||||
}
|
||||
|
||||
def multi_dispense(
|
||||
self,
|
||||
source: ResourceSlot, # 单个源
|
||||
targets: List[ResourceSlot], # 多个目标
|
||||
volumes: List[float]
|
||||
) -> Dict[str, Any]:
|
||||
"""从一个源分配到多个目标
|
||||
|
||||
前端效果:
|
||||
- source: 单选下拉框
|
||||
- targets: 多选下拉框(可选择多个容器)
|
||||
- volumes: 数组输入(每个目标对应一个体积)
|
||||
"""
|
||||
results = []
|
||||
for target, vol in zip(targets, volumes):
|
||||
print(f"Dispensing {vol}μL to {target.name}")
|
||||
results.append({
|
||||
"target": target.id,
|
||||
"volume": vol
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"dispense_results": results
|
||||
}
|
||||
|
||||
def test_with_balance(
|
||||
self,
|
||||
target: ResourceSlot, # 容器
|
||||
balance: DeviceSlot # 天平设备
|
||||
) -> Dict[str, Any]:
|
||||
"""使用天平测量容器
|
||||
|
||||
前端效果:
|
||||
- target: 容器选择下拉框
|
||||
- balance: 设备选择下拉框(仅显示天平类型)
|
||||
"""
|
||||
print(f"Weighing {target.name} on {balance.name}")
|
||||
|
||||
# 可以调用balance的方法
|
||||
# weight = balance.get_weight()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"container": target.id,
|
||||
"balance_used": balance.id
|
||||
}
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
|
||||
#### 1. 类型识别
|
||||
|
||||
注册表扫描方法签名时:
|
||||
|
||||
```python
|
||||
def my_method(self, resource: ResourceSlot, device: DeviceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
系统识别到`ResourceSlot`和`DeviceSlot`类型。
|
||||
|
||||
#### 2. 自动添加 placeholder_keys
|
||||
|
||||
在注册表中自动生成:
|
||||
|
||||
```yaml
|
||||
my_device:
|
||||
class:
|
||||
action_value_mappings:
|
||||
my_method:
|
||||
goal:
|
||||
resource: resource
|
||||
device: device
|
||||
placeholder_keys:
|
||||
resource: unilabos_resources # 自动添加!
|
||||
device: unilabos_devices # 自动添加!
|
||||
```
|
||||
|
||||
#### 3. 前端 UI 生成
|
||||
|
||||
- `unilabos_resources`: 渲染为资源选择下拉框
|
||||
- `unilabos_devices`: 渲染为设备选择下拉框
|
||||
|
||||
#### 4. 运行时解析
|
||||
|
||||
用户选择资源/设备后,实际调用时会传入完整的资源/设备对象:
|
||||
|
||||
```python
|
||||
# 用户在前端选择了 plate_1
|
||||
# 运行时,source参数会收到完整的Resource对象
|
||||
source.id # "plate_1"
|
||||
source.name # "96孔板"
|
||||
source.type # "resource"
|
||||
source.class_ # "corning_96_wellplate_360ul_flat"
|
||||
```
|
||||
|
||||
## 支持的通信方式
|
||||
|
||||
### 1. 串口(Serial)
|
||||
|
||||
```python
|
||||
import serial
|
||||
|
||||
class SerialDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.port = config['port']
|
||||
self.baudrate = config.get('baudrate', 9600)
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1
|
||||
)
|
||||
|
||||
def send_command(self, cmd: str) -> str:
|
||||
"""发送命令并读取响应"""
|
||||
self.ser.write(f"{cmd}\r\n".encode())
|
||||
response = self.ser.readline().decode().strip()
|
||||
return response
|
||||
|
||||
def __del__(self):
|
||||
if hasattr(self, 'ser') and self.ser.is_open:
|
||||
self.ser.close()
|
||||
```
|
||||
|
||||
### 2. TCP/IP Socket
|
||||
|
||||
```python
|
||||
import socket
|
||||
|
||||
class TCPDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.host = config['host']
|
||||
self.port = config['port']
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.connect((self.host, self.port))
|
||||
|
||||
def send_command(self, cmd: str) -> str:
|
||||
self.sock.sendall(cmd.encode())
|
||||
response = self.sock.recv(1024).decode()
|
||||
return response
|
||||
```
|
||||
|
||||
### 3. Modbus
|
||||
|
||||
```python
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
class ModbusDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.host = config['host']
|
||||
self.port = config.get('port', 502)
|
||||
self.client = ModbusTcpClient(self.host, port=self.port)
|
||||
self.client.connect()
|
||||
|
||||
def read_register(self, address: int) -> int:
|
||||
result = self.client.read_holding_registers(address, 1)
|
||||
return result.registers[0]
|
||||
|
||||
def write_register(self, address: int, value: int):
|
||||
self.client.write_register(address, value)
|
||||
```
|
||||
|
||||
### 4. OPC UA
|
||||
|
||||
```python
|
||||
from opcua import Client
|
||||
|
||||
class OPCUADevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.url = config['url']
|
||||
self.client = Client(self.url)
|
||||
self.client.connect()
|
||||
|
||||
def read_node(self, node_id: str):
|
||||
node = self.client.get_node(node_id)
|
||||
return node.get_value()
|
||||
|
||||
def write_node(self, node_id: str, value):
|
||||
node = self.client.get_node(node_id)
|
||||
node.set_value(value)
|
||||
```
|
||||
|
||||
### 5. HTTP/RPC
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
class HTTPDevice:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.base_url = config['url']
|
||||
self.auth_token = config.get('token')
|
||||
|
||||
def send_command(self, endpoint: str, data: Dict) -> Dict:
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
headers = {'Authorization': f'Bearer {self.auth_token}'}
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## 异步 vs 同步方法
|
||||
|
||||
### 同步方法(适合快速操作)
|
||||
|
||||
```python
|
||||
def quick_operation(self, param: float) -> Dict[str, Any]:
|
||||
"""快速操作,立即返回"""
|
||||
result = self._do_something(param)
|
||||
return {"success": True, "result": result}
|
||||
```
|
||||
|
||||
### 异步方法(适合耗时操作)
|
||||
|
||||
```python
|
||||
async def long_operation(self, duration: float) -> Dict[str, Any]:
|
||||
"""长时间运行的操作"""
|
||||
self._status = "running"
|
||||
|
||||
# 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep)
|
||||
await self.sleep(duration)
|
||||
|
||||
# 可以在过程中发送feedback
|
||||
# 需要配合ROS2 Action的feedback机制
|
||||
|
||||
self._status = "idle"
|
||||
return {"success": True, "duration": duration}
|
||||
```
|
||||
|
||||
> **⚠️ 重要提示:ROS2 异步机制 vs Python asyncio**
|
||||
>
|
||||
> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。
|
||||
>
|
||||
> **不能使用的 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 回调机制
|
||||
>
|
||||
> **示例:**
|
||||
>
|
||||
> ```python
|
||||
> async def complex_operation(self, duration: float) -> Dict[str, Any]:
|
||||
> """正确使用 ROS2 异步方法"""
|
||||
> self._status = "processing"
|
||||
>
|
||||
> # ✅ 正确:使用 self.sleep
|
||||
> await self.sleep(duration)
|
||||
>
|
||||
> # ✅ 正确:创建并发任务
|
||||
> task = await self.create_task(self._background_work)
|
||||
>
|
||||
> # ❌ 错误:不要使用 asyncio
|
||||
> # await asyncio.sleep(duration) # 这会导致问题!
|
||||
> # task = asyncio.create_task(...) # 这也不行!
|
||||
>
|
||||
> self._status = "idle"
|
||||
> return {"success": True}
|
||||
>
|
||||
> async def _background_work(self):
|
||||
> """后台任务"""
|
||||
> await self.sleep(1.0)
|
||||
> self.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)
|
||||
> ```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 基本错误处理
|
||||
|
||||
```python
|
||||
def operation_with_error_handling(self, param: float) -> Dict[str, Any]:
|
||||
"""带错误处理的操作"""
|
||||
try:
|
||||
result = self._risky_operation(param)
|
||||
return {
|
||||
"success": True,
|
||||
"result": result
|
||||
}
|
||||
except ValueError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid parameter",
|
||||
"message": str(e)
|
||||
}
|
||||
except IOError as e:
|
||||
self._status = "error"
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Communication error",
|
||||
"message": str(e)
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义异常
|
||||
|
||||
```python
|
||||
class DeviceError(Exception):
|
||||
"""设备错误基类"""
|
||||
pass
|
||||
|
||||
class DeviceNotReadyError(DeviceError):
|
||||
"""设备未就绪"""
|
||||
pass
|
||||
|
||||
class DeviceTimeoutError(DeviceError):
|
||||
"""设备超时"""
|
||||
pass
|
||||
|
||||
class MyDevice:
|
||||
def operation(self) -> Dict[str, Any]:
|
||||
if self._status != "idle":
|
||||
raise DeviceNotReadyError(f"Device is {self._status}")
|
||||
|
||||
# 执行操作
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
def method(
|
||||
self,
|
||||
param1: float,
|
||||
param2: str,
|
||||
optional_param: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""完整的类型注解有助于自动生成注册表"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
"""方法简短描述
|
||||
|
||||
更详细的说明...
|
||||
|
||||
Args:
|
||||
param: 参数说明,包括单位和范围
|
||||
|
||||
Returns:
|
||||
Dict包含:
|
||||
- success (bool): 是否成功
|
||||
- result (Any): 结果数据
|
||||
|
||||
Raises:
|
||||
DeviceError: 错误情况说明
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 3. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
# 验证必需参数
|
||||
required = ['port', 'baudrate']
|
||||
for key in required:
|
||||
if key not in config:
|
||||
raise ValueError(f"Missing required config: {key}")
|
||||
|
||||
self.port = config['port']
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 4. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
"""析构函数,清理资源"""
|
||||
if hasattr(self, 'connection') and self.connection:
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 5. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def measure_temperature(self) -> Dict[str, Any]:
|
||||
"""测量温度
|
||||
|
||||
✅ 好的返回值设计:
|
||||
- 包含 success 状态
|
||||
- 使用描述性键名
|
||||
- 在键名中包含单位
|
||||
- 记录测量时间
|
||||
"""
|
||||
temp = self._read_temperature()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"temperature_celsius": temp, # 键名包含单位
|
||||
"timestamp": time.time(), # 记录时间
|
||||
"sensor_status": "normal", # 额外状态信息
|
||||
"message": f"温度测量完成: {temp}°C" # 用户友好的消息
|
||||
}
|
||||
|
||||
def bad_example(self) -> Dict[str, Any]:
|
||||
"""❌ 不好的返回值设计"""
|
||||
return {
|
||||
"s": True, # ❌ 键名不明确
|
||||
"v": 25.5, # ❌ 没有说明单位
|
||||
"t": 1234567890, # ❌ 不清楚是什么时间戳
|
||||
}
|
||||
```
|
||||
|
||||
**参考 `host_node.test_latency` 方法**(第 1216-1340 行),它返回详细的测试结果,在前端清晰显示:
|
||||
|
||||
```python
|
||||
return {
|
||||
"status": "success",
|
||||
"avg_rtt_ms": 25.5, # 有意义的键名 + 单位
|
||||
"avg_time_diff_ms": 10.2,
|
||||
"max_time_error_ms": 5.3,
|
||||
"task_delay_ms": 15.7,
|
||||
"test_count": 5, # 记录重要信息
|
||||
}
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
看完本文档后,建议继续阅读:
|
||||
|
||||
- {doc}`add_action` - 了解如何添加新的动作指令
|
||||
- {doc}`add_yaml` - 学习如何编写和完善 YAML 注册表
|
||||
|
||||
进阶主题:
|
||||
|
||||
- {doc}`03_add_device_registry` - 了解如何配置注册表
|
||||
- {doc}`04_add_device_testing` - 学习如何测试设备
|
||||
- {doc}`add_old_device` - 没有 SDK 时如何开发设备驱动
|
||||
|
||||
## 参考
|
||||
|
||||
- [Python 类型注解](https://docs.python.org/3/library/typing.html)
|
||||
- [ROS2 rclpy 异步编程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html) - Uni-Lab 使用 ROS2 的异步机制
|
||||
- [串口通信](https://pyserial.readthedocs.io/)
|
||||
|
||||
> **注意:** 虽然设备驱动使用 `async def` 语法,但请**不要参考** Python 标准的 [asyncio 文档](https://docs.python.org/3/library/asyncio.html)。Uni-Lab 使用的是 ROS2 的异步机制,两者不兼容。请使用 `self.sleep()` 和 `self.create_task()` 等 BaseROS2DeviceNode 提供的方法。
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 设备 Driver 开发
|
||||
# 设备 Driver 开发(无 SDK 设备)
|
||||
|
||||
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
|
||||
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。**仅当没有 SDK (Driver) 时,请参考本章进行驱动开发。**
|
||||
|
||||
> **💡 提示:** 本文档介绍如何为没有现成驱动的老设备开发驱动程序。如果您的设备已经有 SDK 或驱动,请直接参考 {doc}`add_device`。
|
||||
|
||||
## 有串口字符串指令集文档的设备:Python 串口通信(常见 RS485, RS232, USB)
|
||||
|
||||
@@ -12,13 +14,13 @@
|
||||
|
||||
Modbus 与 RS485、RS232 不一样的地方在于,会有更多直接寄存器的读写,以及涉及字节序转换(Big Endian, Little Endian)。
|
||||
|
||||
Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
Uni-Lab 开发团队在仓库中提供了 3 个样例:
|
||||
|
||||
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
* 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
|
||||
****
|
||||
---
|
||||
|
||||
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
||||
|
||||
@@ -26,32 +28,32 @@ Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
|
||||
## 没有接口的老设备老软件:使用 PyWinAuto
|
||||
|
||||
**pywinauto**是一个 Python 库,用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作:
|
||||
**pywinauto**是一个 Python 库,用于自动化 Windows GUI 操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI 自动化等场景。它支持通过两个后端进行操作:
|
||||
|
||||
* **win32**后端:适用于大多数Windows应用程序,使用native Win32 API。(pywinauto_recorder默认使用win32后端)
|
||||
* **uia**后端:基于Microsoft UI Automation,适用于较新的应用程序,特别是基于WPF或UWP的应用程序。(在win10上,会有更全的目录,有的窗口win32会识别不到)
|
||||
- **win32**后端:适用于大多数 Windows 应用程序,使用 native Win32 API。(pywinauto_recorder 默认使用 win32 后端)
|
||||
- **uia**后端:基于 Microsoft UI Automation,适用于较新的应用程序,特别是基于 WPF 或 UWP 的应用程序。(在 win10 上,会有更全的目录,有的窗口 win32 会识别不到)
|
||||
|
||||
### windows平台安装pywinauto和pywinauto_recorder
|
||||
### windows 平台安装 pywinauto 和 pywinauto_recorder
|
||||
|
||||
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
|
||||
|
||||
cd到对应目录,执行安装
|
||||
cd 到对应目录,执行安装
|
||||
|
||||
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
` pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple `
|
||||
|
||||

|
||||
|
||||
windows平台测试 python pywinauto_recorder.py,退出使用两次ctrl+alt+r取消选中,关闭命令提示符。
|
||||
windows 平台测试 python pywinauto_recorder.py,退出使用两次 ctrl+alt+r 取消选中,关闭命令提示符。
|
||||
|
||||
### 计算器例子
|
||||
|
||||
你可以先打开windows的计算器,然后在ilab的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的pywinauto用法:
|
||||
你可以先打开 windows 的计算器,然后在 ilab 的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的 pywinauto 用法:
|
||||
|
||||
* 连接到指定进程
|
||||
* 利用dump_tree查找需要的窗口
|
||||
* 获取某个位置的信息
|
||||
* 模拟点击
|
||||
* 模拟输入
|
||||
- 连接到指定进程
|
||||
- 利用 dump_tree 查找需要的窗口
|
||||
- 获取某个位置的信息
|
||||
- 模拟点击
|
||||
- 模拟输入
|
||||
|
||||
#### 代码学习
|
||||
|
||||
@@ -74,39 +76,39 @@ window.dump_tree(depth=3)
|
||||
Dialog - '计算器' (L-419, T773, R-73, B1287)
|
||||
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
|
||||
child_window(title="计算器", control_type="Window")
|
||||
|
|
||||
|
|
||||
| Dialog - '计算器' (L-269, T774, R-81, B806)
|
||||
| ['计算器Dialog2', 'Dialog2', '计算器2']
|
||||
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
|
||||
| |
|
||||
| |
|
||||
| | Menu - '系统' (L0, T0, R0, B0)
|
||||
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
|
||||
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
|
||||
| |
|
||||
| |
|
||||
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
|
||||
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
|
||||
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
|
||||
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
|
||||
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
|
||||
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
|
||||
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Dialog - '计算器' (L-411, T774, R-81, B1279)
|
||||
| ['计算器Dialog3', 'Dialog3', '计算器3']
|
||||
| child_window(title="计算器", control_type="Window")
|
||||
| |
|
||||
| |
|
||||
| | Static - '计算器' (L-363, T782, R-327, B798)
|
||||
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
|
||||
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Custom - '' (L-411, T806, R-81, B1279)
|
||||
| | ['Custom', '计算器Custom']
|
||||
| | child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
|
|
||||
| Pane - '' (L-411, T806, R-81, B1279)
|
||||
| ['Pane', '计算器Pane']
|
||||
"""
|
||||
@@ -122,58 +124,58 @@ target_window.dump_tree(depth=3)
|
||||
Custom - '' (L-411, T806, R-81, B1279)
|
||||
['标准Custom', 'Custom']
|
||||
child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
|
|
||||
| Button - '打开导航' (L-407, T812, R-367, B848)
|
||||
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
|
||||
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Static - '' (L0, T0, R0, B0)
|
||||
| | ['Static', 'Static0', 'Static1']
|
||||
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
||||
|
|
||||
|
|
||||
| GroupBox - '' (L-411, T814, R-81, B1275)
|
||||
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
|
||||
| |
|
||||
| |
|
||||
| | Static - '表达式为 ' (L0, T0, R0, B0)
|
||||
| | ['表达式为 ', 'Static2', '表达式为 Static']
|
||||
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Static - '显示为 0' (L-411, T875, R-81, B947)
|
||||
| | ['显示为 0Static', '显示为 0', 'Static3']
|
||||
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
|
||||
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
|
||||
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
|
||||
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
|
||||
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
|
||||
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
|
||||
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
|
||||
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
|
||||
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
|
||||
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
|
||||
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
|
||||
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
|
||||
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | Button - '正负' (L-407, T1228, R-328, B1275)
|
||||
| | ['Button32', '正负Button', '正负']
|
||||
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Static - '标准' (L-363, T815, R-322, B842)
|
||||
| ['标准', '标准Static', 'Static4']
|
||||
| child_window(title="标准", auto_id="Header", control_type="Text")
|
||||
|
|
||||
|
|
||||
| Button - '始终置顶' (L-312, T814, R-280, B846)
|
||||
| ['始终置顶Button', '始终置顶', 'Button33']
|
||||
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
|
||||
@@ -187,47 +189,47 @@ numpad.dump_tree(depth=2)
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '一' (L-334, T1450, R-255, B1498)
|
||||
| ['一Button', 'Button2', '一']
|
||||
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '二' (L-253, T1450, R-174, B1498)
|
||||
| ['Button3', '二', '二Button']
|
||||
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '三' (L-172, T1450, R-93, B1498)
|
||||
| ['Button4', '三', '三Button']
|
||||
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '四' (L-334, T1400, R-255, B1448)
|
||||
| ['四', 'Button5', '四Button']
|
||||
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '五' (L-253, T1400, R-174, B1448)
|
||||
| ['Button6', '五Button', '五']
|
||||
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '六' (L-172, T1400, R-93, B1448)
|
||||
| ['六Button', 'Button7', '六']
|
||||
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '七' (L-334, T1350, R-255, B1398)
|
||||
| ['Button8', '七Button', '七']
|
||||
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '八' (L-253, T1350, R-174, B1398)
|
||||
| ['八', 'Button9', '八Button']
|
||||
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '九' (L-172, T1350, R-93, B1398)
|
||||
| ['Button10', '九', '九Button']
|
||||
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
|
||||
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
|
||||
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
|
||||
@@ -262,13 +264,13 @@ r, g, b = pyautogui.pixel(point_x, point_y)
|
||||
|
||||
### pywinauto_recorder
|
||||
|
||||
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用pywinauto的一些查找UI步骤。
|
||||
pywinauto_recorder 是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用 DLL 的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用 pywinauto 的一些查找 UI 步骤。
|
||||
|
||||
#### 运行尝试
|
||||
|
||||
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
|
||||
请参照 上手尝试-环境创建-3 开启 pywinauto_recorder
|
||||
|
||||
例如我们这里先启动一个windows自带的计算器软件
|
||||
例如我们这里先启动一个 windows 自带的计算器软件
|
||||
|
||||

|
||||
|
||||
@@ -286,7 +288,7 @@ with UIPath(u"计算器||Window"):
|
||||
click(u"九||Button")
|
||||
```
|
||||
|
||||
执行该python脚本,可以观察到新开启的计算器被点击了数字9
|
||||
执行该 python 脚本,可以观察到新开启的计算器被点击了数字 9
|
||||
|
||||

|
||||
|
||||
@@ -308,23 +310,38 @@ window.dump_tree(depth=[int类型数字], filename=None)
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
"""
|
||||
```
|
||||
|
||||
这里以上面计算器的例子对dump_tree进行解读
|
||||
这里以上面计算器的例子对 dump_tree 进行解读
|
||||
|
||||
2~4行为当前对象的窗口
|
||||
2~4 行为当前对象的窗口
|
||||
|
||||
* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
* 第4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
- 第 2 行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
- 第 3 行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
- 第 4 行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
|
||||
6~8行为当前对象窗口所包含的子窗口信息,信息类型对应2~4行
|
||||
6~8 行为当前对象窗口所包含的子窗口信息,信息类型对应 2~4 行
|
||||
|
||||
### 窗口获取注意事项
|
||||
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
完成设备驱动开发后,建议继续阅读:
|
||||
|
||||
- {doc}`add_device` - 了解如何将驱动添加到 Uni-Lab 中
|
||||
- {doc}`add_action` - 学习如何添加新的动作指令
|
||||
- {doc}`add_yaml` - 编写和完善 YAML 注册表
|
||||
|
||||
进阶主题:
|
||||
|
||||
- {doc}`03_add_device_registry` - 详细的注册表配置
|
||||
- {doc}`04_add_device_testing` - 设备测试指南
|
||||
1139
docs/developer_guide/add_registry.md
Normal file
@@ -1,6 +1,17 @@
|
||||
# 电池装配工站接入(PLC)
|
||||
# 实例:电池装配工站接入(PLC 控制)
|
||||
|
||||
本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
|
||||
> **文档类型**:实际应用案例
|
||||
> **适用场景**:使用 PLC 控制的电池装配工站接入
|
||||
> **前置知识**:{doc}`../add_device` | {doc}`../add_registry`
|
||||
|
||||
本指南以电池装配工站为实际案例,引导你完成 PLC 控制设备的完整接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
|
||||
|
||||
## 案例概述
|
||||
|
||||
**设备类型**:电池装配工站
|
||||
**通信方式**:Modbus TCP (PLC)
|
||||
**工站基类**:`WorkstationBase`
|
||||
**主要功能**:电池组装、寄存器读写、数据采集
|
||||
|
||||
## 1. 新建工站文件
|
||||
|
||||
@@ -39,8 +50,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self.client = tcp.register_node_list(self.nodes)
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 2. 编写驱动与寄存器读写
|
||||
|
||||
### 2.1 寄存器示例
|
||||
@@ -84,49 +93,49 @@ def start_and_read_metrics(self):
|
||||
|
||||
完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。
|
||||
|
||||
|
||||
### 3.1 新增工站设备(或资源)首次生成注册表
|
||||
首先通过以下命令启动unilab。进入unilab系统状态检查页面
|
||||
|
||||
首先通过以下命令启动 unilab。进入 unilab 系统状态检查页面
|
||||
|
||||
```bash
|
||||
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK>
|
||||
```
|
||||
|
||||
点击注册表编辑,进入注册表编辑页面
|
||||

|
||||
|
||||

|
||||
|
||||
按照图示步骤填写自动生成注册表信息:
|
||||

|
||||
|
||||

|
||||
|
||||
步骤说明:
|
||||
|
||||
1. 选择新增的工站`coin_cell_assembly.py`文件
|
||||
2. 点击分析按钮,分析`coin_cell_assembly.py`文件
|
||||
3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`类
|
||||
4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`。
|
||||
4. 填写新增的工站.py 文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`。
|
||||
5. 此处填写新定义工站的类的名字(名称可以自拟)
|
||||
6. 填写新的工站注册表备注信息
|
||||
7. 生成注册表
|
||||
|
||||
以上操作步骤完成,则会生成的新的注册表ymal文件,如下图:
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
以上操作步骤完成,则会生成的新的注册表 YAML 文件,如下图:
|
||||
|
||||

|
||||
|
||||
### 3.2 添加新生成注册表
|
||||
在`unilabos\registry\devices`目录下新建一个yaml文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
|
||||
|
||||
在`unilabos\registry\devices`目录下新建一个 yaml 文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
|
||||
|
||||
在终端输入以下命令进行注册表补全操作。
|
||||
|
||||
```bash
|
||||
python unilabos\app\register.py --complete_registry
|
||||
```
|
||||
|
||||
|
||||
### 3.3 启动并上传注册表
|
||||
|
||||
新增设备之后,启动unilab需要增加`--upload_registry`参数,来上传注册表信息。
|
||||
新增设备之后,启动 unilab 需要增加`--upload_registry`参数,来上传注册表信息。
|
||||
|
||||
```bash
|
||||
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> --upload_registry
|
||||
@@ -134,14 +143,60 @@ python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> -
|
||||
|
||||
## 4. 注意事项
|
||||
|
||||
- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件:
|
||||
### 4.1 验证模块路径
|
||||
|
||||
```
|
||||
在新生成的 YAML 中,确认 `module` 指向新工站类。本例中需检查 `coincellassemblyworkstation_device.yaml` 文件中是否正确指向了 `CoinCellAssemblyWorkstation` 类:
|
||||
|
||||
```yaml
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
```
|
||||
|
||||
- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。
|
||||
### 4.2 首次接入流程
|
||||
|
||||
- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改,则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后,上传注册表即可。
|
||||
首次新增设备(或资源)需要完整流程:
|
||||
|
||||
1. ✅ 在网页端生成注册表信息
|
||||
2. ✅ 使用 `--complete_registry` 补全注册表
|
||||
3. ✅ 使用 `--upload_registry` 上传注册表信息
|
||||
|
||||
### 4.3 驱动更新流程
|
||||
|
||||
如果不是新增设备,仅修改了工站驱动的 `.py` 文件:
|
||||
|
||||
1. ✅ 运行 `--complete_registry` 补全注册表
|
||||
2. ✅ 运行 `--upload_registry` 上传注册表
|
||||
3. ❌ 不需要在网页端重新生成注册表
|
||||
|
||||
### 4.4 PLC 通信注意事项
|
||||
|
||||
- **握手机制**:若需参数下发,建议在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争
|
||||
- **字节序**:FLOAT32 等多字节数据类型需要正确指定字节序(如 `WorderOrder.LITTLE`)
|
||||
- **寄存器映射**:确保 CSV 文件中的寄存器地址与 PLC 实际配置一致
|
||||
- **连接稳定性**:在初始化时检查 PLC 连接状态,建议添加重连机制
|
||||
|
||||
## 5. 扩展阅读
|
||||
|
||||
### 相关文档
|
||||
|
||||
- {doc}`../add_device` - 设备驱动编写通用指南
|
||||
- {doc}`../add_registry` - 注册表配置完整指南
|
||||
- {doc}`../workstation_architecture` - 工站架构详解
|
||||
|
||||
### 技术要点
|
||||
|
||||
- **Modbus TCP 通信**:PLC 通信协议和寄存器读写
|
||||
- **WorkstationBase**:工站基类的继承和使用
|
||||
- **寄存器映射**:CSV 格式的寄存器配置
|
||||
- **注册表生成**:自动化工具使用
|
||||
|
||||
## 6. 总结
|
||||
|
||||
通过本案例,你应该掌握:
|
||||
|
||||
1. ✅ 如何创建 PLC 控制的工站驱动
|
||||
2. ✅ Modbus TCP 通信和寄存器读写
|
||||
3. ✅ 使用可视化编辑器生成注册表
|
||||
4. ✅ 注册表的补全和上传流程
|
||||
5. ✅ 新增设备与更新驱动的区别
|
||||
|
||||
这个案例展示了完整的 PLC 设备接入流程,可以作为其他类似设备接入的参考模板。
|
||||
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,4 +1,8 @@
|
||||
# 物料构建指南
|
||||
# 实例:物料构建指南
|
||||
|
||||
> **文档类型**:物料系统实战指南
|
||||
> **适用场景**:工作站物料系统构建、Deck/Warehouse/Carrier/Bottle 配置
|
||||
> **前置知识**:PyLabRobot 基础 | 资源管理概念
|
||||
|
||||
## 概述
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# 物料教程(Resource)
|
||||
# 实例:物料教程(Resource)
|
||||
|
||||
本教程面向 Uni-Lab-OS 的开发者,讲解“物料”的核心概念、3种物料格式(UniLab、PyLabRobot、奔耀Bioyond)及其相互转换方法,并说明4种 children 结构表现形式及使用场景。
|
||||
> **文档类型**:物料系统完整教程
|
||||
> **适用场景**:物料格式转换、多系统物料对接、资源结构理解
|
||||
> **前置知识**:Python 基础 | JSON 数据结构
|
||||
|
||||
本教程面向 Uni-Lab-OS 的开发者,讲解"物料"的核心概念、3种物料格式(UniLab、PyLabRobot、奔耀Bioyond)及其相互转换方法,并说明4种 children 结构表现形式及使用场景。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# 工作站模板架构设计与对接指南
|
||||
# 实例:工作站模板架构设计与对接指南
|
||||
|
||||
> **文档类型**:架构设计指南与实战案例
|
||||
> **适用场景**:大型工作站接入、子设备管理、物料系统集成
|
||||
> **前置知识**:{doc}`../add_device` | {doc}`../add_registry`
|
||||
|
||||
## 0. 问题简介
|
||||
|
||||
@@ -6,19 +10,19 @@
|
||||
|
||||
### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合;
|
||||
|
||||
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如2个泵共用1个串口、所有设备共同接入PLC等。
|
||||
2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json并管理执行、同时更改物料信息
|
||||
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如 2 个泵共用 1 个串口、所有设备共同接入 PLC 等。
|
||||
2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json 并管理执行、同时更改物料信息
|
||||
3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息
|
||||
|
||||
### 0.2 移液工作站:物料系统和工作流模板管理
|
||||
|
||||

|
||||

|
||||
|
||||
1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供
|
||||
2. 所有任务系统均由工作站本身实现并下发指令,有统一的抽象函数可实现(pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。
|
||||
@@ -26,12 +30,12 @@
|
||||
|
||||
### 0.3 厂家开发的定制大型工站
|
||||
|
||||

|
||||

|
||||
|
||||
由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信
|
||||
|
||||
1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取
|
||||
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC连续读写来配置工作站可用的工作流;
|
||||
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC 连续读写来配置工作站可用的工作流;
|
||||
3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接
|
||||
|
||||
## 1. 整体架构图
|
||||
@@ -45,7 +49,7 @@ graph TB
|
||||
RPN[ROS2WorkstationNode<br/>Protocol执行引擎]
|
||||
WB -.post_init关联.-> RPN
|
||||
end
|
||||
|
||||
|
||||
subgraph "物料管理系统"
|
||||
DECK[Deck<br/>PLR本地物料系统]
|
||||
RS[ResourceSynchronizer<br/>外部物料同步器]
|
||||
@@ -53,7 +57,7 @@ graph TB
|
||||
WB --> RS
|
||||
RS --> DECK
|
||||
end
|
||||
|
||||
|
||||
subgraph "通信与子设备管理"
|
||||
HW[hardware_interface<br/>硬件通信接口]
|
||||
SUBDEV[子设备集合<br/>pumps/grippers/sensors]
|
||||
@@ -61,7 +65,7 @@ graph TB
|
||||
RPN --> SUBDEV
|
||||
HW -.代理模式.-> RPN
|
||||
end
|
||||
|
||||
|
||||
subgraph "工作流任务系统"
|
||||
PROTO[Protocol定义<br/>LiquidHandling/PlateHandling]
|
||||
WORKFLOW[Workflow执行器<br/>步骤管理与编排]
|
||||
@@ -81,32 +85,32 @@ graph LR
|
||||
HW2[通信接口<br/>hardware_interface]
|
||||
HTTP[HTTP服务<br/>WorkstationHTTPService]
|
||||
end
|
||||
|
||||
|
||||
subgraph "外部物料系统"
|
||||
BIOYOND[Bioyond物料管理]
|
||||
LIMS[LIMS系统]
|
||||
WAREHOUSE[第三方仓储]
|
||||
end
|
||||
|
||||
|
||||
subgraph "外部硬件系统"
|
||||
PLC[PLC设备]
|
||||
SERIAL[串口设备]
|
||||
ROBOT[机械臂/机器人]
|
||||
end
|
||||
|
||||
|
||||
subgraph "云端系统"
|
||||
CLOUD[UniLab云端<br/>资源管理]
|
||||
MONITOR[监控与调度]
|
||||
end
|
||||
|
||||
|
||||
BIOYOND <-->|RPC双向同步| DECK2
|
||||
LIMS -->|HTTP报送| HTTP
|
||||
WAREHOUSE <-->|API对接| DECK2
|
||||
|
||||
|
||||
PLC <-->|Modbus TCP| HW2
|
||||
SERIAL <-->|串口通信| HW2
|
||||
ROBOT <-->|SDK/API| HW2
|
||||
|
||||
|
||||
WS -->|ROS消息| CLOUD
|
||||
CLOUD -->|任务下发| WS
|
||||
MONITOR -->|状态查询| WS
|
||||
@@ -119,40 +123,40 @@ graph TB
|
||||
subgraph "工作站基类"
|
||||
BASE[WorkstationBase<br/>抽象基类]
|
||||
end
|
||||
|
||||
|
||||
subgraph "Bioyond集成工作站"
|
||||
BW[BioyondWorkstation]
|
||||
BW_DECK[Deck + Warehouses]
|
||||
BW_SYNC[BioyondResourceSynchronizer]
|
||||
BW_HW[BioyondV1RPC]
|
||||
BW_HTTP[HTTP报送服务]
|
||||
|
||||
|
||||
BW --> BW_DECK
|
||||
BW --> BW_SYNC
|
||||
BW --> BW_HW
|
||||
BW --> BW_HTTP
|
||||
end
|
||||
|
||||
|
||||
subgraph "纯协议节点"
|
||||
PN[ProtocolNode]
|
||||
PN_SUB[子设备集合]
|
||||
PN_PROTO[Protocol工作流]
|
||||
|
||||
|
||||
PN --> PN_SUB
|
||||
PN --> PN_PROTO
|
||||
end
|
||||
|
||||
|
||||
subgraph "PLC控制工作站"
|
||||
PW[PLCWorkstation]
|
||||
PW_DECK[Deck物料系统]
|
||||
PW_PLC[Modbus PLC客户端]
|
||||
PW_WF[工作流定义]
|
||||
|
||||
|
||||
PW --> PW_DECK
|
||||
PW --> PW_PLC
|
||||
PW --> PW_WF
|
||||
end
|
||||
|
||||
|
||||
BASE -.继承.-> BW
|
||||
BASE -.继承.-> PN
|
||||
BASE -.继承.-> PW
|
||||
@@ -171,25 +175,25 @@ classDiagram
|
||||
+hardware_interface: Union[Any, str]
|
||||
+current_workflow_status: WorkflowStatus
|
||||
+supported_workflows: Dict[str, WorkflowInfo]
|
||||
|
||||
|
||||
+post_init(ros_node)*
|
||||
+set_hardware_interface(interface)
|
||||
+call_device_method(method, *args, **kwargs)
|
||||
+get_device_status()
|
||||
+is_device_available()
|
||||
|
||||
|
||||
+get_deck()
|
||||
+get_all_resources()
|
||||
+find_resource_by_name(name)
|
||||
+find_resources_by_type(type)
|
||||
+sync_with_external_system()
|
||||
|
||||
|
||||
+execute_workflow(name, params)
|
||||
+stop_workflow(emergency)
|
||||
+workflow_status
|
||||
+is_busy
|
||||
}
|
||||
|
||||
|
||||
class ROS2WorkstationNode {
|
||||
+device_id: str
|
||||
+children: Dict[str, Any]
|
||||
@@ -198,7 +202,7 @@ classDiagram
|
||||
+_action_clients: Dict
|
||||
+_action_servers: Dict
|
||||
+resource_tracker: DeviceNodeResourceTracker
|
||||
|
||||
|
||||
+initialize_device(device_id, config)
|
||||
+create_ros_action_server(action_name, mapping)
|
||||
+execute_single_action(device_id, action, kwargs)
|
||||
@@ -206,14 +210,14 @@ classDiagram
|
||||
+transfer_resource_to_another(resources, target, sites)
|
||||
+_setup_hardware_proxy(device, comm_device, read, write)
|
||||
}
|
||||
|
||||
|
||||
%% 物料管理相关类
|
||||
class Deck {
|
||||
+name: str
|
||||
+children: List
|
||||
+assign_child_resource()
|
||||
}
|
||||
|
||||
|
||||
class ResourceSynchronizer {
|
||||
<<abstract>>
|
||||
+workstation: WorkstationBase
|
||||
@@ -221,23 +225,23 @@ classDiagram
|
||||
+sync_to_external(plr_resource)*
|
||||
+handle_external_change(change_info)*
|
||||
}
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer {
|
||||
+bioyond_api_client: BioyondV1RPC
|
||||
+sync_interval: int
|
||||
+last_sync_time: float
|
||||
|
||||
|
||||
+initialize()
|
||||
+sync_from_external()
|
||||
+sync_to_external(resource)
|
||||
+handle_external_change(change_info)
|
||||
}
|
||||
|
||||
|
||||
%% 硬件接口相关类
|
||||
class HardwareInterface {
|
||||
<<interface>>
|
||||
}
|
||||
|
||||
|
||||
class BioyondV1RPC {
|
||||
+base_url: str
|
||||
+api_key: str
|
||||
@@ -245,7 +249,7 @@ classDiagram
|
||||
+add_material()
|
||||
+material_inbound()
|
||||
}
|
||||
|
||||
|
||||
%% 服务类
|
||||
class WorkstationHTTPService {
|
||||
+workstation: WorkstationBase
|
||||
@@ -253,7 +257,7 @@ classDiagram
|
||||
+port: int
|
||||
+server: HTTPServer
|
||||
+running: bool
|
||||
|
||||
|
||||
+start()
|
||||
+stop()
|
||||
+_handle_step_finish_report()
|
||||
@@ -262,13 +266,13 @@ classDiagram
|
||||
+_handle_material_change_report()
|
||||
+_handle_error_handling_report()
|
||||
}
|
||||
|
||||
|
||||
%% 具体实现类
|
||||
class BioyondWorkstation {
|
||||
+bioyond_config: Dict
|
||||
+workflow_mappings: Dict
|
||||
+workflow_sequence: List
|
||||
|
||||
|
||||
+post_init(ros_node)
|
||||
+transfer_resource_to_another()
|
||||
+resource_tree_add(resources)
|
||||
@@ -276,25 +280,25 @@ classDiagram
|
||||
+get_all_workflows()
|
||||
+get_bioyond_status()
|
||||
}
|
||||
|
||||
|
||||
class ProtocolNode {
|
||||
+post_init(ros_node)
|
||||
}
|
||||
|
||||
|
||||
%% 核心关系
|
||||
WorkstationBase o-- ROS2WorkstationNode : post_init关联
|
||||
WorkstationBase o-- WorkstationHTTPService : 可选服务
|
||||
|
||||
|
||||
%% 物料管理侧
|
||||
WorkstationBase *-- Deck : deck
|
||||
WorkstationBase *-- ResourceSynchronizer : 可选组合
|
||||
ResourceSynchronizer <|-- BioyondResourceSynchronizer
|
||||
|
||||
|
||||
%% 硬件接口侧
|
||||
WorkstationBase o-- HardwareInterface : hardware_interface
|
||||
HardwareInterface <|.. BioyondV1RPC : 实现
|
||||
BioyondResourceSynchronizer --> BioyondV1RPC : 使用
|
||||
|
||||
|
||||
%% 继承关系
|
||||
BioyondWorkstation --|> WorkstationBase
|
||||
ProtocolNode --|> WorkstationBase
|
||||
@@ -312,49 +316,49 @@ sequenceDiagram
|
||||
participant HW as HardwareInterface
|
||||
participant ROS as ROS2WorkstationNode
|
||||
participant HTTP as HTTPService
|
||||
|
||||
|
||||
APP->>WS: 创建工作站实例(__init__)
|
||||
WS->>DECK: 初始化PLR Deck
|
||||
DECK->>DECK: 创建Warehouse等子资源
|
||||
DECK-->>WS: Deck创建完成
|
||||
|
||||
|
||||
WS->>HW: 创建硬件接口(如BioyondV1RPC)
|
||||
HW->>HW: 建立连接(PLC/RPC/串口等)
|
||||
HW-->>WS: 硬件接口就绪
|
||||
|
||||
|
||||
WS->>SYNC: 创建ResourceSynchronizer(可选)
|
||||
SYNC->>HW: 使用hardware_interface
|
||||
SYNC->>SYNC: 初始化同步配置
|
||||
SYNC-->>WS: 同步器创建完成
|
||||
|
||||
|
||||
WS->>SYNC: sync_from_external()
|
||||
SYNC->>HW: 查询外部物料系统
|
||||
HW-->>SYNC: 返回物料数据
|
||||
SYNC->>DECK: 转换并添加到Deck
|
||||
SYNC-->>WS: 同步完成
|
||||
|
||||
|
||||
Note over WS: __init__完成,等待ROS节点
|
||||
|
||||
|
||||
APP->>ROS: 初始化ROS2WorkstationNode
|
||||
ROS->>ROS: 初始化子设备(children)
|
||||
ROS->>ROS: 创建Action客户端
|
||||
ROS->>ROS: 设置硬件接口代理
|
||||
ROS-->>APP: ROS节点就绪
|
||||
|
||||
|
||||
APP->>WS: post_init(ros_node)
|
||||
WS->>WS: self._ros_node = ros_node
|
||||
WS->>ROS: update_resource([deck])
|
||||
ROS->>ROS: 上传物料到云端
|
||||
ROS-->>WS: 上传完成
|
||||
|
||||
|
||||
WS->>HTTP: 创建WorkstationHTTPService(可选)
|
||||
HTTP->>HTTP: 启动HTTP服务器线程
|
||||
HTTP-->>WS: HTTP服务启动
|
||||
|
||||
|
||||
WS-->>APP: 工作站完全就绪
|
||||
```
|
||||
|
||||
## 4. 工作流执行时序图(Protocol模式)
|
||||
## 4. 工作流执行时序图(Protocol 模式)
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
@@ -365,15 +369,15 @@ sequenceDiagram
|
||||
participant DECK as PLR Deck
|
||||
participant CLOUD as 云端资源管理
|
||||
participant DEV as 子设备
|
||||
|
||||
|
||||
CLIENT->>ROS: 发送Protocol Action请求
|
||||
ROS->>ROS: execute_protocol回调
|
||||
ROS->>ROS: 从Goal提取参数
|
||||
ROS->>ROS: 调用protocol_steps_generator
|
||||
ROS->>ROS: 生成action步骤列表
|
||||
|
||||
|
||||
ROS->>WS: 更新workflow_status = RUNNING
|
||||
|
||||
|
||||
loop 执行每个步骤
|
||||
alt 调用子设备
|
||||
ROS->>ROS: execute_single_action(device_id, action, params)
|
||||
@@ -394,19 +398,19 @@ sequenceDiagram
|
||||
end
|
||||
WS-->>ROS: 返回结果
|
||||
end
|
||||
|
||||
|
||||
ROS->>DECK: 更新本地物料状态
|
||||
DECK->>DECK: 修改PLR资源属性
|
||||
end
|
||||
|
||||
|
||||
ROS->>CLOUD: 同步物料到云端(可选)
|
||||
CLOUD-->>ROS: 同步完成
|
||||
|
||||
|
||||
ROS->>WS: 更新workflow_status = COMPLETED
|
||||
ROS-->>CLIENT: 返回Protocol Result
|
||||
```
|
||||
|
||||
## 5. HTTP报送处理时序图
|
||||
## 5. HTTP 报送处理时序图
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
@@ -416,25 +420,25 @@ sequenceDiagram
|
||||
participant DECK as PLR Deck
|
||||
participant SYNC as ResourceSynchronizer
|
||||
participant CLOUD as 云端
|
||||
|
||||
|
||||
EXT->>HTTP: POST /report/step_finish
|
||||
HTTP->>HTTP: 解析请求数据
|
||||
HTTP->>HTTP: 验证LIMS协议字段
|
||||
HTTP->>WS: process_step_finish_report(request)
|
||||
|
||||
|
||||
WS->>WS: 增加接收计数(_reports_received_count++)
|
||||
WS->>WS: 记录步骤完成事件
|
||||
WS->>DECK: 更新相关物料状态(可选)
|
||||
DECK->>DECK: 修改PLR资源状态
|
||||
|
||||
|
||||
WS->>WS: 保存报送记录到内存
|
||||
|
||||
|
||||
WS-->>HTTP: 返回处理结果
|
||||
HTTP->>HTTP: 构造HTTP响应
|
||||
HTTP-->>EXT: 200 OK + acknowledgment_id
|
||||
|
||||
|
||||
Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送
|
||||
|
||||
|
||||
alt 物料变更报送
|
||||
EXT->>HTTP: POST /report/material_change
|
||||
HTTP->>WS: process_material_change_report(data)
|
||||
@@ -459,7 +463,7 @@ sequenceDiagram
|
||||
participant HW as HardwareInterface
|
||||
participant HTTP as HTTPService
|
||||
participant LOG as 日志系统
|
||||
|
||||
|
||||
alt 设备错误(ROS Action失败)
|
||||
DEV->>ROS: Action返回失败结果
|
||||
ROS->>ROS: 记录错误信息
|
||||
@@ -471,7 +475,7 @@ sequenceDiagram
|
||||
WS->>WS: 记录错误历史
|
||||
WS->>LOG: 记录错误日志
|
||||
end
|
||||
|
||||
|
||||
alt 关键错误需要停止
|
||||
WS->>ROS: stop_workflow(emergency=True)
|
||||
ROS->>ROS: 取消所有进行中的Action
|
||||
@@ -483,44 +487,44 @@ sequenceDiagram
|
||||
WS->>ROS: 触发重试逻辑(可选)
|
||||
ROS->>DEV: 重新发送Action
|
||||
end
|
||||
|
||||
|
||||
WS-->>HTTP: 返回错误处理结果
|
||||
HTTP-->>DEV: 200 OK + 处理状态
|
||||
```
|
||||
|
||||
## 7. 典型工作站实现示例
|
||||
|
||||
### 7.1 Bioyond集成工作站实现
|
||||
### 7.1 Bioyond 集成工作站实现
|
||||
|
||||
```python
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs):
|
||||
# 初始化deck
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
|
||||
|
||||
# 设置硬件接口为Bioyond RPC客户端
|
||||
self.hardware_interface = BioyondV1RPC(bioyond_config)
|
||||
|
||||
|
||||
# 创建资源同步器
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
|
||||
|
||||
# 从Bioyond同步物料到本地deck
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
# 配置工作流
|
||||
self.workflow_mappings = bioyond_config.get("workflow_mappings", {})
|
||||
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
"""ROS节点就绪后的初始化"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
|
||||
# 上传deck(包括所有物料)到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource,
|
||||
True,
|
||||
self._ros_node.update_resource,
|
||||
True,
|
||||
resources=[self.deck]
|
||||
)
|
||||
|
||||
|
||||
def resource_tree_add(self, resources: List[ResourcePLR]):
|
||||
"""添加物料并同步到Bioyond"""
|
||||
for resource in resources:
|
||||
@@ -533,24 +537,24 @@ class BioyondWorkstation(WorkstationBase):
|
||||
```python
|
||||
class ProtocolNode(WorkstationBase):
|
||||
"""纯协议节点,不需要物料管理和外部通信"""
|
||||
|
||||
|
||||
def __init__(self, deck: Optional[Deck] = None, *args, **kwargs):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
# 不设置hardware_interface和resource_synchronizer
|
||||
# 所有功能通过子设备协同完成
|
||||
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
# 不需要上传物料或其他初始化
|
||||
```
|
||||
|
||||
### 7.3 PLC直接控制工作站
|
||||
### 7.3 PLC 直接控制工作站
|
||||
|
||||
```python
|
||||
class PLCWorkstation(WorkstationBase):
|
||||
def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
|
||||
|
||||
# 设置硬件接口为Modbus客户端
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
self.hardware_interface = ModbusTcpClient(
|
||||
@@ -558,7 +562,7 @@ class PLCWorkstation(WorkstationBase):
|
||||
port=plc_config["port"]
|
||||
)
|
||||
self.hardware_interface.connect()
|
||||
|
||||
|
||||
# 定义支持的工作流
|
||||
self.supported_workflows = {
|
||||
"battery_assembly": WorkflowInfo(
|
||||
@@ -570,49 +574,49 @@ class PLCWorkstation(WorkstationBase):
|
||||
parameters_schema={"quantity": int, "model": str}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def execute_workflow(self, workflow_name: str, parameters: Dict):
|
||||
"""通过PLC执行工作流"""
|
||||
workflow_id = self._get_workflow_id(workflow_name)
|
||||
|
||||
|
||||
# 写入PLC寄存器启动工作流
|
||||
self.hardware_interface.write_register(100, workflow_id)
|
||||
self.hardware_interface.write_register(101, parameters["quantity"])
|
||||
|
||||
|
||||
self.current_workflow_status = WorkflowStatus.RUNNING
|
||||
return True
|
||||
```
|
||||
|
||||
## 8. 核心接口说明
|
||||
|
||||
### 8.1 WorkstationBase核心属性
|
||||
### 8.1 WorkstationBase 核心属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
| --------------------------- | ----------------------- | ----------------------------- |
|
||||
| `_ros_node` | ROS2WorkstationNode | ROS节点引用,由post_init设置 |
|
||||
| `deck` | Deck | PyLabRobot Deck,本地物料系统 |
|
||||
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
|
||||
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
|
||||
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
|
||||
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
|
||||
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
|
||||
| 属性 | 类型 | 说明 |
|
||||
| ------------------------- | ----------------------- | ------------------------------- |
|
||||
| `_ros_node` | ROS2WorkstationNode | ROS 节点引用,由 post_init 设置 |
|
||||
| `deck` | Deck | PyLabRobot Deck,本地物料系统 |
|
||||
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
|
||||
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
|
||||
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
|
||||
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
|
||||
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
|
||||
|
||||
### 8.2 必须实现的方法
|
||||
|
||||
- `post_init(ros_node)`: ROS节点就绪后的初始化,必须实现
|
||||
- `post_init(ros_node)`: ROS 节点就绪后的初始化,必须实现
|
||||
|
||||
### 8.3 硬件接口相关方法
|
||||
|
||||
- `set_hardware_interface(interface)`: 设置硬件接口
|
||||
- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用
|
||||
- 支持直接模式: 直接调用hardware_interface的方法
|
||||
- 支持代理模式: hardware_interface="proxy:device_id"通过ROS转发
|
||||
- 支持直接模式: 直接调用 hardware_interface 的方法
|
||||
- 支持代理模式: hardware_interface="proxy:device_id"通过 ROS 转发
|
||||
- `get_device_status()`: 获取设备状态
|
||||
- `is_device_available()`: 检查设备可用性
|
||||
|
||||
### 8.4 物料管理方法
|
||||
|
||||
- `get_deck()`: 获取PLR Deck
|
||||
- `get_deck()`: 获取 PLR Deck
|
||||
- `get_all_resources()`: 获取所有物料
|
||||
- `find_resource_by_name(name)`: 按名称查找物料
|
||||
- `find_resources_by_type(type)`: 按类型查找物料
|
||||
@@ -626,7 +630,7 @@ class PLCWorkstation(WorkstationBase):
|
||||
- `is_busy`: 检查是否忙碌(属性)
|
||||
- `workflow_runtime`: 获取运行时间(属性)
|
||||
|
||||
### 8.6 可选的HTTP报送处理方法
|
||||
### 8.6 可选的 HTTP 报送处理方法
|
||||
|
||||
- `process_step_finish_report()`: 步骤完成处理
|
||||
- `process_sample_finish_report()`: 样本完成处理
|
||||
@@ -634,10 +638,10 @@ class PLCWorkstation(WorkstationBase):
|
||||
- `process_material_change_report()`: 物料变更处理
|
||||
- `handle_external_error()`: 错误处理
|
||||
|
||||
### 8.7 ROS2WorkstationNode核心方法
|
||||
### 8.7 ROS2WorkstationNode 核心方法
|
||||
|
||||
- `initialize_device(device_id, config)`: 初始化子设备
|
||||
- `create_ros_action_server(action_name, mapping)`: 创建Action服务器
|
||||
- `create_ros_action_server(action_name, mapping)`: 创建 Action 服务器
|
||||
- `execute_single_action(device_id, action, kwargs)`: 执行单个动作
|
||||
- `update_resource(resources)`: 同步物料到云端
|
||||
- `transfer_resource_to_another(...)`: 跨设备物料转移
|
||||
@@ -694,7 +698,7 @@ workstation = BioyondWorkstation(
|
||||
"config": {...}
|
||||
},
|
||||
"gripper_1": {
|
||||
"type": "device",
|
||||
"type": "device",
|
||||
"driver": "RobotiqGripperDriver",
|
||||
"communication": "io_modbus_1",
|
||||
"config": {...}
|
||||
@@ -716,7 +720,7 @@ workstation = BioyondWorkstation(
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 HTTP服务配置
|
||||
### 9.3 HTTP 服务配置
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
@@ -737,31 +741,31 @@ http_service.start()
|
||||
### 10.1 清晰的职责分离
|
||||
|
||||
- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理
|
||||
- **ROS2WorkstationNode**: 负责子设备管理、Protocol执行、云端物料同步
|
||||
- **ResourceSynchronizer**: 可选的外部物料系统同步(如Bioyond)
|
||||
- **WorkstationHTTPService**: 可选的HTTP报送接收服务
|
||||
- **ROS2WorkstationNode**: 负责子设备管理、Protocol 执行、云端物料同步
|
||||
- **ResourceSynchronizer**: 可选的外部物料系统同步(如 Bioyond)
|
||||
- **WorkstationHTTPService**: 可选的 HTTP 报送接收服务
|
||||
|
||||
### 10.2 灵活的硬件接口模式
|
||||
|
||||
1. **直接模式**: hardware_interface是具体对象(如BioyondV1RPC、ModbusClient)
|
||||
2. **代理模式**: hardware_interface="proxy:device_id",通过ROS节点转发到子设备
|
||||
1. **直接模式**: hardware_interface 是具体对象(如 BioyondV1RPC、ModbusClient)
|
||||
2. **代理模式**: hardware_interface="proxy:device_id",通过 ROS 节点转发到子设备
|
||||
3. **混合模式**: 工作站有自己的接口,同时管理多个子设备
|
||||
|
||||
### 10.3 统一的物料系统
|
||||
|
||||
- 基于PyLabRobot Deck的标准化物料表示
|
||||
- 通过ResourceSynchronizer实现与外部系统(如Bioyond、LIMS)的双向同步
|
||||
- 通过ROS2WorkstationNode实现与云端的物料状态同步
|
||||
- 基于 PyLabRobot Deck 的标准化物料表示
|
||||
- 通过 ResourceSynchronizer 实现与外部系统(如 Bioyond、LIMS)的双向同步
|
||||
- 通过 ROS2WorkstationNode 实现与云端的物料状态同步
|
||||
|
||||
### 10.4 Protocol驱动的工作流
|
||||
### 10.4 Protocol 驱动的工作流
|
||||
|
||||
- ROS2WorkstationNode负责Protocol的执行和步骤管理
|
||||
- 支持子设备协同(通过Action Client调用)
|
||||
- 支持工作站直接控制(通过hardware_interface)
|
||||
- ROS2WorkstationNode 负责 Protocol 的执行和步骤管理
|
||||
- 支持子设备协同(通过 Action Client 调用)
|
||||
- 支持工作站直接控制(通过 hardware_interface)
|
||||
|
||||
### 10.5 可选的HTTP报送服务
|
||||
### 10.5 可选的 HTTP 报送服务
|
||||
|
||||
- 基于LIMS协议规范的统一报送接口
|
||||
- 基于 LIMS 协议规范的统一报送接口
|
||||
- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型
|
||||
- 与工作站解耦,可独立启停
|
||||
|
||||
334
docs/developer_guide/http_api.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# HTTP API 指南
|
||||
|
||||
本文档介绍如何通过 HTTP API 与 Uni-Lab-OS 进行交互,包括查询设备、提交任务和获取结果。
|
||||
|
||||
## 概述
|
||||
|
||||
Uni-Lab-OS 提供 RESTful HTTP API,允许外部系统通过标准 HTTP 请求控制实验室设备。API 基于 FastAPI 构建,默认运行在 `http://localhost:8002`。
|
||||
|
||||
### 基础信息
|
||||
|
||||
- **Base URL**: `http://localhost:8002/api/v1`
|
||||
- **Content-Type**: `application/json`
|
||||
- **响应格式**: JSON
|
||||
|
||||
### 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------ | ------------------ |
|
||||
| `code` | int | 状态码,0 表示成功 |
|
||||
| `data` | object | 响应数据 |
|
||||
| `message` | string | 响应消息 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
以下是一个完整的工作流示例:查询设备 → 获取动作 → 提交任务 → 获取结果。
|
||||
|
||||
### 步骤 1: 获取在线设备
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/online-devices"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"online_devices": {
|
||||
"host_node": {
|
||||
"device_key": "/host_node",
|
||||
"namespace": "",
|
||||
"machine_name": "本地",
|
||||
"uuid": "xxx-xxx-xxx",
|
||||
"node_name": "host_node"
|
||||
}
|
||||
},
|
||||
"total_count": 1,
|
||||
"timestamp": 1732612345.123
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 获取设备可用动作
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/devices/host_node/actions"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"device_id": "host_node",
|
||||
"actions": {
|
||||
"test_latency": {
|
||||
"type_name": "unilabos_msgs.action._empty_in.EmptyIn",
|
||||
"type_name_convert": "unilabos_msgs/action/_empty_in/EmptyIn",
|
||||
"action_path": "/devices/host_node/test_latency",
|
||||
"goal_info": "{}",
|
||||
"is_busy": false,
|
||||
"current_job_id": null
|
||||
},
|
||||
"create_resource": {
|
||||
"type_name": "unilabos_msgs.action._resource_create_from_outer_easy.ResourceCreateFromOuterEasy",
|
||||
"action_path": "/devices/host_node/create_resource",
|
||||
"goal_info": "{res_id: '', device_id: '', class_name: '', ...}",
|
||||
"is_busy": false,
|
||||
"current_job_id": null
|
||||
}
|
||||
},
|
||||
"action_count": 5
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**动作状态字段说明**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `type_name` | 动作类型的完整名称 |
|
||||
| `action_path` | ROS2 动作路径 |
|
||||
| `goal_info` | 动作参数模板 |
|
||||
| `is_busy` | 动作是否正在执行 |
|
||||
| `current_job_id` | 当前执行的任务 ID(如果繁忙) |
|
||||
|
||||
### 步骤 3: 提交任务
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "host_node",
|
||||
"action": "test_latency",
|
||||
"action_args": {}
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------- | ------ | ---- | ---------------------------------- |
|
||||
| `device_id` | string | ✓ | 目标设备 ID |
|
||||
| `action` | string | ✓ | 动作名称 |
|
||||
| `action_args` | object | ✓ | 动作参数(根据动作类型不同而变化) |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 1,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**任务状态码**:
|
||||
|
||||
| 状态码 | 含义 | 说明 |
|
||||
| ------ | --------- | ------------------------------ |
|
||||
| 0 | UNKNOWN | 未知状态 |
|
||||
| 1 | ACCEPTED | 任务已接受,等待执行 |
|
||||
| 2 | EXECUTING | 任务执行中 |
|
||||
| 3 | CANCELING | 任务取消中 |
|
||||
| 4 | SUCCEEDED | 任务成功完成 |
|
||||
| 5 | CANCELED | 任务已取消 |
|
||||
| 6 | ABORTED | 任务中止(设备繁忙或执行失败) |
|
||||
|
||||
### 步骤 4: 查询任务状态和结果
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/job/b6acb586-733a-42ab-9f73-55c9a52aa8bd/status"
|
||||
```
|
||||
|
||||
**响应示例(执行中)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 2,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(执行完成)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 4,
|
||||
"result": {
|
||||
"error": "",
|
||||
"suc": true,
|
||||
"return_value": {
|
||||
"avg_rtt_ms": 103.99,
|
||||
"avg_time_diff_ms": 7181.55,
|
||||
"max_time_error_ms": 7210.57,
|
||||
"task_delay_ms": -1,
|
||||
"raw_delay_ms": 33.19,
|
||||
"test_count": 5,
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 任务结果在首次查询后会被自动删除,请确保保存返回的结果数据。
|
||||
|
||||
## API 端点列表
|
||||
|
||||
### 设备相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ---------------------------------------------------------- | ---- | ---------------------- |
|
||||
| `/api/v1/online-devices` | GET | 获取在线设备列表 |
|
||||
| `/api/v1/devices` | GET | 获取设备配置 |
|
||||
| `/api/v1/devices/{device_id}/actions` | GET | 获取指定设备的可用动作 |
|
||||
| `/api/v1/devices/{device_id}/actions/{action_name}/schema` | GET | 获取动作参数 Schema |
|
||||
| `/api/v1/actions` | GET | 获取所有设备的可用动作 |
|
||||
|
||||
### 任务相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ----------------------------- | ---- | ------------------ |
|
||||
| `/api/v1/job/add` | POST | 提交新任务 |
|
||||
| `/api/v1/job/{job_id}/status` | GET | 查询任务状态和结果 |
|
||||
|
||||
### 资源相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ------------------- | ---- | ------------ |
|
||||
| `/api/v1/resources` | GET | 获取资源列表 |
|
||||
|
||||
## 常见动作示例
|
||||
|
||||
### test_latency - 延迟测试
|
||||
|
||||
测试系统延迟,无需参数。
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
|
||||
```
|
||||
|
||||
### create_resource - 创建资源
|
||||
|
||||
在设备上创建新资源。
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"device_id": "host_node",
|
||||
"action": "create_resource",
|
||||
"action_args": {
|
||||
"res_id": "my_plate",
|
||||
"device_id": "host_node",
|
||||
"class_name": "Plate",
|
||||
"parent": "deck",
|
||||
"bind_locations": {"x": 0, "y": 0, "z": 0}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 设备繁忙
|
||||
|
||||
当设备正在执行其他任务时,提交新任务会返回 `status: 6`(ABORTED):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "xxx",
|
||||
"status": 6,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
此时应等待当前任务完成后重试,或使用 `/devices/{device_id}/actions` 检查动作的 `is_busy` 状态。
|
||||
|
||||
### 参数错误
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 2002,
|
||||
"data": { ... },
|
||||
"message": "device_id is required"
|
||||
}
|
||||
```
|
||||
|
||||
## 轮询策略
|
||||
|
||||
推荐的任务状态轮询策略:
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
def wait_for_job(job_id, timeout=60, interval=0.5):
|
||||
"""等待任务完成并返回结果"""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
response = requests.get(f"http://localhost:8002/api/v1/job/{job_id}/status")
|
||||
data = response.json()["data"]
|
||||
|
||||
status = data["status"]
|
||||
if status in (4, 5, 6): # SUCCEEDED, CANCELED, ABORTED
|
||||
return data
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
|
||||
|
||||
# 使用示例
|
||||
response = requests.post(
|
||||
"http://localhost:8002/api/v1/job/add",
|
||||
json={"device_id": "host_node", "action": "test_latency", "action_args": {}}
|
||||
)
|
||||
job_id = response.json()["data"]["jobId"]
|
||||
result = wait_for_job(job_id)
|
||||
print(result)
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [设备注册指南](add_device.md)
|
||||
- [动作定义指南](add_action.md)
|
||||
- [网络架构概述](networking_overview.md)
|
||||
594
docs/developer_guide/networking_overview.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# 组网部署与主从模式配置
|
||||
|
||||
本文档介绍 Uni-Lab-OS 的组网架构、部署方式和主从模式的详细配置。
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览](#架构概览)
|
||||
- [节点类型](#节点类型)
|
||||
- [通信机制](#通信机制)
|
||||
- [典型拓扑](#典型拓扑)
|
||||
- [主从模式配置](#主从模式配置)
|
||||
- [网络配置](#网络配置)
|
||||
- [示例:多房间部署](#示例多房间部署)
|
||||
- [故障处理](#故障处理)
|
||||
- [监控和维护](#监控和维护)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
Uni-Lab-OS 支持多种部署模式:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Cloud Platform/Self-hosted Platform │
|
||||
│ uni-lab.bohrium.com │
|
||||
│ (Resource Management, Task Scheduling, │
|
||||
│ Monitoring) │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
│ WebSocket / HTTP
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
┌────▼─────┐ ┌────▼─────┐
|
||||
│ Master │◄──ROS2──►│ Slave │
|
||||
│ Node │ │ Node │
|
||||
│ (Host) │ │ (Slave) │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
┌────┴────┐ ┌────┴────┐
|
||||
│ Device A│ │ Device B│
|
||||
│ Device C│ │ Device D│
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 节点类型
|
||||
|
||||
### 主节点(Host Node)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 创建和管理全局资源
|
||||
- 提供 host_node 服务
|
||||
- 连接云端平台
|
||||
- 协调多个从节点
|
||||
- 提供 Web 管理界面
|
||||
|
||||
**启动命令**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g host_devices.json
|
||||
```
|
||||
|
||||
### 从节点(Slave Node)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 管理本地设备
|
||||
- 不连接云端(可选)
|
||||
- 向主节点注册
|
||||
- 执行分配的任务
|
||||
|
||||
**启动命令**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave_devices.json --is_slave
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通信机制
|
||||
|
||||
### ROS2 通信
|
||||
|
||||
**用途**: 节点间实时通信
|
||||
|
||||
**通信方式**:
|
||||
|
||||
- **Topic**: 状态广播(设备状态、传感器数据)
|
||||
- **Service**: 同步请求(资源查询、配置获取)
|
||||
- **Action**: 异步任务(设备操作、长时间运行)
|
||||
|
||||
**示例**:
|
||||
|
||||
```bash
|
||||
# 查看ROS2节点
|
||||
ros2 node list
|
||||
|
||||
# 查看topic
|
||||
ros2 topic list
|
||||
|
||||
# 查看action
|
||||
ros2 action list
|
||||
```
|
||||
|
||||
### WebSocket 通信
|
||||
|
||||
**用途**: 主节点与云端通信
|
||||
|
||||
**特点**:
|
||||
|
||||
- 实时双向通信
|
||||
- 自动重连
|
||||
- 心跳保持
|
||||
|
||||
**配置**:
|
||||
|
||||
```python
|
||||
# local_config.py
|
||||
BasicConfig.ak = "your_ak"
|
||||
BasicConfig.sk = "your_sk"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型拓扑
|
||||
|
||||
### 单节点模式
|
||||
|
||||
**适用场景**: 小型实验室、开发测试
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Uni-Lab Node │
|
||||
│ ┌────────────┐ │
|
||||
│ │ Device A │ │
|
||||
│ │ Device B │ │
|
||||
│ │ Device C │ │
|
||||
│ └────────────┘ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 配置简单
|
||||
- 无网络延迟
|
||||
- 适合快速原型
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g all_devices.json
|
||||
```
|
||||
|
||||
### 主从模式
|
||||
|
||||
**适用场景**: 多房间、分布式设备
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ Master Node │◄────►│ Slave Node 1 │
|
||||
│ Coordinator │ │ Liquid │
|
||||
│ Web UI │ │ Handling │
|
||||
└──────┬──────┘ └──────────────┘
|
||||
│
|
||||
│ ┌──────────────┐
|
||||
└────────────►│ Slave Node 2 │
|
||||
│ Analytical │
|
||||
│ (NMR/GC) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 物理分隔
|
||||
- 独立故障域
|
||||
- 易于扩展
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 设备物理位置分散
|
||||
- 不同房间的设备
|
||||
- 需要独立故障域
|
||||
- 分阶段扩展系统
|
||||
|
||||
**主节点**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g host.json
|
||||
```
|
||||
|
||||
**从节点**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
|
||||
unilab --ak your_ak --sk your_sk -g slave2.json --is_slave --port 8003
|
||||
```
|
||||
|
||||
### 云端集成模式
|
||||
|
||||
**适用场景**: 远程监控、多实验室协作
|
||||
|
||||
```
|
||||
Cloud Platform
|
||||
│
|
||||
┌───────┴────────┐
|
||||
│ │
|
||||
Laboratory A Laboratory B
|
||||
(Master Node) (Master Node)
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 远程访问
|
||||
- 数据同步
|
||||
- 任务调度
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主从模式配置
|
||||
|
||||
### 主节点配置
|
||||
|
||||
#### 1. 创建主节点设备图
|
||||
|
||||
`host.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 启动主节点
|
||||
|
||||
```bash
|
||||
# 基本启动
|
||||
unilab --ak your_ak --sk your_sk -g host.json
|
||||
|
||||
# 带云端集成
|
||||
unilab --ak your_ak --sk your_sk -g host.json --upload_registry
|
||||
|
||||
# 指定端口
|
||||
unilab --ak your_ak --sk your_sk -g host.json --port 8002
|
||||
```
|
||||
|
||||
#### 3. 验证主节点
|
||||
|
||||
```bash
|
||||
# 检查ROS2节点
|
||||
ros2 node list
|
||||
# 应该看到 /host_node
|
||||
|
||||
# 检查服务
|
||||
ros2 service list | grep host_node
|
||||
|
||||
# Web界面
|
||||
# 访问 http://localhost:8002
|
||||
```
|
||||
|
||||
### 从节点配置
|
||||
|
||||
#### 1. 创建从节点设备图
|
||||
|
||||
`slave1.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "liquid_handler_1",
|
||||
"name": "液体处理工作站",
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {
|
||||
"simulation": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 启动从节点
|
||||
|
||||
```bash
|
||||
# 基本从节点启动
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
|
||||
|
||||
# 指定不同端口(如果多个从节点在同一台机器)
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --port 8003
|
||||
|
||||
# 跳过等待主节点(独立测试)
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --slave_no_host
|
||||
```
|
||||
|
||||
#### 3. 验证从节点
|
||||
|
||||
```bash
|
||||
# 检查节点连接
|
||||
ros2 node list
|
||||
|
||||
# 检查设备状态
|
||||
ros2 topic echo /liquid_handler_1/status
|
||||
```
|
||||
|
||||
### 跨节点通信
|
||||
|
||||
#### 资源访问
|
||||
|
||||
主节点可以访问从节点的资源:
|
||||
|
||||
```bash
|
||||
# 在主节点或其他节点调用从节点设备
|
||||
ros2 action send_goal /liquid_handler_1/transfer_liquid \
|
||||
unilabos_msgs/action/TransferLiquid \
|
||||
"{source: {...}, target: {...}, volume: 100.0}"
|
||||
```
|
||||
|
||||
#### 状态监控
|
||||
|
||||
主节点监控所有从节点状态:
|
||||
|
||||
```bash
|
||||
# 订阅从节点状态
|
||||
ros2 topic echo /liquid_handler_1/status
|
||||
|
||||
# 查看所有设备状态
|
||||
ros2 topic list | grep status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 网络配置
|
||||
|
||||
### ROS2 DDS 配置
|
||||
|
||||
确保主从节点在同一网络:
|
||||
|
||||
```bash
|
||||
# 检查网络可达性
|
||||
ping <slave_node_ip>
|
||||
|
||||
# 设置ROS_DOMAIN_ID(可选,用于隔离)
|
||||
export ROS_DOMAIN_ID=42
|
||||
```
|
||||
|
||||
### 防火墙配置
|
||||
|
||||
**建议做法**:
|
||||
|
||||
为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。
|
||||
|
||||
**Linux**:
|
||||
|
||||
```bash
|
||||
# 关闭防火墙
|
||||
sudo ufw disable
|
||||
|
||||
# 或者临时停止防火墙
|
||||
sudo systemctl stop ufw
|
||||
```
|
||||
|
||||
**Windows**:
|
||||
|
||||
```powershell
|
||||
# 在Windows安全中心关闭防火墙
|
||||
# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭Windows Defender防火墙
|
||||
```
|
||||
|
||||
### 验证网络连通性
|
||||
|
||||
在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常:
|
||||
|
||||
**在主节点机器上**(激活 unilab 环境后):
|
||||
|
||||
```bash
|
||||
# 启动talker
|
||||
ros2 run demo_nodes_cpp talker
|
||||
|
||||
# 同时在另一个终端启动listener
|
||||
ros2 run demo_nodes_cpp listener
|
||||
```
|
||||
|
||||
**在从节点机器上**(激活 unilab 环境后):
|
||||
|
||||
```bash
|
||||
# 启动talker
|
||||
ros2 run demo_nodes_cpp talker
|
||||
|
||||
# 同时在另一个终端启动listener
|
||||
ros2 run demo_nodes_cpp listener
|
||||
```
|
||||
|
||||
**注意**:必须在两台机器上**互相启动** talker 和 listener,否则可能出现只能收不能发的单向通信问题。
|
||||
|
||||
**预期结果**:
|
||||
|
||||
- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息
|
||||
- 如果只能看到本地消息,说明网络配置有问题
|
||||
- 如果两台机器都能互相收发消息,则组网配置正确
|
||||
|
||||
### 本地网络要求
|
||||
|
||||
**ROS2 通信**:
|
||||
|
||||
- 同一局域网或 VPN
|
||||
- 端口:默认 DDS 端口(7400-7500)
|
||||
- 组播支持(或配置 unicast)
|
||||
|
||||
**检查连通性**:
|
||||
|
||||
```bash
|
||||
# Ping测试
|
||||
ping <target_ip>
|
||||
|
||||
# ROS2节点发现
|
||||
ros2 node list
|
||||
ros2 daemon stop && ros2 daemon start
|
||||
```
|
||||
|
||||
### 云端连接
|
||||
|
||||
**要求**:
|
||||
|
||||
- HTTPS (443)
|
||||
- WebSocket 支持
|
||||
- 稳定的互联网连接
|
||||
|
||||
**测试连接**:
|
||||
|
||||
```bash
|
||||
# 测试云端连接
|
||||
curl https://uni-lab.bohrium.com/api/v1/health
|
||||
|
||||
# 测试WebSocket
|
||||
# 启动Uni-Lab后查看日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例:多房间部署
|
||||
|
||||
### 场景描述
|
||||
|
||||
- **房间 A**: 主控室,有 Web 界面
|
||||
- **房间 B**: 液体处理室
|
||||
- **房间 C**: 分析仪器室
|
||||
|
||||
### 房间 A - 主节点
|
||||
|
||||
```bash
|
||||
# host.json
|
||||
unilab --ak your_ak --sk your_sk -g host.json --port 8002
|
||||
```
|
||||
|
||||
### 房间 B - 从节点 1
|
||||
|
||||
```bash
|
||||
# liquid_handler.json
|
||||
unilab --ak your_ak --sk your_sk -g liquid_handler.json --is_slave --port 8003
|
||||
```
|
||||
|
||||
### 房间 C - 从节点 2
|
||||
|
||||
```bash
|
||||
# analytical.json
|
||||
unilab --ak your_ak --sk your_sk -g analytical.json --is_slave --port 8004
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障处理
|
||||
|
||||
### 节点离线
|
||||
|
||||
**检测**:
|
||||
|
||||
```bash
|
||||
ros2 node list # 查看在线节点
|
||||
```
|
||||
|
||||
**处理**:
|
||||
|
||||
1. 检查网络连接
|
||||
2. 重启节点
|
||||
3. 检查日志
|
||||
|
||||
### 从节点无法连接主节点
|
||||
|
||||
1. 检查网络:
|
||||
|
||||
```bash
|
||||
ping <host_ip>
|
||||
```
|
||||
|
||||
2. 检查 ROS_DOMAIN_ID:
|
||||
|
||||
```bash
|
||||
echo $ROS_DOMAIN_ID
|
||||
```
|
||||
|
||||
3. 使用`--slave_no_host`测试:
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave.json --is_slave --slave_no_host
|
||||
```
|
||||
|
||||
### 通信延迟
|
||||
|
||||
**排查**:
|
||||
|
||||
```bash
|
||||
# 网络延迟
|
||||
ping <node_ip>
|
||||
|
||||
# ROS2话题延迟
|
||||
ros2 topic hz /device_status
|
||||
ros2 topic bw /device_status
|
||||
```
|
||||
|
||||
**优化**:
|
||||
|
||||
- 减少发布频率
|
||||
- 使用 QoS 配置
|
||||
- 优化网络带宽
|
||||
|
||||
### 数据同步失败
|
||||
|
||||
**检查**:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f unilabos_data/logs/unilab.log | grep sync
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- 检查云端连接
|
||||
- 验证 AK/SK
|
||||
- 手动触发同步
|
||||
|
||||
### 资源不可见
|
||||
|
||||
检查资源注册:
|
||||
|
||||
```bash
|
||||
ros2 service call /host_node/resource_list \
|
||||
unilabos_msgs/srv/ResourceList
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 节点状态监控
|
||||
|
||||
```bash
|
||||
# 查看所有节点
|
||||
ros2 node list
|
||||
|
||||
# 查看话题
|
||||
ros2 topic list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [最佳实践指南](../user_guide/best_practice.md) - 完整的实验室搭建流程
|
||||
- [安装指南](../user_guide/installation.md) - 环境安装步骤
|
||||
- [启动参数详解](../user_guide/launch.md) - 启动参数说明
|
||||
- [添加设备驱动](add_device.md) - 自定义设备开发
|
||||
- [工作站架构](workstation_architecture.md) - 复杂工作站搭建
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [ROS2 网络配置](https://docs.ros.org/en/humble/Tutorials/Advanced/Networking.html)
|
||||
- [DDS 配置](https://fast-dds.docs.eprosima.com/)
|
||||
- Uni-Lab 云平台文档
|
||||
@@ -1,9 +1,23 @@
|
||||
# Uni-Lab 项目文档
|
||||
# Uni-Lab-OS 项目文档
|
||||
|
||||
欢迎来到项目文档的首页!
|
||||
Uni-Lab-OS 是一个开源的实验室自动化操作系统,提供统一的设备接口、工作流管理和分布式部署能力。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 3
|
||||
|
||||
intro.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/http_api.md
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/action_includes.md
|
||||
```
|
||||
|
||||
@@ -10,35 +10,51 @@ concepts/01-communication-instruction.md
|
||||
concepts/02-topology-and-chemputer-compile.md
|
||||
```
|
||||
|
||||
## **用户指南**
|
||||
## 用户指南
|
||||
|
||||
本指南将带你了解如何使用项目的功能。
|
||||
快速上手、系统配置与使用说明。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
user_guide/best_practice.md
|
||||
user_guide/installation.md
|
||||
user_guide/configuration.md
|
||||
user_guide/launch.md
|
||||
user_guide/graph_files.md
|
||||
boot_examples/index.md
|
||||
```
|
||||
|
||||
## 进阶配置
|
||||
|
||||
高级配置和系统管理。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
advanced_usage/configuration.md
|
||||
advanced_usage/working_directory.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
```{toctree}
|
||||
设备开发、系统扩展与架构说明。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/device_driver
|
||||
developer_guide/add_device
|
||||
developer_guide/add_action
|
||||
developer_guide/actions
|
||||
developer_guide/workstation_architecture
|
||||
developer_guide/add_protocol
|
||||
developer_guide/add_batteryPLC
|
||||
developer_guide/materials_tutorial
|
||||
developer_guide/materials_construction_guide
|
||||
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_old_device.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/actions.md
|
||||
developer_guide/action_includes.md
|
||||
developer_guide/add_protocol.md
|
||||
developer_guide/examples/workstation_architecture.md
|
||||
developer_guide/examples/materials_construction_guide.md
|
||||
developer_guide/examples/materials_tutorial.md
|
||||
developer_guide/examples/battery_plc_workstation.md
|
||||
```
|
||||
|
||||
## 接口文档
|
||||
|
||||
BIN
docs/logo.png
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 262 KiB |
1837
docs/user_guide/best_practice.md
Normal file
@@ -1,442 +0,0 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
Uni-Lab 支持通过 Python 配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的 Uni-Lab 环境。
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
Uni-Lab 支持 Python 格式的配置文件,它比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
### 默认配置示例
|
||||
|
||||
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`:
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
|
||||
sk = "" # 实验室网页给您提供的sk代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
|
||||
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
您可以进入实验室,点击左下角的头像在实验室详情中获取所在实验室的ak sk
|
||||

|
||||
|
||||
### 完整配置示例
|
||||
|
||||
您可以根据需要添加更多配置选项:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
# 基础配置
|
||||
class BasicConfig:
|
||||
ak = "your_access_key" # 实验室访问密钥
|
||||
sk = "your_secret_key" # 实验室私钥
|
||||
working_dir = "" # 工作目录(通常自动设置)
|
||||
config_path = "" # 配置文件路径(自动设置)
|
||||
is_host_mode = True # 是否为主站模式
|
||||
slave_no_host = False # 从站模式下是否跳过等待主机服务
|
||||
upload_registry = False # 是否上传注册表
|
||||
machine_name = "undefined" # 机器名称(自动获取)
|
||||
vis_2d_enable = False # 是否启用2D可视化
|
||||
enable_resource_load = True # 是否启用资源加载
|
||||
communication_protocol = "websocket" # 通信协议
|
||||
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = "" # API主机地址
|
||||
authorization = "" # 授权信息
|
||||
init_endpoint = "" # 初始化端点
|
||||
complete_endpoint = "" # 完成端点
|
||||
max_retries = 3 # 最大重试次数
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1" # 远程地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
] # 需要加载的ROS模块
|
||||
```
|
||||
|
||||
## 命令行参数覆盖配置
|
||||
|
||||
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。命令行参数的优先级高于配置文件。
|
||||
|
||||
### 支持命令行覆盖的配置项
|
||||
|
||||
以下配置项可以通过命令行参数进行覆盖:
|
||||
|
||||
| 配置类 | 配置字段 | 命令行参数 | 说明 |
|
||||
| ------------- | ----------------- | ------------------- | -------------------------------- |
|
||||
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
|
||||
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
|
||||
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
|
||||
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
|
||||
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
|
||||
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
|
||||
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
|
||||
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
|
||||
|
||||
### 特殊命令行参数
|
||||
|
||||
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `--config` | 指定配置文件路径 |
|
||||
| `--port` | Web 服务端口(不影响配置文件) |
|
||||
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
|
||||
| `--visual` | 可视化工具选择(不影响配置文件) |
|
||||
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
|
||||
|
||||
### 配置优先级
|
||||
|
||||
配置项的生效优先级从高到低为:
|
||||
|
||||
1. **命令行参数**:最高优先级
|
||||
2. **环境变量**:中等优先级
|
||||
3. **配置文件**:基础优先级
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 通过命令行覆盖认证信息
|
||||
unilab --ak "new_access_key" --sk "new_secret_key"
|
||||
|
||||
# 覆盖服务器地址
|
||||
unilab --addr "https://custom.server.com/api/v1"
|
||||
|
||||
# 启用从站模式并跳过等待主机
|
||||
unilab --is_slave --slave_no_host
|
||||
|
||||
# 启用上传注册表和2D可视化
|
||||
unilab --upload_registry --2d_vis
|
||||
|
||||
# 组合使用多个覆盖参数
|
||||
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis
|
||||
```
|
||||
|
||||
### 预设环境地址
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
## 配置选项详解
|
||||
|
||||
### 基础配置 (BasicConfig)
|
||||
|
||||
基础配置包含了系统运行的核心参数:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------------- | ------------------------------------------ |
|
||||
| `ak` | str | `""` | 实验室访问密钥(必需) |
|
||||
| `sk` | str | `""` | 实验室私钥(必需) |
|
||||
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
|
||||
| `is_host_mode` | bool | `True` | 是否为主站模式 |
|
||||
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
|
||||
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
|
||||
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
|
||||
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
|
||||
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
|
||||
|
||||
#### 认证配置
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级)
|
||||
- **配置文件**:在 `BasicConfig` 类中设置
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
3. **优先级顺序**:命令行参数 > 环境变量 > 配置文件
|
||||
4. **安全注意**:请妥善保管您的密钥信息
|
||||
|
||||
**推荐做法**:
|
||||
|
||||
- 开发环境:使用配置文件
|
||||
- 生产环境:使用环境变量或命令行参数
|
||||
- 临时测试:使用命令行参数
|
||||
|
||||
### WebSocket 配置 (WSConfig)
|
||||
|
||||
WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------ | ------------------ |
|
||||
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
|
||||
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
|
||||
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
|
||||
|
||||
### HTTP 配置 (HTTPConfig)
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | --------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"http://127.0.0.1:48197/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### ROS 配置 (ROSConfig)
|
||||
|
||||
配置 ROS 消息转换器需要加载的模块:
|
||||
|
||||
```python
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg", # 标准消息类型
|
||||
"geometry_msgs.msg", # 几何消息类型
|
||||
"control_msgs.msg", # 控制消息类型
|
||||
"control_msgs.action", # 控制动作类型
|
||||
"nav2_msgs.action", # 导航动作类型
|
||||
"unilabos_msgs.msg", # UniLab 自定义消息类型
|
||||
"unilabos_msgs.action", # UniLab 自定义动作类型
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据实际使用的设备和功能添加其他 ROS 模块。
|
||||
|
||||
### OSS 上传配置 (OSSUploadConfig)
|
||||
|
||||
对象存储服务配置,用于文件上传功能:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------- | ---- | ------ | -------------------- |
|
||||
| `api_host` | str | `""` | OSS API 主机地址 |
|
||||
| `authorization` | str | `""` | 授权认证信息 |
|
||||
| `init_endpoint` | str | `""` | 上传初始化端点 |
|
||||
| `complete_endpoint` | str | `""` | 上传完成端点 |
|
||||
| `max_retries` | int | `3` | 上传失败最大重试次数 |
|
||||
|
||||
## 环境变量支持
|
||||
|
||||
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。环境变量格式为:
|
||||
|
||||
```
|
||||
UNILABOS_{配置类名}_{字段名}
|
||||
```
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```bash
|
||||
# 设置基础配置
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
|
||||
|
||||
# 设置WebSocket配置
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
### 环境变量类型转换
|
||||
|
||||
- **布尔值**:`"true"`, `"1"`, `"yes"` → `True`;其他 → `False`
|
||||
- **整数**:自动转换为 `int` 类型
|
||||
- **浮点数**:自动转换为 `float` 类型
|
||||
- **字符串**:保持原值
|
||||
|
||||
## 配置文件使用方法
|
||||
|
||||
### 1. 指定配置文件启动
|
||||
|
||||
```bash
|
||||
# 使用指定配置文件启动
|
||||
unilab --config /path/to/your/config.py
|
||||
```
|
||||
|
||||
### 2. 使用默认配置文件
|
||||
|
||||
如果不指定配置文件,系统会按以下顺序查找:
|
||||
|
||||
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
|
||||
2. 工作目录下的 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 3. 配置文件验证
|
||||
|
||||
系统启动时会自动验证配置文件:
|
||||
|
||||
- **语法检查**:确保 Python 语法正确
|
||||
- **类型检查**:验证配置项类型是否匹配
|
||||
- **必需项检查**:确保 `ak` 和 `sk` 已配置
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全配置
|
||||
|
||||
- 不要将包含密钥的配置文件提交到版本控制系统
|
||||
- 使用环境变量或命令行参数在生产环境中配置敏感信息
|
||||
- 定期更换访问密钥
|
||||
- **推荐配置方式**:
|
||||
|
||||
```bash
|
||||
# 生产环境 - 使用环境变量
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
unilab
|
||||
|
||||
# 或使用命令行参数
|
||||
unilab --ak "your_access_key" --sk "your_secret_key"
|
||||
```
|
||||
|
||||
### 2. 多环境配置
|
||||
|
||||
为不同环境创建不同的配置文件并结合命令行参数:
|
||||
|
||||
```
|
||||
configs/
|
||||
├── local_config.py # 本地开发
|
||||
├── test_config.py # 测试环境
|
||||
├── prod_config.py # 生产环境
|
||||
└── example_config.py # 示例配置
|
||||
```
|
||||
|
||||
**环境切换示例**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境
|
||||
unilab --config configs/local_config.py --addr local
|
||||
|
||||
# 测试环境
|
||||
unilab --config configs/test_config.py --addr test --upload_registry
|
||||
|
||||
# 生产环境
|
||||
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK"
|
||||
```
|
||||
|
||||
### 3. 配置管理
|
||||
|
||||
- 保持配置文件简洁,只包含需要修改的配置项
|
||||
- 为配置项添加注释说明其作用
|
||||
- 定期检查和更新配置文件
|
||||
- **命令行参数优先使用场景**:
|
||||
- 临时测试不同配置
|
||||
- CI/CD 流水线中的动态配置
|
||||
- 不同环境间快速切换
|
||||
- 敏感信息的安全传递
|
||||
|
||||
### 4. 灵活配置策略
|
||||
|
||||
**基础配置文件 + 命令行覆盖**的推荐方式:
|
||||
|
||||
```python
|
||||
# base_config.py - 基础配置
|
||||
class BasicConfig:
|
||||
# 非敏感配置写在文件中
|
||||
is_host_mode = True
|
||||
upload_registry = False
|
||||
vis_2d_enable = False
|
||||
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动时通过命令行覆盖关键参数
|
||||
unilab --config base_config.py \
|
||||
--ak "$AK" \
|
||||
--sk "$SK" \
|
||||
--addr "test" \
|
||||
--upload_registry \
|
||||
--2d_vis
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 配置文件加载失败
|
||||
|
||||
**错误信息**:`[ENV] 配置文件 xxx 不存在`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认配置文件路径正确
|
||||
- 检查文件权限是否可读
|
||||
- 确保配置文件是 `.py` 格式
|
||||
|
||||
### 2. 语法错误
|
||||
|
||||
**错误信息**:`[ENV] 加载配置文件 xxx 失败`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 检查 Python 语法是否正确
|
||||
- 确认类名和字段名拼写正确
|
||||
- 验证缩进是否正确(使用空格而非制表符)
|
||||
|
||||
### 3. 认证失败
|
||||
|
||||
**错误信息**:`后续运行必须拥有一个实验室`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认 `ak` 和 `sk` 已正确配置
|
||||
- 检查密钥是否有效
|
||||
- 确认网络连接正常
|
||||
|
||||
### 4. 环境变量不生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认环境变量名格式正确(`UNILABOS_CLASS_FIELD`)
|
||||
- 检查环境变量是否已正确设置
|
||||
- 重启系统或重新加载环境变量
|
||||
|
||||
### 5. 命令行参数不生效
|
||||
|
||||
**错误现象**:设置了命令行参数但配置没有生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`)
|
||||
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
|
||||
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
|
||||
- 查看启动日志确认参数是否被正确解析
|
||||
|
||||
### 6. 配置优先级混淆
|
||||
|
||||
**错误现象**:不确定哪个配置生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 记住优先级:命令行参数 > 环境变量 > 配置文件
|
||||
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息
|
||||
- 检查启动日志中的配置加载信息
|
||||
- 临时移除低优先级配置来测试高优先级配置是否生效
|
||||
860
docs/user_guide/graph_files.md
Normal file
@@ -0,0 +1,860 @@
|
||||
# 设备图文件说明
|
||||
|
||||
设备图文件定义了实验室中所有设备、资源及其连接关系。本文档说明如何创建和使用设备图文件。
|
||||
|
||||
## 概述
|
||||
|
||||
设备图文件采用 JSON 格式,节点定义基于 **`ResourceDict`** 标准模型(定义在 `unilabos.ros.nodes.resource_tracker`)。系统会自动处理旧格式并转换为标准格式,确保向后兼容性。
|
||||
|
||||
**核心概念**:
|
||||
|
||||
- **Nodes(节点)**: 代表设备或资源,通过 `parent` 字段建立层级关系
|
||||
- **Links(连接)**: 可选的连接关系定义,用于展示设备间的物理或通信连接
|
||||
- **UUID**: 全局唯一标识符,用于跨系统的资源追踪
|
||||
- **自动转换**: 旧格式会通过 `ResourceDictInstance.get_resource_instance_from_dict()` 自动转换
|
||||
|
||||
## 文件格式
|
||||
|
||||
Uni-Lab 支持两种格式的设备图文件:
|
||||
|
||||
### JSON 格式(推荐)
|
||||
|
||||
**优点**:
|
||||
|
||||
- 易于编辑和阅读
|
||||
- 支持注释(使用预处理)
|
||||
- 与 Web 界面完全兼容
|
||||
- 便于版本控制
|
||||
|
||||
**示例**: `workshop1.json`
|
||||
|
||||
### GraphML 格式
|
||||
|
||||
**优点**:
|
||||
|
||||
- 可用图形化工具编辑(如 yEd)
|
||||
- 适合复杂拓扑可视化
|
||||
|
||||
**示例**: `setup.graphml`
|
||||
|
||||
## JSON 文件结构
|
||||
|
||||
一个完整的 JSON 设备图文件包含两个主要部分:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
/* 设备和资源节点 */
|
||||
],
|
||||
"links": [
|
||||
/* 连接关系(可选)*/
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Nodes(节点)
|
||||
|
||||
每个节点代表一个设备或资源。节点的定义遵循 `ResourceDict` 标准模型:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "liquid_handler_1",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "液体处理工作站",
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {},
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200
|
||||
},
|
||||
"parent": null
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明(基于 ResourceDict 标准定义)**:
|
||||
|
||||
| 字段 | 必需 | 说明 | 示例 | 默认值 |
|
||||
| ------------- | ---- | ------------------------ | ---------------------------------------------------- | -------- |
|
||||
| `id` | ✓ | 唯一标识符 | `"pump_1"` | - |
|
||||
| `uuid` | | 全局唯一标识符 (UUID) | `"550e8400-e29b-41d4-a716-446655440000"` | 自动生成 |
|
||||
| `name` | ✓ | 显示名称 | `"主反应泵"` | - |
|
||||
| `type` | ✓ | 节点类型 | `"device"`, `"resource"`, `"container"`, `"deck"` 等 | - |
|
||||
| `class` | ✓ | 设备/资源类别 | `"liquid_handler"`, `"syringepump.runze"` | `""` |
|
||||
| `config` | | Python 类的初始化参数 | `{"port": "COM3"}` | `{}` |
|
||||
| `data` | | 资源的运行状态数据 | `{"status": "Idle", "position": 0.0}` | `{}` |
|
||||
| `position` | | 在图中的位置 | `{"x": 100, "y": 200}` 或完整的 pose 结构 | - |
|
||||
| `pose` | | 完整的 3D 位置信息 | 参见下文 | - |
|
||||
| `parent` | | 父节点 ID | `"deck_1"` | `null` |
|
||||
| `parent_uuid` | | 父节点 UUID | `"550e8400-..."` | `null` |
|
||||
| `children` | | 子节点 ID 列表(旧格式) | `["child1", "child2"]` | - |
|
||||
| `description` | | 资源描述 | `"用于精确控制试剂A的加料速率"` | `""` |
|
||||
| `schema` | | 资源 schema 定义 | `{}` | `{}` |
|
||||
| `model` | | 资源 3D 模型信息 | `{}` | `{}` |
|
||||
| `icon` | | 资源图标 | `"pump.webp"` | `""` |
|
||||
| `extra` | | 额外的自定义数据 | `{"custom_field": "value"}` | `{}` |
|
||||
|
||||
### Position 和 Pose(位置信息)
|
||||
|
||||
**简单格式(旧格式,兼容)**:
|
||||
|
||||
```json
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
}
|
||||
```
|
||||
|
||||
**完整格式(推荐)**:
|
||||
|
||||
```json
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 127.76,
|
||||
"height": 85.48,
|
||||
"depth": 10.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
},
|
||||
"layout": "x-y",
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
},
|
||||
"position3d": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
},
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"cross_section_type": "rectangle"
|
||||
}
|
||||
```
|
||||
|
||||
### Links(连接)
|
||||
|
||||
定义节点之间的连接关系(可选,主要用于物理连接或通信关系的可视化):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "reactor_1",
|
||||
"sourceHandle": "output",
|
||||
"targetHandle": "input",
|
||||
"type": "physical"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 必需 | 说明 | 示例 |
|
||||
| -------------- | ---- | ---------------- | ---------------------------------------- |
|
||||
| `source` | ✓ | 源节点 ID | `"pump_1"` |
|
||||
| `target` | ✓ | 目标节点 ID | `"reactor_1"` |
|
||||
| `sourceHandle` | | 源节点的连接点 | `"output"` |
|
||||
| `targetHandle` | | 目标节点的连接点 | `"input"` |
|
||||
| `type` | | 连接类型 | `"physical"`, `"communication"` |
|
||||
| `port` | | 端口映射信息 | `{"source": "port1", "target": "port2"}` |
|
||||
|
||||
**注意**: Links 主要用于图形化展示和文档说明,父子关系通过 `parent` 字段定义,不依赖 links。
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例 1:液体处理工作站(PRCXI9300)
|
||||
|
||||
这是一个真实的液体处理工作站配置,包含设备、工作台和多个板资源。
|
||||
|
||||
**文件位置**: `test/experiments/prcxi_9300.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PRCXI9300",
|
||||
"name": "PRCXI9300",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_child_name": "PRCXI_Deck_9300",
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
|
||||
},
|
||||
"host": "10.181.214.132",
|
||||
"port": 9999,
|
||||
"timeout": 10.0,
|
||||
"axis": "Left",
|
||||
"channel_num": 8,
|
||||
"setup": false,
|
||||
"debug": true,
|
||||
"simulator": true,
|
||||
"matrix_id": "71593"
|
||||
},
|
||||
"data": {},
|
||||
"children": ["PRCXI_Deck_9300"]
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck_9300",
|
||||
"name": "PRCXI_Deck_9300",
|
||||
"parent": "PRCXI9300",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 100,
|
||||
"size_y": 100,
|
||||
"size_z": 100,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck"
|
||||
},
|
||||
"data": {},
|
||||
"children": [
|
||||
"RackT1",
|
||||
"PlateT2",
|
||||
"trash",
|
||||
"PlateT4",
|
||||
"PlateT5",
|
||||
"PlateT6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RackT1",
|
||||
"name": "RackT1",
|
||||
"parent": "PRCXI_Deck_9300",
|
||||
"type": "tip_rack",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "TipRack",
|
||||
"size_x": 127.76,
|
||||
"size_y": 85.48,
|
||||
"size_z": 100
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- 使用 `parent` 字段建立层级关系(PRCXI9300 → Deck → Rack/Plate)
|
||||
- 使用 `children` 字段(旧格式)列出子节点
|
||||
- `config` 中包含设备特定的连接参数
|
||||
- `data` 存储运行时状态
|
||||
- `position` 使用简单的 x/y/z 坐标
|
||||
|
||||
### 示例 2:有机合成工作站(带 Links)
|
||||
|
||||
这是一个格林纳德反应的流动化学工作站配置,展示了完整的设备连接和通信关系。
|
||||
|
||||
**文件位置**: `test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "YugongStation",
|
||||
"name": "愚公常量合成工作站",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": [
|
||||
"PumpTransferProtocol",
|
||||
"CleanProtocol",
|
||||
"SeparateProtocol",
|
||||
"EvaporateProtocol"
|
||||
]
|
||||
},
|
||||
"data": {},
|
||||
"children": [
|
||||
"serial_pump",
|
||||
"pump_reagents",
|
||||
"flask_CH2Cl2",
|
||||
"reactor",
|
||||
"pump_workup",
|
||||
"separator_controller",
|
||||
"flask_separator",
|
||||
"rotavap",
|
||||
"column"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"name": "serial_pump",
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "serial",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "COM7",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "pump_reagents",
|
||||
"name": "pump_reagents",
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "1",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"name": "reactor",
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 430.4087301587302,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_workup": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- 多级设备层次:工作站包含多个子设备和容器
|
||||
- `links` 定义通信关系(泵通过串口连接)
|
||||
- `data` 字段存储设备状态(如泵的位置、速度等)
|
||||
- `class` 可以使用点号分层(如 `"syringepump.runze"`)
|
||||
- 容器的 `class` 可以为 `null`
|
||||
|
||||
## 格式兼容性和转换
|
||||
|
||||
### 旧格式自动转换
|
||||
|
||||
Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法自动处理旧格式的节点数据,确保向后兼容性。
|
||||
|
||||
**自动转换规则**:
|
||||
|
||||
1. **自动生成缺失字段**:
|
||||
|
||||
```python
|
||||
# 如果缺少 id,使用 name 作为 id
|
||||
if "id" not in content:
|
||||
content["id"] = content["name"]
|
||||
|
||||
# 如果缺少 uuid,自动生成
|
||||
if "uuid" not in content:
|
||||
content["uuid"] = str(uuid.uuid4())
|
||||
```
|
||||
|
||||
2. **Position 格式转换**:
|
||||
|
||||
```python
|
||||
# 旧格式:简单的 x/y 坐标
|
||||
"position": {"x": 100, "y": 200}
|
||||
|
||||
# 自动转换为新格式
|
||||
"position": {
|
||||
"position": {"x": 100, "y": 200}
|
||||
}
|
||||
```
|
||||
|
||||
3. **默认值填充**:
|
||||
|
||||
```python
|
||||
# 自动填充空字段
|
||||
if not content.get("class"):
|
||||
content["class"] = ""
|
||||
if not content.get("config"):
|
||||
content["config"] = {}
|
||||
if not content.get("data"):
|
||||
content["data"] = {}
|
||||
if not content.get("extra"):
|
||||
content["extra"] = {}
|
||||
```
|
||||
|
||||
4. **Pose 字段同步**:
|
||||
```python
|
||||
# 如果没有 pose,使用 position
|
||||
if "pose" not in content:
|
||||
content["pose"] = content.get("position", {})
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
|
||||
# 旧格式节点
|
||||
old_format_node = {
|
||||
"name": "pump_1",
|
||||
"type": "device",
|
||||
"class": "syringepump",
|
||||
"position": {"x": 100, "y": 200}
|
||||
}
|
||||
|
||||
# 自动转换为标准格式
|
||||
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
|
||||
|
||||
# 访问标准化后的数据
|
||||
print(instance.res_content.id) # "pump_1"
|
||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||
print(instance.res_content.config) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
```
|
||||
|
||||
### 格式迁移建议
|
||||
|
||||
虽然系统会自动处理旧格式,但建议在新文件中使用完整的标准格式:
|
||||
|
||||
| 字段 | 旧格式(兼容) | 新格式(推荐) |
|
||||
| ------ | ---------------------------------- | ------------------------------------------------ |
|
||||
| 标识符 | 仅 `id` 或仅 `name` | `id` + `uuid` |
|
||||
| 位置 | `"position": {"x": 100, "y": 200}` | 完整的 `pose` 结构 |
|
||||
| 父节点 | `"parent": "parent_id"` | `"parent": "parent_id"` + `"parent_uuid": "..."` |
|
||||
| 配置 | 可省略 | 显式设置为 `{}` |
|
||||
| 数据 | 可省略 | 显式设置为 `{}` |
|
||||
|
||||
## 节点类型详解
|
||||
|
||||
### Device 节点
|
||||
|
||||
设备节点代表实际的硬件设备:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "device_id",
|
||||
"name": "设备名称",
|
||||
"type": "device",
|
||||
"class": "设备类别",
|
||||
"parent": null,
|
||||
"config": {
|
||||
"port": "COM3"
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
**常见设备类别**:
|
||||
|
||||
- `liquid_handler`: 液体处理工作站
|
||||
- `liquid_handler.prcxi`: PRCXI 液体处理工作站
|
||||
- `syringepump`: 注射泵
|
||||
- `syringepump.runze`: 润泽注射泵
|
||||
- `heaterstirrer`: 加热搅拌器
|
||||
- `balance`: 天平
|
||||
- `reactor_vessel`: 反应釜
|
||||
- `serial`: 串口通信设备
|
||||
- `workstation`: 自动化工作站
|
||||
|
||||
### Resource 节点
|
||||
|
||||
资源节点代表物料容器、载具等:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "resource_id",
|
||||
"name": "资源名称",
|
||||
"type": "resource",
|
||||
"class": "资源类别",
|
||||
"parent": "父节点ID",
|
||||
"config": {
|
||||
"size_x": 127.76,
|
||||
"size_y": 85.48,
|
||||
"size_z": 100
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
**常见资源类型**:
|
||||
|
||||
- `deck`: 工作台/甲板
|
||||
- `plate`: 板(96 孔板等)
|
||||
- `tip_rack`: 枪头架
|
||||
- `tube`: 试管
|
||||
- `container`: 容器
|
||||
- `well`: 孔位
|
||||
- `bottle_carrier`: 瓶架
|
||||
|
||||
## Handle(连接点)
|
||||
|
||||
每个设备和资源可以有多个连接点(handles),用于定义可以连接的接口。
|
||||
|
||||
### 查看可用 handles
|
||||
|
||||
设备和资源的可用 handles 定义在注册表中:
|
||||
|
||||
```yaml
|
||||
# 设备注册表示例
|
||||
liquid_handler:
|
||||
handles:
|
||||
- handler_key: pipette
|
||||
io_type: source
|
||||
- handler_key: deck
|
||||
io_type: target
|
||||
```
|
||||
|
||||
### 常见 handles
|
||||
|
||||
| 设备类型 | Source Handles | Target Handles |
|
||||
| ---------- | -------------- | -------------- |
|
||||
| 泵 | output | input |
|
||||
| 反应釜 | output, vessel | input |
|
||||
| 液体处理器 | pipette | deck |
|
||||
| 板 | wells | access |
|
||||
|
||||
## 使用 Web 界面创建图文件
|
||||
|
||||
Uni-Lab 提供 Web 界面来可视化创建和编辑设备图:
|
||||
|
||||
### 1. 启动 Uni-Lab
|
||||
|
||||
```bash
|
||||
unilab
|
||||
```
|
||||
|
||||
### 2. 访问 Web 界面
|
||||
|
||||
打开浏览器访问 `http://localhost:8002`
|
||||
|
||||
### 3. 图形化编辑
|
||||
|
||||
- 拖拽添加设备和资源
|
||||
- 连线建立连接关系
|
||||
- 编辑节点属性
|
||||
- 保存为 JSON 文件
|
||||
|
||||
### 4. 导出图文件
|
||||
|
||||
点击"导出"按钮,下载 JSON 文件到本地。
|
||||
|
||||
## 从云端获取图文件
|
||||
|
||||
如果不指定`-g`参数,Uni-Lab 会自动从云端获取:
|
||||
|
||||
```bash
|
||||
# 使用云端配置
|
||||
unilab
|
||||
|
||||
# 日志会显示:
|
||||
# [INFO] 未指定设备加载文件路径,尝试从HTTP获取...
|
||||
# [INFO] 联网获取设备加载文件成功
|
||||
```
|
||||
|
||||
**云端图文件管理**:
|
||||
|
||||
1. 登录 https://uni-lab.bohrium.com
|
||||
2. 进入"设备配置"
|
||||
3. 创建或编辑配置
|
||||
4. 保存到云端
|
||||
|
||||
本地启动时会自动同步最新配置。
|
||||
|
||||
## 调试图文件
|
||||
|
||||
### 验证 JSON 格式
|
||||
|
||||
```bash
|
||||
# 使用Python验证
|
||||
python -c "import json; json.load(open('workshop1.json'))"
|
||||
|
||||
# 使用在线工具
|
||||
# https://jsonlint.com/
|
||||
```
|
||||
|
||||
### 检查节点引用
|
||||
|
||||
确保:
|
||||
|
||||
- 所有`links`中的`source`和`target`都存在于`nodes`中
|
||||
- `parent`字段指向的节点存在
|
||||
- `class`字段对应的设备/资源在注册表中存在
|
||||
|
||||
### 启动时验证
|
||||
|
||||
```bash
|
||||
# Uni-Lab启动时会验证图文件
|
||||
unilab -g workshop1.json
|
||||
|
||||
# 查看日志中的错误或警告
|
||||
# [ERROR] 节点 xxx 的source端点 yyy 不存在
|
||||
# [WARNING] 节点 zzz missing 'name', defaulting to ...
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 命名规范
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_reagent_1", // 小写+下划线,描述性
|
||||
"name": "试剂进料泵A", // 中文显示名称
|
||||
"class": "syringepump" // 使用注册表中的精确名称
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 层级组织
|
||||
|
||||
```
|
||||
host_node (主节点)
|
||||
└── liquid_handler_1 (设备)
|
||||
└── deck_1 (资源)
|
||||
├── tiprack_1 (资源)
|
||||
├── plate_1 (资源)
|
||||
└── reservoir_1 (资源)
|
||||
```
|
||||
|
||||
### 3. 配置分离
|
||||
|
||||
将设备特定配置放在`config`中:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_1",
|
||||
"class": "syringepump",
|
||||
"config": {
|
||||
"port": "COM3", // 设备特定
|
||||
"max_flow_rate": 10, // 设备特定
|
||||
"volume": 50 // 设备特定
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 版本控制
|
||||
|
||||
```bash
|
||||
# 使用Git管理图文件
|
||||
git add workshop1.json
|
||||
git commit -m "Add new liquid handler configuration"
|
||||
|
||||
# 使用有意义的文件名
|
||||
workshop_v1.json
|
||||
workshop_production.json
|
||||
workshop_test.json
|
||||
```
|
||||
|
||||
### 5. 注释(通过描述字段)
|
||||
|
||||
虽然 JSON 不支持注释,但可以使用`description`字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_1",
|
||||
"name": "进料泵",
|
||||
"description": "用于精确控制试剂A的加料速率,最大流速10mL/min",
|
||||
"class": "syringepump"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例文件位置
|
||||
|
||||
Uni-Lab 在安装时已预置了 **40+ 个真实的设备图文件示例**,位于 `unilabos/test/experiments/` 目录。这些都是真实项目中使用的配置文件,可以直接使用或作为参考。
|
||||
|
||||
### 📁 主要示例文件
|
||||
|
||||
```
|
||||
test/experiments/
|
||||
├── workshop.json # 综合工作台(推荐新手)
|
||||
├── empty_devices.json # 空设备配置(最小化)
|
||||
├── prcxi_9300.json # PRCXI液体处理工作站(本文示例1)
|
||||
├── prcxi_9320.json # PRCXI 9320工作站
|
||||
├── biomek.json # Biomek液体处理工作站
|
||||
├── Grignard_flow_batchreact_single_pumpvalve.json # 格林纳德反应工作站(本文示例2)
|
||||
├── dispensing_station_bioyond.json # Bioyond配液站
|
||||
├── reaction_station_bioyond.json # Bioyond反应站
|
||||
├── HPLC.json # HPLC分析系统
|
||||
├── plr_test.json # PyLabRobot测试配置
|
||||
├── lidocaine-graph.json # 利多卡因合成工作站
|
||||
├── opcua_example.json # OPC UA设备集成示例
|
||||
│
|
||||
├── mock_devices/ # 虚拟设备(用于离线测试)
|
||||
│ ├── mock_all.json # 完整虚拟设备集
|
||||
│ ├── mock_pump.json # 虚拟泵
|
||||
│ ├── mock_stirrer.json # 虚拟搅拌器
|
||||
│ ├── mock_heater.json # 虚拟加热器
|
||||
│ └── ... # 更多虚拟设备
|
||||
│
|
||||
├── Protocol_Test_Station/ # 协议测试工作站
|
||||
│ ├── pumptransfer_test_station.json # 泵转移协议测试
|
||||
│ ├── heatchill_protocol_test_station.json # 加热冷却协议测试
|
||||
│ ├── filter_protocol_test_station.json # 过滤协议测试
|
||||
│ └── ... # 更多协议测试
|
||||
│
|
||||
└── comprehensive_protocol/ # 综合协议示例
|
||||
├── comprehensive_station.json # 综合工作站
|
||||
└── comprehensive_slim.json # 精简版综合工作站
|
||||
```
|
||||
|
||||
### 🚀 快速使用
|
||||
|
||||
无需下载或创建,直接使用 `-g` 参数指定路径:
|
||||
|
||||
```bash
|
||||
# 使用简单工作台(推荐新手)
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json
|
||||
|
||||
# 使用虚拟设备(无需真实硬件)
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
|
||||
# 使用 PRCXI 液体处理工作站
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json
|
||||
|
||||
# 使用格林纳德反应工作站
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json
|
||||
```
|
||||
|
||||
### 📚 文件分类
|
||||
|
||||
| 类别 | 说明 | 文件数量 |
|
||||
| ------------ | ------------------------ | -------- |
|
||||
| **主工作站** | 完整的实验工作站配置 | 15+ |
|
||||
| **虚拟设备** | 用于开发测试的 mock 设备 | 10+ |
|
||||
| **协议测试** | 各种实验协议的测试配置 | 12+ |
|
||||
| **综合示例** | 包含多种协议的综合工作站 | 3+ |
|
||||
|
||||
这些文件展示了不同场景下的设备图配置,涵盖液体处理、有机合成、分析检测等多个领域,是学习和创建自己配置的绝佳参考。
|
||||
|
||||
## 快速参考:ResourceDict 完整字段列表
|
||||
|
||||
基于 `unilabos.ros.nodes.resource_tracker.ResourceDict` 的完整字段定义:
|
||||
|
||||
```python
|
||||
class ResourceDict(BaseModel):
|
||||
# === 基础标识 ===
|
||||
id: str # 资源ID(必需)
|
||||
uuid: str # 全局唯一标识符(自动生成)
|
||||
name: str # 显示名称(必需)
|
||||
|
||||
# === 类型和分类 ===
|
||||
type: Union[Literal["device"], str] # 节点类型(必需)
|
||||
klass: str # 资源类别(alias="class",必需)
|
||||
|
||||
# === 层级关系 ===
|
||||
parent: Optional[ResourceDict] # 父资源对象(不序列化)
|
||||
parent_uuid: Optional[str] # 父资源UUID
|
||||
|
||||
# === 位置和姿态 ===
|
||||
position: ResourceDictPosition # 位置信息
|
||||
pose: ResourceDictPosition # 姿态信息(推荐使用)
|
||||
|
||||
# === 配置和数据 ===
|
||||
config: Dict[str, Any] # 设备配置参数
|
||||
data: Dict[str, Any] # 运行时状态数据
|
||||
extra: Dict[str, Any] # 额外自定义数据
|
||||
|
||||
# === 元数据 ===
|
||||
description: str # 资源描述
|
||||
resource_schema: Dict[str, Any] # schema定义(alias="schema")
|
||||
model: Dict[str, Any] # 3D模型信息
|
||||
icon: str # 图标路径
|
||||
```
|
||||
|
||||
**Position/Pose 结构**:
|
||||
|
||||
```python
|
||||
class ResourceDictPosition(BaseModel):
|
||||
size: ResourceDictPositionSize # width, height, depth
|
||||
scale: ResourceDictPositionScale # x, y, z
|
||||
layout: Literal["2d", "x-y", "z-y", "x-z"]
|
||||
position: ResourceDictPositionObject # x, y, z
|
||||
position3d: ResourceDictPositionObject # x, y, z
|
||||
rotation: ResourceDictPositionObject # x, y, z
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- {doc}`../boot_examples/index` - 查看完整启动示例
|
||||
- {doc}`../developer_guide/add_device` - 了解如何添加新设备
|
||||
- {doc}`06_troubleshooting` - 图文件相关问题排查
|
||||
- 源码参考: `unilabos/ros/nodes/resource_tracker.py` - ResourceDict 标准定义
|
||||
|
||||
## 获取帮助
|
||||
|
||||
- 在 Web 界面中使用模板创建
|
||||
- 参考示例文件:`test/experiments/` 目录
|
||||
- 查看 ResourceDict 源码了解完整定义
|
||||
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
BIN
docs/user_guide/image/test_latency_result.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/user_guide/image/test_latency_running.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/user_guide/image/test_latency_select_device.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
@@ -1,43 +1,516 @@
|
||||
# **Uni-Lab 安装**
|
||||
# Uni-Lab-OS 安装指南
|
||||
|
||||
## 快速开始
|
||||
本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。
|
||||
|
||||
1. **配置 Conda 环境**
|
||||
## 系统要求
|
||||
|
||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境:
|
||||
- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+)
|
||||
- **内存**: 最小 4GB,推荐 8GB 以上
|
||||
- **磁盘空间**: 至少 10GB 可用空间
|
||||
- **网络**: 稳定的互联网连接(用于下载软件包)
|
||||
- **其他**:
|
||||
- 已安装 Conda/Miniconda/Miniforge/Mamba
|
||||
- 开发者需要 Git 和基本的 Python 开发知识
|
||||
- 自定义 msgs 需要 GitHub 账号
|
||||
|
||||
```shell
|
||||
## 安装方式选择
|
||||
|
||||
根据您的使用场景,选择合适的安装方式:
|
||||
|
||||
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||
|
||||
---
|
||||
|
||||
## 方式一:一键安装(推荐新用户)
|
||||
|
||||
使用预打包的 conda 环境,最快速的安装方法。
|
||||
|
||||
### 前置条件
|
||||
|
||||
确保已安装 Conda/Miniconda/Miniforge/Mamba。
|
||||
|
||||
### 安装步骤
|
||||
|
||||
#### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
|
||||
- Windows: `unilab-pack-win-64-{branch}.zip`
|
||||
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
|
||||
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
|
||||
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
|
||||
|
||||
#### 第二步:解压并运行安装脚本
|
||||
|
||||
**Windows**:
|
||||
|
||||
```batch
|
||||
REM 使用 Windows 资源管理器解压下载的 zip 文件
|
||||
REM 或使用命令行:
|
||||
tar -xzf unilab-pack-win-64-dev.zip
|
||||
|
||||
REM 进入解压后的目录
|
||||
cd unilab-pack-win-64-dev
|
||||
|
||||
REM 双击运行 install_unilab.bat
|
||||
REM 或在命令行中执行:
|
||||
install_unilab.bat
|
||||
```
|
||||
|
||||
**macOS**:
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-osx-arm64-dev
|
||||
|
||||
# 运行安装脚本
|
||||
bash install_unilab.sh
|
||||
```
|
||||
|
||||
**Linux**:
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-linux-64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-linux-64-dev
|
||||
|
||||
# 添加执行权限(如果需要)
|
||||
chmod +x install_unilab.sh
|
||||
|
||||
# 运行安装脚本
|
||||
./install_unilab.sh
|
||||
```
|
||||
|
||||
#### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。
|
||||
|
||||
---
|
||||
|
||||
## 方式二:手动安装(标准用户)
|
||||
|
||||
适合生产环境和需要灵活配置的用户。
|
||||
|
||||
### 第一步:安装 Mamba 环境管理器
|
||||
|
||||
Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。
|
||||
|
||||
#### Windows
|
||||
|
||||
下载并安装 Miniforge(包含 Mamba):
|
||||
|
||||
```powershell
|
||||
# 访问 https://github.com/conda-forge/miniforge/releases
|
||||
# 下载 Miniforge3-Windows-x86_64.exe
|
||||
# 运行安装程序
|
||||
|
||||
# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/
|
||||
# 下载 Miniforge3-Windows-x86_64.exe
|
||||
# 运行安装程序
|
||||
```
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 下载 Miniforge 安装脚本
|
||||
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
|
||||
|
||||
# 运行安装
|
||||
bash Miniforge3-$(uname)-$(uname -m).sh
|
||||
|
||||
# 按照提示完成安装,建议选择 yes 来初始化
|
||||
```
|
||||
|
||||
安装完成后,重新打开终端使 Mamba 生效。
|
||||
|
||||
### 第二步:创建 Uni-Lab 环境
|
||||
|
||||
使用以下命令创建 Uni-Lab 专用环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||
|
||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||
|
||||
```bash
|
||||
# 配置清华镜像源
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
|
||||
# 然后重新执行安装命令
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:开发者安装
|
||||
|
||||
适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 已安装 Git
|
||||
- 已安装 Mamba/Conda
|
||||
- 有 GitHub 账号(如需自定义 msgs)
|
||||
- 基本的 Python 开发知识
|
||||
|
||||
### 第一步:克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
如果您需要贡献代码,建议先 Fork 仓库:
|
||||
|
||||
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||
2. 点击右上角的 "Fork" 按钮
|
||||
3. Clone 您的 Fork 版本:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
### 第二步:安装基础环境
|
||||
|
||||
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||
|
||||
#### 选项 A:通过一键安装(推荐)
|
||||
|
||||
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
#### 选项 B:通过手动安装
|
||||
|
||||
参考上文"方式二:手动安装",创建并安装环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11
|
||||
conda activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
|
||||
|
||||
### 第三步:切换到开发版本
|
||||
|
||||
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||
pip uninstall unilabos -y
|
||||
|
||||
# 克隆 dev 分支(如果还未克隆)
|
||||
cd /path/to/your/workspace
|
||||
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# 或者如果已经克隆,切换到 dev 分支
|
||||
cd Uni-Lab-OS
|
||||
git checkout dev
|
||||
git pull
|
||||
|
||||
# 以可编辑模式安装开发版 unilabos
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||
- `-i`: 使用清华镜像源加速下载
|
||||
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||
|
||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||
|
||||
Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装,msgs 包已经自动安装。
|
||||
|
||||
#### 使用已安装的 msgs(大多数用户)
|
||||
|
||||
如果你不需要修改 msgs,可以跳过此步骤,直接使用已安装的 msgs 包。验证安装:
|
||||
|
||||
```bash
|
||||
# 列出所有 unilabos_msgs 接口
|
||||
ros2 interface list | grep unilabos_msgs
|
||||
|
||||
# 查看特定 action 定义
|
||||
ros2 interface show unilabos_msgs/action/DeviceCmd
|
||||
```
|
||||
|
||||
#### 自定义 msgs(高级用户)
|
||||
|
||||
如果你需要:
|
||||
|
||||
- 添加新的 ROS2 action 定义
|
||||
- 修改现有 msg/srv/action 接口
|
||||
- 为特定设备定制通信协议
|
||||
|
||||
请参考 **[添加新动作指令(Action)指南](../developer_guide/add_action.md)**,该指南详细介绍了如何:
|
||||
|
||||
- 编写新的 Action 定义
|
||||
- 在线构建 Action(通过 GitHub Actions)
|
||||
- 下载并安装自定义的 msgs 包
|
||||
- 测试和验证新的 Action
|
||||
|
||||
```bash
|
||||
# 安装自定义构建的 msgs 包
|
||||
mamba remove --force ros-humble-unilabos-msgs
|
||||
mamba config set safety_checks disabled # 关闭 md5 检查
|
||||
mamba install /path/to/ros-humble-unilabos-msgs-*.conda --offline
|
||||
```
|
||||
|
||||
### 第五步:验证开发环境
|
||||
|
||||
完成上述步骤后,验证开发环境是否正确配置:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 检查 ROS2 环境
|
||||
ros2 --version
|
||||
|
||||
# 检查 msgs 包
|
||||
ros2 interface list | grep unilabos_msgs
|
||||
|
||||
# 检查 Python 可以导入 unilabos
|
||||
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
|
||||
|
||||
# 检查 unilab 命令
|
||||
unilab --help
|
||||
```
|
||||
|
||||
如果所有命令都正常输出,说明开发环境配置成功!
|
||||
|
||||
---
|
||||
|
||||
## 验证安装
|
||||
|
||||
无论使用哪种安装方式,都应该验证安装是否成功。
|
||||
|
||||
### 基本验证
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab # 或 unilab-dev
|
||||
|
||||
# 检查 unilab 命令
|
||||
unilab --help
|
||||
```
|
||||
|
||||
您应该看到类似以下的输出:
|
||||
|
||||
```
|
||||
usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH]
|
||||
[--working_dir WORKING_DIR] [--backend {ros,simple,automancer}]
|
||||
...
|
||||
```
|
||||
|
||||
### 检查版本
|
||||
|
||||
```bash
|
||||
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
|
||||
```
|
||||
|
||||
### 使用验证脚本(方式一)
|
||||
|
||||
如果使用一键安装,可以运行预打包的验证脚本:
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab
|
||||
|
||||
# 运行验证脚本
|
||||
python verify_installation.py
|
||||
```
|
||||
|
||||
如果看到 "✓ All checks passed!",说明安装成功!
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1: 找不到 unilab 命令
|
||||
|
||||
**原因**: 环境未正确激活或 PATH 未设置
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保激活了正确的环境
|
||||
conda activate unilab
|
||||
|
||||
# 检查 unilab 是否在 PATH 中
|
||||
which unilab # Linux/macOS
|
||||
where unilab # Windows
|
||||
```
|
||||
|
||||
### 问题 2: 包冲突或依赖错误
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 删除旧环境重新创建
|
||||
conda deactivate
|
||||
conda env remove -n unilab
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
2. **安装开发版 Uni-Lab-OS**
|
||||
### 问题 3: 下载速度慢
|
||||
|
||||
```shell
|
||||
# 配置好conda环境后,克隆仓库
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
|
||||
cd Uni-Lab-OS
|
||||
**解决方案**: 使用国内镜像源(清华、中科大等)
|
||||
|
||||
# 安装 Uni-Lab-OS
|
||||
pip install -e .
|
||||
```bash
|
||||
# 查看当前 channel 配置
|
||||
conda config --show channels
|
||||
|
||||
# 添加清华镜像
|
||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
```
|
||||
|
||||
3. **安装开发版 ros-humble-unilabos-msgs**
|
||||
### 问题 4: 权限错误
|
||||
|
||||
**卸载老版本:**
|
||||
```shell
|
||||
**Windows 解决方案**: 以管理员身份运行命令提示符
|
||||
|
||||
**Linux/macOS 解决方案**:
|
||||
|
||||
```bash
|
||||
# 不要使用 sudo 安装 conda 包
|
||||
# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录
|
||||
```
|
||||
|
||||
### 问题 5: 安装脚本找不到 conda(方式一)
|
||||
|
||||
**解决方案**: 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置:
|
||||
|
||||
- **Windows**:
|
||||
|
||||
- `%USERPROFILE%\miniforge3`
|
||||
- `%USERPROFILE%\miniconda3`
|
||||
- `%USERPROFILE%\anaconda3`
|
||||
- `C:\ProgramData\miniforge3`
|
||||
|
||||
- **macOS/Linux**:
|
||||
- `~/miniforge3`
|
||||
- `~/miniconda3`
|
||||
- `~/anaconda3`
|
||||
- `/opt/conda`
|
||||
|
||||
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
|
||||
|
||||
### 问题 6: 安装后激活环境提示找不到?
|
||||
|
||||
**解决方案**: 尝试以下方法:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用 conda activate
|
||||
conda activate unilab
|
||||
conda remove --force ros-humble-unilabos-msgs
|
||||
```
|
||||
有时相同的安装包版本会由于dev构建得到的md5不一样,触发安全检查,可输入 `config set safety_checks disabled` 来关闭安全检查。
|
||||
|
||||
**安装新版本:**
|
||||
# 方法 2: 使用完整路径激活(Windows)
|
||||
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
|
||||
|
||||
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件)使用如下指令:
|
||||
```shell
|
||||
conda activate base
|
||||
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
|
||||
# 方法 2: 使用完整路径激活(Unix)
|
||||
source ~/miniforge3/envs/unilab/bin/activate
|
||||
```
|
||||
|
||||
4. **启动 Uni-Lab 系统**
|
||||
### 问题 7: conda-unpack 失败怎么办?(方式一)
|
||||
|
||||
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。
|
||||
**解决方案**: 尝试手动运行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
cd %CONDA_PREFIX%\envs\unilab
|
||||
.\Scripts\conda-unpack.exe
|
||||
|
||||
# macOS/Linux
|
||||
cd $CONDA_PREFIX/envs/unilab
|
||||
./bin/conda-unpack
|
||||
```
|
||||
|
||||
### 问题 8: 环境很大,有办法减小吗?
|
||||
|
||||
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||
|
||||
### 问题 9: 如何更新到最新版本?
|
||||
|
||||
**解决方案**:
|
||||
|
||||
**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
|
||||
|
||||
**方式二/三用户**: 在现有环境中更新:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
|
||||
# 更新 unilabos
|
||||
cd /path/to/Uni-Lab-OS
|
||||
git pull
|
||||
pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 更新 ros-humble-unilabos-msgs
|
||||
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
安装完成后,请继续:
|
||||
|
||||
- **快速启动**: 学习如何首次启动 Uni-Lab
|
||||
- **配置指南**: 配置您的实验室环境和设备
|
||||
- **运行示例**: 查看启动示例和最佳实践
|
||||
- **开发指南**:
|
||||
- 添加新设备驱动
|
||||
- 添加新物料资源
|
||||
- 了解工作站架构
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
- **故障排查**: 查看更详细的故障排查信息
|
||||
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
|
||||
---
|
||||
|
||||
**提示**:
|
||||
|
||||
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||
- 开发和测试推荐使用方式三(开发者安装)
|
||||
- 快速体验和演示推荐使用方式一(一键安装)
|
||||
|
||||
@@ -132,15 +132,14 @@ unilab --config path/to/your/config.py
|
||||
|
||||
使用 `-c` 传入控制逻辑配置。
|
||||
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`。
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`,只输入<your-registry-path>即可,支持多次--registry_path指定多个目录。
|
||||
|
||||
## 通信中间件 `--backend`
|
||||
|
||||
目前 Uni-Lab 支持以下通信中间件:
|
||||
|
||||
- **ros** (默认):基于 ROS2 的通信
|
||||
- **simple**:简化通信模式
|
||||
- **automancer**:Automancer 兼容模式
|
||||
- **automancer**:Automancer 兼容模式 (实验性)
|
||||
|
||||
## 端云桥接 `--app_bridges`
|
||||
|
||||
@@ -169,7 +168,7 @@ unilab --config path/to/your/config.py
|
||||
通过 `--visual` 参数选择:
|
||||
|
||||
- **rviz**:使用 RViz 进行 3D 可视化
|
||||
- **web**:使用 Web 界面进行可视化
|
||||
- **web**:使用 Web 界面进行可视化 (基于Pylabrobot)
|
||||
- **disable** (默认):禁用可视化
|
||||
|
||||
## 实验室管理
|
||||
@@ -245,78 +244,3 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
||||
- 检查图谱文件格式是否正确
|
||||
- 验证设备连接和端点配置
|
||||
- 确保注册表路径正确
|
||||
|
||||
## 页面操作
|
||||
|
||||
### 1. 启动成功
|
||||
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
|
||||

|
||||
|
||||
### 2. 根据需求创建设备和物料
|
||||
我们可以做一个简单的案例
|
||||
* 在容器1中加入水
|
||||
* 通过传输泵将容器1中的水转移到容器2中
|
||||
#### 2.1 添加所需的设备和物料
|
||||
仪器设备work_station中的workstation 数量x1
|
||||
仪器设备virtual_device中的virtual_transfer_pump 数量x1
|
||||
物料耗材container中的container 数量x2
|
||||
|
||||
#### 2.2 将设备和物料根据父子关系进行关联
|
||||
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
|
||||
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来,就像真实的设备操作一样
|
||||

|
||||
|
||||
### 3. 创建工作流
|
||||
进入工作流模块 → 点击"我创建的" → 新建工作流
|
||||

|
||||
|
||||
#### 3.1 新增工作流节点
|
||||
我们可以进入指定工作流,在空白处右键
|
||||
* 选择Laboratory→host_node中的creat_resource
|
||||
* 选择Laboratory→workstation中的PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||
#### 3.2 配置节点参数
|
||||
根据案例,工作流包含两个步骤:
|
||||
1. 使用creat_resource在容器中创建水
|
||||
2. 通过泵传输协议将水传输到另一个容器
|
||||
|
||||
我们点击creat_resource卡片上的编辑按钮来配置参数⭐️
|
||||
class_name :container
|
||||
device_id : workstation
|
||||
liquid_input_slot : 0或-1均可
|
||||
liquid_type : water
|
||||
liquid_volume : 根据需求填写即可,默认单位ml,这里举例50
|
||||
parent : workstation
|
||||
res_id : containe
|
||||
关联设备名称(原unilabos_device_id) : 这里就填写host_node
|
||||
**配置完成后点击底部保存按钮**
|
||||
|
||||
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐️
|
||||
event : transfer_liquid
|
||||
from_vessel : water
|
||||
to_vessel : container1
|
||||
volume : 根据需求填写即可,默认单位ml,这里举例50
|
||||
关联设备名称(原unilabos_device_id) : 这里就填写workstation
|
||||
**配置完成后点击底部保存按钮**
|
||||
|
||||
#### 3.3 运行工作流
|
||||
1. 连接两个节点卡片
|
||||
2. 点击底部保存按钮
|
||||
3. 点击运行按钮执行工作流
|
||||
|
||||

|
||||
|
||||
### 运行监控
|
||||
* 运行状态和消息实时显示在底部控制台
|
||||
* 如有报错,可点击查看详细信息
|
||||
|
||||
### 结果验证
|
||||
工作流完成后,返回仪器耗材模块:
|
||||
* 点击 container1卡片查看详情
|
||||
* 确认其中包含参数指定的水和容量
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# Uni-Lab-OS 一键安装快速指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南提供最快速的 Uni-Lab-OS 安装方法,使用预打包的 conda 环境,无需手动配置依赖。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- 已安装 Conda/Miniconda/Miniforge/Mamba
|
||||
- 至少 10GB 可用磁盘空间
|
||||
- Windows 10+, macOS 10.14+, 或 Linux (Ubuntu 20.04+)
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
|
||||
- Windows: `unilab-pack-win-64-{branch}.zip`
|
||||
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
|
||||
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
|
||||
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
|
||||
|
||||
### 第二步:解压并运行安装脚本
|
||||
|
||||
#### Windows
|
||||
|
||||
```batch
|
||||
REM 使用 Windows 资源管理器解压下载的 zip 文件
|
||||
REM 或使用命令行:
|
||||
tar -xzf unilab-pack-win-64-dev.zip
|
||||
|
||||
REM 进入解压后的目录
|
||||
cd unilab-pack-win-64-dev
|
||||
|
||||
REM 双击运行 install_unilab.bat
|
||||
REM 或在命令行中执行:
|
||||
install_unilab.bat
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-osx-arm64-dev
|
||||
|
||||
# 运行安装脚本
|
||||
bash install_unilab.sh
|
||||
```
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-linux-64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-linux-64-dev
|
||||
|
||||
# 添加执行权限(如果需要)
|
||||
chmod +x install_unilab.sh
|
||||
|
||||
# 运行安装脚本
|
||||
./install_unilab.sh
|
||||
```
|
||||
|
||||
### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
### 第四步:验证安装(推荐)
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab
|
||||
|
||||
# 运行验证脚本
|
||||
python verify_installation.py
|
||||
```
|
||||
|
||||
如果看到 "✓ All checks passed!",说明安装成功!
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 安装脚本找不到 conda?
|
||||
|
||||
**A:** 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置:
|
||||
|
||||
- **Windows**:
|
||||
|
||||
- `%USERPROFILE%\miniforge3`
|
||||
- `%USERPROFILE%\miniconda3`
|
||||
- `%USERPROFILE%\anaconda3`
|
||||
- `C:\ProgramData\miniforge3`
|
||||
|
||||
- **macOS/Linux**:
|
||||
- `~/miniforge3`
|
||||
- `~/miniconda3`
|
||||
- `~/anaconda3`
|
||||
- `/opt/conda`
|
||||
|
||||
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
|
||||
|
||||
### Q: 安装后激活环境提示找不到?
|
||||
|
||||
**A:** 尝试以下方法:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用 conda activate
|
||||
conda activate unilab
|
||||
|
||||
# 方法 2: 使用完整路径激活(Windows)
|
||||
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
|
||||
|
||||
# 方法 2: 使用完整路径激活(Unix)
|
||||
source ~/miniforge3/envs/unilab/bin/activate
|
||||
```
|
||||
|
||||
### Q: conda-unpack 失败怎么办?
|
||||
|
||||
**A:** 尝试手动运行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
cd %CONDA_PREFIX%\envs\unilab
|
||||
.\Scripts\conda-unpack.exe
|
||||
|
||||
# macOS/Linux
|
||||
cd $CONDA_PREFIX/envs/unilab
|
||||
./bin/conda-unpack
|
||||
```
|
||||
|
||||
### Q: 验证脚本报错?
|
||||
|
||||
**A:** 首先确认环境已激活:
|
||||
|
||||
```bash
|
||||
# 检查当前环境
|
||||
conda env list
|
||||
|
||||
# 应该看到 unilab 前面有 * 标记
|
||||
```
|
||||
|
||||
如果仍有问题,查看具体报错信息,可能需要:
|
||||
|
||||
- 重新运行安装脚本
|
||||
- 检查磁盘空间
|
||||
- 查看详细文档
|
||||
|
||||
### Q: 环境很大,有办法减小吗?
|
||||
|
||||
**A:** 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用手动安装方式,只安装需要的组件。
|
||||
|
||||
### Q: 如何更新到最新版本?
|
||||
|
||||
**A:** 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
|
||||
|
||||
或者在现有环境中更新:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
|
||||
# 更新 unilabos
|
||||
cd /path/to/Uni-Lab-OS
|
||||
git pull
|
||||
pip install -e . --upgrade
|
||||
|
||||
# 更新 ros-humble-unilabos-msgs
|
||||
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
安装完成后,你可以:
|
||||
|
||||
1. **查看启动指南**: {doc}`launch`
|
||||
2. **运行示例**: {doc}`../boot_examples/index`
|
||||
3. **配置设备**: 编辑 `unilabos_data/startup_config.json`
|
||||
4. **阅读开发文档**: {doc}`../developer_guide/workstation_architecture`
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
- **文档**: [docs/user_guide/installation.md](installation.md)
|
||||
- **问题反馈**: [GitHub Issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **开发版安装**: 参考 {doc}`installation` 的方式二
|
||||
|
||||
---
|
||||
|
||||
**提示**: 这个预打包环境包含了从指定分支(通常是 `dev`)构建的最新代码。如果需要稳定版本,请使用方式二手动安装 release 版本。
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.10
|
||||
version: 0.10.13
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.10"
|
||||
version: "0.10.13"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.10',
|
||||
version='0.10.13',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
0
tests/resources/__init__.py
Normal file
0
tests/ros/__init__.py
Normal file
0
tests/workflow/__init__.py
Normal file
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
35
tests/workflow/merge_workflow.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.workflow.convert_from_json import (
|
||||
convert_from_json,
|
||||
normalize_steps as _normalize_steps,
|
||||
normalize_labware as _normalize_labware,
|
||||
)
|
||||
from unilabos.workflow.common import draw_protocol_graph_with_ports
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"protocol_name",
|
||||
[
|
||||
"example_bio",
|
||||
# "bioyond_materials_liquidhandling_1",
|
||||
"example_prcxi",
|
||||
],
|
||||
)
|
||||
def test_build_protocol_graph(protocol_name):
|
||||
data_path = Path(__file__).with_name(f"{protocol_name}.json")
|
||||
|
||||
graph = convert_from_json(data_path, workstation_name="PRCXi")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
|
||||
draw_protocol_graph_with_ports(graph, str(output_path))
|
||||
print(graph)
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.10"
|
||||
__version__ = "0.10.13"
|
||||
|
||||
@@ -141,7 +141,7 @@ class CommunicationClientFactory:
|
||||
"""
|
||||
if cls._client_cache is None:
|
||||
cls._client_cache = cls.create_client(protocol)
|
||||
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
|
||||
return cls._client_cache
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8002,
|
||||
default=None,
|
||||
help="Port for web service information page",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -159,9 +159,10 @@ def parse_args():
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
args = parse_args()
|
||||
convert_argv_dashes_to_underscores(args)
|
||||
args_dict = vars(args.parse_args())
|
||||
parser = parse_args()
|
||||
convert_argv_dashes_to_underscores(parser)
|
||||
args = parser.parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
@@ -218,19 +219,20 @@ def main():
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
configure_logger(loglevel=BasicConfig.log_level)
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
|
||||
if args_dict["addr"] == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args.addr == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args.addr == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args.addr
|
||||
|
||||
# 设置BasicConfig参数
|
||||
if args_dict.get("ak", ""):
|
||||
@@ -252,6 +254,8 @@ def main():
|
||||
else:
|
||||
print_status("远程资源不存在,本地将进行首次上报!", "info")
|
||||
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
@@ -291,7 +295,9 @@ def main():
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
request_startup_json = http_client.request_startup_json()
|
||||
if args_dict["graph"] is None:
|
||||
|
||||
file_path = args_dict.get("graph", BasicConfig.startup_json_path)
|
||||
if file_path is None:
|
||||
if not request_startup_json:
|
||||
print_status(
|
||||
"未指定设备加载文件路径,尝试从HTTP获取失败,请检查网络或者使用-g参数指定设备加载文件路径", "error"
|
||||
@@ -301,7 +307,11 @@ def main():
|
||||
print_status("联网获取设备加载文件成功", "info")
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
|
||||
else:
|
||||
file_path = args_dict["graph"]
|
||||
if not os.path.isfile(file_path):
|
||||
temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path)))
|
||||
if os.path.isfile(temp_file_path):
|
||||
print_status(f"使用相对路径{temp_file_path}", "info")
|
||||
file_path = temp_file_path
|
||||
if file_path.endswith(".json"):
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
|
||||
else:
|
||||
@@ -319,6 +329,10 @@ def main():
|
||||
for ind, i in enumerate(resource_edge_info[::-1]):
|
||||
source_node: ResourceDict = nodes[i["source"]]
|
||||
target_node: ResourceDict = nodes[i["target"]]
|
||||
if "sourceHandle" not in source_node:
|
||||
continue
|
||||
if "targetHandle" not in target_node:
|
||||
continue
|
||||
source_handle = i["sourceHandle"]
|
||||
target_handle = i["targetHandle"]
|
||||
source_handler_keys = [
|
||||
@@ -413,8 +427,8 @@ def main():
|
||||
server_thread = threading.Thread(
|
||||
target=start_server,
|
||||
kwargs=dict(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
open_browser=not BasicConfig.disable_browser,
|
||||
port=BasicConfig.port,
|
||||
),
|
||||
)
|
||||
server_thread.start()
|
||||
@@ -442,13 +456,13 @@ def main():
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,21 +51,25 @@ class Resp(BaseModel):
|
||||
class JobAddReq(BaseModel):
|
||||
device_id: str = Field(examples=["Gripper"], description="device id")
|
||||
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
|
||||
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
|
||||
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid")
|
||||
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid", default="")
|
||||
server_info: dict = Field(
|
||||
examples=[{"send_timestamp": 1717000000.0}],
|
||||
description="server info (auto-generated if empty)",
|
||||
default_factory=dict,
|
||||
)
|
||||
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict)
|
||||
|
||||
|
||||
class JobStepFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -83,9 +87,7 @@ class JobStepFinishReq(BaseModel):
|
||||
|
||||
class JobPreintakeFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -102,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel):
|
||||
|
||||
class JobFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -133,6 +133,10 @@ class JobData(BaseModel):
|
||||
default=0,
|
||||
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
|
||||
)
|
||||
result: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)",
|
||||
)
|
||||
|
||||
|
||||
class JobStatusResp(Resp):
|
||||
|
||||
@@ -1,161 +1,158 @@
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
from unilabos.config.config import OSSUploadConfig
|
||||
from unilabos.app.web.client import http_client, HTTPClient
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default",
|
||||
expires_hours: int = 1) -> Tuple[bool, Dict]:
|
||||
def _get_oss_token(
|
||||
filename: str,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Tuple[bool, Dict]:
|
||||
"""
|
||||
初始化上传过程
|
||||
获取OSS上传Token
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
expires_hours: 链接过期小时数
|
||||
filename: 文件名
|
||||
driver_name: 驱动名称
|
||||
exp_type: 实验类型
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
(成功标志, 响应数据)
|
||||
(成功标志, Token数据字典包含token/path/host/expires)
|
||||
"""
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
# 使用提供的client或默认的http_client
|
||||
if client is None:
|
||||
client = http_client
|
||||
|
||||
# 构造初始化请求
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 构造scene参数: driver_name-exp_type
|
||||
sub_path = f"{driver_name}-{exp_type}"
|
||||
|
||||
payload = {
|
||||
"device_id": device_id,
|
||||
"process_key": process_key,
|
||||
"filename": filename,
|
||||
"path": oss_path,
|
||||
"expires_hours": expires_hours
|
||||
}
|
||||
# 构造请求URL,使用client的remote_addr(已包含/api/v1/)
|
||||
url = f"{client.remote_addr}/applications/token"
|
||||
params = {"sub_path": sub_path, "filename": filename, "scene": "job"}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True, result.get("data", {})
|
||||
logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}")
|
||||
response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10)
|
||||
|
||||
print(f"初始化上传失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
data = result.get("data", {})
|
||||
|
||||
# 转换expires时间戳为可读格式
|
||||
expires_timestamp = data.get("expires", 0)
|
||||
expires_datetime = datetime.fromtimestamp(expires_timestamp)
|
||||
expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
logger.info(f"[OSS] 获取预签名URL成功")
|
||||
logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}")
|
||||
logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})")
|
||||
|
||||
return True, data
|
||||
|
||||
logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}")
|
||||
return False, {}
|
||||
except Exception as e:
|
||||
print(f"初始化上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 获取预签名URL异常: {str(e)}")
|
||||
return False, {}
|
||||
|
||||
|
||||
def _put_upload(file_path: str, upload_url: str) -> bool:
|
||||
"""
|
||||
执行PUT上传
|
||||
使用预签名URL上传文件到OSS
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
upload_url: 上传URL
|
||||
upload_url: 完整的预签名上传URL
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[OSS] 开始上传文件: {file_path}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
response = requests.put(upload_url, data=f)
|
||||
# 使用预签名URL上传,不需要额外的认证header
|
||||
response = requests.put(upload_url, data=f, timeout=300)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[OSS] 文件上传成功")
|
||||
return True
|
||||
|
||||
print(f"PUT上传失败: {response.status_code}, {response.text}")
|
||||
logger.error(f"[OSS] 上传失败: {response.status_code}")
|
||||
logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"PUT上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _complete_upload(uuid: str) -> bool:
|
||||
"""
|
||||
完成上传过程
|
||||
|
||||
Args:
|
||||
uuid: 上传的UUID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"uuid": uuid
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True
|
||||
|
||||
print(f"完成上传失败: {response.status_code}, {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"完成上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default") -> bool:
|
||||
def oss_upload(
|
||||
file_path: Union[str, Path],
|
||||
filename: Optional[str] = None,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
max_retries: int = 3,
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
文件上传主函数,包含重试机制
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
driver_name: 驱动名称,用于构造scene
|
||||
exp_type: 实验类型,用于构造scene
|
||||
max_retries: 最大重试次数
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
是否成功上传
|
||||
Dict: {
|
||||
"success": bool, # 是否上传成功
|
||||
"original_path": str, # 原始文件路径
|
||||
"oss_path": str # OSS路径(成功时)或空字符串(失败时)
|
||||
}
|
||||
"""
|
||||
max_retries = OSSUploadConfig.max_retries
|
||||
file_path = Path(file_path)
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"[OSS] 文件不存在: {file_path}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": ""}
|
||||
|
||||
retry_count = 0
|
||||
oss_path = ""
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# 步骤1:初始化上传
|
||||
init_success, init_data = _init_upload(
|
||||
file_path=file_path,
|
||||
oss_path=oss_path,
|
||||
filename=filename,
|
||||
process_key=process_key,
|
||||
device_id=device_id
|
||||
# 步骤1:获取预签名URL
|
||||
token_success, token_data = _get_oss_token(
|
||||
filename=filename, driver_name=driver_name, exp_type=exp_type, client=client
|
||||
)
|
||||
|
||||
if not init_success:
|
||||
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
if not token_success:
|
||||
logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1) # 等待1秒后重试
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 获取UUID和上传URL
|
||||
uuid = init_data.get("uuid")
|
||||
upload_url = init_data.get("upload_url")
|
||||
# 获取预签名URL和OSS路径
|
||||
upload_url = token_data.get("url")
|
||||
oss_path = token_data.get("path", "")
|
||||
|
||||
if not uuid or not upload_url:
|
||||
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
|
||||
if not upload_url:
|
||||
logger.warning(f"[OSS] 无法获取上传URL,API未返回url字段")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
@@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
# 步骤2:PUT上传文件
|
||||
put_success = _put_upload(file_path, upload_url)
|
||||
if not put_success:
|
||||
print(f"PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 步骤3:完成上传
|
||||
complete_success = _complete_upload(uuid)
|
||||
if not complete_success:
|
||||
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
logger.warning(f"[OSS] PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 所有步骤都成功
|
||||
print(f"文件 {file_path} 上传成功")
|
||||
return True
|
||||
logger.info(f"[OSS] 文件 {file_path} 上传成功")
|
||||
return {"success": True, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
except Exception as e:
|
||||
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
|
||||
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return False
|
||||
logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \
|
||||
# --ak xxx --sk yyy --remote-addr http://xxx/api/v1
|
||||
# 命令行参数解析
|
||||
parser = argparse.ArgumentParser(description='文件上传测试工具')
|
||||
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
|
||||
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
|
||||
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
|
||||
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
|
||||
parser = argparse.ArgumentParser(description="文件上传测试工具")
|
||||
parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径")
|
||||
parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称")
|
||||
parser.add_argument("--type", "-t", type=str, default="default", help="实验类型")
|
||||
parser.add_argument("--ak", type=str, help="Access Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--sk", type=str, help="Secret Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1),如果提供则覆盖配置")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(args.file):
|
||||
print(f"错误:文件 {args.file} 不存在")
|
||||
logger.error(f"错误:文件 {args.file} 不存在")
|
||||
exit(1)
|
||||
|
||||
print("=" * 50)
|
||||
print(f"开始上传文件: {args.file}")
|
||||
print(f"目标路径: {args.path}")
|
||||
print(f"设备ID: {args.device}")
|
||||
print(f"处理键: {args.process}")
|
||||
print("=" * 50)
|
||||
# 如果提供了ak/sk/remote_addr,创建临时HTTPClient
|
||||
temp_client = None
|
||||
if args.ak and args.sk:
|
||||
import base64
|
||||
|
||||
auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8")
|
||||
remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr
|
||||
temp_client = HTTPClient(remote_addr=remote_addr, auth=auth)
|
||||
logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}")
|
||||
elif args.remote_addr:
|
||||
temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth)
|
||||
logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}")
|
||||
else:
|
||||
logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}")
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"开始上传文件: {args.file}")
|
||||
logger.info(f"驱动名称: {args.driver}")
|
||||
logger.info(f"实验类型: {args.type}")
|
||||
logger.info(f"Scene: {args.driver}-{args.type}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 执行上传
|
||||
success = oss_upload(
|
||||
result = oss_upload(
|
||||
file_path=args.file,
|
||||
oss_path=args.path,
|
||||
filename=None, # 使用默认文件名
|
||||
process_key=args.process,
|
||||
device_id=args.device
|
||||
driver_name=args.driver,
|
||||
exp_type=args.type,
|
||||
client=temp_client,
|
||||
)
|
||||
|
||||
# 输出结果
|
||||
if success:
|
||||
print("\n√ 文件上传成功!")
|
||||
if result["success"]:
|
||||
logger.info(f"\n√ 文件上传成功!")
|
||||
logger.info(f"原始路径: {result['original_path']}")
|
||||
logger.info(f"OSS路径: {result['oss_path']}")
|
||||
exit(0)
|
||||
else:
|
||||
print("\n× 文件上传失败!")
|
||||
logger.error(f"\n× 文件上传失败!")
|
||||
logger.error(f"原始路径: {result['original_path']}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from unilabos.app.web.controler import devices, job_add, job_info
|
||||
from unilabos.app.web.controller import (
|
||||
devices,
|
||||
job_add,
|
||||
job_info,
|
||||
get_online_devices,
|
||||
get_device_actions,
|
||||
get_action_schema,
|
||||
get_all_available_actions,
|
||||
)
|
||||
from unilabos.app.model import (
|
||||
Resp,
|
||||
RespCode,
|
||||
JobStatusResp,
|
||||
JobAddResp,
|
||||
JobAddReq,
|
||||
JobData,
|
||||
)
|
||||
from unilabos.app.web.utils.host_utils import get_host_node_info
|
||||
from unilabos.registry.registry import lab_registry
|
||||
@@ -1234,6 +1243,65 @@ def get_devices():
|
||||
return Resp(data=dict(data))
|
||||
|
||||
|
||||
@api.get("/online-devices", summary="Online devices list", response_model=Resp)
|
||||
def api_get_online_devices():
|
||||
"""获取在线设备列表
|
||||
|
||||
返回当前在线的设备列表,包含设备ID、命名空间、机器名等信息
|
||||
"""
|
||||
isok, data = get_online_devices()
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/devices/{device_id}/actions", summary="Device actions list", response_model=Resp)
|
||||
def api_get_device_actions(device_id: str):
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
返回指定设备的所有可用动作,包含动作名称、类型、是否繁忙等信息
|
||||
"""
|
||||
isok, data = get_device_actions(device_id)
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/devices/{device_id}/actions/{action_name}/schema", summary="Action schema", response_model=Resp)
|
||||
def api_get_action_schema(device_id: str, action_name: str):
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
返回动作的参数Schema、默认值、类型等详细信息
|
||||
"""
|
||||
isok, data = get_action_schema(device_id, action_name)
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/actions", summary="All available actions", response_model=Resp)
|
||||
def api_get_all_actions():
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
返回所有已注册设备的动作列表,包含设备信息和各动作的状态
|
||||
"""
|
||||
isok, data = get_all_available_actions()
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp)
|
||||
def job_status(id: str):
|
||||
"""获取任务状态"""
|
||||
@@ -1244,11 +1312,22 @@ def job_status(id: str):
|
||||
@api.post("/job/add", summary="Create job", response_model=JobAddResp)
|
||||
def post_job_add(req: JobAddReq):
|
||||
"""创建任务"""
|
||||
device_id = req.device_id
|
||||
if not req.data:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data")
|
||||
# 检查必要参数:device_id 和 action
|
||||
if not req.device_id:
|
||||
return JobAddResp(
|
||||
data=JobData(jobId="", status=6),
|
||||
code=RespCode.ErrorInvalidReq,
|
||||
message="device_id is required",
|
||||
)
|
||||
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
if not action_name:
|
||||
return JobAddResp(
|
||||
data=JobData(jobId="", status=6),
|
||||
code=RespCode.ErrorInvalidReq,
|
||||
message="action is required",
|
||||
)
|
||||
|
||||
req.device_id = device_id
|
||||
data = job_add(req)
|
||||
return JobAddResp(data=data)
|
||||
|
||||
|
||||
@@ -299,6 +299,10 @@ class HTTPClient:
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"注册资源失败: {response.text}")
|
||||
return response
|
||||
|
||||
def request_startup_json(self) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
|
||||
import json
|
||||
import traceback
|
||||
import uuid
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
|
||||
|
||||
def get_resources() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().resources_config
|
||||
|
||||
def devices() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().devices_config
|
||||
|
||||
def job_info(id: str):
|
||||
get_goal_status = HostNode.get_instance().get_goal_status(id)
|
||||
return JobData(jobId=id, status=get_goal_status)
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
if req.job_id is None:
|
||||
req.job_id = str(uuid.uuid4())
|
||||
action_name = req.data["action"]
|
||||
action_type = req.data.get("action_type", "LocalUnknown")
|
||||
action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除
|
||||
if action_args is None:
|
||||
action_args = req.data.get("action_args")
|
||||
else:
|
||||
if "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
# print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
|
||||
try:
|
||||
HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info)
|
||||
except Exception as e:
|
||||
for bridge in HostNode.get_instance().bridges:
|
||||
traceback.print_exc()
|
||||
if hasattr(bridge, "publish_job_status"):
|
||||
bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {}))
|
||||
return JobData(jobId=req.job_id)
|
||||
587
unilabos/app/web/controller.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
Web API Controller
|
||||
|
||||
提供Web API的控制器函数,处理设备、任务和动作相关的业务逻辑
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
"""任务结果数据"""
|
||||
|
||||
job_id: str
|
||||
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
|
||||
result: Dict[str, Any] = field(default_factory=dict)
|
||||
feedback: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class JobResultStore:
|
||||
"""任务结果存储(单例)"""
|
||||
|
||||
_instance: Optional["JobResultStore"] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, "_initialized"):
|
||||
self._results: Dict[str, JobResult] = {}
|
||||
self._results_lock = threading.RLock()
|
||||
self._initialized = True
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def store_result(
|
||||
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果"""
|
||||
with self._results_lock:
|
||||
self._results[job_id] = JobResult(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
result=result or {},
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
"""仅获取任务结果(不删除)"""
|
||||
with self._results_lock:
|
||||
return self._results.get(job_id)
|
||||
|
||||
def cleanup_old_results(self, max_age_seconds: float = 3600):
|
||||
"""清理过期的结果"""
|
||||
current_time = time.time()
|
||||
with self._results_lock:
|
||||
expired_jobs = [
|
||||
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
|
||||
]
|
||||
for job_id in expired_jobs:
|
||||
del self._results[job_id]
|
||||
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
|
||||
|
||||
|
||||
# 全局结果存储实例
|
||||
job_result_store = JobResultStore()
|
||||
|
||||
|
||||
def store_job_result(
|
||||
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果(供外部调用)
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
status: 状态字符串 ("success", "failed", "cancelled")
|
||||
result: 结果数据
|
||||
feedback: 反馈数据
|
||||
"""
|
||||
# 转换状态字符串为整数
|
||||
status_map = {
|
||||
"success": 4, # SUCCEEDED
|
||||
"failed": 6, # ABORTED
|
||||
"cancelled": 5, # CANCELED
|
||||
"running": 2, # EXECUTING
|
||||
}
|
||||
status_int = status_map.get(status, 0)
|
||||
|
||||
# 只存储最终状态
|
||||
if status_int in (4, 5, 6):
|
||||
job_result_store.store_result(job_id, status_int, result, feedback)
|
||||
|
||||
|
||||
def get_resources() -> Tuple[bool, Any]:
|
||||
"""获取资源配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.resources_config
|
||||
|
||||
|
||||
def devices() -> Tuple[bool, Any]:
|
||||
"""获取设备配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.devices_config
|
||||
|
||||
|
||||
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
|
||||
"""获取任务信息
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
remove_after_read: 是否在读取后删除结果(默认True)
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据
|
||||
"""
|
||||
# 首先检查结果存储中是否有已完成的结果
|
||||
if remove_after_read:
|
||||
stored_result = job_result_store.get_and_remove(job_id)
|
||||
else:
|
||||
stored_result = job_result_store.get_result(job_id)
|
||||
|
||||
if stored_result:
|
||||
# 有存储的结果,直接返回
|
||||
return JobData(
|
||||
jobId=job_id,
|
||||
status=stored_result.status,
|
||||
result=stored_result.result,
|
||||
)
|
||||
|
||||
# 没有存储的结果,从 HostNode 获取当前状态
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return JobData(jobId=job_id, status=0)
|
||||
|
||||
get_goal_status = host_node.get_goal_status(job_id)
|
||||
return JobData(jobId=job_id, status=get_goal_status)
|
||||
|
||||
|
||||
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
|
||||
"""检查设备动作是否正在执行(被占用)
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, None
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查 _device_action_status 中是否有正在执行的任务
|
||||
if device_action_key in host_node._device_action_status:
|
||||
status = host_node._device_action_status[device_action_key]
|
||||
if status.job_ids:
|
||||
# 返回第一个正在执行的job_id
|
||||
current_job_id = next(iter(status.job_ids.keys()), None)
|
||||
return True, current_job_id
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
|
||||
"""从注册表自动获取动作类型
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
动作类型字符串,未找到返回None
|
||||
"""
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
# 方法1: 从运行时注册设备获取
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
# 转换为字符串格式
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
# 方法2: 从lab_registry获取
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node and lab_registry:
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
|
||||
|
||||
Args:
|
||||
req: 任务添加请求
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据(包含状态)
|
||||
"""
|
||||
# 服务端自动生成 job_id 和 task_id
|
||||
job_id = str(uuid.uuid4())
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 服务端自动生成 server_info
|
||||
server_info = {"send_timestamp": time.time()}
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
|
||||
return JobData(jobId=job_id, status=6) # 6 = ABORTED
|
||||
|
||||
# 解析动作信息
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
|
||||
|
||||
if action_args is None:
|
||||
action_args = req.action_args or {}
|
||||
elif isinstance(action_args, dict) and "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
|
||||
# 自动获取 action_type
|
||||
action_type = _get_action_type(req.device_id, action_name)
|
||||
if action_type is None:
|
||||
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
# 检查设备动作是否繁忙
|
||||
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
|
||||
|
||||
if is_busy:
|
||||
logger.warning(
|
||||
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
|
||||
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
|
||||
)
|
||||
# 返回失败状态,status=6 表示 ABORTED
|
||||
return JobData(jobId=job_id, status=6)
|
||||
|
||||
# 设备空闲,提交任务执行
|
||||
try:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
queue_item = QueueItem(
|
||||
task_type="job_call_back_status",
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
device_action_key=device_action_key,
|
||||
)
|
||||
|
||||
host_node.send_goal(
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
|
||||
# 返回已接受状态,status=1 表示 ACCEPTED
|
||||
return JobData(jobId=job_id, status=1)
|
||||
|
||||
except ValueError as e:
|
||||
# ActionClient not found 等错误
|
||||
logger.error(f"[Controller] Action not available: {str(e)}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error submitting job: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
|
||||
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取在线设备列表
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 在线设备信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
online_devices = {}
|
||||
for device_key in host_node._online_devices:
|
||||
# device_key 格式: "namespace/device_id"
|
||||
parts = device_key.split("/")
|
||||
if len(parts) >= 2:
|
||||
device_id = parts[-1]
|
||||
else:
|
||||
device_id = device_key
|
||||
|
||||
# 获取设备详细信息
|
||||
device_info = registered_devices.get(device_id, {})
|
||||
machine_name = host_node.device_machine_names.get(device_id, "未知")
|
||||
|
||||
online_devices[device_id] = {
|
||||
"device_key": device_key,
|
||||
"namespace": host_node.devices_names.get(device_id, ""),
|
||||
"machine_name": machine_name,
|
||||
"uuid": device_info.get("uuid", "") if device_info else "",
|
||||
"node_name": device_info.get("node_name", "") if device_info else "",
|
||||
}
|
||||
|
||||
return True, {
|
||||
"online_devices": online_devices,
|
||||
"total_count": len(online_devices),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting online devices: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 动作列表信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
# 检查设备是否已注册
|
||||
if device_id not in registered_devices:
|
||||
return False, {"error": f"Device not found: {device_id}"}
|
||||
|
||||
device_info = registered_devices[device_id]
|
||||
actions = device_info.get("actions", {})
|
||||
|
||||
actions_list = {}
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
actions_list[action_name] = {
|
||||
**action_info,
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
|
||||
actions_list[action_name] = {
|
||||
"type_name": "unknown",
|
||||
"action_path": f"/devices/{device_id}/{action_name}",
|
||||
"is_busy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"device_id": device_id,
|
||||
"actions": actions_list,
|
||||
"action_count": len(actions_list),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting device actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, Schema信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
result = {
|
||||
"device_id": device_id,
|
||||
"action_name": action_name,
|
||||
"schema": None,
|
||||
"goal_default": None,
|
||||
"action_type": None,
|
||||
"is_busy": False,
|
||||
}
|
||||
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
result["is_busy"] = is_busy
|
||||
result["current_job_id"] = current_job[:8] if current_job else None
|
||||
|
||||
# 方法1: 从 registered_devices 获取运行时信息
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
if action_name in action_mappings:
|
||||
mapping = action_mappings[action_name]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
|
||||
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
|
||||
if result["schema"] is None and lab_registry:
|
||||
# 尝试查找设备类型
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
# 从配置中获取设备类型
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
mapping = action_mappings[key]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
result["handles"] = mapping.get("handles", {})
|
||||
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
|
||||
break
|
||||
|
||||
if result["schema"] is None:
|
||||
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
|
||||
|
||||
return True, result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting action schema: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
all_actions = {}
|
||||
total_action_count = 0
|
||||
|
||||
for device_id, device_info in registered_devices.items():
|
||||
actions = device_info.get("actions", {})
|
||||
device_actions = {}
|
||||
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
device_actions[action_name] = {
|
||||
"type_name": action_info.get("type_name", ""),
|
||||
"action_path": action_info.get("action_path", ""),
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
total_action_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
if device_actions:
|
||||
all_actions[device_id] = {
|
||||
"actions": device_actions,
|
||||
"action_count": len(device_actions),
|
||||
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"devices": all_actions,
|
||||
"device_count": len(all_actions),
|
||||
"total_action_count": total_action_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
@@ -359,6 +359,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = ""
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -388,7 +389,7 @@ class MessageProcessor:
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor")
|
||||
self.thread.start()
|
||||
logger.info("[MessageProcessor] Started")
|
||||
logger.trace("[MessageProcessor] Started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止消息处理线程"""
|
||||
@@ -427,7 +428,10 @@ class MessageProcessor:
|
||||
ssl=ssl_context,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
additional_headers={"Authorization": f"Lab {BasicConfig.auth_secret()}"},
|
||||
additional_headers={
|
||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||
"EdgeSession": f"{self.session_id}",
|
||||
},
|
||||
logger=ws_logger,
|
||||
) as websocket:
|
||||
self.websocket = websocket
|
||||
@@ -572,6 +576,9 @@ class MessageProcessor:
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -932,7 +939,7 @@ class QueueProcessor:
|
||||
# 事件通知机制
|
||||
self.queue_update_event = threading.Event()
|
||||
|
||||
logger.info("[QueueProcessor] Initialized")
|
||||
logger.trace("[QueueProcessor] Initialized")
|
||||
|
||||
def set_websocket_client(self, websocket_client: "WebSocketClient"):
|
||||
"""设置WebSocket客户端引用"""
|
||||
@@ -947,7 +954,7 @@ class QueueProcessor:
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor")
|
||||
self.thread.start()
|
||||
logger.info("[QueueProcessor] Started")
|
||||
logger.trace("[QueueProcessor] Started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
@@ -1196,6 +1203,18 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
|
||||
logger.info("[WebSocketClient] Stopping connection")
|
||||
|
||||
# 发送 normal_exit 消息
|
||||
if self.is_connected():
|
||||
try:
|
||||
session_id = self.message_processor.session_id
|
||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||
# 给一点时间让消息发送出去
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||
|
||||
# 停止两个核心线程
|
||||
self.message_processor.stop()
|
||||
self.queue_processor.stop()
|
||||
@@ -1295,3 +1314,19 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully")
|
||||
else:
|
||||
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
||||
|
||||
def publish_host_ready(self) -> None:
|
||||
"""发布host_node ready信号"""
|
||||
if self.is_disabled or not self.is_connected():
|
||||
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
||||
return
|
||||
|
||||
message = {
|
||||
"action": "host_node_ready",
|
||||
"data": {
|
||||
"status": "ready",
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info("[WebSocketClient] Host node ready signal published")
|
||||
|
||||
@@ -18,6 +18,9 @@ class BasicConfig:
|
||||
vis_2d_enable = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
|
||||
@classmethod
|
||||
@@ -36,18 +39,9 @@ class WSConfig:
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = ""
|
||||
authorization = ""
|
||||
init_endpoint = ""
|
||||
complete_endpoint = ""
|
||||
max_retries = 3
|
||||
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
||||
|
||||
|
||||
# ROS配置
|
||||
|
||||
@@ -3,7 +3,7 @@ from enum import Enum
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple, Union, Optional, Any, List
|
||||
|
||||
from opcua import Client, Node
|
||||
from opcua import Client, Node, ua
|
||||
from opcua.ua import NodeId, NodeClass, VariantType
|
||||
|
||||
|
||||
@@ -47,23 +47,68 @@ class Base(ABC):
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._node = self._client.get_node(node_id)
|
||||
# 尝试多种 NodeId 字符串格式解析,兼容不同服务器/库的输出
|
||||
# 可能的格式示例: 'ns=2;i=1234', 'ns=2;s=SomeString',
|
||||
# 'StringNodeId(ns=4;s=OPC|变量名)', 'NumericNodeId(ns=2;i=1234)' 等
|
||||
import re
|
||||
|
||||
nid = self._node_id
|
||||
# 如果已经是 NodeId/Node 对象(库用户可能传入),直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
if isinstance(nid, UaNodeId):
|
||||
self._node = self._client.get_node(nid)
|
||||
return self._node
|
||||
except Exception:
|
||||
# 若导入或类型判断失败,则继续下一步
|
||||
pass
|
||||
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
# 提取括号内的实际 node_id 字符串
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
# 常见短格式 'ns=2;i=1234' 或 'ns=2;s=SomeString'
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._node = self._client.get_node(nid)
|
||||
else:
|
||||
raise ValueError(f"无法解析节点ID: {self._node_id}")
|
||||
# 尝试提取 ns 和 i 或 s
|
||||
# 对于字符串标识符,可能包含特殊字符,使用非贪婪匹配
|
||||
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
|
||||
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
|
||||
if m_num:
|
||||
ns = int(m_num.group(1))
|
||||
identifier = int(m_num.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._node = self._client.get_node(node_id)
|
||||
elif m_str:
|
||||
ns = int(m_str.group(1))
|
||||
identifier = m_str.group(2).strip()
|
||||
# 对于字符串标识符,直接使用字符串格式
|
||||
node_id_str = f"ns={ns};s={identifier}"
|
||||
self._node = self._client.get_node(node_id_str)
|
||||
else:
|
||||
# 回退:尝试直接传入字符串(有些实现接受其它格式)
|
||||
try:
|
||||
self._node = self._client.get_node(self._node_id)
|
||||
except Exception as e:
|
||||
# 输出更详细的错误信息供调试
|
||||
print(f"获取节点失败(尝试直接字符串): {self._node_id}, 错误: {e}")
|
||||
raise
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
# 非字符串,尝试直接使用
|
||||
self._node = self._client.get_node(self._node_id)
|
||||
except Exception as e:
|
||||
print(f"获取节点失败: {self._node_id}, 错误: {e}")
|
||||
# 添加额外提示,帮助定位 BadNodeIdUnknown 问题
|
||||
print("提示: 请确认该 node_id 是否来自当前连接的服务器地址空间," \
|
||||
"以及 CSV/配置中名称与服务器 BrowseName 是否匹配。")
|
||||
raise
|
||||
return self._node
|
||||
|
||||
@@ -104,7 +149,56 @@ class Variable(Base):
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
try:
|
||||
self._get_node().set_value(value)
|
||||
# 如果声明了数据类型,则尝试转换并使用对应的 Variant 写入
|
||||
coerced = value
|
||||
try:
|
||||
if self._data_type is not None:
|
||||
# 基于声明的数据类型做简单类型转换
|
||||
dt = self._data_type
|
||||
if dt in (DataType.SBYTE, DataType.BYTE, DataType.INT16, DataType.UINT16,
|
||||
DataType.INT32, DataType.UINT32, DataType.INT64, DataType.UINT64):
|
||||
# 数值类型 -> int
|
||||
if isinstance(value, str):
|
||||
coerced = int(value)
|
||||
else:
|
||||
coerced = int(value)
|
||||
elif dt in (DataType.FLOAT, DataType.DOUBLE):
|
||||
if isinstance(value, str):
|
||||
coerced = float(value)
|
||||
else:
|
||||
coerced = float(value)
|
||||
elif dt == DataType.BOOLEAN:
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ("true", "1", "yes", "on"):
|
||||
coerced = True
|
||||
elif v in ("false", "0", "no", "off"):
|
||||
coerced = False
|
||||
else:
|
||||
coerced = bool(value)
|
||||
else:
|
||||
coerced = bool(value)
|
||||
elif dt == DataType.STRING or dt == DataType.BYTESTRING or dt == DataType.DATETIME:
|
||||
coerced = str(value)
|
||||
|
||||
# 使用 ua.Variant 明确指定 VariantType
|
||||
try:
|
||||
variant = ua.Variant(coerced, dt.value)
|
||||
self._get_node().set_value(variant)
|
||||
except Exception:
|
||||
# 回退:有些 set_value 实现接受 (value, variant_type)
|
||||
try:
|
||||
self._get_node().set_value(coerced, dt.value)
|
||||
except Exception:
|
||||
# 最后回退到直接写入(保持兼容性)
|
||||
self._get_node().set_value(coerced)
|
||||
else:
|
||||
# 未声明数据类型,直接写入
|
||||
self._get_node().set_value(value)
|
||||
except Exception:
|
||||
# 若在转换或按数据类型写入失败,尝试直接写入原始值并让上层捕获错误
|
||||
self._get_node().set_value(value)
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"写入变量 {self._name} 失败: {e}")
|
||||
@@ -120,20 +214,50 @@ class Method(Base):
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._parent_node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._parent_node = self._client.get_node(node_id)
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
if isinstance(nid, UaNodeId):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
else:
|
||||
raise ValueError(f"无法解析父节点ID: {self._parent_node_id}")
|
||||
# 提取 ns 和 i 或 s
|
||||
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
|
||||
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
|
||||
if m_num:
|
||||
ns = int(m_num.group(1))
|
||||
identifier = int(m_num.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._parent_node = self._client.get_node(node_id)
|
||||
elif m_str:
|
||||
ns = int(m_str.group(1))
|
||||
identifier = m_str.group(2).strip()
|
||||
node_id_str = f"ns={ns};s={identifier}"
|
||||
self._parent_node = self._client.get_node(node_id_str)
|
||||
else:
|
||||
# 回退
|
||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||
except Exception as e:
|
||||
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")
|
||||
|
||||
@@ -128,14 +128,21 @@ class ResourceVisualization:
|
||||
new_dev.set("device_name", node["id"]+"_")
|
||||
# if node["parent"] is not None:
|
||||
# new_dev.set("station_name", node["parent"]+'_')
|
||||
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
if "position" in node:
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
if "rotation" in node["config"]:
|
||||
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
|
||||
if "pose" in node:
|
||||
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000))
|
||||
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
|
||||
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
|
||||
if "device_config" in node["config"]:
|
||||
for key, value in node["config"]["device_config"].items():
|
||||
new_dev.set(key, str(value))
|
||||
|
||||
0
unilabos/devices/cameraSII/__init__.py
Normal file
712
unilabos/devices/cameraSII/cameraDriver.py
Normal file
@@ -0,0 +1,712 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
|
||||
logging.getLogger("zeep").setLevel(logging.WARNING)
|
||||
logging.getLogger("zeep.xsd.schema").setLevel(logging.WARNING)
|
||||
logging.getLogger("zeep.xsd.schema.schema").setLevel(logging.WARNING)
|
||||
from onvif import ONVIFCamera # 新增:ONVIF PTZ 控制
|
||||
|
||||
|
||||
# ======================= 独立的 PTZController =======================
|
||||
class PTZController:
|
||||
def __init__(self, host: str, port: int, user: str, password: str):
|
||||
"""
|
||||
:param host: 摄像机 IP 或域名(和 RTSP 的一样即可)
|
||||
:param port: ONVIF 端口(多数为 80,看你的设备)
|
||||
:param user: 摄像机用户名
|
||||
:param password: 摄像机密码
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.password = password
|
||||
|
||||
self.cam: Optional[ONVIFCamera] = None
|
||||
self.media_service = None
|
||||
self.ptz_service = None
|
||||
self.profile = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
建立 ONVIF 连接并初始化 PTZ 能力,失败返回 False(不抛异常)
|
||||
Note: 首先 pip install onvif-zeep
|
||||
"""
|
||||
try:
|
||||
self.cam = ONVIFCamera(self.host, self.port, self.user, self.password)
|
||||
self.media_service = self.cam.create_media_service()
|
||||
self.ptz_service = self.cam.create_ptz_service()
|
||||
profiles = self.media_service.GetProfiles()
|
||||
if not profiles:
|
||||
print("[PTZ] No media profiles found on camera.", file=sys.stderr)
|
||||
return False
|
||||
self.profile = profiles[0]
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[PTZ] Failed to init ONVIF PTZ: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def _continuous_move(self, pan: float, tilt: float, zoom: float, duration: float) -> bool:
|
||||
"""
|
||||
连续移动一段时间(秒),之后自动停止。
|
||||
此函数为阻塞模式:只有在 Stop 调用结束后,才返回 True/False。
|
||||
"""
|
||||
if not self.ptz_service or not self.profile:
|
||||
print("[PTZ] _continuous_move: ptz_service or profile not ready", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# 进入前先强行停一下,避免前一次残留动作
|
||||
self._force_stop()
|
||||
|
||||
req = self.ptz_service.create_type("ContinuousMove")
|
||||
req.ProfileToken = self.profile.token
|
||||
|
||||
req.Velocity = {
|
||||
"PanTilt": {"x": pan, "y": tilt},
|
||||
"Zoom": {"x": zoom},
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"[PTZ] ContinuousMove start: pan={pan}, tilt={tilt}, zoom={zoom}, duration={duration}", file=sys.stderr)
|
||||
self.ptz_service.ContinuousMove(req)
|
||||
except Exception as e:
|
||||
print(f"[PTZ] ContinuousMove failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# 阻塞等待:这里决定“运动时间”
|
||||
import time
|
||||
wait_seconds = max(2 * duration, 0.0)
|
||||
time.sleep(wait_seconds)
|
||||
|
||||
# 运动完成后强制停止
|
||||
return self._force_stop()
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""
|
||||
阻塞调用 Stop(带重试),成功 True,失败 False。
|
||||
"""
|
||||
return self._force_stop()
|
||||
|
||||
# ------- 对外动作接口(给 CameraController 调用) -------
|
||||
# 所有接口都为“阻塞模式”:只有在运动 + Stop 完成后才返回 True/False
|
||||
|
||||
def move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_up called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=0.0, tilt=+speed, zoom=0.0, duration=duration)
|
||||
|
||||
def move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_down called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=0.0, tilt=-speed, zoom=0.0, duration=duration)
|
||||
|
||||
def move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_left called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=-speed, tilt=0.0, zoom=0.0, duration=duration)
|
||||
|
||||
def move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[PTZ] move_right called, speed={speed}, duration={duration}", file=sys.stderr)
|
||||
return self._continuous_move(pan=+speed, tilt=0.0, zoom=0.0, duration=duration)
|
||||
|
||||
# ------- 占位的变倍接口(当前设备不支持) -------
|
||||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def _force_stop(self, retries: int = 3, delay: float = 0.1) -> bool:
|
||||
"""
|
||||
尝试多次调用 Stop,作为“强制停止”手段。
|
||||
:param retries: 重试次数
|
||||
:param delay: 每次重试间隔(秒)
|
||||
"""
|
||||
if not self.ptz_service or not self.profile:
|
||||
print("[PTZ] _force_stop: ptz_service or profile not ready", file=sys.stderr)
|
||||
return False
|
||||
|
||||
import time
|
||||
last_error = None
|
||||
for i in range(retries):
|
||||
try:
|
||||
print(f"[PTZ] _force_stop: calling Stop(), attempt={i+1}", file=sys.stderr)
|
||||
self.ptz_service.Stop({"ProfileToken": self.profile.token})
|
||||
print("[PTZ] _force_stop: Stop() returned OK", file=sys.stderr)
|
||||
return True
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"[PTZ] _force_stop: Stop() failed at attempt {i+1}: {e}", file=sys.stderr)
|
||||
time.sleep(delay)
|
||||
|
||||
print(f"[PTZ] _force_stop: all {retries} attempts failed, last error: {last_error}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# ======================= CameraController(加入 PTZ) =======================
|
||||
|
||||
class CameraController:
|
||||
"""
|
||||
Uni-Lab-OS 摄像头驱动(driver 形式)
|
||||
启动 Uni-Lab-OS 后,立即开始推流
|
||||
|
||||
- WebSocket 信令:通过 signal_backend_url 连接到后端
|
||||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
||||
- 媒体服务器:通过 rtmp_url / webrtc_api / webrtc_stream_url
|
||||
当前配置为 SRS,与独立 HostSimulator 独立运行脚本保持一致。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host_id: str = "demo-host",
|
||||
|
||||
# (1)信令后端(WebSocket)
|
||||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
|
||||
# (2)媒体后端(RTMP + WebRTC API)
|
||||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
camera_rtsp_url: str = "",
|
||||
|
||||
# (3)PTZ 控制相关(ONVIF)
|
||||
ptz_host: str = "", # 一般就是摄像头 IP,比如 "192.168.31.164"
|
||||
ptz_port: int = 80, # ONVIF 端口,不一定是 80,按实际情况改
|
||||
ptz_user: str = "", # admin
|
||||
ptz_password: str = "", # admin123
|
||||
):
|
||||
self.host_id = host_id
|
||||
self.camera_rtsp_url = camera_rtsp_url
|
||||
|
||||
# 拼接最终的 WebSocket URL:.../host/<host_id>
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
||||
|
||||
# 媒体服务器配置
|
||||
self.rtmp_url = rtmp_url
|
||||
self.webrtc_api = webrtc_api
|
||||
self.webrtc_stream_url = webrtc_stream_url
|
||||
|
||||
# PTZ 控制
|
||||
self.ptz_host = ptz_host
|
||||
self.ptz_port = ptz_port
|
||||
self.ptz_user = ptz_user
|
||||
self.ptz_password = ptz_password
|
||||
self._ptz: Optional[PTZController] = None
|
||||
self._init_ptz_if_possible()
|
||||
|
||||
# 运行时状态
|
||||
self._ws: Optional[object] = None
|
||||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
||||
self._running = False
|
||||
self._loop_task: Optional[asyncio.Future] = None
|
||||
|
||||
# 事件循环 & 线程
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
try:
|
||||
self.start()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
||||
|
||||
# ------------------------ PTZ 初始化 ------------------------
|
||||
|
||||
# ------------------------ PTZ 公开动作方法(一个动作一个函数) ------------------------
|
||||
|
||||
def ptz_move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_up called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_up(speed=speed, duration=duration)
|
||||
|
||||
def ptz_move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_down called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_down(speed=speed, duration=duration)
|
||||
|
||||
def ptz_move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_left called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_left(speed=speed, duration=duration)
|
||||
|
||||
def ptz_move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
print(f"[CameraController] ptz_move_right called, speed={speed}, duration={duration}")
|
||||
return self._ptz.move_right(speed=speed, duration=duration)
|
||||
|
||||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||||
"""
|
||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||||
"""
|
||||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def ptz_stop(self):
|
||||
if self._ptz is None:
|
||||
print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
||||
return
|
||||
self._ptz.stop()
|
||||
|
||||
def _init_ptz_if_possible(self):
|
||||
"""
|
||||
根据 ptz_host / user / password 初始化 PTZ;
|
||||
如果配置信息不全则不启用 PTZ(静默)。
|
||||
"""
|
||||
if not (self.ptz_host and self.ptz_user and self.ptz_password):
|
||||
return
|
||||
ctrl = PTZController(
|
||||
host=self.ptz_host,
|
||||
port=self.ptz_port,
|
||||
user=self.ptz_user,
|
||||
password=self.ptz_password,
|
||||
)
|
||||
if ctrl.connect():
|
||||
self._ptz = ctrl
|
||||
else:
|
||||
self._ptz = None
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 对外暴露的方法:供 Uni-Lab-OS 调用
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def start(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
启动 Camera 连接 & 消息循环,并在启动时就开启 FFmpeg 推流,
|
||||
"""
|
||||
|
||||
if self._running:
|
||||
return {"status": "already_running", "host_id": self.host_id}
|
||||
|
||||
# 应用 config 覆盖(如果有)
|
||||
if config:
|
||||
self.camera_rtsp_url = config.get("camera_rtsp_url", self.camera_rtsp_url)
|
||||
cfg_host_id = config.get("host_id")
|
||||
if cfg_host_id:
|
||||
self.host_id = cfg_host_id
|
||||
|
||||
signal_backend_url = config.get("signal_backend_url")
|
||||
if signal_backend_url:
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
||||
|
||||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
||||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
||||
self.webrtc_stream_url = config.get(
|
||||
"webrtc_stream_url", self.webrtc_stream_url
|
||||
)
|
||||
|
||||
# PTZ 相关配置也允许通过 config 注入
|
||||
self.ptz_host = config.get("ptz_host", self.ptz_host)
|
||||
self.ptz_port = int(config.get("ptz_port", self.ptz_port))
|
||||
self.ptz_user = config.get("ptz_user", self.ptz_user)
|
||||
self.ptz_password = config.get("ptz_password", self.ptz_password)
|
||||
self._init_ptz_if_possible()
|
||||
|
||||
self._running = True
|
||||
|
||||
# === start 时启动 FFmpeg 推流 ===
|
||||
self._start_ffmpeg()
|
||||
|
||||
# 创建新的事件循环和线程(用于 WebSocket 信令)
|
||||
self._loop = asyncio.new_event_loop()
|
||||
|
||||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
||||
|
||||
self._loop_thread = threading.Thread(
|
||||
target=loop_runner, args=(self._loop,), daemon=True
|
||||
)
|
||||
self._loop_thread.start()
|
||||
|
||||
self._loop_task = asyncio.run_coroutine_threadsafe(
|
||||
self._run_main_loop(), self._loop
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"host_id": self.host_id,
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
"webrtc_api": self.webrtc_api,
|
||||
"webrtc_stream_url": self.webrtc_stream_url,
|
||||
}
|
||||
|
||||
def stop(self) -> Dict[str, Any]:
|
||||
"""
|
||||
停止推流 & 断开 WebSocket,并关闭事件循环线程。
|
||||
"""
|
||||
self._running = False
|
||||
|
||||
self._stop_ffmpeg()
|
||||
|
||||
if self._ws and self._loop is not None:
|
||||
async def close_ws():
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when closing WebSocket: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
||||
|
||||
if self._loop_task is not None:
|
||||
if not self._loop_task.done():
|
||||
self._loop_task.cancel()
|
||||
try:
|
||||
self._loop_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] main loop task error in stop(): {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
finally:
|
||||
self._loop_task = None
|
||||
|
||||
if self._loop is not None:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when stopping event loop: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if self._loop_thread is not None:
|
||||
try:
|
||||
self._loop_thread.join(timeout=5)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when joining loop thread: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
finally:
|
||||
self._loop_thread = None
|
||||
|
||||
self._ws = None
|
||||
self._loop = None
|
||||
|
||||
return {"status": "stopped", "host_id": self.host_id}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
查询当前状态,方便在 Uni-Lab-OS 中做监控。
|
||||
"""
|
||||
ws_closed = None
|
||||
if self._ws is not None:
|
||||
ws_closed = getattr(self._ws, "closed", None)
|
||||
|
||||
if ws_closed is None:
|
||||
websocket_connected = self._ws is not None
|
||||
else:
|
||||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
||||
|
||||
return {
|
||||
"host_id": self.host_id,
|
||||
"running": self._running,
|
||||
"websocket_connected": websocket_connected,
|
||||
"ffmpeg_running": bool(
|
||||
self._ffmpeg_process and self._ffmpeg_process.poll() is None
|
||||
),
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 内部实现逻辑:WebSocket 循环 / FFmpeg / WebRTC Offer 处理
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
async def _run_main_loop(self):
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
async with websockets.connect(self.signal_backend_url) as ws:
|
||||
self._ws = ws
|
||||
await self._recv_loop()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
print(
|
||||
f"[CameraController] WebSocket connection error: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _recv_loop(self):
|
||||
assert self._ws is not None
|
||||
ws = self._ws
|
||||
|
||||
async for message in ws:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
print(
|
||||
f"[CameraController] received non-JSON message: {message}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._handle_message(data)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error while handling message {data}: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
async def _handle_message(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理来自信令后端的消息:
|
||||
- command: start_stream / stop_stream / ptz_xxx
|
||||
- type: offer (WebRTC)
|
||||
"""
|
||||
cmd = data.get("command")
|
||||
|
||||
# ---------- 推流控制 ----------
|
||||
if cmd == "start_stream":
|
||||
try:
|
||||
self._start_ffmpeg()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when starting FFmpeg on start_stream: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
if cmd == "stop_stream":
|
||||
try:
|
||||
self._stop_ffmpeg()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when stopping FFmpeg on stop_stream: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# # ---------- PTZ 控制 ----------
|
||||
# # 例如信令可以发:
|
||||
# # {"command": "ptz_move", "direction": "down", "speed": 0.5, "duration": 0.5}
|
||||
# if cmd == "ptz_move":
|
||||
# if self._ptz is None:
|
||||
# # 没有初始化 PTZ,静默忽略或打印一条
|
||||
# print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
||||
# return
|
||||
|
||||
# direction = data.get("direction", "")
|
||||
# speed = float(data.get("speed", 0.5))
|
||||
# duration = float(data.get("duration", 0.5))
|
||||
|
||||
# try:
|
||||
# if direction == "up":
|
||||
# self._ptz.move_up(speed=speed, duration=duration)
|
||||
# elif direction == "down":
|
||||
# self._ptz.move_down(speed=speed, duration=duration)
|
||||
# elif direction == "left":
|
||||
# self._ptz.move_left(speed=speed, duration=duration)
|
||||
# elif direction == "right":
|
||||
# self._ptz.move_right(speed=speed, duration=duration)
|
||||
# elif direction == "zoom_in":
|
||||
# self._ptz.zoom_in(speed=speed, duration=duration)
|
||||
# elif direction == "zoom_out":
|
||||
# self._ptz.zoom_out(speed=speed, duration=duration)
|
||||
# elif direction == "stop":
|
||||
# self._ptz.stop()
|
||||
# else:
|
||||
# # 未知方向,忽略
|
||||
# pass
|
||||
# except Exception as e:
|
||||
# print(
|
||||
# f"[CameraController] error when handling PTZ move: {e}",
|
||||
# file=sys.stderr,
|
||||
# )
|
||||
# return
|
||||
|
||||
# ---------- WebRTC Offer ----------
|
||||
if data.get("type") == "offer":
|
||||
offer_sdp = data.get("sdp", "")
|
||||
camera_id = data.get("cameraId", "camera-01")
|
||||
try:
|
||||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when handling WebRTC offer: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
if self._ws:
|
||||
answer_payload = {
|
||||
"type": "answer",
|
||||
"sdp": answer_sdp,
|
||||
"cameraId": camera_id,
|
||||
"hostId": self.host_id,
|
||||
}
|
||||
try:
|
||||
await self._ws.send(json.dumps(answer_payload))
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when sending WebRTC answer: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# ------------------------ FFmpeg 相关 ------------------------
|
||||
|
||||
def _start_ffmpeg(self):
|
||||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
||||
return
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rtsp_transport", "tcp",
|
||||
"-i", self.camera_rtsp_url,
|
||||
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "baseline",
|
||||
"-b:v", "1M",
|
||||
"-maxrate", "1M",
|
||||
"-bufsize", "2M",
|
||||
"-g", "10",
|
||||
"-keyint_min", "10",
|
||||
"-sc_threshold", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-x264-params", "bframes=0",
|
||||
|
||||
"-c:a", "aac",
|
||||
"-ar", "44100",
|
||||
"-ac", "1",
|
||||
"-b:a", "64k",
|
||||
|
||||
"-f", "flv",
|
||||
self.rtmp_url,
|
||||
]
|
||||
|
||||
try:
|
||||
self._ffmpeg_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=False,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
||||
self._ffmpeg_process = None
|
||||
raise
|
||||
|
||||
def _stop_ffmpeg(self):
|
||||
proc = self._ffmpeg_process
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"[CameraController] FFmpeg process did not exit even after kill (pid={proc.pid})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] failed to kill FFmpeg process: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] error when stopping FFmpeg: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
self._ffmpeg_process = None
|
||||
|
||||
# ------------------------ WebRTC Offer 相关 ------------------------
|
||||
|
||||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
||||
payload = {
|
||||
"api": self.webrtc_api,
|
||||
"streamurl": self.webrtc_stream_url,
|
||||
"sdp": offer_sdp,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def _do_request():
|
||||
return requests.post(
|
||||
self.webrtc_api,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(None, _do_request)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] failed to send offer to media server: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] media server HTTP error: {e}, "
|
||||
f"status={resp.status_code}, body={resp.text[:200]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[CameraController] failed to parse media server JSON: {e}, "
|
||||
f"raw={resp.text[:200]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
answer_sdp = data.get("sdp", "")
|
||||
if not answer_sdp:
|
||||
msg = f"empty SDP from media server: {data}"
|
||||
print(f"[CameraController] {msg}", file=sys.stderr)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return answer_sdp
|
||||
401
unilabos/devices/cameraSII/cameraUSB.py
Normal file
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
|
||||
|
||||
class CameraController:
|
||||
"""
|
||||
Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ)
|
||||
|
||||
- WebSocket 信令:signal_backend_url 连接到后端
|
||||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
||||
- 媒体服务器:RTMP 推流到 rtmp_url;WebRTC offer 转发到 SRS 的 webrtc_api
|
||||
- 视频源:本地 USB 摄像头(V4L2,默认 /dev/video0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host_id: str = "demo-host",
|
||||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
video_device: str = "/dev/video0",
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
fps: int = 30,
|
||||
video_bitrate: str = "1500k",
|
||||
audio_device: Optional[str] = None, # 比如 "hw:1,0",没有音频就保持 None
|
||||
audio_bitrate: str = "64k",
|
||||
):
|
||||
self.host_id = host_id
|
||||
|
||||
# 拼接最终 WebSocket URL:.../host/<host_id>
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
||||
|
||||
# 媒体服务器配置
|
||||
self.rtmp_url = rtmp_url
|
||||
self.webrtc_api = webrtc_api
|
||||
self.webrtc_stream_url = webrtc_stream_url
|
||||
|
||||
# 本地采集配置
|
||||
self.video_device = video_device
|
||||
self.width = int(width)
|
||||
self.height = int(height)
|
||||
self.fps = int(fps)
|
||||
self.video_bitrate = video_bitrate
|
||||
self.audio_device = audio_device
|
||||
self.audio_bitrate = audio_bitrate
|
||||
|
||||
# 运行时状态
|
||||
self._ws: Optional[object] = None
|
||||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
||||
self._running = False
|
||||
self._loop_task: Optional[asyncio.Future] = None
|
||||
|
||||
# 事件循环 & 线程
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
try:
|
||||
self.start()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 对外方法
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def start(self, config: Optional[Dict[str, Any]] = None):
|
||||
if self._running:
|
||||
return {"status": "already_running", "host_id": self.host_id}
|
||||
|
||||
# 应用 config 覆盖(如果有)
|
||||
if config:
|
||||
cfg_host_id = config.get("host_id")
|
||||
if cfg_host_id:
|
||||
self.host_id = cfg_host_id
|
||||
|
||||
signal_backend_url = config.get("signal_backend_url")
|
||||
if signal_backend_url:
|
||||
signal_backend_url = signal_backend_url.rstrip("/")
|
||||
if not signal_backend_url.endswith("/host"):
|
||||
signal_backend_url = signal_backend_url + "/host"
|
||||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
||||
|
||||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
||||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
||||
self.webrtc_stream_url = config.get("webrtc_stream_url", self.webrtc_stream_url)
|
||||
|
||||
self.video_device = config.get("video_device", self.video_device)
|
||||
self.width = int(config.get("width", self.width))
|
||||
self.height = int(config.get("height", self.height))
|
||||
self.fps = int(config.get("fps", self.fps))
|
||||
self.video_bitrate = config.get("video_bitrate", self.video_bitrate)
|
||||
self.audio_device = config.get("audio_device", self.audio_device)
|
||||
self.audio_bitrate = config.get("audio_bitrate", self.audio_bitrate)
|
||||
|
||||
self._running = True
|
||||
|
||||
print("[CameraController] start(): starting FFmpeg streaming...", file=sys.stderr)
|
||||
self._start_ffmpeg()
|
||||
|
||||
self._loop = asyncio.new_event_loop()
|
||||
|
||||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_forever()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
||||
|
||||
self._loop_thread = threading.Thread(target=loop_runner, args=(self._loop,), daemon=True)
|
||||
self._loop_thread.start()
|
||||
|
||||
self._loop_task = asyncio.run_coroutine_threadsafe(self._run_main_loop(), self._loop)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"host_id": self.host_id,
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
"webrtc_api": self.webrtc_api,
|
||||
"webrtc_stream_url": self.webrtc_stream_url,
|
||||
"video_device": self.video_device,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"fps": self.fps,
|
||||
"video_bitrate": self.video_bitrate,
|
||||
"audio_device": self.audio_device,
|
||||
}
|
||||
|
||||
def stop(self) -> Dict[str, Any]:
|
||||
self._running = False
|
||||
|
||||
# 先取消主任务(让 ws connect/sleep 尽快退出)
|
||||
if self._loop_task is not None and not self._loop_task.done():
|
||||
self._loop_task.cancel()
|
||||
|
||||
# 停止推流
|
||||
self._stop_ffmpeg()
|
||||
|
||||
# 关闭 WebSocket(在 loop 中执行)
|
||||
if self._ws and self._loop is not None:
|
||||
|
||||
async def close_ws():
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error closing WebSocket: {e}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 停止事件循环
|
||||
if self._loop is not None:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error stopping loop: {e}", file=sys.stderr)
|
||||
|
||||
# 等待线程退出
|
||||
if self._loop_thread is not None:
|
||||
try:
|
||||
self._loop_thread.join(timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error joining loop thread: {e}", file=sys.stderr)
|
||||
|
||||
self._ws = None
|
||||
self._loop_task = None
|
||||
self._loop = None
|
||||
self._loop_thread = None
|
||||
|
||||
return {"status": "stopped", "host_id": self.host_id}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
ws_closed = None
|
||||
if self._ws is not None:
|
||||
ws_closed = getattr(self._ws, "closed", None)
|
||||
|
||||
if ws_closed is None:
|
||||
websocket_connected = self._ws is not None
|
||||
else:
|
||||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
||||
|
||||
return {
|
||||
"host_id": self.host_id,
|
||||
"running": self._running,
|
||||
"websocket_connected": websocket_connected,
|
||||
"ffmpeg_running": bool(self._ffmpeg_process and self._ffmpeg_process.poll() is None),
|
||||
"signal_backend_url": self.signal_backend_url,
|
||||
"rtmp_url": self.rtmp_url,
|
||||
"video_device": self.video_device,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"fps": self.fps,
|
||||
"video_bitrate": self.video_bitrate,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# WebSocket / 信令
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
async def _run_main_loop(self):
|
||||
print("[CameraController] main loop started", file=sys.stderr)
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
async with websockets.connect(self.signal_backend_url) as ws:
|
||||
self._ws = ws
|
||||
print(f"[CameraController] WebSocket connected: {self.signal_backend_url}", file=sys.stderr)
|
||||
await self._recv_loop()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
print(f"[CameraController] WebSocket connection error: {e}", file=sys.stderr)
|
||||
await asyncio.sleep(3)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
print("[CameraController] main loop exited", file=sys.stderr)
|
||||
|
||||
async def _recv_loop(self):
|
||||
assert self._ws is not None
|
||||
ws = self._ws
|
||||
|
||||
async for message in ws:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
print(f"[CameraController] non-JSON message: {message}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._handle_message(data)
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error handling message {data}: {e}", file=sys.stderr)
|
||||
|
||||
async def _handle_message(self, data: Dict[str, Any]):
|
||||
cmd = data.get("command")
|
||||
|
||||
if cmd == "start_stream":
|
||||
self._start_ffmpeg()
|
||||
return
|
||||
|
||||
if cmd == "stop_stream":
|
||||
self._stop_ffmpeg()
|
||||
return
|
||||
|
||||
if data.get("type") == "offer":
|
||||
offer_sdp = data.get("sdp", "")
|
||||
camera_id = data.get("cameraId", "camera-01")
|
||||
|
||||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
||||
|
||||
if self._ws:
|
||||
answer_payload = {
|
||||
"type": "answer",
|
||||
"sdp": answer_sdp,
|
||||
"cameraId": camera_id,
|
||||
"hostId": self.host_id,
|
||||
}
|
||||
await self._ws.send(json.dumps(answer_payload))
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# FFmpeg 推流(V4L2 USB 摄像头)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _start_ffmpeg(self):
|
||||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
||||
return
|
||||
|
||||
# 兼容性优先:不强制输入像素格式;失败再通过外部调整 width/height/fps
|
||||
video_size = f"{self.width}x{self.height}"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
|
||||
# video input
|
||||
"-f", "v4l2",
|
||||
"-framerate", str(self.fps),
|
||||
"-video_size", video_size,
|
||||
"-i", self.video_device,
|
||||
]
|
||||
|
||||
# optional audio input
|
||||
if self.audio_device:
|
||||
cmd += [
|
||||
"-f", "alsa",
|
||||
"-i", self.audio_device,
|
||||
"-c:a", "aac",
|
||||
"-b:a", self.audio_bitrate,
|
||||
"-ar", "44100",
|
||||
"-ac", "1",
|
||||
]
|
||||
else:
|
||||
cmd += ["-an"]
|
||||
|
||||
# video encode + rtmp out
|
||||
cmd += [
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "baseline",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-b:v", self.video_bitrate,
|
||||
"-maxrate", self.video_bitrate,
|
||||
"-bufsize", "2M",
|
||||
"-g", str(max(self.fps, 10)),
|
||||
"-keyint_min", str(max(self.fps, 10)),
|
||||
"-sc_threshold", "0",
|
||||
"-x264-params", "bframes=0",
|
||||
|
||||
"-f", "flv",
|
||||
self.rtmp_url,
|
||||
]
|
||||
|
||||
print(f"[CameraController] starting FFmpeg: {' '.join(cmd)}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
# 不再丢弃日志,至少能看到 ffmpeg 报错(调试很关键)
|
||||
self._ffmpeg_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=sys.stderr,
|
||||
shell=False,
|
||||
)
|
||||
except Exception as e:
|
||||
self._ffmpeg_process = None
|
||||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
||||
|
||||
def _stop_ffmpeg(self):
|
||||
proc = self._ffmpeg_process
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
print(f"[CameraController] error stopping FFmpeg: {e}", file=sys.stderr)
|
||||
self._ffmpeg_process = None
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# WebRTC offer -> SRS
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
||||
payload = {
|
||||
"api": self.webrtc_api,
|
||||
"streamurl": self.webrtc_stream_url,
|
||||
"sdp": offer_sdp,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def _do_post():
|
||||
return requests.post(self.webrtc_api, json=payload, headers=headers, timeout=10)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(None, _do_post)
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
answer_sdp = data.get("sdp", "")
|
||||
if not answer_sdp:
|
||||
raise RuntimeError(f"empty SDP from media server: {data}")
|
||||
return answer_sdp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 直接运行用于手动测试
|
||||
c = CameraController(
|
||||
host_id="demo-host",
|
||||
video_device="/dev/video0",
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=30,
|
||||
video_bitrate="1500k",
|
||||
audio_device=None,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
c.stop()
|
||||
51
unilabos/devices/cameraSII/cameraUSB_test.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
import json
|
||||
|
||||
from cameraUSB import CameraController
|
||||
|
||||
|
||||
def main():
|
||||
# 按你的实际情况改
|
||||
cfg = dict(
|
||||
host_id="demo-host",
|
||||
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
video_device="/dev/video7",
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=30,
|
||||
video_bitrate="1500k",
|
||||
audio_device=None,
|
||||
)
|
||||
|
||||
c = CameraController(**cfg)
|
||||
|
||||
# 可选:如果你不想依赖 __init__ 自动 start,可以这样显式调用:
|
||||
# c = CameraController(host_id=cfg["host_id"])
|
||||
# c.start(cfg)
|
||||
|
||||
run_seconds = 30 # 测试运行时长
|
||||
t0 = time.time()
|
||||
|
||||
try:
|
||||
while True:
|
||||
st = c.get_status()
|
||||
print(json.dumps(st, ensure_ascii=False, indent=2))
|
||||
|
||||
if time.time() - t0 >= run_seconds:
|
||||
break
|
||||
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted, stopping...")
|
||||
finally:
|
||||
print("Stopping controller...")
|
||||
c.stop()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
36
unilabos/devices/cameraSII/demo_camera_pic.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import cv2
|
||||
|
||||
# 推荐把 @ 进行 URL 编码:@ -> %40
|
||||
RTSP_URL = "rtsp://admin:admin123@192.168.31.164:554/stream1"
|
||||
OUTPUT_IMAGE = "rtsp_test_frame.jpg"
|
||||
|
||||
def main():
|
||||
print(f"尝试连接 RTSP 流: {RTSP_URL}")
|
||||
cap = cv2.VideoCapture(RTSP_URL)
|
||||
|
||||
if not cap.isOpened():
|
||||
print("错误:无法打开 RTSP 流,请检查:")
|
||||
print(" 1. IP/端口是否正确")
|
||||
print(" 2. 账号密码(尤其是 @ 是否已转成 %40)是否正确")
|
||||
print(" 3. 摄像头是否允许当前主机访问(同一网段、防火墙等)")
|
||||
return
|
||||
|
||||
print("连接成功,开始读取一帧...")
|
||||
ret, frame = cap.read()
|
||||
|
||||
if not ret or frame is None:
|
||||
print("错误:已连接但未能读取到帧数据(可能是码流未开启或网络抖动)")
|
||||
cap.release()
|
||||
return
|
||||
|
||||
# 保存当前帧
|
||||
success = cv2.imwrite(OUTPUT_IMAGE, frame)
|
||||
cap.release()
|
||||
|
||||
if success:
|
||||
print(f"成功截取一帧并保存为: {OUTPUT_IMAGE}")
|
||||
else:
|
||||
print("错误:写入图片失败,请检查磁盘权限/路径")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
21
unilabos/devices/cameraSII/demo_camera_push.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# run_camera_push.py
|
||||
import time
|
||||
from cameraDriver import CameraController # 这里根据你的文件名调整
|
||||
|
||||
if __name__ == "__main__":
|
||||
controller = CameraController(
|
||||
host_id="demo-host",
|
||||
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
|
||||
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||||
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
|
||||
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||||
camera_rtsp_url="rtsp://admin:admin123@192.168.31.164:554/stream1",
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
status = controller.get_status()
|
||||
print(status)
|
||||
time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
controller.stop()
|
||||
78
unilabos/devices/cameraSII/ptz_cameracontroller_test.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
使用 CameraController 来测试 PTZ:
|
||||
让摄像头按顺序向下、向上、向左、向右运动几次。
|
||||
"""
|
||||
|
||||
import time
|
||||
import sys
|
||||
|
||||
# 根据你的工程结构修改导入路径:
|
||||
# 假设 CameraController 定义在 cameraController.py 里
|
||||
from cameraDriver import CameraController
|
||||
|
||||
|
||||
def main():
|
||||
# === 根据你的实际情况填 IP、端口、账号密码 ===
|
||||
ptz_host = "192.168.31.164"
|
||||
ptz_port = 2020 # 注意要和你单独测试 PTZController 时保持一致
|
||||
ptz_user = "admin"
|
||||
ptz_password = "admin123"
|
||||
|
||||
# 1. 创建 CameraController 实例
|
||||
cam = CameraController(
|
||||
# 其他摄像机相关参数按你类的 __init__ 来补充
|
||||
ptz_host=ptz_host,
|
||||
ptz_port=ptz_port,
|
||||
ptz_user=ptz_user,
|
||||
ptz_password=ptz_password,
|
||||
)
|
||||
|
||||
# 2. 启动 / 初始化(如果你的 CameraController 有 start(config) 之类的接口)
|
||||
# 这里给一个最小的 config,重点是 PTZ 相关字段
|
||||
config = {
|
||||
"ptz_host": ptz_host,
|
||||
"ptz_port": ptz_port,
|
||||
"ptz_user": ptz_user,
|
||||
"ptz_password": ptz_password,
|
||||
}
|
||||
|
||||
try:
|
||||
cam.start(config)
|
||||
except Exception as e:
|
||||
print(f"[TEST] CameraController start() 失败: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 这里可以判断一下内部 _ptz 是否初始化成功(如果你对 CameraController 做了封装)
|
||||
if getattr(cam, "_ptz", None) is None:
|
||||
print("[TEST] CameraController 内部 PTZ 未初始化成功,请检查 ptz_host/port/user/password 配置。", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 3. 依次调用 CameraController 的 PTZ 方法
|
||||
# 这里假设你在 CameraController 中提供了这几个对外方法:
|
||||
# ptz_move_down / ptz_move_up / ptz_move_left / ptz_move_right
|
||||
# 如果你命名不一样,把下面调用名改成你的即可。
|
||||
|
||||
print("向下移动(通过 CameraController)...")
|
||||
cam.ptz_move_down(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向上移动(通过 CameraController)...")
|
||||
cam.ptz_move_up(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向左移动(通过 CameraController)...")
|
||||
cam.ptz_move_left(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向右移动(通过 CameraController)...")
|
||||
cam.ptz_move_right(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("测试结束。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
unilabos/devices/cameraSII/ptz_test.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
测试 cameraDriver.py中的 PTZController 类,让摄像头按顺序运动几次
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from cameraDriver import PTZController
|
||||
|
||||
|
||||
def main():
|
||||
# 根据你的实际情况填 IP、端口、账号密码
|
||||
host = "192.168.31.164"
|
||||
port = 80
|
||||
user = "admin"
|
||||
password = "admin123"
|
||||
|
||||
ptz = PTZController(host=host, port=port, user=user, password=password)
|
||||
|
||||
# 1. 连接摄像头
|
||||
if not ptz.connect():
|
||||
print("连接 PTZ 失败,检查 IP/用户名/密码/端口。")
|
||||
return
|
||||
|
||||
# 2. 依次测试几个动作
|
||||
# 每个动作之间 sleep 一下方便观察
|
||||
|
||||
print("向下移动...")
|
||||
ptz.move_down(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向上移动...")
|
||||
ptz.move_up(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向左移动...")
|
||||
ptz.move_left(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("向右移动...")
|
||||
ptz.move_right(speed=0.5, duration=1.0)
|
||||
time.sleep(1)
|
||||
|
||||
print("测试结束。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker):
|
||||
for i in range(self.driver._finished, temp):
|
||||
sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数
|
||||
pdf, txt = self.driver.get_data_file(i + 1)
|
||||
device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default"
|
||||
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id)
|
||||
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id)
|
||||
# 使用新的OSS上传接口,传入driver_name和exp_type
|
||||
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
|
||||
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
|
||||
|
||||
if pdf_result["success"]:
|
||||
print(f"PDF上传成功: {pdf_result['oss_path']}")
|
||||
else:
|
||||
print(f"PDF上传失败: {pdf_result['original_path']}")
|
||||
|
||||
if txt_result["success"]:
|
||||
print(f"TXT上传成功: {txt_result['oss_path']}")
|
||||
else:
|
||||
print(f"TXT上传失败: {txt_result['original_path']}")
|
||||
# self.driver.extract_data_from_txt()
|
||||
except Exception as ex:
|
||||
self.driver._finished = 0
|
||||
@@ -456,8 +466,12 @@ if __name__ == "__main__":
|
||||
}
|
||||
sample_id = obj._get_resource_sample_id("test", 0)
|
||||
pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6))
|
||||
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example")
|
||||
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result")
|
||||
# 使用新的OSS上传接口,传入driver_name和exp_type
|
||||
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
|
||||
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
|
||||
|
||||
print(f"PDF上传结果: {pdf_result}")
|
||||
print(f"TXT上传结果: {txt_result}")
|
||||
# driver = HPLCDriver()
|
||||
# for i in range(10000):
|
||||
# print({k: v for k, v in driver._device_status.items() if isinstance(v, str)})
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站集成模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
|
||||
- 硬件后端和抽象接口
|
||||
- 资源定义和管理
|
||||
- 协议执行和液体传输
|
||||
- 工作台配置和布局
|
||||
|
||||
主要组件:
|
||||
- LaiYuLiquidBackend: 硬件后端实现
|
||||
- LaiYuLiquid: 液体处理器抽象接口
|
||||
- 各种资源类:枪头架、板、容器等
|
||||
- 便捷创建函数和配置管理
|
||||
|
||||
使用示例:
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidBackend,
|
||||
create_standard_deck,
|
||||
create_tip_rack_1000ul
|
||||
)
|
||||
|
||||
# 创建后端和液体处理器
|
||||
backend = LaiYuLiquidBackend()
|
||||
lh = LaiYuLiquid(backend=backend)
|
||||
|
||||
# 创建工作台
|
||||
deck = create_standard_deck()
|
||||
lh.deck = deck
|
||||
|
||||
# 设置和运行
|
||||
await lh.setup()
|
||||
"""
|
||||
|
||||
# 版本信息
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Integration Team"
|
||||
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
|
||||
|
||||
# 驱动程序导入
|
||||
from .drivers import (
|
||||
XYZStepperController,
|
||||
SOPAPipette,
|
||||
MotorAxis,
|
||||
MotorStatus,
|
||||
SOPAConfig,
|
||||
SOPAStatusCode,
|
||||
StepperMotorDriver
|
||||
)
|
||||
|
||||
# 控制器导入
|
||||
from .controllers import (
|
||||
XYZController,
|
||||
PipetteController,
|
||||
)
|
||||
|
||||
# 后端导入
|
||||
from .backend.rviz_backend import (
|
||||
LiquidHandlerRvizBackend,
|
||||
)
|
||||
|
||||
# 资源类和创建函数导入
|
||||
from .core.laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
# 主设备类和配置
|
||||
from .core.laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
# 后端创建函数导入
|
||||
from .backend import (
|
||||
LaiYuLiquidBackend,
|
||||
create_laiyu_backend,
|
||||
)
|
||||
|
||||
# 导出所有公共接口
|
||||
__all__ = [
|
||||
# 版本信息
|
||||
"__version__",
|
||||
"__author__",
|
||||
"__description__",
|
||||
|
||||
# 驱动程序
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
|
||||
# 控制器
|
||||
"PipetteController",
|
||||
"XYZController",
|
||||
|
||||
# 后端
|
||||
"LiquidHandlerRvizBackend",
|
||||
|
||||
# 资源创建函数
|
||||
"create_tip_rack_1000ul",
|
||||
"create_tip_rack_200ul",
|
||||
"create_96_well_plate",
|
||||
"create_deep_well_plate",
|
||||
"create_8_tube_rack",
|
||||
"create_standard_deck",
|
||||
"create_waste_container",
|
||||
"create_wash_container",
|
||||
"create_reagent_container",
|
||||
"load_deck_config",
|
||||
|
||||
# 后端创建函数
|
||||
"create_laiyu_backend",
|
||||
|
||||
# 主要类
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidDeck",
|
||||
|
||||
# 工具函数
|
||||
"get_version",
|
||||
"get_supported_resources",
|
||||
"create_quick_setup",
|
||||
"validate_installation",
|
||||
"print_module_info",
|
||||
"setup_logging",
|
||||
]
|
||||
|
||||
# 别名定义,为了向后兼容
|
||||
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
|
||||
LaiYuLiquidController = XYZController # 控制器别名
|
||||
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
|
||||
|
||||
# 模块级别的便捷函数
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
获取模块版本
|
||||
|
||||
Returns:
|
||||
str: 版本号
|
||||
"""
|
||||
return __version__
|
||||
|
||||
|
||||
def get_supported_resources() -> dict:
|
||||
"""
|
||||
获取支持的资源类型
|
||||
|
||||
Returns:
|
||||
dict: 支持的资源类型字典
|
||||
"""
|
||||
return {
|
||||
"tip_racks": {
|
||||
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
|
||||
},
|
||||
"containers": {
|
||||
"LaiYuLiquidContainer": LaiYuLiquidContainer,
|
||||
},
|
||||
"decks": {
|
||||
"LaiYuLiquidDeck": LaiYuLiquidDeck,
|
||||
},
|
||||
"devices": {
|
||||
"LaiYuLiquid": LaiYuLiquid,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> tuple:
|
||||
"""
|
||||
快速创建基本设置
|
||||
|
||||
Returns:
|
||||
tuple: (backend, controllers, resources) 的元组
|
||||
"""
|
||||
# 创建后端
|
||||
backend = LiquidHandlerRvizBackend()
|
||||
|
||||
# 创建控制器(使用默认端口进行演示)
|
||||
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
|
||||
|
||||
# 创建测试资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
well_plate = create_96_well_plate("96_well_plate")
|
||||
|
||||
controllers = {
|
||||
'pipette': pipette_controller,
|
||||
'xyz': xyz_controller
|
||||
}
|
||||
|
||||
resources = {
|
||||
'tip_rack_1000': tip_rack_1000,
|
||||
'tip_rack_200': tip_rack_200,
|
||||
'well_plate': well_plate
|
||||
}
|
||||
|
||||
return backend, controllers, resources
|
||||
|
||||
|
||||
def validate_installation() -> bool:
|
||||
"""
|
||||
验证模块安装是否正确
|
||||
|
||||
Returns:
|
||||
bool: 安装是否正确
|
||||
"""
|
||||
try:
|
||||
# 检查核心类是否可以导入
|
||||
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
|
||||
from .backend import LaiYuLiquidBackend
|
||||
from .controllers import XYZController, PipetteController
|
||||
from .drivers import XYZStepperController, SOPAPipette
|
||||
|
||||
# 尝试创建基本对象
|
||||
config = LaiYuLiquidConfig()
|
||||
backend = create_laiyu_backend("validation_test")
|
||||
|
||||
print("模块安装验证成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"模块安装验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_module_info():
|
||||
"""打印模块信息"""
|
||||
print(f"LaiYu_Liquid 集成模块")
|
||||
print(f"版本: {__version__}")
|
||||
print(f"作者: {__author__}")
|
||||
print(f"描述: {__description__}")
|
||||
print(f"")
|
||||
print(f"支持的资源类型:")
|
||||
|
||||
resources = get_supported_resources()
|
||||
for category, types in resources.items():
|
||||
print(f" {category}:")
|
||||
for type_name, type_class in types.items():
|
||||
print(f" - {type_name}: {type_class.__name__}")
|
||||
|
||||
print(f"")
|
||||
print(f"主要功能:")
|
||||
print(f" - 硬件集成: LaiYuLiquidBackend")
|
||||
print(f" - 抽象接口: LaiYuLiquid")
|
||||
print(f" - 资源管理: 各种资源类和创建函数")
|
||||
print(f" - 协议执行: transfer_liquid 和相关函数")
|
||||
print(f" - 配置管理: deck.json 和加载函数")
|
||||
|
||||
|
||||
# 模块初始化时的检查
|
||||
def _check_dependencies():
|
||||
"""检查依赖项"""
|
||||
try:
|
||||
import pylabrobot
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
return True
|
||||
except ImportError as e:
|
||||
import logging
|
||||
logging.warning(f"缺少依赖项 {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 执行依赖检查
|
||||
_dependencies_ok = _check_dependencies()
|
||||
|
||||
if not _dependencies_ok:
|
||||
import logging
|
||||
logging.warning("某些依赖项缺失,模块功能可能受限")
|
||||
|
||||
|
||||
# 模块级别的日志配置
|
||||
import logging
|
||||
|
||||
def setup_logging(level: str = "INFO"):
|
||||
"""
|
||||
设置模块日志
|
||||
|
||||
Args:
|
||||
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||
"""
|
||||
logger = logging.getLogger("LaiYu_Liquid")
|
||||
logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 默认日志设置
|
||||
_logger = setup_logging()
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
@@ -1,334 +0,0 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端实现
|
||||
|
||||
提供设备的后端接口和控制逻辑
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 尝试导入PyLabRobot后端
|
||||
try:
|
||||
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
# 创建模拟后端基类
|
||||
class LiquidHandlerBackend:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.is_connected = False
|
||||
|
||||
def connect(self):
|
||||
"""连接设备"""
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
pass
|
||||
|
||||
|
||||
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
||||
"""LaiYu液体处理设备后端"""
|
||||
|
||||
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
||||
"""
|
||||
初始化LaiYu液体处理设备后端
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
"""
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
||||
super().__init__()
|
||||
else:
|
||||
# 模拟版本接受 name 参数
|
||||
super().__init__(name)
|
||||
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.is_connected = False
|
||||
self.device_info = {
|
||||
"name": "LaiYu液体处理设备",
|
||||
"version": "1.0.0",
|
||||
"manufacturer": "LaiYu",
|
||||
"model": "LaiYu_Liquid_Handler"
|
||||
}
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到LaiYu液体处理设备
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在连接到LaiYu液体处理设备...")
|
||||
# 这里应该实现实际的设备连接逻辑
|
||||
# 目前返回模拟连接成功
|
||||
self.is_connected = True
|
||||
self.logger.info("成功连接到LaiYu液体处理设备")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""
|
||||
断开与LaiYu液体处理设备的连接
|
||||
|
||||
Returns:
|
||||
bool: 断开连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
||||
# 这里应该实现实际的设备断开连接逻辑
|
||||
self.is_connected = False
|
||||
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""
|
||||
检查设备是否已连接
|
||||
|
||||
Returns:
|
||||
bool: 设备是否已连接
|
||||
"""
|
||||
return self.is_connected
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备信息字典
|
||||
"""
|
||||
return self.device_info.copy()
|
||||
|
||||
def home_device(self) -> bool:
|
||||
"""
|
||||
设备归零操作
|
||||
|
||||
Returns:
|
||||
bool: 归零是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行归零操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info("正在执行设备归零操作...")
|
||||
# 这里应该实现实际的设备归零逻辑
|
||||
self.logger.info("设备归零操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"设备归零操作失败: {e}")
|
||||
return False
|
||||
|
||||
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
吸液操作
|
||||
|
||||
Args:
|
||||
volume: 吸液体积 (微升)
|
||||
location: 吸液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 吸液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行吸液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的吸液逻辑
|
||||
self.logger.info("吸液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"吸液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
排液操作
|
||||
|
||||
Args:
|
||||
volume: 排液体积 (微升)
|
||||
location: 排液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 排液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行排液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的排液逻辑
|
||||
self.logger.info("排液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"排液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
取枪头操作
|
||||
|
||||
Args:
|
||||
location: 枪头位置信息
|
||||
|
||||
Returns:
|
||||
bool: 取枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行取枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的取枪头逻辑
|
||||
self.logger.info("取枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"取枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
丢弃枪头操作
|
||||
|
||||
Args:
|
||||
location: 丢弃位置信息
|
||||
|
||||
Returns:
|
||||
bool: 丢弃枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的丢弃枪头逻辑
|
||||
self.logger.info("丢弃枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"丢弃枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def move_to(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
location: 目标位置信息
|
||||
|
||||
Returns:
|
||||
bool: 移动是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行移动操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在移动到位置: {location}")
|
||||
# 这里应该实现实际的移动逻辑
|
||||
self.logger.info("移动操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动操作失败: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备状态信息
|
||||
"""
|
||||
return {
|
||||
"connected": self.is_connected,
|
||||
"device_info": self.device_info,
|
||||
"status": "ready" if self.is_connected else "disconnected"
|
||||
}
|
||||
|
||||
# PyLabRobot 抽象方法实现
|
||||
def stop(self):
|
||||
"""停止所有操作"""
|
||||
self.logger.info("停止所有操作")
|
||||
pass
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
"""返回通道数量"""
|
||||
return 1 # 单通道移液器
|
||||
|
||||
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
||||
"""检查是否可以拾取吸头"""
|
||||
return True # 简化实现,总是返回True
|
||||
|
||||
def pick_up_tips(self, tip_rack, tip_positions):
|
||||
"""拾取多个吸头"""
|
||||
self.logger.info(f"拾取吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def drop_tips(self, tip_rack, tip_positions):
|
||||
"""丢弃多个吸头"""
|
||||
self.logger.info(f"丢弃吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def pick_up_tips96(self, tip_rack):
|
||||
"""拾取96个吸头"""
|
||||
self.logger.info("拾取96个吸头")
|
||||
pass
|
||||
|
||||
def drop_tips96(self, tip_rack):
|
||||
"""丢弃96个吸头"""
|
||||
self.logger.info("丢弃96个吸头")
|
||||
pass
|
||||
|
||||
def aspirate96(self, volume, plate, well_positions):
|
||||
"""96通道吸液"""
|
||||
self.logger.info(f"96通道吸液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def dispense96(self, volume, plate, well_positions):
|
||||
"""96通道排液"""
|
||||
self.logger.info(f"96通道排液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def pick_up_resource(self, resource, location):
|
||||
"""拾取资源"""
|
||||
self.logger.info(f"拾取资源: {resource}")
|
||||
pass
|
||||
|
||||
def drop_resource(self, resource, location):
|
||||
"""放置资源"""
|
||||
self.logger.info(f"放置资源: {resource}")
|
||||
pass
|
||||
|
||||
def move_picked_up_resource(self, resource, location):
|
||||
"""移动已拾取的资源"""
|
||||
self.logger.info(f"移动资源: {resource} 到 {location}")
|
||||
pass
|
||||
|
||||
|
||||
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
||||
"""
|
||||
创建LaiYu液体处理设备后端实例
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidBackend: 后端实例
|
||||
"""
|
||||
return LaiYuLiquidBackend(name)
|
||||
@@ -1,209 +0,0 @@
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pylabrobot.liquid_handling.backends.backend import (
|
||||
LiquidHandlerBackend,
|
||||
)
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
Drop,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadAspirationPlate,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
Pickup,
|
||||
PickupTipRack,
|
||||
ResourceDrop,
|
||||
ResourceMove,
|
||||
ResourcePickup,
|
||||
SingleChannelAspiration,
|
||||
SingleChannelDispense,
|
||||
)
|
||||
from pylabrobot.resources import Resource, Tip
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
import time
|
||||
from rclpy.action import ActionClient
|
||||
from unilabos_msgs.action import SendCmd
|
||||
import re
|
||||
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
||||
|
||||
|
||||
class LiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
||||
|
||||
_pip_length = 5
|
||||
_vol_length = 8
|
||||
_resource_length = 20
|
||||
_offset_length = 16
|
||||
_flow_rate_length = 10
|
||||
_blowout_length = 10
|
||||
_lld_z_length = 10
|
||||
_kwargs_length = 15
|
||||
_tip_type_length = 12
|
||||
_max_volume_length = 16
|
||||
_fitting_depth_length = 20
|
||||
_tip_length_length = 16
|
||||
# _pickup_method_length = 20
|
||||
_filter_length = 10
|
||||
|
||||
def __init__(self, num_channels: int = 8):
|
||||
"""Initialize a chatter box backend."""
|
||||
super().__init__()
|
||||
self._num_channels = num_channels
|
||||
# rclpy.init()
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
|
||||
async def setup(self):
|
||||
self.joint_state_publisher = JointStatePublisher()
|
||||
await super().setup()
|
||||
async def stop(self):
|
||||
pass
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {**super().serialize(), "num_channels": self.num_channels}
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
return self._num_channels
|
||||
|
||||
async def assigned_resource_callback(self, resource: Resource):
|
||||
pass
|
||||
|
||||
async def unassigned_resource_callback(self, name: str):
|
||||
pass
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
|
||||
)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
|
||||
# goback()
|
||||
|
||||
|
||||
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
|
||||
# goback()
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
ops: List[SingleChannelAspiration],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
# 执行吸液操作
|
||||
pass
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<15}"
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
ops: List[SingleChannelDispense],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<{LiquidHandlerRvizBackend._kwargs_length}}"
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
x = coordinate.x
|
||||
y = coordinate.y
|
||||
z = coordinate.z + 70
|
||||
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||
|
||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||
pass
|
||||
|
||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||
pass
|
||||
|
||||
async def aspirate96(
|
||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||
):
|
||||
pass
|
||||
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
pass
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
# 执行资源拾取操作
|
||||
pass
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
# 执行资源移动操作
|
||||
pass
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
# 执行资源放置操作
|
||||
pass
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
goto 171 178 57 H1
|
||||
goto 171 117 57 A1
|
||||
goto 172 178 130
|
||||
goto 173 179 133
|
||||
goto 173 180 133
|
||||
goto 173 180 138
|
||||
goto 173 180 125 (+10mm,在空的上面边缘)
|
||||
goto 173 180 130 取不到
|
||||
goto 173 180 133 取不到
|
||||
goto 173 180 135
|
||||
goto 173 180 137 取到了!!!!
|
||||
goto 173 180 131 弹出枪头 H1
|
||||
|
||||
goto 173 117 137 A1 (+10mm,可以取到新枪头了!!!!)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 控制器模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器:
|
||||
- 移液器控制器:提供液体处理的高级接口
|
||||
- XYZ运动控制器:提供三轴运动的高级接口
|
||||
"""
|
||||
|
||||
# 移液器控制器导入
|
||||
from .pipette_controller import PipetteController
|
||||
|
||||
# XYZ运动控制器导入
|
||||
from .xyz_controller import XYZController
|
||||
|
||||
__all__ = [
|
||||
# 移液器控制器
|
||||
"PipetteController",
|
||||
|
||||
# XYZ运动控制器
|
||||
"XYZController",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Controller Team"
|
||||
__description__ = "LaiYu_Liquid 高级控制器集合"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备核心模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的核心功能组件:
|
||||
- LaiYu_Liquid.py: 主设备类和配置管理
|
||||
- abstract_protocol.py: 抽象协议定义
|
||||
- laiyu_liquid_res.py: 设备资源管理
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
from .laiyu_liquid_main import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidBackend,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
from .laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 主设备类
|
||||
'LaiYuLiquid',
|
||||
'LaiYuLiquidConfig',
|
||||
'LaiYuLiquidBackend',
|
||||
|
||||
# 设备资源
|
||||
'LaiYuLiquidDeck',
|
||||
'LaiYuLiquidContainer',
|
||||
'LaiYuLiquidTipRack',
|
||||
|
||||
# 工具函数
|
||||
'create_quick_setup'
|
||||
]
|
||||
@@ -1,529 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 抽象协议实现
|
||||
|
||||
该模块提供了液体资源管理和转移的抽象协议,包括:
|
||||
- MaterialResource: 液体资源管理类
|
||||
- transfer_liquid: 液体转移函数
|
||||
- 相关的辅助类和函数
|
||||
|
||||
主要功能:
|
||||
- 管理多孔位的液体资源
|
||||
- 计算和跟踪液体体积
|
||||
- 处理液体转移操作
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Union, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import time
|
||||
|
||||
# pylabrobot 导入
|
||||
from pylabrobot.resources import Resource, Well, Plate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiquidType(Enum):
|
||||
"""液体类型枚举"""
|
||||
WATER = "water"
|
||||
ETHANOL = "ethanol"
|
||||
DMSO = "dmso"
|
||||
BUFFER = "buffer"
|
||||
SAMPLE = "sample"
|
||||
REAGENT = "reagent"
|
||||
WASTE = "waste"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiquidInfo:
|
||||
"""液体信息类"""
|
||||
liquid_type: LiquidType = LiquidType.UNKNOWN
|
||||
volume: float = 0.0 # 体积 (μL)
|
||||
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
|
||||
ph: Optional[float] = None # pH值
|
||||
temperature: Optional[float] = None # 温度 (°C)
|
||||
viscosity: Optional[float] = None # 粘度 (cP)
|
||||
density: Optional[float] = None # 密度 (g/ml)
|
||||
description: str = "" # 描述信息
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.liquid_type.value}({self.description})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WellContent:
|
||||
"""孔位内容类"""
|
||||
volume: float = 0.0 # 当前体积 (ul)
|
||||
max_volume: float = 1000.0 # 最大容量 (ul)
|
||||
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
|
||||
last_updated: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""检查是否为空"""
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""检查是否已满"""
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""可用体积"""
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
@property
|
||||
def fill_percentage(self) -> float:
|
||||
"""填充百分比"""
|
||||
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
|
||||
|
||||
def can_add_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以添加指定体积"""
|
||||
return (self.volume + volume) <= self.max_volume
|
||||
|
||||
def can_remove_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以移除指定体积"""
|
||||
return self.volume >= volume
|
||||
|
||||
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
|
||||
"""
|
||||
添加液体体积
|
||||
|
||||
Args:
|
||||
volume: 要添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if not self.can_add_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume += volume
|
||||
if liquid_info:
|
||||
self.liquid_info = liquid_info
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""
|
||||
移除液体体积
|
||||
|
||||
Args:
|
||||
volume: 要移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if not self.can_remove_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
|
||||
# 如果完全清空,重置液体信息
|
||||
if self.volume <= 0.0:
|
||||
self.volume = 0.0
|
||||
self.liquid_info = LiquidInfo()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class MaterialResource:
|
||||
"""
|
||||
液体资源管理类
|
||||
|
||||
该类用于管理液体处理过程中的资源状态,包括:
|
||||
- 跟踪多个孔位的液体体积和类型
|
||||
- 计算总体积和可用体积
|
||||
- 处理液体的添加和移除
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: Resource,
|
||||
wells: Optional[List[Well]] = None,
|
||||
default_max_volume: float = 1000.0
|
||||
):
|
||||
"""
|
||||
初始化材料资源
|
||||
|
||||
Args:
|
||||
resource: pylabrobot 资源对象
|
||||
wells: 孔位列表,如果为None则自动获取
|
||||
default_max_volume: 默认最大体积 (ul)
|
||||
"""
|
||||
self.resource = resource
|
||||
self.resource_id = str(uuid.uuid4())
|
||||
self.default_max_volume = default_max_volume
|
||||
|
||||
# 获取孔位列表
|
||||
if wells is None:
|
||||
if hasattr(resource, 'get_wells'):
|
||||
self.wells = resource.get_wells()
|
||||
elif hasattr(resource, 'wells'):
|
||||
self.wells = resource.wells
|
||||
else:
|
||||
# 如果没有孔位,创建一个虚拟孔位
|
||||
self.wells = [resource]
|
||||
else:
|
||||
self.wells = wells
|
||||
|
||||
# 初始化孔位内容
|
||||
self.well_contents: Dict[str, WellContent] = {}
|
||||
for well in self.wells:
|
||||
well_id = self._get_well_id(well)
|
||||
self.well_contents[well_id] = WellContent(
|
||||
max_volume=default_max_volume
|
||||
)
|
||||
|
||||
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
|
||||
|
||||
def _get_well_id(self, well: Union[Well, Resource]) -> str:
|
||||
"""获取孔位ID"""
|
||||
if hasattr(well, 'name'):
|
||||
return well.name
|
||||
else:
|
||||
return str(id(well))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""资源名称"""
|
||||
return self.resource.name
|
||||
|
||||
@property
|
||||
def total_volume(self) -> float:
|
||||
"""总液体体积"""
|
||||
return sum(content.volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def total_max_volume(self) -> float:
|
||||
"""总最大容量"""
|
||||
return sum(content.max_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""总可用体积"""
|
||||
return sum(content.available_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def well_count(self) -> int:
|
||||
"""孔位数量"""
|
||||
return len(self.wells)
|
||||
|
||||
@property
|
||||
def empty_wells(self) -> List[str]:
|
||||
"""空孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_empty]
|
||||
|
||||
@property
|
||||
def full_wells(self) -> List[str]:
|
||||
"""满孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_full]
|
||||
|
||||
@property
|
||||
def occupied_wells(self) -> List[str]:
|
||||
"""有液体的孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if not content.is_empty]
|
||||
|
||||
def get_well_content(self, well_id: str) -> Optional[WellContent]:
|
||||
"""获取指定孔位的内容"""
|
||||
return self.well_contents.get(well_id)
|
||||
|
||||
def get_well_volume(self, well_id: str) -> float:
|
||||
"""获取指定孔位的体积"""
|
||||
content = self.get_well_content(well_id)
|
||||
return content.volume if content else 0.0
|
||||
|
||||
def set_well_volume(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
设置指定孔位的体积
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
if volume > content.max_volume:
|
||||
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
|
||||
return False
|
||||
|
||||
content.volume = max(0.0, volume)
|
||||
if liquid_info:
|
||||
content.liquid_info = liquid_info
|
||||
content.last_updated = time.time()
|
||||
|
||||
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
|
||||
return True
|
||||
|
||||
def add_liquid(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
向指定孔位添加液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.add_volume(volume, liquid_info)
|
||||
|
||||
if success:
|
||||
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def remove_liquid(self, well_id: str, volume: float) -> bool:
|
||||
"""
|
||||
从指定孔位移除液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.remove_volume(volume)
|
||||
|
||||
if success:
|
||||
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def find_wells_with_volume(self, min_volume: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小体积的孔位
|
||||
|
||||
Args:
|
||||
min_volume: 最小体积 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.volume >= min_volume]
|
||||
|
||||
def find_wells_with_space(self, min_space: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小空间的孔位
|
||||
|
||||
Args:
|
||||
min_space: 最小空间 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.available_volume >= min_space]
|
||||
|
||||
def get_status_summary(self) -> Dict[str, Any]:
|
||||
"""获取资源状态摘要"""
|
||||
return {
|
||||
"resource_name": self.name,
|
||||
"resource_id": self.resource_id,
|
||||
"well_count": self.well_count,
|
||||
"total_volume": self.total_volume,
|
||||
"total_max_volume": self.total_max_volume,
|
||||
"available_volume": self.available_volume,
|
||||
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
|
||||
"empty_wells": len(self.empty_wells),
|
||||
"full_wells": len(self.full_wells),
|
||||
"occupied_wells": len(self.occupied_wells)
|
||||
}
|
||||
|
||||
def get_detailed_status(self) -> Dict[str, Any]:
|
||||
"""获取详细状态信息"""
|
||||
well_details = {}
|
||||
for well_id, content in self.well_contents.items():
|
||||
well_details[well_id] = {
|
||||
"volume": content.volume,
|
||||
"max_volume": content.max_volume,
|
||||
"available_volume": content.available_volume,
|
||||
"fill_percentage": content.fill_percentage,
|
||||
"liquid_type": content.liquid_info.liquid_type.value,
|
||||
"description": content.liquid_info.description,
|
||||
"last_updated": content.last_updated
|
||||
}
|
||||
|
||||
return {
|
||||
"summary": self.get_status_summary(),
|
||||
"wells": well_details
|
||||
}
|
||||
|
||||
|
||||
def transfer_liquid(
|
||||
source: MaterialResource,
|
||||
target: MaterialResource,
|
||||
volume: float,
|
||||
source_well_id: Optional[str] = None,
|
||||
target_well_id: Optional[str] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
在两个材料资源之间转移液体
|
||||
|
||||
Args:
|
||||
source: 源资源
|
||||
target: 目标资源
|
||||
volume: 转移体积 (ul)
|
||||
source_well_id: 源孔位ID,如果为None则自动选择
|
||||
target_well_id: 目标孔位ID,如果为None则自动选择
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 转移是否成功
|
||||
"""
|
||||
try:
|
||||
# 自动选择源孔位
|
||||
if source_well_id is None:
|
||||
available_wells = source.find_wells_with_volume(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
|
||||
return False
|
||||
source_well_id = available_wells[0]
|
||||
|
||||
# 自动选择目标孔位
|
||||
if target_well_id is None:
|
||||
available_wells = target.find_wells_with_space(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
|
||||
return False
|
||||
target_well_id = available_wells[0]
|
||||
|
||||
# 检查源孔位是否有足够液体
|
||||
if not source.get_well_content(source_well_id).can_remove_volume(volume):
|
||||
logger.error(f"源孔位 {source_well_id} 液体不足")
|
||||
return False
|
||||
|
||||
# 检查目标孔位是否有足够空间
|
||||
if not target.get_well_content(target_well_id).can_add_volume(volume):
|
||||
logger.error(f"目标孔位 {target_well_id} 空间不足")
|
||||
return False
|
||||
|
||||
# 获取源液体信息
|
||||
source_content = source.get_well_content(source_well_id)
|
||||
transfer_liquid_info = liquid_info or source_content.liquid_info
|
||||
|
||||
# 执行转移
|
||||
if source.remove_liquid(source_well_id, volume):
|
||||
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
|
||||
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
|
||||
return True
|
||||
else:
|
||||
# 如果目标添加失败,回滚源操作
|
||||
source.add_liquid(source_well_id, volume, source_content.liquid_info)
|
||||
logger.error("目标添加失败,已回滚源操作")
|
||||
return False
|
||||
else:
|
||||
logger.error("源移除失败")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_material_resource(
|
||||
name: str,
|
||||
resource: Resource,
|
||||
initial_volumes: Optional[Dict[str, float]] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None,
|
||||
max_volume: float = 1000.0
|
||||
) -> MaterialResource:
|
||||
"""
|
||||
创建材料资源的便捷函数
|
||||
|
||||
Args:
|
||||
name: 资源名称
|
||||
resource: pylabrobot 资源对象
|
||||
initial_volumes: 初始体积字典 {well_id: volume}
|
||||
liquid_info: 液体信息
|
||||
max_volume: 最大体积
|
||||
|
||||
Returns:
|
||||
MaterialResource: 创建的材料资源
|
||||
"""
|
||||
material_resource = MaterialResource(
|
||||
resource=resource,
|
||||
default_max_volume=max_volume
|
||||
)
|
||||
|
||||
# 设置初始体积
|
||||
if initial_volumes:
|
||||
for well_id, volume in initial_volumes.items():
|
||||
material_resource.set_well_volume(well_id, volume, liquid_info)
|
||||
|
||||
return material_resource
|
||||
|
||||
|
||||
def batch_transfer_liquid(
|
||||
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> List[bool]:
|
||||
"""
|
||||
批量液体转移
|
||||
|
||||
Args:
|
||||
transfers: 转移列表 [(source, target, volume), ...]
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
List[bool]: 每个转移操作的结果
|
||||
"""
|
||||
results = []
|
||||
|
||||
for source, target, volume in transfers:
|
||||
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
|
||||
results.append(result)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
|
||||
|
||||
success_count = sum(results)
|
||||
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
|
||||
|
||||
return results
|
||||
@@ -1,888 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站主要集成文件
|
||||
|
||||
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
|
||||
主要包含:
|
||||
- LaiYuLiquidBackend: 硬件通信后端
|
||||
- LaiYuLiquid: 主要接口类
|
||||
- 相关的异常类和容器类
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
# 基础导入
|
||||
try:
|
||||
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
|
||||
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 pylabrobot 不可用,创建基础的模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class Well(Resource):
|
||||
pass
|
||||
|
||||
|
||||
# LaiYu_Liquid 控制器导入
|
||||
try:
|
||||
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
|
||||
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
|
||||
|
||||
CONTROLLERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
CONTROLLERS_AVAILABLE = False
|
||||
|
||||
# 创建模拟的控制器类
|
||||
class PipetteController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
return True
|
||||
|
||||
def initialize(self):
|
||||
return True
|
||||
|
||||
class XYZController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect_device(self):
|
||||
return True
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaiYuLiquidError(RuntimeError):
|
||||
"""LaiYu_Liquid 设备异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaiYuLiquidConfig:
|
||||
"""LaiYu_Liquid 设备配置"""
|
||||
|
||||
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
|
||||
address: int = 1 # 设备地址
|
||||
baudrate: int = 9600 # 波特率
|
||||
timeout: float = 5.0 # 通信超时时间
|
||||
|
||||
# 工作台尺寸
|
||||
deck_width: float = 340.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 250.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 160.0 # 工作台深度 (mm)
|
||||
|
||||
# 移液参数
|
||||
max_volume: float = 1000.0 # 最大体积 (μL)
|
||||
min_volume: float = 0.1 # 最小体积 (μL)
|
||||
|
||||
# 运动参数
|
||||
max_speed: float = 100.0 # 最大速度 (mm/s)
|
||||
acceleration: float = 50.0 # 加速度 (mm/s²)
|
||||
|
||||
# 安全参数
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
|
||||
liquid_detection: bool = True # 液面检测
|
||||
|
||||
# 取枪头相关参数
|
||||
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
|
||||
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
|
||||
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
|
||||
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
|
||||
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
|
||||
|
||||
# 丢弃枪头相关参数
|
||||
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
|
||||
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
|
||||
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
|
||||
|
||||
# 安全范围配置
|
||||
deck_width: float = 300.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 200.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 100.0 # 工作台深度 (mm)
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
position_validation: bool = True # 启用位置验证
|
||||
emergency_stop_enabled: bool = True # 启用紧急停止
|
||||
|
||||
|
||||
class LaiYuLiquidDeck:
|
||||
"""LaiYu_Liquid 工作台管理"""
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig):
|
||||
self.config = config
|
||||
self.resources: Dict[str, Resource] = {}
|
||||
self.positions: Dict[str, Tuple[float, float, float]] = {}
|
||||
|
||||
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
self.resources[name] = resource
|
||||
self.positions[name] = position
|
||||
|
||||
def get_resource(self, name: str) -> Optional[Resource]:
|
||||
"""获取资源"""
|
||||
return self.resources.get(name)
|
||||
|
||||
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
|
||||
"""获取资源位置"""
|
||||
return self.positions.get(name)
|
||||
|
||||
def list_resources(self) -> List[str]:
|
||||
"""列出所有资源"""
|
||||
return list(self.resources.keys())
|
||||
|
||||
|
||||
class LaiYuLiquidContainer:
|
||||
"""LaiYu_Liquid 容器类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 0,
|
||||
size_y: float = 0,
|
||||
size_z: float = 0,
|
||||
container_type: str = "",
|
||||
volume: float = 0.0,
|
||||
max_volume: float = 1000.0,
|
||||
lid_height: float = 0.0,
|
||||
):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.lid_height = lid_height
|
||||
self.container_type = container_type
|
||||
self.volume = volume
|
||||
self.max_volume = max_volume
|
||||
self.last_updated = time.time()
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
def add_volume(self, volume: float) -> bool:
|
||||
"""添加体积"""
|
||||
if self.volume + volume <= self.max_volume:
|
||||
self.volume += volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""移除体积"""
|
||||
if self.volume >= volume:
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
|
||||
if hasattr(resource, "name"):
|
||||
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||
|
||||
|
||||
class LaiYuLiquidTipRack:
|
||||
"""LaiYu_Liquid 吸头架类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 0,
|
||||
size_y: float = 0,
|
||||
size_z: float = 0,
|
||||
tip_count: int = 96,
|
||||
tip_volume: float = 1000.0,
|
||||
):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.tip_count = tip_count
|
||||
self.tip_volume = tip_volume
|
||||
self.tips_available = [True] * tip_count
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def available_tips(self) -> int:
|
||||
return sum(self.tips_available)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.available_tips == 0
|
||||
|
||||
def pick_tip(self, position: int) -> bool:
|
||||
"""拾取吸头"""
|
||||
if 0 <= position < self.tip_count and self.tips_available[position]:
|
||||
self.tips_available[position] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_tip(self, position: int) -> bool:
|
||||
"""检查位置是否有吸头"""
|
||||
if 0 <= position < self.tip_count:
|
||||
return self.tips_available[position]
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源到指定位置"""
|
||||
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||
|
||||
|
||||
def get_module_info():
|
||||
"""获取模块信息"""
|
||||
return {
|
||||
"name": "LaiYu_Liquid",
|
||||
"version": "1.0.0",
|
||||
"description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能",
|
||||
"author": "UniLabOS Team",
|
||||
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
|
||||
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
|
||||
}
|
||||
|
||||
|
||||
class LaiYuLiquidBackend:
|
||||
"""LaiYu_Liquid 硬件通信后端"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
|
||||
self.config = config
|
||||
self.deck = deck # 工作台引用,用于获取资源位置信息
|
||||
self.pipette_controller = None
|
||||
self.xyz_controller = None
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
|
||||
# 状态跟踪
|
||||
self.current_position = (0.0, 0.0, 0.0)
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
def _validate_position(self, x: float, y: float, z: float) -> bool:
|
||||
"""验证位置是否在安全范围内"""
|
||||
try:
|
||||
# 检查X轴范围
|
||||
if not (0 <= x <= self.config.deck_width):
|
||||
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
|
||||
return False
|
||||
|
||||
# 检查Y轴范围
|
||||
if not (0 <= y <= self.config.deck_height):
|
||||
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
|
||||
return False
|
||||
|
||||
# 检查Z轴范围(负值表示向下,0为工作台表面)
|
||||
if not (-self.config.deck_depth <= z <= self.config.safe_height):
|
||||
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"位置验证失败: {e}")
|
||||
return False
|
||||
|
||||
def _check_hardware_ready(self) -> bool:
|
||||
"""检查硬件是否准备就绪"""
|
||||
if not self.is_connected:
|
||||
logger.error("设备未连接")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
if self.xyz_controller is None:
|
||||
logger.error("XYZ控制器未初始化")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def emergency_stop(self) -> bool:
|
||||
"""紧急停止所有运动"""
|
||||
try:
|
||||
logger.warning("执行紧急停止")
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 停止XYZ控制器
|
||||
await self.xyz_controller.stop_all_motion()
|
||||
logger.info("XYZ控制器已停止")
|
||||
|
||||
if self.pipette_controller:
|
||||
# 停止移液器控制器
|
||||
await self.pipette_controller.stop()
|
||||
logger.info("移液器控制器已停止")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"紧急停止失败: {e}")
|
||||
return False
|
||||
|
||||
async def move_to_safe_position(self) -> bool:
|
||||
"""移动到安全位置"""
|
||||
try:
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
safe_position = (
|
||||
self.config.deck_width / 2, # 工作台中心X
|
||||
self.config.deck_height / 2, # 工作台中心Y
|
||||
self.config.safe_height, # 安全高度Z
|
||||
)
|
||||
|
||||
if not self._validate_position(*safe_position):
|
||||
logger.error("安全位置无效")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
await self.xyz_controller.move_to_work_coord(*safe_position)
|
||||
self.current_position = safe_position
|
||||
logger.info(f"已移动到安全位置: {safe_position}")
|
||||
return True
|
||||
else:
|
||||
# 模拟模式
|
||||
self.current_position = safe_position
|
||||
logger.info("模拟移动到安全位置")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动到安全位置失败: {e}")
|
||||
return False
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置硬件连接"""
|
||||
try:
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
# 初始化移液器控制器
|
||||
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
|
||||
|
||||
# 初始化XYZ控制器
|
||||
machine_config = MachineConfig()
|
||||
self.xyz_controller = XYZController(
|
||||
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
|
||||
)
|
||||
|
||||
# 连接设备
|
||||
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
|
||||
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
|
||||
|
||||
if pipette_connected and xyz_connected:
|
||||
self.is_connected = True
|
||||
logger.info("LaiYu_Liquid 硬件连接成功")
|
||||
return True
|
||||
else:
|
||||
logger.error("LaiYu_Liquid 硬件连接失败")
|
||||
return False
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("LaiYu_Liquid 运行在模拟模式")
|
||||
self.is_connected = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止设备"""
|
||||
try:
|
||||
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
|
||||
await asyncio.to_thread(self.pipette_controller.disconnect)
|
||||
|
||||
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
|
||||
await asyncio.to_thread(self.xyz_controller.disconnect)
|
||||
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
logger.info("LaiYu_Liquid 已停止")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 停止失败: {e}")
|
||||
|
||||
async def move_to(self, x: float, y: float, z: float) -> bool:
|
||||
"""移动到指定位置"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
# 模拟移动
|
||||
await self._ros_node.sleep(0.1) # 模拟移动时间
|
||||
self.current_position = (x, y, z)
|
||||
logger.debug(f"移动到位置: ({x}, {y}, {z})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动失败: {e}")
|
||||
return False
|
||||
|
||||
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
|
||||
"""拾取吸头 - 包含真正的Z轴下降控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if self.tip_attached:
|
||||
logger.warning("已有吸头附着,无法拾取新吸头")
|
||||
return False
|
||||
|
||||
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
|
||||
|
||||
# 获取枪头架位置信息
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
tip_position = self.deck.get_position(tip_rack)
|
||||
if tip_position is None:
|
||||
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
|
||||
return False
|
||||
|
||||
# 计算具体枪头位置(这里简化处理,实际应根据position计算偏移)
|
||||
tip_x, tip_y, tip_z = tip_position
|
||||
|
||||
# 验证所有关键位置的安全性
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
|
||||
if not (
|
||||
self._validate_position(tip_x, tip_y, safe_z)
|
||||
and self._validate_position(tip_x, tip_y, pickup_z)
|
||||
and self._validate_position(tip_x, tip_y, retract_z)
|
||||
):
|
||||
logger.error("枪头拾取位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头拾取")
|
||||
|
||||
try:
|
||||
# 1. 移动到枪头上方的安全位置
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到枪头上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到枪头位置
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到枪头位置失败")
|
||||
return False
|
||||
|
||||
# 3. 等待一小段时间确保枪头牢固附着
|
||||
await self._ros_node.sleep(0.2)
|
||||
|
||||
# 4. Z轴上升到回退高度
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 5. 更新当前位置
|
||||
self.current_position = (tip_x, tip_y, retract_z)
|
||||
|
||||
except Exception as move_error:
|
||||
logger.error(f"枪头拾取过程中发生错误: {move_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头拾取动作")
|
||||
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
|
||||
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
|
||||
|
||||
# 6. 标记枪头已附着
|
||||
self.tip_attached = True
|
||||
logger.info("吸头拾取成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"拾取吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def drop_tip(self, location: str = "trash") -> bool:
|
||||
"""丢弃吸头 - 包含真正的Z轴控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if not self.tip_attached:
|
||||
logger.warning("没有吸头附着,无需丢弃")
|
||||
return True
|
||||
|
||||
logger.info(f"开始丢弃吸头到 {location}")
|
||||
|
||||
# 确定丢弃位置
|
||||
if location == "trash":
|
||||
# 使用配置中的垃圾桶位置
|
||||
drop_x, drop_y, drop_z = self.config.trash_position
|
||||
else:
|
||||
# 尝试从deck获取指定位置
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
drop_position = self.deck.get_position(location)
|
||||
if drop_position is None:
|
||||
logger.error(f"未找到丢弃位置 {location} 的信息")
|
||||
return False
|
||||
drop_x, drop_y, drop_z = drop_position
|
||||
|
||||
# 验证丢弃位置的安全性
|
||||
safe_z = drop_z + self.config.safe_height
|
||||
drop_height_z = drop_z + self.config.tip_drop_height
|
||||
|
||||
if not (
|
||||
self._validate_position(drop_x, drop_y, safe_z)
|
||||
and self._validate_position(drop_x, drop_y, drop_height_z)
|
||||
):
|
||||
logger.error("枪头丢弃位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头丢弃")
|
||||
|
||||
try:
|
||||
# 1. 移动到丢弃位置上方的安全高度
|
||||
safe_z = drop_z + self.config.tip_drop_height
|
||||
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到丢弃位置上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到丢弃高度
|
||||
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到丢弃位置失败")
|
||||
return False
|
||||
|
||||
# 3. 执行枪头弹出动作(如果有移液器控制器)
|
||||
if self.pipette_controller:
|
||||
try:
|
||||
# 发送弹出枪头命令
|
||||
await asyncio.to_thread(self.pipette_controller.eject_tip)
|
||||
logger.info("执行枪头弹出命令")
|
||||
except Exception as e:
|
||||
logger.warning(f"枪头弹出命令失败: {e}")
|
||||
|
||||
# 4. 等待一小段时间确保枪头完全脱离
|
||||
await self._ros_node.sleep(0.3)
|
||||
|
||||
# 5. Z轴上升到安全高度
|
||||
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 6. 更新当前位置
|
||||
self.current_position = (drop_x, drop_y, safe_z)
|
||||
|
||||
except Exception as drop_error:
|
||||
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头丢弃动作")
|
||||
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
|
||||
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
|
||||
|
||||
# 7. 标记枪头已脱离,清空体积
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
logger.info("吸头丢弃成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"丢弃吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def aspirate(self, volume: float, location: str) -> bool:
|
||||
"""吸取液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.config.max_volume:
|
||||
raise LaiYuLiquidError(f"体积超出范围: {volume}")
|
||||
|
||||
# 模拟吸取
|
||||
await self._ros_node.sleep(0.3)
|
||||
self.current_volume += volume
|
||||
logger.debug(f"从 {location} 吸取 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"吸取失败: {e}")
|
||||
return False
|
||||
|
||||
async def dispense(self, volume: float, location: str) -> bool:
|
||||
"""分配液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.current_volume:
|
||||
raise LaiYuLiquidError(f"分配体积无效: {volume}")
|
||||
|
||||
# 模拟分配
|
||||
await self._ros_node.sleep(0.3)
|
||||
self.current_volume -= volume
|
||||
logger.debug(f"向 {location} 分配 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class LaiYuLiquid:
|
||||
"""LaiYu_Liquid 主要接口类"""
|
||||
|
||||
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
|
||||
# 如果传入了关键字参数,创建配置对象
|
||||
if kwargs and config is None:
|
||||
# 从kwargs中提取配置参数
|
||||
config_params = {}
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(LaiYuLiquidConfig, key):
|
||||
config_params[key] = value
|
||||
self.config = LaiYuLiquidConfig(**config_params)
|
||||
else:
|
||||
self.config = config or LaiYuLiquidConfig()
|
||||
|
||||
# 先创建deck,然后传递给backend
|
||||
self.deck = LaiYuLiquidDeck(self.config)
|
||||
self.backend = LaiYuLiquidBackend(self.config, self.deck)
|
||||
self.is_setup = False
|
||||
|
||||
@property
|
||||
def current_position(self) -> Tuple[float, float, float]:
|
||||
"""获取当前位置"""
|
||||
return self.backend.current_position
|
||||
|
||||
@property
|
||||
def current_volume(self) -> float:
|
||||
"""获取当前体积"""
|
||||
return self.backend.current_volume
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""获取连接状态"""
|
||||
return self.backend.is_connected
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""获取初始化状态"""
|
||||
return self.backend.is_initialized
|
||||
|
||||
@property
|
||||
def tip_attached(self) -> bool:
|
||||
"""获取吸头附着状态"""
|
||||
return self.backend.tip_attached
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置液体处理器"""
|
||||
try:
|
||||
success = await self.backend.setup()
|
||||
if success:
|
||||
self.is_setup = True
|
||||
logger.info("LaiYu_Liquid 设置完成")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止液体处理器"""
|
||||
await self.backend.stop()
|
||||
self.is_setup = False
|
||||
|
||||
async def transfer(
|
||||
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
|
||||
) -> bool:
|
||||
"""液体转移"""
|
||||
try:
|
||||
if not self.is_setup:
|
||||
raise LaiYuLiquidError("设备未设置")
|
||||
|
||||
# 获取源和目标位置
|
||||
source_pos = self.deck.get_position(source)
|
||||
target_pos = self.deck.get_position(target)
|
||||
tip_pos = self.deck.get_position(tip_rack)
|
||||
|
||||
if not all([source_pos, target_pos, tip_pos]):
|
||||
raise LaiYuLiquidError("位置信息不完整")
|
||||
|
||||
# 执行转移步骤
|
||||
steps = [
|
||||
("移动到吸头架", self.backend.move_to(*tip_pos)),
|
||||
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
|
||||
("移动到源位置", self.backend.move_to(*source_pos)),
|
||||
("吸取液体", self.backend.aspirate(volume, source)),
|
||||
("移动到目标位置", self.backend.move_to(*target_pos)),
|
||||
("分配液体", self.backend.dispense(volume, target)),
|
||||
("丢弃吸头", self.backend.drop_tip()),
|
||||
]
|
||||
|
||||
for step_name, step_coro in steps:
|
||||
logger.debug(f"执行步骤: {step_name}")
|
||||
success = await step_coro
|
||||
if not success:
|
||||
raise LaiYuLiquidError(f"步骤失败: {step_name}")
|
||||
|
||||
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
if resource_type == "plate":
|
||||
resource = Plate(name)
|
||||
elif resource_type == "tip_rack":
|
||||
resource = TipRack(name)
|
||||
else:
|
||||
resource = Resource(name)
|
||||
|
||||
self.deck.add_resource(name, resource, position)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取设备状态"""
|
||||
return {
|
||||
"connected": self.backend.is_connected,
|
||||
"setup": self.is_setup,
|
||||
"current_position": self.backend.current_position,
|
||||
"tip_attached": self.backend.tip_attached,
|
||||
"current_volume": self.backend.current_volume,
|
||||
"resources": self.deck.list_resources(),
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建快速设置的LaiYu液体处理工作站
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 创建默认配置
|
||||
config = LaiYuLiquidConfig()
|
||||
|
||||
# 创建工作台
|
||||
deck = LaiYuLiquidDeck(config)
|
||||
|
||||
# 导入资源创建函数
|
||||
try:
|
||||
from .laiyu_liquid_res import (
|
||||
create_tip_rack_1000ul,
|
||||
create_tip_rack_200ul,
|
||||
create_96_well_plate,
|
||||
create_waste_container,
|
||||
)
|
||||
|
||||
# 添加基本资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
plate_96 = create_96_well_plate("plate_96")
|
||||
waste = create_waste_container("waste")
|
||||
|
||||
# 添加到工作台
|
||||
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
|
||||
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
|
||||
deck.add_resource("plate_96", plate_96, (250, 50, 0))
|
||||
deck.add_resource("waste", waste, (50, 150, 0))
|
||||
|
||||
except ImportError:
|
||||
# 如果资源模块不可用,创建空的工作台
|
||||
logger.warning("资源模块不可用,创建空的工作台")
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidDeck",
|
||||
"LaiYuLiquidContainer",
|
||||
"LaiYuLiquidTipRack",
|
||||
"LaiYuLiquidError",
|
||||
"create_quick_setup",
|
||||
"get_module_info",
|
||||
]
|
||||
@@ -1,954 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 资源定义模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
|
||||
- 各种规格的枪头架
|
||||
- 不同类型的板和容器
|
||||
- 特殊功能位置
|
||||
- 资源创建的便捷函数
|
||||
|
||||
所有资源都基于 deck.json 中的配置参数创建。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from pathlib import Path
|
||||
|
||||
# PyLabRobot 资源导入
|
||||
try:
|
||||
from pylabrobot.resources import (
|
||||
Resource, Deck, Plate, TipRack, Container, Tip,
|
||||
Coordinate
|
||||
)
|
||||
from pylabrobot.resources.tip_rack import TipSpot
|
||||
from pylabrobot.resources.well import Well as PlateWell
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 PyLabRobot 不可用,创建模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Container(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class TipSpot(Resource):
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name)
|
||||
# 忽略其他参数
|
||||
|
||||
class PlateWell(Resource):
|
||||
pass
|
||||
|
||||
class Coordinate:
|
||||
def __init__(self, x: float, y: float, z: float):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
|
||||
# 本地导入
|
||||
from .laiyu_liquid_main import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
|
||||
|
||||
|
||||
def load_deck_config() -> Dict[str, Any]:
|
||||
"""
|
||||
加载工作台配置文件
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 配置字典
|
||||
"""
|
||||
# 优先使用最新的deckconfig.json文件
|
||||
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
|
||||
|
||||
# 如果最新配置文件不存在,回退到旧配置文件
|
||||
if not config_path.exists():
|
||||
config_path = Path(__file__).parent / "config" / "deck.json"
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
# 如果找不到配置文件,返回默认配置
|
||||
return {
|
||||
"name": "LaiYu_Liquid_Deck",
|
||||
"size_x": 340.0,
|
||||
"size_y": 250.0,
|
||||
"size_z": 160.0
|
||||
}
|
||||
|
||||
|
||||
# 加载配置
|
||||
DECK_CONFIG = load_deck_config()
|
||||
|
||||
|
||||
class LaiYuTipRack1000(LaiYuLiquidTipRack):
|
||||
"""1000μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=1000.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="1000ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
# 从配置文件中获取枪头架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tip_module = None
|
||||
|
||||
# 查找枪头架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tip_rack":
|
||||
tip_module = module
|
||||
break
|
||||
|
||||
if not tip_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=95.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tip_module.get("wells", []):
|
||||
spot_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 9.0),
|
||||
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYuTipRack200(LaiYuLiquidTipRack):
|
||||
"""200μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=200.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="200ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=72.0, # 200ul枪头长度
|
||||
maximal_volume=200.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=72.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYu96WellPlate(LaiYuLiquidContainer):
|
||||
"""96孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=14.22,
|
||||
container_type="96_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=200.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=200.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 10.0 # 96孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取96孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找96孔板模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_module = module
|
||||
break
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
|
||||
"""深孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=41.3,
|
||||
container_type="deep_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=2000.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=2000.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 35.0 # 深孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取深孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找深孔板模块(通常是第二个96孔板模块)
|
||||
plate_modules = []
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_modules.append(module)
|
||||
|
||||
# 如果有多个96孔板模块,选择第二个作为深孔板
|
||||
if len(plate_modules) > 1:
|
||||
plate_module = plate_modules[1]
|
||||
elif len(plate_modules) == 1:
|
||||
plate_module = plate_modules[0]
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWasteContainer(Container):
|
||||
"""废液容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWashContainer(Container):
|
||||
"""清洗容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuReagentContainer(Container):
|
||||
"""试剂容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0,
|
||||
max_volume=2000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYu8TubeRack(LaiYuLiquidContainer):
|
||||
"""8管试管架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=151.0,
|
||||
size_y=75.0,
|
||||
size_z=75.0,
|
||||
container_type="tube_rack",
|
||||
volume=0.0,
|
||||
max_volume=77000.0
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=8,
|
||||
well_volume=77000.0,
|
||||
well_spacing=35.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 117.0 # 试管深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取8管试管架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tube_module = None
|
||||
|
||||
# 查找8管试管架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tube_rack":
|
||||
tube_module = module
|
||||
break
|
||||
|
||||
if not tube_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 2
|
||||
cols = 4
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=29.0,
|
||||
size_y=29.0,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tube_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 29.0),
|
||||
size_y=well_config.get("diameter", 29.0),
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuTipDisposal(Resource):
|
||||
"""枪头废料位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuMaintenancePosition(Resource):
|
||||
"""维护位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0
|
||||
)
|
||||
|
||||
|
||||
# 资源创建函数
|
||||
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
|
||||
"""
|
||||
创建1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack1000: 1000μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack1000(name)
|
||||
|
||||
|
||||
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
|
||||
"""
|
||||
创建200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack200: 200μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack200(name)
|
||||
|
||||
|
||||
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
|
||||
"""
|
||||
创建96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYu96WellPlate: 96孔板实例
|
||||
"""
|
||||
return LaiYu96WellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
|
||||
"""
|
||||
创建深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYuDeepWellPlate: 深孔板实例
|
||||
"""
|
||||
return LaiYuDeepWellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
|
||||
"""
|
||||
创建8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
|
||||
Returns:
|
||||
LaiYu8TubeRack: 8管试管架实例
|
||||
"""
|
||||
return LaiYu8TubeRack(name)
|
||||
|
||||
|
||||
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
|
||||
"""
|
||||
创建废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWasteContainer: 废液容器实例
|
||||
"""
|
||||
return LaiYuWasteContainer(name)
|
||||
|
||||
|
||||
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
|
||||
"""
|
||||
创建清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWashContainer: 清洗容器实例
|
||||
"""
|
||||
return LaiYuWashContainer(name)
|
||||
|
||||
|
||||
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
|
||||
"""
|
||||
创建试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuReagentContainer: 试剂容器实例
|
||||
"""
|
||||
return LaiYuReagentContainer(name)
|
||||
|
||||
|
||||
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
|
||||
"""
|
||||
创建枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipDisposal: 枪头废料位置实例
|
||||
"""
|
||||
return LaiYuTipDisposal(name)
|
||||
|
||||
|
||||
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
|
||||
"""
|
||||
创建维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuMaintenancePosition: 维护位置实例
|
||||
"""
|
||||
return LaiYuMaintenancePosition(name)
|
||||
|
||||
|
||||
def create_standard_deck() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建标准工作台配置
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 从配置文件创建工作台
|
||||
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
|
||||
"""
|
||||
根据名称获取资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
name: 资源名称
|
||||
|
||||
Returns:
|
||||
Optional[Resource]: 找到的资源,如果不存在则返回None
|
||||
"""
|
||||
for child in deck.children:
|
||||
if child.name == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
|
||||
"""
|
||||
根据类型获取资源列表
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
resource_type: 资源类型
|
||||
|
||||
Returns:
|
||||
List[Resource]: 匹配类型的资源列表
|
||||
"""
|
||||
return [child for child in deck.children if isinstance(child, resource_type)]
|
||||
|
||||
|
||||
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
|
||||
"""
|
||||
列出所有资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: 按类型分组的资源名称字典
|
||||
"""
|
||||
resources = {
|
||||
"tip_racks": [],
|
||||
"plates": [],
|
||||
"containers": [],
|
||||
"positions": []
|
||||
}
|
||||
|
||||
for child in deck.children:
|
||||
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
|
||||
resources["tip_racks"].append(child.name)
|
||||
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
|
||||
resources["plates"].append(child.name)
|
||||
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
|
||||
resources["containers"].append(child.name)
|
||||
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
|
||||
resources["positions"].append(child.name)
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
# 导出的类别名(向后兼容)
|
||||
TipRack1000ul = LaiYuTipRack1000
|
||||
TipRack200ul = LaiYuTipRack200
|
||||
Plate96Well = LaiYu96WellPlate
|
||||
Plate96DeepWell = LaiYuDeepWellPlate
|
||||
TubeRack8 = LaiYu8TubeRack
|
||||
WasteContainer = LaiYuWasteContainer
|
||||
WashContainer = LaiYuWashContainer
|
||||
ReagentContainer = LaiYuReagentContainer
|
||||
TipDisposal = LaiYuTipDisposal
|
||||
MaintenancePosition = LaiYuMaintenancePosition
|
||||
@@ -1,69 +0,0 @@
|
||||
# 更新日志
|
||||
|
||||
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
|
||||
|
||||
## [1.0.0] - 2024-01-XX
|
||||
|
||||
### 新增功能
|
||||
- ✅ 完整的液体处理工作站集成
|
||||
- ✅ RS485 通信协议支持
|
||||
- ✅ SOPA 气动式移液器驱动
|
||||
- ✅ XYZ 三轴步进电机控制
|
||||
- ✅ PyLabRobot 兼容后端
|
||||
- ✅ 标准化资源管理系统
|
||||
- ✅ 96孔板、离心管架、枪头架支持
|
||||
- ✅ RViz 可视化后端
|
||||
- ✅ 完整的配置管理系统
|
||||
- ✅ 抽象协议实现
|
||||
- ✅ 生产级错误处理和日志记录
|
||||
|
||||
### 技术特性
|
||||
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
|
||||
- **通信协议**: RS485总线,波特率115200
|
||||
- **坐标系统**: 机械坐标与工作坐标自动转换
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **兼容性**: 完全兼容 PyLabRobot 框架
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── core/
|
||||
│ └── LaiYu_Liquid.py # 主模块文件
|
||||
├── __init__.py # 模块初始化
|
||||
├── abstract_protocol.py # 抽象协议
|
||||
├── laiyu_liquid_res.py # 资源管理
|
||||
├── rviz_backend.py # RViz后端
|
||||
├── backend/ # 后端驱动
|
||||
├── config/ # 配置文件
|
||||
├── controllers/ # 控制器
|
||||
├── docs/ # 技术文档
|
||||
└── drivers/ # 底层驱动
|
||||
```
|
||||
|
||||
### 已知问题
|
||||
- 无
|
||||
|
||||
### 依赖要求
|
||||
- Python 3.8+
|
||||
- PyLabRobot
|
||||
- pyserial
|
||||
- asyncio
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号格式
|
||||
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
|
||||
|
||||
- **MAJOR**: 不兼容的API变更
|
||||
- **MINOR**: 向后兼容的功能新增
|
||||
- **PATCH**: 向后兼容的问题修复
|
||||
|
||||
### 变更类型
|
||||
- **新增功能**: 新的功能特性
|
||||
- **变更**: 现有功能的变更
|
||||
- **弃用**: 即将移除的功能
|
||||
- **移除**: 已移除的功能
|
||||
- **修复**: 问题修复
|
||||
- **安全**: 安全相关的修复
|
||||
@@ -1,267 +0,0 @@
|
||||
# SOPA气动式移液器RS485控制指令合集
|
||||
|
||||
## 1. RS485通信基本配置
|
||||
|
||||
### 1.1 支持的设备型号
|
||||
- **仅SC-STxxx-00-13支持RS485通信**
|
||||
- 其他型号主要使用CAN通信
|
||||
|
||||
### 1.2 通信参数
|
||||
- **波特率**: 9600, 115200(默认值)
|
||||
- **地址范围**: 1~254个设备,255为广播地址
|
||||
- **通信接口**: RS485差分信号
|
||||
|
||||
### 1.3 引脚分配(10位LIF连接器)
|
||||
- **引脚7**: RS485+ (RS485通信正极)
|
||||
- **引脚8**: RS485- (RS485通信负极)
|
||||
|
||||
## 2. RS485通信协议格式
|
||||
|
||||
### 2.1 发送数据格式
|
||||
```
|
||||
头码 | 地址 | 命令/数据 | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.2 从机回应格式
|
||||
```
|
||||
头码 | 地址 | 数据(固定9字节) | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.3 格式详细说明
|
||||
- **头码**:
|
||||
- 终端调试: '/' (0x2F)
|
||||
- OEM通信: '[' (0x5B)
|
||||
- **地址**: 设备节点地址,1~254,多字节ASCII(注意:地址不可为47,69,91)
|
||||
- **命令/数据**: ASCII格式的命令字符串
|
||||
- **尾码**: 'E' (0x45)
|
||||
- **校验和**: 以上数据的累加值,1字节
|
||||
|
||||
## 3. 初始化和基本控制指令
|
||||
|
||||
### 3.1 初始化指令
|
||||
```bash
|
||||
# 初始化活塞驱动机构
|
||||
HE
|
||||
|
||||
# 示例(OEM通信):
|
||||
# 主机发送: 5B 32 48 45 1A
|
||||
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
|
||||
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
|
||||
```
|
||||
|
||||
### 3.2 枪头操作指令
|
||||
```bash
|
||||
# 顶出枪头
|
||||
RE
|
||||
|
||||
# 枪头检测状态报告
|
||||
Q28 # 返回枪头存在状态(0=不存在,1=存在)
|
||||
```
|
||||
|
||||
## 4. 移液控制指令
|
||||
|
||||
### 4.1 位置控制指令
|
||||
```bash
|
||||
# 绝对位置移动(微升)
|
||||
A[n]E
|
||||
# 示例:移动到位置0
|
||||
A0E
|
||||
|
||||
# 相对抽吸(向上移动)
|
||||
P[n]E
|
||||
# 示例:抽吸200微升
|
||||
P200E
|
||||
|
||||
# 相对分配(向下移动)
|
||||
D[n]E
|
||||
# 示例:分配200微升
|
||||
D200E
|
||||
```
|
||||
|
||||
### 4.2 速度设置指令
|
||||
```bash
|
||||
# 设置最高速度(0.1ul/秒为单位)
|
||||
s[n]E
|
||||
# 示例:设置最高速度为2000(200ul/秒)
|
||||
s2000E
|
||||
|
||||
# 设置启动速度
|
||||
b[n]E
|
||||
# 示例:设置启动速度为100(10ul/秒)
|
||||
b100E
|
||||
|
||||
# 设置断流速度
|
||||
c[n]E
|
||||
# 示例:设置断流速度为100(10ul/秒)
|
||||
c100E
|
||||
|
||||
# 设置加速度
|
||||
a[n]E
|
||||
# 示例:设置加速度为30000
|
||||
a30000E
|
||||
```
|
||||
|
||||
## 5. 液体检测和安全控制指令
|
||||
|
||||
### 5.1 吸排液检测控制
|
||||
```bash
|
||||
# 开启吸排液检测
|
||||
f1E # 开启
|
||||
f0E # 关闭
|
||||
|
||||
# 设置空吸门限
|
||||
$[n]E
|
||||
# 示例:设置空吸门限为4
|
||||
$4E
|
||||
|
||||
# 设置泡沫门限
|
||||
![n]E
|
||||
# 示例:设置泡沫门限为20
|
||||
!20E
|
||||
|
||||
# 设置堵塞门限
|
||||
%[n]E
|
||||
# 示例:设置堵塞门限为350
|
||||
%350E
|
||||
```
|
||||
|
||||
### 5.2 液位检测指令
|
||||
```bash
|
||||
# 压力式液位检测
|
||||
m0E # 设置为压力探测模式
|
||||
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
|
||||
k[n]E # 设置检测速度(100~2000)
|
||||
|
||||
# 电容式液位检测
|
||||
m1E # 设置为电容探测模式
|
||||
```
|
||||
|
||||
## 6. 状态查询和报告指令
|
||||
|
||||
### 6.1 基本状态查询
|
||||
```bash
|
||||
# 查询固件版本
|
||||
V
|
||||
|
||||
# 查询设备状态
|
||||
Q[n]
|
||||
# 常用查询参数:
|
||||
Q01 # 报告加速度
|
||||
Q02 # 报告启动速度
|
||||
Q03 # 报告断流速度
|
||||
Q06 # 报告最大速度
|
||||
Q08 # 报告节点地址
|
||||
Q11 # 报告波特率
|
||||
Q18 # 报告当前位置
|
||||
Q28 # 报告枪头存在状态
|
||||
Q29 # 报告校准系数
|
||||
Q30 # 报告空吸门限
|
||||
Q31 # 报告堵针门限
|
||||
Q32 # 报告泡沫门限
|
||||
```
|
||||
|
||||
## 7. 配置和校准指令
|
||||
|
||||
### 7.1 校准参数设置
|
||||
```bash
|
||||
# 设置校准系数
|
||||
j[n]E
|
||||
# 示例:设置校准系数为1.04
|
||||
j1.04E
|
||||
|
||||
# 设置补偿偏差
|
||||
e[n]E
|
||||
# 示例:设置补偿偏差为2.03
|
||||
e2.03E
|
||||
|
||||
# 设置吸头容量
|
||||
C[n]E
|
||||
# 示例:设置1000ul吸头
|
||||
C1000E
|
||||
```
|
||||
|
||||
### 7.2 高级控制参数
|
||||
```bash
|
||||
# 设置回吸粘度
|
||||
][n]E
|
||||
# 示例:设置回吸粘度为30
|
||||
]30E
|
||||
|
||||
# 延时控制
|
||||
M[n]E
|
||||
# 示例:延时1000毫秒
|
||||
M1000E
|
||||
```
|
||||
|
||||
## 8. 复合操作指令示例
|
||||
|
||||
### 8.1 标准移液操作
|
||||
```bash
|
||||
# 完整的200ul移液操作
|
||||
a30000b200c200s2000P200E
|
||||
# 解析:设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
|
||||
```
|
||||
|
||||
### 8.2 带检测的移液操作
|
||||
```bash
|
||||
# 带空吸检测的200ul抽吸
|
||||
a30000b200c200s2000f1P200f0E
|
||||
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
|
||||
```
|
||||
|
||||
### 8.3 液面检测操作
|
||||
```bash
|
||||
# 压力式液面检测
|
||||
m0k200L5E
|
||||
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
|
||||
|
||||
# 电容式液面检测
|
||||
m1L3E
|
||||
# 解析:电容模式 + 灵敏度3 + 执行检测
|
||||
```
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
### 9.1 状态字节说明
|
||||
- **00h**: 无错误
|
||||
- **01h**: 上次动作未完成
|
||||
- **02h**: 设备未初始化
|
||||
- **03h**: 设备过载
|
||||
- **04h**: 无效指令
|
||||
- **05h**: 液位探测故障
|
||||
- **0Dh**: 空吸
|
||||
- **0Eh**: 堵针
|
||||
- **10h**: 泡沫
|
||||
- **11h**: 吸液超过吸头容量
|
||||
|
||||
### 9.2 错误查询
|
||||
```bash
|
||||
# 查询当前错误状态
|
||||
Q # 返回状态字节和错误代码
|
||||
```
|
||||
|
||||
## 10. 通信示例
|
||||
|
||||
### 10.1 基本通信流程
|
||||
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
|
||||
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
|
||||
|
||||
### 10.2 快速指令表
|
||||
| 操作 | 指令 | 说明 |
|
||||
|------|------|------|
|
||||
| 初始化 | `HE` | 初始化设备 |
|
||||
| 退枪头 | `RE` | 顶出枪头 |
|
||||
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
|
||||
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
|
||||
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
|
||||
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
|
||||
| 电容液面检测 | `m1L3E` | cLLD检测 |
|
||||
|
||||
## 11. 注意事项
|
||||
|
||||
1. **地址限制**: RS485地址不可设为47、69、91
|
||||
2. **校验和**: 终端调试时不关心校验和,OEM通信需要校验
|
||||
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
|
||||
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
|
||||
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
|
||||
6. **波特率设置**: 默认115200,可设置为9600
|
||||
@@ -1,162 +0,0 @@
|
||||
# 步进电机B系列控制指令详解
|
||||
|
||||
## 基本通信参数
|
||||
- **通信方式**: RS485
|
||||
- **协议**: Modbus
|
||||
- **波特率**: 115200 (默认)
|
||||
- **数据位**: 8位
|
||||
- **停止位**: 1位
|
||||
- **校验位**: 无
|
||||
- **默认站号**: 1 (可设置1-254)
|
||||
|
||||
## 支持的功能码
|
||||
- **03H**: 读取寄存器
|
||||
- **06H**: 写入单个寄存器
|
||||
- **10H**: 写入多个寄存器
|
||||
|
||||
## 寄存器地址表
|
||||
|
||||
### 状态监控寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
|
||||
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
|
||||
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
|
||||
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
|
||||
| 05H | 03H | 电流 | 当前工作电流 (mA) |
|
||||
|
||||
### 控制寄存器 (读写)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
|
||||
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
|
||||
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
|
||||
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
|
||||
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
|
||||
|
||||
### 位置模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
|
||||
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
|
||||
| 12H | 03H/06H/10H | 保留 | - |
|
||||
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
|
||||
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
|
||||
|
||||
### 速度模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 60H | 03H/06H/10H | 保留 | - |
|
||||
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
|
||||
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
|
||||
### 设备参数寄存器
|
||||
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|
||||
|------|--------|------|--------|------|
|
||||
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
|
||||
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
|
||||
| E2H | 03H/06H/10H | 保留 | 0258H | - |
|
||||
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
|
||||
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
|
||||
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
|
||||
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
|
||||
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
|
||||
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
|
||||
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
|
||||
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
|
||||
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
|
||||
|
||||
### 版本信息寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| F0H | 03H | 版本号 | 固件版本信息 |
|
||||
| F1H-F4H | 03H | 型号 | 产品型号信息 |
|
||||
|
||||
## 常用控制指令示例
|
||||
|
||||
### 读取电机状态
|
||||
```
|
||||
发送: 01 03 00 00 00 01 84 0A
|
||||
接收: 01 03 02 00 01 79 84
|
||||
说明: 电机状态为0001H (正在运行)
|
||||
```
|
||||
|
||||
### 读取当前位置
|
||||
```
|
||||
发送: 01 03 00 01 00 02 95 CB
|
||||
接收: 01 03 04 00 19 00 00 2B F4
|
||||
说明: 当前位置为1638400步 (100圈)
|
||||
```
|
||||
|
||||
### 停止电机
|
||||
```
|
||||
发送: 01 10 00 04 00 01 02 00 00 A7 D4
|
||||
接收: 01 10 00 04 00 01 40 08
|
||||
说明: 急停指令
|
||||
```
|
||||
|
||||
### 位置模式运动
|
||||
```
|
||||
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
|
||||
接收: 01 10 00 10 00 06 41 CE
|
||||
说明: 以5000rpm速度运动到1638400步位置
|
||||
```
|
||||
|
||||
### 速度模式 - 正转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度正转
|
||||
```
|
||||
|
||||
### 速度模式 - 反转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度反转 (EC78H = -5000)
|
||||
```
|
||||
|
||||
### 设置设备地址
|
||||
```
|
||||
发送: 00 06 00 E0 00 02 C9 F1
|
||||
接收: 00 06 00 E0 00 02 C9 F1
|
||||
说明: 将设备地址设置为2
|
||||
```
|
||||
|
||||
## 错误码
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0001H | 功能码错误 |
|
||||
| 0002H | 地址错误 |
|
||||
| 0003H | 长度错误 |
|
||||
|
||||
## CRC校验算法
|
||||
```c
|
||||
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
|
||||
int wCrc = 0x0000FFFF;
|
||||
byte[] CRC = new byte[2];
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
wCrc ^= ((data[i + offset]) & 0xFF);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((wCrc & 0x00000001) == 1) {
|
||||
wCrc >>= 1;
|
||||
wCrc ^= 0x0000A001;
|
||||
} else {
|
||||
wCrc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
|
||||
CRC[0] = (byte) (wCrc & 0x000000FF);
|
||||
return CRC;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
1. 所有16位数据采用大端序传输
|
||||
2. 步数计算: 实际步数 = 高位<<16 | 低位
|
||||
3. 负数使用补码表示
|
||||
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
|
||||
5. 光电开关需使用NPN开漏型
|
||||
6. 限位开关: LF正向, LB反向
|
||||
@@ -1,269 +0,0 @@
|
||||
# LaiYu_Liquid 液体处理工作站
|
||||
|
||||
## 概述
|
||||
|
||||
LaiYu_Liquid 是一个完全集成到 UniLabOS 的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 硬件组件
|
||||
- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
|
||||
- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
|
||||
- **通信接口**: RS485转USB模块,默认波特率115200
|
||||
- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
|
||||
|
||||
### 软件架构
|
||||
- **驱动层**: 底层硬件通信驱动,支持RS485协议
|
||||
- **控制层**: 高级控制逻辑和坐标系管理
|
||||
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
|
||||
- **资源层**: 标准化的实验器具和耗材管理
|
||||
|
||||
## 🎯 生产就绪组件
|
||||
|
||||
### ✅ 核心驱动程序 (`drivers/`)
|
||||
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
|
||||
- 支持液体吸取、分配、检测
|
||||
- 完整的错误处理和状态管理
|
||||
- 生产级别的通信协议实现
|
||||
|
||||
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
|
||||
- 精确的位置控制和运动规划
|
||||
- 安全限位和错误检测
|
||||
- 高性能运动控制算法
|
||||
|
||||
### ✅ 高级控制器 (`controllers/`)
|
||||
- **`pipette_controller.py`** - 移液控制器
|
||||
- 封装高级液体处理功能
|
||||
- 支持多种液体类型和处理参数
|
||||
- 智能错误恢复机制
|
||||
|
||||
- **`xyz_controller.py`** - XYZ运动控制器
|
||||
- 坐标系管理和转换
|
||||
- 运动路径优化
|
||||
- 安全运动控制
|
||||
|
||||
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
|
||||
- **完整的液体处理抽象接口**
|
||||
- **标准化的资源管理系统**
|
||||
- **与PyLabRobot兼容的后端实现**
|
||||
- **生产级别的错误处理和日志记录**
|
||||
|
||||
### ✅ 资源管理系统
|
||||
- **`laiyu_liquid_res.py`** - 标准化资源定义
|
||||
- 96孔板、离心管架、枪头架等标准器具
|
||||
- 自动化的资源创建和配置函数
|
||||
- 与工作台布局的完美集成
|
||||
|
||||
### ✅ 配置管理 (`config/`)
|
||||
- **`config/deck.json`** - 工作台布局配置
|
||||
- 精确的空间定义和槽位管理
|
||||
- 支持多种实验器具的标准化放置
|
||||
- 可扩展的配置架构
|
||||
|
||||
- **`__init__.py`** - 模块集成和导出
|
||||
- 完整的API导出和版本管理
|
||||
- 依赖检查和安装验证
|
||||
- 专业的模块信息展示
|
||||
|
||||
<!-- ### ✅ 可视化支持
|
||||
- **`rviz_backend.py`** - RViz可视化后端
|
||||
- 实时运动状态可视化
|
||||
- 液体处理过程监控
|
||||
- 与ROS系统的无缝集成 -->
|
||||
|
||||
## 🚀 核心功能特性
|
||||
|
||||
### 液体处理能力
|
||||
- **精密体积控制**: 支持1-1000μL精确分配
|
||||
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
|
||||
- **智能检测**: 液位检测、气泡检测、堵塞检测
|
||||
- **自动化流程**: 完整的吸取-转移-分配工作流
|
||||
|
||||
### 运动控制系统
|
||||
- **三轴精密定位**: 微米级精度控制
|
||||
- **路径优化**: 智能运动规划和碰撞避免
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **坐标系管理**: 工作坐标与机械坐标的自动转换
|
||||
|
||||
### 资源管理
|
||||
- **标准化器具**: 支持96孔板、离心管架、枪头架等
|
||||
- **状态跟踪**: 实时监控液体体积、枪头状态等
|
||||
- **自动配置**: 基于JSON的灵活配置系统
|
||||
- **扩展性**: 易于添加新的器具类型
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── __init__.py # 模块初始化和API导出
|
||||
├── readme.md # 本文档
|
||||
├── backend/ # 后端驱动模块
|
||||
│ ├── __init__.py
|
||||
│ └── laiyu_backend.py # PyLabRobot兼容后端
|
||||
├── core/ # 核心模块
|
||||
│ ├── core/
|
||||
│ │ └── LaiYu_Liquid.py # 主设备类
|
||||
│ ├── abstract_protocol.py # 抽象协议
|
||||
│ └── laiyu_liquid_res.py # 设备资源定义
|
||||
├── config/ # 配置文件目录
|
||||
│ └── deck.json # 工作台布局配置
|
||||
├── controllers/ # 高级控制器
|
||||
│ ├── __init__.py
|
||||
│ ├── pipette_controller.py # 移液控制器
|
||||
│ └── xyz_controller.py # XYZ运动控制器
|
||||
├── docs/ # 技术文档
|
||||
│ ├── SOPA气动式移液器RS485控制指令.md
|
||||
│ ├── 步进电机控制指令.md
|
||||
│ └── hardware/ # 硬件相关文档
|
||||
├── drivers/ # 底层驱动程序
|
||||
│ ├── __init__.py
|
||||
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
|
||||
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
|
||||
└── tests/ # 测试文件
|
||||
```
|
||||
|
||||
## 🔧 快速开始
|
||||
|
||||
### 1. 安装和验证
|
||||
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
create_quick_setup,
|
||||
print_module_info
|
||||
)
|
||||
|
||||
# 查看模块信息
|
||||
print_module_info()
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"已创建 {len(resources)} 个资源")
|
||||
```
|
||||
|
||||
### 2. 基本使用示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_quick_setup,
|
||||
create_96_well_plate,
|
||||
create_laiyu_backend
|
||||
)
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"创建了以下资源: {list(resources.keys())}")
|
||||
|
||||
# 创建96孔板
|
||||
plate_96 = create_96_well_plate("test_plate")
|
||||
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
|
||||
|
||||
# 创建后端实例(用于PyLabRobot集成)
|
||||
backend = create_laiyu_backend("LaiYu_Device")
|
||||
print(f"后端设备: {backend.name}")
|
||||
```
|
||||
|
||||
### 3. 后端驱动使用
|
||||
|
||||
```python
|
||||
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
|
||||
|
||||
# 创建后端实例
|
||||
backend = create_laiyu_backend("LaiYu_Liquid_Station")
|
||||
|
||||
# 连接设备
|
||||
await backend.connect()
|
||||
|
||||
# 设备归位
|
||||
await backend.home_device()
|
||||
|
||||
# 获取设备状态
|
||||
status = await backend.get_status()
|
||||
print(f"设备状态: {status}")
|
||||
|
||||
# 断开连接
|
||||
await backend.disconnect()
|
||||
```
|
||||
|
||||
### 4. 资源管理示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_centrifuge_tube_rack,
|
||||
create_tip_rack,
|
||||
load_deck_config
|
||||
)
|
||||
|
||||
# 加载工作台配置
|
||||
deck_config = load_deck_config()
|
||||
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
|
||||
|
||||
# 创建不同类型的资源
|
||||
tube_rack = create_centrifuge_tube_rack("sample_rack")
|
||||
tip_rack = create_tip_rack("tip_rack_200ul")
|
||||
|
||||
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
|
||||
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
|
||||
```
|
||||
|
||||
## 🔍 技术架构
|
||||
|
||||
### 坐标系统
|
||||
- **机械坐标**: 基于步进电机的原始坐标系统
|
||||
- **工作坐标**: 用户友好的实验室坐标系统
|
||||
- **自动转换**: 透明的坐标系转换和校准
|
||||
|
||||
### 通信协议
|
||||
- **RS485总线**: 高可靠性工业通信标准
|
||||
- **Modbus协议**: 标准化的设备通信协议
|
||||
- **错误检测**: 完整的通信错误检测和恢复
|
||||
|
||||
### 安全机制
|
||||
- **限位保护**: 硬件和软件双重限位保护
|
||||
- **紧急停止**: 即时停止所有运动和操作
|
||||
- **状态监控**: 实时设备状态监控和报警
|
||||
|
||||
## 🧪 验证和测试
|
||||
|
||||
### 功能验证
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import validate_installation
|
||||
validate_installation()
|
||||
|
||||
# 查看模块信息
|
||||
from unilabos.devices.laiyu_liquid import print_module_info
|
||||
print_module_info()
|
||||
```
|
||||
|
||||
### 硬件连接测试
|
||||
```python
|
||||
# 测试SOPA移液器连接
|
||||
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
|
||||
|
||||
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
|
||||
pipette = SOPAPipette(config)
|
||||
success = pipette.connect()
|
||||
print(f"SOPA连接状态: {'成功' if success else '失败'}")
|
||||
```
|
||||
|
||||
## 📚 维护和支持
|
||||
|
||||
### 日志记录
|
||||
- **结构化日志**: 使用Python logging模块的专业日志记录
|
||||
- **错误追踪**: 详细的错误信息和堆栈跟踪
|
||||
- **性能监控**: 操作时间和性能指标记录
|
||||
|
||||
### 配置管理
|
||||
- **JSON配置**: 灵活的JSON格式配置文件
|
||||
- **参数验证**: 自动配置参数验证和错误提示
|
||||
- **热重载**: 支持配置文件的动态重载
|
||||
|
||||
### 扩展性
|
||||
- **模块化设计**: 易于扩展和定制的模块化架构
|
||||
- **插件接口**: 支持第三方插件和扩展
|
||||
- **API兼容**: 向后兼容的API设计
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
LaiYu_Liquid 驱动程序模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序:
|
||||
- SOPA移液器驱动程序
|
||||
- XYZ步进电机驱动程序
|
||||
"""
|
||||
|
||||
# SOPA移液器驱动程序导入
|
||||
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
|
||||
|
||||
# XYZ步进电机驱动程序导入
|
||||
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
|
||||
|
||||
__all__ = [
|
||||
# SOPA移液器
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"SOPAStatusCode",
|
||||
|
||||
# XYZ步进电机
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
"MotorAxis",
|
||||
"MotorStatus",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Driver Team"
|
||||
__description__ = "LaiYu_Liquid 硬件驱动程序集合"
|
||||