Compare commits

...

21 Commits

Author SHA1 Message Date
Xuwznln
eac2c27e99 删除大写目录 2025-06-05 23:42:50 +08:00
Xuwznln
f2f9b45aa6 更改Mock大写文件夹名 2025-06-05 23:23:00 +08:00
Xuwznln
0e2fdb5695 规范模拟设备代码与注册表信息 2025-06-05 23:00:41 +08:00
KCFeng425
ca15173717 Add Device Mock \_init_.py 2025-06-05 15:37:24 +08:00
KCFeng425
94e79418b9 Add Device MockSolenoidValve 2025-06-05 15:36:53 +08:00
KCFeng425
02b4750637 Add Device MockVacuum 2025-06-05 15:12:35 +08:00
KCFeng425
4e636b91b4 Add Device MockHeater 2025-06-05 14:08:30 +08:00
KCFeng425
3af1964328 Add Device MockStirrer 2025-06-05 13:47:10 +08:00
KCFeng425
f661049823 Add Device MockSeparator 2025-06-05 13:41:55 +08:00
KCFeng425
a79c26b9a4 Add Device MockRotavap 2025-06-05 13:40:41 +08:00
KCFeng425
8cf51ee8d3 Add Device MockPump 2025-06-05 13:35:22 +08:00
KCFeng425
8c81dab7e1 Add Device MockFilter 2025-06-05 13:34:54 +08:00
KCFeng425
ff76bb1a76 Add Device MockChiller
Add device MockChiller
2025-06-05 13:33:56 +08:00
Xuwznln
6f69df440c 修复linux 64构建问题 2025-05-30 01:27:34 +08:00
Xuwznln
b420d1fa8e bump version to 0.9.1 2025-05-29 22:17:50 +08:00
Xuwznln
767e0fcdee bump version to 0.9.1 2025-05-29 20:44:30 +08:00
Xuwznln
84944396e9 34 icon support online (#35)
* unify liquid_handler definition

* remove default values

* Dev Sync (#25)

* Update README and MQTTClient for installation instructions and code improvements

* feat: 支持local_config启动
add: 增加对crt path的说明,为传入config.py的相对路径
move: web component

* add: registry description

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* feat: node_info_update srv
fix: OTDeck cant create

* close #12
feat: slave node registry

* feat: show machine name
fix: host node registry not uploaded

* feat: add hplc registry

* feat: add hplc registry

* fix: hplc status typo

* fix: devices/

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* fix: device.class possible null

* fix: HPLC additions with online service

* fix: slave mode spin not working

* fix: slave mode spin not working

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* feat: 多ProtocolNode 允许子设备ID相同
feat: 上报发现的ActionClient
feat: Host重启动,通过discover机制要求slaveNode重新注册,实现信息及时上报

* feat: 支持env设置config

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* Device visualization (#14)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: wznln <18435084+Xuwznln@users.noreply.github.com>

* fix: missing hostname in devices_names
fix: upload_file for model file

* fix: missing paho-mqtt package
bump version to 0.9.0

* fix startup
add ResourceCreateFromOuter.action

* fix type hint

* update actions

* update actions

* host node add_resource_from_outer
fix cmake list

* pass device config to device class

* add: bind_parent_ids to resource create action
fix: message convert string

* fix: host node should not be re_discovered

* feat: resource tracker support dict

* feat: add more necessary params

* feat: fix boolean null in registry action data

* feat: add outer resource

* 编写mesh添加action

* feat: append resource

* add action

* feat: vis 2d for plr

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

* Device visualization (#22)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* 编写mesh添加action

* add action

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: wznln <18435084+Xuwznln@users.noreply.github.com>

* fix: multi channel

* fix: aspirate

* fix: aspirate

* fix: aspirate

* fix: aspirate

* 提交

* fix: jobadd

* fix: jobadd

* fix: msg converter

* tijiao

* add resource creat easy action

* identify debug msg

* mq client id

---------

Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>

* remove default behavior for visualization

* change liquidhandler name

* resource create from outer easy

* add easy resource creation

* easy resource creation logic

* remove wrongly debug msg from others

* remove wrongly debug msg from others

* add missing action clients

* fix device_id

* fix slot_on_deck

* fix registry typo

* complete require packages
msg converter support array string
implements create resource logic

* 修复port输入

* 修复必须两次启动edge后端才有节点生成的bug
新增resources报送

* 新增延迟统计

---------

Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
2025-05-29 20:43:01 +08:00
Xuwznln
bfcb214b53 24 high level liquidhandler (#28)
* unify liquid_handler definition

* remove default values

* Dev Sync (#25)

* Update README and MQTTClient for installation instructions and code improvements

* feat: 支持local_config启动
add: 增加对crt path的说明,为传入config.py的相对路径
move: web component

* add: registry description

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* feat: node_info_update srv
fix: OTDeck cant create

* close #12
feat: slave node registry

* feat: show machine name
fix: host node registry not uploaded

* feat: add hplc registry

* feat: add hplc registry

* fix: hplc status typo

* fix: devices/

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* fix: device.class possible null

* fix: HPLC additions with online service

* fix: slave mode spin not working

* fix: slave mode spin not working

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* feat: 多ProtocolNode 允许子设备ID相同
feat: 上报发现的ActionClient
feat: Host重启动,通过discover机制要求slaveNode重新注册,实现信息及时上报

* feat: 支持env设置config

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* Device visualization (#14)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: wznln <18435084+Xuwznln@users.noreply.github.com>

* fix: missing hostname in devices_names
fix: upload_file for model file

* fix: missing paho-mqtt package
bump version to 0.9.0

* fix startup
add ResourceCreateFromOuter.action

* fix type hint

* update actions

* update actions

* host node add_resource_from_outer
fix cmake list

* pass device config to device class

* add: bind_parent_ids to resource create action
fix: message convert string

* fix: host node should not be re_discovered

* feat: resource tracker support dict

* feat: add more necessary params

* feat: fix boolean null in registry action data

* feat: add outer resource

* 编写mesh添加action

* feat: append resource

* add action

* feat: vis 2d for plr

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

* Device visualization (#22)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* 编写mesh添加action

* add action

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: wznln <18435084+Xuwznln@users.noreply.github.com>

* fix: multi channel

* fix: aspirate

* fix: aspirate

* fix: aspirate

* fix: aspirate

* 提交

* fix: jobadd

* fix: jobadd

* fix: msg converter

* tijiao

* add resource creat easy action

* identify debug msg

* mq client id

---------

Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>

* remove default behavior for visualization

* change liquidhandler name

* resource create from outer easy

* add easy resource creation

* easy resource creation logic

* remove wrongly debug msg from others

* remove wrongly debug msg from others

* add missing action clients

* fix device_id

* fix slot_on_deck

* fix registry typo

* complete require packages
msg converter support array string
implements create resource logic

* 修复port输入

* fix: remove dirty actions

---------

Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
2025-05-29 20:40:16 +08:00
Xuwznln
ec4e6c6cfd 增加英文readme描述 (#33) 2025-05-23 10:06:30 +08:00
Xuwznln
53b6457a88 修复property类型的Action执行失败 (#30) 2025-05-17 17:57:49 +08:00
Xuwznln
133dbf77bb 嵌套节点上报云端出现ID错误 (#27)
* 修复嵌套节点,mq发送任务id出错的问题
修正一处注册表命名错误

* 修复本地看板Action显示不全
修复本地子设备没有机器名称的bug

* 补全vacuum_pump.mock注册信息
2025-05-16 19:12:59 +08:00
62 changed files with 4589 additions and 343 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ __pycache__/
.vscode .vscode
*.py[cod] *.py[cod]
*$py.class *$py.class
service
# C extensions # C extensions
*.so *.so

View File

@@ -4,83 +4,86 @@
# Uni-Lab-OS # Uni-Lab-OS
<!-- Language switcher -->
**English** | [中文](README_zh.md)
[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members) [![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues) [![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues)
[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE) [![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab 操作系统是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。 Uni-Lab Operating System is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows.
## 核心特点 ## Key Features
- 多设备集成管理 - Multi-device integration management
- 自动化实验流程 - Automated experimental workflows
- 云端连接能力 - Cloud connectivity capabilities
- 灵活的配置系统 - Flexible configuration system
- 支持多种实验协议 - Support for multiple experimental protocols
## 文档 ## Documentation
详细文档可在以下位置找到: Detailed documentation can be found at:
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/) - [Online Documentation](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 快速开始 ## Quick Start
1. 配置Conda环境 1. Configure Conda Environment
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件: Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
```bash ```bash
# 创建新环境 # Create new environment
mamba env create -f unilabos-[YOUR_OS].yaml mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab mamba activate unilab
# 或更新现有环境 # Or update existing environment
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, `osx-arm64` # Where `[YOUR_OS]` can be `win64`, `linux-64`, `osx-64`, or `osx-arm64`.
conda env update --file unilabos-[YOUR_OS].yml -n 环境名 conda env update --file unilabos-[YOUR_OS].yml -n environment_name
# 现阶段,需要安装 `unilabos_msgs` # Currently, you need to install the `unilabos_msgs` package
# 可以前往 Release 页面下载系统对应的包进行安装 # You can download the system-specific package from the Release page
conda install ros-humble-unilabos-msgs-0.9.0-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.1-xxxxx.tar.bz2
# 安装PyLabRobot等前置 # Install PyLabRobot and other prerequisites
git clone https://github.com/PyLabRobot/pylabrobot plr_repo git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo cd plr_repo
pip install .[opentrons] pip install .[opentrons]
``` ```
2. 安装 Uni-Lab-OS: 2. Install Uni-Lab-OS:
```bash ```bash
# 克隆仓库 # Clone the repository
git clone https://github.com/dptech-corp/Uni-Lab-OS.git git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS cd Uni-Lab-OS
# 安装 Uni-Lab-OS # Install Uni-Lab-OS
pip install . pip install .
``` ```
3. 启动 Uni-Lab 系统: 3. Start Uni-Lab System:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html) Please refer to [Documentation - Boot Examples](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式 ## Message Format
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。 Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) page.
## 许可证 ## License
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。 This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
## 项目统计 ## Project Statistics
### Stars 趋势 ### Stars Trend
<a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date"> <a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date">
<img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600"> <img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600">
</a> </a>
## 联系我们 ## Contact Us
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) - GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)

89
README_zh.md Normal file
View File

@@ -0,0 +1,89 @@
<div align="center">
<img src="docs/logo.png" alt="Uni-Lab Logo" width="200"/>
</div>
# Uni-Lab-OS
<!-- Language switcher -->
[English](README.md) | **中文**
[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues)
[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab 操作系统是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
## 核心特点
- 多设备集成管理
- 自动化实验流程
- 云端连接能力
- 灵活的配置系统
- 支持多种实验协议
## 文档
详细文档可在以下位置找到:
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 快速开始
1. 配置Conda环境
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
```bash
# 创建新环境
mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab
# 或更新现有环境
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, 或 `osx-arm64`。
conda env update --file unilabos-[YOUR_OS].yml -n 环境名
# 现阶段,需要安装 `unilabos_msgs` 包
# 可以前往 Release 页面下载系统对应的包进行安装
conda install ros-humble-unilabos-msgs-0.9.1-xxxxx.tar.bz2
# 安装PyLabRobot等前置
git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo
pip install .[opentrons]
```
2. 安装 Uni-Lab-OS:
```bash
# 克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
# 安装 Uni-Lab-OS
pip install .
```
3. 启动 Uni-Lab 系统:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。
## 许可证
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。
## 项目统计
### Stars 趋势
<a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date">
<img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600">
</a>
## 联系我们
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.9.0 version: 0.9.1
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
folder: ros-humble-unilabos-msgs/src/work folder: ros-humble-unilabos-msgs/src/work

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.9.0" version: "0.9.1"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.9.0', version='0.9.1',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -1,5 +1,5 @@
使用plr_test.json启动将Well加入Plate中 使用plr_test.json启动将Well加入Plate中
```bash ```bash
ros2 action send_goal /devices/host_node/add_resource_from_outer unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }" ros2 action send_goal /devices/host_node/create_resource_detailed unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }"
``` ```

View File

@@ -0,0 +1,235 @@
{
"nodes": [
{
"id": "MockStirrer1",
"name": "模拟搅拌器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_stirrer",
"position": {
"x": 100,
"y": 100,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"stir_speed": 0.0,
"target_stir_speed": 0.0,
"stir_state": "Stopped",
"temperature": 25.0,
"target_temperature": 25.0,
"heating_state": "Off",
"heating_power": 0.0,
"max_stir_speed": 2000.0,
"max_temperature": 300.0
}
},
{
"id": "MockVacuum1",
"name": "模拟真空泵",
"children": [],
"parent": null,
"type": "device",
"class": "mock_vacuum",
"position": {
"x": 300,
"y": 100,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"pump_state": "Stopped",
"vacuum_level": 1013.25,
"target_vacuum": 50.0,
"pump_speed": 0.0,
"pump_efficiency": 95.0,
"max_pump_speed": 100.0
}
},
{
"id": "MockSeparator1",
"name": "模拟分离器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_separator",
"position": {
"x": 500,
"y": 100,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"settling_time": 0.0,
"valve_state": "Closed",
"shake_time": 0.0,
"shake_status": "Not Shaking"
}
},
{
"id": "MockSolenoidValve1",
"name": "模拟电磁阀",
"children": [],
"parent": null,
"type": "device",
"class": "mock_solenoid_valve",
"position": {
"x": 700,
"y": 100,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"valve_status": "Closed"
}
},
{
"id": "MockRotavap1",
"name": "模拟旋转蒸发器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_rotavap",
"position": {
"x": 100,
"y": 300,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"rotate_state": "Stopped",
"rotate_time": 0.0,
"rotate_speed": 0.0,
"pump_state": "Stopped",
"pump_time": 0.0,
"vacuum_level": 1013.25,
"temperature": 25.0,
"target_temperature": 25.0,
"success": "True"
}
},
{
"id": "MockHeater1",
"name": "模拟加热器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_heater",
"position": {
"x": 300,
"y": 300,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"current_temperature": 25.0,
"target_temperature": 25.0,
"status": "Idle",
"power_on": false,
"is_heating": false,
"heating_power": 0.0,
"max_temperature": 300.0
}
},
{
"id": "MockPump1",
"name": "模拟泵设备",
"children": [],
"parent": null,
"type": "device",
"class": "mock_pump",
"position": {
"x": 500,
"y": 300,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"pump_state": "Stopped",
"flow_rate": 0.0,
"target_flow_rate": 0.0,
"pressure": 0.0,
"total_volume": 0.0,
"direction": "Forward",
"max_flow_rate": 100.0,
"max_pressure": 10.0
}
},
{
"id": "MockChiller1",
"name": "模拟冷却器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_chiller",
"position": {
"x": 700,
"y": 300,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"current_temperature": 25.0,
"target_temperature": 25.0,
"status": "Idle",
"power_on": false,
"is_cooling": false,
"is_heating": false
}
},
{
"id": "MockFilter1",
"name": "模拟过滤器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_filter",
"position": {
"x": 400,
"y": 500,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"is_filtering": false,
"filter_efficiency": 95.0,
"flow_rate": 0.0,
"pressure_drop": 0.0,
"filter_life": 100.0,
"power_on": false
}
}
],
"links": []
}

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "MockChiller1",
"name": "模拟冷却器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_chiller",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"current_temperature": 25.0,
"target_temperature": 25.0,
"status": "Idle",
"power_on": false,
"is_cooling": false,
"is_heating": false
}
}
],
"links": []
}

View File

@@ -0,0 +1,30 @@
{
"nodes": [
{
"id": "MockFilter1",
"name": "模拟过滤器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_filter",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"is_filtering": false,
"filter_efficiency": 95.0,
"flow_rate": 0.0,
"pressure_drop": 0.0,
"filter_life": 100.0,
"power_on": false
}
}
],
"links": []
}

View File

@@ -0,0 +1,30 @@
{
"nodes": [
{
"id": "MockHeater1",
"name": "模拟加热器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_heater",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"current_temperature": 25.0,
"target_temperature": 25.0,
"status": "Idle",
"power_on": false,
"is_heating": false,
"heating_power": 0.0,
"max_temperature": 300.0
}
}
],
"links": []
}

View File

@@ -0,0 +1,33 @@
{
"nodes": [
{
"id": "MockPump1",
"name": "模拟泵设备",
"children": [],
"parent": null,
"type": "device",
"class": "mock_pump",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"pump_state": "Stopped",
"flow_rate": 0.0,
"target_flow_rate": 0.0,
"pressure": 0.0,
"total_volume": 0.0,
"direction": "Forward",
"max_flow_rate": 100.0,
"max_pressure": 10.0
}
}
],
"links": []
}

View File

@@ -0,0 +1,34 @@
{
"nodes": [
{
"id": "MockRotavap1",
"name": "模拟旋转蒸发器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_rotavap",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"rotate_state": "Stopped",
"rotate_time": 0.0,
"rotate_speed": 0.0,
"pump_state": "Stopped",
"pump_time": 0.0,
"vacuum_level": 1013.25,
"temperature": 25.0,
"target_temperature": 25.0,
"success": "True"
}
}
],
"links": []
}

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "MockSeparator1",
"name": "模拟分离器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_separator",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"settling_time": 0.0,
"valve_state": "Closed",
"shake_time": 0.0,
"shake_status": "Not Shaking"
}
}
],
"links": []
}

View File

