Initial commit
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
234
.gitignore
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
.idea
|
||||
.vscode
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
|
||||
## ROS
|
||||
|
||||
devel/
|
||||
logs/
|
||||
log/
|
||||
build/
|
||||
bin/
|
||||
lib/
|
||||
install/
|
||||
*/devel/
|
||||
*/logs/
|
||||
*/log/
|
||||
*/build/
|
||||
*/bin/
|
||||
*/lib/
|
||||
*/install/
|
||||
msg_gen/
|
||||
srv_gen/
|
||||
msg/*Action.msg
|
||||
msg/*ActionFeedback.msg
|
||||
msg/*ActionGoal.msg
|
||||
msg/*ActionResult.msg
|
||||
msg/*Feedback.msg
|
||||
msg/*Goal.msg
|
||||
msg/*Result.msg
|
||||
msg/_*.py
|
||||
build_isolated/
|
||||
devel_isolated/
|
||||
|
||||
# Generated by dynamic reconfigure
|
||||
*.cfgc
|
||||
/cfg/cpp/
|
||||
/cfg/*.py
|
||||
|
||||
# Ignore generated docs
|
||||
*.dox
|
||||
*.wikidoc
|
||||
|
||||
# eclipse stuff
|
||||
.project
|
||||
.cproject
|
||||
|
||||
# qcreator stuff
|
||||
CMakeLists.txt.user
|
||||
|
||||
srv/_*.py
|
||||
*.pcd
|
||||
*.pyc
|
||||
qtcreator-*
|
||||
*.user
|
||||
|
||||
/planning/cfg
|
||||
/planning/docs
|
||||
/planning/src
|
||||
|
||||
*~
|
||||
|
||||
# Emacs
|
||||
.#*
|
||||
|
||||
# Catkin custom files
|
||||
CATKIN_IGNORE
|
||||
|
||||
.DS_Store
|
||||
|
||||
local_config.py
|
||||
|
||||
*.graphml
|
||||
20
docs/Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
10
docs/boot_examples/index.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 启动样例
|
||||
|
||||
本章节提供了几个典型的启动样例,帮助您快速了解和使用系统。每个样例都包含了详细的配置说明、文件解读以及操作步骤,便于您参考和实践。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 1
|
||||
|
||||
liquid_handler.md
|
||||
organic_synthesis.md
|
||||
```
|
||||
100
docs/boot_examples/liquid_handler.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 移液站样例
|
||||
|
||||
本样例介绍如何配置和启动移液站设备,并执行基本操作如插入枪头等。
|
||||
|
||||
## 准备工作
|
||||
|
||||
### 设备配置文件
|
||||
|
||||
移液站设备的完整配置可在 `test/experiments/plr_test.json` 文件中找到。该配置文件采用平展结构,通过 `type` 字段区分物料和设备,并通过 `parent` 和 `children` 字段实现层级关系。
|
||||
|
||||
配置文件示例片段:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PLR_STATION",
|
||||
"name": "PLR_LH_TEST",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
"deck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deck",
|
||||
"name": "deck",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"parent": "PLR_STATION",
|
||||
"children": [
|
||||
"trash",
|
||||
"trash_core96",
|
||||
"teaching_carrier",
|
||||
"tip_rack",
|
||||
"plate"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
配置文件定义了移液站的组成部分,主要包括:
|
||||
- 移液站本体(LiquidHandler)- 设备类型
|
||||
- 移液站携带物料实例(deck)- 物料类型
|
||||
|
||||
## 启动方法
|
||||
|
||||
### 1. 启动移液站节点
|
||||
|
||||
使用以下命令启动移液站设备:
|
||||
|
||||
```bash
|
||||
unilab -g test/experiments/plr_test.json --app_bridges ""
|
||||
```
|
||||
|
||||
### 2. 执行枪头插入操作
|
||||
|
||||
启动后,您可以使用以下命令执行插入枪头操作:
|
||||
|
||||
```bash
|
||||
ros2 action send_goal /devices/PLR_STATION/pick_up_tips unilabos_msgs/action/_liquid_handler_pick_up_tips/LiquidHandlerPickUpTips "{ tip_spots: [ { id: 'tip_rack_tipspot_0_0', name: 'tip_rack_tipspot_0_0', sample_id: null, children: [], parent: 'tip_rack', type: 'device', config: { position: { x: 7.2, y: 68.3, z: -83.5 }, size_x: 9.0, size_y: 9.0, size_z: 0, rotation: { x: 0, y: 0, z: 0, type: 'Rotation' }, category: 'tip_spot', model: null, type: 'TipSpot', prototype_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } }, data: { tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' }, tip_state: { liquids: [], pending_liquids: [], liquid_history: [] }, pending_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } } } ], use_channels: [ 0 ], offsets: [ { x: 0.0, y: 0.0, z: 0.0 } ] }"
|
||||
```
|
||||
|
||||
此命令会通过ros通信触发移液站执行枪头插入操作,得到如下的PyLabRobot的输出日志。
|
||||
|
||||
```log
|
||||
Picking up tips:
|
||||
pip# resource offset tip type max volume (µL) fitting depth (mm) tip length (mm) filter
|
||||
p0: tip_rack_tipspot_0_0 0.0,0.0,0.0 HamiltonTip 1065 8 95.1 Yes
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
1. **重复插入枪头不成功**:操作编排应该符合实际操作顺序,可自行通过PyLabRobot进行测试
|
||||
|
||||
## 移液站支持的操作
|
||||
|
||||
移液站支持多种操作,以下是当前系统支持的操作列表:
|
||||
|
||||
1. **LiquidHandlerAspirate** - 吸液操作
|
||||
2. **LiquidHandlerDispense** - 排液操作
|
||||
3. **LiquidHandlerDiscardTips** - 丢弃枪头
|
||||
4. **LiquidHandlerDropTips** - 卸下枪头
|
||||
5. **LiquidHandlerDropTips96** - 卸下96通道枪头
|
||||
6. **LiquidHandlerMoveLid** - 移动盖子
|
||||
7. **LiquidHandlerMovePlate** - 移动板子
|
||||
8. **LiquidHandlerMoveResource** - 移动资源
|
||||
9. **LiquidHandlerPickUpTips** - 插入枪头
|
||||
10. **LiquidHandlerPickUpTips96** - 插入96通道枪头
|
||||
11. **LiquidHandlerReturnTips** - 归还枪头
|
||||
12. **LiquidHandlerReturnTips96** - 归还96通道枪头
|
||||
13. **LiquidHandlerStamp** - 打印标记
|
||||
14. **LiquidHandlerTransfer** - 液体转移
|
||||
|
||||
这些操作可通过ROS2 Action接口进行调用,以实现复杂的移液流程。
|
||||
105
docs/boot_examples/organic_synthesis.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 有机常量合成样例
|
||||
|
||||
本样例演示如何配置和操作有机常量合成工作站,实现抽真空和充气等基本操作。
|
||||
|
||||
## 准备工作
|
||||
|
||||
### 设备配置文件
|
||||
|
||||
有机常量合成工作站的完整配置可在 `test/experiments/mock_reactor.json` 文件中找到。该配置文件采用平展结构,通过 `type` 字段区分物料和设备,并通过 `parent` 和 `children` 字段实现层级关系。
|
||||
|
||||
配置文件示例片段:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ReactorX",
|
||||
"children": [
|
||||
"reactor",
|
||||
"vacuum_valve",
|
||||
"gas_valve",
|
||||
"vacuum_pump",
|
||||
"gas_source"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation"
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"parent": "ReactorX",
|
||||
"type": "container"
|
||||
},
|
||||
{
|
||||
"id": "vacuum_valve",
|
||||
"parent": "ReactorX",
|
||||
"type": "device"
|
||||
},
|
||||
{
|
||||
"id": "gas_valve",
|
||||
"parent": "ReactorX",
|
||||
"type": "device"
|
||||
},
|
||||
{
|
||||
"id": "vacuum_pump",
|
||||
"parent": "ReactorX",
|
||||
"type": "device"
|
||||
},
|
||||
{
|
||||
"id": "gas_source",
|
||||
"parent": "ReactorX",
|
||||
"type": "device"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "reactor",
|
||||
"target": "vacuum_valve"
|
||||
},
|
||||
{
|
||||
"source": "reactor",
|
||||
"target": "gas_valve"
|
||||
},
|
||||
{
|
||||
"source": "vacuum_pump",
|
||||
"target": "vacuum_valve"
|
||||
},
|
||||
{
|
||||
"source": "gas_source",
|
||||
"target": "gas_valve"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
配置文件定义了反应系统的组成部分,主要包括:
|
||||
|
||||
1. **反应工作站 (ReactorX)** - 整个系统的父节点,包含所有子组件
|
||||
2. **反应器 (reactor)** - 实际进行反应的容器
|
||||
3. **真空阀 (vacuum_valve)** - 连接反应器和真空泵
|
||||
4. **气体阀 (gas_valve)** - 连接反应器和气源
|
||||
5. **真空泵 (vacuum_pump)** - 用于抽真空
|
||||
6. **气源 (gas_source)** - 提供充气
|
||||
|
||||
这些组件通过链接关系形成一个完整的气路系统,可以实现抽真空和充气的功能。
|
||||
|
||||
## 启动方法
|
||||
|
||||
### 1. 启动反应器节点
|
||||
|
||||
使用以下命令启动模拟反应器:
|
||||
|
||||
```bash
|
||||
unilab -g test/experiments/mock_reactor.json --app_bridges ""
|
||||
```
|
||||
|
||||
### 2. 执行抽真空和充气操作
|
||||
|
||||
启动后,您可以使用以下命令执行抽真空操作:
|
||||
|
||||
```bash
|
||||
ros2 action send_goal /devices/ReactorX/EvacuateAndRefillProtocol unilabos_msgs/action/EvacuateAndRefill "{vessel: reactor, gas: N2, repeats: 2}"
|
||||
```
|
||||
|
||||
此命令会通过ros通信触发工作站执行抽真空和充气的协议操作,与此同时,您可以通过 http://localhost:8002/status 在`主机节点信息`-`设备状态`查看该操作对设备开关的实时效果。
|
||||
45
docs/concepts/01-communication-instruction.md
Normal file
@@ -0,0 +1,45 @@
|
||||
(instructions)=
|
||||
# 设备抽象、指令集与通信中间件
|
||||
|
||||
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
|
||||
## 设备间通信模式
|
||||
|
||||
### **[话题(一对多发送,一对多订阅)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.4_%E8%AF%9D%E9%A2%98/)**
|
||||
|
||||
典型的话题通信有:
|
||||
|
||||
* 传感器连续发送设备状态和数据;
|
||||
* 连续时间控制器发送控制指令,如控温、连续称量、机械臂轨迹跟随、视觉识别操作等
|
||||
|
||||

|
||||
|
||||
### **[服务(短时请求与响应)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.5_%E6%9C%8D%E5%8A%A1/)**
|
||||
|
||||
典型的服务通信有:
|
||||
|
||||
* 查/改全局参数如物料、设备
|
||||
* 使用其他通信接口发送/接收数据
|
||||
|
||||

|
||||
|
||||
### **[动作(长时任务启动,随后连续收到反馈值,直到达到目标)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.7_%E5%8A%A8%E4%BD%9C/)**
|
||||
|
||||
动作机制主要用于处理运行时长较长的单点任务或任务组合,如:
|
||||
|
||||
* 执行工作流
|
||||
* 执行工作流的子动作
|
||||
|
||||

|
||||
|
||||
## 通信指令集
|
||||
|
||||
Uni-Lab 目前使用 ROS2 作为通信中间件,因此大量使用其标准消息作为话题、服务、动作。新增指令位于仓库中的 `unilabos_msgs` ,各类实验动作指令集分类整理于 {ref}`actions`
|
||||
|
||||
## 通信中间件层
|
||||
|
||||
通信中间件层的一个重要设计思想是:将业务逻辑开发,与实际部署中的通信和运行解耦。开发者在实现具体业务逻辑时,可以不用关心最终运行时的 **部署方式** 、 **通信方式** 。当用户开发完成后,再根据实际情况决定部署、通信方案。
|
||||
|
||||
* 对于 **“流动化学实验室”和“桌面机器人”** 来说,一台电脑通过串口控制所有设备足够。**此时在这台电脑启动 Uni-Lab 作为 Server 即可。**
|
||||
* 对于 **“移动机器人”大型实验室** ,典型场景是,一个实验室由多台不同位置的工作站组成,每台大型设备有一台工控机,通过串口再控制子设备。同时有 AGV/机械臂 负责转运。**此时,在每台工控机启动 Uni-Lab,完成通信中间件层的包装之后,只要处于同一局域网下,他们将能自动互相发现并组成分布式的“Uni-Lab-Edge Server”。**
|
||||
* 通信中间件层的分布式机制,使得 Node 之间做好了隔离,一台设备故障时只需重启单个 Node。很像微服务、微内核的设计理念。
|
||||
79
docs/concepts/02-topology-and-chemputer-compile.md
Normal file
@@ -0,0 +1,79 @@
|
||||
(graph)=
|
||||
# 实验室组态图
|
||||
|
||||
组态(configuration)图是指在自动化领域中,用来描述和展示控制系统中各个组件之间关系的图形化表示方法。
|
||||
它是一个系统的框架图,通过图形符号和连接线,将各个组件(如传感器、执行器、控制器等)以及它们之间的关系进行可视化展示。
|
||||
|
||||
Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包含4类重要信息:
|
||||
|
||||
* 单个设备/物料配置,即图中节点的参数;
|
||||
* 父子关系,如一台工作站包含它的多个子设备、放置着多个物料耗材;
|
||||
* 物理连接关系,如流体管路连接、AGV/机械臂/直线模组转运连接。
|
||||
* 通信转接关系,如多个 IO 设备通过 IO 板卡或 PLC 转为 Modbus;串口转网口等
|
||||
* 控制逻辑关系,如某个输出量被某个输入量 PID 控制
|
||||
|
||||
## 父子关系、物质流与"编译"操作
|
||||
|
||||
在计算机操作系统下,软件操作数据和文件。在实验操作系统下,实验“软件”利用仪器“硬件”操作物质。实验人员能理解的操作,最终都是对物质的处理。将实验步骤,转化为硬件指令,这个操作我们可以类比为“编译”。
|
||||
|
||||
对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。**
|
||||
|
||||
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。
|
||||
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
|
||||
|
||||

|
||||
|
||||
接收“移液”动作,编译为一系列泵指令和阀指令
|
||||
|
||||
```text
|
||||
Goal received: {
|
||||
'from_vessel': 'flask_acetone',
|
||||
'to_vessel': 'reactor',
|
||||
'volume': 2000.0,
|
||||
'flowrate': 100.0
|
||||
}, running steps:
|
||||
```
|
||||
|
||||
```JSON
|
||||
[
|
||||
{
|
||||
"device_id": "pump_reagents",
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "3"}
|
||||
},
|
||||
{
|
||||
"device_id": "pump_reagents",
|
||||
"action_name": "set_position",
|
||||
"action_kwargs": {
|
||||
"position": 2000.0,
|
||||
"max_velocity": 100.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": "pump_reagents",
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": '5'}
|
||||
},
|
||||
{
|
||||
"device_id": "pump_reagents",
|
||||
"action_name": "set_position",
|
||||
"action_kwargs": {
|
||||
"position": 0.0,
|
||||
"max_velocity": 100.0
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
若想开发新的“编译”/“规划”功能,在 `unilabos/compilers` 实现一个新函数即可。详情请见 [添加新实验操作(Protocol)](../developer_guide/add_protocol.md)
|
||||
|
||||
## 通信转接关系
|
||||
|
||||
Uni-Lab 秉持着**通信逻辑**与**业务逻辑**分离的设计理念,以追求实验设备、通信设备最大的代码复用性。
|
||||
|
||||
如对 IO 板卡8路 IO 控制的8个电磁阀,经过设备抽象层后,仅有逻辑意义上分立的8个电磁阀。在组态图中,他们通过代表通信关系的边(edge)与 IO 板卡设备相连,边的属性包含 IO 地址。
|
||||
在实际使用中,对抽象的电磁阀发送开关动作请求,将通过 Uni-Lab 设备抽象&通信中间件转化为对 IO 板卡发送 IO 读写指令。因此一定意义上,组态图的通信转接部分代表了简化的电气接线图。
|
||||
|
||||
代码架构上,实验设备位于 Uni-Lab 仓库/注册表的 `devices` 目录,通信设备位于 Uni-Lab 仓库/注册表的 `device_comms` 目录。
|
||||
|
||||

|
||||
BIN
docs/concepts/image/01-communication-instruction/action.gif
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 304 KiB |
BIN
docs/concepts/image/01-communication-instruction/service.gif
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
docs/concepts/image/01-communication-instruction/topic.gif
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 586 KiB |
BIN
docs/concepts/image/overview/Uni-Lab-layers.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
docs/concepts/image/overview/Uni-Lab-whiteboard.png
Normal file
|
After Width: | Height: | Size: 612 KiB |
3
docs/concepts/overview.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Uni-Lab 操作系统总览
|
||||
|
||||

|
||||
205
docs/conf.py
Normal file
@@ -0,0 +1,205 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
# Sphinx 文档生成器的配置文件
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 将项目的根目录添加到 sys.path 中,以便 Sphinx 能够找到 unilabos 包
|
||||
sys.path.insert(0, os.path.abspath(".."))
|
||||
|
||||
project = "Uni-Lab"
|
||||
copyright = "2025, Uni-Lab Community, DP Technology & Peking University"
|
||||
author = "Uni-Lab Community, DP Technology & Peking University"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = [
|
||||
"myst_parser",
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||
"sphinx_rtd_theme"
|
||||
]
|
||||
|
||||
source_suffix = {
|
||||
".rst": "restructuredtext",
|
||||
".txt": "markdown",
|
||||
".md": "markdown",
|
||||
}
|
||||
|
||||
myst_enable_extensions = [
|
||||
"colon_fence",
|
||||
"deflist",
|
||||
"dollarmath",
|
||||
"html_image",
|
||||
"replacements",
|
||||
"smartquotes",
|
||||
"substitution",
|
||||
]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
language = "zh"
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
# 设置 HTML 主题为 sphinx-book-theme
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# sphinx-book-theme 主题选项
|
||||
html_theme_options = {
|
||||
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||
"use_repository_button": True,
|
||||
"use_issues_button": True,
|
||||
"use_edit_page_button": True,
|
||||
"use_download_button": True,
|
||||
"path_to_docs": "docs",
|
||||
"show_navbar_depth": 2,
|
||||
"show_toc_level": 2,
|
||||
"home_page_in_toc": True,
|
||||
"logo_only": False,
|
||||
}
|
||||
|
||||
# 设置 HTML 文档的静态文件路径
|
||||
html_static_path = ["_static"] # 如果有自定义 CSS,可以放在 _static 目录中
|
||||
|
||||
section_titles = {
|
||||
"Simple": "## 简单单变量动作函数",
|
||||
"Organic": """## 常量有机化学操作
|
||||
|
||||
Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。
|
||||
""",
|
||||
"Bio": """## 移液工作站及相关生物自动化设备操作
|
||||
|
||||
Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。
|
||||
""",
|
||||
"MobileRobot": "## 多工作站及小车运行、物料转移",
|
||||
"Robot": """## 机械臂、夹爪等机器人设备
|
||||
|
||||
Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`:
|
||||
""",
|
||||
}
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_conda_share_dir(package_name=None):
|
||||
"""获取 Conda 环境的 share 目录路径
|
||||
|
||||
:param package_name: 可选参数,指定具体包的 share 子目录
|
||||
:return: Path 对象或 None
|
||||
"""
|
||||
# 获取当前 Conda 环境根目录
|
||||
conda_prefix = os.getenv("CONDA_PREFIX")
|
||||
if not conda_prefix:
|
||||
raise EnvironmentError("未检测到激活的 Conda 环境")
|
||||
|
||||
# 构建基础 share 目录路径
|
||||
share_dir = Path(conda_prefix) / "share"
|
||||
|
||||
# 如果指定了包名,追加包子目录
|
||||
if package_name:
|
||||
share_dir = share_dir / package_name
|
||||
|
||||
# 验证路径是否存在
|
||||
if not share_dir.exists():
|
||||
print(f"警告: 路径 {share_dir} 不存在")
|
||||
return None
|
||||
|
||||
return share_dir
|
||||
|
||||
|
||||
def generate_action_includes(app):
|
||||
src_dir = Path(app.srcdir)
|
||||
print(f"Generating action includes for {src_dir}")
|
||||
action_dir = src_dir.parent / "unilabos_msgs" / "action" # 修改为你的实际路径
|
||||
output_file = src_dir / "developer_guide" / "action_includes.md"
|
||||
|
||||
# 确保输出目录存在
|
||||
output_file.parent.mkdir(exist_ok=True)
|
||||
|
||||
# 初始化各部分内容
|
||||
sections = {}
|
||||
|
||||
# 仅处理本地消息文件
|
||||
if action_dir.exists():
|
||||
for action_file in sorted(action_dir.glob("*.action")):
|
||||
# 获取相对路径
|
||||
rel_path = f"../../unilabos_msgs/action/{action_file.name}"
|
||||
# 读取首行注释
|
||||
try:
|
||||
with open(action_file, "r", encoding="utf-8") as af:
|
||||
first_line = af.readline().strip()
|
||||
# 提取注释内容(去除#和空格)
|
||||
section = first_line.lstrip("#").strip()
|
||||
|
||||
text = f"""
|
||||
### `{action_file.stem}`
|
||||
|
||||
```{{literalinclude}} {rel_path}
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
"""
|
||||
|
||||
if sections.get(section) is None:
|
||||
sections[section] = text
|
||||
else:
|
||||
sections[section] += text
|
||||
except Exception as e:
|
||||
print(f"处理文件 {action_file} 时出错: {e}")
|
||||
else:
|
||||
print(f"警告: 动作消息目录 {action_dir} 不存在")
|
||||
|
||||
ros_action_dirs = []
|
||||
control_msgs_dir = get_conda_share_dir("control_msgs")
|
||||
nav2_msgs_dir = get_conda_share_dir("nav2_msgs")
|
||||
|
||||
if control_msgs_dir is not None:
|
||||
ros_action_dirs.append(control_msgs_dir / "action")
|
||||
if nav2_msgs_dir is not None:
|
||||
ros_action_dirs.append(nav2_msgs_dir / "action")
|
||||
|
||||
for action_dir in ros_action_dirs:
|
||||
for action_file in sorted(action_dir.glob("*.action")):
|
||||
# 获取相对路径
|
||||
rel_path = f"{action_file.absolute()}"
|
||||
# 读取首行注释
|
||||
with open(action_file, "r", encoding="utf-8") as af:
|
||||
# 提取注释内容(去除#和空格)
|
||||
section = "Robot"
|
||||
|
||||
text = f"""### `{action_file.stem}`
|
||||
|
||||
```yaml
|
||||
{open(rel_path, 'r').read()}
|
||||
```
|
||||
|
||||
----
|
||||
"""
|
||||
if sections.get(section) is None:
|
||||
sections[section] = text
|
||||
else:
|
||||
sections[section] += text
|
||||
|
||||
# 写入内容到输出文件
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
# 按 Section 生成总文档
|
||||
for section, title in section_titles.items():
|
||||
content = sections.get(section, "")
|
||||
if content: # 只有有内容时才写入标题和内容
|
||||
f.write(f"{title}\n\n")
|
||||
f.write(content)
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.connect("builder-inited", generate_action_includes)
|
||||
577
docs/developer_guide/action_includes.md
Normal file
@@ -0,0 +1,577 @@
|
||||
## 简单单变量动作函数
|
||||
|
||||
|
||||
### `SendCmd`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/SendCmd.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
## 常量有机化学操作
|
||||
|
||||
Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。
|
||||
|
||||
|
||||
|
||||
### `Clean`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Clean.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `EvacuateAndRefill`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `Evaporate`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Evaporate.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `HeatChill`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/HeatChill.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `HeatChillStart`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `HeatChillStop`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `PumpTransfer`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `Separate`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Separate.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `Stir`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/Stir.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
## 移液工作站及相关生物自动化设备操作
|
||||
|
||||
Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。
|
||||
|
||||
|
||||
|
||||
### `LiquidHandlerAspirate`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerDiscardTips`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDiscardTips.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerDispense`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDispense.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerDropTips`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDropTips.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerDropTips96`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDropTips96.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerMoveLid`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveLid.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerMovePlate`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMovePlate.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerMoveResource`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveResource.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerPickUpTips`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerPickUpTips96`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips96.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerReturnTips`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerReturnTips96`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips96.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerStamp`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerStamp.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `LiquidHandlerTransfer`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
## 多工作站及小车运行、物料转移
|
||||
|
||||
|
||||
### `AGVTransfer`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
### `WorkStationRun`
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action
|
||||
:language: yaml
|
||||
```
|
||||
|
||||
----
|
||||
## 机械臂、夹爪等机器人设备
|
||||
|
||||
Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`:
|
||||
|
||||
|
||||
### `FollowJointTrajectory`
|
||||
|
||||
```yaml
|
||||
# The trajectory for all revolute, continuous or prismatic joints
|
||||
trajectory_msgs/JointTrajectory trajectory
|
||||
# The trajectory for all planar or floating joints (i.e. individual joints with more than one DOF)
|
||||
trajectory_msgs/MultiDOFJointTrajectory multi_dof_trajectory
|
||||
|
||||
# Tolerances for the trajectory. If the measured joint values fall
|
||||
# outside the tolerances the trajectory goal is aborted. Any
|
||||
# tolerances that are not specified (by being omitted or set to 0) are
|
||||
# set to the defaults for the action server (often taken from the
|
||||
# parameter server).
|
||||
|
||||
# Tolerances applied to the joints as the trajectory is executed. If
|
||||
# violated, the goal aborts with error_code set to
|
||||
# PATH_TOLERANCE_VIOLATED.
|
||||
JointTolerance[] path_tolerance
|
||||
JointComponentTolerance[] component_path_tolerance
|
||||
|
||||
# To report success, the joints must be within goal_tolerance of the
|
||||
# final trajectory value. The goal must be achieved by time the
|
||||
# trajectory ends plus goal_time_tolerance. (goal_time_tolerance
|
||||
# allows some leeway in time, so that the trajectory goal can still
|
||||
# succeed even if the joints reach the goal some time after the
|
||||
# precise end time of the trajectory).
|
||||
#
|
||||
# If the joints are not within goal_tolerance after "trajectory finish
|
||||
# time" + goal_time_tolerance, the goal aborts with error_code set to
|
||||
# GOAL_TOLERANCE_VIOLATED
|
||||
JointTolerance[] goal_tolerance
|
||||
JointComponentTolerance[] component_goal_tolerance
|
||||
builtin_interfaces/Duration goal_time_tolerance
|
||||
|
||||
---
|
||||
int32 error_code
|
||||
int32 SUCCESSFUL = 0
|
||||
int32 INVALID_GOAL = -1
|
||||
int32 INVALID_JOINTS = -2
|
||||
int32 OLD_HEADER_TIMESTAMP = -3
|
||||
int32 PATH_TOLERANCE_VIOLATED = -4
|
||||
int32 GOAL_TOLERANCE_VIOLATED = -5
|
||||
|
||||
# Human readable description of the error code. Contains complementary
|
||||
# information that is especially useful when execution fails, for instance:
|
||||
# - INVALID_GOAL: The reason for the invalid goal (e.g., the requested
|
||||
# trajectory is in the past).
|
||||
# - INVALID_JOINTS: The mismatch between the expected controller joints
|
||||
# and those provided in the goal.
|
||||
# - PATH_TOLERANCE_VIOLATED and GOAL_TOLERANCE_VIOLATED: Which joint
|
||||
# violated which tolerance, and by how much.
|
||||
string error_string
|
||||
|
||||
---
|
||||
std_msgs/Header header
|
||||
string[] joint_names
|
||||
trajectory_msgs/JointTrajectoryPoint desired
|
||||
trajectory_msgs/JointTrajectoryPoint actual
|
||||
trajectory_msgs/JointTrajectoryPoint error
|
||||
|
||||
string[] multi_dof_joint_names
|
||||
trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_desired
|
||||
trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_actual
|
||||
trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `GripperCommand`
|
||||
|
||||
```yaml
|
||||
GripperCommand command
|
||||
---
|
||||
float64 position # The current gripper gap size (in meters)
|
||||
float64 effort # The current effort exerted (in Newtons)
|
||||
bool stalled # True iff the gripper is exerting max effort and not moving
|
||||
bool reached_goal # True iff the gripper position has reached the commanded setpoint
|
||||
---
|
||||
float64 position # The current gripper gap size (in meters)
|
||||
float64 effort # The current effort exerted (in Newtons)
|
||||
bool stalled # True iff the gripper is exerting max effort and not moving
|
||||
bool reached_goal # True iff the gripper position has reached the commanded setpoint
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `JointTrajectory`
|
||||
|
||||
```yaml
|
||||
trajectory_msgs/JointTrajectory trajectory
|
||||
---
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `PointHead`
|
||||
|
||||
```yaml
|
||||
geometry_msgs/PointStamped target
|
||||
geometry_msgs/Vector3 pointing_axis
|
||||
string pointing_frame
|
||||
builtin_interfaces/Duration min_duration
|
||||
float64 max_velocity
|
||||
---
|
||||
---
|
||||
float64 pointing_angle_error
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `SingleJointPosition`
|
||||
|
||||
```yaml
|
||||
float64 position
|
||||
builtin_interfaces/Duration min_duration
|
||||
float64 max_velocity
|
||||
---
|
||||
---
|
||||
std_msgs/Header header
|
||||
float64 position
|
||||
float64 velocity
|
||||
float64 error
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `AssistedTeleop`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
builtin_interfaces/Duration time_allowance
|
||||
---
|
||||
#result definition
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback
|
||||
builtin_interfaces/Duration current_teleop_duration
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `BackUp`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/Point target
|
||||
float32 speed
|
||||
builtin_interfaces/Duration time_allowance
|
||||
---
|
||||
#result definition
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
float32 distance_traveled
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `ComputePathThroughPoses`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/PoseStamped[] goals
|
||||
geometry_msgs/PoseStamped start
|
||||
string planner_id
|
||||
bool use_start # If false, use current robot pose as path start, if true, use start above instead
|
||||
---
|
||||
#result definition
|
||||
nav_msgs/Path path
|
||||
builtin_interfaces/Duration planning_time
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `ComputePathToPose`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/PoseStamped goal
|
||||
geometry_msgs/PoseStamped start
|
||||
string planner_id
|
||||
bool use_start # If false, use current robot pose as path start, if true, use start above instead
|
||||
---
|
||||
#result definition
|
||||
nav_msgs/Path path
|
||||
builtin_interfaces/Duration planning_time
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `DriveOnHeading`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/Point target
|
||||
float32 speed
|
||||
builtin_interfaces/Duration time_allowance
|
||||
---
|
||||
#result definition
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
float32 distance_traveled
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `DummyBehavior`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
std_msgs/String command
|
||||
---
|
||||
#result definition
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `FollowPath`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
nav_msgs/Path path
|
||||
string controller_id
|
||||
string goal_checker_id
|
||||
---
|
||||
#result definition
|
||||
std_msgs/Empty result
|
||||
---
|
||||
#feedback definition
|
||||
float32 distance_to_goal
|
||||
float32 speed
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `FollowWaypoints`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/PoseStamped[] poses
|
||||
---
|
||||
#result definition
|
||||
int32[] missed_waypoints
|
||||
---
|
||||
#feedback definition
|
||||
uint32 current_waypoint
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `NavigateThroughPoses`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/PoseStamped[] poses
|
||||
string behavior_tree
|
||||
---
|
||||
#result definition
|
||||
std_msgs/Empty result
|
||||
---
|
||||
#feedback definition
|
||||
geometry_msgs/PoseStamped current_pose
|
||||
builtin_interfaces/Duration navigation_time
|
||||
builtin_interfaces/Duration estimated_time_remaining
|
||||
int16 number_of_recoveries
|
||||
float32 distance_remaining
|
||||
int16 number_of_poses_remaining
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `NavigateToPose`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
geometry_msgs/PoseStamped pose
|
||||
string behavior_tree
|
||||
---
|
||||
#result definition
|
||||
std_msgs/Empty result
|
||||
---
|
||||
#feedback definition
|
||||
geometry_msgs/PoseStamped current_pose
|
||||
builtin_interfaces/Duration navigation_time
|
||||
builtin_interfaces/Duration estimated_time_remaining
|
||||
int16 number_of_recoveries
|
||||
float32 distance_remaining
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `SmoothPath`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
nav_msgs/Path path
|
||||
string smoother_id
|
||||
builtin_interfaces/Duration max_smoothing_duration
|
||||
bool check_for_collisions
|
||||
---
|
||||
#result definition
|
||||
nav_msgs/Path path
|
||||
builtin_interfaces/Duration smoothing_duration
|
||||
bool was_completed
|
||||
---
|
||||
#feedback definition
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `Spin`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
float32 target_yaw
|
||||
builtin_interfaces/Duration time_allowance
|
||||
---
|
||||
#result definition
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
float32 angular_distance_traveled
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
### `Wait`
|
||||
|
||||
```yaml
|
||||
#goal definition
|
||||
builtin_interfaces/Duration time
|
||||
---
|
||||
#result definition
|
||||
builtin_interfaces/Duration total_elapsed_time
|
||||
---
|
||||
#feedback definition
|
||||
builtin_interfaces/Duration time_left
|
||||
|
||||
```
|
||||
|
||||
----
|
||||
7
docs/developer_guide/actions.md
Normal file
@@ -0,0 +1,7 @@
|
||||
(actions)=
|
||||
# Uni-Lab 动作指令集
|
||||
|
||||
Uni-Lab 当前动作指令集设计时,主要考虑兼容领域历史开源工具。目前包括以下场景:
|
||||
|
||||
```{include} action_includes.md
|
||||
```
|
||||
37
docs/developer_guide/add_action.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 添加新动作指令(Action)
|
||||
|
||||
1. 在 `unilabos_msgs/action` 中新建实验操作名和参数列表,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成,分别是目标(Goal)、结果(Result)和反馈(Feedback),之间使用 `---` 分隔:
|
||||
|
||||
```action
|
||||
# 目标(Goal)
|
||||
string command
|
||||
---
|
||||
# 结果(Result)
|
||||
bool success
|
||||
---
|
||||
# 反馈(Feedback)
|
||||
```
|
||||
|
||||
2. 在 `unilabos_msgs/CMakeLists.txt` 中添加新定义的 action
|
||||
|
||||
```cmake
|
||||
add_action_files(
|
||||
FILES
|
||||
MyDeviceCmd.action
|
||||
)
|
||||
```
|
||||
|
||||
3. 因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径:
|
||||
|
||||
```bash
|
||||
cd unilabos_msgs
|
||||
colcon build
|
||||
source ./install/local_setup.sh
|
||||
cd ..
|
||||
```
|
||||
|
||||
调试成功后,发起 pull request,Uni-Lab 的 CI/CD 系统会自动将新的指令集编译打包,mamba执行升级即可永久生效:
|
||||
|
||||
```bash
|
||||
mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging
|
||||
```
|
||||
200
docs/developer_guide/add_device.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 添加新设备
|
||||
|
||||
在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
|
||||
|
||||
能被 Uni-Lab 添加的驱动程序类型有以下种类:
|
||||
|
||||
1. Python Class,如
|
||||
|
||||
```python
|
||||
class MockGripper:
|
||||
def __init__(self):
|
||||
self._position: float = 0.0
|
||||
self._velocity: float = 2.0
|
||||
self._torque: float = 0.0
|
||||
self._status = "Idle"
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def velocity(self) -> float:
|
||||
return self._velocity
|
||||
|
||||
@property
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
@status.setter
|
||||
def status(self, target):
|
||||
self._status = target
|
||||
|
||||
# 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
def push_to(self, position: float, torque: float, velocity: float = 0.0):
|
||||
self._status = "Running"
|
||||
current_pos = self.position
|
||||
if velocity == 0.0:
|
||||
velocity = self.velocity
|
||||
|
||||
move_time = abs(position - current_pos) / velocity
|
||||
for i in range(20):
|
||||
self._position = current_pos + (position - current_pos) / 20 * (i+1)
|
||||
self._torque = torque / (20 - i)
|
||||
self._velocity = velocity
|
||||
time.sleep(move_time / 20)
|
||||
self._torque = torque
|
||||
self._status = "Idle"
|
||||
```
|
||||
|
||||
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。
|
||||
|
||||
2. C# Class,如
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
public class MockGripper
|
||||
{
|
||||
// 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
public double position { get; private set; } = 0.0;
|
||||
public double velocity { get; private set; } = 2.0;
|
||||
public double torque { get; private set; } = 0.0;
|
||||
public string status { get; private set; } = "Idle";
|
||||
|
||||
// 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
|
||||
public async Task PushToAsync(double Position, double Torque, double Velocity = 0.0)
|
||||
{
|
||||
status = "Running";
|
||||
double currentPos = Position;
|
||||
if (Velocity == 0.0)
|
||||
{
|
||||
velocity = Velocity;
|
||||
}
|
||||
double moveTime = Math.Abs(Position - currentPos) / velocity;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
position = currentPos + (Position - currentPos) / 20 * (i + 1);
|
||||
torque = Torque / (20 - i);
|
||||
velocity = Velocity;
|
||||
await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds
|
||||
}
|
||||
torque = Torque;
|
||||
status = "Idle";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
|
||||
|
||||
## 注册表文件位置
|
||||
|
||||
Uni-Lab 启动时会自动读取默认注册表路径 `unilabos/registry/devices` 下的所有注册设备。您也可以任意维护自己的注册表路径,只需要在 Uni-Lab 启动时使用 `--registry` 参数将路径添加即可。
|
||||
|
||||
在 `<path-to-registry>/devices` 中新建一个 yaml 文件,即可开始撰写。您可以将多个设备写到同一个 yaml 文件中。
|
||||
|
||||
## 注册表的结构
|
||||
|
||||
1. 顶层名称:每个设备的注册表以设备名称开头,例如 `new_device`, `gripper.mock`。
|
||||
1. `class` 字段:定义设备的模块路径和驱动程序语言。
|
||||
1. `status_types` 字段:定义设备定时对 Uni-Lab 实验室内发送的属性名及其类型。
|
||||
1. `action_value_mappings` 字段:定义设备支持的动作及其目标、反馈和结果。
|
||||
1. `schema` 字段:定义设备定时对 Uni-Lab 云端监控发送的属性名及其类型、描述(非必须)
|
||||
|
||||
## 创建新的注册表教程
|
||||
|
||||
1. 创建文件
|
||||
在 devices 文件夹中创建一个新的 YAML 文件,例如 `new_device.yaml`。
|
||||
2. 定义设备名称
|
||||
在文件中定义设备的顶层名称,例如:`new_device` 或 `gripper.mock`
|
||||
3. 定义设备的类信息
|
||||
添加设备的模块路径和类型:
|
||||
|
||||
```yaml
|
||||
gripper.mock:
|
||||
class: # 定义设备的类信息
|
||||
module: unilabos.devices.gripper.mock:MockGripper
|
||||
type: python # 指定驱动语言为 Python
|
||||
status_types:
|
||||
position: Float64
|
||||
torque: Float64
|
||||
status: String
|
||||
```
|
||||
|
||||
4. 定义设备的定时发布属性。注意,对于 Python Class 来说,PROP 是 class 的 `property`,或满足能被 `getattr(cls, PROP)` 或 `cls.get_PROP` 读取到的属性值的对象。
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
PROP: TYPE
|
||||
```
|
||||
5. 定义设备支持的动作
|
||||
添加设备支持的动作及其目标、反馈和结果:
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
set_speed:
|
||||
type: SendCmd
|
||||
goal:
|
||||
command: speed
|
||||
feedback: {}
|
||||
result:
|
||||
success: success
|
||||
```
|
||||
|
||||
在 devices 文件夹中的 YAML 文件中,action_value_mappings 是用来将驱动内的动作函数,映射到 Uni-Lab 标准动作(actions)及其目标参数值(goal)、反馈值(feedback)和结果值(result)的映射规则。若在 Uni-Lab 指令集内找不到符合心意的,请【创建新指令】。
|
||||
|
||||
```yaml
|
||||
action_value_mappings:
|
||||
<action_name>: # <action_name>:动作的名称
|
||||
# start:启动设备或某个功能。
|
||||
# stop:停止设备或某个功能。
|
||||
# set_speed:设置设备的速度。
|
||||
# set_temperature:设置设备的温度。
|
||||
# move_to_position:移动设备到指定位置。
|
||||
# stir:执行搅拌操作。
|
||||
# heatchill:执行加热或冷却操作。
|
||||
# send_nav_task:发送导航任务(例如机器人导航)。
|
||||
# set_timer:设置设备的计时器。
|
||||
# valve_open_cmd:打开阀门。
|
||||
# valve_close_cmd:关闭阀门。
|
||||
# execute_command_from_outer:执行外部命令。
|
||||
# push_to:控制设备推送到某个位置(例如机械爪)。
|
||||
# move_through_points:导航设备通过多个点。
|
||||
|
||||
type: <ActionType> # 动作的类型,表示动作的功能
|
||||
# 根据动作的功能选择合适的类型,请查阅 Uni-Lab 已支持的指令集。
|
||||
|
||||
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
|
||||
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
|
||||
|
||||
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
|
||||
<feedback_key>: <mapped_value>
|
||||
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
|
||||
<result_key>: <mapped_value>
|
||||
```
|
||||
|
||||
6. 定义设备的网页展示属性类型,这部分会被用于在 Uni-Lab 网页端渲染成状态监控
|
||||
添加设备的属性模式,包括属性类型和描述:
|
||||
|
||||
```yaml
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
description: The status of the device
|
||||
speed:
|
||||
type: number
|
||||
description: The speed of the device
|
||||
required:
|
||||
- status
|
||||
- speed
|
||||
additionalProperties: false
|
||||
```
|
||||
50
docs/developer_guide/add_protocol.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 添加新实验操作(Protocol)
|
||||
|
||||
在 `Uni-Lab` 中,实验操作(Protocol)指的是**对实验有意义的单个完整动作**——加入某种液体多少量;萃取分液;洗涤仪器;机械+末端执行器等等,就像实验步骤文字书写的那样。
|
||||
|
||||
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,或者同一设备连续动作,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。`Protocol` 根据实验操作目标和设备物理连接关系,通过 `unilabos/compile` 中的“编译”过程产生硬件可执行的机器指令,并依次执行。
|
||||
|
||||
开发一个 `Protocol` 一般共需要修改6个文件:
|
||||
|
||||
1. 在 `unilabos_msgs/action` 中新建实验操作名和参数列表,如 `PumpTransfer.action`。一个 Action 定义由三个部分组成,分别是目标(Goal)、结果(Result)和反馈(Feedback),之间使用 `---` 分隔:
|
||||
|
||||
```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action
|
||||
```
|
||||
|
||||
2. 在 `unilabos_msgs/CMakeLists.txt` 中添加新定义的 action
|
||||
因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径:
|
||||
```bash
|
||||
cd unilabos_msgs
|
||||
colcon build
|
||||
source ./install/local_setup.sh
|
||||
cd ..
|
||||
```
|
||||
|
||||
调试成功后,发起 pull request,Uni-Lab 的 CI/CD 系统会自动将新的指令集编译打包,mamba执行升级即可永久生效:
|
||||
|
||||
```bash
|
||||
mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging
|
||||
```
|
||||
|
||||
3. 在 `unilabos/messages/__init__.py` 中添加 Pydantic 定义的实验操作名和参数列表
|
||||
```{literalinclude} ../../unilabos/messages/__init__.py
|
||||
:start-after: Start Protocols
|
||||
:end-before: End Protocols
|
||||
```
|
||||
|
||||
4. 在 `unilabos/compile` 中新建编译为机器指令的函数,函数入参为设备连接图 `G` 和实验操作参数。
|
||||
```{literalinclude} ../../unilabos/compile/pump_protocol.py
|
||||
:start-after: Pump protocol compilation
|
||||
:end-before: End Protocols
|
||||
```
|
||||
|
||||
5. 将该函数加入 `unilabos/compile/__init__.py` 的 `action_protocol_generators` 中:
|
||||
```{literalinclude} ../../unilabos/compile/__init__.py
|
||||
:start-after: Define
|
||||
:end-before: End Protocols
|
||||
```
|
||||
|
||||
6. 记得将新开发的 `Protocol` 添加至启动时的 `devices.json` 中。
|
||||
```{literalinclude} ../../devices.json
|
||||
:lines: 2-4
|
||||
```
|
||||
95
docs/developer_guide/add_yaml.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# yaml注册表编写指南
|
||||
|
||||
`注册表的结构`
|
||||
|
||||
1. 顶层名称:每个设备的注册表以设备名称开头,例如 new_device。
|
||||
2. class 字段:定义设备的模块路径和类型。
|
||||
3. schema 字段:定义设备的属性模式,包括属性类型、描述和必需字段。
|
||||
4. action_value_mappings 字段:定义设备支持的动作及其目标、反馈和结果。
|
||||
|
||||
`创建新的注册表教程`
|
||||
1. 创建文件
|
||||
在 devices 文件夹中创建一个新的 YAML 文件,例如 new_device.yaml。
|
||||
|
||||
2. 定义设备名称
|
||||
在文件中定义设备的顶层名称,例如:new_device
|
||||
|
||||
3. 定义设备的类信息
|
||||
添加设备的模块路径和类型:
|
||||
|
||||
```python
|
||||
new_device: # 定义一个名为 linear_motion.grbl 的设备
|
||||
|
||||
|
||||
class: # 定义设备的类信息
|
||||
module: unilabos.devices_names.new_device:NewDeviceClass # 指定模块路径和类名
|
||||
type: python # 指定类型为 Python 类
|
||||
status_types:
|
||||
```
|
||||
4. 定义设备支持的动作
|
||||
添加设备支持的动作及其目标、反馈和结果:
|
||||
```python
|
||||
action_value_mappings:
|
||||
set_speed:
|
||||
type: SendCmd
|
||||
goal:
|
||||
command: speed
|
||||
feedback: {}
|
||||
result:
|
||||
success: success
|
||||
```
|
||||
`如何编写action_valve_mappings`
|
||||
1. 在 devices 文件夹中的 YAML 文件中,action_value_mappings 是用来定义设备支持的动作(actions)及其目标值(goal)、反馈值(feedback)和结果值(result)的映射规则。以下是规则和编写方法:
|
||||
```python
|
||||
action_value_mappings:
|
||||
<action_name>: # <action_name>:动作的名称
|
||||
# start:启动设备或某个功能。
|
||||
# stop:停止设备或某个功能。
|
||||
# set_speed:设置设备的速度。
|
||||
# set_temperature:设置设备的温度。
|
||||
# move_to_position:移动设备到指定位置。
|
||||
# stir:执行搅拌操作。
|
||||
# heatchill:执行加热或冷却操作。
|
||||
# send_nav_task:发送导航任务(例如机器人导航)。
|
||||
# set_timer:设置设备的计时器。
|
||||
# valve_open_cmd:打开阀门。
|
||||
# valve_close_cmd:关闭阀门。
|
||||
# execute_command_from_outer:执行外部命令。
|
||||
# push_to:控制设备推送到某个位置(例如机械爪)。
|
||||
# move_through_points:导航设备通过多个点。
|
||||
|
||||
type: <ActionType> # 动作的类型,表示动作的功能
|
||||
# 根据动作的功能选择合适的类型:
|
||||
# SendCmd:发送简单命令。
|
||||
# NavigateThroughPoses:导航动作。
|
||||
# SingleJointPosition:设置单一关节的位置。
|
||||
# Stir:搅拌动作。
|
||||
# HeatChill:加热或冷却动作。
|
||||
|
||||
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
|
||||
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
|
||||
|
||||
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
|
||||
<feedback_key>: <mapped_value>
|
||||
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
|
||||
<result_key>: <mapped_value>
|
||||
```
|
||||
|
||||
6. 定义设备的属性模式
|
||||
添加设备的属性模式,包括属性类型和描述:
|
||||
```python
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
description: The status of the device
|
||||
speed:
|
||||
type: number
|
||||
description: The speed of the device
|
||||
required:
|
||||
- status
|
||||
- speed
|
||||
additionalProperties: false
|
||||
```
|
||||
# 写完yaml注册表后需要添加到哪些其他文件?
|
||||
330
docs/developer_guide/device_driver.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# 设备 Driver 开发
|
||||
|
||||
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
|
||||
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。
|
||||
|
||||
## 有串口字符串指令集文档的设备:Python 串口通信(常见 RS485, RS232, USB)
|
||||
|
||||
开发方式:对照厂家给出的指令集文档,实现相应发送指令字符串的 python 函数。可参考 [注射泵串口驱动样例](https://github.com/TablewareBox/runze-syringe-pump)
|
||||
|
||||
## 常见工业通信协议:Modbus(RTU, TCP)
|
||||
|
||||
Modbus 与 RS485、RS232 不一样的地方在于,会有更多直接寄存器的读写,以及涉及字节序转换(Big Endian, Little Endian)。
|
||||
|
||||
Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
|
||||
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
* 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
|
||||
****
|
||||
|
||||
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
||||
|
||||
【敬请期待】
|
||||
|
||||
## 没有接口的老设备老软件:使用 PyWinAuto
|
||||
|
||||
**pywinauto**是一个 Python 库,用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作:
|
||||
|
||||
* **win32**后端:适用于大多数Windows应用程序,使用native Win32 API。(pywinauto_recorder默认使用win32后端)
|
||||
* **uia**后端:基于Microsoft UI Automation,适用于较新的应用程序,特别是基于WPF或UWP的应用程序。(在win10上,会有更全的目录,有的窗口win32会识别不到)
|
||||
|
||||
### windows平台安装pywinauto和pywinauto_recorder
|
||||
|
||||
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
|
||||
|
||||
cd到对应目录,执行安装
|
||||
|
||||
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
|
||||

|
||||
|
||||
windows平台测试 python pywinauto_recorder.py,退出使用两次ctrl+alt+r取消选中,关闭命令提示符。
|
||||
|
||||
### 计算器例子
|
||||
|
||||
你可以先打开windows的计算器,然后在ilab的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的pywinauto用法:
|
||||
|
||||
* 连接到指定进程
|
||||
* 利用dump_tree查找需要的窗口
|
||||
* 获取某个位置的信息
|
||||
* 模拟点击
|
||||
* 模拟输入
|
||||
|
||||
#### 代码学习
|
||||
|
||||
```Python
|
||||
from pywinauto import Application
|
||||
import time
|
||||
|
||||
from pywinauto.findwindows import ElementAmbiguousError
|
||||
|
||||
# 启动计算器应用
|
||||
app = Application(backend='uia').connect(title="计算器")
|
||||
|
||||
# 连接到计算器窗口
|
||||
window = app["计算器Dialog0"]
|
||||
|
||||
# 打印窗口控件树结构,帮助定位控件
|
||||
window.dump_tree(depth=3)
|
||||
# 详细输出
|
||||
"""
|
||||
Dialog - '计算器' (L-419, T773, R-73, B1287)
|
||||
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
|
||||
child_window(title="计算器", control_type="Window")
|
||||
|
|
||||
| Dialog - '计算器' (L-269, T774, R-81, B806)
|
||||
| ['计算器Dialog2', 'Dialog2', '计算器2']
|
||||
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
|
||||
| |
|
||||
| | Menu - '系统' (L0, T0, R0, B0)
|
||||
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
|
||||
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
|
||||
| |
|
||||
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
|
||||
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
|
||||
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
|
||||
| |
|
||||
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
|
||||
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
|
||||
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
|
||||
| |
|
||||
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
|
||||
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
|
||||
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
||||
|
|
||||
| Dialog - '计算器' (L-411, T774, R-81, B1279)
|
||||
| ['计算器Dialog3', 'Dialog3', '计算器3']
|
||||
| child_window(title="计算器", control_type="Window")
|
||||
| |
|
||||
| | Static - '计算器' (L-363, T782, R-327, B798)
|
||||
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
|
||||
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
|
||||
| |
|
||||
| | Custom - '' (L-411, T806, R-81, B1279)
|
||||
| | ['Custom', '计算器Custom']
|
||||
| | child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
| Pane - '' (L-411, T806, R-81, B1279)
|
||||
| ['Pane', '计算器Pane']
|
||||
"""
|
||||
|
||||
# 通过控件路径(可参考下一小节路径)可以发现,本文档第48-50行是我们需要定位的控件
|
||||
# 本文档第16-18行为其自身,即depth=1,我们要定位的第48-50行是depth=3的控件,从树来一级一级定位即可
|
||||
# PyWinAuto为我们提供了非常便捷的取窗口方式,在每3行dump的内容中,第三行就是从上一级取出当前窗口的方式,直接复制即可
|
||||
# 这里注意到,使用title="计算器", control_type="Window"进行匹配,会出现两个,因此指定found_index=1
|
||||
sub_window = window.child_window(title="计算器", control_type="Window", found_index=1)
|
||||
target_window = sub_window.child_window(auto_id="NavView", control_type="Custom")
|
||||
target_window.dump_tree(depth=3)
|
||||
"""
|
||||
Custom - '' (L-411, T806, R-81, B1279)
|
||||
['标准Custom', 'Custom']
|
||||
child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
| Button - '打开导航' (L-407, T812, R-367, B848)
|
||||
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
|
||||
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
|
||||
| |
|
||||
| | Static - '' (L0, T0, R0, B0)
|
||||
| | ['Static', 'Static0', 'Static1']
|
||||
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
||||
|
|
||||
| GroupBox - '' (L-411, T814, R-81, B1275)
|
||||
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
|
||||
| |
|
||||
| | Static - '表达式为 ' (L0, T0, R0, B0)
|
||||
| | ['表达式为 ', 'Static2', '表达式为 Static']
|
||||
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
|
||||
| |
|
||||
| | Static - '显示为 0' (L-411, T875, R-81, B947)
|
||||
| | ['显示为 0Static', '显示为 0', 'Static3']
|
||||
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
|
||||
| |
|
||||
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
|
||||
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
|
||||
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
|
||||
| |
|
||||
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
|
||||
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
|
||||
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
|
||||
| |
|
||||
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
|
||||
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
|
||||
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
|
||||
| |
|
||||
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
|
||||
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
|
||||
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
|
||||
| |
|
||||
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
|
||||
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
|
||||
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
|
||||
| |
|
||||
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
|
||||
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
|
||||
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
| |
|
||||
| | Button - '正负' (L-407, T1228, R-328, B1275)
|
||||
| | ['Button32', '正负Button', '正负']
|
||||
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
||||
|
|
||||
| Static - '标准' (L-363, T815, R-322, B842)
|
||||
| ['标准', '标准Static', 'Static4']
|
||||
| child_window(title="标准", auto_id="Header", control_type="Text")
|
||||
|
|
||||
| Button - '始终置顶' (L-312, T814, R-280, B846)
|
||||
| ['始终置顶Button', '始终置顶', 'Button33']
|
||||
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
|
||||
"""
|
||||
# 观察到GroupBox控件并没有提供默认的child_window,而list中的identifier均可作为best_match来索引
|
||||
# ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1'] 这里选用第0项
|
||||
group_box = target_window.child_window(best_match="标准GroupBox")
|
||||
numpad = group_box.child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
numpad.dump_tree(depth=2)
|
||||
"""
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
|
|
||||
| Button - '一' (L-334, T1450, R-255, B1498)
|
||||
| ['一Button', 'Button2', '一']
|
||||
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
||||
|
|
||||
| Button - '二' (L-253, T1450, R-174, B1498)
|
||||
| ['Button3', '二', '二Button']
|
||||
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
||||
|
|
||||
| Button - '三' (L-172, T1450, R-93, B1498)
|
||||
| ['Button4', '三', '三Button']
|
||||
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
||||
|
|
||||
| Button - '四' (L-334, T1400, R-255, B1448)
|
||||
| ['四', 'Button5', '四Button']
|
||||
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
||||
|
|
||||
| Button - '五' (L-253, T1400, R-174, B1448)
|
||||
| ['Button6', '五Button', '五']
|
||||
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
||||
|
|
||||
| Button - '六' (L-172, T1400, R-93, B1448)
|
||||
| ['六Button', 'Button7', '六']
|
||||
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
||||
|
|
||||
| Button - '七' (L-334, T1350, R-255, B1398)
|
||||
| ['Button8', '七Button', '七']
|
||||
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
||||
|
|
||||
| Button - '八' (L-253, T1350, R-174, B1398)
|
||||
| ['八', 'Button9', '八Button']
|
||||
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
||||
|
|
||||
| Button - '九' (L-172, T1350, R-93, B1398)
|
||||
| ['Button10', '九', '九Button']
|
||||
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
||||
|
|
||||
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
|
||||
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
|
||||
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
|
||||
"""
|
||||
# 获取按钮 '9'
|
||||
button_9 = numpad.child_window(title="九", auto_id="num9Button", control_type="Button")
|
||||
# 利用相同的办法,我们也可以找到增加和等于号的控件
|
||||
std_calc_panel = group_box.child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
|
||||
equal_operation = std_calc_panel.child_window(title="等于", auto_id="equalButton", control_type="Button")
|
||||
|
||||
# 模拟点击按钮 '9'
|
||||
button_9.click_input()
|
||||
# 键入:https://github.com/pywinauto/pywinauto/blob/atspi/pywinauto/windows/keyboard.py
|
||||
# 模拟输入 '加号' 和 数字9
|
||||
window.type_keys("{VK_ADD}9")
|
||||
# 等于
|
||||
equal_operation.click_input()
|
||||
# 获取计算结果文本(显示在计算器窗口的文本框中)
|
||||
result = group_box.child_window(auto_id="CalculatorResults", control_type="Text").window_text()
|
||||
print(f"计算结果:{result[4:]}") # 应当得到结果18
|
||||
```
|
||||
|
||||
#### 依据像素判定状态
|
||||
|
||||
```Python
|
||||
# 有时,你需要根据窗口的颜色判断是否可用,是否正在运行,可以使用pyautogui来实现这一功能
|
||||
# pyautogui需要对应在环境中进行安装
|
||||
point_x = int(control_view.rectangle().left + control_view.rectangle().width() * 0.15)
|
||||
point_y = 15 + control_view.rectangle().top
|
||||
r, g, b = pyautogui.pixel(point_x, point_y)
|
||||
```
|
||||
|
||||
### pywinauto_recorder
|
||||
|
||||
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用pywinauto的一些查找UI步骤。
|
||||
|
||||
#### 运行尝试
|
||||
|
||||
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
|
||||
|
||||
例如我们这里先启动一个windows自带的计算器软件
|
||||
|
||||

|
||||
|
||||
在录制状态下点击数字键盘的“9”,随后退出录制,得到下图运行的文件。
|
||||
|
||||

|
||||
|
||||
```Python
|
||||
# encoding: utf-8
|
||||
|
||||
from pywinauto_recorder.player import *
|
||||
|
||||
with UIPath(u"计算器||Window"):
|
||||
with UIPath(u"计算器||Window->||Custom->||Group->数字键盘||Group"):
|
||||
click(u"九||Button")
|
||||
```
|
||||
|
||||
执行该python脚本,可以观察到新开启的计算器被点击了数字9
|
||||
|
||||

|
||||
|
||||
### `dump_tree`详解
|
||||
|
||||
`dump_tree`方法用于打印控件树结构,帮助我们快速了解应用程序窗口中的控件层级,尤其是在自动化测试或脚本开发中,识别控件非常重要。
|
||||
|
||||
```Python
|
||||
window.dump_tree(depth=[int类型数字], filename=None)
|
||||
# 打印当前窗口及其子控件的树结构
|
||||
# 在debug的过程中,如果需要查找某一控件,可以通过depth指定为4~5,利用搜索查看是哪个独立控件
|
||||
# 指定filename后将保存到对应目录文件中
|
||||
```
|
||||
|
||||
输出会列出窗口的各个控件及其子控件,显示每个控件的属性(如标题、类型等)。
|
||||
|
||||
```Python
|
||||
"""
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
"""
|
||||
```
|
||||
|
||||
这里以上面计算器的例子对dump_tree进行解读
|
||||
|
||||
2~4行为当前对象的窗口
|
||||
|
||||
* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
* 第4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
|
||||
6~8行为当前对象窗口所包含的子窗口信息,信息类型对应2~4行
|
||||
|
||||
### 窗口获取注意事项
|
||||
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
BIN
docs/developer_guide/image/device_driver/calculator_01.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/developer_guide/image/device_driver/calculator_02.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/developer_guide/image/device_driver/calculator_03.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/developer_guide/image/device_driver/pywinauto_install.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
9
docs/index.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Uni-Lab 项目文档
|
||||
|
||||
欢迎来到项目文档的首页!
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 3
|
||||
|
||||
intro.md
|
||||
```
|
||||
44
docs/intro.md
Normal file
@@ -0,0 +1,44 @@
|
||||
欢迎来到项目文档的首页!
|
||||
|
||||
## 核心概念
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
concepts/overview.md
|
||||
concepts/01-communication-instruction.md
|
||||
concepts/02-topology-and-chemputer-compile.md
|
||||
```
|
||||
|
||||
## **用户指南**
|
||||
|
||||
本指南将带你了解如何使用项目的功能。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
user_guide/installation.md
|
||||
user_guide/configuration.md
|
||||
user_guide/launch.md
|
||||
boot_examples/index.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/device_driver
|
||||
developer_guide/add_device
|
||||
developer_guide/add_action
|
||||
developer_guide/actions
|
||||
developer_guide/add_protocol
|
||||
```
|
||||
|
||||
## 接口文档
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
apidocs/unilabos
|
||||
```
|
||||
35
docs/make.bat
Normal file
@@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=.
|
||||
set BUILDDIR=_build
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://www.sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
||||
120
docs/user_guide/configuration.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
Uni-Lab支持通过Python配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的Uni-Lab环境。
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
Uni-Lab支持Python格式的配置文件,它比YAML或JSON提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
### 基本配置示例
|
||||
|
||||
一个典型的配置文件包含以下部分:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 配置类定义
|
||||
@dataclass
|
||||
class MQConfig:
|
||||
"""MQTT 配置类"""
|
||||
lab_id: str = "YOUR_LAB_ID"
|
||||
# 更多配置...
|
||||
|
||||
# 其他配置类...
|
||||
```
|
||||
|
||||
## 配置选项说明
|
||||
|
||||
### MQTT配置 (MQConfig)
|
||||
|
||||
MQTT配置用于连接消息队列服务,是Uni-Lab与云端通信的主要方式。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MQConfig:
|
||||
"""MQTT 配置类"""
|
||||
lab_id: str = "7AAEDBEA" # 实验室唯一标识
|
||||
instance_id: str = "mqtt-cn-instance"
|
||||
access_key: str = "your-access-key"
|
||||
secret_key: str = "your-secret-key"
|
||||
group_id: str = "GID_labs"
|
||||
broker_url: str = "mqtt-cn-instance.mqtt.aliyuncs.com"
|
||||
port: int = 8883
|
||||
|
||||
# 可以直接提供证书文件路径
|
||||
ca_file: str = "/path/to/ca.pem"
|
||||
cert_file: str = "/path/to/cert.pem"
|
||||
key_file: str = "/path/to/key.pem"
|
||||
|
||||
# 或者直接提供证书内容
|
||||
ca_content: str = ""
|
||||
cert_content: str = ""
|
||||
key_content: str = ""
|
||||
```
|
||||
|
||||
#### 证书配置
|
||||
|
||||
MQTT连接支持两种方式配置证书:
|
||||
|
||||
1. **文件路径方式**(推荐):指定证书文件的路径,系统会自动读取文件内容
|
||||
2. **直接内容方式**:直接在配置中提供证书内容
|
||||
|
||||
推荐使用文件路径方式,便于证书的更新和管理。
|
||||
|
||||
### HTTP客户端配置 (HTTPConfig)
|
||||
|
||||
即将开放 Uni-Lab 云端实验室。
|
||||
|
||||
### ROS模块配置 (ROSConfig)
|
||||
|
||||
配置ROS消息转换器需要加载的模块:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ROSConfig:
|
||||
"""ROS模块配置"""
|
||||
modules: list = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.modules is None:
|
||||
self.modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据需要添加其他ROS模块。
|
||||
|
||||
### 其他配置选项
|
||||
|
||||
- **OSSUploadConfig**: 对象存储上传配置
|
||||
|
||||
## 如何使用配置文件
|
||||
|
||||
启动Uni-Lab时通过`--config`参数指定配置文件路径:
|
||||
|
||||
```bash
|
||||
unilab --config path/to/your/config.py
|
||||
```
|
||||
|
||||
## 环境变量覆盖
|
||||
|
||||
某些配置项可以通过环境变量进行覆盖,这在不同环境部署时特别有用:
|
||||
|
||||
```bash
|
||||
# 设置环境变量覆盖配置
|
||||
export UNILAB_LAB_ID="YOUR_LAB_ID"
|
||||
export UNILAB_MQTT_BROKER="mqtt-broker-address"
|
||||
|
||||
# 启动Uni-Lab
|
||||
python -m unilabos.app.main --config path/to/your/config.py
|
||||
```
|
||||
24
docs/user_guide/installation.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# **Uni-Lab 安装**
|
||||
|
||||
请先 `git clone` 本仓库,随后按照以下步骤安装项目:
|
||||
|
||||
`Uni-Lab` 建议您采用 `mamba` 管理环境。若需从头建立 `Uni-Lab` 的运行依赖环境,请执行
|
||||
|
||||
```shell
|
||||
mamba env create -f unilabos-<YOUR_OS>.yaml
|
||||
mamba activate ilab
|
||||
```
|
||||
|
||||
其中 `YOUR_OS` 是您的操作系统,可选值 `win64`, `linux-64`, `osx-64`, `osx-arm64`
|
||||
|
||||
若需将依赖安装进当前环境,请执行
|
||||
|
||||
```shell
|
||||
conda env update --file unilabos-<YOUR_OS>.yml
|
||||
```
|
||||
|
||||
随后,可在本仓库安装 `unilabos` 的开发版:
|
||||
|
||||
```shell
|
||||
pip install .
|
||||
```
|
||||
77
docs/user_guide/launch.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Uni-Lab 启动
|
||||
|
||||
安装完毕后,可以通过 `unilab` 命令行启动:
|
||||
|
||||
```bash
|
||||
Start Uni-Lab Edge server.
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-g GRAPH, --graph GRAPH
|
||||
Physical setup graph.
|
||||
-d DEVICES, --devices DEVICES
|
||||
Devices config file.
|
||||
-r RESOURCES, --resources RESOURCES
|
||||
Resources config file.
|
||||
-c CONTROLLERS, --controllers CONTROLLERS
|
||||
Controllers config file.
|
||||
--registry_path REGISTRY_PATH
|
||||
Path to the registry
|
||||
--backend {ros,simple,automancer}
|
||||
Choose the backend to run with: 'ros', 'simple', or 'automancer'.
|
||||
--app_bridges APP_BRIDGES [APP_BRIDGES ...]
|
||||
Bridges to connect to. Now support 'mqtt' and 'fastapi'.
|
||||
--without_host Run the backend as slave (without host).
|
||||
--config CONFIG Configuration file path for system settings
|
||||
```
|
||||
|
||||
## 使用配置文件
|
||||
|
||||
Uni-Lab支持使用Python格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径:
|
||||
|
||||
```bash
|
||||
# 使用配置文件启动
|
||||
unilab --config path/to/your/config.py
|
||||
```
|
||||
|
||||
配置文件包含MQTT、HTTP、ROS等系统设置。有关配置文件的详细信息,请参阅[配置指南](configuration.md)。
|
||||
|
||||
## 初始化信息来源
|
||||
|
||||
启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备、耗材、通信、控制逻辑:
|
||||
|
||||
### 1. 组态&拓扑图
|
||||
|
||||
使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 graphml 和 node-link json 两种格式。格式可参照 `tests/experiments` 下的启动文件。
|
||||
|
||||
### 2. 分别指定设备、耗材、控制逻辑
|
||||
|
||||
分别使用 `-d, -r, -c` 依次传入设备组态配置、耗材列表、控制逻辑。
|
||||
|
||||
可参照 `devices.json` 和 `resources.json`。
|
||||
|
||||
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry` 加入 `<your-registry-path>/{devices,device_comms,resources}`。
|
||||
|
||||
## 通信中间件 `--backend`
|
||||
|
||||
目前 Uni-Lab 仅支持 ros2 作为通信中间件。
|
||||
|
||||
## 端云桥接 `--app_bridges`
|
||||
|
||||
目前 Uni-Lab 提供 FastAPI (http), MQTT 两种端云通信方式。其中默认 MQTT 负责端对云状态同步和云对端任务下发,FastAPI 负责端对云物料更新。
|
||||
|
||||
## 分布式组网
|
||||
|
||||
启动 Uni-Lab 时,加入 `--without_host` 将作为从站,不加将作为主站,主站 (host) 持有物料修改权以及对云端的通信。局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。
|
||||
|
||||
## 完整启动示例
|
||||
|
||||
以下是一些常用的启动命令示例:
|
||||
|
||||
```bash
|
||||
# 使用配置文件和组态图启动
|
||||
unilab -g path/to/graph.json
|
||||
|
||||
# 使用配置文件和分离的设备/资源文件启动
|
||||
unilab -d devices.json -r resources.json
|
||||
```
|
||||
22
package.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos</name>
|
||||
<version>0.0.0</version>
|
||||
<description>ROS2 package for unilabos server</description>
|
||||
<maintainer email="changjh@pku.edu.cn">changjh</maintainer>
|
||||
<license>TODO: License declaration</license>
|
||||
|
||||
<build_depend>action_msgs</build_depend>
|
||||
<exec_depend>action_msgs</exec_depend>
|
||||
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
47
recipes/conda_build_config.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
gazebo:
|
||||
- '11'
|
||||
libpqxx:
|
||||
- 6
|
||||
numpy:
|
||||
- 1.26.*
|
||||
|
||||
cdt_name: # [linux]
|
||||
- cos7 # [linux]
|
||||
|
||||
python:
|
||||
- 3.11.* *_cpython
|
||||
python_impl:
|
||||
- cpython
|
||||
|
||||
# Project overrides
|
||||
macos_min_version: # [osx and x86_64]
|
||||
- 10.14 # [osx and x86_64]
|
||||
macos_machine: # [osx]
|
||||
- x86_64-apple-darwin13.4.0 # [osx and x86_64]
|
||||
- arm64-apple-darwin20.0.0 # [osx and arm64]
|
||||
MACOSX_DEPLOYMENT_TARGET: # [osx]
|
||||
- 11.0 # [osx and arm64]
|
||||
- 10.14 # [osx and x86_64]
|
||||
CONDA_BUILD_SYSROOT:
|
||||
- /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk # [osx and arm64]
|
||||
|
||||
|
||||
# fix build metadata, needed for mapviz and moveit-core
|
||||
replacements:
|
||||
all_replacements:
|
||||
- tag: 'pkg-config build metadata'
|
||||
glob_patterns:
|
||||
- '*.pc'
|
||||
regex_re: '(?:-L|-I)?\"?([^;\s]+\/sysroot\/)'
|
||||
replacement_re: '$(CONDA_BUILD_SYSROOT_S)'
|
||||
regex_rg: '([^;\s"]+/sysroot/)'
|
||||
- tag: 'CMake build metadata'
|
||||
glob_patterns:
|
||||
- '*.cmake'
|
||||
regex_re: '([^;\s"]+/sysroot)'
|
||||
replacement_re: '$ENV{CONDA_BUILD_SYSROOT}'
|
||||
- tag: 'CMake build metadata OSX'
|
||||
glob_patterns:
|
||||
- '*.cmake'
|
||||
regex_re: '([^;\s"]+/MacOSX\d*\.?\d*\.sdk)'
|
||||
replacement_re: '$ENV{CONDA_BUILD_SYSROOT}'
|
||||
41
recipes/ros-humble-unilabos-msgs/bld_ament_cmake.bat
Normal file
@@ -0,0 +1,41 @@
|
||||
:: Generated by vinca http://github.com/RoboStack/vinca.
|
||||
:: DO NOT EDIT!
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
set "PYTHONPATH=%LIBRARY_PREFIX%\lib\site-packages;%SP_DIR%"
|
||||
|
||||
:: MSVC is preferred.
|
||||
set CC=cl.exe
|
||||
set CXX=cl.exe
|
||||
|
||||
rd /s /q build
|
||||
mkdir build
|
||||
pushd build
|
||||
|
||||
:: set "CMAKE_GENERATOR=Ninja"
|
||||
|
||||
:: try to fix long paths issues by using default generator
|
||||
set "CMAKE_GENERATOR=Visual Studio %VS_MAJOR% %VS_YEAR%"
|
||||
set "SP_DIR_FORWARDSLASHES=%SP_DIR:\=/%"
|
||||
|
||||
set PYTHON="%PREFIX%\python.exe"
|
||||
|
||||
cmake ^
|
||||
-G "%CMAKE_GENERATOR%" ^
|
||||
-DCMAKE_INSTALL_PREFIX=%LIBRARY_PREFIX% ^
|
||||
-DCMAKE_BUILD_TYPE=Release ^
|
||||
-DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True ^
|
||||
-DPYTHON_EXECUTABLE=%PYTHON% ^
|
||||
-DPython_EXECUTABLE=%PYTHON% ^
|
||||
-DPython3_EXECUTABLE=%PYTHON% ^
|
||||
-DSETUPTOOLS_DEB_LAYOUT=OFF ^
|
||||
-DBUILD_SHARED_LIBS=ON ^
|
||||
-DBUILD_TESTING=OFF ^
|
||||
-DCMAKE_OBJECT_PATH_MAX=255 ^
|
||||
-DPYTHON_INSTALL_DIR=%SP_DIR_FORWARDSLASHES% ^
|
||||
--compile-no-warning-as-error ^
|
||||
%SRC_DIR%\%PKG_NAME%\src\work
|
||||
if errorlevel 1 exit 1
|
||||
|
||||
cmake --build . --config Release --target install
|
||||
if errorlevel 1 exit 1
|
||||
71
recipes/ros-humble-unilabos-msgs/build_ament_cmake.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
# Generated by vinca http://github.com/RoboStack/vinca.
|
||||
# DO NOT EDIT!
|
||||
|
||||
rm -rf build
|
||||
mkdir build
|
||||
cd build
|
||||
|
||||
# necessary for correctly linking SIP files (from python_qt_bindings)
|
||||
export LINK=$CXX
|
||||
|
||||
if [[ "$CONDA_BUILD_CROSS_COMPILATION" != "1" ]]; then
|
||||
PYTHON_EXECUTABLE=$PREFIX/bin/python
|
||||
PKG_CONFIG_EXECUTABLE=$PREFIX/bin/pkg-config
|
||||
OSX_DEPLOYMENT_TARGET="10.15"
|
||||
else
|
||||
PYTHON_EXECUTABLE=$BUILD_PREFIX/bin/python
|
||||
PKG_CONFIG_EXECUTABLE=$BUILD_PREFIX/bin/pkg-config
|
||||
OSX_DEPLOYMENT_TARGET="11.0"
|
||||
fi
|
||||
|
||||
echo "USING PYTHON_EXECUTABLE=${PYTHON_EXECUTABLE}"
|
||||
echo "USING PKG_CONFIG_EXECUTABLE=${PKG_CONFIG_EXECUTABLE}"
|
||||
|
||||
export ROS_PYTHON_VERSION=`$PYTHON_EXECUTABLE -c "import sys; print('%i.%i' % (sys.version_info[0:2]))"`
|
||||
echo "Using Python ${ROS_PYTHON_VERSION}"
|
||||
# Fix up SP_DIR which for some reason might contain a path to a wrong Python version
|
||||
FIXED_SP_DIR=$(echo $SP_DIR | sed -E "s/python[0-9]+\.[0-9]+/python$ROS_PYTHON_VERSION/")
|
||||
echo "Using site-package dir ${FIXED_SP_DIR}"
|
||||
|
||||
# see https://github.com/conda-forge/cross-python-feedstock/issues/24
|
||||
if [[ "$CONDA_BUILD_CROSS_COMPILATION" == "1" ]]; then
|
||||
find $PREFIX/lib/cmake -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
|
||||
find $PREFIX/share/rosidl* -type f -exec sed -i "s~$PREFIX/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
|
||||
find $PREFIX/share/rosidl* -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
|
||||
find $PREFIX/lib/cmake -type f -exec sed -i "s~message(FATAL_ERROR \"The imported target~message(WARNING \"The imported target~g" {} + || true
|
||||
fi
|
||||
|
||||
if [[ $target_platform =~ linux.* ]]; then
|
||||
export CFLAGS="${CFLAGS} -D__STDC_FORMAT_MACROS=1"
|
||||
export CXXFLAGS="${CXXFLAGS} -D__STDC_FORMAT_MACROS=1"
|
||||
fi;
|
||||
|
||||
# Needed for qt-gui-cpp ..
|
||||
if [[ $target_platform =~ linux.* ]]; then
|
||||
ln -s $GCC ${BUILD_PREFIX}/bin/gcc
|
||||
ln -s $GXX ${BUILD_PREFIX}/bin/g++
|
||||
fi;
|
||||
|
||||
cmake \
|
||||
-G "Ninja" \
|
||||
-DCMAKE_INSTALL_PREFIX=$PREFIX \
|
||||
-DCMAKE_PREFIX_PATH=$PREFIX \
|
||||
-DAMENT_PREFIX_PATH=$PREFIX \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DPYTHON_EXECUTABLE=$PYTHON_EXECUTABLE \
|
||||
-DPython_EXECUTABLE=$PYTHON_EXECUTABLE \
|
||||
-DPython3_EXECUTABLE=$PYTHON_EXECUTABLE \
|
||||
-DPython3_FIND_STRATEGY=LOCATION \
|
||||
-DPKG_CONFIG_EXECUTABLE=$PKG_CONFIG_EXECUTABLE \
|
||||
-DPYTHON_INSTALL_DIR=$FIXED_SP_DIR \
|
||||
-DSETUPTOOLS_DEB_LAYOUT=OFF \
|
||||
-DCATKIN_SKIP_TESTING=$SKIP_TESTING \
|
||||
-DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DBUILD_TESTING=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=$OSX_DEPLOYMENT_TARGET \
|
||||
--compile-no-warning-as-error \
|
||||
$SRC_DIR/$PKG_NAME/src/work
|
||||
|
||||
cmake --build . --config Release --target install
|
||||
61
recipes/ros-humble-unilabos-msgs/recipe.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.8.0
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
folder: ros-humble-unilabos-msgs/src/work
|
||||
|
||||
build:
|
||||
script:
|
||||
sel(win): bld_ament_cmake.bat
|
||||
sel(unix): build_ament_cmake.sh
|
||||
number: 5
|
||||
about:
|
||||
home: https://www.ros.org/
|
||||
license: BSD-3-Clause
|
||||
summary: |
|
||||
Robot Operating System
|
||||
|
||||
extra:
|
||||
recipe-maintainers:
|
||||
- ros-forge
|
||||
|
||||
requirements:
|
||||
build:
|
||||
- "{{ compiler('cxx') }}"
|
||||
- "{{ compiler('c') }}"
|
||||
- sel(linux64): sysroot_linux-64 2.17
|
||||
- ninja
|
||||
- setuptools
|
||||
- sel(unix): make
|
||||
- sel(unix): coreutils
|
||||
- sel(osx): tapi
|
||||
- sel(build_platform != target_platform): pkg-config
|
||||
- cmake
|
||||
- cython
|
||||
- sel(win): vs2022_win-64
|
||||
- sel(build_platform != target_platform): python
|
||||
- sel(build_platform != target_platform): cross-python_{{ target_platform }}
|
||||
- sel(build_platform != target_platform): numpy
|
||||
host:
|
||||
- numpy
|
||||
- pip
|
||||
- sel(build_platform == target_platform): pkg-config
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ament-cmake
|
||||
- robostack-staging::ros-humble-ament-lint-auto
|
||||
- robostack-staging::ros-humble-ament-lint-common
|
||||
- robostack-staging::ros-humble-ros-environment
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-generators
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.6.*
|
||||
run:
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.6.*
|
||||
- sel(osx and x86_64): __osx >={{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||
15
recipes/unilabos/clean_build_dir.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
import shutil
|
||||
for item in os.listdir("../.."):
|
||||
if item.startswith("."):
|
||||
continue
|
||||
if item.endswith(".bat"):
|
||||
continue
|
||||
if item in ("setup.py", "unilabos", "config"):
|
||||
continue
|
||||
print("****", item)
|
||||
if os.path.isfile(item) or os.path.islink(item):
|
||||
os.remove(item)
|
||||
elif os.path.isdir(item):
|
||||
shutil.rmtree(item)
|
||||
print(os.listdir("../.."))
|
||||
23
recipes/unilabos/recipe.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.8.0"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
build:
|
||||
noarch: python
|
||||
script: |
|
||||
{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv
|
||||
# {{ PYTHON }} clean_build_dir.py
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python
|
||||
- pip
|
||||
run:
|
||||
- python
|
||||
|
||||
test:
|
||||
imports:
|
||||
- unilabos
|
||||
4
setup.cfg
Normal file
@@ -0,0 +1,4 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/unilabos
|
||||
[install]
|
||||
install_scripts=$base/lib/unilabos
|
||||
32
setup.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from setuptools import setup, find_packages
|
||||
from glob import glob
|
||||
import os
|
||||
|
||||
package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.8.0',
|
||||
packages=find_packages(),
|
||||
# data_files=[
|
||||
# ('share/ament_index/resource_index/packages',
|
||||
# ['resource/' + package_name]),
|
||||
# ('share/' + package_name, ['package.xml']),
|
||||
# # (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
|
||||
# # (os.path.join('share', package_name, 'urdf'), glob('urdf/*')),
|
||||
# # (os.path.join('share', package_name, 'meshes'), glob('meshes/*')),
|
||||
# # (os.path.join('share', package_name, 'config'), glob('config/*'))
|
||||
# ],
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=True,
|
||||
maintainer='Junhan Chang',
|
||||
maintainer_email='changjh@pku.edu.cn',
|
||||
description='TODO: Package description',
|
||||
license='TODO: License declaration',
|
||||
tests_require=['pytest'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
"unilab = unilabos.app.main:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
966
test/experiments/Grignard_flow_batchreact_single_pumpvalve.json
Normal file
@@ -0,0 +1,966 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "YugongStation",
|
||||
"name": "愚公常量合成工作站",
|
||||
"children": [
|
||||
"serial_pump",
|
||||
"pump_reagents",
|
||||
"flask_CH2Cl2",
|
||||
"flask_acetone",
|
||||
"flask_NH4Cl",
|
||||
"flask_grignard",
|
||||
"flask_THF",
|
||||
"reactor",
|
||||
"pump_workup",
|
||||
"waste_workup",
|
||||
"separator_controller",
|
||||
"flask_separator",
|
||||
"flask_holding",
|
||||
"flask_H2O",
|
||||
"flask_NaHCO3",
|
||||
"pump_column",
|
||||
"rotavap",
|
||||
"flask_rv",
|
||||
"column",
|
||||
"flask_column",
|
||||
"flask_air",
|
||||
"dry_column",
|
||||
"flask_dry_column",
|
||||
"pump_ext",
|
||||
"stirrer"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": ["PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol"]
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"name": "serial_pump",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "serial",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "COM7",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pump_reagents",
|
||||
"name": "pump_reagents",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "1",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_CH2Cl2",
|
||||
"name": "flask_CH2Cl2",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 430.4087301587302,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "CH2Cl2",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_acetone",
|
||||
"name": "flask_acetone",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 295.36944444444447,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "acetone",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_NH4Cl",
|
||||
"name": "flask_NH4Cl",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 165.36944444444444,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "NH4Cl",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_grignard",
|
||||
"name": "flask_grignard",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 165.36944444444444,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "grignard",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_THF",
|
||||
"name": "flask_THF",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 35,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "THF",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"name": "reactor",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 5000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "stirrer",
|
||||
"name": "stirrer",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "heaterstirrer.dalong",
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 478,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "COM43",
|
||||
"temp_warning": 60.0
|
||||
},
|
||||
"data": {
|
||||
"status": "Idle",
|
||||
"temp": 0.0,
|
||||
"stir_speed": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pump_workup",
|
||||
"name": "pump_workup",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 1195.611507936508,
|
||||
"y": 686,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "2",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "waste_workup",
|
||||
"name": "waste_workup",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1587.703373015873,
|
||||
"y": 1172.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "separator_controller",
|
||||
"name": "separator_controller",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "separator_controller",
|
||||
"position": {
|
||||
"x": 1624.4027777777778,
|
||||
"y": 665.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port_executor": "COM41",
|
||||
"port_sensor": "COM40"
|
||||
},
|
||||
"data": {
|
||||
"sensordata": 0.0,
|
||||
"status": "Idle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_separator",
|
||||
"name": "flask_separator",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1614.404365079365,
|
||||
"y": 948,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_holding",
|
||||
"name": "flask_holding",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1915.7035714285714,
|
||||
"y": 665.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_H2O",
|
||||
"name": "flask_H2O",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1785.7035714285714,
|
||||
"y": 665.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "H2O",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_NaHCO3",
|
||||
"name": "flask_NaHCO3",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 2054.0650793650793,
|
||||
"y": 665.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
{
|
||||
"liquid_type": "NaHCO3",
|
||||
"liquid_volume": 1500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pump_column",
|
||||
"name": "pump_column",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 1630.6527777777778,
|
||||
"y": 448.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "3",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rotavap",
|
||||
"name": "rotavap",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "rotavap",
|
||||
"position": {
|
||||
"x": 1339.7031746031746,
|
||||
"y": 968.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "COM15"
|
||||
},
|
||||
"data": {
|
||||
"temperature": 0.0,
|
||||
"rotate_time": 0.0,
|
||||
"status": "Idle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_rv",
|
||||
"name": "flask_rv",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1339.7031746031746,
|
||||
"y": 1152,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "column",
|
||||
"name": "column",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 909.722619047619,
|
||||
"y": 948,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 200.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_column",
|
||||
"name": "flask_column",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 867.972619047619,
|
||||
"y": 1152,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_air",
|
||||
"name": "flask_air",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 742.722619047619,
|
||||
"y": 948,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dry_column",
|
||||
"name": "dry_column",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1206.722619047619,
|
||||
"y": 948,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 200.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flask_dry_column",
|
||||
"name": "flask_dry_column",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 1148.222619047619,
|
||||
"y": 1152,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 2000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pump_ext",
|
||||
"name": "pump_ext",
|
||||
"children": [],
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 1469.7031746031746,
|
||||
"y": 968.5,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "4",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "AGV",
|
||||
"name": "AGV",
|
||||
"children": ["zhixing_agv", "zhixing_ur_arm"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 478,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": ["AGVTransferProtocol"]
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zhixing_agv",
|
||||
"name": "zhixing_agv",
|
||||
"children": [],
|
||||
"parent": "AGV",
|
||||
"type": "device",
|
||||
"class": "zhixing_agv",
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 478,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"host": "192.168.1.42"
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zhixing_ur_arm",
|
||||
"name": "zhixing_ur_arm",
|
||||
"children": [],
|
||||
"parent": "AGV",
|
||||
"type": "device",
|
||||
"class": "zhixing_ur_arm",
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 478,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"host": "192.168.1.178"
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_ext",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "reactor",
|
||||
"target": "pump_reagents",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"reactor": "top",
|
||||
"pump_reagents": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "rotavap",
|
||||
"target": "flask_rv",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"rotavap": "bottom",
|
||||
"flask_rv": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "separator_controller",
|
||||
"target": "flask_separator",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"separator_controller": "bottom",
|
||||
"flask_separator": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "column",
|
||||
"target": "flask_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"column": "bottom",
|
||||
"flask_column": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "dry_column",
|
||||
"target": "flask_dry_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"dry_column": "bottom",
|
||||
"flask_dry_column": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_ext",
|
||||
"target": "pump_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_ext": "8",
|
||||
"pump_column": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_ext",
|
||||
"target": "waste_workup",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_ext": "2",
|
||||
"waste_workup": "-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "flask_THF",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "7",
|
||||
"flask_THF": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "flask_NH4Cl",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "4",
|
||||
"flask_NH4Cl": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "flask_CH2Cl2",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "2",
|
||||
"flask_CH2Cl2": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "flask_acetone",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "3",
|
||||
"flask_acetone": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "pump_workup",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "1",
|
||||
"pump_workup": "8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "flask_grignard",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "6",
|
||||
"flask_grignard": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "reactor",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "5",
|
||||
"reactor": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "flask_air",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_reagents": "8",
|
||||
"flask_air": "-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "waste_workup",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "2",
|
||||
"waste_workup": "-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "flask_H2O",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "7",
|
||||
"flask_H2O": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "flask_NaHCO3",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "6",
|
||||
"flask_NaHCO3": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "pump_reagents",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "8",
|
||||
"pump_reagents": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "flask_holding",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "5",
|
||||
"flask_holding": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "separator_controller",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "4",
|
||||
"separator_controller": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "flask_separator",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "3",
|
||||
"flask_separator": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "pump_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_workup": "1",
|
||||
"pump_column": "8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "4",
|
||||
"column": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "flask_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "3",
|
||||
"flask_column": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "rotavap",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "2",
|
||||
"rotavap": "-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "pump_workup",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "8",
|
||||
"pump_workup": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "flask_air",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "5",
|
||||
"flask_air": "-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "dry_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "7",
|
||||
"dry_column": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "flask_dry_column",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "6",
|
||||
"flask_dry_column": "top"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_column",
|
||||
"target": "pump_ext",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"pump_column": "1",
|
||||
"pump_ext": "8"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
174
test/experiments/HPLC.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "HPLC",
|
||||
"name": "HPLC",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "hplc",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
"BottlesRack3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BottlesRack3",
|
||||
"name": "Revvity上样盘3",
|
||||
"parent": "Revvity",
|
||||
"type": "plate",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
"Bottle3-1",
|
||||
"Bottle3-2",
|
||||
"Bottle3-3",
|
||||
"Bottle3-4",
|
||||
"Bottle3-5",
|
||||
"Bottle3-6",
|
||||
"Bottle3-7",
|
||||
"Bottle3-8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-1",
|
||||
"name": "Bottle3-1",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-2",
|
||||
"name": "Bottle3-2",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-3",
|
||||
"name": "Bottle3-3",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-4",
|
||||
"name": "Bottle3-4",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-5",
|
||||
"name": "Bottle3-5",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-6",
|
||||
"name": "Bottle3-6",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-7",
|
||||
"name": "Bottle3-7",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Bottle3-8",
|
||||
"name": "Bottle3-8",
|
||||
"parent": "BottlesRack3",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": [
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
4806
test/experiments/HT_hiwo.json
Normal file
21
test/experiments/deis_control_config.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
controller_manager:
|
||||
ros__parameters:
|
||||
update_rate: 1.0 # 更新频率,单位 Hz. 20s 更新一次
|
||||
|
||||
# 控制器列表
|
||||
controllers:
|
||||
deis_current_controller:
|
||||
type: EISModelBasedController # PID 控制器类型
|
||||
inputs:
|
||||
eis: # 环境输入(电化学阻抗谱)
|
||||
topic: /devices/BioLogic/EISdata # 输入话题
|
||||
type: list[float] # 输入数据类型
|
||||
outputs:
|
||||
current_control: # 控制输出(充电电流)
|
||||
topic: /devices/BioLogic/current_control # 输出话题
|
||||
type: float # 输出数据类型
|
||||
parameters:
|
||||
# set_point: 22.0 # 目标温度(可以动态更新)
|
||||
# kp: 1.0 # PID 参数
|
||||
# ki: 0.1
|
||||
# kd: 0.05
|
||||
54
test/experiments/devices.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"PumpBackbone": {
|
||||
"class": "protocol",
|
||||
"protocol_type": ["PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol"],
|
||||
"children": {
|
||||
"Serial": {
|
||||
"class": "serial",
|
||||
"port": "COM7",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"pump_reagents": {
|
||||
"class": "syringepump.runze",
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "1"
|
||||
},
|
||||
"pump_workup": {
|
||||
"class": "syringepump.runze",
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "2"
|
||||
},
|
||||
"pump_column": {
|
||||
"class": "syringepump.runze",
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "3"
|
||||
},
|
||||
"pump_ext": {
|
||||
"class": "syringepump.runze",
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "4"
|
||||
},
|
||||
"separator_controller": {
|
||||
"class": "separator_controller",
|
||||
"port_executor": "COM41",
|
||||
"port_sensor": "COM40"
|
||||
},
|
||||
"rotavap_chiller": {
|
||||
"class": "chiller",
|
||||
"port": "COM17"
|
||||
},
|
||||
"rotavap_controller": {
|
||||
"class": "rotavap",
|
||||
"port": "COM15"
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
"AichemecoHiWo": {
|
||||
"class": "work_station.aichemeco_hiwo"
|
||||
},
|
||||
"Stirrer": {
|
||||
"class": "heaterstirrer.dalong",
|
||||
"port": "COM43"
|
||||
}
|
||||
}
|
||||
1
test/experiments/empty_devices.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1233
test/experiments/lidocaine-graph.json
Normal file
158
test/experiments/mock_reactor.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ReactorX",
|
||||
"name": "模拟常量合成工作站",
|
||||
"children": [
|
||||
"reactor",
|
||||
"vacuum_valve",
|
||||
"gas_valve",
|
||||
"vacuum_pump",
|
||||
"gas_source"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": ["EvacuateAndRefillProtocol"]
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"name": "reactor",
|
||||
"children": [],
|
||||
"parent": "ReactorX",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 5000.0
|
||||
},
|
||||
"data": {
|
||||
"liquid": [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vacuum_valve",
|
||||
"name": "vacuum_valve",
|
||||
"children": [
|
||||
],
|
||||
"parent": "ReactorX",
|
||||
"type": "device",
|
||||
"class": "solenoid_valve.mock",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gas_valve",
|
||||
"name": "gas_valve",
|
||||
"children": [
|
||||
],
|
||||
"parent": "ReactorX",
|
||||
"type": "device",
|
||||
"class": "solenoid_valve.mock",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vacuum_pump",
|
||||
"name": "vacuum_pump",
|
||||
"children": [
|
||||
],
|
||||
"parent": "ReactorX",
|
||||
"type": "device",
|
||||
"class": "vacuum_pump.mock",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gas_source",
|
||||
"name": "gas_source",
|
||||
"children": [
|
||||
],
|
||||
"parent": "ReactorX",
|
||||
"type": "device",
|
||||
"class": "gas_source.mock",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "reactor",
|
||||
"target": "vacuum_valve",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"reactor": "top",
|
||||
"vacuum_valve": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "reactor",
|
||||
"target": "gas_valve",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"reactor": "top",
|
||||
"gas_valve": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "vacuum_pump",
|
||||
"target": "vacuum_valve",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"vacuum_pump": "out",
|
||||
"vacuum_valve": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "gas_source",
|
||||
"target": "gas_valve",
|
||||
"type": "physical",
|
||||
"port": {
|
||||
"gas_source": "out",
|
||||
"gas_valve": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
10553
test/experiments/plr_test.json
Normal file
43
test/experiments/test.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "Gripper1",
|
||||
"name": "假夹爪",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "gripper.mock",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "Plate1",
|
||||
"name": "Plate1",
|
||||
"children": [
|
||||
],
|
||||
"parent": null,
|
||||
"type": "plate",
|
||||
"class": "nest_96_wellplate_2ml_deep",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
|
||||
]
|
||||
}
|
||||
1
test/ros/msgs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 消息转换器测试包
|
||||
71
test/ros/msgs/test_basic.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
基本测试
|
||||
|
||||
测试消息转换器的基本功能,包括导入、类型映射等。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
msg_converter_manager,
|
||||
get_msg_type,
|
||||
get_action_type,
|
||||
get_ros_type_by_msgname,
|
||||
Point3D,
|
||||
Point,
|
||||
Float64,
|
||||
String,
|
||||
Bool,
|
||||
Int32,
|
||||
)
|
||||
|
||||
|
||||
class TestBasicFunctionality(unittest.TestCase):
|
||||
"""测试消息转换器的基本功能"""
|
||||
|
||||
def test_manager_initialization(self):
|
||||
"""测试导入管理器初始化"""
|
||||
self.assertIsNotNone(msg_converter_manager)
|
||||
self.assertTrue(len(msg_converter_manager.list_modules()) > 0)
|
||||
self.assertTrue(len(msg_converter_manager.list_classes()) > 0)
|
||||
|
||||
def test_get_msg_type(self):
|
||||
"""测试获取消息类型"""
|
||||
self.assertEqual(get_msg_type(float), Float64)
|
||||
self.assertEqual(get_msg_type(str), String)
|
||||
self.assertEqual(get_msg_type(bool), Bool)
|
||||
self.assertEqual(get_msg_type(int), Int32)
|
||||
self.assertEqual(get_msg_type(Point3D), Point)
|
||||
|
||||
# 测试错误情况
|
||||
with self.assertRaises(ValueError):
|
||||
get_msg_type(set) # 不支持的类型
|
||||
|
||||
def test_get_action_type(self):
|
||||
"""测试获取动作类型"""
|
||||
float_action = get_action_type(float)
|
||||
self.assertIsNotNone(float_action)
|
||||
self.assertTrue("type" in float_action)
|
||||
self.assertTrue("goal" in float_action)
|
||||
self.assertTrue("feedback" in float_action)
|
||||
|
||||
# 测试错误情况
|
||||
with self.assertRaises(ValueError):
|
||||
get_action_type(set) # 不支持的类型
|
||||
|
||||
def test_get_ros_type_by_msgname(self):
|
||||
"""测试通过消息名称获取ROS类型"""
|
||||
# 测试有效的消息名称
|
||||
point_type = get_ros_type_by_msgname("geometry_msgs/msg/Point")
|
||||
self.assertEqual(point_type, Point)
|
||||
|
||||
# 测试无效的消息名称
|
||||
with self.assertRaises(ValueError):
|
||||
get_ros_type_by_msgname("invalid_format")
|
||||
|
||||
# 不存在的消息类型可能会引发ImportError,但这依赖于运行环境
|
||||
# 因此不进行显式测试
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
131
test/ros/msgs/test_conversion.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
转换测试
|
||||
|
||||
测试Python对象和ROS消息之间的转换功能。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
convert_to_ros_msg,
|
||||
convert_from_ros_msg,
|
||||
convert_to_ros_msg_with_mapping,
|
||||
convert_from_ros_msg_with_mapping,
|
||||
Point,
|
||||
Float64,
|
||||
String,
|
||||
Point3D,
|
||||
Resource,
|
||||
)
|
||||
|
||||
|
||||
# 定义一些测试数据类
|
||||
@dataclass
|
||||
class TestPoint:
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
z: float = 0.0
|
||||
|
||||
|
||||
class TestBasicConversion(unittest.TestCase):
|
||||
"""测试基本类型转换"""
|
||||
|
||||
def test_primitive_conversion(self):
|
||||
"""测试原始类型转换"""
|
||||
# Float转换
|
||||
float_value = 3.14
|
||||
ros_float = convert_to_ros_msg(Float64, float_value)
|
||||
self.assertEqual(ros_float.data, float_value)
|
||||
|
||||
# 反向转换
|
||||
py_float = convert_from_ros_msg(ros_float)
|
||||
self.assertEqual(py_float, float_value)
|
||||
|
||||
# 字符串转换
|
||||
str_value = "hello"
|
||||
ros_str = convert_to_ros_msg(String, str_value)
|
||||
self.assertEqual(ros_str.data, str_value)
|
||||
|
||||
# 反向转换
|
||||
py_str = convert_from_ros_msg(ros_str)
|
||||
self.assertEqual(py_str, str_value)
|
||||
|
||||
def test_point_conversion(self):
|
||||
"""测试点类型转换"""
|
||||
# 创建Point3D对象
|
||||
py_point = Point3D(x=1.0, y=2.0, z=3.0)
|
||||
|
||||
# 转换为ROS Point
|
||||
ros_point = convert_to_ros_msg(Point, py_point)
|
||||
self.assertEqual(ros_point.x, py_point.x)
|
||||
self.assertEqual(ros_point.y, py_point.y)
|
||||
self.assertEqual(ros_point.z, py_point.z)
|
||||
|
||||
# 反向转换
|
||||
py_point_back = convert_from_ros_msg(ros_point)
|
||||
self.assertEqual(py_point_back.x, py_point.x)
|
||||
self.assertEqual(py_point_back.y, py_point.y)
|
||||
self.assertEqual(py_point_back.z, py_point.z)
|
||||
|
||||
def test_dataclass_conversion(self):
|
||||
"""测试dataclass转换"""
|
||||
# 创建dataclass
|
||||
test_point = TestPoint(x=1.0, y=2.0, z=3.0)
|
||||
|
||||
# 转换
|
||||
ros_point = convert_to_ros_msg(Point, test_point)
|
||||
self.assertEqual(ros_point.x, test_point.x)
|
||||
self.assertEqual(ros_point.y, test_point.y)
|
||||
self.assertEqual(ros_point.z, test_point.z)
|
||||
|
||||
|
||||
class TestMappingConversion(unittest.TestCase):
|
||||
"""测试映射转换功能"""
|
||||
|
||||
def test_mapping_conversion(self):
|
||||
"""测试带映射的转换"""
|
||||
# 创建测试数据
|
||||
test_data = {
|
||||
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
"name": "test_resource",
|
||||
"id": "123",
|
||||
"type": "test_type",
|
||||
}
|
||||
|
||||
# 定义映射
|
||||
mapping = {
|
||||
"id": "id",
|
||||
"name": "name",
|
||||
"type": "type",
|
||||
"pose.position": "position",
|
||||
}
|
||||
|
||||
# 转换为ROS资源
|
||||
ros_resource = convert_to_ros_msg_with_mapping(Resource, test_data, mapping)
|
||||
self.assertEqual(ros_resource.id, "123")
|
||||
self.assertEqual(ros_resource.name, "test_resource")
|
||||
self.assertEqual(ros_resource.type, "test_type")
|
||||
self.assertEqual(ros_resource.pose.position.x, 1.0)
|
||||
self.assertEqual(ros_resource.pose.position.y, 2.0)
|
||||
self.assertEqual(ros_resource.pose.position.z, 3.0)
|
||||
|
||||
# 反向转换
|
||||
reverse_mapping = {
|
||||
"id": "id",
|
||||
"name": "name",
|
||||
"type": "type",
|
||||
"pose.position": "position",
|
||||
}
|
||||
|
||||
py_data = convert_from_ros_msg_with_mapping(ros_resource, reverse_mapping)
|
||||
self.assertEqual(py_data["id"], "123")
|
||||
self.assertEqual(py_data["name"], "test_resource")
|
||||
self.assertEqual(py_data["type"], "test_type")
|
||||
self.assertEqual(py_data["position"].x, 1.0)
|
||||
self.assertEqual(py_data["position"].y, 2.0)
|
||||
self.assertEqual(py_data["position"].z, 3.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
120
test/ros/msgs/test_mapping.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
映射测试
|
||||
|
||||
测试消息类型映射和字段映射功能。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
_msg_mapping,
|
||||
_action_mapping,
|
||||
_msg_converter,
|
||||
_msg_converter_back,
|
||||
compare_model_fields,
|
||||
Point,
|
||||
Point3D,
|
||||
Float64,
|
||||
String,
|
||||
set_msg_data,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestMappingModel:
|
||||
"""用于测试映射的数据类"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
value: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestPointModel:
|
||||
"""用于测试字段比较的点模型"""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class TestTypeMapping(unittest.TestCase):
|
||||
"""测试类型映射"""
|
||||
|
||||
def test_msg_mapping(self):
|
||||
"""测试消息类型映射"""
|
||||
self.assertIn(float, _msg_mapping)
|
||||
self.assertEqual(_msg_mapping[float], Float64)
|
||||
|
||||
self.assertIn(str, _msg_mapping)
|
||||
self.assertEqual(_msg_mapping[str], String)
|
||||
|
||||
self.assertIn(Point3D, _msg_mapping)
|
||||
self.assertEqual(_msg_mapping[Point3D], Point)
|
||||
|
||||
def test_action_mapping(self):
|
||||
"""测试动作类型映射"""
|
||||
self.assertIn(float, _action_mapping)
|
||||
self.assertIn("type", _action_mapping[float])
|
||||
self.assertIn("goal", _action_mapping[float])
|
||||
self.assertIn("feedback", _action_mapping[float])
|
||||
self.assertIn("result", _action_mapping[float])
|
||||
|
||||
def test_converter_mapping(self):
|
||||
"""测试转换器映射"""
|
||||
# 测试Python到ROS映射
|
||||
self.assertIn(float, _msg_converter)
|
||||
self.assertIn(Float64, _msg_converter)
|
||||
self.assertIn(String, _msg_converter)
|
||||
self.assertIn(Point, _msg_converter)
|
||||
|
||||
# 测试ROS到Python映射
|
||||
self.assertIn(float, _msg_converter_back)
|
||||
self.assertIn(Float64, _msg_converter_back)
|
||||
self.assertIn(String, _msg_converter_back)
|
||||
self.assertIn(Point, _msg_converter_back)
|
||||
|
||||
|
||||
class TestFieldMapping(unittest.TestCase):
|
||||
"""测试字段映射"""
|
||||
|
||||
def test_compare_model_fields(self):
|
||||
"""测试模型字段比较"""
|
||||
# Point3D和TestPointModel有相同的字段
|
||||
self.assertTrue(compare_model_fields(Point3D, TestPointModel))
|
||||
|
||||
# 与其他类型比较
|
||||
self.assertFalse(compare_model_fields(Point3D, TestMappingModel))
|
||||
self.assertFalse(compare_model_fields(Point3D, float))
|
||||
|
||||
# 类型对象和实例对象比较
|
||||
point = Point3D(x=1.0, y=2.0, z=3.0)
|
||||
self.assertTrue(compare_model_fields(Point3D, type(point)))
|
||||
|
||||
def test_set_msg_data(self):
|
||||
"""测试设置消息数据类型"""
|
||||
# 测试float转换
|
||||
float_value = "3.14"
|
||||
self.assertEqual(set_msg_data("float", float_value), 3.14)
|
||||
self.assertEqual(set_msg_data("double", float_value), 3.14)
|
||||
|
||||
# 测试int转换
|
||||
int_value = "42"
|
||||
self.assertEqual(set_msg_data("int", int_value), 42)
|
||||
|
||||
# 测试bool转换
|
||||
bool_value = "True"
|
||||
self.assertEqual(set_msg_data("bool", bool_value), True)
|
||||
|
||||
# 测试str转换
|
||||
str_value = "hello"
|
||||
self.assertEqual(set_msg_data("str", str_value), "hello")
|
||||
|
||||
# 测试默认转换
|
||||
default_value = 123
|
||||
self.assertEqual(set_msg_data("unknown_type", default_value), "123")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
47
test/ros/msgs/test_runner.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
测试运行器
|
||||
|
||||
运行所有消息转换器的测试用例。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
# 导入测试模块
|
||||
from test.ros.msgs.test_basic import TestBasicFunctionality
|
||||
from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
||||
from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""运行所有测试"""
|
||||
# 创建测试加载器
|
||||
loader = unittest.TestLoader()
|
||||
|
||||
# 创建测试套件
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# 添加测试类
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestBasicFunctionality))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestBasicConversion))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestMappingConversion))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestTypeMapping))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestFieldMapping))
|
||||
|
||||
# 创建测试运行器
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
|
||||
# 运行测试
|
||||
result = runner.run(suite)
|
||||
|
||||
# 返回结果
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(not success)
|
||||
61
unilabos-linux-64.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: unilab
|
||||
channels:
|
||||
- robostack
|
||||
- robostack-staging
|
||||
- conda-forge
|
||||
dependencies:
|
||||
# Basics
|
||||
- python=3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- make
|
||||
- ninja
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Data Visualization
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
# Device communication
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
# Service
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
# ros
|
||||
- colcon-common-extensions
|
||||
- ros-humble-desktop-full
|
||||
- ros-humble-control-msgs
|
||||
- ros-humble-sensor-msgs
|
||||
- ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# web
|
||||
- ros-humble-rosbridge-server
|
||||
# geometry & motion planning
|
||||
- ros-humble-tf2
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
# simulation
|
||||
- ros-humble-simulation
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
# - ros-humble-unilabos-msgs
|
||||
61
unilabos-osx-64.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: unilab
|
||||
channels:
|
||||
- robostack
|
||||
- robostack-staging
|
||||
- conda-forge
|
||||
dependencies:
|
||||
# Basics
|
||||
- python=3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- make
|
||||
- ninja
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Data Visualization
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
# Device communication
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
# Service
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
# ros
|
||||
- colcon-common-extensions
|
||||
- ros-humble-desktop-full
|
||||
- ros-humble-control-msgs
|
||||
- ros-humble-sensor-msgs
|
||||
- ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# web
|
||||
- ros-humble-rosbridge-server
|
||||
# geometry & motion planning
|
||||
- ros-humble-tf2
|
||||
# - ros-humble-moveit // ignored because of NO python3.11 package in OSX
|
||||
# - ros-humble-moveit-servo
|
||||
# simulation
|
||||
- ros-humble-simulation
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
# - ros-humble-unilabos-msgs
|
||||
63
unilabos-osx-arm64.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: unilab
|
||||
channels:
|
||||
- robostack
|
||||
- robostack-staging
|
||||
- conda-forge
|
||||
dependencies:
|
||||
# Basics
|
||||
- python=3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- make
|
||||
- ninja
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Data Visualization
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
- openpyxl
|
||||
# Device communication
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
# Service
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
- paho-mqtt
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
# ros
|
||||
- colcon-common-extensions
|
||||
- ros-humble-desktop-full
|
||||
- ros-humble-control-msgs
|
||||
- ros-humble-sensor-msgs
|
||||
- ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# web
|
||||
- ros-humble-rosbridge-server
|
||||
# geometry & motion planning
|
||||
- ros-humble-tf2
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
# simulation
|
||||
- ros-humble-simulation
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
# - ros-humble-unilabos-msgs
|
||||
61
unilabos-win64.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: unilab
|
||||
channels:
|
||||
- robostack
|
||||
- robostack-staging
|
||||
- conda-forge
|
||||
dependencies:
|
||||
# Basics
|
||||
- python=3.11.11
|
||||
- compilers
|
||||
- cmake
|
||||
- make
|
||||
- ninja
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
# Data Visualization
|
||||
- numpy
|
||||
- scipy
|
||||
- pandas
|
||||
- networkx
|
||||
- matplotlib
|
||||
- pint
|
||||
# Device communication
|
||||
- pyserial
|
||||
- pyusb
|
||||
- pylibftdi
|
||||
- pymodbus
|
||||
- python-can
|
||||
- pyvisa
|
||||
- opencv
|
||||
# Service
|
||||
- pydantic
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- gradio
|
||||
- flask
|
||||
- websocket
|
||||
# Notebook
|
||||
- ipython
|
||||
- jupyter
|
||||
- jupyros
|
||||
# ros
|
||||
- colcon-common-extensions
|
||||
- ros-humble-desktop-full
|
||||
- ros-humble-control-msgs
|
||||
- ros-humble-sensor-msgs
|
||||
- ros-humble-trajectory-msgs
|
||||
- ros-humble-navigation2
|
||||
- ros-humble-ros2-control
|
||||
- ros-humble-robot-state-publisher
|
||||
- ros-humble-joint-state-publisher
|
||||
# web
|
||||
- ros-humble-rosbridge-server
|
||||
# geometry & motion planning
|
||||
- ros-humble-tf2
|
||||
- ros-humble-moveit
|
||||
- ros-humble-moveit-servo
|
||||
# simulation
|
||||
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64
|
||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||
# ilab equipments
|
||||
# - ros-humble-unilabos-msgs
|
||||
0
unilabos/__init__.py
Normal file
0
unilabos/app/__init__.py
Normal file
35
unilabos/app/backend.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import threading
|
||||
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
# 根据选择的 backend 启动相应的功能
|
||||
def start_backend(
|
||||
backend: str,
|
||||
devices_config: dict = {},
|
||||
resources_config: dict = {},
|
||||
graph=None,
|
||||
controllers_config: dict = {},
|
||||
bridges=[],
|
||||
without_host: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
if backend == "ros":
|
||||
# 假设 ros_main, simple_main, automancer_main 是不同 backend 的启动函数
|
||||
from unilabos.ros.main_slave_run import main, slave # 如果选择 'ros' 作为 backend
|
||||
elif backend == 'simple':
|
||||
# 这里假设 simple_backend 和 automancer_backend 是你定义的其他两个后端
|
||||
# from simple_backend import main as simple_main
|
||||
pass
|
||||
elif backend == 'automancer':
|
||||
# from automancer_backend import main as automancer_main
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f"Unsupported backend: {backend}")
|
||||
|
||||
backend_thread = threading.Thread(
|
||||
target=main if not without_host else slave,
|
||||
args=(devices_config, resources_config, graph, controllers_config, bridges)
|
||||
)
|
||||
backend_thread.start()
|
||||
logger.info(f"Backend {backend} started.")
|
||||
34
unilabos/app/controler.py
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
|
||||
|
||||
def get_resources() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().resources_config
|
||||
|
||||
def devices() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().devices_config
|
||||
|
||||
def job_info(id: str):
|
||||
get_goal_status = HostNode.get_instance().get_goal_status(id)
|
||||
return JobData(jobId=id, status=get_goal_status)
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
if req.job_id is None:
|
||||
req.job_id = str(uuid.uuid4())
|
||||
action_name = req.data["action"]
|
||||
action_kwargs = req.data["action_kwargs"]
|
||||
req.data['action'] = action_name
|
||||
if action_name == "execute_command_from_outer":
|
||||
action_kwargs = {"command": json.dumps(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)
|
||||
return JobData(jobId=req.job_id)
|
||||
155
unilabos/app/main.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
from copy import deepcopy
|
||||
|
||||
# 首先添加项目根目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
ilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
if ilabos_dir not in sys.path:
|
||||
sys.path.append(ilabos_dir)
|
||||
|
||||
from unilabos.config.config import load_config, BasicConfig
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||
parser.add_argument("-g", "--graph", help="Physical setup graph.")
|
||||
parser.add_argument("-d", "--devices", help="Devices config file.")
|
||||
parser.add_argument("-r", "--resources", help="Resources config file.")
|
||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file.")
|
||||
parser.add_argument(
|
||||
"--registry_path",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to the registry",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=["ros", "simple", "automancer"],
|
||||
default="ros",
|
||||
help="Choose the backend to run with: 'ros', 'simple', or 'automancer'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--app_bridges",
|
||||
nargs="+",
|
||||
default=["mqtt", "fastapi"],
|
||||
help="Bridges to connect to. Now support 'mqtt' and 'fastapi'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--without_host",
|
||||
action="store_true",
|
||||
help="Run the backend as slave (without host).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slave_no_host",
|
||||
action="store_true",
|
||||
help="Slave模式下跳过等待host服务",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
default=None,
|
||||
help="配置文件路径,支持.py格式的Python配置文件",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
args = parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# 加载配置文件 - 这里保持最先加载配置的逻辑
|
||||
if args_dict.get("config"):
|
||||
config_path = args_dict["config"]
|
||||
if not os.path.exists(config_path):
|
||||
print_status(f"配置文件 {config_path} 不存在", "error")
|
||||
elif not config_path.endswith(".py"):
|
||||
print_status(f"配置文件 {config_path} 不是Python文件,必须以.py结尾", "error")
|
||||
else:
|
||||
load_config(config_path)
|
||||
|
||||
# 设置BasicConfig参数
|
||||
BasicConfig.is_host_mode = not args_dict.get("without_host", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
dict_from_graph,
|
||||
dict_to_nested_dict,
|
||||
initialize_resources,
|
||||
)
|
||||
from unilabos.app.mq import mqtt_client
|
||||
from unilabos.registry.registry import build_registry
|
||||
from unilabos.app.backend import start_backend
|
||||
from unilabos.web import http_client
|
||||
from unilabos.web import start_server
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
build_registry(args_dict["registry_path"])
|
||||
|
||||
if args_dict["graph"] is not None:
|
||||
import unilabos.resources.graphio as graph_res
|
||||
graph_res.physical_setup_graph = (
|
||||
read_node_link_json(args_dict["graph"])
|
||||
if args_dict["graph"].endswith(".json")
|
||||
else read_graphml(args_dict["graph"])
|
||||
)
|
||||
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
|
||||
args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
|
||||
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
|
||||
# args_dict["resources_config"] = dict_to_tree(devices_and_resources, devices_only=False)
|
||||
args_dict["graph"] = graph_res.physical_setup_graph
|
||||
else:
|
||||
if args_dict["devices"] is None or args_dict["resources"] is None:
|
||||
print_status("Either graph or devices and resources must be provided.", "error")
|
||||
sys.exit(1)
|
||||
args_dict["devices_config"] = json.load(open(args_dict["devices"], encoding="utf-8"))
|
||||
args_dict["resources_config"] = initialize_resources(
|
||||
list(json.load(open(args_dict["resources"], encoding="utf-8")).values())
|
||||
)
|
||||
|
||||
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
|
||||
for i in args_dict["resources_config"]:
|
||||
print_status(f"DeviceId: {i['id']}, Class: {i['class']}", "info")
|
||||
|
||||
if args_dict["controllers"] is not None:
|
||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||
else:
|
||||
args_dict["controllers_config"] = None
|
||||
|
||||
args_dict["bridges"] = []
|
||||
|
||||
if "mqtt" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(mqtt_client)
|
||||
if "fastapi" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(http_client)
|
||||
if "mqtt" in args_dict["app_bridges"]:
|
||||
|
||||
def _exit(signum, frame):
|
||||
mqtt_client.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _exit)
|
||||
signal.signal(signal.SIGTERM, _exit)
|
||||
mqtt_client.start()
|
||||
|
||||
start_backend(**args_dict)
|
||||
start_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
137
unilabos/app/model.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RespCode:
|
||||
Success = 0
|
||||
|
||||
ErrorHostNotInit = 2001 # Host node not initialized
|
||||
ErrorInvalidReq = 2002 # Invalid request data
|
||||
|
||||
|
||||
class DeviceAction(BaseModel):
|
||||
x: str
|
||||
y: str
|
||||
action: str
|
||||
|
||||
|
||||
class Device(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
action: DeviceAction
|
||||
|
||||
|
||||
class DeviceList(BaseModel):
|
||||
items: list[Device] = []
|
||||
page: int
|
||||
pageSize: int
|
||||
|
||||
|
||||
class DevicesResponse(BaseModel):
|
||||
code: int
|
||||
data: DeviceList
|
||||
|
||||
|
||||
class DeviceInfoResponse(BaseModel):
|
||||
code: int
|
||||
data: Device
|
||||
|
||||
|
||||
class PageResp(BaseModel):
|
||||
item: list = []
|
||||
page: int = 1
|
||||
pageSize: int = 10
|
||||
|
||||
|
||||
class Resp(BaseModel):
|
||||
code: int = RespCode.Success
|
||||
data: dict = {}
|
||||
message: str = "success"
|
||||
|
||||
|
||||
class JobAddReq(BaseModel):
|
||||
device_id: str = Field(examples=["Gripper"], description="device id")
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
|
||||
job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid")
|
||||
node_id: str = Field(examples=["sfsfsfeq"], description="node uuid")
|
||||
|
||||
|
||||
class JobStepFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
"orderCode": "任务号。字符串",
|
||||
"orderName": "任务名称。字符串",
|
||||
"stepName": "步骤名称。字符串",
|
||||
"stepId": "步骤Id。GUID",
|
||||
"sampleId": "通量Id。GUID",
|
||||
"startTime": "开始时间。时间格式",
|
||||
"endTime": "完成时间。时间格式",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class JobPreintakeFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
"orderCode": "任务号。字符串",
|
||||
"orderName": "任务名称。字符串",
|
||||
"sampleId": "通量Id。GUID",
|
||||
"startTime": "开始时间。时间格式",
|
||||
"endTime": "完成时间。时间格式",
|
||||
"Status": "通量状态,0待生产、2进样、10开始、完成20、异常停止-2、人工停止或取消-3",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class JobFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
"orderCode": "任务号。字符串",
|
||||
"orderName": "任务名称。字符串",
|
||||
"startTime": "开始时间。时间格式",
|
||||
"endTime": "完成时间。时间格式",
|
||||
"status": "通量状态,完成30、异常停止-11、人工停止或取消-12",
|
||||
"usedMaterials": [
|
||||
{
|
||||
"materialId": "物料Id。GUID",
|
||||
"locationId": "库位Id。GUID",
|
||||
"typeMode": "物料类型。 样品1、试剂2、耗材0",
|
||||
"usedQuantity": "使用的数量。 数字",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class JobData(BaseModel):
|
||||
jobId: str = Field(examples=["sfsfsfeq"], description="goal uuid")
|
||||
status: int = Field(
|
||||
examples=[0, 1],
|
||||
default=0,
|
||||
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
|
||||
)
|
||||
|
||||
|
||||
class JobStatusResp(Resp):
|
||||
data: JobData
|
||||
|
||||
|
||||
class JobAddResp(Resp):
|
||||
data: JobData
|
||||
177
unilabos/app/mq.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import ssl, base64, hmac
|
||||
from hashlib import sha1
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from unilabos.config.config import MQConfig
|
||||
from unilabos.app.controler import devices, job_add
|
||||
from unilabos.app.model import JobAddReq, JobAddResp
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
mqtt_disable = True
|
||||
|
||||
def __init__(self):
|
||||
self.mqtt_disable = not MQConfig.lab_id
|
||||
self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}"
|
||||
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5)
|
||||
self._setup_callbacks()
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self.client.on_log = self._on_log
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_message = self._on_message
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
|
||||
def _on_log(self, client, userdata, level, buf):
|
||||
logger.info(f"[MQTT] log: {buf}")
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc, properties=None):
|
||||
logger.info("[MQTT] Connected with result code " + str(rc))
|
||||
client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0)
|
||||
isok, data = devices()
|
||||
if not isok:
|
||||
logger.error("[MQTT] on_connect ErrorHostNotInit")
|
||||
return
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
|
||||
try:
|
||||
payload_str = msg.payload.decode("utf-8")
|
||||
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/":
|
||||
logger.debug("job_add", type(payload_json), payload_json)
|
||||
job_req = JobAddReq.model_validate(payload_json)
|
||||
data = job_add(job_req)
|
||||
return JobAddResp(data=data)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[MQTT] JSON 解析错误: {e}")
|
||||
logger.error(f"[MQTT] Raw message: {msg.payload}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MQTT] 处理消息时出错: {e}")
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc, reasonCode=None, properties=None):
|
||||
if rc != 0:
|
||||
logger.error(f"[MQTT] Unexpected disconnection {rc}")
|
||||
|
||||
def _setup_ssl_context(self):
|
||||
temp_files = []
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as ca_temp:
|
||||
ca_temp.write(MQConfig.ca_content)
|
||||
temp_files.append(ca_temp.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as cert_temp:
|
||||
cert_temp.write(MQConfig.cert_content)
|
||||
temp_files.append(cert_temp.name)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as key_temp:
|
||||
key_temp.write(MQConfig.key_content)
|
||||
temp_files.append(key_temp.name)
|
||||
|
||||
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
context.load_verify_locations(cafile=temp_files[0])
|
||||
context.load_cert_chain(certfile=temp_files[1], keyfile=temp_files[2])
|
||||
self.client.tls_set_context(context)
|
||||
finally:
|
||||
for temp_file in temp_files:
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
if self.mqtt_disable:
|
||||
logger.warning("MQTT is disabled, skipping connection.")
|
||||
return
|
||||
userName = f"Signature|{MQConfig.access_key}|{MQConfig.instance_id}"
|
||||
password = base64.b64encode(
|
||||
hmac.new(MQConfig.secret_key.encode(), self.client_id.encode(), sha1).digest()
|
||||
).decode()
|
||||
|
||||
self.client.username_pw_set(userName, password)
|
||||
self._setup_ssl_context()
|
||||
|
||||
# 创建连接线程
|
||||
def connect_thread_func():
|
||||
try:
|
||||
self.client.connect(MQConfig.broker_url, MQConfig.port, 60)
|
||||
self.client.loop_start()
|
||||
|
||||
# 添加连接超时检测
|
||||
max_attempts = 5
|
||||
attempt = 0
|
||||
while not self.client.is_connected() and attempt < max_attempts:
|
||||
logger.info(
|
||||
f"[MQTT] 正在连接到 {MQConfig.broker_url}:{MQConfig.port},尝试 {attempt+1}/{max_attempts}"
|
||||
)
|
||||
time.sleep(3)
|
||||
attempt += 1
|
||||
|
||||
if self.client.is_connected():
|
||||
logger.info(f"[MQTT] 已成功连接到 {MQConfig.broker_url}:{MQConfig.port}")
|
||||
else:
|
||||
logger.error(f"[MQTT] 连接超时,可能是账号密码错误或网络问题")
|
||||
self.client.loop_stop()
|
||||
except Exception as e:
|
||||
logger.error(f"[MQTT] 连接失败: {str(e)}")
|
||||
|
||||
connect_thread_func()
|
||||
# connect_thread = threading.Thread(target=connect_thread_func)
|
||||
# connect_thread.daemon = True
|
||||
# connect_thread.start()
|
||||
|
||||
def stop(self):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
self.client.disconnect()
|
||||
self.client.loop_stop()
|
||||
|
||||
def publish_device_status(self, device_status: dict, device_id, property_name):
|
||||
# status = device_status.get(device_id, {})
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
status = {"data": device_status.get(device_id, {}), "device_id": device_id}
|
||||
address = f"labs/{MQConfig.lab_id}/devices"
|
||||
self.client.publish(address, json.dumps(status), qos=2)
|
||||
logger.critical(f"Device status published: address: {address}, {status}")
|
||||
|
||||
def publish_job_status(self, feedback_data: dict, job_id: str, status: str):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
jobdata = {"job_id": job_id, "data": feedback_data, "status": status}
|
||||
self.client.publish(f"labs/{MQConfig.lab_id}/job/list/", json.dumps(jobdata), qos=2)
|
||||
|
||||
def publish_registry(self, device_id: str, device_info: dict):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
address = f"labs/{MQConfig.lab_id}/registry/"
|
||||
registry_data = json.dumps({device_id: device_info}, ensure_ascii = False, cls = TypeEncoder)
|
||||
self.client.publish(address, registry_data, qos=2)
|
||||
logger.debug(f"Registry data published: address: {address}, {registry_data}")
|
||||
|
||||
def publish_actions(self, action_id: str, action_info: dict):
|
||||
if self.mqtt_disable:
|
||||
return
|
||||
address = f"labs/{MQConfig.lab_id}/actions/"
|
||||
action_type_name = action_info["title"]
|
||||
action_info["title"] = action_id
|
||||
action_data = json.dumps({action_type_name: action_info}, ensure_ascii=False)
|
||||
self.client.publish(address, action_data, qos=2)
|
||||
logger.debug(f"Action data published: address: {address}, {action_data}")
|
||||
|
||||
|
||||
mqtt_client = MQTTClient()
|
||||
|
||||
if __name__ == "__main__":
|
||||
mqtt_client.start()
|
||||
231
unilabos/app/oss_upload.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from unilabos.config.config import OSSUploadConfig
|
||||
|
||||
|
||||
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default",
|
||||
expires_hours: int = 1) -> Tuple[bool, Dict]:
|
||||
"""
|
||||
初始化上传过程
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
expires_hours: 链接过期小时数
|
||||
|
||||
Returns:
|
||||
(成功标志, 响应数据)
|
||||
"""
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# 构造初始化请求
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"device_id": device_id,
|
||||
"process_key": process_key,
|
||||
"filename": filename,
|
||||
"path": oss_path,
|
||||
"expires_hours": expires_hours
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True, result.get("data", {})
|
||||
|
||||
print(f"初始化上传失败: {response.status_code}, {response.text}")
|
||||
return False, {}
|
||||
except Exception as e:
|
||||
print(f"初始化上传异常: {str(e)}")
|
||||
return False, {}
|
||||
|
||||
|
||||
def _put_upload(file_path: str, upload_url: str) -> bool:
|
||||
"""
|
||||
执行PUT上传
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
upload_url: 上传URL
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
response = requests.put(upload_url, data=f)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
|
||||
print(f"PUT上传失败: {response.status_code}, {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"PUT上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _complete_upload(uuid: str) -> bool:
|
||||
"""
|
||||
完成上传过程
|
||||
|
||||
Args:
|
||||
uuid: 上传的UUID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"uuid": uuid
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True
|
||||
|
||||
print(f"完成上传失败: {response.status_code}, {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"完成上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default") -> bool:
|
||||
"""
|
||||
文件上传主函数,包含重试机制
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
|
||||
Returns:
|
||||
是否成功上传
|
||||
"""
|
||||
max_retries = OSSUploadConfig.max_retries
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# 步骤1:初始化上传
|
||||
init_success, init_data = _init_upload(
|
||||
file_path=file_path,
|
||||
oss_path=oss_path,
|
||||
filename=filename,
|
||||
process_key=process_key,
|
||||
device_id=device_id
|
||||
)
|
||||
|
||||
if not init_success:
|
||||
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1) # 等待1秒后重试
|
||||
continue
|
||||
|
||||
# 获取UUID和上传URL
|
||||
uuid = init_data.get("uuid")
|
||||
upload_url = init_data.get("upload_url")
|
||||
|
||||
if not uuid or not upload_url:
|
||||
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 步骤2:PUT上传文件
|
||||
put_success = _put_upload(file_path, upload_url)
|
||||
if not put_success:
|
||||
print(f"PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 步骤3:完成上传
|
||||
complete_success = _complete_upload(uuid)
|
||||
if not complete_success:
|
||||
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 所有步骤都成功
|
||||
print(f"文件 {file_path} 上传成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
|
||||
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
|
||||
# 命令行参数解析
|
||||
parser = argparse.ArgumentParser(description='文件上传测试工具')
|
||||
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
|
||||
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
|
||||
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
|
||||
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(args.file):
|
||||
print(f"错误:文件 {args.file} 不存在")
|
||||
exit(1)
|
||||
|
||||
print("=" * 50)
|
||||
print(f"开始上传文件: {args.file}")
|
||||
print(f"目标路径: {args.path}")
|
||||
print(f"设备ID: {args.device}")
|
||||
print(f"处理键: {args.process}")
|
||||
print("=" * 50)
|
||||
|
||||
# 执行上传
|
||||
success = oss_upload(
|
||||
file_path=args.file,
|
||||
oss_path=args.path,
|
||||
filename=None, # 使用默认文件名
|
||||
process_key=args.process,
|
||||
device_id=args.device
|
||||
)
|
||||
|
||||
# 输出结果
|
||||
if success:
|
||||
print("\n√ 文件上传成功!")
|
||||
exit(0)
|
||||
else:
|
||||
print("\n× 文件上传失败!")
|
||||
exit(1)
|
||||
|
||||
19
unilabos/compile/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from unilabos.messages import *
|
||||
from .pump_protocol import generate_pump_protocol, generate_pump_protocol_with_rinsing
|
||||
from .clean_protocol import generate_clean_protocol
|
||||
from .separate_protocol import generate_separate_protocol
|
||||
from .evaporate_protocol import generate_evaporate_protocol
|
||||
from .evacuateandrefill_protocol import generate_evacuateandrefill_protocol
|
||||
from .agv_transfer_protocol import generate_agv_transfer_protocol
|
||||
|
||||
|
||||
# Define a dictionary of protocol generators.
|
||||
action_protocol_generators = {
|
||||
PumpTransferProtocol: generate_pump_protocol_with_rinsing,
|
||||
CleanProtocol: generate_clean_protocol,
|
||||
SeparateProtocol: generate_separate_protocol,
|
||||
EvaporateProtocol: generate_evaporate_protocol,
|
||||
EvacuateAndRefillProtocol: generate_evacuateandrefill_protocol,
|
||||
AGVTransferProtocol: generate_agv_transfer_protocol,
|
||||
}
|
||||
# End Protocols
|
||||
53
unilabos/compile/agv_transfer_protocol.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def generate_agv_transfer_protocol(
|
||||
G: nx.Graph,
|
||||
from_repo: dict,
|
||||
from_repo_position: str,
|
||||
to_repo: dict = {},
|
||||
to_repo_position: str = ""
|
||||
):
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
to_repo_ = list(to_repo.values())[0]
|
||||
resource_to_move = from_repo_["children"].pop(from_repo_position)
|
||||
resource_to_move["parent"] = to_repo_["id"]
|
||||
to_repo_["children"][to_repo_position] = resource_to_move
|
||||
|
||||
from_repo_id = from_repo_["id"]
|
||||
to_repo_id = to_repo_["id"]
|
||||
|
||||
wf_list = {
|
||||
("AiChemEcoHiWo", "zhixing_agv"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
("AiChemEcoHiWo", "AGV"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
|
||||
("zhixing_agv", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
|
||||
("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
|
||||
("Revvity", "HPLC"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_hplc.urp"}'},
|
||||
|
||||
("HPLC", "Revvity"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_lfp.urp"}'},
|
||||
}
|
||||
return [
|
||||
{
|
||||
"device_id": "zhixing_agv",
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["nav_command"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": "zhixing_ur_arm",
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["arm_command"]
|
||||
}
|
||||
}
|
||||
]
|
||||
62
unilabos/compile/clean_protocol.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def generate_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: str, # Vessel to clean.
|
||||
solvent: str, # Solvent to clean vessel with.
|
||||
volume: float = 25000.0, # Optional. Volume of solvent to clean vessel with.
|
||||
temp: float = 25, # Optional. Temperature to heat vessel to while cleaning.
|
||||
repeats: int = 1, # Optional. Number of cleaning cycles to perform.
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Generate a protocol to clean a vessel with a solvent.
|
||||
|
||||
:param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections.
|
||||
:param vessel: Vessel to clean.
|
||||
:param solvent: Solvent to clean vessel with.
|
||||
:param volume: Volume of solvent to clean vessel with.
|
||||
:param temp: Temperature to heat vessel to while cleaning.
|
||||
:param repeats: Number of cleaning cycles to perform.
|
||||
:return: List of actions to clean vessel.
|
||||
"""
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
pump_action_sequence = []
|
||||
from_vessel = f"flask_{solvent}"
|
||||
waste_vessel = f"waste_workup"
|
||||
|
||||
transfer_flowrate = flowrate = 2500.0
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
for i in range(repeats):
|
||||
# 单泵依次执行阀指令、活塞指令,将液体吸入与之相连的第一台泵
|
||||
pump_action_sequence.extend([
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": from_vessel,
|
||||
"to_vessel": vessel,
|
||||
"volume": volume,
|
||||
"time": volume / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
pump_action_sequence.extend([
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": vessel,
|
||||
"to_vessel": waste_vessel,
|
||||
"volume": volume,
|
||||
"time": volume / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
])
|
||||
return pump_action_sequence
|
||||
143
unilabos/compile/evacuateandrefill_protocol.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def generate_evacuateandrefill_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: str,
|
||||
gas: str,
|
||||
repeats: int = 1
|
||||
) -> list[dict]:
|
||||
"""
|
||||
生成泵操作的动作序列。
|
||||
|
||||
:param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
|
||||
:param from_vessel: 容器A
|
||||
:param to_vessel: 容器B
|
||||
:param volume: 转移的体积
|
||||
:param flowrate: 最终注入容器B时的流速
|
||||
:param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同)
|
||||
:return: 泵操作的动作序列
|
||||
"""
|
||||
|
||||
# 生成电磁阀、真空泵、气源操作的动作序列
|
||||
vacuum_action_sequence = []
|
||||
nodes = G.nodes(data=True)
|
||||
|
||||
# 找到和 vessel 相连的电磁阀和真空泵、气源
|
||||
vacuum_backbone = {"vessel": vessel}
|
||||
|
||||
for neighbor in G.neighbors(vessel):
|
||||
if nodes[neighbor]["class"].startswith("solenoid_valve"):
|
||||
for neighbor2 in G.neighbors(neighbor):
|
||||
if neighbor2 == vessel:
|
||||
continue
|
||||
if nodes[neighbor2]["class"].startswith("vacuum_pump"):
|
||||
vacuum_backbone.update({"vacuum_valve": neighbor, "pump": neighbor2})
|
||||
break
|
||||
elif nodes[neighbor2]["class"].startswith("gas_source"):
|
||||
vacuum_backbone.update({"gas_valve": neighbor, "gas": neighbor2})
|
||||
break
|
||||
# 判断是否设备齐全
|
||||
if len(vacuum_backbone) < 5:
|
||||
print(f"\n\n\n{vacuum_backbone}\n\n\n")
|
||||
raise ValueError("Not all devices are connected to the vessel.")
|
||||
|
||||
# 生成操作的动作序列
|
||||
for i in range(repeats):
|
||||
# 打开真空泵阀门、关闭气源阀门
|
||||
vacuum_action_sequence.append([
|
||||
{
|
||||
"device_id": vacuum_backbone["vacuum_valve"],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": "OPEN"
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": vacuum_backbone["gas_valve"],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": "CLOSED"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
# 打开真空泵、关闭气源
|
||||
vacuum_action_sequence.append([
|
||||
{
|
||||
"device_id": vacuum_backbone["pump"],
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {
|
||||
"command": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": vacuum_backbone["gas"],
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {
|
||||
"command": "OFF"
|
||||
}
|
||||
}
|
||||
])
|
||||
vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
|
||||
|
||||
# 关闭真空泵阀门、打开气源阀门
|
||||
vacuum_action_sequence.append([
|
||||
{
|
||||
"device_id": vacuum_backbone["vacuum_valve"],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": "CLOSED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": vacuum_backbone["gas_valve"],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": "OPEN"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
# 关闭真空泵、打开气源
|
||||
vacuum_action_sequence.append([
|
||||
{
|
||||
"device_id": vacuum_backbone["pump"],
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {
|
||||
"command": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": vacuum_backbone["gas"],
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {
|
||||
"command": "ON"
|
||||
}
|
||||
}
|
||||
])
|
||||
vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
|
||||
|
||||
# 关闭气源
|
||||
vacuum_action_sequence.append(
|
||||
{
|
||||
"device_id": vacuum_backbone["gas"],
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {
|
||||
"command": "OFF"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 关闭阀门
|
||||
vacuum_action_sequence.append(
|
||||
{
|
||||
"device_id": vacuum_backbone["gas_valve"],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": "CLOSED"
|
||||
}
|
||||
}
|
||||
)
|
||||
return vacuum_action_sequence
|
||||
81
unilabos/compile/evaporate_protocol.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def generate_evaporate_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: str,
|
||||
pressure: float,
|
||||
temp: float,
|
||||
time: float,
|
||||
stir_speed: float
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Generate a protocol to evaporate a solution from a vessel.
|
||||
|
||||
:param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections.
|
||||
:param vessel: Vessel to clean.
|
||||
:param solvent: Solvent to clean vessel with.
|
||||
:param volume: Volume of solvent to clean vessel with.
|
||||
:param temp: Temperature to heat vessel to while cleaning.
|
||||
:param repeats: Number of cleaning cycles to perform.
|
||||
:return: List of actions to clean vessel.
|
||||
"""
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
pump_action_sequence = []
|
||||
reactor_volume = 500000.0
|
||||
transfer_flowrate = flowrate = 2500.0
|
||||
|
||||
# 开启冷凝器
|
||||
pump_action_sequence.append({
|
||||
"device_id": "rotavap_chiller",
|
||||
"action_name": "set_temperature",
|
||||
"action_kwargs": {
|
||||
"command": "-40"
|
||||
}
|
||||
})
|
||||
# TODO: 通过温度反馈改为 HeatChillToTemp,而非等待固定时间
|
||||
pump_action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 1800
|
||||
}
|
||||
})
|
||||
|
||||
# 开启旋蒸真空泵、旋转,在液体转移后运行time时间
|
||||
pump_action_sequence.append({
|
||||
"device_id": "rotavap_controller",
|
||||
"action_name": "set_pump_time",
|
||||
"action_kwargs": {
|
||||
"command": str(time + reactor_volume / flowrate * 3)
|
||||
}
|
||||
})
|
||||
pump_action_sequence.append({
|
||||
"device_id": "rotavap_controller",
|
||||
"action_name": "set_pump_time",
|
||||
"action_kwargs": {
|
||||
"command": str(time + reactor_volume / flowrate * 3)
|
||||
}
|
||||
})
|
||||
|
||||
# 液体转入旋转蒸发器
|
||||
pump_action_sequence.append({
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": vessel,
|
||||
"to_vessel": "rotavap",
|
||||
"volume": reactor_volume,
|
||||
"time": reactor_volume / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
})
|
||||
|
||||
pump_action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": time
|
||||
}
|
||||
})
|
||||
return pump_action_sequence
|
||||
213
unilabos/compile/pump_protocol.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def generate_pump_protocol(
|
||||
G: nx.DiGraph,
|
||||
from_vessel: str,
|
||||
to_vessel: str,
|
||||
volume: float,
|
||||
flowrate: float = 500.0,
|
||||
transfer_flowrate: float = 0,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
生成泵操作的动作序列。
|
||||
|
||||
:param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
|
||||
:param from_vessel: 容器A
|
||||
:param to_vessel: 容器B
|
||||
:param volume: 转移的体积
|
||||
:param flowrate: 最终注入容器B时的流速
|
||||
:param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同)
|
||||
:return: 泵操作的动作序列
|
||||
"""
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
pump_action_sequence = []
|
||||
nodes = G.nodes(data=True)
|
||||
# 从from_vessel到to_vessel的最短路径
|
||||
shortest_path = nx.shortest_path(G, source=from_vessel, target=to_vessel)
|
||||
print(shortest_path)
|
||||
|
||||
pump_backbone = shortest_path
|
||||
if not from_vessel.startswith("pump"):
|
||||
pump_backbone = pump_backbone[1:]
|
||||
if not to_vessel.startswith("pump"):
|
||||
pump_backbone = pump_backbone[:-1]
|
||||
|
||||
if transfer_flowrate == 0:
|
||||
transfer_flowrate = flowrate
|
||||
|
||||
min_transfer_volume = min([nodes[pump]["max_volume"] for pump in pump_backbone])
|
||||
repeats = int(np.ceil(volume / min_transfer_volume))
|
||||
if repeats > 1 and (from_vessel.startswith("pump") or to_vessel.startswith("pump")):
|
||||
raise ValueError("Cannot transfer volume larger than min_transfer_volume between two pumps.")
|
||||
|
||||
volume_left = volume
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
for i in range(repeats):
|
||||
# 单泵依次执行阀指令、活塞指令,将液体吸入与之相连的第一台泵
|
||||
if not from_vessel.startswith("pump"):
|
||||
pump_action_sequence.extend([
|
||||
{
|
||||
"device_id": pump_backbone[0],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": G.get_edge_data(pump_backbone[0], from_vessel)["port"][pump_backbone[0]]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": pump_backbone[0],
|
||||
"action_name": "set_position",
|
||||
"action_kwargs": {
|
||||
"position": float(min(volume_left, min_transfer_volume)),
|
||||
"max_velocity": transfer_flowrate
|
||||
}
|
||||
}
|
||||
])
|
||||
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}})
|
||||
for pumpA, pumpB in zip(pump_backbone[:-1], pump_backbone[1:]):
|
||||
# 相邻两泵同时切换阀门至连通位置
|
||||
pump_action_sequence.append([
|
||||
{
|
||||
"device_id": pumpA,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": G.get_edge_data(pumpA, pumpB)["port"][pumpA]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": pumpB,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": G.get_edge_data(pumpB, pumpA)["port"][pumpB],
|
||||
}
|
||||
}
|
||||
])
|
||||
# 相邻两泵液体转移:泵A排出液体,泵B吸入液体
|
||||
pump_action_sequence.append([
|
||||
{
|
||||
"device_id": pumpA,
|
||||
"action_name": "set_position",
|
||||
"action_kwargs": {
|
||||
"position": 0.0,
|
||||
"max_velocity": transfer_flowrate
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": pumpB,
|
||||
"action_name": "set_position",
|
||||
"action_kwargs": {
|
||||
"position": float(min(volume_left, min_transfer_volume)),
|
||||
"max_velocity": transfer_flowrate
|
||||
}
|
||||
}
|
||||
])
|
||||
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}})
|
||||
|
||||
if not to_vessel.startswith("pump"):
|
||||
# 单泵依次执行阀指令、活塞指令,将最后一台泵液体缓慢加入容器B
|
||||
pump_action_sequence.extend([
|
||||
{
|
||||
"device_id": pump_backbone[-1],
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {
|
||||
"command": G.get_edge_data(pump_backbone[-1], to_vessel)["port"][pump_backbone[-1]]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": pump_backbone[-1],
|
||||
"action_name": "set_position",
|
||||
"action_kwargs": {
|
||||
"position": 0.0,
|
||||
"max_velocity": flowrate
|
||||
}
|
||||
}
|
||||
])
|
||||
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}})
|
||||
|
||||
volume_left -= min_transfer_volume
|
||||
return pump_action_sequence
|
||||
|
||||
|
||||
# Pump protocol compilation
|
||||
def generate_pump_protocol_with_rinsing(
|
||||
G: nx.DiGraph,
|
||||
from_vessel: str,
|
||||
to_vessel: str,
|
||||
volume: float,
|
||||
amount: str = "",
|
||||
time: float = 0,
|
||||
viscous: bool = False,
|
||||
rinsing_solvent: str = "air",
|
||||
rinsing_volume: float = 5000.0,
|
||||
rinsing_repeats: int = 2,
|
||||
solid: bool = False,
|
||||
flowrate: float = 2500.0,
|
||||
transfer_flowrate: float = 500.0,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Generates a pump protocol for transferring a specified volume between vessels, including rinsing steps with a chosen solvent. This function constructs a sequence of pump actions based on the provided parameters and the shortest path in a directed graph.
|
||||
|
||||
Args:
|
||||
G (nx.DiGraph): The directed graph representing the vessels and connections. 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
|
||||
from_vessel (str): The name of the vessel to transfer from.
|
||||
to_vessel (str): The name of the vessel to transfer to.
|
||||
volume (float): The volume to transfer.
|
||||
amount (str, optional): Additional amount specification (default is "").
|
||||
time (float, optional): Time over which to perform the transfer (default is 0).
|
||||
viscous (bool, optional): Indicates if the fluid is viscous (default is False).
|
||||
rinsing_solvent (str, optional): The solvent to use for rinsing (default is "air").
|
||||
rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5000.0).
|
||||
rinsing_repeats (int, optional): The number of times to repeat rinsing (default is 2).
|
||||
solid (bool, optional): Indicates if the transfer involves a solid (default is False).
|
||||
flowrate (float, optional): The flow rate for the transfer (default is 2500.0). 最终注入容器B时的流速
|
||||
transfer_flowrate (float, optional): The flow rate for the transfer action (default is 500.0). 泵骨架中转移流速(若不指定,默认与注入流速相同)
|
||||
|
||||
Returns:
|
||||
list[dict]: A sequence of pump actions to be executed for the transfer and rinsing process. 泵操作的动作序列.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the number of rinsing solvents does not match the number of rinsing repeats.
|
||||
|
||||
Examples:
|
||||
pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 100.0, rinsing_solvent="water")
|
||||
"""
|
||||
air_vessel = "flask_air"
|
||||
waste_vessel = f"waste_workup"
|
||||
|
||||
shortest_path = nx.shortest_path(G, source=from_vessel, target=to_vessel)
|
||||
pump_backbone = shortest_path[1: -1]
|
||||
nodes = G.nodes(data=True)
|
||||
min_transfer_volume = float(min([nodes[pump]["max_volume"] for pump in pump_backbone]))
|
||||
if time != 0:
|
||||
flowrate = transfer_flowrate = volume / time
|
||||
|
||||
pump_action_sequence = generate_pump_protocol(G, from_vessel, to_vessel, float(volume), flowrate, transfer_flowrate)
|
||||
if rinsing_solvent != "air":
|
||||
if "," in rinsing_solvent:
|
||||
rinsing_solvents = rinsing_solvent.split(",")
|
||||
assert len(rinsing_solvents) == rinsing_repeats, "Number of rinsing solvents must match number of rinsing repeats."
|
||||
else:
|
||||
rinsing_solvents = [rinsing_solvent] * rinsing_repeats
|
||||
|
||||
for rinsing_solvent in rinsing_solvents:
|
||||
solvent_vessel = f"flask_{rinsing_solvent}"
|
||||
# 清洗泵
|
||||
pump_action_sequence.extend(
|
||||
generate_pump_protocol(G, solvent_vessel, pump_backbone[0], min_transfer_volume, flowrate, transfer_flowrate) +
|
||||
generate_pump_protocol(G, pump_backbone[0], pump_backbone[-1], min_transfer_volume, flowrate, transfer_flowrate) +
|
||||
generate_pump_protocol(G, pump_backbone[-1], waste_vessel, min_transfer_volume, flowrate, transfer_flowrate)
|
||||
)
|
||||
# 如果转移的是溶液,第一种冲洗溶剂请选用溶液的溶剂,稀释泵内、转移管道内的溶液。后续冲洗溶剂不需要此操作。
|
||||
if rinsing_solvent == rinsing_solvents[0]:
|
||||
pump_action_sequence.extend(generate_pump_protocol(G, solvent_vessel, from_vessel, rinsing_volume, flowrate, transfer_flowrate))
|
||||
pump_action_sequence.extend(generate_pump_protocol(G, solvent_vessel, to_vessel, rinsing_volume, flowrate, transfer_flowrate))
|
||||
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, solvent_vessel, rinsing_volume, flowrate, transfer_flowrate))
|
||||
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, waste_vessel, rinsing_volume, flowrate, transfer_flowrate))
|
||||
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, from_vessel, rinsing_volume, flowrate, transfer_flowrate) * 2)
|
||||
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, to_vessel, rinsing_volume, flowrate, transfer_flowrate) * 2)
|
||||
|
||||
return pump_action_sequence
|
||||
# End Protocols
|
||||
230
unilabos/compile/separate_protocol.py
Normal file
@@ -0,0 +1,230 @@
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def generate_separate_protocol(
|
||||
G: nx.DiGraph,
|
||||
purpose: str, # 'wash' or 'extract'. 'wash' means that product phase will not be the added solvent phase, 'extract' means product phase will be the added solvent phase. If no solvent is added just use 'extract'.
|
||||
product_phase: str, # 'top' or 'bottom'. Phase that product will be in.
|
||||
from_vessel: str, #Contents of from_vessel are transferred to separation_vessel and separation is performed.
|
||||
separation_vessel: str, # Vessel in which separation of phases will be carried out.
|
||||
to_vessel: str, # Vessel to send product phase to.
|
||||
waste_phase_to_vessel: str, # Optional. Vessel to send waste phase to.
|
||||
solvent: str, # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases.
|
||||
solvent_volume: float = 50000, # Optional. Volume of solvent to add.
|
||||
through: str = "", # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'.
|
||||
repeats: int = 1, # Optional. Number of separations to perform.
|
||||
stir_time: float = 30, # Optional. Time stir for after adding solvent, before separation of phases.
|
||||
stir_speed: float = 300, # Optional. Speed to stir at after adding solvent, before separation of phases.
|
||||
settling_time: float = 300 # Optional. Time
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Generate a protocol to clean a vessel with a solvent.
|
||||
|
||||
:param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections.
|
||||
:param vessel: Vessel to clean.
|
||||
:param solvent: Solvent to clean vessel with.
|
||||
:param volume: Volume of solvent to clean vessel with.
|
||||
:param temp: Temperature to heat vessel to while cleaning.
|
||||
:param repeats: Number of cleaning cycles to perform.
|
||||
:return: List of actions to clean vessel.
|
||||
"""
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
pump_action_sequence = []
|
||||
reactor_volume = 500000.0
|
||||
waste_vessel = waste_phase_to_vessel
|
||||
|
||||
# TODO:通过物料管理系统找到溶剂的容器
|
||||
if "," in solvent:
|
||||
solvents = solvent.split(",")
|
||||
assert len(solvents) == repeats, "Number of solvents must match number of repeats."
|
||||
else:
|
||||
solvents = [solvent] * repeats
|
||||
|
||||
# TODO: 通过设备连接图找到分离容器的控制器、底部出口
|
||||
separator_controller = f"{separation_vessel}_controller"
|
||||
separation_vessel_bottom = f"flask_{separation_vessel}"
|
||||
|
||||
transfer_flowrate = flowrate = 2500.0
|
||||
|
||||
if from_vessel != separation_vessel:
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": from_vessel,
|
||||
"to_vessel": separation_vessel,
|
||||
"volume": reactor_volume,
|
||||
"time": reactor_volume / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# for i in range(2):
|
||||
# pump_action_sequence.append(
|
||||
# {
|
||||
# "device_id": "",
|
||||
# "action_name": "CleanProtocol",
|
||||
# "action_kwargs": {
|
||||
# "vessel": from_vessel,
|
||||
# "solvent": "H2O", # Solvent to clean vessel with.
|
||||
# "volume": solvent_volume, # Optional. Volume of solvent to clean vessel with.
|
||||
# "temp": 25.0, # Optional. Temperature to heat vessel to while cleaning.
|
||||
# "repeats": 1
|
||||
# }
|
||||
# }
|
||||
# )
|
||||
# pump_action_sequence.append(
|
||||
# {
|
||||
# "device_id": "",
|
||||
# "action_name": "CleanProtocol",
|
||||
# "action_kwargs": {
|
||||
# "vessel": from_vessel,
|
||||
# "solvent": "CH2Cl2", # Solvent to clean vessel with.
|
||||
# "volume": solvent_volume, # Optional. Volume of solvent to clean vessel with.
|
||||
# "temp": 25.0, # Optional. Temperature to heat vessel to while cleaning.
|
||||
# "repeats": 1
|
||||
# }
|
||||
# }
|
||||
# )
|
||||
|
||||
# 生成泵操作的动作序列
|
||||
for i in range(repeats):
|
||||
# 找到当次萃取所用溶剂
|
||||
solvent_thistime = solvents[i]
|
||||
solvent_vessel = f"flask_{solvent_thistime}"
|
||||
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": solvent_vessel,
|
||||
"to_vessel": separation_vessel,
|
||||
"volume": solvent_volume,
|
||||
"time": solvent_volume / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
pump_action_sequence.extend([
|
||||
# 搅拌、静置
|
||||
{
|
||||
"device_id": separator_controller,
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
"stir_time": stir_time,
|
||||
"stir_speed": stir_speed,
|
||||
"settling_time": settling_time
|
||||
}
|
||||
},
|
||||
# 分液(判断电导突跃)
|
||||
{
|
||||
"device_id": separator_controller,
|
||||
"action_name": "valve_open",
|
||||
"action_kwargs": {
|
||||
"command": "delta > 0.05"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
if product_phase == "bottom":
|
||||
# 产物转移到目标瓶
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": separation_vessel_bottom,
|
||||
"to_vessel": to_vessel,
|
||||
"volume": 250000.0,
|
||||
"time": 250000.0 / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
# 放出上面那一相,60秒后关阀门
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": separator_controller,
|
||||
"action_name": "valve_open",
|
||||
"action_kwargs": {
|
||||
"command": "time > 60"
|
||||
}
|
||||
}
|
||||
)
|
||||
# 弃去上面那一相进废液
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": separation_vessel_bottom,
|
||||
"to_vessel": waste_vessel,
|
||||
"volume": 250000.0,
|
||||
"time": 250000.0 / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
elif product_phase == "top":
|
||||
# 弃去下面那一相进废液
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": separation_vessel_bottom,
|
||||
"to_vessel": waste_vessel,
|
||||
"volume": 250000.0,
|
||||
"time": 250000.0 / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
# 放出上面那一相
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": separator_controller,
|
||||
"action_name": "valve_open",
|
||||
"action_kwargs": {
|
||||
"command": "time > 60"
|
||||
}
|
||||
}
|
||||
)
|
||||
# 产物转移到目标瓶
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": separation_vessel_bottom,
|
||||
"to_vessel": to_vessel,
|
||||
"volume": 250000.0,
|
||||
"time": 250000.0 / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
elif product_phase == "organic":
|
||||
pass
|
||||
|
||||
# 如果不是最后一次,从中转瓶转移回分液漏斗
|
||||
if i < repeats - 1:
|
||||
pump_action_sequence.append(
|
||||
{
|
||||
"device_id": "",
|
||||
"action_name": "PumpTransferProtocol",
|
||||
"action_kwargs": {
|
||||
"from_vessel": to_vessel,
|
||||
"to_vessel": separation_vessel,
|
||||
"volume": 250000.0,
|
||||
"time": 250000.0 / flowrate,
|
||||
# "transfer_flowrate": transfer_flowrate,
|
||||
}
|
||||
}
|
||||
)
|
||||
return pump_action_sequence
|
||||
0
unilabos/config/__init__.py
Normal file
123
unilabos/config/config.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
# 定义配置变量和加载函数
|
||||
import traceback
|
||||
import os
|
||||
import importlib.util
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
class BasicConfig:
|
||||
ENV = "pro" # 'test'
|
||||
config_path = ""
|
||||
is_host_mode = True # 从registry.py移动过来
|
||||
slave_no_host = False # 是否跳过rclient.wait_for_service()
|
||||
|
||||
|
||||
# MQTT配置
|
||||
class MQConfig:
|
||||
lab_id = ""
|
||||
instance_id = ""
|
||||
access_key = ""
|
||||
secret_key = ""
|
||||
group_id = ""
|
||||
broker_url = ""
|
||||
port = 1883
|
||||
ca_content = ""
|
||||
cert_content = ""
|
||||
key_content = ""
|
||||
|
||||
# 指定
|
||||
ca_file = ""
|
||||
cert_file = ""
|
||||
key_file = ""
|
||||
|
||||
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = ""
|
||||
authorization = ""
|
||||
init_endpoint = ""
|
||||
complete_endpoint = ""
|
||||
max_retries = 3
|
||||
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
]
|
||||
|
||||
|
||||
def _update_config_from_module(module):
|
||||
for name, obj in globals().items():
|
||||
if isinstance(obj, type) and name.endswith("Config"):
|
||||
if hasattr(module, name) and isinstance(getattr(module, name), type):
|
||||
for attr in dir(getattr(module, name)):
|
||||
if not attr.startswith("_"):
|
||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||
# 更新OSS认证
|
||||
if len(OSSUploadConfig.authorization) == 0:
|
||||
OSSUploadConfig.authorization = f"lab {MQConfig.lab_id}"
|
||||
# 对 ca_file cert_file key_file 进行初始化
|
||||
if len(MQConfig.ca_content) == 0:
|
||||
# 需要先判断是否为相对路径
|
||||
if MQConfig.ca_file.startswith("."):
|
||||
MQConfig.ca_file = os.path.join(BasicConfig.config_path, MQConfig.ca_file)
|
||||
with open(MQConfig.ca_file, "r", encoding="utf-8") as f:
|
||||
MQConfig.ca_content = f.read()
|
||||
if len(MQConfig.cert_content) == 0:
|
||||
# 需要先判断是否为相对路径
|
||||
if MQConfig.cert_file.startswith("."):
|
||||
MQConfig.cert_file = os.path.join(BasicConfig.config_path, MQConfig.cert_file)
|
||||
with open(MQConfig.cert_file, "r", encoding="utf-8") as f:
|
||||
MQConfig.cert_content = f.read()
|
||||
if len(MQConfig.key_content) == 0:
|
||||
# 需要先判断是否为相对路径
|
||||
if MQConfig.key_file.startswith("."):
|
||||
MQConfig.key_file = os.path.join(BasicConfig.config_path, MQConfig.key_file)
|
||||
with open(MQConfig.key_file, "r", encoding="utf-8") as f:
|
||||
MQConfig.key_content = f.read()
|
||||
|
||||
|
||||
def load_config(config_path=None):
|
||||
# 如果提供了配置文件路径,从该文件导入配置
|
||||
if config_path:
|
||||
BasicConfig.config_path = os.path.abspath(os.path.dirname(config_path))
|
||||
if not os.path.exists(config_path):
|
||||
logger.error(f"配置文件 {config_path} 不存在")
|
||||
return
|
||||
|
||||
try:
|
||||
module_name = "lab_" + os.path.basename(config_path).replace(".py", "")
|
||||
spec = importlib.util.spec_from_file_location(module_name, config_path)
|
||||
if spec is None:
|
||||
logger.error(f"配置文件 {config_path} 错误")
|
||||
return
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore
|
||||
_update_config_from_module(module)
|
||||
logger.info(f"配置文件 {config_path} 加载成功")
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件 {config_path} 失败: {e}")
|
||||
traceback.print_exc()
|
||||
exit(1)
|
||||
else:
|
||||
try:
|
||||
import unilabos.config.local_config as local_config # type: ignore
|
||||
|
||||
_update_config_from_module(local_config)
|
||||
logger.info("已加载默认配置 unilabos.config.local_config")
|
||||
except ImportError:
|
||||
pass
|
||||
1
unilabos/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .eis_model import EISModelBasedController
|
||||
5
unilabos/controllers/eis_model.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import numpy as np
|
||||
|
||||
|
||||
def EISModelBasedController(eis: np.array) -> float:
|
||||
return 0.0
|
||||
0
unilabos/device_comms/__init__.py
Normal file
0
unilabos/device_comms/modbus_plc/__init__.py
Normal file
537
unilabos/device_comms/modbus_plc/client.py
Normal file
@@ -0,0 +1,537 @@
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Union, List, Dict, Callable, Optional, Tuple
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
|
||||
from pymodbus.framer import FramerType
|
||||
from typing import TypedDict
|
||||
|
||||
from unilabos.device_comms.modbus_plc.node.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder
|
||||
from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase
|
||||
from unilabos.device_comms.universal_driver import UniversalDriver
|
||||
from unilabos.utils.log import logger
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class ModbusNode(BaseModel):
|
||||
name: str
|
||||
device_type: DeviceType
|
||||
address: int
|
||||
data_type: DataType = DataType.INT16
|
||||
slave: int = 1
|
||||
|
||||
|
||||
class PLCWorkflow(BaseModel):
|
||||
name: str
|
||||
actions: List[
|
||||
Union[
|
||||
"PLCWorkflow",
|
||||
Callable[
|
||||
[Callable[[str], ModbusNodeBase]],
|
||||
None
|
||||
]]
|
||||
]
|
||||
|
||||
class Action(BaseModel):
|
||||
name: str
|
||||
rw: bool # read是0 write是1
|
||||
|
||||
class WorkflowAction(BaseModel):
|
||||
init: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None
|
||||
start: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None
|
||||
stop: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None
|
||||
cleanup: Optional[Callable[[Callable[[str], ModbusNodeBase]], None]] = None
|
||||
|
||||
|
||||
class ModbusWorkflow(BaseModel):
|
||||
name: str
|
||||
actions: List[Union["ModbusWorkflow", WorkflowAction]]
|
||||
|
||||
|
||||
""" 前后端Json解析用 """
|
||||
class AddressFunctionJson(TypedDict):
|
||||
func_name: str
|
||||
node_name: str
|
||||
mode: str
|
||||
value: Any
|
||||
|
||||
class InitFunctionJson(AddressFunctionJson):
|
||||
pass
|
||||
|
||||
class StartFunctionJson(AddressFunctionJson):
|
||||
pass
|
||||
|
||||
class StopFunctionJson(AddressFunctionJson):
|
||||
pass
|
||||
|
||||
class CleanupFunctionJson(AddressFunctionJson):
|
||||
pass
|
||||
|
||||
class ActionJson(TypedDict):
|
||||
address_function_to_create: list[AddressFunctionJson]
|
||||
create_init_function: Optional[InitFunctionJson]
|
||||
create_start_function: Optional[StartFunctionJson]
|
||||
create_stop_function: Optional[StopFunctionJson]
|
||||
create_cleanup_function: Optional[CleanupFunctionJson]
|
||||
|
||||
class WorkflowCreateJson(TypedDict):
|
||||
name: str
|
||||
action: list[Union[ActionJson, 'WorkflowCreateJson'] | str]
|
||||
|
||||
class ExecuteProcedureJson(TypedDict):
|
||||
register_node_list_from_csv_path: Optional[dict[str, Any]]
|
||||
create_flow: list[WorkflowCreateJson]
|
||||
execute_flow: list[str]
|
||||
|
||||
|
||||
|
||||
class BaseClient(UniversalDriver):
|
||||
client: Optional[Union[ModbusSerialClient, ModbusTcpClient]] = None
|
||||
_node_registry: Dict[str, ModbusNodeBase] = {}
|
||||
DEFAULT_ADDRESS_PATH = ""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# self.register_node_list_from_csv_path()
|
||||
|
||||
def _set_client(self, client: Optional[Union[ModbusSerialClient, ModbusTcpClient]]) -> None:
|
||||
if client is None:
|
||||
raise ValueError('client is not valid')
|
||||
# if not isinstance(client, TCPClient ) or not isinstance(client, RTUClient):
|
||||
# raise ValueError('client is not valid')
|
||||
self.client = client
|
||||
|
||||
def _connect(self) -> None:
|
||||
logger.info('try to connect client...')
|
||||
if self.client:
|
||||
if self.client.connect():
|
||||
logger.info('client connected!')
|
||||
else:
|
||||
logger.error('client connect failed')
|
||||
else:
|
||||
raise ValueError('client is not initialized')
|
||||
|
||||
@classmethod
|
||||
def load_csv(cls, file_path: str):
|
||||
df = pd.read_csv(file_path)
|
||||
df = df.drop_duplicates(subset='Name', keep='first') # FIXME: 重复的数据应该报错
|
||||
data_dict = df.set_index('Name').to_dict(orient='index')
|
||||
nodes = []
|
||||
for k, v in data_dict.items():
|
||||
deviceType = v.get('DeviceType', None)
|
||||
addr = v.get('Address', 0)
|
||||
dataType = v.get('DataType', 'BOOL')
|
||||
if not deviceType or not addr:
|
||||
continue
|
||||
|
||||
if deviceType == DeviceType.COIL.value:
|
||||
byteAddr = int(addr / 10)
|
||||
bitAddr = addr % 10
|
||||
addr = byteAddr * 8 + bitAddr
|
||||
|
||||
if dataType == 'BOOL':
|
||||
# noinspection PyTypeChecker
|
||||
dataType = 'INT16'
|
||||
# noinspection PyTypeChecker
|
||||
if pd.isna(dataType):
|
||||
print(v, "没有注册成功!")
|
||||
continue
|
||||
dataType: DataType = DataType[dataType]
|
||||
nodes.append(ModbusNode(name=k, device_type=DeviceType(deviceType), address=addr, data_type=dataType))
|
||||
return nodes
|
||||
|
||||
def use_node(self, name: str) -> ModbusNodeBase:
|
||||
if name not in self._node_registry:
|
||||
raise ValueError(f'node {name} is not registered')
|
||||
|
||||
return self._node_registry[name]
|
||||
|
||||
def get_node_registry(self) -> Dict[str, ModbusNodeBase]:
|
||||
return self._node_registry
|
||||
|
||||
def register_node_list_from_csv_path(self, path: str = None) -> "BaseClient":
|
||||
if path is None:
|
||||
path = self.DEFAULT_ADDRESS_PATH
|
||||
nodes = self.load_csv(path)
|
||||
return self.register_node_list(nodes)
|
||||
|
||||
def register_node_list(self, node_list: List[ModbusNode]) -> "BaseClient":
|
||||
if not self.client:
|
||||
raise ValueError('client is not connected')
|
||||
|
||||
if not node_list or len(node_list) == 0:
|
||||
logger.warning('node list is empty')
|
||||
return self
|
||||
|
||||
logger.info(f'start to register {len(node_list)} nodes...')
|
||||
for node in node_list:
|
||||
if node is None:
|
||||
continue
|
||||
if node.name in self._node_registry:
|
||||
logger.info(f'node {node.name} already exists')
|
||||
exist = self._node_registry[node.name]
|
||||
if exist.type != node.device_type:
|
||||
raise ValueError(f'node {node.name} type {node.device_type} is diplicated with {exist.type}')
|
||||
if exist.address != node.address:
|
||||
raise ValueError(f'node {node.name} address is duplicated with {exist.address}')
|
||||
continue
|
||||
if not isinstance(node.device_type, DeviceType):
|
||||
raise ValueError(f'node {node.name} type is not valid')
|
||||
|
||||
if node.device_type == DeviceType.HOLD_REGISTER:
|
||||
self._node_registry[node.name] = HoldRegister(self.client, node.name, node.address, node.data_type)
|
||||
elif node.device_type == DeviceType.COIL:
|
||||
self._node_registry[node.name] = Coil(self.client, node.name, node.address, node.data_type)
|
||||
elif node.device_type == DeviceType.INPUT_REGISTER:
|
||||
self._node_registry[node.name] = InputRegister(self.client, node.name, node.address, node.data_type)
|
||||
elif node.device_type == DeviceType.DISCRETE_INPUTS:
|
||||
self._node_registry[node.name] = DiscreteInputs(self.client, node.name, node.address, node.data_type)
|
||||
else:
|
||||
raise ValueError(f'node {node.name} type {node.device_type} is not valid')
|
||||
|
||||
logger.info('register nodes done.')
|
||||
return self
|
||||
|
||||
def run_plc_workflow(self, workflow: PLCWorkflow) -> None:
|
||||
if not self.client:
|
||||
raise ValueError('client is not connected')
|
||||
|
||||
logger.info(f'start to run workflow {workflow.name}...')
|
||||
|
||||
for action in workflow.actions:
|
||||
if isinstance(action, PLCWorkflow):
|
||||
self.run_plc_workflow(action)
|
||||
elif isinstance(action, Callable):
|
||||
action(self.use_node)
|
||||
else:
|
||||
raise ValueError(f'invalid action {action}')
|
||||
|
||||
def call_lifecycle_fn(
|
||||
self,
|
||||
workflow: ModbusWorkflow,
|
||||
fn: Optional[Callable[[Callable], bool]],
|
||||
) -> bool:
|
||||
if not fn:
|
||||
raise ValueError('fn is not valid in call_lifecycle_fn')
|
||||
try:
|
||||
return fn(self.use_node)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logger.error(f'execute {workflow.name} lifecycle failed, err: {e}')
|
||||
return False
|
||||
|
||||
def run_modbus_workflow(self, workflow: ModbusWorkflow) -> bool:
|
||||
if not self.client:
|
||||
raise ValueError('client is not connected')
|
||||
|
||||
logger.info(f'start to run workflow {workflow.name}...')
|
||||
|
||||
for action in workflow.actions:
|
||||
if isinstance(action, ModbusWorkflow):
|
||||
if self.run_modbus_workflow(action):
|
||||
logger.info(f"{action.name} workflow done.")
|
||||
continue
|
||||
else:
|
||||
logger.error(f"{action.name} workflow failed")
|
||||
return False
|
||||
elif isinstance(action, WorkflowAction):
|
||||
init = action.init
|
||||
start = action.start
|
||||
stop = action.stop
|
||||
cleanup = action.cleanup
|
||||
if not init and not start and not stop:
|
||||
raise ValueError(f'invalid action {action}')
|
||||
|
||||
is_err = False
|
||||
try:
|
||||
if init and not self.call_lifecycle_fn(workflow, init):
|
||||
raise ValueError(f"{workflow.name} init action failed")
|
||||
if not self.call_lifecycle_fn(workflow, start):
|
||||
raise ValueError(f"{workflow.name} start action failed")
|
||||
if not self.call_lifecycle_fn(workflow, stop):
|
||||
raise ValueError(f"{workflow.name} stop action failed")
|
||||
logger.info(f"{workflow.name} action done.")
|
||||
except Exception as e:
|
||||
is_err = True
|
||||
traceback.print_exc()
|
||||
logger.error(f"{workflow.name} action failed, err: {e}")
|
||||
finally:
|
||||
logger.info(f"{workflow.name} try to run cleanup")
|
||||
if cleanup:
|
||||
self.call_lifecycle_fn(workflow, cleanup)
|
||||
else:
|
||||
logger.info(f"{workflow.name} cleanup is not defined")
|
||||
if is_err:
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
raise ValueError(f'invalid action type {type(action)}')
|
||||
|
||||
return True
|
||||
|
||||
function_name: dict[str, Callable[[Callable[[str], ModbusNodeBase]], bool]] = {}
|
||||
|
||||
@classmethod
|
||||
def pack_func(cls, func, value="UNDEFINED"):
|
||||
def execute_pack_func(use_node: Callable[[str], ModbusNodeBase]):
|
||||
if value == "UNDEFINED":
|
||||
func()
|
||||
else:
|
||||
func(use_node, value)
|
||||
return execute_pack_func
|
||||
|
||||
def create_address_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None) -> Callable[[Callable[[str], ModbusNodeBase]], bool]:
|
||||
def execute_address_function(use_node: Callable[[str], ModbusNodeBase]) -> Union[bool, Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]]:
|
||||
param = {"value": value}
|
||||
if data_type is not None:
|
||||
param["data_type"] = data_type
|
||||
if word_order is not None:
|
||||
param["word_order"] = word_order
|
||||
if slave is not None:
|
||||
param["slave"] = slave
|
||||
target_node = use_node(node_name)
|
||||
print("执行", node_name, type(target_node).__name__, target_node.address, mode, value)
|
||||
if mode == 'read':
|
||||
return use_node(node_name).read(**param)
|
||||
elif mode == 'write':
|
||||
return not use_node(node_name).write(**param)
|
||||
return False
|
||||
if func_name is None:
|
||||
func_name = node_name + '_' + mode + '_' + str(value)
|
||||
print("创建 address function", mode, func_name)
|
||||
self.function_name[func_name] = execute_address_function
|
||||
return execute_address_function
|
||||
|
||||
def create_init_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None):
|
||||
return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave)
|
||||
|
||||
def create_stop_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None):
|
||||
return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave)
|
||||
|
||||
def create_cleanup_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None):
|
||||
return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave)
|
||||
|
||||
def create_start_function(self, func_name: str, write_functions: list[str], condition_functions: list[str], stop_condition_expression: str):
|
||||
def execute_start_function(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
for write_function in write_functions:
|
||||
self.function_name[write_function](use_node)
|
||||
while True:
|
||||
next_loop = False
|
||||
condition_source = {}
|
||||
for condition_function in condition_functions:
|
||||
read_res, read_err = self.function_name[condition_function](use_node)
|
||||
if read_err:
|
||||
next_loop = True
|
||||
break
|
||||
condition_source[condition_function] = read_res
|
||||
if not next_loop:
|
||||
if stop_condition_expression:
|
||||
condition_source["__RESULT"] = None
|
||||
exec(f"__RESULT = {stop_condition_expression}", {}, condition_source) # todo: safety check
|
||||
res = condition_source["__RESULT"]
|
||||
print("取得计算结果;", res)
|
||||
if res:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.3)
|
||||
return True
|
||||
return execute_start_function
|
||||
|
||||
def create_action_from_json(self, data: ActionJson):
|
||||
for i in data["address_function_to_create"]:
|
||||
self.create_address_function(**i)
|
||||
init = None
|
||||
start = None
|
||||
stop = None
|
||||
cleanup = None
|
||||
if data["create_init_function"]:
|
||||
print("创建 init function")
|
||||
init = self.create_init_function(**data["create_init_function"])
|
||||
if data["create_start_function"]:
|
||||
print("创建 start function")
|
||||
start = self.create_start_function(**data["create_start_function"])
|
||||
if data["create_stop_function"]:
|
||||
print("创建 stop function")
|
||||
stop = self.create_stop_function(**data["create_stop_function"])
|
||||
if data["create_cleanup_function"]:
|
||||
print("创建 cleanup function")
|
||||
cleanup = self.create_cleanup_function(**data["create_cleanup_function"])
|
||||
return WorkflowAction(init=init, start=start, stop=stop, cleanup=cleanup)
|
||||
|
||||
workflow_name = {}
|
||||
|
||||
def create_workflow_from_json(self, data: list[WorkflowCreateJson]):
|
||||
for ind, flow in enumerate(data):
|
||||
print("正在创建 workflow", ind, flow["name"])
|
||||
actions = []
|
||||
for i in flow["action"]:
|
||||
if isinstance(i, str):
|
||||
print("沿用 已有workflow 作为action", i)
|
||||
action = self.workflow_name[i]
|
||||
else:
|
||||
print("创建 action")
|
||||
action = self.create_action_from_json(i)
|
||||
actions.append(action)
|
||||
flow_instance = ModbusWorkflow(name=flow["name"], actions=actions)
|
||||
print("创建完成 workflow", flow["name"])
|
||||
self.workflow_name[flow["name"]] = flow_instance
|
||||
|
||||
def execute_workflow_from_json(self, data: list[str]):
|
||||
for i in data:
|
||||
print("正在执行 workflow", i)
|
||||
self.run_modbus_workflow(self.workflow_name[i])
|
||||
|
||||
def execute_procedure_from_json(self, data: ExecuteProcedureJson):
|
||||
if data["register_node_list_from_csv_path"]:
|
||||
print("注册节点 csv", data["register_node_list_from_csv_path"])
|
||||
self.register_node_list_from_csv_path(**data["register_node_list_from_csv_path"])
|
||||
print("创建工作流")
|
||||
self.create_workflow_from_json(data["create_flow"])
|
||||
print("执行工作流")
|
||||
self.execute_workflow_from_json(data["execute_flow"])
|
||||
|
||||
|
||||
class TCPClient(BaseClient):
|
||||
def __init__(self, addr: str, port: int):
|
||||
super().__init__()
|
||||
self._set_client(ModbusTcpClient(host=addr, port=port))
|
||||
# self._connect()
|
||||
|
||||
|
||||
class RTUClient(BaseClient):
|
||||
def __init__(self, port: str, baudrate: int, timeout: int):
|
||||
super().__init__()
|
||||
self._set_client(ModbusSerialClient(framer=FramerType.RTU, port=port, baudrate=baudrate, timeout=timeout))
|
||||
self._connect()
|
||||
|
||||
if __name__ == '__main__':
|
||||
""" 代码写法① """
|
||||
def idel_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# 修改速度
|
||||
use_node('M01_idlepos_velocity_rw').write(20.0)
|
||||
# 修改位置
|
||||
# use_node('M01_idlepos_position_rw').write(35.22)
|
||||
return True
|
||||
|
||||
def idel_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_idlepos_coil_w').write(True)
|
||||
while True:
|
||||
pos_idel, idel_err = use_node('M01_idlepos_coil_r').read(1)
|
||||
pos_stop, stop_err = use_node('M01_manual_stop_coil_r').read(1)
|
||||
time.sleep(0.5)
|
||||
if not idel_err and not stop_err and pos_idel[0] and pos_stop[0]:
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def idel_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_idlepos_coil_w').write(False)
|
||||
return True
|
||||
|
||||
move_idel = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
|
||||
init=idel_init,
|
||||
start=idel_position,
|
||||
stop=idel_stop,
|
||||
)])
|
||||
|
||||
def pipetter_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# 修改速度
|
||||
# use_node('M01_idlepos_velocity_rw').write(10.0)
|
||||
# 修改位置
|
||||
# use_node('M01_idlepos_position_rw').write(35.22)
|
||||
return True
|
||||
|
||||
def pipetter_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_pipette0_coil_w').write(True)
|
||||
while True:
|
||||
pos_idel, isError = use_node('M01_pipette0_coil_r').read(1)
|
||||
pos_stop, isError = use_node('M01_manual_stop_coil_r').read(1)
|
||||
time.sleep(0.5)
|
||||
if pos_idel[0] and pos_stop[0]:
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def pipetter_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_pipette0_coil_w').write(False)
|
||||
return True
|
||||
|
||||
move_pipetter = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
|
||||
init=None,
|
||||
start=pipetter_position,
|
||||
stop=pipetter_stop,
|
||||
)])
|
||||
|
||||
workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[
|
||||
move_idel,
|
||||
move_pipetter,
|
||||
])
|
||||
|
||||
# .run_modbus_workflow(move_2_left_workflow)
|
||||
|
||||
""" 代码写法② """
|
||||
# if False:
|
||||
# modbus_tcp_client_test2 = TCPClient('192.168.3.2', 502)
|
||||
# modbus_tcp_client_test2.register_node_list_from_csv_path('M01.csv')
|
||||
# init = modbus_tcp_client_test2.create_init_function('idel_init', 'M01_idlepos_velocity_rw', 'write', 20.0)
|
||||
#
|
||||
# modbus_tcp_client_test2.create_address_function('pos_tip', 'M01_idlepos_coil_w', 'write', True)
|
||||
# modbus_tcp_client_test2.create_address_function('pos_tip_read', 'M01_idlepos_coil_r', 'read', 1)
|
||||
# modbus_tcp_client_test2.create_address_function('manual_stop', 'M01_manual_stop_coil_r', 'read', 1)
|
||||
# start = modbus_tcp_client_test2.create_start_function(
|
||||
# 'idel_position',
|
||||
# write_functions=[
|
||||
# 'pos_tip'
|
||||
# ],
|
||||
# condition_functions=[
|
||||
# 'pos_tip_read',
|
||||
# 'manual_stop'
|
||||
# ],
|
||||
# stop_condition_expression='pos_tip_read[0] and manual_stop[0]'
|
||||
# )
|
||||
# stop = modbus_tcp_client_test2.create_stop_function('idel_stop', 'M01_idlepos_coil_w', 'write', False)
|
||||
#
|
||||
# move_idel = ModbusWorkflow(name="归位", actions=[WorkflowAction(
|
||||
# init=init,
|
||||
# start=start,
|
||||
# stop=stop,
|
||||
# )])
|
||||
#
|
||||
# modbus_tcp_client_test2.create_address_function('pipetter_position', 'M01_pipette0_coil_w', 'write', True)
|
||||
# modbus_tcp_client_test2.create_address_function('pipetter_position_read', 'M01_pipette0_coil_r', 'read', 1)
|
||||
# modbus_tcp_client_test2.create_address_function('pipetter_stop_read', 'M01_manual_stop_coil_r', 'read', 1)
|
||||
# pipetter_position = modbus_tcp_client_test2.create_start_function(
|
||||
# 'pipetter_start',
|
||||
# write_functions=[
|
||||
# 'pipetter_position'
|
||||
# ],
|
||||
# condition_functions=[
|
||||
# 'pipetter_position_read',
|
||||
# 'pipetter_stop_read'
|
||||
# ],
|
||||
# stop_condition_expression='pipetter_position[0] and pipetter_stop_read[0]'
|
||||
# )
|
||||
# pipetter_stop = modbus_tcp_client_test2.create_stop_function('pipetter_stop', 'M01_pipette0_coil_w', 'write', False)
|
||||
#
|
||||
# move_pipetter = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
|
||||
# init=None,
|
||||
# start=pipetter_position,
|
||||
# stop=pipetter_stop,
|
||||
# )])
|
||||
#
|
||||
# workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[
|
||||
# move_idel,
|
||||
# move_pipetter,
|
||||
# ])
|
||||
#
|
||||
# workflow_test_2.run_modbus_workflow()
|
||||
|
||||
""" 代码写法③ """
|
||||
with open('example_json.json', 'r', encoding='utf-8') as f:
|
||||
example_json = json.load(f)
|
||||
modbus_tcp_client_test2 = TCPClient('127.0.0.1', 5021)
|
||||
modbus_tcp_client_test2.execute_procedure_from_json(example_json)
|
||||
# .run_modbus_workflow(move_2_left_workflow)
|
||||
# init_client(FramerType.SOCKET, "", '192.168.3.2', 502)
|
||||
104
unilabos/device_comms/modbus_plc/example_json.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"register_node_list_from_csv_path": {
|
||||
"path": "M01.csv"
|
||||
},
|
||||
"create_flow": [
|
||||
{
|
||||
"name": "归位",
|
||||
"action": [
|
||||
{
|
||||
"address_function_to_create": [
|
||||
{
|
||||
"func_name": "pos_tip",
|
||||
"node_name": "M01_idlepos_coil_w",
|
||||
"mode": "write",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"func_name": "pos_tip_read",
|
||||
"node_name": "M01_idlepos_coil_r",
|
||||
"mode": "read",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"func_name": "manual_stop",
|
||||
"node_name": "M01_manual_stop_coil_r",
|
||||
"mode": "read",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"create_init_function": {
|
||||
"func_name": "idel_init",
|
||||
"node_name": "M01_idlepos_velocity_rw",
|
||||
"mode": "write",
|
||||
"value": 20.0
|
||||
},
|
||||
"create_start_function": {
|
||||
"func_name": "idel_position",
|
||||
"write_functions": ["pos_tip"],
|
||||
"condition_functions": ["pos_tip_read", "manual_stop"],
|
||||
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
|
||||
},
|
||||
"create_stop_function": {
|
||||
"func_name": "idel_stop",
|
||||
"node_name": "M01_idlepos_coil_w",
|
||||
"mode": "write",
|
||||
"value": false
|
||||
},
|
||||
"create_cleanup_function": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "测试待机位置",
|
||||
"action": [
|
||||
{
|
||||
"address_function_to_create": [
|
||||
{
|
||||
"func_name": "pipetter_position",
|
||||
"node_name": "M01_pipette0_coil_w",
|
||||
"mode": "write",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"func_name": "pipetter_position_read",
|
||||
"node_name": "M01_pipette0_coil_r",
|
||||
"mode": "read",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"func_name": "pipetter_stop_read",
|
||||
"node_name": "M01_manual_stop_coil_r",
|
||||
"mode": "read",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"create_init_function": null,
|
||||
"create_start_function": {
|
||||
"func_name": "pipetter_start",
|
||||
"write_functions": ["pipetter_position"],
|
||||
"condition_functions": ["pipetter_position_read", "pipetter_stop_read"],
|
||||
"stop_condition_expression": "pipetter_position_read[0] and pipetter_stop_read[0]"
|
||||
},
|
||||
"create_stop_function": {
|
||||
"func_name": "pipetter_stop",
|
||||
"node_name": "M01_pipette0_coil_w",
|
||||
"mode": "write",
|
||||
"value": false
|
||||
},
|
||||
"create_cleanup_function": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "归位并测试待机位置",
|
||||
"action": [
|
||||
"归位",
|
||||
"测试待机位置"
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute_flow": [
|
||||
"归位并测试待机位置"
|
||||
]
|
||||
}
|
||||
0
unilabos/device_comms/modbus_plc/node/__init__.py
Normal file
161
unilabos/device_comms/modbus_plc/node/modbus.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# coding=utf-8
|
||||
from enum import Enum
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pymodbus.client import ModbusBaseSyncClient
|
||||
from pymodbus.client.mixin import ModbusClientMixin
|
||||
from typing import Tuple, Union, Optional
|
||||
|
||||
DataType = ModbusClientMixin.DATATYPE
|
||||
|
||||
class WorderOrder(Enum):
|
||||
BIG = "big"
|
||||
LITTLE = "little"
|
||||
|
||||
class DeviceType(Enum):
|
||||
COIL = 'coil'
|
||||
DISCRETE_INPUTS = 'discrete_inputs'
|
||||
HOLD_REGISTER = 'hold_register'
|
||||
INPUT_REGISTER = 'input_register'
|
||||
|
||||
|
||||
class Base(ABC):
|
||||
def __init__(self, client: ModbusBaseSyncClient, name: str, address: int, typ: DeviceType, data_type: DataType):
|
||||
self._address: int = address
|
||||
self._client = client
|
||||
self._name = name
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
|
||||
@abstractmethod
|
||||
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
|
||||
pass
|
||||
|
||||
@property
|
||||
def type(self) -> DeviceType:
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def address(self) -> int:
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
|
||||
class Coil(Base):
|
||||
def __init__(self, client,name, address: int, data_type: DataType):
|
||||
super().__init__(client, name, address, DeviceType.COIL, data_type)
|
||||
|
||||
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
|
||||
resp = self._client.read_coils(
|
||||
address = self.address,
|
||||
count = value,
|
||||
slave = slave)
|
||||
|
||||
return resp.bits, resp.isError()
|
||||
|
||||
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
|
||||
if isinstance(value, list):
|
||||
for v in value:
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f'value invalidate: {value}')
|
||||
|
||||
return self._client.write_coils(
|
||||
address = self.address,
|
||||
values = [bool(v) for v in value],
|
||||
slave = slave).isError()
|
||||
else:
|
||||
return self._client.write_coil(
|
||||
address = self.address,
|
||||
value = bool(value),
|
||||
slave = slave).isError()
|
||||
|
||||
|
||||
class DiscreteInputs(Base):
|
||||
def __init__(self, client,name, address: int, data_type: DataType):
|
||||
super().__init__(client, name, address, DeviceType.COIL, data_type)
|
||||
|
||||
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
|
||||
if not data_type and not self._data_type:
|
||||
raise ValueError('data type is required')
|
||||
if not data_type:
|
||||
data_type = self._data_type
|
||||
resp = self._client.read_discrete_inputs(
|
||||
self.address,
|
||||
count = value,
|
||||
slave = slave)
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
|
||||
|
||||
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
|
||||
raise ValueError('discrete inputs only support read')
|
||||
|
||||
class HoldRegister(Base):
|
||||
def __init__(self, client,name, address: int, data_type: DataType):
|
||||
super().__init__(client, name, address, DeviceType.COIL, data_type)
|
||||
|
||||
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
|
||||
if not data_type and not self._data_type:
|
||||
raise ValueError('data type is required')
|
||||
|
||||
if not data_type:
|
||||
data_type = self._data_type
|
||||
|
||||
resp = self._client.read_holding_registers(
|
||||
address = self.address,
|
||||
count = value,
|
||||
slave = slave)
|
||||
# noinspection PyTypeChecker
|
||||
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
|
||||
|
||||
|
||||
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
|
||||
if not data_type and not self._data_type:
|
||||
raise ValueError('data type is required')
|
||||
|
||||
if not data_type:
|
||||
data_type = self._data_type
|
||||
|
||||
if isinstance(value , bool):
|
||||
if value:
|
||||
return self._client.write_register(self.address, 1, slave= slave).isError()
|
||||
else:
|
||||
return self._client.write_register(self.address, 0, slave= slave).isError()
|
||||
elif isinstance(value, int):
|
||||
return self._client.write_register(self.address, value, slave= slave).isError()
|
||||
else:
|
||||
# noinspection PyTypeChecker
|
||||
encoder_resp = self._client.convert_to_registers(value, data_type=data_type, word_order=word_order.value)
|
||||
return self._client.write_registers(self.address, encoder_resp, slave=slave).isError()
|
||||
|
||||
|
||||
|
||||
class InputRegister(Base):
|
||||
def __init__(self, client,name, address: int, data_type: DataType):
|
||||
super().__init__(client, name, address, DeviceType.COIL, data_type)
|
||||
|
||||
|
||||
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
|
||||
if not data_type and not self._data_type:
|
||||
raise ValueError('data type is required')
|
||||
|
||||
if not data_type:
|
||||
data_type = self._data_type
|
||||
|
||||
resp = self._client.read_holding_registers(
|
||||
address = self.address,
|
||||
count = value,
|
||||
slave = slave)
|
||||
# noinspection PyTypeChecker
|
||||
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
|
||||
|
||||
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
|
||||
raise ValueError('input register only support read')
|
||||
|
||||
37
unilabos/device_comms/modbus_plc/server.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import modbus_tk.defines as cst
|
||||
import modbus_tk.modbus_tcp as modbus_tcp
|
||||
|
||||
# 创建一个 Modbus TCP 服务器
|
||||
server = modbus_tcp.TcpServer(
|
||||
address="127.0.0.1", port=5021, timeout_in_sec=1
|
||||
)
|
||||
|
||||
# 添加一个从设备 (slave)
|
||||
slave_id = 1
|
||||
slave = server.add_slave(slave_id)
|
||||
|
||||
# 为从设备分配地址空间,例如保持寄存器 (holding registers)
|
||||
# 假设地址范围为 7000 到 7100,对应客户端M01_idlepos_velocity_rw
|
||||
slave.add_block('hr', cst.HOLDING_REGISTERS, 7000, 100)
|
||||
slave.add_block('coil_block', cst.COILS, 56000, 1000)
|
||||
|
||||
|
||||
# 初始化地址 56488 和 56432 的值为 True
|
||||
slave.set_values('coil_block', 56488, [True]) # Coil 56488 设置为 True
|
||||
slave.set_values('coil_block', 56432, [True]) # Coil 56432 设置为 True
|
||||
|
||||
slave.set_values('coil_block', 56496, [True]) # Coil 56488 设置为 True
|
||||
slave.set_values('coil_block', 56432, [True]) # Coil 56432 设置为 True
|
||||
|
||||
|
||||
# slave.add_block('hr', cst.COILS, 7000, 100)
|
||||
server.start()
|
||||
print("Modbus TCP server running on localhost:5021")
|
||||
|
||||
# 保持服务器运行,直到按下 Ctrl+C
|
||||
try:
|
||||
while True:
|
||||
pass
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
print("Server stopped.")
|
||||
0
unilabos/device_comms/modbus_plc/test/__init__.py
Normal file
107
unilabos/device_comms/modbus_plc/test/client.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import time
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister
|
||||
from pymodbus.payload import BinaryPayloadDecoder
|
||||
from pymodbus.constants import Endian
|
||||
|
||||
# client = ModbusTcpClient('localhost', port=5020)
|
||||
client = ModbusTcpClient('192.168.3.2', port=502)
|
||||
|
||||
client.connect()
|
||||
|
||||
|
||||
coil1 = Coil(client=client, name='coil_test1', data_type=bool, address=4502*8)
|
||||
coil1.write(True)
|
||||
time.sleep(3)
|
||||
coil1.write(False)
|
||||
|
||||
|
||||
|
||||
coil1 = Coil(client=client, name='coil_test1', data_type=bool, address=4503*8)
|
||||
coil1.write(True)
|
||||
time.sleep(3)
|
||||
coil1.write(False)
|
||||
|
||||
|
||||
exit(0)
|
||||
|
||||
|
||||
|
||||
register1 = HoldRegister(client=client, name="test-1", address=7040)
|
||||
|
||||
|
||||
coil1 = Coil(client=client, name='coil_test1', address=7002*8)
|
||||
|
||||
|
||||
coil1.write(True)
|
||||
|
||||
while True:
|
||||
# result = client.read_holding_registers(address=7040, count=2, slave=1) # unit=1 是从站地址
|
||||
result = register1.read(2, slave=1)
|
||||
if result.isError():
|
||||
print("读取失败")
|
||||
else:
|
||||
print("读取成功:", result.registers)
|
||||
decoder = BinaryPayloadDecoder.fromRegisters(
|
||||
result.registers, byteorder=Endian.BIG, wordorder=Endian.LITTLE
|
||||
)
|
||||
real_value = decoder.decode_32bit_float()
|
||||
print("这里的值是: ", real_value)
|
||||
if real_value > 42:
|
||||
coil1.write(False)
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
# # 创建 Modbus TCP 客户端,连接到本地模拟的服务器
|
||||
# client = ModbusClient('localhost', port=5020)
|
||||
|
||||
# # 连接到服务器
|
||||
|
||||
# # 读取保持寄存器(地址 0,读取 10 个寄存器)
|
||||
# # address: int,
|
||||
# # *,
|
||||
# # count: int = 1,
|
||||
# # slave: int = 1,
|
||||
# response = client.read_holding_registers(
|
||||
# address=0, count=10, slave=1
|
||||
# )
|
||||
|
||||
# response = coil1.read(2, slave=1)
|
||||
#
|
||||
# if response.isError():
|
||||
# print(f"Error reading registers: {response}")
|
||||
# else:
|
||||
# print(f"Read holding registers: {response.bits}")
|
||||
#
|
||||
# coil1.write(1, slave=1)
|
||||
# print("Wrote value 1234 to holding register 0")
|
||||
#
|
||||
# response = coil1.read(2, slave=1)
|
||||
# if response.isError():
|
||||
# print(f"Error reading registers: {response}")
|
||||
# else:
|
||||
# print(f"Read holding registers after write: {response.bits}")
|
||||
#
|
||||
#
|
||||
# if response.isError():
|
||||
# print(f"Error reading registers: {response}")
|
||||
# else:
|
||||
# print(f"Read holding registers after write: {response.bits}")
|
||||
|
||||
client.close()
|
||||
|
||||
# # 写入保持寄存器(地址 0,值为 1234)
|
||||
# client.write_register(0, 1234, slave=1)
|
||||
# print("Wrote value 1234 to holding register 0")
|
||||
|
||||
# # 再次读取寄存器,确认写入成功
|
||||
# response = client.read_holding_registers(address=0, count=10, slave=1)
|
||||
# if response.isError():
|
||||
# print(f"Error reading registers: {response}")
|
||||
# else:
|
||||
# print(f"Read holding registers after write: {response.registers}")
|
||||
|
||||
# # 关闭连接
|
||||
# client.close()
|
||||
|
||||
45
unilabos/device_comms/modbus_plc/test/node_test.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# coding=utf-8
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from unilabos.device_comms.modbus_plc.node.modbus import Coil
|
||||
import time
|
||||
|
||||
|
||||
client = ModbusTcpClient('192.168.3.2', port=502)
|
||||
client.connect()
|
||||
|
||||
coil1 = Coil(client=client, name='0', address=7012*8)
|
||||
|
||||
coil2 = Coil(client=client, name='0', address=7062*8)
|
||||
coil3 = Coil(client=client, name='0', address=7054*8)
|
||||
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
resp, isError = coil2.read(1)
|
||||
resp1, isError = coil3.read(1)
|
||||
print(resp[0], resp1[0])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# hr = HoldRegister(client, '1', 100)
|
||||
# resp = hr.write([666.3, 777.4], data_type=DATATYPE.FLOAT32, word_order=WORDORDER.BIG)
|
||||
# print('write ===== hr1', resp)
|
||||
# time.sleep(1)
|
||||
# h_resp = hr.read(4, data_type=DATATYPE.FLOAT32, word_order=WORDORDER.BIG)
|
||||
# print('=======hr1', h_resp)
|
||||
#
|
||||
#
|
||||
# resp = hr.write([666, 777], data_type=DATATYPE.INT32, word_order=WORDORDER.BIG)
|
||||
# print('write ===== hr1', resp)
|
||||
# time.sleep(1)
|
||||
# h_resp = hr.read(4, data_type=DATATYPE.INT32, word_order=WORDORDER.BIG)
|
||||
# print('=======hr1', h_resp)
|
||||
#
|
||||
#
|
||||
# resp = hr.write('hello world!', data_type=DATATYPE.STRING, word_order=WORDORDER.BIG)
|
||||
# print('write ===== hr1', resp)
|
||||
# time.sleep(1)
|
||||
# h_resp = hr.read(12, data_type=DATATYPE.STRING, word_order=WORDORDER.BIG)
|
||||
# print('=======hr1', h_resp)
|
||||
42
unilabos/device_comms/modbus_plc/test/server.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import modbus_tk.modbus_tcp as modbus_tcp
|
||||
import modbus_tk.defines as cst
|
||||
from modbus_tk.modbus import Slave
|
||||
|
||||
# 创建一个 Modbus TCP 服务器
|
||||
server = modbus_tcp.TcpServer(
|
||||
address="localhost", port=5020, timeout_in_sec=1
|
||||
)
|
||||
|
||||
# 设置服务器的地址和端口
|
||||
# server.set_address("localhost", 5020) # 监听在本地端口5020
|
||||
|
||||
# 添加一个从设备,Slave ID 设为 1
|
||||
slave: Slave = server.add_slave(1)
|
||||
|
||||
|
||||
# 向从设备添加一个保持寄存器块,假设从地址0开始,10个寄存器
|
||||
# def add_block(self, block_name, block_type, starting_address, size)
|
||||
# slave.add_block('0', cst.HOLDING_REGISTERS, 0, 10)
|
||||
|
||||
# 添加一个线圈
|
||||
# 0 名字, 从 16 字节内存位置开始,分配连续两个字节的内存大小,注意地址只能是 8 的整数倍
|
||||
slave.add_block('0', cst.COILS, 2*8, 2)
|
||||
|
||||
# 1 名字,100 起始地址, 8 是从 100 的位置分配 8 个字节内存, 两个线圈的量
|
||||
slave.add_block('1', cst.HOLDING_REGISTERS, 100, 8)
|
||||
slave.add_block('2', cst.HOLDING_REGISTERS, 200, 16)
|
||||
|
||||
# slave.add_block('2', cst.DISCRETE_INPUTS , 200, 2)
|
||||
# slave.add_block('3', cst.ANALOG_INPUTS , 300, 2)
|
||||
# 启动服务器
|
||||
server.start()
|
||||
|
||||
print("Modbus TCP server running on localhost:5020")
|
||||
|
||||
# 保持服务器运行,直到按下 Ctrl+C
|
||||
try:
|
||||
while True:
|
||||
pass
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
print("Server stopped.")
|
||||
168
unilabos/device_comms/modbus_plc/test/test_workflow.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import time
|
||||
from typing import Callable
|
||||
from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusWorkflow, WorkflowAction, load_csv
|
||||
from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase
|
||||
|
||||
############ 第一种写法 ##############
|
||||
|
||||
|
||||
# modbus_tcp_client_test1 = TCPClient('192.168.3.2', 502)
|
||||
#
|
||||
#
|
||||
# node_list = [
|
||||
# ModbusNode(name="left_move_coli", device_type=DeviceType.COIL, address=7003 * 8),
|
||||
# ModbusNode(name="right_move_coli", device_type=DeviceType.COIL, address=7002 * 8),
|
||||
# ModbusNode(name="position_register", device_type=DeviceType.HOLD_REGISTER, address=7040),
|
||||
# ]
|
||||
#
|
||||
# def judge_position(node: ModbusNodeBase):
|
||||
# idx = 0
|
||||
# while idx <= 5:
|
||||
# result, is_err = node.read(2)
|
||||
# if is_err:
|
||||
# print("读取失败")
|
||||
# else:
|
||||
# print("读取成功:", result)
|
||||
# idx+=1
|
||||
# time.sleep(1)
|
||||
#
|
||||
# workflow_move_2_right = PLCWorkflow(name="测试水平向右移动", actions=[
|
||||
# lambda use_node: use_node('left_move_coli').write(False),
|
||||
# lambda use_node: use_node('right_move_coli').write(True),
|
||||
# lambda use_node: judge_position(use_node('position_register')),
|
||||
# lambda use_node: use_node('right_move_coli').write(False),
|
||||
# ])
|
||||
#
|
||||
#
|
||||
# workflow_move_2_left = PLCWorkflow(name="测试水平向左移动", actions=[
|
||||
# lambda use_node: use_node('right_move_coli').write(False),
|
||||
# lambda use_node: use_node('left_move_coli').write(True),
|
||||
# lambda use_node: judge_position(use_node('position_register')),
|
||||
# lambda use_node: use_node('left_move_coli').write(False),
|
||||
# ])
|
||||
#
|
||||
#
|
||||
# workflow_test_1 = PLCWorkflow(name="测试水平移动并停止", actions=[
|
||||
# workflow_move_2_right,
|
||||
# workflow_move_2_left,
|
||||
# ])
|
||||
#
|
||||
# modbus_tcp_client_test1 \
|
||||
# .register_node_list(node_list) \
|
||||
# .run_plc_workflow(workflow_test_1)
|
||||
#
|
||||
|
||||
|
||||
############ 第二种写法 ##############
|
||||
|
||||
modbus_tcp_client_test2 = TCPClient('192.168.3.2', 502)
|
||||
|
||||
# def judge_position(node: ModbusNodeBase):
|
||||
# idx = 0
|
||||
# while idx <= 5:
|
||||
# result, is_err = node.read(2)
|
||||
# if is_err:
|
||||
# print("读取失败")
|
||||
# else:
|
||||
# print("读取成功:", result)
|
||||
# idx+=1
|
||||
# time.sleep(1)
|
||||
|
||||
|
||||
|
||||
# def move_2_right_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# use_node('left_move_coli').write(False)
|
||||
# use_node('right_move_coli').write(True)
|
||||
# return True
|
||||
|
||||
# def move_2_right_start(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# judge_position(use_node('position_register'))
|
||||
# return True
|
||||
|
||||
# def move_2_right_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# use_node('right_move_coli').write(False)
|
||||
# return True
|
||||
|
||||
# move_2_right_workflow = ModbusWorkflow(name="测试水平向右移动", actions=[WorkflowAction(
|
||||
# init=move_2_right_init,
|
||||
# start=move_2_right_start,
|
||||
# stop=move_2_right_stop,
|
||||
# )])
|
||||
|
||||
# move_2_right_workflow = ModbusWorkflow(name="测试水平向右移动", actions=[WorkflowAction(
|
||||
# init=move_2_right_init,
|
||||
# start= None,
|
||||
# stop= None,
|
||||
# cleanup=None,
|
||||
# )])
|
||||
|
||||
|
||||
def idel_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# 修改速度
|
||||
use_node('M01_idlepos_velocity_rw').write(20.0)
|
||||
# 修改位置
|
||||
# use_node('M01_idlepos_position_rw').write(35.22)
|
||||
return True
|
||||
|
||||
def idel_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_idlepos_coil_w').write(True)
|
||||
while True:
|
||||
pos_idel, idel_err = use_node('M01_idlepos_coil_r').read(1)
|
||||
pos_stop, stop_err = use_node('M01_manual_stop_coil_r').read(1)
|
||||
time.sleep(0.5)
|
||||
if not idel_err and not stop_err and pos_idel[0] and pos_stop[0]:
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def idel_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_idlepos_coil_w').write(False)
|
||||
return True
|
||||
|
||||
move_idel= ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
|
||||
init=idel_init,
|
||||
start=idel_position,
|
||||
stop=idel_stop,
|
||||
)])
|
||||
|
||||
def pipetter_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
# 修改速度
|
||||
# use_node('M01_idlepos_velocity_rw').write(10.0)
|
||||
# 修改位置
|
||||
# use_node('M01_idlepos_position_rw').write(35.22)
|
||||
return True
|
||||
|
||||
def pipetter_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_pipette0_coil_w').write(True)
|
||||
while True:
|
||||
pos_idel, isError = use_node('M01_pipette0_coil_r').read(1)
|
||||
pos_stop, isError = use_node('M01_manual_stop_coil_r').read(1)
|
||||
time.sleep(0.5)
|
||||
if pos_idel[0] and pos_stop[0]:
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def pipetter_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
|
||||
use_node('M01_pipette0_coil_w').write(False)
|
||||
return True
|
||||
|
||||
move_pipetter= ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
|
||||
init=None,
|
||||
start=pipetter_position,
|
||||
stop=pipetter_stop,
|
||||
)])
|
||||
|
||||
|
||||
|
||||
workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[
|
||||
move_idel,
|
||||
move_pipetter,
|
||||
])
|
||||
|
||||
nodes = load_csv('/Users/dingshinn/Desktop/lbg/uni-lab/M01.csv')
|
||||
|
||||
modbus_tcp_client_test2 \
|
||||
.register_node_list(nodes) \
|
||||
.run_modbus_workflow(workflow_test_2)
|
||||
# .run_modbus_workflow(move_2_left_workflow)
|
||||
66
unilabos/device_comms/rpc.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
import requests
|
||||
from rclpy.logging import get_logger
|
||||
|
||||
|
||||
class BaseRequest:
|
||||
def __init__(self):
|
||||
self._logger = get_logger(__name__)
|
||||
|
||||
def get_logger(self):
|
||||
return self._logger
|
||||
|
||||
def get(self, url, params, headers={"Content-Type": "application/json"}):
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=30)
|
||||
self.get_logger().debug(
|
||||
f"Request >>> : {params} {response.status_code} {response.text}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"Request ERROR: {e}")
|
||||
return
|
||||
|
||||
def post(self, url, params={}, files=None, headers={"Content-Type": "application/json"}):
|
||||
try:
|
||||
response = requests.post(
|
||||
url, data=json.dumps(params) if params else None, headers=headers, timeout=120, files=files
|
||||
)
|
||||
self.get_logger().debug(
|
||||
f"Request >>> : {response.request.body} {response.status_code} {response.text}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception("Request ERROR:", response.text)
|
||||
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"Request ERROR: {e}")
|
||||
return
|
||||
|
||||
def form_post(self, url, params):
|
||||
try:
|
||||
response = requests.post(
|
||||
url=url,
|
||||
data=params,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=3,
|
||||
)
|
||||
self.get_logger().debug(
|
||||
f"Request >>> : {response.request.body} {response.status_code} {response.text}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception("Request ERROR:", response.text)
|
||||
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"Request ERROR: {e}")
|
||||
return
|
||||
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
228
unilabos/device_comms/universal_driver.py
Normal file
@@ -0,0 +1,228 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import wraps
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
from socket import socket
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
import serial
|
||||
|
||||
class SingleRunningExecutor(object):
|
||||
"""
|
||||
异步执行单个任务,不允许重复执行,通过class的函数获得唯一任务名的实例
|
||||
需要对
|
||||
"""
|
||||
__instance = {}
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, func, post_func=None, name=None, *var, **kwargs):
|
||||
print(f"!!!get_instance: {name} {kwargs}")
|
||||
if name is None:
|
||||
name = func.__name__
|
||||
if name not in cls.__instance:
|
||||
cls.__instance[name] = cls(func, post_func, *var, **kwargs)
|
||||
return cls.__instance[name]
|
||||
|
||||
start_time: float = None
|
||||
end_time: float = None
|
||||
is_running: bool = None
|
||||
is_error: bool = None
|
||||
is_success: bool = None
|
||||
|
||||
@property
|
||||
def is_ended(self):
|
||||
return not self.is_running and (self.is_error or self.is_success)
|
||||
|
||||
@property
|
||||
def is_started(self):
|
||||
return self.is_running or self.is_error or self.is_success
|
||||
|
||||
def reset(self):
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
self.is_running = False
|
||||
self.is_error = False
|
||||
self.is_success = False
|
||||
self._final_var = {}
|
||||
self._thread = threading.Thread(target=self._execute)
|
||||
|
||||
def __init__(self, func, post_func=None, *var, **kwargs):
|
||||
self._func = func
|
||||
self._post_func = post_func
|
||||
self._sig = inspect.signature(self._func)
|
||||
self._var = var
|
||||
self._kwargs = kwargs
|
||||
self.reset()
|
||||
|
||||
def _execute(self):
|
||||
res = None
|
||||
try:
|
||||
for ind, i in enumerate(self._var):
|
||||
self._final_var[list(self._sig.parameters.keys())[ind]] = i
|
||||
for k, v in self._kwargs.items():
|
||||
if k in self._sig.parameters.keys():
|
||||
self._final_var[k] = v
|
||||
self.is_running = True
|
||||
print(f"!!!_final_var: {self._final_var}")
|
||||
res = self._func(**self._final_var)
|
||||
except Exception as e:
|
||||
self.is_running = False
|
||||
self.is_error = True
|
||||
traceback.print_exc()
|
||||
if callable(self._post_func):
|
||||
self._post_func(res, self._final_var)
|
||||
|
||||
def start(self, **kwargs):
|
||||
if len(kwargs) > 0:
|
||||
self._kwargs = kwargs
|
||||
self.start_time = time.time()
|
||||
self._thread.start()
|
||||
|
||||
def join(self):
|
||||
if self.is_running:
|
||||
self._thread.join()
|
||||
self.end_time = time.time()
|
||||
|
||||
|
||||
def command(func):
|
||||
"""
|
||||
Decorator for command_set execution. Checks if the method is called in the same thread as the class instance,
|
||||
if so enqueues the command_set and waits for a reply in the reply queue. Else it concludes it must be the command
|
||||
handler thread and actually executes the method. This way methods in the child classes need to be written
|
||||
just once and decorated accordingly.
|
||||
:return: decorated method
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
device_instance = args[0]
|
||||
if threading.get_ident() == device_instance.current_thread:
|
||||
command_set = [func, args, kwargs]
|
||||
device_instance.command_queue.put(command_set)
|
||||
while True:
|
||||
if not device_instance.reply_queue.empty():
|
||||
return device_instance.reply_queue.get()
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
|
||||
class UniversalDriver(object):
|
||||
def _init_logger(self):
|
||||
self.logger = logging.getLogger(f"{self.__class__.__name__}_logger")
|
||||
|
||||
def __init__(self):
|
||||
self._init_logger()
|
||||
|
||||
def execute_command_from_outer(self, command: str):
|
||||
try:
|
||||
command = json.loads(command.replace("'", '"').replace("False", "false").replace("True", "true")) # 要求不能出现'作为字符串
|
||||
except Exception as e:
|
||||
print(f"Json解析失败: {e}")
|
||||
return False
|
||||
for k, v in command.items():
|
||||
print(f"执行{k}方法,参数为{v}")
|
||||
try:
|
||||
getattr(self, k)(**v)
|
||||
except Exception as e:
|
||||
print(f"执行{k}方法失败: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class TransportDriver(UniversalDriver):
|
||||
COMMAND_QUEUE_ENABLE = True
|
||||
command_handler_thread: Optional[threading.Thread] = None
|
||||
__connection: Optional[serial.Serial | socket] = None
|
||||
|
||||
|
||||
def _init_command_queue(self):
|
||||
self.command_queue = queue.Queue()
|
||||
self.reply_queue = queue.Queue()
|
||||
|
||||
def __command_handler_daemon(self):
|
||||
while True:
|
||||
try:
|
||||
if not self.command_queue.empty():
|
||||
command_item = self.command_queue.get()
|
||||
method = command_item[0]
|
||||
arguments = command_item[1]
|
||||
keywordarguments = command_item[2]
|
||||
reply = method(*arguments, **keywordarguments)
|
||||
self.reply_queue.put(reply)
|
||||
else:
|
||||
self.keepalive()
|
||||
except ValueError as e:
|
||||
# workaround if something goes wrong with the serial connection
|
||||
# future me will certainly not hate past me for this...
|
||||
self.logger.critical(e)
|
||||
self.__connection.flush()
|
||||
# thread-safe purging of both queues
|
||||
while not self.command_queue.empty():
|
||||
self.command_queue.get()
|
||||
while not self.reply_queue.empty():
|
||||
self.reply_queue.get()
|
||||
|
||||
def launch_command_handler(self):
|
||||
if self.COMMAND_QUEUE_ENABLE:
|
||||
self.command_handler_thread = threading.Thread(target=self.__command_handler_daemon, name="{0}_command_handler".format(self.device_name), daemon=True)
|
||||
self.command_handler_thread.start()
|
||||
|
||||
@abstractmethod
|
||||
def open_connection(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close_connection(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def keepalive(self):
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
if self.COMMAND_QUEUE_ENABLE:
|
||||
self.launch_command_handler()
|
||||
|
||||
|
||||
class DriverChecker(object):
|
||||
def __init__(self, driver, interval: int | float):
|
||||
self.driver = driver
|
||||
self.interval = interval
|
||||
self._thread = threading.Thread(target=self._monitor)
|
||||
self._thread.daemon = True
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def _monitor(self):
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# print(self.__class__.__name__, "Started!")
|
||||
self.check()
|
||||
except Exception as e:
|
||||
print(f"Error in {self.__class__.__name__}: {str(e)}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
time.sleep(self.interval)
|
||||
|
||||
@abstractmethod
|
||||
def check(self):
|
||||
"""子类必须实现此方法"""
|
||||
raise NotImplementedError
|
||||
|
||||
def start_monitoring(self):
|
||||
self._thread.start()
|
||||
|
||||
def stop_monitoring(self):
|
||||
self._stop_event.set()
|
||||
self._thread.join()
|
||||
|
||||
0
unilabos/devices/UV_test/__init__.py
Normal file
333
unilabos/devices/UV_test/fuxiang2.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk # 使用 ttk 替换 tk 控件
|
||||
from tkinter import messagebox
|
||||
from tkinter.font import Font
|
||||
from threading import Thread
|
||||
from ttkthemes import ThemedTk
|
||||
import time
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
import clr # pythonnet library
|
||||
import sys
|
||||
import threading
|
||||
import datetime
|
||||
|
||||
jifenshijian = 10 #积分时间
|
||||
shuaxinshijian = 0.01 #刷新时间
|
||||
zihaodaxiao = 16 #字号大小
|
||||
ymax = 70000
|
||||
ymin = -2000
|
||||
|
||||
# 加载DLL
|
||||
dll_path = "C:\\auto\\UV_spec\\idea-sdk 3.0.9\\idea-sdk.UPI\\IdeaOptics.dll"
|
||||
clr.AddReference(dll_path)
|
||||
from IdeaOptics import Wrapper
|
||||
|
||||
# 初始化Wrapper对象和光谱仪
|
||||
wrapper = Wrapper()
|
||||
number_of_spectrometers = wrapper.OpenAllSpectrometers()
|
||||
if number_of_spectrometers > 0:
|
||||
spectrometer_index = 0 # 假设使用第一个光谱仪
|
||||
integration_time = jifenshijian # 设置积分时间
|
||||
wrapper.setIntegrationTime(spectrometer_index, integration_time)
|
||||
|
||||
class App:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("光谱测试")
|
||||
self.is_continuous = False
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
|
||||
self.stop_event = threading.Event() # 使用Event代替布尔标志
|
||||
self.continuous_thread = None # 在这里初始化
|
||||
self.background_spectrum = None
|
||||
self.correct_background = False
|
||||
self.test_count = 0
|
||||
self.background_count = 0
|
||||
|
||||
self.source_spectrum = None # 初始化光源强度变量
|
||||
self.transmission_mode = False # 初始化透射模式标志
|
||||
|
||||
self.data_ready = False
|
||||
|
||||
# 使用 grid 布局
|
||||
self.root.columnconfigure(0, weight=1)
|
||||
self.root.rowconfigure(0, weight=1)
|
||||
self.root.rowconfigure(1, weight=1)
|
||||
self.root.rowconfigure(2, weight=1)
|
||||
self.root.rowconfigure(3, weight=1)
|
||||
|
||||
self.current_ylim = [-100, 1000] # 初始化y轴范围
|
||||
|
||||
# 创建一个 Style 对象
|
||||
style = ttk.Style()
|
||||
|
||||
# 定义一个新的样式
|
||||
style.configure('Custom.TButton', font=('Helvetica', zihaodaxiao, 'bold'), foreground='white')
|
||||
|
||||
# 创建滑动条和按钮的容器 Frame
|
||||
control_frame = ttk.Frame(self.root)
|
||||
control_frame.grid(row=0, column=0, sticky="ew")
|
||||
|
||||
# 创建一个滑动条来选择平滑次数
|
||||
self.boxcar_width_slider = tk.Scale(control_frame, from_=0, to=10, orient=tk.HORIZONTAL, length=300, label="平滑次数", font=("Helvetica", zihaodaxiao, 'bold'))
|
||||
self.boxcar_width_slider.grid(row=0, column=0, padx=10, pady=10)
|
||||
|
||||
# 创建一个滑动条来选择平均次数
|
||||
self.scans_to_average_slider = tk.Scale(control_frame, from_=1, to=10, orient=tk.HORIZONTAL, length=300, label="平均次数", font=("Helvetica", zihaodaxiao, 'bold'))
|
||||
self.scans_to_average_slider.grid(row=0, column=1, padx=10, pady=10)
|
||||
|
||||
# 调整 Scale 控件的外观
|
||||
self.boxcar_width_slider.config(bg='grey', fg='white')
|
||||
self.scans_to_average_slider.config(bg='grey', fg='white')
|
||||
|
||||
# 字体设置
|
||||
entry_font = ('Helvetica', zihaodaxiao, 'bold')
|
||||
|
||||
# 添加输入框的容器 Frame
|
||||
entry_frame = ttk.Frame(self.root)
|
||||
entry_frame.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
# 创建并放置"积分时间(ms)"输入框
|
||||
ttk.Label(entry_frame, text="积分时间(ms):", font=entry_font).grid(row=0, column=0, padx=10, pady=10)
|
||||
self.integration_time_entry = ttk.Entry(entry_frame, font=entry_font)
|
||||
self.integration_time_entry.grid(row=0, column=1, padx=10, pady=10)
|
||||
self.integration_time_entry.insert(0, "10") # 设置默认值
|
||||
|
||||
# 创建并放置"刷新间隔(s)"输入框
|
||||
ttk.Label(entry_frame, text="刷新间隔(s):", font=entry_font).grid(row=0, column=2, padx=10, pady=10)
|
||||
self.refresh_interval_entry = ttk.Entry(entry_frame, font=entry_font)
|
||||
self.refresh_interval_entry.grid(row=0, column=3, padx=10, pady=10)
|
||||
self.refresh_interval_entry.insert(0, "0.01") # 设置默认值
|
||||
|
||||
# 创建按钮的容器 Frame
|
||||
button_frame = ttk.Frame(self.root)
|
||||
button_frame.grid(row=2, column=0, sticky="ew")
|
||||
|
||||
# 创建并放置按钮
|
||||
ttk.Button(button_frame, text="测试一下", style='Custom.TButton', command=self.single_test).grid(row=0, column=0, padx=10, pady=10)
|
||||
ttk.Button(button_frame, text="连续测试", style='Custom.TButton', command=self.start_continuous_test).grid(row=0, column=1, padx=10, pady=10)
|
||||
ttk.Button(button_frame, text="停止测试", style='Custom.TButton', command=self.stop_continuous_test).grid(row=0, column=2, padx=10, pady=10)
|
||||
|
||||
# 创建背景相关按钮的容器 Frame
|
||||
background_frame = ttk.Frame(self.root)
|
||||
background_frame.grid(row=3, column=0, sticky="ew")
|
||||
|
||||
# 创建并放置“采集背景”按钮
|
||||
self.collect_background_button = ttk.Button(background_frame, text="采集背景", style='Custom.TButton', command=self.collect_background)
|
||||
self.collect_background_button.grid(row=0, column=0, padx=10, pady=10)
|
||||
|
||||
# 创建并放置“背景校正”按钮
|
||||
self.correct_background_button = ttk.Button(background_frame, text="背景校正", style='Custom.TButton', command=self.toggle_background_correction)
|
||||
self.correct_background_button.grid(row=0, column=1, padx=10, pady=10)
|
||||
|
||||
# 创建“光源采集”按钮
|
||||
ttk.Button(background_frame, text="光源采集", style='Custom.TButton', command=self.collect_source).grid(row=0, column=2, padx=10, pady=10)
|
||||
|
||||
# 创建“透射模式”按钮
|
||||
self.transmission_button = ttk.Button(background_frame, text="透射模式", style='Custom.TButton', command=self.toggle_transmission_mode)
|
||||
self.transmission_button.grid(row=0, column=3, padx=10, pady=10)
|
||||
|
||||
# 创建 matplotlib 画布
|
||||
plt.style.use('ggplot') # 使用预定义的样式,如 'ggplot'
|
||||
self.figure, self.ax = plt.subplots(figsize=(10, 8))
|
||||
self.canvas = FigureCanvasTkAgg(self.figure, self.root)
|
||||
self.canvas_widget = self.canvas.get_tk_widget()
|
||||
self.canvas_widget.grid(row=3, column=0, sticky="ew")
|
||||
|
||||
# 使用 grid 布局来放置 matplotlib 画布
|
||||
self.canvas_widget = self.canvas.get_tk_widget()
|
||||
self.canvas_widget.grid(row=4, column=0, sticky="ew")
|
||||
|
||||
# 创建文件名并打开文件
|
||||
start_time = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.data_file = open(f"C:\\auto\\UV_spec\\data\\{start_time}.txt", "w")
|
||||
|
||||
def get_spectrum_data(self):
|
||||
# 获取波长和光谱值
|
||||
pixels = wrapper.getNumberOfPixels(spectrometer_index)
|
||||
spectrum = wrapper.getSpectrum(spectrometer_index)
|
||||
wavelengths = wrapper.getWavelengths(spectrometer_index)
|
||||
|
||||
# 转换.NET数组到Python列表
|
||||
spectrum_list = [spectrum[i] for i in range(pixels)]
|
||||
wavelengths_list = [wavelengths[i] for i in range(pixels)]
|
||||
|
||||
self.data_ready = True
|
||||
return wavelengths_list, spectrum_list
|
||||
|
||||
def collect_source(self):
|
||||
# 采集光源强度数据
|
||||
wavelengths, self.source_spectrum = self.get_spectrum_data()
|
||||
conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao}"
|
||||
self.write_data_to_file("source", 1, conditions, self.source_spectrum)
|
||||
self.update_plot(wavelengths, self.source_spectrum)
|
||||
|
||||
def toggle_transmission_mode(self):
|
||||
# 切换透射模式
|
||||
self.transmission_mode = not self.transmission_mode
|
||||
self.transmission_button.config(text="正在透射" if self.transmission_mode else "透射模式")
|
||||
|
||||
def calculate_transmission(self, spectrum):
|
||||
# 计算透射率
|
||||
transmission = []
|
||||
for s, b, src in zip(spectrum, self.background_spectrum, self.source_spectrum):
|
||||
denominator = max(src - b, 0.1)
|
||||
trans_value = (s - b) / denominator * 100
|
||||
trans_value = max(0, min(trans_value, 100))
|
||||
transmission.append(trans_value)
|
||||
return transmission
|
||||
|
||||
def update_plot(self, wavelengths, spectrum, plot_type='spectrum'):
|
||||
|
||||
if not self.data_ready:
|
||||
return
|
||||
|
||||
self.ax.clear()
|
||||
|
||||
if plot_type == 'transmission':
|
||||
# 透射率模式的绘图设置
|
||||
self.ax.plot(wavelengths, spectrum, label='Transmission (%)')
|
||||
self.ax.set_ylim(-10, 110) # 设置y轴范围为0%到100%
|
||||
self.ax.set_ylabel('Transmission (%)', fontname='Arial', fontsize=zihaodaxiao)
|
||||
else:
|
||||
# 普通光谱模式的绘图设置
|
||||
self.ax.plot(wavelengths, spectrum)
|
||||
self.ax.set_ylim(self.current_ylim) # 使用当前y轴范围
|
||||
|
||||
# 计算新的最大值和最小值
|
||||
new_min, new_max = min(spectrum), max(spectrum)
|
||||
|
||||
# 检查新的最大值或最小值是否超过当前y轴范围
|
||||
while new_min < self.current_ylim[0] or new_max > self.current_ylim[1]:
|
||||
# 扩大y轴范围
|
||||
self.current_ylim = [self.current_ylim[0] * 2, self.current_ylim[1] * 2]
|
||||
|
||||
# 确保新的y轴范围不超过最大限制
|
||||
if self.current_ylim[0] < ymin:
|
||||
self.current_ylim[0] = ymin
|
||||
if self.current_ylim[1] > ymax:
|
||||
self.current_ylim[1] = ymax
|
||||
break
|
||||
|
||||
self.ax.set_ylabel('Intensity', fontname='Arial', fontsize=zihaodaxiao)
|
||||
|
||||
self.ax.set_xlabel('Wavelength (nm)', fontname='Arial', fontsize=zihaodaxiao)
|
||||
self.ax.set_title('Spectrum', fontname='Arial', fontsize=zihaodaxiao)
|
||||
|
||||
self.canvas.draw()
|
||||
|
||||
self.data_ready = False
|
||||
|
||||
def draw_plot(self):
|
||||
self.canvas.draw()
|
||||
|
||||
def write_data_to_file(self, test_type, test_number, conditions, spectrum):
|
||||
data_str = " ".join(map(str, spectrum))
|
||||
self.data_file.write(f"{test_type}{test_number}\n{conditions}\n{data_str}\n\n")
|
||||
self.data_file.flush()
|
||||
|
||||
def collect_background(self):
|
||||
# 设置平滑次数
|
||||
boxcar_width = self.boxcar_width_slider.get()
|
||||
wrapper.setBoxcarWidth(spectrometer_index, boxcar_width)
|
||||
|
||||
# 设置平均次数
|
||||
scans_to_average = self.scans_to_average_slider.get()
|
||||
wrapper.setScansToAverage(spectrometer_index, scans_to_average)
|
||||
|
||||
# 采集背景数据
|
||||
wavelengths, self.background_spectrum = self.get_spectrum_data()
|
||||
conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao} pinghuacishu = {self.boxcar_width_slider.get()} pingjuncishu = {self.scans_to_average_slider.get()}"
|
||||
self.background_count += 1
|
||||
self.write_data_to_file("background", self.background_count, conditions, self.background_spectrum)
|
||||
self.update_plot(wavelengths, self.background_spectrum)
|
||||
|
||||
def toggle_background_correction(self):
|
||||
self.correct_background = not self.correct_background
|
||||
self.correct_background_button.config(text="正在校正" if self.correct_background else "背景校正")
|
||||
|
||||
def apply_background_correction(self, spectrum):
|
||||
if self.background_spectrum is not None and self.correct_background:
|
||||
return [s - b for s, b in zip(spectrum, self.background_spectrum)]
|
||||
return spectrum
|
||||
|
||||
def single_test(self):
|
||||
# 获取输入框的值
|
||||
jifenshijian = float(self.integration_time_entry.get())
|
||||
shuaxinshijian = float(self.refresh_interval_entry.get())
|
||||
|
||||
# 设置平滑次数
|
||||
boxcar_width = self.boxcar_width_slider.get()
|
||||
wrapper.setBoxcarWidth(spectrometer_index, boxcar_width)
|
||||
|
||||
# 设置平均次数
|
||||
scans_to_average = self.scans_to_average_slider.get()
|
||||
wrapper.setScansToAverage(spectrometer_index, scans_to_average)
|
||||
|
||||
conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao} pinghuacishu = {self.boxcar_width_slider.get()} pingjuncishu = {self.scans_to_average_slider.get()}"
|
||||
self.test_count += 1
|
||||
|
||||
wavelengths, spectrum = self.get_spectrum_data()
|
||||
|
||||
# 在透射模式下计算透射率,否则应用背景校正
|
||||
if self.transmission_mode and self.background_spectrum is not None and self.source_spectrum is not None:
|
||||
transmission = self.calculate_transmission(spectrum)
|
||||
self.update_plot(wavelengths, transmission, plot_type='transmission')
|
||||
else:
|
||||
corrected_spectrum = self.apply_background_correction(spectrum)
|
||||
self.update_plot(wavelengths, corrected_spectrum, plot_type='spectrum')
|
||||
|
||||
def continuous_test(self):
|
||||
while not self.stop_event.is_set():
|
||||
# 获取输入框的值
|
||||
jifenshijian = float(self.integration_time_entry.get())
|
||||
shuaxinshijian = float(self.refresh_interval_entry.get())
|
||||
|
||||
# 设置平滑次数和平均次数
|
||||
boxcar_width = self.boxcar_width_slider.get()
|
||||
wrapper.setBoxcarWidth(spectrometer_index, boxcar_width)
|
||||
scans_to_average = self.scans_to_average_slider.get()
|
||||
wrapper.setScansToAverage(spectrometer_index, scans_to_average)
|
||||
|
||||
conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao} pinghuacishu = {self.boxcar_width_slider.get()} pingjuncishu = {self.scans_to_average_slider.get()}"
|
||||
self.test_count += 1
|
||||
wavelengths, spectrum = self.get_spectrum_data()
|
||||
self.write_data_to_file("test", self.test_count, conditions, spectrum)
|
||||
|
||||
# 根据当前模式计算并更新图表
|
||||
if self.transmission_mode and self.background_spectrum is not None and self.source_spectrum is not None:
|
||||
transmission = self.calculate_transmission(spectrum)
|
||||
self.update_plot(wavelengths, transmission, plot_type='transmission')
|
||||
else:
|
||||
corrected_spectrum = self.apply_background_correction(spectrum)
|
||||
self.update_plot(wavelengths, corrected_spectrum)
|
||||
|
||||
time.sleep(shuaxinshijian)
|
||||
|
||||
def start_continuous_test(self):
|
||||
self.stop_event.clear() # 重置事件
|
||||
self.continuous_thread = Thread(target=self.continuous_test)
|
||||
self.continuous_thread.start()
|
||||
|
||||
def stop_continuous_test(self):
|
||||
self.stop_event.set() # 设置事件通知线程停止
|
||||
self.continuous_thread = None
|
||||
|
||||
def on_closing(self):
|
||||
if self.data_file:
|
||||
self.data_file.close()
|
||||
if messagebox.askyesno("退出", "实验g了?"):
|
||||
self.stop_continuous_test()
|
||||
self.root.destroy()
|
||||
sys.exit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 使用 ThemedTk 而不是普通的 Tk
|
||||
root = ThemedTk(theme="equilux") # 使用 'arc' 主题
|
||||
|
||||
# 由于我们已经使用了 ttkthemes 来设置主题,下面这些行可以省略
|
||||
# style = ttk.Style()
|
||||
# style.theme_use('arc')
|
||||
|
||||
app = App(root)
|
||||
root.mainloop()
|
||||
0
unilabos/devices/__init__.py
Normal file
0
unilabos/devices/agv/__init__.py
Normal file
101
unilabos/devices/agv/agv_navigator.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AgvNavigator:
|
||||
def __init__(self, host):
|
||||
self.control_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.receive_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.control_socket.connect((host, 19206))
|
||||
self.receive_socket.connect((host, 19204))
|
||||
self.rec_cmd_code = {
|
||||
"pose" : "03EC",
|
||||
"status" : "03FC",
|
||||
"nav" : "0BEB"
|
||||
}
|
||||
self.status_list = ['NONE', 'WAITING', 'RUNNING', 'SUSPENDED', 'COMPLETED', 'FAILED', 'CANCELED']
|
||||
self._pose = []
|
||||
self._status = 'NONE'
|
||||
self.success = False
|
||||
|
||||
@property
|
||||
def pose(self) -> list:
|
||||
data = self.send('pose')
|
||||
|
||||
try:
|
||||
self._pose = [data['x'], data['y'], data['angle']]
|
||||
except:
|
||||
print(data)
|
||||
|
||||
return self._pose
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
data = self.send('status')
|
||||
self._status = self.status_list[data['task_status']]
|
||||
return self._status
|
||||
|
||||
def send(self, cmd, ex_data = '', obj = 'receive_socket'):
|
||||
data = bytearray.fromhex(f"5A 01 00 01 00 00 00 00 {self.rec_cmd_code[cmd]} 00 00 00 00 00 00")
|
||||
if ex_data:
|
||||
data_ = ex_data
|
||||
data[7] = len(data_)
|
||||
data= data + bytearray(data_,"utf-8")
|
||||
|
||||
cmd_obj = getattr(self, obj)
|
||||
cmd_obj.sendall(data)
|
||||
response_data = b""
|
||||
while True:
|
||||
part = cmd_obj.recv(4096) # 每次接收 4096 字节
|
||||
response_data += part
|
||||
if len(part) < 4096: # 当接收到的数据少于缓冲区大小时,表示接收完毕
|
||||
break
|
||||
|
||||
response_str = response_data.hex()
|
||||
json_start = response_str.find('7b') # 找到JSON的开始位置
|
||||
if json_start == -1:
|
||||
raise "Error: No JSON data found in response."
|
||||
|
||||
json_data = bytes.fromhex(response_str[json_start:])
|
||||
|
||||
# 尝试解析 JSON 数据
|
||||
try:
|
||||
parsed_json = json.loads(json_data.decode('utf-8'))
|
||||
return parsed_json
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise f"JSON Decode Error: {e}"
|
||||
|
||||
|
||||
def send_nav_task(self, command:str):
|
||||
self.success = False
|
||||
# source,target = cmd.replace(' ','').split("->")
|
||||
|
||||
target = json.loads(command)['target']
|
||||
json_data = {}
|
||||
# json_data["source_id"] = source
|
||||
json_data["id"] = target
|
||||
# json_data["use_down_pgv"] = True
|
||||
result = self.send('nav', ex_data=json.dumps(json_data), obj="control_socket")
|
||||
try:
|
||||
if result['ret_code'] == 0:
|
||||
# print(result)
|
||||
while True:
|
||||
if self.status == 'COMPLETED':
|
||||
break
|
||||
time.sleep(1)
|
||||
self.success = True
|
||||
except:
|
||||
self.success = False
|
||||
|
||||
|
||||
def __del__(self):
|
||||
self.control_socket.close()
|
||||
self.receive_socket.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
agv = AgvNavigator("192.168.1.42")
|
||||
# print(agv.pose)
|
||||
agv.send_nav_task('LM14')
|
||||