@@ -0,0 +1,25 @@
{
"nodes": [
{
"id": "MockSolenoidValve1",
"name": "模拟电磁阀",
"children": [],
"parent": null,
"type": "device",
"class": "mock_solenoid_valve",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"valve_status": "Closed"
}
}
],
"links": []
}

View File

@@ -0,0 +1,34 @@
{
"nodes": [
{
"id": "MockStirrer1",
"name": "模拟搅拌器",
"children": [],
"parent": null,
"type": "device",
"class": "mock_stirrer",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"stir_speed": 0.0,
"target_stir_speed": 0.0,
"stir_state": "Stopped",
"temperature": 25.0,
"target_temperature": 25.0,
"heating_state": "Off",
"heating_power": 0.0,
"max_stir_speed": 2000.0,
"max_temperature": 300.0
}
}
],
"links": []
}

View File

@@ -0,0 +1,31 @@
{
"nodes": [
{
"id": "MockVacuum1",
"name": "模拟真空泵",
"children": [],
"parent": null,
"type": "device",
"class": "mock_vacuum",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "MOCK"
},
"data": {
"status": "Idle",
"power_state": "Off",
"pump_state": "Stopped",
"vacuum_level": 1013.25,
"target_vacuum": 50.0,
"pump_speed": 0.0,
"pump_efficiency": 95.0,
"max_pump_speed": 100.0
}
}
],
"links": []
}

View File

@@ -56,6 +56,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -56,6 +56,8 @@ dependencies:
# - ros-humble-moveit-servo # - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -58,6 +58,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -56,6 +56,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64 - ros-humble-simulation # ignored because of NO python3.11 package in WIN64
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# ros-humble-unilabos-msgs # ros-humble-unilabos-msgs

View File

@@ -31,6 +31,6 @@ def job_add(req: JobAddReq) -> JobData:
action_kwargs = {"command": json.dumps(action_kwargs)} action_kwargs = {"command": json.dumps(action_kwargs)}
elif "command" in action_kwargs: elif "command" in action_kwargs:
action_kwargs = action_kwargs["command"] action_kwargs = action_kwargs["command"]
print(f"job_add:{req.device_id} {action_name} {action_kwargs}") # print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id) HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id, server_info=req.server_info)
return JobData(jobId=req.job_id) return JobData(jobId=req.job_id)

View File

@@ -18,7 +18,6 @@ if unilabos_dir not in sys.path:
from unilabos.config.config import load_config, BasicConfig, _update_config_from_env from unilabos.config.config import load_config, BasicConfig, _update_config_from_env
from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.device_mesh.resource_visalization import ResourceVisualization
def parse_args(): def parse_args():
@@ -188,11 +187,12 @@ def main():
if args_dict["visual"] != "disable": if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz" enable_rviz = args_dict["visual"] == "rviz"
if devices_and_resources is not None: if devices_and_resources is not None:
from unilabos.device_mesh.resource_visalization import ResourceVisualization # 此处开启后logger会变更为INFO有需要请调整
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz) resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
args_dict["resources_mesh_config"] = resource_visualization.resource_model args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict) start_backend(**args_dict)
server_thread = threading.Thread(target=start_server, kwargs=dict( server_thread = threading.Thread(target=start_server, kwargs=dict(
open_browser=not args_dict["disable_browser"] open_browser=not args_dict["disable_browser"], port=args_dict["port"],
)) ))
server_thread.start() server_thread.start()
asyncio.set_event_loop(asyncio.new_event_loop()) asyncio.set_event_loop(asyncio.new_event_loop())
@@ -201,10 +201,10 @@ def main():
time.sleep(1) time.sleep(1)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(open_browser=not args_dict["disable_browser"]) start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(open_browser=not args_dict["disable_browser"]) start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -51,8 +51,9 @@ class Resp(BaseModel):
class JobAddReq(BaseModel): class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id") device_id: str = Field(examples=["Gripper"], description="device id")
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}]) data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid") job_id: str = Field(examples=["job_id"], description="goal uuid")
node_id: str = Field(examples=["sfsfsfeq"], description="node uuid") node_id: str = Field(examples=["node_id"], description="node uuid")
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
class JobStepFinishReq(BaseModel): class JobStepFinishReq(BaseModel):

View File

@@ -12,7 +12,7 @@ import tempfile
import os import os
from unilabos.config.config import MQConfig from unilabos.config.config import MQConfig
from unilabos.app.controler import devices, job_add from unilabos.app.controler import job_add
from unilabos.app.model import JobAddReq from unilabos.app.model import JobAddReq
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.type_check import TypeEncoder from unilabos.utils.type_check import TypeEncoder
@@ -26,6 +26,7 @@ class MQTTClient:
def __init__(self): def __init__(self):
self.mqtt_disable = not MQConfig.lab_id self.mqtt_disable = not MQConfig.lab_id
self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}" self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}"
logger.info("[MQTT] Client_id: " + self.client_id)
self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5) self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5)
self._setup_callbacks() self._setup_callbacks()
@@ -42,20 +43,14 @@ class MQTTClient:
def _on_connect(self, client, userdata, flags, rc, properties=None): def _on_connect(self, client, userdata, flags, rc, properties=None):
logger.info("[MQTT] Connected with result code " + str(rc)) logger.info("[MQTT] Connected with result code " + str(rc))
client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0) client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0)
isok, data = devices() client.subscribe(f"labs/{MQConfig.lab_id}/pong/", 0)
if not isok:
logger.error("[MQTT] on_connect ErrorHostNotInit")
return
def _on_message(self, client, userdata, msg) -> None: def _on_message(self, client, userdata, msg) -> None:
logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload)) # logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
try: try:
payload_str = msg.payload.decode("utf-8") payload_str = msg.payload.decode("utf-8")
payload_json = json.loads(payload_str) payload_json = json.loads(payload_str)
logger.debug(f"Topic: {msg.topic}")
logger.debug("Payload:", json.dumps(payload_json, indent=2, ensure_ascii=False))
if msg.topic == f"labs/{MQConfig.lab_id}/job/start/": if msg.topic == f"labs/{MQConfig.lab_id}/job/start/":
logger.debug("job_add", type(payload_json), payload_json)
if "data" not in payload_json: if "data" not in payload_json:
payload_json["data"] = {} payload_json["data"] = {}
if "action" in payload_json: if "action" in payload_json:
@@ -65,6 +60,14 @@ class MQTTClient:
job_req = JobAddReq.model_validate(payload_json) job_req = JobAddReq.model_validate(payload_json)
data = job_add(job_req) data = job_add(job_req)
return return
elif msg.topic == f"labs/{MQConfig.lab_id}/pong/":
# 处理pong响应通知HostNode
from unilabos.ros.nodes.presets.host_node import HostNode
host_instance = HostNode.get_instance(0)
if host_instance:
host_instance.handle_pong_response(payload_json)
return
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"[MQTT] JSON 解析错误: {e}") logger.error(f"[MQTT] JSON 解析错误: {e}")
@@ -181,6 +184,28 @@ class MQTTClient:
self.client.publish(address, json.dumps(action_info), qos=2) self.client.publish(address, json.dumps(action_info), qos=2)
logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}") logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}")
def send_ping(self, ping_id: str, timestamp: float):
"""发送ping消息到服务端"""
if self.mqtt_disable:
return
address = f"labs/{MQConfig.lab_id}/ping/"
ping_data = {"ping_id": ping_id, "client_timestamp": timestamp, "type": "ping"}
self.client.publish(address, json.dumps(ping_data), qos=2)
def setup_pong_subscription(self):
"""设置pong消息订阅"""
if self.mqtt_disable:
return
pong_topic = f"labs/{MQConfig.lab_id}/pong/"
self.client.subscribe(pong_topic, 0)
logger.debug(f"Subscribed to pong topic: {pong_topic}")
def handle_pong(self, pong_data: dict):
"""处理pong响应这个方法会在收到pong消息时被调用"""
logger.debug(f"Pong received: {pong_data}")
# 这里会被HostNode的ping-pong处理逻辑调用
pass
mqtt_client = MQTTClient() mqtt_client = MQTTClient()

View File

@@ -42,7 +42,7 @@ def get_host_node_info() -> Dict[str, Any]:
host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics))
# 获取动作客户端信息 # 获取动作客户端信息
for action_id, client in host_node._action_clients.items(): for action_id, client in host_node._action_clients.items():
host_info["action_clients"] = {action_id: get_action_info(client, full_name=action_id)} host_info["action_clients"][action_id] = get_action_info(client, full_name=action_id)
# 获取设备状态 # 获取设备状态
host_info["device_status"] = host_node.device_status host_info["device_status"] = host_node.device_status

View File

@@ -14,7 +14,7 @@ from pylabrobot.resources import (
Well Well
) )
class DPLiquidHandler(LiquidHandler): class LiquidHandlerAbstract(LiquidHandler):
"""Extended LiquidHandler with additional operations.""" """Extended LiquidHandler with additional operations."""
# --------------------------------------------------------------- # ---------------------------------------------------------------

View File

View File

@@ -0,0 +1,170 @@
import time
import threading
class MockChiller:
def __init__(self, port: str = "MOCK"):
self.port = port
self._current_temperature: float = 25.0 # 室温开始
self._target_temperature: float = 25.0
self._status: str = "Idle"
self._is_cooling: bool = False
self._is_heating: bool = False
self._power_on: bool = False
# 模拟温度变化的线程
self._temperature_thread = None
self._running = False
@property
def current_temperature(self) -> float:
"""当前温度 - 会被自动识别的设备属性"""
return self._current_temperature
@property
def target_temperature(self) -> float:
"""目标温度"""
return self._target_temperature
@property
def status(self) -> str:
"""设备状态 - 会被自动识别的设备属性"""
return self._status
@property
def power_on(self) -> bool:
"""电源状态"""
return self._power_on
@property
def is_cooling(self) -> bool:
"""是否正在冷却"""
return self._is_cooling
@property
def is_heating(self) -> bool:
"""是否正在加热"""
return self._is_heating
def set_temperature(self, temperature: float):
"""设置目标温度 - 需要在注册表添加的设备动作"""
if not self._power_on:
self._status = "Error: Power Off"
return False
# 将传入温度转换为 float并限制在允许范围内
temperature = float(temperature)
self._target_temperature = temperature
# 立即更新状态
diff = self._target_temperature - self._current_temperature
if abs(diff) < 0.1:
self._status = "At Target Temperature"
self._is_cooling = False
self._is_heating = False
elif diff < 0:
self._status = "Cooling"
self._is_cooling = True
self._is_heating = False
else:
self._status = "Heating"
self._is_heating = True
self._is_cooling = False
# 启动温度控制
self._start_temperature_control()
return True
def power_on_off(self, power_state: str):
"""开关机控制"""
if power_state == "on":
self._power_on = True
# 不在这里直接设置状态和加热/制冷标志
self._start_temperature_control()
else:
self._power_on = False
self._status = "Power Off"
self._stop_temperature_control()
self._is_cooling = False
self._is_heating = False
def _start_temperature_control(self):
"""启动温度控制线程"""
if self._power_on: # 移除 not self._running 检查
self._running = True
if self._temperature_thread is None or not self._temperature_thread.is_alive():
self._temperature_thread = threading.Thread(target=self._temperature_control_loop)
self._temperature_thread.daemon = True
self._temperature_thread.start()
def _stop_temperature_control(self):
"""停止温度控制"""
self._running = False
if self._temperature_thread:
self._temperature_thread.join(timeout=1.0)
def _temperature_control_loop(self):
"""温度控制循环 - 模拟真实冷却器的温度变化"""
while self._running and self._power_on:
temp_diff = self._target_temperature - self._current_temperature
if abs(temp_diff) < 0.1: # 将判断范围从0.5改小到0.1
self._status = "At Target Temperature"
self._is_cooling = False
self._is_heating = False
elif temp_diff < 0: # 需要冷却
self._status = "Cooling"
self._is_cooling = True
self._is_heating = False
# 模拟冷却过程每秒降低0.5度
self._current_temperature -= 0.5
else: # 需要加热
self._status = "Heating"
self._is_heating = True
self._is_cooling = False
# 模拟加热过程每秒升高0.3度
self._current_temperature += 0.3
# 限制温度范围
self._current_temperature = max(-20.0, min(80.0, self._current_temperature))
time.sleep(1.0) # 每秒更新一次
def emergency_stop(self):
"""紧急停止"""
self._status = "Emergency Stop"
self._stop_temperature_control()
self._is_cooling = False
self._is_heating = False
def get_status_info(self) -> dict:
"""获取完整状态信息"""
return {
"current_temperature": self._current_temperature,
"target_temperature": self._target_temperature,
"status": self._status,
"power_on": self._power_on,
"is_cooling": self._is_cooling,
"is_heating": self._is_heating,
}
# 用于测试的主函数
if __name__ == "__main__":
chiller = MockChiller()
# 测试基本功能
print("启动冷却器测试...")
chiller.power_on_off("on")
print(f"初始状态: {chiller.get_status_info()}")
# 设置目标温度为5度
chiller.set_temperature(5.0)
# 模拟运行10秒
for i in range(10):
time.sleep(1)
print(f"{i+1}秒: 当前温度={chiller.current_temperature:.1f}°C, 状态={chiller.status}")
chiller.emergency_stop()
print("测试完成")

View File

@@ -0,0 +1,170 @@
import time
import threading
class MockFilter:
def __init__(self, port: str = "MOCK"):
self.port = port
self._status: str = "Idle"
self._is_filtering: bool = False
self._filter_efficiency: float = 95.0 # 过滤效率百分比
self._flow_rate: float = 0.0 # 流速 L/min
self._pressure_drop: float = 0.0 # 压降 Pa
self._filter_life: float = 100.0 # 滤芯寿命百分比
self._power_on: bool = False
# 模拟过滤过程的线程
self._filter_thread = None
self._running = False
@property
def status(self) -> str:
"""设备状态 - 会被自动识别的设备属性"""
return self._status
@property
def is_filtering(self) -> bool:
"""是否正在过滤"""
return self._is_filtering
@property
def filter_efficiency(self) -> float:
"""过滤效率"""
return self._filter_efficiency
@property
def flow_rate(self) -> float:
"""流速"""
return self._flow_rate
@property
def pressure_drop(self) -> float:
"""压降"""
return self._pressure_drop
@property
def filter_life(self) -> float:
"""滤芯寿命"""
return self._filter_life
@property
def power_on(self) -> bool:
"""电源状态"""
return self._power_on
def start_filtering(self, flow_rate: float = 1.0):
"""开始过滤 - 需要在注册表添加的设备动作"""
if not self._power_on:
self._status = "Error: Power Off"
return False
self._flow_rate = flow_rate
self._status = "Starting Filter"
self._start_filter_process()
return True
def stop_filtering(self):
"""停止过滤"""
self._status = "Stopping Filter"
self._stop_filter_process()
self._flow_rate = 0.0
self._is_filtering = False
self._status = "Idle"
return True
def power_on_off(self, power_state: str):
"""开关机控制"""
if power_state == "on":
self._power_on = True
self._status = "Power On"
else:
self._power_on = False
self._status = "Power Off"
self._stop_filter_process()
self._is_filtering = False
self._flow_rate = 0.0
def replace_filter(self):
"""更换滤芯"""
self._filter_life = 100.0
self._filter_efficiency = 95.0
self._status = "Filter Replaced"
return True
def _start_filter_process(self):
"""启动过滤过程线程"""
if not self._running and self._power_on:
self._running = True
self._is_filtering = True
self._filter_thread = threading.Thread(target=self._filter_loop)
self._filter_thread.daemon = True
self._filter_thread.start()
def _stop_filter_process(self):
"""停止过滤过程"""
self._running = False
if self._filter_thread:
self._filter_thread.join(timeout=1.0)
def _filter_loop(self):
"""过滤过程循环 - 模拟真实过滤器的工作过程"""
while self._running and self._power_on and self._is_filtering:
self._status = "Filtering"
# 模拟滤芯磨损
if self._filter_life > 0:
self._filter_life -= 0.1 # 每秒减少0.1%寿命
# 根据滤芯寿命调整效率和压降
life_factor = self._filter_life / 100.0
self._filter_efficiency = 95.0 * life_factor + 50.0 * (1 - life_factor)
self._pressure_drop = 100.0 + (200.0 * (1 - life_factor)) # 压降随磨损增加
# 检查滤芯是否需要更换
if self._filter_life <= 10.0:
self._status = "Filter Needs Replacement"
time.sleep(1.0) # 每秒更新一次
def emergency_stop(self):
"""紧急停止"""
self._status = "Emergency Stop"
self._stop_filter_process()
self._is_filtering = False
self._flow_rate = 0.0
def get_status_info(self) -> dict:
"""获取完整状态信息"""
return {
"status": self._status,
"is_filtering": self._is_filtering,
"filter_efficiency": self._filter_efficiency,
"flow_rate": self._flow_rate,
"pressure_drop": self._pressure_drop,
"filter_life": self._filter_life,
"power_on": self._power_on,
}
# 用于测试的主函数
if __name__ == "__main__":
filter_device = MockFilter()
# 测试基本功能
print("启动过滤器测试...")
filter_device.power_on_off("on")
print(f"初始状态: {filter_device.get_status_info()}")
# 开始过滤
filter_device.start_filtering(2.0)
# 模拟运行10秒
for i in range(10):
time.sleep(1)
print(
f"{i+1}秒: 效率={filter_device.filter_efficiency:.1f}%, "
f"寿命={filter_device.filter_life:.1f}%, 状态={filter_device.status}"
)
filter_device.emergency_stop()
print("测试完成")

View File

@@ -0,0 +1,202 @@
import time
import threading
class MockHeater:
def __init__(self, port: str = "MOCK"):
self.port = port
self._current_temperature: float = 25.0 # 室温开始
self._target_temperature: float = 25.0
self._status: str = "Idle"
self._is_heating: bool = False
self._power_on: bool = False
self._heating_power: float = 0.0 # 加热功率百分比 0-100
self._max_temperature: float = 300.0 # 最大加热温度
# 模拟加热过程的线程
self._heating_thread = None
self._running = False
@property
def current_temperature(self) -> float:
"""当前温度 - 会被自动识别的设备属性"""
return self._current_temperature
@property
def target_temperature(self) -> float:
"""目标温度"""
return self._target_temperature
@property
def status(self) -> str:
"""设备状态 - 会被自动识别的设备属性"""
return self._status
@property
def power_on(self) -> bool:
"""电源状态"""
return self._power_on
@property
def is_heating(self) -> bool:
"""是否正在加热"""
return self._is_heating
@property
def heating_power(self) -> float:
"""加热功率百分比"""
return self._heating_power
@property
def max_temperature(self) -> float:
"""最大加热温度"""
return self._max_temperature
def set_temperature(self, temperature: float):
"""设置目标温度 - 需要在注册表添加的设备动作"""
try:
temperature = float(temperature)
except ValueError:
self._status = "Error: Invalid temperature value"
return False
if not self._power_on:
self._status = "Error: Power Off"
return False
if temperature > self._max_temperature:
self._status = f"Error: Temperature exceeds maximum ({self._max_temperature}°C)"
return False
self._target_temperature = temperature
self._status = "Setting Temperature"
# 启动加热控制
self._start_heating_control()
return True
def set_heating_power(self, power: float):
"""设置加热功率"""
try:
power = float(power)
except ValueError:
self._status = "Error: Invalid power value"
return False
if not self._power_on:
self._status = "Error: Power Off"
return False
self._heating_power = max(0.0, min(100.0, power)) # 限制在0-100%
return True
def power_on_off(self, power_state: str):
"""开关机控制,接收字符串命令 "On""Off" """
power_state = power_state.capitalize()
if power_state not in ["On", "Off"]:
self._status = "Error: Invalid power state"
return "Error"
self._power_on = True if power_state == "On" else False
if self._power_on:
self._status = "Power On"
self._start_heating_control()
else:
self._status = "Power Off"
self._stop_heating_control()
self._is_heating = False
self._heating_power = 0.0
return "Success"
def _start_heating_control(self):
"""启动加热控制线程"""
if not self._running and self._power_on:
self._running = True
self._heating_thread = threading.Thread(target=self._heating_control_loop)
self._heating_thread.daemon = True
self._heating_thread.start()
def _stop_heating_control(self):
"""停止加热控制"""
self._running = False
if self._heating_thread:
self._heating_thread.join(timeout=1.0)
def _heating_control_loop(self):
"""加热控制循环 - 模拟真实加热器的温度变化"""
while self._running and self._power_on:
temp_diff = self._target_temperature - self._current_temperature
if abs(temp_diff) < 0.5: # 温度接近目标值
self._status = "At Target Temperature"
self._is_heating = False
self._heating_power = 10.0 # 维持温度的最小功率
elif temp_diff > 0: # 需要加热
self._status = "Heating"
self._is_heating = True
# 根据温差调整加热功率
if temp_diff > 50:
self._heating_power = 100.0
elif temp_diff > 20:
self._heating_power = 80.0
elif temp_diff > 10:
self._heating_power = 60.0
else:
self._heating_power = 40.0
# 模拟加热过程,加热速度与功率成正比
heating_rate = self._heating_power / 100.0 * 2.0 # 最大每秒升温2度
self._current_temperature += heating_rate
else: # 目标温度低于当前温度,自然冷却
self._status = "Cooling Down"
self._is_heating = False
self._heating_power = 0.0
# 模拟自然冷却每秒降低0.2度
self._current_temperature -= 0.2
# 限制温度范围
self._current_temperature = max(20.0, min(self._max_temperature, self._current_temperature))
time.sleep(1.0) # 每秒更新一次
def emergency_stop(self):
"""紧急停止"""
self._status = "Emergency Stop"
self._stop_heating_control()
self._is_heating = False
self._heating_power = 0.0
def get_status_info(self) -> dict:
"""获取完整状态信息"""
return {
"current_temperature": self._current_temperature,
"target_temperature": self._target_temperature,
"status": self._status,
"power_on": self._power_on,
"is_heating": self._is_heating,
"heating_power": self._heating_power,
"max_temperature": self._max_temperature,
}
# 用于测试的主函数
if __name__ == "__main__":
heater = MockHeater()
# 测试基本功能
print("启动加热器测试...")
heater.power_on_off("On")
print(f"初始状态: {heater.get_status_info()}")
# 设置目标温度为80度
heater.set_temperature(80.0)
# 模拟运行15秒
for i in range(15):
time.sleep(1)
print(
f"{i+1}秒: 当前温度={heater.current_temperature:.1f}°C, 功率={heater.heating_power:.1f}%, "
f"状态={heater.status}"
)
heater.emergency_stop()
print("测试完成")

View File

@@ -0,0 +1,414 @@
import time
import threading
class MockPump:
"""
模拟泵设备类
这个类模拟了一个实验室泵设备的行为,包括流量控制、压力监测、
运行状态管理等功能。所有的控制参数都使用字符串类型以提供更好的
可读性和扩展性。
"""
def __init__(self, port: str = "MOCK"):
"""
初始化MockPump实例
Args:
port (str): 设备端口,默认为"MOCK"表示模拟设备
"""
self.port = port
# 设备基本状态属性
self._status: str = "Idle" # 设备状态Idle, Running, Error, Stopped
self._power_state: str = "Off" # 电源状态On, Off
self._pump_state: str = "Stopped" # 泵运行状态Running, Stopped, Paused
# 流量相关属性
self._flow_rate: float = 0.0 # 当前流速 (mL/min)
self._target_flow_rate: float = 0.0 # 目标流速 (mL/min)
self._max_flow_rate: float = 100.0 # 最大流速 (mL/min)
self._total_volume: float = 0.0 # 累计流量 (mL)
# 压力相关属性
self._pressure: float = 0.0 # 当前压力 (bar)
self._max_pressure: float = 10.0 # 最大压力 (bar)
# 方向控制属性
self._direction: str = "Forward" # 泵方向Forward, Reverse
# 运行控制线程
self._pump_thread = None
self._running = False
self._thread_lock = threading.Lock()
# ==================== 状态属性 ====================
# 这些属性会被Uni-Lab系统自动识别并定时对外广播
@property
def status(self) -> str:
return self._status
@property
def power_state(self) -> str:
return self._power_state
@property
def pump_state(self) -> str:
return self._pump_state
@property
def flow_rate(self) -> float:
return self._flow_rate
@property
def target_flow_rate(self) -> float:
return self._target_flow_rate
@property
def pressure(self) -> float:
return self._pressure
@property
def total_volume(self) -> float:
return self._total_volume
@property
def direction(self) -> str:
return self._direction
@property
def max_flow_rate(self) -> float:
return self._max_flow_rate
@property
def max_pressure(self) -> float:
return self._max_pressure
# ==================== 设备控制方法 ====================
# 这些方法需要在注册表中添加会作为ActionServer接受控制指令
def power_control(self, power_state: str = "On") -> str:
"""
电源控制方法
Args:
power_state (str): 电源状态,可选值:"On", "Off"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if power_state not in ["On", "Off"]:
self._status = "Error: Invalid power state"
return "Error"
self._power_state = power_state
if power_state == "On":
self._status = "Power On"
else:
self._status = "Power Off"
# 关机时停止所有运行
self.stop_pump()
return "Success"
def set_flow_rate(self, flow_rate: float) -> str:
"""
设置目标流速
Args:
flow_rate (float): 目标流速 (mL/min)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
try:
flow_rate = float(flow_rate)
except ValueError:
self._status = "Error: Invalid flow rate"
return "Error"
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if flow_rate < 0 or flow_rate > self._max_flow_rate:
self._status = f"Error: Flow rate out of range (0-{self._max_flow_rate})"
return "Error"
self._target_flow_rate = flow_rate
self._status = "Setting Flow Rate"
# 如果设置了非零流速,自动启动泵
if flow_rate > 0:
# 自动切换泵状态为 "Running" 以触发操作循环
self._pump_state = "Running"
self._start_pump_operation()
else:
self.stop_pump()
return "Success"
def start_pump(self) -> str:
"""
启动泵运行
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if self._target_flow_rate <= 0:
self._status = "Error: No target flow rate set"
return "Error"
self._pump_state = "Running"
self._status = "Starting Pump"
self._start_pump_operation()
return "Success"
def stop_pump(self) -> str:
"""
停止泵运行
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._pump_state = "Stopped"
self._status = "Stopping Pump"
self._stop_pump_operation()
self._flow_rate = 0.0
self._pressure = 0.0
return "Success"
def pause_pump(self) -> str:
"""
暂停泵运行
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._pump_state != "Running":
self._status = "Error: Pump not running"
return "Error"
self._pump_state = "Paused"
self._status = "Pump Paused"
self._stop_pump_operation()
return "Success"
def resume_pump(self) -> str:
"""
恢复泵运行
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._pump_state != "Paused":
self._status = "Error: Pump not paused"
return "Error"
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
self._pump_state = "Running"
self._status = "Resuming Pump"
self._start_pump_operation()
return "Success"
def set_direction(self, direction: str = "Forward") -> str:
"""
设置泵方向
Args:
direction (str): 泵方向,可选值:"Forward", "Reverse"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if direction not in ["Forward", "Reverse"]:
self._status = "Error: Invalid direction"
return "Error"
# 如果泵正在运行,需要先停止
was_running = self._pump_state == "Running"
if was_running:
self.stop_pump()
time.sleep(0.5) # 等待停止完成
self._direction = direction
self._status = f"Direction set to {direction}"
# 如果之前在运行,重新启动
if was_running:
self.start_pump()
return "Success"
def reset_volume_counter(self) -> str:
"""
重置累计流量计数器
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._total_volume = 0.0
self._status = "Volume counter reset"
return "Success"
def emergency_stop(self) -> str:
"""
紧急停止
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._status = "Emergency Stop"
self._pump_state = "Stopped"
self._stop_pump_operation()
self._flow_rate = 0.0
self._pressure = 0.0
self._target_flow_rate = 0.0
return "Success"
# ==================== 内部控制方法 ====================
def _start_pump_operation(self):
"""
启动泵运行线程
这个方法启动一个后台线程来模拟泵的实际运行过程,
包括流速控制、压力变化和累计流量计算。
"""
with self._thread_lock:
if not self._running and self._power_state == "On":
self._running = True
self._pump_thread = threading.Thread(target=self._pump_operation_loop)
self._pump_thread.daemon = True
self._pump_thread.start()
def _stop_pump_operation(self):
"""
停止泵运行线程
安全地停止后台运行线程并等待其完成。
"""
with self._thread_lock:
self._running = False
if self._pump_thread and self._pump_thread.is_alive():
self._pump_thread.join(timeout=2.0)
def _pump_operation_loop(self):
"""
泵运行主循环
这个方法在后台线程中运行,模拟真实泵的工作过程:
1. 逐步调整流速到目标值
2. 根据流速计算压力
3. 累计流量统计
4. 状态更新
"""
while self._running and self._power_state == "On" and self._pump_state == "Running":
try:
# 模拟流速调节过程(逐步接近目标流速)
flow_diff = self._target_flow_rate - self._flow_rate
if abs(flow_diff) < 0.1: # 流速接近目标值
self._flow_rate = self._target_flow_rate
self._status = "At Target Flow Rate"
else:
# 模拟流速调节每秒调整10%的差值
adjustment = flow_diff * 0.1
self._flow_rate += adjustment
self._status = "Adjusting Flow Rate"
# 确保流速在合理范围内
self._flow_rate = max(0.0, min(self._max_flow_rate, self._flow_rate))
# 模拟压力变化(压力与流速成正比,加上一些随机波动)
base_pressure = (self._flow_rate / self._max_flow_rate) * self._max_pressure
pressure_variation = 0.1 * base_pressure * (time.time() % 1.0 - 0.5) # ±5%波动
self._pressure = max(0.0, base_pressure + pressure_variation)
# 累计流量计算(每秒更新)
if self._flow_rate > 0:
volume_increment = self._flow_rate / 60.0 # 转换为mL/s
if self._direction == "Reverse":
volume_increment = -volume_increment
self._total_volume += volume_increment
# 压力保护检查
if self._pressure > self._max_pressure * 0.95:
self._status = "Warning: High Pressure"
# 等待1秒后继续下一次循环
time.sleep(1.0)
except Exception as e:
self._status = f"Error in pump operation: {str(e)}"
break
# 循环结束时的清理工作
if self._pump_state == "Running":
self._status = "Idle"
def get_status_info(self) -> dict:
"""
获取完整的设备状态信息
Returns:
dict: 包含所有设备状态的字典
"""
return {
"status": self._status,
"power_state": self._power_state,
"pump_state": self._pump_state,
"flow_rate": self._flow_rate,
"target_flow_rate": self._target_flow_rate,
"pressure": self._pressure,
"total_volume": self._total_volume,
"direction": self._direction,
"max_flow_rate": self._max_flow_rate,
"max_pressure": self._max_pressure,
}
# 用于测试的主函数
if __name__ == "__main__":
pump = MockPump()
# 测试基本功能
print("启动泵设备测试...")
pump.power_control("On")
print(f"初始状态: {pump.get_status_info()}")
# 设置流速并启动
pump.set_flow_rate(50.0)
pump.start_pump()
# 模拟运行10秒
for i in range(10):
time.sleep(1)
print(f"{i+1}秒: 流速={pump.flow_rate:.1f}mL/min, 压力={pump.pressure:.2f}bar, 状态={pump.status}")
# 测试方向切换
print("切换泵方向...")
pump.set_direction("Reverse")
# 继续运行5秒
for i in range(5):
time.sleep(1)
print(f"反向第{i+1}秒: 累计流量={pump.total_volume:.1f}mL, 方向={pump.direction}")
pump.emergency_stop()
print("测试完成")

View File

@@ -0,0 +1,443 @@
import time
import threading
import json
class MockRotavap:
"""
模拟旋转蒸发器设备类
这个类模拟了一个实验室旋转蒸发器的行为,包括旋转控制、
真空泵控制、温度控制等功能。参考了现有的 RotavapOne 实现。
"""
def __init__(self, port: str = "MOCK"):
"""
初始化MockRotavap实例
Args:
port (str): 设备端口,默认为"MOCK"表示模拟设备
"""
self.port = port
# 设备基本状态属性
self._status: str = "Idle" # 设备状态Idle, Running, Error, Stopped
self._power_state: str = "Off" # 电源状态On, Off
# 旋转相关属性
self._rotate_state: str = "Stopped" # 旋转状态Running, Stopped
self._rotate_time: float = 0.0 # 旋转剩余时间 (秒)
self._rotate_speed: float = 0.0 # 旋转速度 (rpm)
self._max_rotate_speed: float = 300.0 # 最大旋转速度 (rpm)
# 真空泵相关属性
self._pump_state: str = "Stopped" # 泵状态Running, Stopped
self._pump_time: float = 0.0 # 泵剩余时间 (秒)
self._vacuum_level: float = 0.0 # 真空度 (mbar)
self._target_vacuum: float = 50.0 # 目标真空度 (mbar)
# 温度相关属性
self._temperature: float = 25.0 # 水浴温度 (°C)
self._target_temperature: float = 25.0 # 目标温度 (°C)
self._max_temperature: float = 180.0 # 最大温度 (°C)
# 运行控制线程
self._operation_thread = None
self._running = False
self._thread_lock = threading.Lock()
# 操作成功标志
self.success: str = "True" # 使用字符串而不是布尔值
# ==================== 状态属性 ====================
# 这些属性会被Uni-Lab系统自动识别并定时对外广播
@property
def status(self) -> str:
return self._status
@property
def power_state(self) -> str:
return self._power_state
@property
def rotate_state(self) -> str:
return self._rotate_state
@property
def rotate_time(self) -> float:
return self._rotate_time
@property
def rotate_speed(self) -> float:
return self._rotate_speed
@property
def pump_state(self) -> str:
return self._pump_state
@property
def pump_time(self) -> float:
return self._pump_time
@property
def vacuum_level(self) -> float:
return self._vacuum_level
@property
def temperature(self) -> float:
return self._temperature
@property
def target_temperature(self) -> float:
return self._target_temperature
# ==================== 设备控制方法 ====================
# 这些方法需要在注册表中添加会作为ActionServer接受控制指令
def power_control(self, power_state: str = "On") -> str:
"""
电源控制方法
Args:
power_state (str): 电源状态,可选值:"On", "Off"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if power_state not in ["On", "Off"]:
self._status = "Error: Invalid power state"
self.success = "False"
return "Error"
self._power_state = power_state
if power_state == "On":
self._status = "Power On"
self._start_operation()
else:
self._status = "Power Off"
self.stop_all_operations()
self.success = "True"
return "Success"
def set_timer(self, command: str) -> str:
"""
设置定时器 - 兼容现有RotavapOne接口
Args:
command (str): JSON格式的命令字符串包含rotate_time和pump_time
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
self.success = "False"
return "Error"
try:
timer = json.loads(command)
rotate_time = timer.get("rotate_time", 0)
pump_time = timer.get("pump_time", 0)
self.success = "False"
self._rotate_time = float(rotate_time)
self._pump_time = float(pump_time)
self.success = "True"
self._status = "Timer Set"
return "Success"
except (json.JSONDecodeError, ValueError, KeyError) as e:
self._status = f"Error: Invalid command format - {str(e)}"
self.success = "False"
return "Error"
def set_rotate_time(self, time_seconds: float) -> str:
"""
设置旋转时间
Args:
time_seconds (float): 旋转时间 (秒)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
self.success = "False"
self._rotate_time = max(0.0, float(time_seconds))
self.success = "True"
self._status = "Rotate time set"
return "Success"
def set_pump_time(self, time_seconds: float) -> str:
"""
设置泵时间
Args:
time_seconds (float): 泵时间 (秒)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
self.success = "False"
self._pump_time = max(0.0, float(time_seconds))
self.success = "True"
self._status = "Pump time set"
return "Success"
def set_rotate_speed(self, speed: float) -> str:
"""
设置旋转速度
Args:
speed (float): 旋转速度 (rpm)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if speed < 0 or speed > self._max_rotate_speed:
self._status = f"Error: Speed out of range (0-{self._max_rotate_speed})"
return "Error"
self._rotate_speed = speed
self._status = "Rotate speed set"
return "Success"
def set_temperature(self, temperature: float) -> str:
"""
设置水浴温度
Args:
temperature (float): 目标温度 (°C)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if temperature < 0 or temperature > self._max_temperature:
self._status = f"Error: Temperature out of range (0-{self._max_temperature})"
return "Error"
self._target_temperature = temperature
self._status = "Temperature set"
return "Success"
def start_rotation(self) -> str:
"""
启动旋转
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if self._rotate_time <= 0:
self._status = "Error: No rotate time set"
return "Error"
self._rotate_state = "Running"
self._status = "Rotation started"
return "Success"
def start_pump(self) -> str:
"""
启动真空泵
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if self._pump_time <= 0:
self._status = "Error: No pump time set"
return "Error"
self._pump_state = "Running"
self._status = "Pump started"
return "Success"
def stop_all_operations(self) -> str:
"""
停止所有操作
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._rotate_state = "Stopped"
self._pump_state = "Stopped"
self._stop_operation()
self._rotate_time = 0.0
self._pump_time = 0.0
self._vacuum_level = 0.0
self._status = "All operations stopped"
return "Success"
def emergency_stop(self) -> str:
"""
紧急停止
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._status = "Emergency Stop"
self.stop_all_operations()
return "Success"
# ==================== 内部控制方法 ====================
def _start_operation(self):
"""
启动操作线程
这个方法启动一个后台线程来模拟旋蒸的实际运行过程。
"""
with self._thread_lock:
if not self._running and self._power_state == "On":
self._running = True
self._operation_thread = threading.Thread(target=self._operation_loop)
self._operation_thread.daemon = True
self._operation_thread.start()
def _stop_operation(self):
"""
停止操作线程
安全地停止后台运行线程并等待其完成。
"""
with self._thread_lock:
self._running = False
if self._operation_thread and self._operation_thread.is_alive():
self._operation_thread.join(timeout=2.0)
def _operation_loop(self):
"""
操作主循环
这个方法在后台线程中运行,模拟真实旋蒸的工作过程:
1. 时间倒计时
2. 温度控制
3. 真空度控制
4. 状态更新
"""
while self._running and self._power_state == "On":
try:
# 处理旋转时间倒计时
if self._rotate_time > 0:
self._rotate_state = "Running"
self._rotate_time = max(0.0, self._rotate_time - 1.0)
else:
self._rotate_state = "Stopped"
# 处理泵时间倒计时
if self._pump_time > 0:
self._pump_state = "Running"
self._pump_time = max(0.0, self._pump_time - 1.0)
# 模拟真空度变化
if self._vacuum_level > self._target_vacuum:
self._vacuum_level = max(self._target_vacuum, self._vacuum_level - 5.0)
else:
self._pump_state = "Stopped"
# 真空度逐渐回升
self._vacuum_level = min(1013.25, self._vacuum_level + 2.0)
# 模拟温度控制
temp_diff = self._target_temperature - self._temperature
if abs(temp_diff) > 0.5:
if temp_diff > 0:
self._temperature += min(1.0, temp_diff * 0.1)
else:
self._temperature += max(-1.0, temp_diff * 0.1)
# 更新整体状态
if self._rotate_state == "Running" or self._pump_state == "Running":
self._status = "Operating"
elif self._rotate_time > 0 or self._pump_time > 0:
self._status = "Ready"
else:
self._status = "Idle"
# 等待1秒后继续下一次循环
time.sleep(1.0)
except Exception as e:
self._status = f"Error in operation: {str(e)}"
break
# 循环结束时的清理工作
if self._power_state == "On":
self._status = "Idle"
def get_status_info(self) -> dict:
"""
获取完整的设备状态信息
Returns:
dict: 包含所有设备状态的字典
"""
return {
"status": self._status,
"power_state": self._power_state,
"rotate_state": self._rotate_state,
"rotate_time": self._rotate_time,
"rotate_speed": self._rotate_speed,
"pump_state": self._pump_state,
"pump_time": self._pump_time,
"vacuum_level": self._vacuum_level,
"temperature": self._temperature,
"target_temperature": self._target_temperature,
"success": self.success,
}
# 用于测试的主函数
if __name__ == "__main__":
rotavap = MockRotavap()
# 测试基本功能
print("启动旋转蒸发器测试...")
rotavap.power_control("On")
print(f"初始状态: {rotavap.get_status_info()}")
# 设置定时器
timer_command = '{"rotate_time": 300, "pump_time": 600}'
rotavap.set_timer(timer_command)
# 设置温度和转速
rotavap.set_temperature(60.0)
rotavap.set_rotate_speed(120.0)
# 启动操作
rotavap.start_rotation()
rotavap.start_pump()
# 模拟运行10秒
for i in range(10):
time.sleep(1)
print(
f"{i+1}秒: 旋转={rotavap.rotate_time:.0f}s, 泵={rotavap.pump_time:.0f}s, "
f"温度={rotavap.temperature:.1f}°C, 真空={rotavap.vacuum_level:.1f}mbar"
)
rotavap.emergency_stop()
print("测试完成")

View File

@@ -0,0 +1,184 @@
import time
import threading
class MockSeparator:
def __init__(self, port: str = "MOCK"):
self.port = port
# 基本状态属性
self._power_state: str = "Off" # 电源On 或 Off
self._status: str = "Idle" # 当前总体状态
self._valve_state: str = "Closed" # 阀门状态Open 或 Closed
self._settling_time: float = 0.0 # 静置时间(秒)
# 搅拌相关属性
self._shake_time: float = 0.0 # 剩余摇摆时间(秒)
self._shake_status: str = "Not Shaking" # 摇摆状态
# 用于后台模拟 shake 动作
self._operation_thread = None
self._thread_lock = threading.Lock()
@property
def power_state(self) -> str:
return self._power_state
@property
def valve_state(self) -> str:
return self._valve_state
@property
def settling_time(self) -> float:
return self._settling_time
@property
def status(self) -> str:
return self._status
@property
def shake_time(self) -> float:
with self._thread_lock:
return self._shake_time
@property
def shake_status(self) -> str:
with self._thread_lock:
return self._shake_status
def power_control(self, power_state: str) -> str:
"""
电源控制:只接受 "On""Off"
"""
if power_state not in ["On", "Off"]:
self._status = "Error: Invalid power state"
return "Error"
self._power_state = power_state
if power_state == "On":
self._status = "Powered On"
else:
self._status = "Powered Off"
self.stop_operations()
return "Success"
def shake(self, shake_time: float) -> str:
"""
模拟 shake搅拌操作
- 进入 "Shaking" 状态,倒计时 shake_time 秒
- shake 结束后,进入 "Settling" 状态,静置时间固定为 5 秒
- 最后恢复为 Idle
"""
try:
shake_time = float(shake_time)
except ValueError:
self._status = "Error: Invalid shake time"
return "Error"
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
with self._thread_lock:
self._status = "Shaking"
self._settling_time = 0.0
self._shake_time = shake_time
self._shake_status = "Shaking"
def _run_shake():
remaining = shake_time
while remaining > 0:
time.sleep(1)
remaining -= 1
with self._thread_lock:
self._shake_time = remaining
with self._thread_lock:
self._status = "Settling"
self._settling_time = 60.0 # 固定静置时间为60秒
self._shake_status = "Settling"
while True:
with self._thread_lock:
if self._settling_time <= 0:
self._status = "Idle"
self._shake_status = "Idle"
break
time.sleep(1)
with self._thread_lock:
self._settling_time = max(0.0, self._settling_time - 1)
self._operation_thread = threading.Thread(target=_run_shake)
self._operation_thread.daemon = True
self._operation_thread.start()
return "Success"
def set_valve(self, command: str) -> str:
"""
阀门控制命令:传入 "open""close"
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
command = command.lower()
if command == "open":
self._valve_state = "Open"
self._status = "Valve Opened"
elif command == "close":
self._valve_state = "Closed"
self._status = "Valve Closed"
else:
self._status = "Error: Invalid valve command"
return "Error"
return "Success"
def stop_operations(self) -> str:
"""
停止任何正在执行的操作
"""
with self._thread_lock:
self._settling_time = 0.0
self._status = "Idle"
self._shake_status = "Idle"
self._shake_time = 0.0
return "Success"
def get_status_info(self) -> dict:
"""
获取当前设备状态信息
"""
with self._thread_lock:
return {
"status": self._status,
"power_state": self._power_state,
"valve_state": self._valve_state,
"settling_time": self._settling_time,
"shake_time": self._shake_time,
"shake_status": self._shake_status,
}
# 主函数用于测试
if __name__ == "__main__":
separator = MockSeparator()
print("启动简单版分离器测试...")
print(separator.power_control("On"))
print("初始状态:", separator.get_status_info())
# 触发 shake 操作,模拟 10 秒的搅拌
print("执行 shake 操作...")
print(separator.shake(10.0))
# 循环显示状态变化
for i in range(20):
time.sleep(1)
info = separator.get_status_info()
print(
f"{i+1}秒: 状态={info['status']}, 静置时间={info['settling_time']:.1f}秒, "
f"阀门状态={info['valve_state']}, shake_time={info['shake_time']:.1f}, "
f"shake_status={info['shake_status']}"
)
# 模拟打开阀门
print("打开阀门...", separator.set_valve("open"))
print("最终状态:", separator.get_status_info())

View File

@@ -0,0 +1,89 @@
import time
class MockSolenoidValve:
"""
模拟电磁阀设备类 - 简化版本
这个类提供了电磁阀的基本功能:开启、关闭和状态查询
"""
def __init__(self, port: str = "MOCK"):
"""
初始化MockSolenoidValve实例
Args:
port (str): 设备端口,默认为"MOCK"表示模拟设备
"""
self.port = port
self._status: str = "Idle"
self._valve_status: str = "Closed" # 阀门位置Open, Closed
@property
def status(self) -> str:
"""设备状态 - 会被自动识别的设备属性"""
return self._status
@property
def valve_status(self) -> str:
"""阀门状态"""
return self._valve_status
def set_valve_status(self, status: str) -> str:
"""
设置阀门位置
Args:
position (str): 阀门位置,可选值:"Open", "Closed"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if status not in ["Open", "Closed"]:
self._status = "Error: Invalid position"
return "Error"
self._status = "Moving"
time.sleep(1) # 模拟阀门动作时间
self._valve_status = status
self._status = "Idle"
return "Success"
def open_valve(self) -> str:
"""打开阀门"""
return self.set_valve_status("Open")
def close_valve(self) -> str:
"""关闭阀门"""
return self.set_valve_status("Closed")
def get_valve_status(self) -> str:
"""获取阀门位置"""
return self._valve_status
def is_open(self) -> bool:
"""检查阀门是否打开"""
return self._valve_status == "Open"
def is_closed(self) -> bool:
"""检查阀门是否关闭"""
return self._valve_status == "Closed"
# 用于测试的主函数
if __name__ == "__main__":
valve = MockSolenoidValve()
print("启动电磁阀测试...")
print(f"初始状态: 位置={valve.valve_status}, 状态={valve.status}")
# 测试开启阀门
valve.open_valve()
print(f"开启后: 位置={valve.valve_status}, 状态={valve.status}")
# 测试关闭阀门
valve.close_valve()
print(f"关闭后: 位置={valve.valve_status}, 状态={valve.status}")
print("测试完成")

View File

@@ -0,0 +1,482 @@
import time
import threading
class MockStirrer:
"""
模拟搅拌器设备类
这个类模拟了一个实验室搅拌器的行为,包括搅拌速度控制、
温度监测、加热控制等功能。参考了现有的 HeaterStirrer_DaLong 实现。
"""
def __init__(self, port: str = "MOCK"):
"""
初始化MockStirrer实例
Args:
port (str): 设备端口,默认为"MOCK"表示模拟设备
"""
self.port = port
# 设备基本状态属性
self._status: str = "Idle" # 设备状态Idle, Running, Error, Stopped
self._power_state: str = "Off" # 电源状态On, Off
# 搅拌相关属性
self._stir_speed: float = 0.0 # 当前搅拌速度 (rpm)
self._target_stir_speed: float = 0.0 # 目标搅拌速度 (rpm)
self._max_stir_speed: float = 2000.0 # 最大搅拌速度 (rpm)
self._stir_state: str = "Stopped" # 搅拌状态Running, Stopped
# 温度相关属性
self._temperature: float = 25.0 # 当前温度 (°C)
self._target_temperature: float = 25.0 # 目标温度 (°C)
self._max_temperature: float = 300.0 # 最大温度 (°C)
self._heating_state: str = "Off" # 加热状态On, Off
self._heating_power: float = 0.0 # 加热功率百分比 0-100
# 运行控制线程
self._operation_thread = None
self._running = False
self._thread_lock = threading.Lock()
# ==================== 状态属性 ====================
# 这些属性会被Uni-Lab系统自动识别并定时对外广播
@property
def status(self) -> str:
"""
设备状态 - 会被自动识别的设备属性
Returns:
str: 当前设备状态 (Idle, Running, Error, Stopped)
"""
return self._status
@property
def power_state(self) -> str:
"""
电源状态
Returns:
str: 电源状态 (On, Off)
"""
return self._power_state
@property
def stir_speed(self) -> float:
"""
当前搅拌速度
Returns:
float: 当前搅拌速度 (rpm)
"""
return self._stir_speed
@property
def target_stir_speed(self) -> float:
"""
目标搅拌速度
Returns:
float: 目标搅拌速度 (rpm)
"""
return self._target_stir_speed
@property
def stir_state(self) -> str:
"""
搅拌状态
Returns:
str: 搅拌状态 (Running, Stopped)
"""
return self._stir_state
@property
def temperature(self) -> float:
"""
当前温度
Returns:
float: 当前温度 (°C)
"""
return self._temperature
@property
def target_temperature(self) -> float:
"""
目标温度
Returns:
float: 目标温度 (°C)
"""
return self._target_temperature
@property
def heating_state(self) -> str:
"""
加热状态
Returns:
str: 加热状态 (On, Off)
"""
return self._heating_state
@property
def heating_power(self) -> float:
"""
加热功率
Returns:
float: 加热功率百分比 (0-100)
"""
return self._heating_power
@property
def max_stir_speed(self) -> float:
"""
最大搅拌速度
Returns:
float: 最大搅拌速度 (rpm)
"""
return self._max_stir_speed
@property
def max_temperature(self) -> float:
"""
最大温度
Returns:
float: 最大温度 (°C)
"""
return self._max_temperature
# ==================== 设备控制方法 ====================
# 这些方法需要在注册表中添加会作为ActionServer接受控制指令
def power_control(self, power_state: str = "On") -> str:
"""
电源控制方法
Args:
power_state (str): 电源状态,可选值:"On", "Off"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if power_state not in ["On", "Off"]:
self._status = "Error: Invalid power state"
return "Error"
self._power_state = power_state
if power_state == "On":
self._status = "Power On"
self._start_operation()
else:
self._status = "Power Off"
self.stop_all_operations()
return "Success"
def set_stir_speed(self, speed: float) -> str:
"""
设置搅拌速度
Args:
speed (float): 目标搅拌速度 (rpm)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
speed = float(speed) # 确保传入的速度是浮点数
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if speed < 0 or speed > self._max_stir_speed:
self._status = f"Error: Speed out of range (0-{self._max_stir_speed})"
return "Error"
self._target_stir_speed = speed
self._status = "Setting Stir Speed"
# 如果设置了非零速度,启动搅拌
if speed > 0:
self._stir_state = "Running"
else:
self._stir_state = "Stopped"
return "Success"
def set_temperature(self, temperature: float) -> str:
"""
设置目标温度
Args:
temperature (float): 目标温度 (°C)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
temperature = float(temperature) # 确保传入的温度是浮点数
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if temperature < 0 or temperature > self._max_temperature:
self._status = f"Error: Temperature out of range (0-{self._max_temperature})"
return "Error"
self._target_temperature = temperature
self._status = "Setting Temperature"
return "Success"
def start_stirring(self) -> str:
"""
启动搅拌
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if self._target_stir_speed <= 0:
self._status = "Error: No target speed set"
return "Error"
self._stir_state = "Running"
self._status = "Stirring Started"
return "Success"
def stop_stirring(self) -> str:
"""
停止搅拌
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._stir_state = "Stopped"
self._target_stir_speed = 0.0
self._status = "Stirring Stopped"
return "Success"
def heating_control(self, heating_state: str = "On") -> str:
"""
加热控制
Args:
heating_state (str): 加热状态,可选值:"On", "Off"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if heating_state not in ["On", "Off"]:
self._status = "Error: Invalid heating state"
return "Error"
self._heating_state = heating_state
if heating_state == "On":
self._status = "Heating On"
else:
self._status = "Heating Off"
self._heating_power = 0.0
return "Success"
def stop_all_operations(self) -> str:
"""
停止所有操作
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._stir_state = "Stopped"
self._heating_state = "Off"
self._stop_operation()
self._stir_speed = 0.0
self._target_stir_speed = 0.0
self._heating_power = 0.0
self._status = "All operations stopped"
return "Success"
def emergency_stop(self) -> str:
"""
紧急停止
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._status = "Emergency Stop"
self.stop_all_operations()
return "Success"
# ==================== 内部控制方法 ====================
def _start_operation(self):
"""
启动操作线程
这个方法启动一个后台线程来模拟搅拌器的实际运行过程。
"""
with self._thread_lock:
if not self._running and self._power_state == "On":
self._running = True
self._operation_thread = threading.Thread(target=self._operation_loop)
self._operation_thread.daemon = True
self._operation_thread.start()
def _stop_operation(self):
"""
停止操作线程
安全地停止后台运行线程并等待其完成。
"""
with self._thread_lock:
self._running = False
if self._operation_thread and self._operation_thread.is_alive():
self._operation_thread.join(timeout=2.0)
def _operation_loop(self):
"""
操作主循环
这个方法在后台线程中运行,模拟真实搅拌器的工作过程:
1. 搅拌速度控制
2. 温度控制和加热
3. 状态更新
"""
while self._running and self._power_state == "On":
try:
# 处理搅拌速度控制
if self._stir_state == "Running":
speed_diff = self._target_stir_speed - self._stir_speed
if abs(speed_diff) < 1.0: # 速度接近目标值
self._stir_speed = self._target_stir_speed
if self._stir_speed > 0:
self._status = "Stirring at Target Speed"
else:
# 模拟速度调节每秒调整10%的差值
adjustment = speed_diff * 0.1
self._stir_speed += adjustment
self._status = "Adjusting Stir Speed"
# 确保速度在合理范围内
self._stir_speed = max(0.0, min(self._max_stir_speed, self._stir_speed))
else:
# 搅拌停止时速度逐渐降为0
if self._stir_speed > 0:
self._stir_speed = max(0.0, self._stir_speed - 50.0) # 每秒减少50rpm
# 处理温度控制
if self._heating_state == "On":
temp_diff = self._target_temperature - self._temperature
if abs(temp_diff) < 0.5: # 温度接近目标值
self._heating_power = 20.0 # 维持温度的最小功率
elif temp_diff > 0: # 需要加热
# 根据温差调整加热功率
if temp_diff > 50:
self._heating_power = 100.0
elif temp_diff > 20:
self._heating_power = 80.0
elif temp_diff > 10:
self._heating_power = 60.0
else:
self._heating_power = 40.0
# 模拟加热过程
heating_rate = self._heating_power / 100.0 * 1.5 # 最大每秒升温1.5度
self._temperature += heating_rate
else: # 目标温度低于当前温度
self._heating_power = 0.0
# 自然冷却
self._temperature -= 0.1
else:
self._heating_power = 0.0
# 自然冷却到室温
if self._temperature > 25.0:
self._temperature -= 0.2
# 限制温度范围
self._temperature = max(20.0, min(self._max_temperature, self._temperature))
# 更新整体状态
if self._stir_state == "Running" and self._heating_state == "On":
self._status = "Stirring and Heating"
elif self._stir_state == "Running":
self._status = "Stirring Only"
elif self._heating_state == "On":
self._status = "Heating Only"
else:
self._status = "Idle"
# 等待1秒后继续下一次循环
time.sleep(1.0)
except Exception as e:
self._status = f"Error in operation: {str(e)}"
break
# 循环结束时的清理工作
if self._power_state == "On":
self._status = "Idle"
def get_status_info(self) -> dict:
"""
获取完整的设备状态信息
Returns:
dict: 包含所有设备状态的字典
"""
return {
"status": self._status,
"power_state": self._power_state,
"stir_speed": self._stir_speed,
"target_stir_speed": self._target_stir_speed,
"stir_state": self._stir_state,
"temperature": self._temperature,
"target_temperature": self._target_temperature,
"heating_state": self._heating_state,
"heating_power": self._heating_power,
"max_stir_speed": self._max_stir_speed,
"max_temperature": self._max_temperature,
}
# 用于测试的主函数
if __name__ == "__main__":
stirrer = MockStirrer()
# 测试基本功能
print("启动搅拌器测试...")
stirrer.power_control("On")
print(f"初始状态: {stirrer.get_status_info()}")
# 设置搅拌速度和温度
stirrer.set_stir_speed(800.0)
stirrer.set_temperature(60.0)
stirrer.heating_control("On")
# 模拟运行15秒
for i in range(15):
time.sleep(1)
print(
f"{i+1}秒: 速度={stirrer.stir_speed:.0f}rpm, 温度={stirrer.temperature:.1f}°C, "
f"功率={stirrer.heating_power:.1f}%, 状态={stirrer.status}"
)
stirrer.emergency_stop()
print("测试完成")

View File

@@ -0,0 +1,410 @@
import time
import threading
class MockVacuum:
"""
模拟真空泵设备类
这个类模拟了一个实验室真空泵的行为,包括真空度控制、
压力监测、运行状态管理等功能。参考了现有的 VacuumPumpMock 实现。
"""
def __init__(self, port: str = "MOCK"):
"""
初始化MockVacuum实例
Args:
port (str): 设备端口,默认为"MOCK"表示模拟设备
"""
self.port = port
# 设备基本状态属性
self._status: str = "Idle" # 设备状态Idle, Running, Error, Stopped
self._power_state: str = "Off" # 电源状态On, Off
self._pump_state: str = "Stopped" # 泵运行状态Running, Stopped, Paused
# 真空相关属性
self._vacuum_level: float = 1013.25 # 当前真空度 (mbar) - 大气压开始
self._target_vacuum: float = 50.0 # 目标真空度 (mbar)
self._min_vacuum: float = 1.0 # 最小真空度 (mbar)
self._max_vacuum: float = 1013.25 # 最大真空度 (mbar) - 大气压
# 泵性能相关属性
self._pump_speed: float = 0.0 # 泵速 (L/s)
self._max_pump_speed: float = 100.0 # 最大泵速 (L/s)
self._pump_efficiency: float = 95.0 # 泵效率百分比
# 运行控制线程
self._vacuum_thread = None
self._running = False
self._thread_lock = threading.Lock()
# ==================== 状态属性 ====================
# 这些属性会被Uni-Lab系统自动识别并定时对外广播
@property
def status(self) -> str:
"""
设备状态 - 会被自动识别的设备属性
Returns:
str: 当前设备状态 (Idle, Running, Error, Stopped)
"""
return self._status
@property
def power_state(self) -> str:
"""
电源状态
Returns:
str: 电源状态 (On, Off)
"""
return self._power_state
@property
def pump_state(self) -> str:
"""
泵运行状态
Returns:
str: 泵状态 (Running, Stopped, Paused)
"""
return self._pump_state
@property
def vacuum_level(self) -> float:
"""
当前真空度
Returns:
float: 当前真空度 (mbar)
"""
return self._vacuum_level
@property
def target_vacuum(self) -> float:
"""
目标真空度
Returns:
float: 目标真空度 (mbar)
"""
return self._target_vacuum
@property
def pump_speed(self) -> float:
"""
泵速
Returns:
float: 泵速 (L/s)
"""
return self._pump_speed
@property
def pump_efficiency(self) -> float:
"""
泵效率
Returns:
float: 泵效率百分比
"""
return self._pump_efficiency
@property
def max_pump_speed(self) -> float:
"""
最大泵速
Returns:
float: 最大泵速 (L/s)
"""
return self._max_pump_speed
# ==================== 设备控制方法 ====================
# 这些方法需要在注册表中添加会作为ActionServer接受控制指令
def power_control(self, power_state: str = "On") -> str:
"""
电源控制方法
Args:
power_state (str): 电源状态,可选值:"On", "Off"
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if power_state not in ["On", "Off"]:
self._status = "Error: Invalid power state"
return "Error"
self._power_state = power_state
if power_state == "On":
self._status = "Power On"
self._start_vacuum_operation()
else:
self._status = "Power Off"
self.stop_vacuum()
return "Success"
def set_vacuum_level(self, vacuum_level: float) -> str:
"""
设置目标真空度
Args:
vacuum_level (float): 目标真空度 (mbar)
Returns:
str: 操作结果状态 ("Success", "Error")
"""
try:
vacuum_level = float(vacuum_level)
except ValueError:
self._status = "Error: Invalid vacuum level"
return "Error"
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
if vacuum_level < self._min_vacuum or vacuum_level > self._max_vacuum:
self._status = f"Error: Vacuum level out of range ({self._min_vacuum}-{self._max_vacuum})"
return "Error"
self._target_vacuum = vacuum_level
self._status = "Setting Vacuum Level"
return "Success"
def start_vacuum(self) -> str:
"""
启动真空泵
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
self._pump_state = "Running"
self._status = "Starting Vacuum Pump"
self._start_vacuum_operation()
return "Success"
def stop_vacuum(self) -> str:
"""
停止真空泵
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._pump_state = "Stopped"
self._status = "Stopping Vacuum Pump"
self._stop_vacuum_operation()
self._pump_speed = 0.0
return "Success"
def pause_vacuum(self) -> str:
"""
暂停真空泵
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._pump_state != "Running":
self._status = "Error: Pump not running"
return "Error"
self._pump_state = "Paused"
self._status = "Vacuum Pump Paused"
self._stop_vacuum_operation()
return "Success"
def resume_vacuum(self) -> str:
"""
恢复真空泵运行
Returns:
str: 操作结果状态 ("Success", "Error")
"""
if self._pump_state != "Paused":
self._status = "Error: Pump not paused"
return "Error"
if self._power_state != "On":
self._status = "Error: Power Off"
return "Error"
self._pump_state = "Running"
self._status = "Resuming Vacuum Pump"
self._start_vacuum_operation()
return "Success"
def vent_to_atmosphere(self) -> str:
"""
通大气 - 将真空度恢复到大气压
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._target_vacuum = self._max_vacuum # 设置为大气压
self._status = "Venting to Atmosphere"
return "Success"
def emergency_stop(self) -> str:
"""
紧急停止
Returns:
str: 操作结果状态 ("Success", "Error")
"""
self._status = "Emergency Stop"
self._pump_state = "Stopped"
self._stop_vacuum_operation()
self._pump_speed = 0.0
return "Success"
# ==================== 内部控制方法 ====================
def _start_vacuum_operation(self):
"""
启动真空操作线程
这个方法启动一个后台线程来模拟真空泵的实际运行过程。
"""
with self._thread_lock:
if not self._running and self._power_state == "On":
self._running = True
self._vacuum_thread = threading.Thread(target=self._vacuum_operation_loop)
self._vacuum_thread.daemon = True
self._vacuum_thread.start()
def _stop_vacuum_operation(self):
"""
停止真空操作线程
安全地停止后台运行线程并等待其完成。
"""
with self._thread_lock:
self._running = False
if self._vacuum_thread and self._vacuum_thread.is_alive():
self._vacuum_thread.join(timeout=2.0)
def _vacuum_operation_loop(self):
"""
真空操作主循环
这个方法在后台线程中运行,模拟真空泵的工作过程:
1. 检查电源状态和运行状态
2. 如果泵状态为 "Running",根据目标真空调整泵速和真空度
3. 否则等待
"""
while self._running and self._power_state == "On":
try:
with self._thread_lock:
# 只有泵状态为 Running 时才进行更新
if self._pump_state == "Running":
vacuum_diff = self._vacuum_level - self._target_vacuum
if abs(vacuum_diff) < 1.0: # 真空度接近目标值
self._status = "At Target Vacuum"
self._pump_speed = self._max_pump_speed * 0.2 # 维持真空的最小泵速
elif vacuum_diff > 0: # 需要抽真空(降低压力)
self._status = "Pumping Down"
if vacuum_diff > 500:
self._pump_speed = self._max_pump_speed
elif vacuum_diff > 100:
self._pump_speed = self._max_pump_speed * 0.8
elif vacuum_diff > 50:
self._pump_speed = self._max_pump_speed * 0.6
else:
self._pump_speed = self._max_pump_speed * 0.4
# 根据泵速和效率计算真空降幅
pump_rate = (self._pump_speed / self._max_pump_speed) * self._pump_efficiency / 100.0
vacuum_reduction = pump_rate * 10.0 # 每秒最大降低10 mbar
self._vacuum_level = max(self._target_vacuum, self._vacuum_level - vacuum_reduction)
else: # 目标真空度高于当前值,需要通气
self._status = "Venting"
self._pump_speed = 0.0
self._vacuum_level = min(self._target_vacuum, self._vacuum_level + 5.0)
# 限制真空度范围
self._vacuum_level = max(self._min_vacuum, min(self._max_vacuum, self._vacuum_level))
else:
# 当泵状态不是 Running 时,可保持原状态
self._status = "Vacuum Pump Not Running"
# 释放锁后等待1秒钟
time.sleep(1.0)
except Exception as e:
with self._thread_lock:
self._status = f"Error in vacuum operation: {str(e)}"
break
# 循环结束后的清理工作
if self._pump_state == "Running":
self._status = "Idle"
# 停止泵后,真空度逐渐回升到大气压
while self._vacuum_level < self._max_vacuum * 0.9:
with self._thread_lock:
self._vacuum_level += 2.0
time.sleep(0.1)
def get_status_info(self) -> dict:
"""
获取完整的设备状态信息
Returns:
dict: 包含所有设备状态的字典
"""
return {
"status": self._status,
"power_state": self._power_state,
"pump_state": self._pump_state,
"vacuum_level": self._vacuum_level,
"target_vacuum": self._target_vacuum,
"pump_speed": self._pump_speed,
"pump_efficiency": self._pump_efficiency,
"max_pump_speed": self._max_pump_speed,
}
# 用于测试的主函数
if __name__ == "__main__":
vacuum = MockVacuum()
# 测试基本功能
print("启动真空泵测试...")
vacuum.power_control("On")
print(f"初始状态: {vacuum.get_status_info()}")
# 设置目标真空度并启动
vacuum.set_vacuum_level(10.0) # 设置为10mbar
vacuum.start_vacuum()
# 模拟运行15秒
for i in range(15):
time.sleep(1)
print(
f"{i+1}秒: 真空度={vacuum.vacuum_level:.1f}mbar, 泵速={vacuum.pump_speed:.1f}L/s, 状态={vacuum.status}"
)
# 测试通大气
print("测试通大气...")
vacuum.vent_to_atmosphere()
# 继续运行5秒观察通大气过程
for i in range(5):
time.sleep(1)
print(f"通大气第{i+1}秒: 真空度={vacuum.vacuum_level:.1f}mbar, 状态={vacuum.status}")
vacuum.emergency_stop()
print("测试完成")

View File

@@ -1,11 +1,96 @@
liquid_handler: liquid_handler:
description: Liquid handler device controlled by pylabrobot description: Liquid handler device controlled by pylabrobot
class: class:
module: pylabrobot.liquid_handling:LiquidHandler module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract
type: python type: python
status_types: status_types:
name: String name: String
action_value_mappings: action_value_mappings:
remove:
type: LiquidHandlerRemove
goal:
vols: vols
sources: sources
waste_liquid: waste_liquid
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
delays: delays
is_96_well: is_96_well
top: top
none_keys: none_keys
feedback: { }
result: { }
add_liquid:
type: LiquidHandlerAdd
goal:
asp_vols: asp_vols
dis_vols: dis_vols
reagent_sources: reagent_sources
targets: targets
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_time: mix_time
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
none_keys: none_keys
feedback: { }
result: { }
transfer_liquid:
type: LiquidHandlerTransfer
goal:
asp_vols: asp_vols
dis_vols: dis_vols
sources: sources
targets: targets
tip_racks: tip_racks
use_channels: use_channels
asp_flow_rates: asp_flow_rates
dis_flow_rates: dis_flow_rates
offsets: offsets
touch_tip: touch_tip
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
delays: delays
none_keys: none_keys
feedback: { }
result: { }
mix:
type: LiquidHandlerMix
goal:
targets: targets
mix_time: mix_time
mix_vol: mix_vol
height_to_bottom: height_to_bottom
offsets: offsets
mix_rate: mix_rate
none_keys: none_keys
feedback: { }
result: { }
move_to:
type: LiquidHandlerMoveTo
goal:
well: well
dis_to_top: dis_to_top
channel: channel
feedback: { }
result: { }
aspirate: aspirate:
type: LiquidHandlerAspirate type: LiquidHandlerAspirate
goal: goal:
@@ -170,127 +255,6 @@ liquid_handler:
- name - name
additionalProperties: false additionalProperties: false
dp_liquid_handler:
description: 通用液体处理
class:
module: unilabos.devices.liquid_handling.action_definition:DPLiquidHandler
type: python
status_types:
status: String
action_value_mappings:
remove_liquid:
type: DPLiquidHandlerRemoveLiquid
goal:
vols: vols
sources: sources
waste_liquid: waste_liquid
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
delays: delays
is_96_well: is_96_well
top: top
none_keys: none_keys
feedback: {}
result: {}
add_liquid:
type: DPLiquidHandlerAddLiquid
goal:
asp_vols: asp_vols
dis_vols: dis_vols
reagent_sources: reagent_sources
targets: targets
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_time: mix_time
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
none_keys: none_keys
feedback: {}
result: {}
transfer_liquid:
type: DPLiquidHandlerTransferLiquid
goal:
asp_vols: asp_vols
dis_vols: dis_vols
sources: sources
targets: targets
tip_racks: tip_racks
use_channels: use_channels
asp_flow_rates: asp_flow_rates
dis_flow_rates: dis_flow_rates
offsets: offsets
touch_tip: touch_tip
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
delays: delays
none_keys: none_keys
feedback: {}
result: {}
custom_delay:
type: DPLiquidHandlerCustomDelay
goal:
seconds: seconds
msg: msg
feedback: {}
result: {}
touch_tip:
type: DPLiquidHandlerTouchTip
goal:
targets: targets
feedback: {}
result: {}
mix:
type: DPLiquidHandlerMix
goal:
targets: targets
mix_time: mix_time
mix_vol: mix_vol
height_to_bottom: height_to_bottom
offsets: offsets
mix_rate: mix_rate
none_keys: none_keys
feedback: {}
result: {}
set_tiprack:
type: DPLiquidHandlerSetTiprack
goal:
tip_racks: tip_racks
feedback: {}
result: {}
move_to:
type: DPLiquidHandlerMoveTo
goal:
well: well
dis_to_top: dis_to_top
channel: channel
feedback: {}
result: {}
schema:
type: object
properties:
name:
type: string
description: 物料名
required:
- name
liquid_handler.revvity: liquid_handler.revvity:
class: class:
module: unilabos.devices.liquid_handling.revvity:Revvity module: unilabos.devices.liquid_handling.revvity:Revvity

View File

@@ -0,0 +1,708 @@
mock_chiller:
description: Mock Chiller Device
class:
module: unilabos.devices.mock.mock_chiller:MockChiller
type: python
status_types:
current_temperature: Float64
target_temperature: Float64
status: String
power_on: Bool
is_cooling: Bool
is_heating: Bool
action_value_mappings:
set_temperature:
type: FloatSingleInput
goal:
float_in: temperature
feedback: {}
result:
success: success
power_on_off:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
emergency_stop:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
current_temperature:
type: number
description: Current temperature of the chiller in °C
target_temperature:
type: number
description: Target temperature setting in °C
status:
type: string
description: Current status of the device
power_on:
type: boolean
description: Power state of the device
is_cooling:
type: boolean
description: Whether the device is actively cooling
is_heating:
type: boolean
description: Whether the device is actively heating
required:
- current_temperature
- target_temperature
- status
- power_on
additionalProperties: false
mock_filter:
description: Mock Filter Device
class:
module: unilabos.devices.mock.mock_filter:MockFilter
type: python
status_types:
status: String
is_filtering: Bool
filter_efficiency: Float64
flow_rate: Float64
pressure_drop: Float64
filter_life: Float64
power_on: Bool
action_value_mappings:
start_filtering:
type: FloatSingleInput
goal:
float_in: flow_rate
feedback: {}
result:
success: success
stop_filtering:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
power_on_off:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
replace_filter:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the filter
is_filtering:
type: boolean
description: Whether the filter is actively filtering
filter_efficiency:
type: number
description: Filter efficiency percentage
flow_rate:
type: number
description: Current flow rate in L/min
pressure_drop:
type: number
description: Pressure drop across the filter in Pa
filter_life:
type: number
description: Remaining filter life percentage
power_on:
type: boolean
description: Power state of the device
required:
- status
- is_filtering
- filter_efficiency
- power_on
additionalProperties: false
mock_heater:
description: Mock Heater Device
class:
module: unilabos.devices.mock.mock_heater:MockHeater
type: python
status_types:
current_temperature: Float64
target_temperature: Float64
status: String
power_on: Bool
is_heating: Bool
heating_power: Float64
max_temperature: Float64
action_value_mappings:
set_temperature:
type: FloatSingleInput
goal:
float_in: temperature
feedback: {}
result:
success: success
set_heating_power:
type: FloatSingleInput
goal:
float_in: power
feedback: {}
result:
success: success
power_on_off:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
schema:
type: object
properties:
current_temperature:
type: number
description: Current temperature of the heater
target_temperature:
type: number
description: Target temperature setting
status:
type: string
description: Current status of the device
power_on:
type: boolean
description: Power state of the device
is_heating:
type: boolean
description: Whether the device is actively heating
heating_power:
type: number
description: Current heating power percentage
max_temperature:
type: number
description: Maximum heating temperature limit
required:
- current_temperature
- target_temperature
- status
- power_on
additionalProperties: false
mock_pump:
description: Mock Pump Device
class:
module: unilabos.devices.mock.mock_pump:MockPump
type: python
status_types:
status: String
power_state: String
pump_state: String
flow_rate: Float64
target_flow_rate: Float64
pressure: Float64
total_volume: Float64
direction: String
max_flow_rate: Float64
max_pressure: Float64
action_value_mappings:
power_control:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
set_flow_rate:
type: FloatSingleInput
goal:
float_in: flow_rate
feedback: {}
result:
success: success
start_pump:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
stop_pump:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
pause_pump:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
resume_pump:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
set_direction:
type: StrSingleInput
goal:
string: direction
feedback: {}
result:
success: success
reset_volume_counter:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the pump
power_state:
type: string
description: Power state (On/Off)
pump_state:
type: string
description: Pump operation state (Running/Stopped/Paused)
flow_rate:
type: number
description: Current flow rate in mL/min
target_flow_rate:
type: number
description: Target flow rate in mL/min
pressure:
type: number
description: Current pressure in bar
total_volume:
type: number
description: Total accumulated volume in mL
direction:
type: string
description: Pump direction (Forward/Reverse)
max_flow_rate:
type: number
description: Maximum flow rate in mL/min
max_pressure:
type: number
description: Maximum pressure in bar
required:
- status
- power_state
- pump_state
- flow_rate
additionalProperties: false
mock_rotavap:
description: Mock Rotavap Device
class:
module: unilabos.devices.mock.mock_rotavap:MockRotavap
type: python
status_types:
status: String
power_state: String
rotate_state: String
rotate_time: Float64
rotate_speed: Float64
pump_state: String
pump_time: Float64
vacuum_level: Float64
temperature: Float64
target_temperature: Float64
success: String
action_value_mappings:
set_timer:
type: StrSingleInput
goal:
string: command
feedback: {}
result:
success: success
power_control:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
set_rotate_time:
type: FloatSingleInput
goal:
float_in: time_seconds
feedback: {}
result:
success: success
set_pump_time:
type: FloatSingleInput
goal:
float_in: time_seconds
feedback: {}
result:
success: success
set_rotate_speed:
type: FloatSingleInput
goal:
float_in: speed
feedback: {}
result:
success: success
set_temperature:
type: FloatSingleInput
goal:
float_in: temperature
feedback: {}
result:
success: success
start_rotation:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
start_pump:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the rotavap
power_state:
type: string
description: Power state (On/Off)
rotate_state:
type: string
description: Rotation state (Running/Stopped)
rotate_time:
type: number
description: Remaining rotation time in seconds
rotate_speed:
type: number
description: Rotation speed in rpm
pump_state:
type: string
description: Pump state (Running/Stopped)
pump_time:
type: number
description: Remaining pump time in seconds
vacuum_level:
type: number
description: Current vacuum level in mbar
temperature:
type: number
description: Current water bath temperature
target_temperature:
type: number
description: Target water bath temperature
success:
type: string
description: Operation success status
required:
- status
- rotate_time
- pump_time
- temperature
additionalProperties: false
mock_separator:
description: Simplified Mock Separator Device
class:
module: unilabos.devices.mock.mock_separator:MockSeparator
type: python
status_types:
status: String
power_state: String
settling_time: Float64
valve_state: String
shake_time: Float64
shake_status: String
action_value_mappings:
shake:
type: FloatSingleInput
goal:
float_in: shake_time
feedback:
status: status
result:
success: success
power_control:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
stop_operations:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the separator
power_state:
type: string
description: Power state (On/Off)
settling_time:
type: number
description: Settling time in seconds
valve_state:
type: string
description: Valve state (Open/Closed)
shake_time:
type: number
description: Remaining shake time in seconds
shake_status:
type: string
description: Current shake state (e.g. Shaking, Settling, Idle)
required:
- status
- power_state
additionalProperties: false
mock_solenoid_valve:
description: Mock Solenoid Valve Device
class:
module: unilabos.devices.mock.mock_solenoid_valve:MockSolenoidValve
type: python
status_types:
status: String
valve_status: String
action_value_mappings:
set_valve_status:
type: StrSingleInput
goal:
string: status
feedback: {}
result:
success: success
open_valve:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
close_valve:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the valve
valve_status:
type: string
description: Valve status (Open/Closed)
required:
- status
- valve_status
additionalProperties: false
mock_stirrer:
description: Mock Stirrer Device
class:
module: unilabos.devices.mock.mock_stirrer:MockStirrer
type: python
status_types:
status: String
power_state: String
stir_speed: Float64
target_stir_speed: Float64
stir_state: String
temperature: Float64
target_temperature: Float64
heating_state: String
heating_power: Float64
max_stir_speed: Float64
max_temperature: Float64
action_value_mappings:
power_control:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
set_stir_speed:
type: FloatSingleInput
goal:
float_in: speed
feedback: {}
result:
success: success
set_temperature:
type: FloatSingleInput
goal:
float_in: temperature
feedback: {}
result:
success: success
start_stirring:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
stop_stirring:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
heating_control:
type: StrSingleInput
goal:
string: heating_state
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the stirrer
power_state:
type: string
description: Power state (On/Off)
stir_speed:
type: number
description: Current stirring speed in rpm
target_stir_speed:
type: number
description: Target stirring speed in rpm
stir_state:
type: string
description: Stirring state (Running/Stopped)
temperature:
type: number
description: Current temperature in °C
target_temperature:
type: number
description: Target temperature in °C
heating_state:
type: string
description: Heating state (On/Off)
heating_power:
type: number
description: Current heating power percentage
max_stir_speed:
type: number
description: Maximum stirring speed in rpm
max_temperature:
type: number
description: Maximum temperature in °C
required:
- status
- stir_speed
- temperature
- power_state
additionalProperties: false
mock_vacuum:
description: Mock Vacuum Pump Device
class:
module: unilabos.devices.mock.mock_vacuum:MockVacuum
type: python
status_types:
status: String
power_state: String
pump_state: String
vacuum_level: Float64
target_vacuum: Float64
pump_speed: Float64
pump_efficiency: Float64
max_pump_speed: Float64
action_value_mappings:
power_control:
type: StrSingleInput
goal:
string: power_state
feedback: {}
result:
success: success
set_vacuum_level:
type: FloatSingleInput
goal:
float_in: vacuum_level
feedback: {}
result:
success: success
start_vacuum:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
stop_vacuum:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
pause_vacuum:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
resume_vacuum:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
vent_to_atmosphere:
type: EmptyIn
goal: {}
feedback: {}
result:
success: success
schema:
type: object
properties:
status:
type: string
description: Current status of the vacuum pump
power_state:
type: string
description: Power state (On/Off)
pump_state:
type: string
description: Pump operation state (Running/Stopped/Paused)
vacuum_level:
type: number
description: Current vacuum level in mbar
target_vacuum:
type: number
description: Target vacuum level in mbar
pump_speed:
type: number
description: Current pump speed in L/s
pump_efficiency:
type: number
description: Pump efficiency percentage
max_pump_speed:
type: number
description: Maximum pump speed in L/s
required:
- status
- power_state
- pump_state
- vacuum_level
additionalProperties: false

View File

@@ -12,7 +12,7 @@ separator.homemade:
goal: goal:
stir_time: stir_time, stir_time: stir_time,
stir_speed: stir_speed stir_speed: stir_speed
settling_time": settling_time settling_time: settling_time
feedback: feedback:
status: status status: status
result: result:

View File

@@ -62,4 +62,4 @@ tempsensor:
command: command command: command
feedback: {} feedback: {}
result: result:
success: success success: success

View File

@@ -3,6 +3,25 @@ vacuum_pump.mock:
class: class:
module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock
type: python type: python
status_types:
status: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
set_status:
type: StrSingleInput
goal:
string: string
feedback: {}
result: {}
gas_source.mock: gas_source.mock:
description: Mock gas source description: Mock gas source

View File

@@ -1,5 +1,4 @@
import io import io
import json
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -7,10 +6,9 @@ from typing import Any
import yaml import yaml
from unilabos.utils import logger
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema
from unilabos.utils import logger
from unilabos.utils.decorator import singleton from unilabos.utils.decorator import singleton
from unilabos.utils.type_check import TypeEncoder
DEFAULT_PATHS = [Path(__file__).absolute().parent] DEFAULT_PATHS = [Path(__file__).absolute().parent]
@@ -21,43 +19,16 @@ class Registry:
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值 self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
if registry_paths: if registry_paths:
self.registry_paths.extend(registry_paths) self.registry_paths.extend(registry_paths)
action_type = self._replace_type_with_class( self.ResourceCreateFromOuter = self._replace_type_with_class(
"ResourceCreateFromOuter", "host_node", f"动作 add_resource_from_outer" "ResourceCreateFromOuter", "host_node", f"动作 create_resource_detailed"
) )
schema = ros_action_to_json_schema(action_type) self.ResourceCreateFromOuterEasy = self._replace_type_with_class(
self.device_type_registry = { "ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
"host_node": { )
"description": "UniLabOS主机节点", self.EmptyIn = self._replace_type_with_class(
"class": { "EmptyIn", "host_node", f""
"module": "unilabos.ros.nodes.presets.host_node", )
"type": "python", self.device_type_registry = {}
"status_types": {},
"action_value_mappings": {
"add_resource_from_outer": {
"type": msg_converter_manager.search_class("ResourceCreateFromOuter"),
"goal": {
"resources": "resources",
"device_ids": "device_ids",
"bind_parent_ids": "bind_parent_ids",
"bind_locations": "bind_locations",
"other_calling_params": "other_calling_params",
},
"feedback": {},
"result": {
"success": "success"
},
"schema": schema
}
}
},
"schema": {
"properties": {},
"additionalProperties": False,
"type": "object"
},
"file_path": "/"
}
}
self.resource_type_registry = {} self.resource_type_registry = {}
self._setup_called = False # 跟踪setup是否已调用 self._setup_called = False # 跟踪setup是否已调用
# 其他状态变量 # 其他状态变量
@@ -69,9 +40,70 @@ class Registry:
logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用") logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用")
return return
# 标记setup已被调用 from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
self._setup_called = True
self.device_type_registry.update(
{
"host_node": {
"description": "UniLabOS主机节点",
"class": {
"module": "unilabos.ros.nodes.presets.host_node",
"type": "python",
"status_types": {},
"action_value_mappings": {
"create_resource_detailed": {
"type": self.ResourceCreateFromOuter,
"goal": {
"resources": "resources",
"device_ids": "device_ids",
"bind_parent_ids": "bind_parent_ids",
"bind_locations": "bind_locations",
"other_calling_params": "other_calling_params",
},
"feedback": {},
"result": {"success": "success"},
"schema": ros_action_to_json_schema(self.ResourceCreateFromOuter),
"goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuter.Goal))
),
},
"create_resource": {
"type": self.ResourceCreateFromOuterEasy,
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck",
},
"feedback": {},
"result": {"success": "success"},
"schema": ros_action_to_json_schema(self.ResourceCreateFromOuterEasy),
"goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuterEasy.Goal))
),
},
"test_latency": {
"type": self.EmptyIn,
"goal": {},
"feedback": {},
"result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"},
"schema": ros_action_to_json_schema(self.EmptyIn),
"goal_default": {},
},
},
},
"icon": "icon_device.webp",
"registry_type": "device",
"schema": {"properties": {}, "additionalProperties": False, "type": "object"},
"file_path": "/",
}
}
)
logger.debug(f"[UniLab Registry] ----------Setup----------") logger.debug(f"[UniLab Registry] ----------Setup----------")
self.registry_paths = [Path(path).absolute() for path in self.registry_paths] self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths): for i, path in enumerate(self.registry_paths):
@@ -81,6 +113,8 @@ class Registry:
self.load_device_types(path) self.load_device_types(path)
self.load_resource_types(path) self.load_resource_types(path)
logger.info("[UniLab Registry] 注册表设置完成") logger.info("[UniLab Registry] 注册表设置完成")
# 标记setup已被调用
self._setup_called = True
def load_resource_types(self, path: os.PathLike): def load_resource_types(self, path: os.PathLike):
abs_path = Path(path).absolute() abs_path = Path(path).absolute()
@@ -96,6 +130,9 @@ class Registry:
resource_info["file_path"] = str(file.absolute()).replace("\\", "/") resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
if "description" not in resource_info: if "description" not in resource_info:
resource_info["description"] = "" resource_info["description"] = ""
if "icon" not in resource_info:
resource_info["icon"] = ""
resource_info["registry_type"] = "resource"
self.resource_type_registry.update(data) self.resource_type_registry.update(data)
logger.debug( logger.debug(
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} " f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} "
@@ -145,6 +182,7 @@ class Registry:
) )
current_device_number = len(self.device_type_registry) + 1 current_device_number = len(self.device_type_registry) + 1
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
for i, file in enumerate(files): for i, file in enumerate(files):
data = yaml.safe_load(open(file, encoding="utf-8")) data = yaml.safe_load(open(file, encoding="utf-8"))
if data: if data:
@@ -154,6 +192,9 @@ class Registry:
device_config["file_path"] = str(file.absolute()).replace("\\", "/") device_config["file_path"] = str(file.absolute()).replace("\\", "/")
if "description" not in device_config: if "description" not in device_config:
device_config["description"] = "" device_config["description"] = ""
if "icon" not in device_config:
device_config["icon"] = ""
device_config["registry_type"] = "device"
if "class" in device_config: if "class" in device_config:
# 处理状态类型 # 处理状态类型
if "status_types" in device_config["class"]: if "status_types" in device_config["class"]:
@@ -169,8 +210,15 @@ class Registry:
action_config["type"] = self._replace_type_with_class( action_config["type"] = self._replace_type_with_class(
action_config["type"], device_id, f"动作 {action_name}" action_config["type"], device_id, f"动作 {action_name}"
) )
action_config["goal_default"] = yaml.safe_load(io.StringIO(get_yaml_from_goal_type(action_config["type"].Goal))) if action_config["type"] is not None:
action_config["schema"] = ros_action_to_json_schema(action_config["type"]) action_config["goal_default"] = yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(action_config["type"].Goal))
)
action_config["schema"] = ros_action_to_json_schema(action_config["type"])
else:
logger.warning(
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
)
self.device_type_registry.update(data) self.device_type_registry.update(data)
@@ -188,13 +236,17 @@ class Registry:
def obtain_registry_device_info(self): def obtain_registry_device_info(self):
devices = [] devices = []
for device_id, device_info in self.device_type_registry.items(): for device_id, device_info in self.device_type_registry.items():
msg = { msg = {"id": device_id, **device_info}
"id": device_id,
**device_info
}
devices.append(msg) devices.append(msg)
return devices return devices
def obtain_registry_resource_info(self):
resources = []
for resource_id, resource_info in self.resource_type_registry.items():
msg = {"id": resource_id, **resource_info}
resources.append(msg)
return resources
# 全局单例实例 # 全局单例实例
lab_registry = Registry() lab_registry = Registry()

View File

@@ -1,35 +1,35 @@
agilent_1_reservoir_290ml: agilent_1_reservoir_290ml:
description: Agilent 1 reservoir 290ml description: Agilent 1 reservoir 290ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:agilent_1_reservoir_290ml module: pylabrobot.resources.opentrons.reservoirs:agilent_1_reservoir_290ml
type: pylabrobot type: pylabrobot
axygen_1_reservoir_90ml: axygen_1_reservoir_90ml:
description: Axygen 1 reservoir 90ml description: Axygen 1 reservoir 90ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:axygen_1_reservoir_90ml module: pylabrobot.resources.opentrons.reservoirs:axygen_1_reservoir_90ml
type: pylabrobot type: pylabrobot
nest_12_reservoir_15ml: nest_12_reservoir_15ml:
description: Nest 12 reservoir 15ml description: Nest 12 reservoir 15ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_12_reservoir_15ml module: pylabrobot.resources.opentrons.reservoirs:nest_12_reservoir_15ml
type: pylabrobot type: pylabrobot
nest_1_reservoir_195ml: nest_1_reservoir_195ml:
description: Nest 1 reservoir 195ml description: Nest 1 reservoir 195ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_195ml module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_195ml
type: pylabrobot type: pylabrobot
nest_1_reservoir_290ml: nest_1_reservoir_290ml:
description: Nest 1 reservoir 290ml description: Nest 1 reservoir 290ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_290ml module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_290ml
type: pylabrobot type: pylabrobot
usascientific_12_reservoir_22ml: usascientific_12_reservoir_22ml:
description: USAScientific 12 reservoir 22ml description: USAScientific 12 reservoir 22ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:usascientific_12_reservoir_22ml module: pylabrobot.resources.opentrons.reservoirs:usascientific_12_reservoir_22ml
type: pylabrobot type: pylabrobot

View File

@@ -189,6 +189,7 @@ def dict_from_graph(graph: nx.Graph) -> dict:
def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]: def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# 将节点转换为字典,以便通过 ID 快速查找 # 将节点转换为字典,以便通过 ID 快速查找
nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only] nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only]
id_list = [node["id"] for node in nodes_list]
# 初始化每个节点的 children 为包含节点字典的列表 # 初始化每个节点的 children 为包含节点字典的列表
for node in nodes_list: for node in nodes_list:
@@ -196,7 +197,7 @@ def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# 找到根节点并返回 # 找到根节点并返回
root_nodes = [ root_nodes = [
node for node in nodes_list if len(nodes_list) == 1 or node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] node for node in nodes_list if len(nodes_list) == 1 or node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] or node.get("parent", node.get("parent_name")) not in id_list
] ]
# 如果存在多个根节点,返回所有根节点 # 如果存在多个根节点,返回所有根节点
@@ -421,7 +422,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None):
return r return r
def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]: def initialize_resource(resource_config: dict) -> list[dict]:
"""Initializes a resource based on its configuration. """Initializes a resource based on its configuration.
If the config is detailed, then do nothing; If the config is detailed, then do nothing;
@@ -433,6 +434,7 @@ def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]
Returns: Returns:
None None
""" """
from unilabos.registry.registry import lab_registry
resource_class_config = resource_config.get("class", None) resource_class_config = resource_config.get("class", None)
if resource_class_config is None: if resource_class_config is None:
return [resource_config] return [resource_config]
@@ -476,11 +478,8 @@ def initialize_resources(resources_config) -> list[dict]:
None None
""" """
from unilabos.registry.registry import lab_registry
resources = [] resources = []
for resource_config in resources_config: for resource_config in resources_config:
if resource_config["parent"] == "tip_rack" or resource_config["parent"] == "plate_well": resources.extend(initialize_resource(resource_config))
continue
resources.extend(initialize_resource(resource_config, lab_registry))
return resources return resources

View File

@@ -348,10 +348,16 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any:
if isinstance(td, NamespacedType): if isinstance(td, NamespacedType):
target_class = msg_converter_manager.get_class(f"{'.'.join(td.namespaces)}.{td.name}") target_class = msg_converter_manager.get_class(f"{'.'.join(td.namespaces)}.{td.name}")
setattr(ros_msg, key, [convert_to_ros_msg(target_class, v) for v in value]) setattr(ros_msg, key, [convert_to_ros_msg(target_class, v) for v in value])
elif isinstance(td, UnboundedString):
setattr(ros_msg, key, value)
else: else:
logger.warning(f"Not Supported type: {td}")
setattr(ros_msg, key, []) # FIXME setattr(ros_msg, key, []) # FIXME
elif "array.array" in str(type(attr)): elif "array.array" in str(type(attr)):
setattr(ros_msg, key, value) if attr.typecode == "f":
setattr(ros_msg, key, [float(i) for i in value])
else:
setattr(ros_msg, key, value)
else: else:
nested_ros_msg = convert_to_ros_msg(type(attr)(), value) nested_ros_msg = convert_to_ros_msg(type(attr)(), value)
setattr(ros_msg, key, nested_ros_msg) setattr(ros_msg, key, nested_ros_msg)
@@ -574,6 +580,7 @@ basic_type_map = {
'int64': {'type': 'integer'}, 'int64': {'type': 'integer'},
'uint64': {'type': 'integer', 'minimum': 0}, 'uint64': {'type': 'integer', 'minimum': 0},
'double': {'type': 'number'}, 'double': {'type': 'number'},
'float': {'type': 'number'},
'float32': {'type': 'number'}, 'float32': {'type': 'number'},
'float64': {'type': 'number'}, 'float64': {'type': 'number'},
'string': {'type': 'string'}, 'string': {'type': 'string'},

View File

@@ -1,3 +1,5 @@
import copy
import functools
import json import json
import threading import threading
import time import time
@@ -19,7 +21,7 @@ from unilabos_msgs.action import SendCmd
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \ from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \
initialize_resources initialize_resources, list_to_nested_dict, dict_to_tree, resource_plr_to_ulab, tree_to_list
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg, convert_to_ros_msg,
convert_from_ros_msg, convert_from_ros_msg,
@@ -311,7 +313,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 物料传输到对应的node节点 # 物料传输到对应的node节点
rclient = self.create_client(ResourceAdd, "/resources/add") rclient = self.create_client(ResourceAdd, "/resources/add")
rclient.wait_for_service() rclient.wait_for_service()
rclient2 = self.create_client(ResourceAdd, "/resources/add")
rclient2.wait_for_service()
request = ResourceAdd.Request() request = ResourceAdd.Request()
request2 = ResourceAdd.Request()
command_json = json.loads(req.command) command_json = json.loads(req.command)
namespace = command_json["namespace"] namespace = command_json["namespace"]
bind_parent_id = command_json["bind_parent_id"] bind_parent_id = command_json["bind_parent_id"]
@@ -320,11 +325,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
other_calling_param = command_json["other_calling_param"] other_calling_param = command_json["other_calling_param"]
resources = command_json["resource"] resources = command_json["resource"]
initialize_full = other_calling_param.pop("initialize_full", False) initialize_full = other_calling_param.pop("initialize_full", False)
# 用来增加液体
ADD_LIQUID_TYPE = other_calling_param.pop("ADD_LIQUID_TYPE", [])
LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", [])
LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
slot = other_calling_param.pop("slot", -1)
if slot >= 0: # slot为负数的时候采用assign方法
other_calling_param["slot"] = slot
# 本地拿到这个物料,可能需要先做初始化? # 本地拿到这个物料,可能需要先做初始化?
if isinstance(resources, list): if isinstance(resources, list):
if initialize_full: if len(resources) == 1 and isinstance(resources[0], list) and not initialize_full: # 取消,不存在的情况
# 预先initialize过以整组的形式传入
request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]]
elif initialize_full:
resources = initialize_resources(resources) resources = initialize_resources(resources)
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
else:
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
else: else:
if initialize_full: if initialize_full:
resources = initialize_resources([resources]) resources = initialize_resources([resources])
@@ -334,20 +351,31 @@ class BaseROS2DeviceNode(Node, Generic[T]):
res.response = "OK" res.response = "OK"
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中 # 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
request.resources = [convert_to_ros_msg(Resource, resources)] # request.resources = [convert_to_ros_msg(Resource, resources)]
try: try:
from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.resource import Resource as ResourcePLR
from pylabrobot.resources.deck import Deck from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import OTDeck from pylabrobot.resources import OTDeck
from pylabrobot.resources import Plate
contain_model = not isinstance(resource, Deck) contain_model = not isinstance(resource, Deck)
if isinstance(resource, ResourcePLR): if isinstance(resource, ResourcePLR):
# resources.list() # resources.list()
plr_instance = resource_ulab_to_plr(resources, contain_model) resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources}))
plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model)
if isinstance(plr_instance, Plate):
empty_liquid_info_in = [(None, 0)] * plr_instance.num_items
for liquid_type, liquid_volume, liquid_input_slot in zip(ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
if isinstance(resource, OTDeck) and "slot" in other_calling_param: if isinstance(resource, OTDeck) and "slot" in other_calling_param:
resource.assign_child_at_slot(plr_instance, **other_calling_param) resource.assign_child_at_slot(plr_instance, **other_calling_param)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param) else:
_discard_slot = other_calling_param.pop("slot", -1)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param)
request2.resources = [convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)])]
rclient2.call(request2)
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group
@@ -404,6 +432,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 加入全局注册表 # 加入全局注册表
registered_devices[self.device_id] = device_info registered_devices[self.device_id] = device_info
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.ros.nodes.presets.host_node import HostNode
if not BasicConfig.is_host_mode: if not BasicConfig.is_host_mode:
sclient = self.create_client(SerialCommand, "/node_info_update") sclient = self.create_client(SerialCommand, "/node_info_update")
# 启动线程执行发送任务 # 启动线程执行发送任务
@@ -413,6 +442,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_send_slave_node_info" name=f"ROSDevice{self.device_id}_send_slave_node_info"
).start() ).start()
else:
host_node = HostNode.get_instance(0)
if host_node is not None:
host_node.device_machine_names[self.device_id] = "本地"
def send_slave_node_info(self, sclient): def send_slave_node_info(self, sclient):
sclient.wait_for_service() sclient.wait_for_service()
@@ -481,6 +514,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}") self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}")
def get_real_function(self, instance, attr_name):
if hasattr(instance.__class__, attr_name):
obj = getattr(instance.__class__, attr_name)
if isinstance(obj, property):
return lambda *args, **kwargs: obj.fset(instance, *args, **kwargs), get_type_hints(obj.fset)
obj = getattr(instance, attr_name)
return obj, get_type_hints(obj)
else:
obj = getattr(instance, attr_name)
return obj, get_type_hints(obj)
def _create_execute_callback(self, action_name, action_value_mapping): def _create_execute_callback(self, action_name, action_value_mapping):
"""创建动作执行回调函数""" """创建动作执行回调函数"""
@@ -495,22 +539,21 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for i, action in enumerate(self._action_value_mappings["sequence"]): for i, action in enumerate(self._action_value_mappings["sequence"]):
if i == 0: if i == 0:
self.lab_logger().info(f"执行序列动作第一步: {action}") self.lab_logger().info(f"执行序列动作第一步: {action}")
getattr(self.driver_instance, action)(**kwargs) self.get_real_function(self.driver_instance, action)[0](**kwargs)
else: else:
self.lab_logger().info(f"执行序列动作后续步骤: {action}") self.lab_logger().info(f"执行序列动作后续步骤: {action}")
getattr(self.driver_instance, action)() self.get_real_function(self.driver_instance, action)[0]()
action_paramtypes = get_type_hints( action_paramtypes = get_type_hints(
getattr(self.driver_instance, self._action_value_mappings["sequence"][0]) self.get_real_function(self.driver_instance, self._action_value_mappings["sequence"][0])
) )[1]
else: else:
ACTION = getattr(self.driver_instance, action_name) ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
action_paramtypes = get_type_hints(ACTION)
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
self.lab_logger().debug(f"接收到原始目标: {action_kwargs}") self.lab_logger().debug(f"接收到原始目标: {action_kwargs}")
# 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过 # 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过
if action_name != "add_resource_from_outer": if action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}") self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
@@ -609,7 +652,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
del future del future
# 向Host更新物料当前状态 # 向Host更新物料当前状态
if action_name != "add_resource_from_outer": if action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue continue
@@ -748,7 +791,7 @@ class ROS2DeviceNode:
self.resource_tracker = DeviceNodeResourceTracker() self.resource_tracker = DeviceNodeResourceTracker()
# use_pylabrobot_creator 使用 cls的包路径检测 # use_pylabrobot_creator 使用 cls的包路径检测
use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "DPLiquidHandler" use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "LiquidHandlerAbstract"
# TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建 # TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建
# 创建设备类实例 # 创建设备类实例

View File

@@ -12,11 +12,18 @@ from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service from rclpy.service import Service
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \ from unilabos_msgs.srv import (
SerialCommand # type: ignore ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
) # type: ignore
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.graphio import initialize_resource
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
@@ -86,6 +93,7 @@ class HostNode(BaseROS2DeviceNode):
self.__class__._instance = self self.__class__._instance = self
# 初始化配置 # 初始化配置
self.server_latest_timestamp = 0.0 #
self.devices_config = devices_config self.devices_config = devices_config
self.resources_config = resources_config self.resources_config = resources_config
self.physical_setup_graph = physical_setup_graph self.physical_setup_graph = physical_setup_graph
@@ -99,9 +107,32 @@ class HostNode(BaseROS2DeviceNode):
# 创建设备、动作客户端和目标存储 # 创建设备、动作客户端和目标存储
self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射 self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射 self.device_machine_names: Dict[str, str] = {
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 device_id: "本地",
self._action_value_mappings: Dict[str, Dict] = {} # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系 } # 存储设备ID到机器名称的映射
self._action_clients: Dict[str, ActionClient] = { # 为了方便了解实际的数据类型host的默认写好
"/devices/host_node/create_resource": ActionClient(
self,
lab_registry.ResourceCreateFromOuterEasy,
"/devices/host_node/create_resource",
callback_group=self.callback_group,
),
"/devices/host_node/create_resource_detailed": ActionClient(
self,
lab_registry.ResourceCreateFromOuter,
"/devices/host_node/create_resource_detailed",
callback_group=self.callback_group,
),
"/devices/host_node/test_latency": ActionClient(
self,
lab_registry.EmptyIn,
"/devices/host_node/test_latency",
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = (
{}
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
self._last_discovery_time = 0.0 # 上次设备发现的时间 self._last_discovery_time = 0.0 # 上次设备发现的时间
@@ -115,8 +146,11 @@ class HostNode(BaseROS2DeviceNode):
self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 self.device_status_timestamps = {} # 用来存储设备状态最后更新时间
from unilabos.app.mq import mqtt_client from unilabos.app.mq import mqtt_client
for device_config in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_config["id"], device_config) for device_info in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_info["id"], device_info)
for resource_info in lab_registry.obtain_registry_resource_info():
mqtt_client.publish_registry(resource_info["id"], resource_info)
# 首次发现网络中的设备 # 首次发现网络中的设备
self._discover_devices() self._discover_devices()
@@ -141,12 +175,36 @@ class HostNode(BaseROS2DeviceNode):
].items(): ].items():
controller_config["update_rate"] = update_rate controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config) self.initialize_controller(controller_id, controller_config)
resources_config.insert(
0,
{
"id": "host_node",
"name": "host_node",
"parent": None,
"type": "device",
"class": "host_node",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {},
"children": [],
},
)
resource_with_parent_name = []
resource_ids_to_instance = {i["id"]: i for i in resources_config}
for res in resources_config:
if res.get("parent") and res.get("type") == "device" and res.get("class"):
parent_id = res.get("parent")
parent_res = resource_ids_to_instance[parent_id]
if parent_res.get("type") == "device" and parent_res.get("class"):
resource_with_parent_name.append(copy.deepcopy(res))
resource_with_parent_name[-1]["id"] = f"{parent_res['id']}/{res['id']}"
continue
resource_with_parent_name.append(copy.deepcopy(res))
try: try:
for bridge in self.bridges: for bridge in self.bridges:
if hasattr(bridge, "resource_add"): if hasattr(bridge, "resource_add"):
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.") self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.")
bridge.resource_add(add_schema(resources_config)) resource_add_res = bridge.resource_add(add_schema(resource_with_parent_name))
except Exception as ex: except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!") self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc()) self.lab_logger().error(traceback.format_exc())
@@ -156,6 +214,10 @@ class HostNode(BaseROS2DeviceNode):
discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup() discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup()
) )
# 添加ping-pong相关属性
self._ping_responses = {} # 存储ping响应
self._ping_lock = threading.Lock()
self.lab_logger().info("[Host Node] Host node initialized.") self.lab_logger().info("[Host Node] Host node initialized.")
HostNode._ready_event.set() HostNode._ready_event.set()
@@ -191,7 +253,7 @@ class HostNode(BaseROS2DeviceNode):
# 如果是新设备记录并创建ActionClient # 如果是新设备记录并创建ActionClient
if edge_device_id not in self.devices_names: if edge_device_id not in self.devices_names:
self.lab_logger().info(f"[Host Node] Discovered new device: {device_key}") self.lab_logger().info(f"[Host Node] Discovered new device: {edge_device_id}")
self.devices_names[edge_device_id] = namespace self.devices_names[edge_device_id] = namespace
self._create_action_clients_for_device(device_id, namespace) self._create_action_clients_for_device(device_id, namespace)
self._online_devices.add(device_key) self._online_devices.add(device_key)
@@ -200,7 +262,7 @@ class HostNode(BaseROS2DeviceNode):
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
).start() ).start()
elif device_key not in self._online_devices: elif device_key not in self._online_devices:
# 设备重新上线 # 设备重新上线
@@ -211,7 +273,7 @@ class HostNode(BaseROS2DeviceNode):
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
).start() ).start()
# 检测离线设备 # 检测离线设备
@@ -255,7 +317,7 @@ class HostNode(BaseROS2DeviceNode):
self, action_type, action_id, callback_group=self.callback_group self, action_type, action_id, callback_group=self.callback_group
) )
self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}") self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}")
action_name = action_id[len(namespace) + 1:] action_name = action_id[len(namespace) + 1 :]
edge_device_id = namespace[9:] edge_device_id = namespace[9:]
# from unilabos.app.mq import mqtt_client # from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type) # info_with_schema = ros_action_to_json_schema(action_type)
@@ -268,30 +330,84 @@ class HostNode(BaseROS2DeviceNode):
except Exception as e: except Exception as e:
self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}")
def add_resource_from_outer(self, resources: list["Resource"], device_ids: list[str], bind_parent_ids: list[str], bind_locations: list[Point], other_calling_params: list[str]): def create_resource_detailed(
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(resources, device_ids, bind_parent_ids, bind_locations, other_calling_params): self,
resources: list["Resource"],
device_ids: list[str],
bind_parent_ids: list[str],
bind_locations: list[Point],
other_calling_params: list[str],
):
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(
resources, device_ids, bind_parent_ids, bind_locations, other_calling_params
):
# 这里要求device_id传入必须是edge_device_id # 这里要求device_id传入必须是edge_device_id
namespace = "/devices/" + device_id namespace = "/devices/" + device_id
srv_address = f"/srv{namespace}/append_resource" srv_address = f"/srv{namespace}/append_resource"
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
sclient.wait_for_service() sclient.wait_for_service()
request = SerialCommand.Request() request = SerialCommand.Request()
request.command = json.dumps({ request.command = json.dumps(
"resource": resource, {
"namespace": namespace, "resource": resource, # 单个/单组 可为 list[list[Resource]]
"edge_device_id": device_id, "namespace": namespace,
"bind_parent_id": bind_parent_id, "edge_device_id": device_id,
"bind_location": { "bind_parent_id": bind_parent_id,
"x": bind_location.x, "bind_location": {
"y": bind_location.y, "x": bind_location.x,
"z": bind_location.z, "y": bind_location.y,
"z": bind_location.z,
},
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {},
}, },
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {}, ensure_ascii=False,
}, ensure_ascii=False) )
response = sclient.call(request) response = sclient.call(request)
pass pass
pass pass
def create_resource(
self,
device_id: str,
res_id: str,
class_name: str,
parent: str,
bind_locations: Point,
liquid_input_slot: list[int],
liquid_type: list[str],
liquid_volume: list[int],
slot_on_deck: int,
):
init_new_res = initialize_resource(
{
"name": res_id,
"class": class_name,
"parent": parent,
"position": {
"x": bind_locations.x,
"y": bind_locations.y,
"z": bind_locations.z,
},
}
) # flatten的格式
resources = init_new_res # initialize_resource已经返回list[dict]
device_ids = [device_id]
bind_parent_id = [parent]
bind_location = [bind_locations]
other_calling_param = [
json.dumps(
{
"ADD_LIQUID_TYPE": liquid_type,
"LIQUID_VOLUME": liquid_volume,
"LIQUID_INPUT_SLOT": liquid_input_slot,
"initialize_full": False,
"slot": slot_on_deck,
}
)
]
return self.create_resource_detailed(resources, device_ids, bind_parent_id, bind_location, other_calling_param)
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None: def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
""" """
根据配置初始化设备, 根据配置初始化设备,
@@ -319,7 +435,9 @@ class HostNode(BaseROS2DeviceNode):
if action_id not in self._action_clients: if action_id not in self._action_clients:
action_type = action_value_mapping["type"] action_type = action_value_mapping["type"]
self._action_clients[action_id] = ActionClient(self, action_type, action_id) self._action_clients[action_id] = ActionClient(self, action_type, action_id)
self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的 self.lab_logger().debug(
f"[Host Node] Created ActionClient (Local): {action_id}"
) # 子设备再创建用的是Discover发现的
# from unilabos.app.mq import mqtt_client # from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type) # info_with_schema = ros_action_to_json_schema(action_type)
# mqtt_client.publish_actions(action_name, { # mqtt_client.publish_actions(action_name, {
@@ -419,7 +537,12 @@ class HostNode(BaseROS2DeviceNode):
) )
def send_goal( def send_goal(
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any], goal_uuid: Optional[str] = None self,
device_id: str,
action_name: str,
action_kwargs: Dict[str, Any],
goal_uuid: Optional[str] = None,
server_info: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
""" """
向设备发送目标请求 向设备发送目标请求
@@ -431,6 +554,8 @@ class HostNode(BaseROS2DeviceNode):
goal_uuid: 目标UUID如果为None则自动生成 goal_uuid: 目标UUID如果为None则自动生成
""" """
action_id = f"/devices/{device_id}/{action_name}" action_id = f"/devices/{device_id}/{action_name}"
if action_name == "test_latency" and server_info is not None:
self.server_latest_timestamp = server_info.get("send_timestamp", 0.0)
if action_id not in self._action_clients: if action_id not in self._action_clients:
self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.") self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.")
return return
@@ -725,3 +850,148 @@ class HostNode(BaseROS2DeviceNode):
# 这里可以实现返回资源列表的逻辑 # 这里可以实现返回资源列表的逻辑
self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}") self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}")
return response return response
def test_latency(self):
"""
测试网络延迟的action实现
通过5次ping-pong机制校对时间误差并计算实际延迟
"""
import time
import uuid as uuid_module
self.lab_logger().info("=" * 60)
self.lab_logger().info("开始网络延迟测试...")
# 记录任务开始执行的时间
task_start_time = time.time()
# 进行5次ping-pong测试
ping_results = []
for i in range(5):
self.lab_logger().info(f"{i+1}/5次ping-pong测试...")
# 生成唯一的ping ID
ping_id = str(uuid_module.uuid4())
# 记录发送时间
send_timestamp = time.time()
# 发送ping
from unilabos.app.mq import mqtt_client
mqtt_client.send_ping(ping_id, send_timestamp)
# 等待pong响应
timeout = 10.0
start_wait_time = time.time()
while time.time() - start_wait_time < timeout:
with self._ping_lock:
if ping_id in self._ping_responses:
pong_data = self._ping_responses.pop(ping_id)
break
time.sleep(0.001)
else:
self.lab_logger().error(f"❌ 第{i+1}次测试超时")
continue
# 计算本次测试结果
receive_timestamp = time.time()
client_timestamp = pong_data["client_timestamp"]
server_timestamp = pong_data["server_timestamp"]
# 往返时间
rtt_ms = (receive_timestamp - send_timestamp) * 1000
# 客户端与服务端时间差(客户端时间 - 服务端时间)
# 假设网络延迟对称,取中间点的服务端时间
mid_point_time = send_timestamp + (receive_timestamp - send_timestamp) / 2
time_diff_ms = (mid_point_time - server_timestamp) * 1000
ping_results.append({"rtt_ms": rtt_ms, "time_diff_ms": time_diff_ms})
self.lab_logger().info(f"✅ 第{i+1}次: 往返时间={rtt_ms:.2f}ms, 时间差={time_diff_ms:.2f}ms")
time.sleep(0.1)
if not ping_results:
self.lab_logger().error("❌ 所有ping-pong测试都失败了")
return {"status": "all_timeout"}
# 统计分析
rtts = [r["rtt_ms"] for r in ping_results]
time_diffs = [r["time_diff_ms"] for r in ping_results]
avg_rtt_ms = sum(rtts) / len(rtts)
avg_time_diff_ms = sum(time_diffs) / len(time_diffs)
max_time_diff_error_ms = max(abs(min(time_diffs)), abs(max(time_diffs)))
self.lab_logger().info("-" * 50)
self.lab_logger().info("[测试统计]")
self.lab_logger().info(f"有效测试次数: {len(ping_results)}/5")
self.lab_logger().info(f"平均往返时间: {avg_rtt_ms:.2f}ms")
self.lab_logger().info(f"平均时间差: {avg_time_diff_ms:.2f}ms")
self.lab_logger().info(f"时间差范围: {min(time_diffs):.2f}ms ~ {max(time_diffs):.2f}ms")
self.lab_logger().info(f"最大时间误差: ±{max_time_diff_error_ms:.2f}ms")
# 计算任务执行延迟
if hasattr(self, "server_latest_timestamp") and self.server_latest_timestamp > 0:
self.lab_logger().info("-" * 50)
self.lab_logger().info("[任务执行延迟分析]")
self.lab_logger().info(f"服务端任务下发时间: {self.server_latest_timestamp:.6f}")
self.lab_logger().info(f"客户端任务开始时间: {task_start_time:.6f}")
# 原始时间差(不考虑时间同步误差)
raw_delay_ms = (task_start_time - self.server_latest_timestamp) * 1000
# 考虑时间同步误差后的延迟(用平均时间差校正)
corrected_delay_ms = raw_delay_ms - avg_time_diff_ms
self.lab_logger().info(f"📊 原始时间差: {raw_delay_ms:.2f}ms")
self.lab_logger().info(f"🔧 时间同步校正: {avg_time_diff_ms:.2f}ms")
self.lab_logger().info(f"⏰ 实际任务延迟: {corrected_delay_ms:.2f}ms")
self.lab_logger().info(f"📏 误差范围: ±{max_time_diff_error_ms:.2f}ms")
# 给出延迟范围
min_delay = corrected_delay_ms - max_time_diff_error_ms
max_delay = corrected_delay_ms + max_time_diff_error_ms
self.lab_logger().info(f"📋 延迟范围: {min_delay:.2f}ms ~ {max_delay:.2f}ms")
else:
self.lab_logger().warning("⚠️ 无法获取服务端任务下发时间,跳过任务延迟分析")
corrected_delay_ms = -1
self.lab_logger().info("=" * 60)
return {
"avg_rtt_ms": avg_rtt_ms,
"avg_time_diff_ms": avg_time_diff_ms,
"max_time_error_ms": max_time_diff_error_ms,
"task_delay_ms": corrected_delay_ms if corrected_delay_ms > 0 else -1,
"raw_delay_ms": (
raw_delay_ms if hasattr(self, "server_latest_timestamp") and self.server_latest_timestamp > 0 else -1
),
"test_count": len(ping_results),
"status": "success",
}
def handle_pong_response(self, pong_data: dict):
"""
处理pong响应
"""
ping_id = pong_data.get("ping_id")
if ping_id:
with self._ping_lock:
self._ping_responses[ping_id] = pong_data
# 详细信息合并为一条日志
client_timestamp = pong_data.get("client_timestamp", 0)
server_timestamp = pong_data.get("server_timestamp", 0)
current_time = time.time()
self.lab_logger().debug(
f"📨 Pong | ID:{ping_id[:8]}.. | C→S→C: {client_timestamp:.3f}{server_timestamp:.3f}{current_time:.3f}"
)
else:
self.lab_logger().warning("⚠️ 收到无效的Pong响应缺少ping_id")

View File

@@ -13,6 +13,7 @@ endif()
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
add_compile_options(-include cstdint)
endif() endif()
# find dependencies # find dependencies
@@ -43,14 +44,10 @@ set(action_files
"action/LiquidHandlerStamp.action" "action/LiquidHandlerStamp.action"
"action/LiquidHandlerTransfer.action" "action/LiquidHandlerTransfer.action"
"action/DPLiquidHandlerAddLiquid.action" "action/LiquidHandlerAdd.action"
"action/DPLiquidHandlerCustomDelay.action" "action/LiquidHandlerMix.action"
"action/DPLiquidHandlerMix.action" "action/LiquidHandlerMoveTo.action"
"action/DPLiquidHandlerMoveTo.action" "action/LiquidHandlerRemove.action"
"action/DPLiquidHandlerRemoveLiquid.action"
"action/DPLiquidHandlerSetTiprack.action"
"action/DPLiquidHandlerTouchTip.action"
"action/DPLiquidHandlerTransferLiquid.action"
"action/EmptyIn.action" "action/EmptyIn.action"
"action/FloatSingleInput.action" "action/FloatSingleInput.action"
@@ -59,9 +56,10 @@ set(action_files
"action/Point3DSeparateInput.action" "action/Point3DSeparateInput.action"
"action/ResourceCreateFromOuter.action" "action/ResourceCreateFromOuter.action"
"action/ResourceCreateFromOuterEasy.action"
"action/SolidDispenseAddPowderTube.action" "action/SolidDispenseAddPowderTube.action"
"action/PumpTransfer.action" "action/PumpTransfer.action"
"action/Clean.action" "action/Clean.action"
"action/Separate.action" "action/Separate.action"

View File

@@ -1,6 +0,0 @@
float64 seconds
string msg
---
bool success
---
# 反馈

View File

@@ -1,5 +0,0 @@
Resource[] tip_racks
---
bool success
---
# 反馈

View File

@@ -1,5 +0,0 @@
Resource[] targets
---
bool success
---
# 反馈

View File

@@ -1,25 +0,0 @@
float64[] asp_vols
float64[] dis_vols
Resource[] sources
Resource[] targets
Resource[] tip_racks
int32[] use_channels
float64[] asp_flow_rates
float64[] dis_flow_rates
geometry_msgs/Point[] offsets
bool touch_tip
float64[] liquid_height
float64[] blow_out_air_volume
string spread
bool is_96_well
string mix_stage
int32[] mix_times
int32 mix_vol
int32 mix_rate
float64 mix_liquid_height
int32[] delays
string[] none_keys
---
bool success
---
# 反馈

View File

@@ -5,7 +5,7 @@ float64[] flow_rates
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
float64[] liquid_height float64[] liquid_height
float64[] blow_out_air_volume float64[] blow_out_air_volume
string spread="wide" string spread
--- ---
bool success bool success
--- ---

View File

@@ -5,7 +5,7 @@ int32[] use_channels
float64[] flow_rates float64[] flow_rates
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
int32[] blow_out_air_volume int32[] blow_out_air_volume
string spread="wide" string spread
--- ---
# 结果字段 # 结果字段
bool success bool success

View File

@@ -1,11 +1,25 @@
# Bio float64[] asp_vols
Resource source float64[] dis_vols
Resource[] sources
Resource[] targets Resource[] targets
float64 source_vol Resource[] tip_racks
float64[] ratios int32[] use_channels
float64[] target_vols float64[] asp_flow_rates
float64 aspiration_flow_rate float64[] dis_flow_rates
float64[] dispense_flow_rates geometry_msgs/Point[] offsets
bool touch_tip
float64[] liquid_height
float64[] blow_out_air_volume
string spread
bool is_96_well
string mix_stage
int32[] mix_times
int32 mix_vol
int32 mix_rate
float64 mix_liquid_height
int32[] delays
string[] none_keys
--- ---
bool success bool success
--- ---
# 反馈

View File

@@ -0,0 +1,12 @@
string res_id
string device_id
string class_name
string parent
geometry_msgs/Point bind_locations
int32[] liquid_input_slot
string[] liquid_type
float32[] liquid_volume
int32 slot_on_deck
---
bool success
---