Compare commits

...

25 Commits

Author SHA1 Message Date
Xuwznln
c8d16c7024 update todo 2025-10-11 13:53:17 +08:00
Junhan Chang
25d46dc9d5 pass the tests 2025-10-11 07:20:34 +08:00
Junhan Chang
88c4d1a9d1 modify bioyond/plr converter, bioyond resource registry, and tests 2025-10-11 04:59:59 +08:00
Xuwznln
81fd8291c5 update todo 2025-10-11 03:38:59 +08:00
Xuwznln
3a11eb90d4 feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了 2025-10-11 03:38:14 +08:00
Xuwznln
387866b9c9 修复同步任务报错不显示的bug 2025-10-11 03:14:12 +08:00
Xuwznln
7f40f141f6 移动内部action以兼容host node 2025-10-11 03:11:17 +08:00
Xuwznln
6fc7ed1b88 过滤本地动作 2025-10-11 03:06:37 +08:00
Xuwznln
93f0e08d75 fix host_node test_resource error 2025-10-11 03:04:15 +08:00
Xuwznln
4b43734b55 fix host_node test_resource error 2025-10-11 02:57:14 +08:00
Xuwznln
174b1914d4 fix host_node error 2025-10-11 02:54:15 +08:00
Xuwznln
704e13f030 新增test_resource动作 2025-10-11 02:53:18 +08:00
Xuwznln
0c42d60cf2 更新transfer_resource_to_another参数,支持spot入参 2025-10-11 02:41:37 +08:00
Xuwznln
df33e1a214 修复transfer_resource_to_another生成 2025-10-11 01:12:56 +08:00
Xuwznln
1f49924966 修复资源添加 2025-10-11 00:58:56 +08:00
Xuwznln
609b6006e8 支持选择器注册表自动生成
支持转运物料
2025-10-11 00:57:22 +08:00
Xuwznln
67c01271b7 add update remove 2025-10-10 20:15:16 +08:00
Xuwznln
a1783f489e Merge remote-tracking branch 'origin/workstation_dev_YB2' into dev
# Conflicts:
#	unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py
#	unilabos/devices/workstation/bioyond_studio/station.py
#	unilabos/resources/graphio.py
2025-10-10 15:38:45 +08:00
Xuwznln
a8f6527de9 修复to_plr_resources 2025-10-10 15:30:26 +08:00
Xuwznln
5610c28b67 更新物料接口 2025-10-10 07:13:59 +08:00
Junhan Chang
cfc1ee6e79 Workstation templates: Resources and its CRUD, and workstation tasks (#95)
* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
2025-09-30 17:23:13 +08:00
Junhan Chang
709eb0d91c Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-09-27 00:10:59 +08:00
Junhan Chang
14b7d52825 create/update resources with POST/PUT for big amount/ small amount data 2025-09-26 23:25:50 +08:00
LccLink
c6c2da69ba frontend_docs 2025-09-26 23:20:22 +08:00
Junhan Chang
622e579063 fix: update resource with correct structure; remove deprecated liquid_handler set_group action 2025-09-26 20:24:15 +08:00
59 changed files with 7258 additions and 637 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ configs/
temp/
output/
unilabos_data/
pyrightconfig.json
## Python
# Byte-compiled / optimized / DLL files

View File

@@ -111,8 +111,8 @@ new_device: # 设备名,要唯一
1.`auto-` 开头的动作:从你 Python 类的方法自动生成
2. 通用的驱动动作:
- `_execute_driver_command`:同步执行驱动命令
- `_execute_driver_command_async`:异步执行驱动命令
- `_execute_driver_command`:同步执行驱动命令(仅本地可用)
- `_execute_driver_command_async`:异步执行驱动命令(仅本地可用)
### 如果要手动定义动作

View File

@@ -24,6 +24,8 @@ class WSConfig:
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
```
您可以进入实验室点击左下角的头像在实验室详情中获取所在实验室的ak sk
![copy_aksk.gif](image/copy_aksk.gif)
### 完整配置示例

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -245,3 +245,78 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
- 检查图谱文件格式是否正确
- 验证设备连接和端点配置
- 确保注册表路径正确
## 页面操作
### 1. 启动成功
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
![material.png](image/material.png)
### 2. 根据需求创建设备和物料
我们可以做一个简单的案例
* 在容器1中加入水
* 通过传输泵将容器1中的水转移到容器2中
#### 2.1 添加所需的设备和物料
仪器设备work_station中的workstation 数量x1
仪器设备virtual_device中的virtual_transfer_pump 数量x1
物料耗材container中的container 数量x2
#### 2.2 将设备和物料根据父子关系进行关联
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来就像真实的设备操作一样
![links.png](image/links.png)
### 3. 创建工作流
进入工作流模块 → 点击"我创建的" → 新建工作流
![new.png](image/new.png)
#### 3.1 新增工作流节点
我们可以进入指定工作流,在空白处右键
* 选择Laboratory→host_node中的creat_resource
* 选择Laboratory→workstation中的PumpTransferProtocol
![creatworkfollow.gif](image/creatworkfollow.gif)
#### 3.2 配置节点参数
根据案例,工作流包含两个步骤:
1. 使用creat_resource在容器中创建水
2. 通过泵传输协议将水传输到另一个容器
我们点击creat_resource卡片上的编辑按钮来配置参数⭐
class_name container
device_id workstation
liquid_input_slot 0或-1均可
liquid_type : water
liquid_volume 根据需求填写即可默认单位ml这里举例50
parent workstation
res_id containe
关联设备名称(原unilabos_device_id) 这里就填写host_node
**配置完成后点击底部保存按钮**
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐
event transfer_liquid
from_vessel water
to_vessel container1
volume 根据需求填写即可默认单位ml这里举例50
关联设备名称(原unilabos_device_id) 这里就填写workstation
**配置完成后点击底部保存按钮**
#### 3.3 运行工作流
1. 连接两个节点卡片
2. 点击底部保存按钮
3. 点击运行按钮执行工作流
![linksandrun.png](image/linksandrun.png)
### 运行监控
* 运行状态和消息实时显示在底部控制台
* 如有报错,可点击查看详细信息
### 结果验证
工作流完成后,返回仪器耗材模块:
* 点击 container1卡片查看详情
* 确认其中包含参数指定的水和容量

View File

@@ -21,7 +21,7 @@
"timeout": 10.0,
"axis": "Left",
"channel_num": 8,
"setup": true,
"setup": false,
"debug": true,
"simulator": true,
"matrix_id": "71593"

View File

@@ -0,0 +1,181 @@
[
{
"id": "3a1c62c4-c3d2-b803-b72d-7f1153ffef3b",
"typeName": "试剂瓶",
"code": "0004-00050",
"barCode": "",
"name": "NMP",
"quantity": 287.16699029126215,
"lockQuantity": 285.16699029126215,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-efce-0939-69ca5a7dfd39",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0008",
"x": 2,
"y": 4,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-1bc1-1296-dae1905c4108",
"typeName": "试剂瓶",
"code": "0004-00052",
"barCode": "",
"name": "NMP",
"quantity": 386.8990291262136,
"lockQuantity": 45.89902912621359,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-f3e7-871a-e470d144296f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0005",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-68a4-bcb3-02fc6ba72d1b",
"typeName": "试剂瓶",
"code": "0004-00053",
"barCode": "",
"name": "NMP",
"quantity": 400.0,
"lockQuantity": 0.0,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0006",
"x": 2,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-d5e0-d850-5439-4499f20f07fe",
"typeName": "分装板",
"code": "0007-00185",
"barCode": "",
"name": "1010",
"quantity": 1.0,
"lockQuantity": 2.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0009",
"x": 3,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdefe-d5e0-28a4-f5d0-f7e2436c575f",
"detailMaterialId": "3a1cdefe-d5e0-94ae-f770-27847e73ad38",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-3ed6-3607-133df89baf5b",
"detailMaterialId": "3a1cdefe-d5e0-f2fa-66bf-94c565d852fb",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-72b6-e015-be7b93cf09eb",
"detailMaterialId": "3a1cdefe-d5e0-81cf-7dad-2e51cab9ffd6",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-81d3-ad30-48134afc9ce7",
"detailMaterialId": "3a1cdefe-d5e0-3fa1-cc72-fda6276ae38d",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-dbdf-d966-9a8926fe1e06",
"detailMaterialId": "3a1cdefe-d5e0-c632-c7da-02d385b18628",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-f099-b260-e3089a2d08c3",
"detailMaterialId": "3a1cdefe-d5e0-561f-73b6-f8501f814dbb",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
}
]
}
]

View File

@@ -0,0 +1,216 @@
[
{
"id": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"typeName": "样品瓶",
"code": "0002-00407",
"barCode": "",
"name": "ODA",
"quantity": 25.0,
"lockQuantity": 2.0,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-7887-9258-e8f8ab7c8a7a",
"typeName": "样品板",
"code": "0008-00160",
"barCode": "",
"name": "1010sample",
"quantity": 1.0,
"lockQuantity": 27.69187,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-4379-affa-9a2935c17f99",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cde21-a4f4-0339-f2b6-8e680ad7e8c7",
"detailMaterialId": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"code": null,
"name": "MPDA",
"quantity": "10.505",
"lockQuantity": "-0.0174",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-a21a-23cf-bb7857b41947",
"detailMaterialId": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"code": null,
"name": "ODA",
"quantity": "1.795",
"lockQuantity": "2.0093",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-af1b-ba0b-2874836800e9",
"detailMaterialId": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"code": null,
"name": "ODA",
"quantity": "25",
"lockQuantity": "2",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"typeName": "样品瓶",
"code": "0002-00406",
"barCode": "",
"name": "ODA",
"quantity": 1.795,
"lockQuantity": 2.00927,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"typeName": "样品瓶",
"code": "0002-00408",
"barCode": "",
"name": "MPDA",
"quantity": 10.505,
"lockQuantity": -0.0174,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92a-08f6-c822-732ab734154c",
"typeName": "样品板",
"code": "0008-00161",
"barCode": "",
"name": "1010sample2",
"quantity": 1.0,
"lockQuantity": 3.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-31f0-8a22-0f98f72260df",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdeff-c92b-3ace-9623-0bcdef6fa07d",
"detailMaterialId": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"code": null,
"name": "BTDA1",
"quantity": "0.362",
"lockQuantity": "14.494",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-856e-f481-792b91b6dbde",
"detailMaterialId": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"code": null,
"name": "BTDA3",
"quantity": "1.935",
"lockQuantity": "13.067",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-d144-c5e5-ab9d94e21187",
"detailMaterialId": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"code": null,
"name": "BTDA2",
"quantity": "1.903",
"lockQuantity": "13.035",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"typeName": "样品瓶",
"code": "0002-00411",
"barCode": "",
"name": "BTDA3",
"quantity": 1.935,
"lockQuantity": 13.067,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"typeName": "样品瓶",
"code": "0002-00410",
"barCode": "",
"name": "BTDA2",
"quantity": 1.903,
"lockQuantity": 13.035,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"typeName": "样品瓶",
"code": "0002-00409",
"barCode": "",
"name": "BTDA1",
"quantity": 0.362,
"lockQuantity": 14.494,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
}
]

View File

@@ -1,5 +1,4 @@
{
"data": [
[
{
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
"typeName": "烧杯",
@@ -191,8 +190,4 @@
}
]
}
],
"code": 1,
"message": "",
"timestamp": 1758560573511
}
]

View File

@@ -2,6 +2,7 @@ import pytest
import json
import os
from pylabrobot.resources import Resource as ResourcePLR
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.registry.registry import lab_registry
@@ -13,23 +14,63 @@ lab_registry.setup()
type_mapping = {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier",
"样品板": "BIOYOND_PolymerStation_6StockCarrier",
"分装板": "BIOYOND_PolymerStation_6VialCarrier",
"样品瓶": "BIOYOND_PolymerStation_Solid_Stock",
"90%分装小瓶": "BIOYOND_PolymerStation_Solid_Vial",
"10%分装小瓶": "BIOYOND_PolymerStation_Liquid_Vial",
}
type_uuid_mapping = {
"烧杯": "",
"试剂瓶": "",
"样品板": "",
"分装板": "3a14196e-5dfe-6e21-0c79-fe2036d052c4",
"样品瓶": "3a14196a-cf7d-8aea-48d8-b9662c7dba94",
"90%分装小瓶": "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea",
"10%分装小瓶": "3a14196c-76be-2279-4e22-7310d69aed68",
}
@pytest.fixture
def bioyond_materials() -> list[dict]:
def bioyond_materials_reaction() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials.json", "r", encoding="utf-8") as f:
data = json.load(f)["data"]
with open("bioyond_materials_reaction.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
def test_bioyond_to_plr(bioyond_materials) -> list[dict]:
@pytest.fixture
def bioyond_materials_liquidhandling_1() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_1.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_2() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_2.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.mark.parametrize("materials_fixture", [
"bioyond_materials_reaction",
"bioyond_materials_liquidhandling_1",
])
def test_bioyond_to_plr(materials_fixture, request) -> list[dict]:
materials = request.getfixturevalue(materials_fixture)
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
print("将 BioYond 物料数据转换为 PLR 格式...")
output = resource_bioyond_to_plr(bioyond_materials, type_mapping=type_mapping, deck=deck)
output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
print(deck.summary())
print([resource.serialize() for resource in output])
print([resource.serialize_all_state() for resource in output])
json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)

View File

@@ -1,14 +1,15 @@
import threading
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils import logger
# 根据选择的 backend 启动相应的功能
def start_backend(
backend: str,
devices_config: dict = {},
resources_config: list = [],
resources_edge_config: list = [],
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list[dict] = [],
graph=None,
controllers_config: dict = {},
bridges=[],

View File

@@ -6,10 +6,12 @@ import signal
import sys
import threading
import time
from copy import deepcopy
from typing import Dict, Any, List
import networkx as nx
import yaml
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -43,7 +45,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
for i, arg in enumerate(sys.argv):
for option_string in option_strings:
if arg.startswith(option_string):
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
sys.argv[i] = new_arg
break
@@ -225,6 +227,15 @@ def main():
else:
HTTPConfig.remote_addr = args_dict.get("addr", "")
# 设置BasicConfig参数
if args_dict.get("ak", ""):
BasicConfig.ak = args_dict.get("ak", "")
print_status("传入了ak参数优先采用传入参数", "info")
if args_dict.get("sk", ""):
BasicConfig.sk = args_dict.get("sk", "")
print_status("传入了sk参数优先采用传入参数", "info")
# 使用远程资源启动
if args_dict["use_remote_resource"]:
print_status("使用远程资源启动", "info")
from unilabos.app.web import http_client
@@ -236,13 +247,6 @@ def main():
else:
print_status("远程资源不存在,本地将进行首次上报!", "info")
# 设置BasicConfig参数
if args_dict.get("ak", ""):
BasicConfig.ak = args_dict.get("ak", "")
print_status("传入了ak参数优先采用传入参数", "info")
if args_dict.get("sk", ""):
BasicConfig.sk = args_dict.get("sk", "")
print_status("传入了sk参数优先采用传入参数", "info")
BasicConfig.working_dir = working_dir
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
@@ -257,8 +261,6 @@ def main():
read_node_link_json,
read_graphml,
dict_from_graph,
dict_to_nested_dict,
initialize_resources,
)
from unilabos.app.communication import get_communication_client
from unilabos.registry.registry import build_registry
@@ -278,8 +280,11 @@ def main():
if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
graph: nx.Graph
resource_tree_set: ResourceTreeSet
resource_links: List[Dict[str, Any]]
request_startup_json = http_client.request_startup_json()
if args_dict["graph"] is None:
request_startup_json = http_client.request_startup_json()
if not request_startup_json:
print_status(
"未指定设备加载文件路径尝试从HTTP获取失败请检查网络或者使用-g参数指定设备加载文件路径", "error"
@@ -287,57 +292,60 @@ def main():
os._exit(1)
else:
print_status("联网获取设备加载文件成功", "info")
graph, data = read_node_link_json(request_startup_json)
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
else:
file_path = args_dict["graph"]
if file_path.endswith(".json"):
graph, data = read_node_link_json(file_path)
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
else:
graph, data = read_graphml(file_path)
graph, resource_tree_set, resource_links = read_graphml(file_path)
import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = graph
resource_edge_info = modify_to_backend_format(data["links"])
resource_edge_info = modify_to_backend_format(resource_links)
materials = lab_registry.obtain_registry_resource_info()
materials.extend(lab_registry.obtain_registry_device_info())
materials = {k["id"]: k for k in materials}
nodes = {k["id"]: k for k in data["nodes"]}
# 从 ResourceTreeSet 中获取节点信息
nodes = {node.res_content.id: node.res_content for node in resource_tree_set.all_nodes}
edge_info = len(resource_edge_info)
for ind, i in enumerate(resource_edge_info[::-1]):
source_node = nodes[i["source"]]
target_node = nodes[i["target"]]
source_node: ResourceDict = nodes[i["source"]]
target_node: ResourceDict = nodes[i["target"]]
source_handle = i["sourceHandle"]
target_handle = i["targetHandle"]
source_handler_keys = [
h["handler_key"] for h in materials[source_node["class"]]["handles"] if h["io_type"] == "source"
h["handler_key"] for h in materials[source_node.klass]["handles"] if h["io_type"] == "source"
]
target_handler_keys = [
h["handler_key"] for h in materials[target_node["class"]]["handles"] if h["io_type"] == "target"
h["handler_key"] for h in materials[target_node.klass]["handles"] if h["io_type"] == "target"
]
if source_handle not in source_handler_keys:
print_status(
f"节点 {source_node['id']} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
f"节点 {source_node.id} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
"error",
)
resource_edge_info.pop(edge_info - ind - 1)
continue
if target_handle not in target_handler_keys:
print_status(
f"节点 {target_node['id']} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
f"节点 {target_node.id} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
"error",
)
resource_edge_info.pop(edge_info - ind - 1)
continue
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["resources_config"] = list(devices_and_resources.values())
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
args_dict["graph"] = graph_res.physical_setup_graph
# 如果从远端获取了物料信息,则与本地物料进行同步
if request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"])
resource_tree_set.merge_remote_resources(remote_tree_set)
print_status("远端物料同步完成", "info")
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")
# 使用 ResourceTreeSet 代替 list
args_dict["resources_config"] = resource_tree_set
args_dict["devices_config"] = resource_tree_set
args_dict["graph"] = graph_res.physical_setup_graph
if BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
@@ -351,9 +359,7 @@ def main():
else:
print_status("未提供 ak 和 sk跳过设备注册", "info")
else:
print_status(
"本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning"
)
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
if args_dict["controllers"] is not None:
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
@@ -383,13 +389,16 @@ def main():
# web visiualize 2D
if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz"
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
if devices_and_resources is not None:
from unilabos.device_mesh.resource_visalization import (
ResourceVisualization,
) # 此处开启后logger会变更为INFO有需要请调整
resource_visualization = ResourceVisualization(
devices_and_resources, args_dict["resources_config"], enable_rviz=enable_rviz
devices_and_resources,
[n.res_content for n in args_dict["resources_config"].all_nodes], # type: ignore # FIXME
enable_rviz=enable_rviz,
)
args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict)

View File

@@ -1,11 +1,6 @@
import argparse
import json
import time
from unilabos.config.config import BasicConfig
from unilabos.registry.registry import build_registry
from unilabos.app.main import load_config_from_file
from unilabos.utils.log import logger
from unilabos.utils.type_check import TypeEncoder

View File

@@ -9,6 +9,7 @@ import os
from typing import List, Dict, Any, Optional
import requests
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils.log import info
from unilabos.config.config import HTTPConfig, BasicConfig
from unilabos.utils import logger
@@ -46,7 +47,7 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/material/edge",
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
},
@@ -61,6 +62,83 @@ class HTTPClient:
logger.error(f"添加物料关系失败: {response.status_code}, {response.text}")
return response
def resource_tree_add(self, resources: ResourceTreeSet, mount_uuid: str, first_add: bool) -> Dict[str, str]:
"""
添加资源
Args:
resources: 要添加的资源树集合ResourceTreeSet
mount_uuid: 要挂载的资源的uuid
first_add: 是否为首次添加资源可以是host也可以是slave来的
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
# 处理响应构建UUID映射
uuid_mapping = {}
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料失败: {response.text}")
else:
data = res["data"]
for i in data:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]
for c in n.children:
c.res_content.parent_uuid = n.res_content.uuid
else:
logger.warning(f"资源UUID未更新: {u}")
return uuid_mapping
def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[Dict[str, Any]]:
"""
添加资源
Args:
uuid_list: List[str]
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
response = requests.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
return data
else:
logger.error(f"查询物料失败: {response.text}")
return []
def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
"""
添加资源
@@ -220,7 +298,7 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.get(
f"{self.remote_addr}/lab/resource/graph_info/",
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
)

View File

@@ -19,9 +19,12 @@ import websockets
import ssl as ssl_module
from queue import Queue, Empty
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, Callable, List, Set
from typing import Optional, Dict, Any, List
from urllib.parse import urlparse
from enum import Enum
from jedi.inference.gradual.typing import TypedDict
from unilabos.app.model import JobAddReq
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils.type_check import serialize_result_info
@@ -96,6 +99,14 @@ class WebSocketMessage:
timestamp: float = field(default_factory=time.time)
class WSResourceChatData(TypedDict):
uuid: str
device_uuid: str
device_id: str
device_old_uuid: str
device_old_id: str
class DeviceActionManager:
"""设备动作管理器 - 管理每个device_action_key的任务队列"""
@@ -543,7 +554,7 @@ class MessageProcessor:
async def _process_message(self, data: Dict[str, Any]):
"""处理收到的消息"""
message_type = data.get("action", "")
message_data = data.get("data", {})
message_data = data.get("data")
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
@@ -556,8 +567,12 @@ class MessageProcessor:
await self._handle_job_start(message_data)
elif message_type == "cancel_action" or message_type == "cancel_task":
await self._handle_cancel_action(message_data)
elif message_type == "":
return
elif message_type == "add_material":
await self._handle_resource_tree_update(message_data, "add")
elif message_type == "update_material":
await self._handle_resource_tree_update(message_data, "update")
elif message_type == "remove_material":
await self._handle_resource_tree_update(message_data, "remove")
else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -574,6 +589,7 @@ class MessageProcessor:
async def _handle_query_action_state(self, data: Dict[str, Any]):
"""处理query_action_state消息"""
device_id = data.get("device_id", "")
device_uuid = data.get("device_uuid", "")
action_name = data.get("action_name", "")
task_id = data.get("task_id", "")
job_id = data.get("job_id", "")
@@ -760,6 +776,92 @@ class MessageProcessor:
else:
logger.warning("[MessageProcessor] Cancel request missing both task_id and job_id")
async def _handle_resource_tree_update(self, resource_uuid_list: List[WSResourceChatData], action: str):
"""处理资源树更新消息add_material/update_material/remove_material"""
if not resource_uuid_list:
return
# 按device_id和action分组
# device_action_groups: {(device_id, action): [uuid_list]}
device_action_groups = {}
for item in resource_uuid_list:
device_id = item["device_id"]
if not device_id:
device_id = "host_node"
# 特殊处理update action: 检查是否设备迁移
if action == "update":
device_old_id = item.get("device_old_id", "")
if not device_old_id:
device_old_id = "host_node"
# 设备迁移device_id != device_old_id
if device_id != device_old_id:
# 给旧设备发送remove
key_remove = (device_old_id, "remove")
if key_remove not in device_action_groups:
device_action_groups[key_remove] = []
device_action_groups[key_remove].append(item["uuid"])
# 给新设备发送add
key_add = (device_id, "add")
if key_add not in device_action_groups:
device_action_groups[key_add] = []
device_action_groups[key_add].append(item["uuid"])
logger.info(
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
)
else:
# 正常update
key = (device_id, "update")
if key not in device_action_groups:
device_action_groups[key] = []
device_action_groups[key].append(item["uuid"])
else:
# add或remove action直接分组
key = (device_id, action)
if key not in device_action_groups:
device_action_groups[key] = []
device_action_groups[key].append(item["uuid"])
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
# 为每个(device_id, action)创建独立的更新线程
for (device_id, actual_action), items in device_action_groups.items():
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
def _notify_resource_tree(dev_id, act, item_list):
try:
host_node = HostNode.get_instance(timeout=5)
if not host_node:
logger.error(f"[MessageProcessor] HostNode instance not available for {act}")
return
success = host_node.notify_resource_tree_update(dev_id, act, item_list)
if success:
logger.info(
f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, "
f"items: {len(item_list)}"
)
else:
logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}")
except Exception as e:
logger.error(f"[MessageProcessor] Error in resource tree {act} for device {dev_id}: {str(e)}")
logger.error(traceback.format_exc())
# 在新线程中执行通知
thread = threading.Thread(
target=_notify_resource_tree,
args=(device_id, actual_action, items),
daemon=True,
name=f"ResourceTreeUpdate-{actual_action}-{device_id}",
)
thread.start()
async def _send_action_state_response(
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
):
@@ -1008,6 +1110,8 @@ class WebSocketClient(BaseCommunicationClient):
# 构建WebSocket URL
self.websocket_url = self._build_websocket_url()
if not self.websocket_url:
self.websocket_url = "" # 默认空字符串避免None
# 两个核心线程
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)

View File

@@ -0,0 +1,129 @@
# config.py
"""
配置文件 - 包含所有配置信息和映射关系
"""
# API配置
API_CONFIG = {
"api_key": "",
"api_host": ""
}
# 站点类型配置
STATION_TYPES = {
"REACTION": "reaction_station", # 仅反应站
"DISPENSING": "dispensing_station", # 仅配液站
"HYBRID": "hybrid_station" # 混合模式
}
# 默认站点配置
DEFAULT_STATION_CONFIG = {
"station_type": STATION_TYPES["REACTION"], # 默认反应站模式
"enable_reaction_station": True, # 是否启用反应站功能
"enable_dispensing_station": False, # 是否启用配液站功能
"station_name": "BioyondReactionStation", # 站点名称
"description": "Bioyond反应工作站" # 站点描述
}
# 工作流映射配置
WORKFLOW_MAPPINGS = {
"reactor_taken_out": "",
"reactor_taken_in": "",
"Solid_feeding_vials": "",
"Liquid_feeding_vials(non-titration)": "",
"Liquid_feeding_solvents": "",
"Liquid_feeding(titration)": "",
"liquid_feeding_beaker": "",
"Drip_back": "",
}
# 工作流名称到DisplaySectionName的映射
WORKFLOW_TO_SECTION_MAP = {
'reactor_taken_in': '反应器放入',
'liquid_feeding_beaker': '液体投料-烧杯',
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
'Liquid_feeding_solvents': '液体投料-溶剂',
'Solid_feeding_vials': '固体投料-小瓶',
'Liquid_feeding(titration)': '液体投料-滴定',
'reactor_taken_out': '反应器取出'
}
# 库位映射配置
LOCATION_MAPPING = {
'A01': '',
'A02': '',
'A03': '',
'A04': '',
'A05': '',
'A06': '',
'A07': '',
'A08': '',
'B01': '',
'B02': '',
'B03': '',
'B04': '',
'B05': '',
'B06': '',
'B07': '',
'B08': '',
'C01': '',
'C02': '',
'C03': '',
'C04': '',
'C05': '',
'C06': '',
'C07': '',
'C08': '',
'D01': '',
'D02': '',
'D03': '',
'D04': '',
'D05': '',
'D06': '',
'D07': '',
'D08': '',
}
# 物料类型配置
MATERIAL_TYPE_IDS = {
"样品板": "",
"样品": "",
"烧杯": ""
}
MATERIAL_TYPE_MAPPINGS = {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier",
}
# 步骤参数配置各工作流的步骤UUID
WORKFLOW_STEP_IDS = {
"reactor_taken_in": {
"config": ""
},
"liquid_feeding_beaker": {
"liquid": "",
"observe": ""
},
"liquid_feeding_vials_non_titration": {
"liquid": "",
"observe": ""
},
"liquid_feeding_solvents": {
"liquid": "",
"observe": ""
},
"solid_feeding_vials": {
"feeding": "",
"observe": ""
},
"liquid_feeding_titration": {
"liquid": "",
"observe": ""
},
"drip_back": {
"liquid": "",
"observe": ""
}
}

View File

@@ -10,12 +10,14 @@ import json
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.resources.warehouse import WareHouse
from unilabos.utils.log import logger
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from pylabrobot.resources.resource import Resource as ResourcePLR
from unilabos.devices.workstation.bioyond_studio.config import (
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS,
@@ -153,6 +155,14 @@ class BioyondWorkstation(WorkstationBase):
"resources": [self.deck]
})
def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot):
ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{
"plr_resources": resource,
"target_device_id": mount_device_id,
"target_resources": mount_resource,
"sites": sites,
})
def _configure_station_type(self, station_config: Optional[Dict[str, Any]] = None) -> None:
"""配置站点类型和功能模块

View File

@@ -61,7 +61,6 @@ class ElectrodeSheet(Resource):
info=None
)
# TODO: 这个还要不要给self._unilabos_state赋值的
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
@@ -665,7 +664,6 @@ class BatteryPressSlot(Resource):
reassign: bool = True,
):
"""放置极片"""
# TODO: 让高京看下槽位只有一个电池时是否这么写。
if self.has_battery():
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
super().assign_child_resource(resource, location, reassign)
@@ -674,7 +672,6 @@ class BatteryPressSlot(Resource):
def get_battery_info(self, index: int) -> Battery:
return self.children[0]
# TODO:这个移液枪架子看一下从哪继承
class TipBox64State(TypedDict):
"""电池状态字典"""
tip_diameter: float = 5.0

View File

@@ -1012,7 +1012,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
# else:
# print("子弹夹洞位0没有极片")
#
# # TODO:#把电解液从瓶中取到电池夹子中
# #把电解液从瓶中取到电池夹子中
# battery_site = deck.get_resource("battery_press_1")
# clip_magazine_battery = deck.get_resource("clip_magazine_battery")
# if battery_site.has_battery():

View File

@@ -10,6 +10,7 @@ serial:
request: null
response: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: handle_serial_request的参数schema
@@ -36,6 +37,7 @@ serial:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: read_data的参数schema
@@ -57,6 +59,7 @@ serial:
goal_default:
command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: send_command的参数schema

View File

@@ -8,6 +8,7 @@ camera.USB:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 用于安全地关闭摄像头设备释放摄像头资源停止视频采集和发布服务。调用此函数将清理OpenCV摄像头连接并销毁ROS2节点。
@@ -28,6 +29,7 @@ camera.USB:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 定时器回调函数的参数schema。此函数负责定期采集摄像头视频帧将OpenCV格式的图像转换为ROS Image消息格式并发布到指定的视频话题。默认以10Hz频率执行确保视频流的连续性和实时性。

View File

@@ -8,6 +8,7 @@ hplc.agilent:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 检查安捷伦HPLC设备状态的函数。用于监控设备的运行状态、连接状态、错误信息等关键指标。该函数定期查询设备状态确保系统稳定运行及时发现和报告设备异常。适用于自动化流程中的设备监控、故障诊断、系统维护等场景。
@@ -29,6 +30,7 @@ hplc.agilent:
goal_default:
file_path: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 从文本文件中提取分析数据的函数。用于解析安捷伦HPLC生成的结果文件提取峰面积、保留时间、浓度等关键分析数据。支持多种文件格式的自动识别和数据结构化处理为后续数据分析和报告生成提供标准化的数据格式。适用于批量数据处理、结果验证、质量控制等分析工作流程。
@@ -55,6 +57,7 @@ hplc.agilent:
resource: null
wf_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 启动安捷伦HPLC分析序列的函数。用于执行预定义的分析方法序列包括样品进样、色谱分离、检测等完整的分析流程。支持参数配置、资源分配、工作流程管理等功能实现全自动的样品分析。适用于批量样品处理、标准化分析、质量检测等需要连续自动分析的应用场景。
@@ -83,6 +86,7 @@ hplc.agilent:
goal_default:
device_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 尝试关闭HPLC子设备的函数。用于安全地关闭泵、检测器、进样器等各个子模块确保设备正常断开连接并保护硬件安全。该函数提供错误处理和状态确认机制避免强制关闭可能造成的设备损坏。适用于设备维护、系统重启、紧急停机等需要安全关闭设备的场景。
@@ -106,6 +110,7 @@ hplc.agilent:
goal_default:
device_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 尝试打开HPLC子设备的函数。用于初始化和连接泵、检测器、进样器等各个子模块建立设备通信并进行自检。该函数提供连接验证和错误恢复机制确保子设备正常启动并准备就绪。适用于设备初始化、系统启动、设备重连等需要建立设备连接的场景。
@@ -263,6 +268,7 @@ hplc.agilent-zhida:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: HPLC设备连接关闭函数。安全地断开与智达HPLC设备的TCP socket连接释放网络资源。该函数确保连接的正确关闭避免网络资源泄露。通常在设备使用完毕或系统关闭时调用。
@@ -283,6 +289,7 @@ hplc.agilent-zhida:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: HPLC设备连接建立函数。与智达HPLC设备建立TCP socket通信连接配置通信超时参数。该函数是设备使用前的必要步骤建立成功后可进行状态查询、方法获取、任务启动等操作。连接失败时会抛出异常。

View File

@@ -9,6 +9,7 @@ raman.home_made:
goal_default:
int_time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 设置CCD检测器积分时间的函数。用于配置拉曼光谱仪的信号采集时间控制光谱数据的质量和信噪比。较长的积分时间可获得更高的信号强度和更好的光谱质量但会增加测量时间。该函数允许根据样品特性和测量要求动态调整检测参数优化测量效果。
@@ -33,6 +34,7 @@ raman.home_made:
goal_default:
output_voltage_laser: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 设置激光器输出功率的函数。用于控制拉曼光谱仪激光器的功率输出,调节激光强度以适应不同样品的测量需求。适当的激光功率能够获得良好的拉曼信号同时避免样品损伤。该函数支持精确的功率控制,确保测量结果的稳定性和重现性。
@@ -58,6 +60,7 @@ raman.home_made:
int_time: null
laser_power: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 执行无背景扣除的拉曼光谱测量函数。用于直接采集样品的拉曼光谱信号,不进行背景校正处理。该函数配置积分时间和激光功率参数,获取原始光谱数据用于后续的数据处理分析。适用于对光谱数据质量要求较高或需要自定义背景处理流程的测量场景。
@@ -88,6 +91,7 @@ raman.home_made:
laser_power: null
sample_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 执行多次平均的无背景拉曼光谱测量函数。通过多次测量取平均值来提高光谱数据的信噪比和测量精度,减少随机噪声影响。该函数支持自定义平均次数、积分时间、激光功率等参数,并可为样品指定名称便于数据管理。适用于对测量精度要求较高的定量分析和研究应用。

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ gas_source.mock:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -28,6 +29,7 @@ gas_source.mock:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_open的参数schema
@@ -188,6 +190,7 @@ vacuum_pump.mock:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -208,6 +211,7 @@ vacuum_pump.mock:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_open的参数schema

View File

@@ -564,6 +564,7 @@ liquid_handler:
protocol_type: null
protocol_version: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 创建实验协议函数。用于建立新的液体处理实验协议,定义协议名称、描述、版本、作者、日期等基本信息。该函数支持协议模板化管理,便于实验流程的标准化和重复性。适用于实验设计、方法开发、标准操作程序建立等需要协议管理的应用场景。
@@ -607,6 +608,7 @@ liquid_handler:
msg: null
seconds: 0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 自定义延时函数。在实验流程中插入可配置的等待时间,用于满足特定的反应时间、孵育时间或设备稳定时间要求。支持自定义延时消息和秒数设置,提供流程控制和时间管理功能。适用于酶反应等待、温度平衡、样品孵育等需要时间控制的实验步骤。
@@ -633,6 +635,7 @@ liquid_handler:
goal_default:
tip_racks: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 吸头迭代函数。用于自动管理和切换吸头架中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
@@ -659,6 +662,7 @@ liquid_handler:
volumes: null
wells: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -689,6 +693,7 @@ liquid_handler:
goal_default:
tip_racks: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 吸头架设置函数。用于配置和初始化液体处理系统的吸头架信息,包括吸头架位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、吸头架更换、实验配置等需要吸头资源管理的操作场景。
@@ -713,6 +718,7 @@ liquid_handler:
goal_default:
targets: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 吸头碰触函数。控制移液器吸头轻触容器边缘或底部,用于去除吸头外壁附着的液滴,提高移液精度和减少污染。该函数支持多目标位置操作,可配置碰触参数和位置偏移。适用于精密移液、减少液体残留、防止交叉污染等需要提高移液质量的实验操作。
@@ -739,6 +745,7 @@ liquid_handler:
target_group_name: null
unit_volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -4495,6 +4502,7 @@ liquid_handler.biomek:
resources: null
slot_on_deck: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: create_resource的参数schema
@@ -4554,6 +4562,7 @@ liquid_handler.biomek:
parent: null
slot_on_deck: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: instrument_setup_biomek的参数schema
@@ -6042,6 +6051,7 @@ liquid_handler.prcxi:
protocol_type: ''
protocol_version: ''
handles: {}
placeholder_keys: {}
result: {}
schema:
description: create_protocol的参数schema
@@ -6087,6 +6097,7 @@ liquid_handler.prcxi:
msg: null
seconds: 0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: custom_delay的参数schema
@@ -6113,6 +6124,7 @@ liquid_handler.prcxi:
goal_default:
tip_racks: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: iter_tips的参数schema
@@ -6139,6 +6151,7 @@ liquid_handler.prcxi:
dis_to_top: 0
well: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: move_to的参数schema
@@ -6168,6 +6181,7 @@ liquid_handler.prcxi:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: run_protocol的参数schema
@@ -6183,12 +6197,50 @@ liquid_handler.prcxi:
title: run_protocol参数
type: object
type: UniLabJsonCommandAsync
auto-set_group:
feedback: {}
goal: {}
goal_default:
group_name: null
volumes: null
wells: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
group_name:
type: string
volumes:
items:
type: number
type: array
wells:
items:
type: object
type: array
required:
- group_name
- wells
- volumes
type: object
result: {}
required:
- goal
title: set_group参数
type: object
type: UniLabJsonCommand
auto-touch_tip:
feedback: {}
goal: {}
goal_default:
targets: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: touch_tip的参数schema
@@ -6207,6 +6259,39 @@ liquid_handler.prcxi:
title: touch_tip参数
type: object
type: UniLabJsonCommandAsync
auto-transfer_group:
feedback: {}
goal: {}
goal_default:
source_group_name: null
target_group_name: null
unit_volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
source_group_name:
type: string
target_group_name:
type: string
unit_volume:
type: number
required:
- source_group_name
- target_group_name
- unit_volume
type: object
result: {}
required:
- goal
title: transfer_group参数
type: object
type: UniLabJsonCommandAsync
discard_tips:
feedback: {}
goal:

View File

@@ -9,6 +9,7 @@ neware_battery_test_system:
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -32,6 +33,7 @@ neware_battery_test_system:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -52,6 +54,7 @@ neware_battery_test_system:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''

View File

@@ -9,6 +9,7 @@ rotavap.one:
goal_default:
cmd: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cmd_write的参数schema
@@ -32,6 +33,7 @@ rotavap.one:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: main_loop的参数schema
@@ -53,6 +55,7 @@ rotavap.one:
goal_default:
time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_pump_time的参数schema
@@ -77,6 +80,7 @@ rotavap.one:
goal_default:
time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_rotate_time的参数schema
@@ -172,6 +176,7 @@ separator.homemade:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: read_sensor_loop的参数schema
@@ -194,6 +199,7 @@ separator.homemade:
condition: null
value: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: valve_open的参数schema
@@ -221,6 +227,7 @@ separator.homemade:
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: write的参数schema

View File

@@ -8,6 +8,7 @@ solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: close的参数schema
@@ -28,6 +29,7 @@ solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -48,6 +50,7 @@ solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_open的参数schema
@@ -68,6 +71,7 @@ solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -88,6 +92,7 @@ solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: read_data的参数schema
@@ -109,6 +114,7 @@ solenoid_valve:
goal_default:
command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: send_command的参数schema
@@ -205,6 +211,7 @@ solenoid_valve.mock:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -225,6 +232,7 @@ solenoid_valve.mock:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_open的参数schema
@@ -246,6 +254,7 @@ solenoid_valve.mock:
goal_default:
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_valve_position的参数schema
@@ -376,6 +385,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: close的参数schema
@@ -396,6 +406,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -417,6 +428,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: pull_plunger的参数schema
@@ -441,6 +453,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: push_plunger的参数schema
@@ -464,6 +477,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_aux_input_status_1的参数schema
@@ -484,6 +498,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_aux_input_status_2的参数schema
@@ -504,6 +519,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_backlash_position的参数schema
@@ -524,6 +540,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_command_buffer_status的参数schema
@@ -544,6 +561,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_software_version的参数schema
@@ -565,6 +583,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
full_command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: send_command的参数schema
@@ -589,6 +608,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
baudrate: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_baudrate的参数schema
@@ -613,6 +633,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_max_velocity的参数schema
@@ -638,6 +659,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
max_velocity: null
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_position的参数schema
@@ -664,6 +686,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_valve_position的参数schema
@@ -688,6 +711,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_velocity_grade的参数schema
@@ -711,6 +735,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: stop_operation的参数schema
@@ -731,6 +756,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_error的参数schema
@@ -880,6 +906,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: close的参数schema
@@ -900,6 +927,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -921,6 +949,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: pull_plunger的参数schema
@@ -945,6 +974,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: push_plunger的参数schema
@@ -968,6 +998,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_aux_input_status_1的参数schema
@@ -988,6 +1019,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_aux_input_status_2的参数schema
@@ -1008,6 +1040,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_backlash_position的参数schema
@@ -1028,6 +1061,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_command_buffer_status的参数schema
@@ -1048,6 +1082,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: query_software_version的参数schema
@@ -1069,6 +1104,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
full_command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: send_command的参数schema
@@ -1093,6 +1129,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
baudrate: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_baudrate的参数schema
@@ -1117,6 +1154,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_max_velocity的参数schema
@@ -1142,6 +1180,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
max_velocity: null
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_position的参数schema
@@ -1168,6 +1207,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_valve_position的参数schema
@@ -1192,6 +1232,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_velocity_grade的参数schema
@@ -1215,6 +1256,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: stop_operation的参数schema
@@ -1235,6 +1277,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_error的参数schema

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ agv.SEER:
ex_data: ''
obj: receive_socket
handles: {}
placeholder_keys: {}
result: {}
schema:
description: AGV底层通信命令发送函数。通过TCP socket连接向AGV发送底层控制命令支持pose位置、status状态、nav导航等命令类型。用于获取AGV当前位置坐标、运行状态或发送导航指令。该函数封装了AGV的通信协议将命令转换为十六进制数据包并处理响应解析。

View File

@@ -8,6 +8,7 @@ robotic_arm.SCARA_with_slider.virtual:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: check_tf_update_actions的参数schema
@@ -33,6 +34,7 @@ robotic_arm.SCARA_with_slider.virtual:
retry: 10
speed: 1
handles: {}
placeholder_keys: {}
result: {}
schema:
description: moveit_joint_task的参数schema
@@ -78,6 +80,7 @@ robotic_arm.SCARA_with_slider.virtual:
speed: 1
target_link: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: moveit_task的参数schema
@@ -125,6 +128,7 @@ robotic_arm.SCARA_with_slider.virtual:
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: post_init的参数schema
@@ -150,6 +154,7 @@ robotic_arm.SCARA_with_slider.virtual:
parent_link: null
resource: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: resource_manager的参数schema
@@ -176,6 +181,7 @@ robotic_arm.SCARA_with_slider.virtual:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_for_resource_action的参数schema
@@ -360,6 +366,7 @@ robotic_arm.UR:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 机械臂初始化函数。执行UR机械臂的完整初始化流程包括上电、释放制动器、解除保护停止状态等。该函数确保机械臂从安全停止状态恢复到可操作状态是机械臂使用前的必要步骤。初始化完成后机械臂将处于就绪状态可以接收后续的运动指令。
@@ -381,6 +388,7 @@ robotic_arm.UR:
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 从JSON字符串加载位置数据函数。接收包含机械臂位置信息的JSON格式字符串解析并存储位置数据供后续运动任务使用。位置数据通常包含多个预定义的工作位置坐标用于实现精确的多点运动控制。适用于动态配置机械臂工作位置的场景。
@@ -405,6 +413,7 @@ robotic_arm.UR:
goal_default:
file: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 从文件加载位置数据函数。读取指定的JSON文件并加载其中的机械臂位置信息。该函数支持从外部配置文件中获取预设的工作位置便于位置数据的管理和重用。适用于需要从固定配置文件中读取复杂位置序列的应用场景。
@@ -428,6 +437,7 @@ robotic_arm.UR:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 重新加载位置数据函数。重新读取并解析之前设置的位置文件,更新内存中的位置数据。该函数用于在位置文件被修改后刷新机械臂的位置配置,无需重新初始化整个系统。适用于动态更新机械臂工作位置的场景。
@@ -536,6 +546,7 @@ robotic_arm.elite:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -556,6 +567,7 @@ robotic_arm.elite:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -579,6 +591,7 @@ robotic_arm.elite:
start_addr: null
unit_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -609,6 +622,7 @@ robotic_arm.elite:
goal_default:
job_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -635,6 +649,7 @@ robotic_arm.elite:
unit_id: null
value: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -665,6 +680,7 @@ robotic_arm.elite:
goal_default:
response: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -689,6 +705,7 @@ robotic_arm.elite:
goal_default:
command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''

View File

@@ -8,6 +8,7 @@ gripper.misumi_rz:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: data_loop的参数schema
@@ -28,6 +29,7 @@ gripper.misumi_rz:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: data_reader的参数schema
@@ -51,6 +53,7 @@ gripper.misumi_rz:
pos: null
speed: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 夹爪抓取运动控制函数。控制夹爪的开合运动,支持位置、速度、力矩的精确设定。位置参数控制夹爪开合程度,速度参数控制运动快慢,力矩参数控制夹持强度。该函数提供安全的力控制,避免损坏被抓取物体,适用于各种形状和材质的物品抓取。
@@ -80,6 +83,7 @@ gripper.misumi_rz:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 夹爪初始化函数。执行Misumi RZ夹爪的完整初始化流程包括Modbus通信建立、电机参数配置、传感器校准等。该函数确保夹爪系统从安全状态恢复到可操作状态是夹爪使用前的必要步骤。初始化完成后夹爪将处于就绪状态可接收抓取和旋转指令。
@@ -101,6 +105,7 @@ gripper.misumi_rz:
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: modbus_crc的参数schema
@@ -130,6 +135,7 @@ gripper.misumi_rz:
spin_pos: null
spin_v: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: move_and_rotate的参数schema
@@ -169,6 +175,7 @@ gripper.misumi_rz:
goal_default:
cmd: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 节点夹爪移动任务函数。接收逗号分隔的命令字符串,解析位置、速度、力矩参数并执行夹爪抓取动作。该函数等待运动完成并返回执行结果,提供同步的运动控制接口。适用于需要可靠完成确认的精密抓取操作。
@@ -193,6 +200,7 @@ gripper.misumi_rz:
goal_default:
cmd: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 节点旋转移动任务函数。接收逗号分隔的命令字符串,解析角度、速度、力矩参数并执行夹爪旋转动作。该函数等待旋转完成并返回执行结果,提供同步的旋转控制接口。适用于需要精确角度定位和完成确认的旋转操作。
@@ -219,6 +227,7 @@ gripper.misumi_rz:
data_len: null
id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: read_address的参数schema
@@ -251,6 +260,7 @@ gripper.misumi_rz:
pos: null
speed: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 夹爪绝对位置旋转控制函数。控制夹爪主轴旋转到指定的绝对角度位置支持360度连续旋转。位置参数指定目标角度速度参数控制旋转速率力矩参数设定旋转阻力限制。该函数提供高精度的角度定位适用于需要精确方向控制的操作场景。
@@ -284,6 +294,7 @@ gripper.misumi_rz:
fun: null
id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: send_cmd的参数schema
@@ -316,6 +327,7 @@ gripper.misumi_rz:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_for_gripper的参数schema
@@ -336,6 +348,7 @@ gripper.misumi_rz:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_for_gripper_init的参数schema
@@ -356,6 +369,7 @@ gripper.misumi_rz:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_for_rotate的参数schema
@@ -462,6 +476,7 @@ gripper.mock:
Gripper1: {}
wf_name: gripper_run
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 模拟夹爪资源ID编辑函数。用于测试和演示资源管理功能模拟修改夹爪资源的标识信息。该函数接收工作流名称、参数和资源对象模拟真实的资源更新过程并返回修改后的资源信息。适用于系统测试和开发调试场景。

View File

@@ -8,6 +8,7 @@ linear_motion.grbl:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: CNC设备初始化函数。执行Grbl CNC的完整初始化流程包括归零操作、轴校准和状态复位。该函数将所有轴移动到原点位置(0,0,0),确保设备处于已知的参考状态。初始化完成后设备进入空闲状态,可接收后续的运动指令。
@@ -29,6 +30,7 @@ linear_motion.grbl:
goal_default:
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: CNC绝对位置设定函数。控制CNC设备移动到指定的三维坐标位置(x,y,z)。该函数支持安全限位检查,防止超出设备工作范围。移动过程中会监控设备状态,确保安全到达目标位置。适用于精确定位和轨迹控制操作。
@@ -52,6 +54,7 @@ linear_motion.grbl:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: CNC操作停止函数。立即停止当前正在执行的所有CNC运动包括轴移动和主轴旋转。该函数用于紧急停止或任务中断确保设备和工件的安全。停止后设备将保持当前位置等待新的指令。
@@ -72,6 +75,7 @@ linear_motion.grbl:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_error的参数schema
@@ -482,6 +486,7 @@ linear_motion.toyo_xyz.sim:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: check_tf_update_actions的参数schema
@@ -507,6 +512,7 @@ linear_motion.toyo_xyz.sim:
retry: 10
speed: 1
handles: {}
placeholder_keys: {}
result: {}
schema:
description: moveit_joint_task的参数schema
@@ -552,6 +558,7 @@ linear_motion.toyo_xyz.sim:
speed: 1
target_link: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: moveit_task的参数schema
@@ -599,6 +606,7 @@ linear_motion.toyo_xyz.sim:
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: post_init的参数schema
@@ -624,6 +632,7 @@ linear_motion.toyo_xyz.sim:
parent_link: null
resource: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: resource_manager的参数schema
@@ -650,6 +659,7 @@ linear_motion.toyo_xyz.sim:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: wait_for_resource_action的参数schema
@@ -837,6 +847,7 @@ motor.iCL42:
position: null
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 步进电机执行运动函数。直接执行电机运动命令,包括位置设定、速度控制和路径规划。该函数处理底层的电机控制协议,消除警告信息,设置运动参数并启动电机运行。适用于需要直接控制电机运动的应用场景。
@@ -866,6 +877,7 @@ motor.iCL42:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: iCL42电机设备初始化函数。建立与iCL42步进电机驱动器的串口通信连接配置通信参数包括波特率、数据位、校验位等。该函数是电机使用前的必要步骤确保驱动器处于可控状态并准备接收运动指令。
@@ -889,6 +901,7 @@ motor.iCL42:
position: null
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 步进电机运动控制函数。根据指定的运动模式、目标位置和速度参数控制电机运动。支持多种运动模式和精确的位置控制,自动处理运动轨迹规划和执行。该函数提供异步执行和状态反馈,确保运动的准确性和可靠性。

View File

@@ -65,6 +65,7 @@ solid_dispenser.laiyu:
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: Modbus CRC-16校验码计算函数。计算Modbus RTU通信协议所需的CRC-16校验码确保数据传输的完整性和可靠性。该函数实现标准的CRC-16算法用于构造完整的Modbus指令帧。
@@ -89,6 +90,7 @@ solid_dispenser.laiyu:
goal_default:
command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: Modbus指令发送函数。构造完整的Modbus RTU指令帧包含CRC校验发送给分装设备并等待响应。该函数处理底层通信协议确保指令的正确传输和响应接收支持最长3分钟的响应等待时间。

View File

@@ -12,6 +12,7 @@ chiller:
register_address: null
value: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: build_modbus_frame的参数schema
@@ -46,6 +47,7 @@ chiller:
decimal_points: 1
temperature: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: convert_temperature_to_modbus_value的参数schema
@@ -73,6 +75,7 @@ chiller:
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: modbus_crc的参数schema
@@ -96,6 +99,7 @@ chiller:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: stop的参数schema
@@ -188,6 +192,7 @@ heaterstirrer.dalong:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: close的参数schema
@@ -209,6 +214,7 @@ heaterstirrer.dalong:
goal_default:
speed: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_stir_speed的参数schema
@@ -234,6 +240,7 @@ heaterstirrer.dalong:
temp: null
type: warning
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_temp_inner的参数schema
@@ -580,6 +587,7 @@ tempsensor:
register_address: null
register_count: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: build_modbus_request的参数schema
@@ -613,6 +621,7 @@ tempsensor:
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: calculate_crc的参数schema
@@ -637,6 +646,7 @@ tempsensor:
goal_default:
response: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: read_modbus_response的参数schema
@@ -661,6 +671,7 @@ tempsensor:
goal_default:
command: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: send_prototype_command的参数schema

View File

@@ -8,6 +8,7 @@ virtual_centrifuge:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -28,6 +29,7 @@ virtual_centrifuge:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -296,6 +298,7 @@ virtual_column:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -316,6 +319,7 @@ virtual_column:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -691,6 +695,7 @@ virtual_filter:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -711,6 +716,7 @@ virtual_filter:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -1089,6 +1095,7 @@ virtual_gas_source:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -1109,6 +1116,7 @@ virtual_gas_source:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -1129,6 +1137,7 @@ virtual_gas_source:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -1149,6 +1158,7 @@ virtual_gas_source:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_open的参数schema
@@ -1311,6 +1321,7 @@ virtual_heatchill:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -1331,6 +1342,7 @@ virtual_heatchill:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -1880,6 +1892,7 @@ virtual_multiway_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: close的参数schema
@@ -1901,6 +1914,7 @@ virtual_multiway_valve:
goal_default:
port_number: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_at_port的参数schema
@@ -1925,6 +1939,7 @@ virtual_multiway_valve:
goal_default:
position: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_at_position的参数schema
@@ -1948,6 +1963,7 @@ virtual_multiway_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_at_pump_position的参数schema
@@ -1968,6 +1984,7 @@ virtual_multiway_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -1988,6 +2005,7 @@ virtual_multiway_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: reset的参数schema
@@ -2009,6 +2027,7 @@ virtual_multiway_valve:
goal_default:
port_number: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_to_port的参数schema
@@ -2032,6 +2051,7 @@ virtual_multiway_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_to_pump_position的参数schema
@@ -2053,6 +2073,7 @@ virtual_multiway_valve:
goal_default:
port_number: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: switch_between_pump_and_port的参数schema
@@ -2300,6 +2321,7 @@ virtual_rotavap:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -2320,6 +2342,7 @@ virtual_rotavap:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -2630,6 +2653,7 @@ virtual_separator:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -2650,6 +2674,7 @@ virtual_separator:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -3517,6 +3542,7 @@ virtual_solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -3537,6 +3563,7 @@ virtual_solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -3557,6 +3584,7 @@ virtual_solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -3577,6 +3605,7 @@ virtual_solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: reset的参数schema
@@ -3597,6 +3626,7 @@ virtual_solenoid_valve:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: toggle的参数schema
@@ -4035,6 +4065,7 @@ virtual_solid_dispenser:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -4056,6 +4087,7 @@ virtual_solid_dispenser:
goal_default:
reagent_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -4079,6 +4111,7 @@ virtual_solid_dispenser:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -4100,6 +4133,7 @@ virtual_solid_dispenser:
goal_default:
mass_str: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -4124,6 +4158,7 @@ virtual_solid_dispenser:
goal_default:
mol_str: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
@@ -4206,6 +4241,7 @@ virtual_stirrer:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -4226,6 +4262,7 @@ virtual_stirrer:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -4777,6 +4814,7 @@ virtual_transfer_pump:
velocity: null
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: aspirate的参数schema
@@ -4802,6 +4840,7 @@ virtual_transfer_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -4824,6 +4863,7 @@ virtual_transfer_pump:
velocity: null
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: dispense的参数schema
@@ -4850,6 +4890,7 @@ virtual_transfer_pump:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: empty_syringe的参数schema
@@ -4873,6 +4914,7 @@ virtual_transfer_pump:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: fill_syringe的参数schema
@@ -4895,6 +4937,7 @@ virtual_transfer_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -4915,6 +4958,7 @@ virtual_transfer_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_empty的参数schema
@@ -4935,6 +4979,7 @@ virtual_transfer_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_full的参数schema
@@ -4957,6 +5002,7 @@ virtual_transfer_pump:
velocity: null
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: pull_plunger的参数schema
@@ -4984,6 +5030,7 @@ virtual_transfer_pump:
velocity: null
volume: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: push_plunger的参数schema
@@ -5010,6 +5057,7 @@ virtual_transfer_pump:
goal_default:
velocity: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: set_max_velocity的参数schema
@@ -5033,6 +5081,7 @@ virtual_transfer_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: stop_operation的参数schema
@@ -5277,6 +5326,7 @@ virtual_vacuum_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
@@ -5297,6 +5347,7 @@ virtual_vacuum_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: initialize的参数schema
@@ -5317,6 +5368,7 @@ virtual_vacuum_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_closed的参数schema
@@ -5337,6 +5389,7 @@ virtual_vacuum_pump:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: is_open的参数schema

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ zhida_gcms:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 安全关闭与智达 GCMS 设备的 TCP 连接,释放网络资源。
@@ -60,6 +61,7 @@ zhida_gcms:
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 与智达 GCMS 设备建立 TCP 连接,配置超时参数。
@@ -81,6 +83,7 @@ zhida_gcms:
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''

View File

@@ -0,0 +1,9 @@
from pylabrobot.resources import Resource
class ResourceSlot(Resource):
pass
class DeviceSlot(str):
pass

View File

@@ -8,10 +8,16 @@ from pathlib import Path
from typing import Any, Dict, List, Union, Tuple
import yaml
from unilabos_msgs.msg import Resource
from unilabos.config.config import BasicConfig
from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema, String
from unilabos.ros.msgs.message_converter import (
msg_converter_manager,
ros_action_to_json_schema,
String,
ros_message_to_json_schema,
)
from unilabos.utils import logger
from unilabos.utils.decorator import singleton
from unilabos.utils.import_manager import get_enhanced_class_info, get_class
@@ -19,6 +25,7 @@ from unilabos.utils.type_check import NoAliasDumper
DEFAULT_PATHS = [Path(__file__).absolute().parent]
class ROSMsgNotFound(Exception):
pass
@@ -48,6 +55,7 @@ class Registry:
"ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
)
self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
self.StrSingleInput = self._replace_type_with_class("StrSingleInput", "host_node", f"")
self.device_type_registry = {}
self.device_module_to_registry = {}
self.resource_type_registry = {}
@@ -123,7 +131,6 @@ class Registry:
}
]
},
# todo: support nested keys, switch to non ros message schema
"placeholder_keys": {
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
@@ -134,13 +141,53 @@ class Registry:
"type": self.EmptyIn,
"goal": {},
"feedback": {},
"result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"},
"result": {},
"schema": ros_action_to_json_schema(
self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。"
),
"goal_default": {},
"handles": {},
},
"auto-test_resource": {
"type": "UniLabJsonCommand",
"goal": {},
"feedback": {},
"result": {},
"schema": {
"description": "",
"properties": {
"feedback": {},
"goal": {
"properties": {
"resource": ros_message_to_json_schema(Resource, "resource"),
"resources": {
"items": {
"properties": ros_message_to_json_schema(
Resource, "resources"
),
"type": "object",
},
"type": "array",
},
"device": {"type": "string"},
"devices": {"items": {"type": "string"}, "type": "array"},
},
"type": "object",
},
"result": {},
},
"title": "test_resource",
"type": "object",
},
"placeholder_keys": {
"device": "unilabos_devices",
"devices": "unilabos_devices",
"resource": "unilabos_resources",
"resources": "unilabos_resources",
},
"goal_default": {},
"handles": {},
},
},
},
"version": "1.0.0",
@@ -154,6 +201,8 @@ class Registry:
}
}
)
# 为host_node添加内置的驱动命令动作
self._add_builtin_actions(self.device_type_registry["host_node"], "host_node")
logger.trace(f"[UniLab Registry] ----------Setup----------")
self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths):
@@ -426,7 +475,17 @@ class Registry:
param_type = arg_info.get("type", "")
param_default = arg_info.get("default")
param_required = arg_info.get("required", True)
schema["properties"][param_name] = self._generate_schema_from_info(param_name, param_type, param_default)
if param_type == "unilabos.registry.placeholder_type:ResourceSlot":
schema["properties"][param_name] = ros_message_to_json_schema(Resource, param_name)
elif param_type == ("list", "unilabos.registry.placeholder_type:ResourceSlot"):
schema["properties"][param_name] = {
"items": ros_message_to_json_schema(Resource, param_name),
"type": "array",
}
else:
schema["properties"][param_name] = self._generate_schema_from_info(
param_name, param_type, param_default
)
if param_required:
schema["required"].append(param_name)
@@ -438,6 +497,43 @@ class Registry:
"required": ["goal"],
}
def _add_builtin_actions(self, device_config: Dict[str, Any], device_id: str):
"""
为设备配置添加内置的执行驱动命令动作
Args:
device_config: 设备配置字典
device_id: 设备ID
"""
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
if "class" not in device_config:
return
if "action_value_mappings" not in device_config["class"]:
device_config["class"]["action_value_mappings"] = {}
for additional_action in ["_execute_driver_command", "_execute_driver_command_async"]:
device_config["class"]["action_value_mappings"][additional_action] = {
"type": self._replace_type_with_class("StrSingleInput", device_id, f"动作 {additional_action}"),
"goal": {"string": "string"},
"feedback": {},
"result": {},
"schema": ros_action_to_json_schema(
self._replace_type_with_class("StrSingleInput", device_id, f"动作 {additional_action}")
),
"goal_default": yaml.safe_load(
io.StringIO(
get_yaml_from_goal_type(
self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
).Goal
)
)
),
"handles": {},
}
def load_device_types(self, path: os.PathLike, complete_registry: bool):
# return
abs_path = Path(path).absolute()
@@ -499,7 +595,9 @@ class Registry:
status_type = "String" # 替换成ROS的String便于显示
device_config["class"]["status_types"][status_name] = status_type
try:
target_type = self._replace_type_with_class(status_type, device_id, f"状态 {status_name}")
target_type = self._replace_type_with_class(
status_type, device_id, f"状态 {status_name}"
)
except ROSMsgNotFound:
continue
if target_type in [
@@ -536,13 +634,29 @@ class Registry:
"schema": self._generate_unilab_json_command_schema(v["args"], k),
"goal_default": {i["name"]: i["default"] for i in v["args"]},
"handles": [],
"placeholder_keys": {
i["name"]: (
"unilabos_resources"
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
or i["type"]
== ("list", "unilabos.registry.placeholder_type:ResourceSlot")
else "unilabos_devices"
)
for i in v["args"]
if i.get("type", "")
in [
"unilabos.registry.placeholder_type:ResourceSlot",
"unilabos.registry.placeholder_type:DeviceSlot",
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
]
},
}
# 不生成已配置action的动作
for k, v in enhanced_info["action_methods"].items()
if k not in device_config["class"]["action_value_mappings"]
}
)
# 恢复原有的description信息auto开头的不修改
for action_name, description in old_descriptions.items():
if action_name in device_config["class"]["action_value_mappings"]: # 有一些会被删除
@@ -595,30 +709,8 @@ class Registry:
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
action_config["type"] = action_str_type_mapping[action_config["type"]]
for additional_action in ["_execute_driver_command", "_execute_driver_command_async"]:
device_config["class"]["action_value_mappings"][additional_action] = {
"type": self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
),
"goal": {"string": "string"},
"feedback": {},
"result": {},
"schema": ros_action_to_json_schema(
self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
)
),
"goal_default": yaml.safe_load(
io.StringIO(
get_yaml_from_goal_type(
self._replace_type_with_class(
"StrSingleInput", device_id, f"动作 {additional_action}"
).Goal
)
)
),
"handles": {},
}
# 添加内置的驱动命令动作
self._add_builtin_actions(device_config, device_id)
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
device_config["registry_type"] = "device"
logger.trace( # type: ignore
@@ -642,7 +734,16 @@ class Registry:
device_info_copy = copy.deepcopy(device_info)
if "class" in device_info_copy and "action_value_mappings" in device_info_copy["class"]:
action_mappings = device_info_copy["class"]["action_value_mappings"]
for action_name, action_config in action_mappings.items():
# 过滤掉内置的驱动命令动作
builtin_actions = ["_execute_driver_command", "_execute_driver_command_async"]
filtered_action_mappings = {
action_name: action_config
for action_name, action_config in action_mappings.items()
if action_name not in builtin_actions
}
device_info_copy["class"]["action_value_mappings"] = filtered_action_mappings
for action_name, action_config in filtered_action_mappings.items():
if "schema" in action_config and action_config["schema"]:
schema = action_config["schema"]
# 确保schema结构存在

View File

@@ -34,3 +34,16 @@ BIOYOND_PolymerStation_6VialCarrier:
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerStation_6StockCarrier:
category:
- bottle_carriers
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6StockCarrier
type: pylabrobot
description: BIOYOND_PolymerStation_6StockCarrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -0,0 +1,24 @@
BIOYOND_PolymerStation_Solid_Stock:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock
type: pylabrobot
BIOYOND_PolymerStation_Solid_Vial:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial
type: pylabrobot
BIOYOND_PolymerStation_Liquid_Vial:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial
type: pylabrobot
BIOYOND_PolymerStation_Solution_Beaker:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solution_Beaker
type: pylabrobot
BIOYOND_PolymerStation_Reagent_Bottle:
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle
type: pylabrobot

View File

@@ -1,15 +1,3 @@
BIOYOND_PolymerReactionStation_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck
type: pylabrobot
description: BIOYOND PolymerReactionStation Deck
handles: []
icon: '反应站.webp'
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerPreparationStation_Deck:
category:
- deck
@@ -18,7 +6,19 @@ BIOYOND_PolymerPreparationStation_Deck:
type: pylabrobot
description: BIOYOND PolymerPreparationStation Deck
handles: []
icon: '配液站.webp'
icon: 配液站.webp
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerReactionStation_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck
type: pylabrobot
description: BIOYOND PolymerReactionStation Deck
handles: []
icon: 反应站.webp
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -1,7 +1,13 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Solid_Stock,
BIOYOND_PolymerStation_Solid_Vial,
BIOYOND_PolymerStation_Liquid_Vial,
BIOYOND_PolymerStation_Solution_Beaker,
BIOYOND_PolymerStation_Reagent_Bottle
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
@@ -92,6 +98,57 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
return carrier
def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 20.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="BIOYOND_PolymerStation_6VialCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
for i in range(6):
carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}")
return carrier
def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
@@ -138,8 +195,10 @@ def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier:
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
for i in range(6):
carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{ordering[i]}")
for i in range(3):
carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_solidvial_{ordering[i]}")
for i in range(3, 6):
carrier[i] = BIOYOND_PolymerStation_Liquid_Vial(f"{name}_liquidvial_{ordering[i]}")
return carrier

View File

@@ -2,12 +2,30 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数
def BIOYOND_PolymerStation_Solid_Vial(
def BIOYOND_PolymerStation_Solid_Stock(
name: str,
diameter: float = 20.0,
height: float = 100.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建粉末瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Solid_Stock",
)
def BIOYOND_PolymerStation_Solid_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建粉末瓶"""
return Bottle(
@@ -20,6 +38,24 @@ def BIOYOND_PolymerStation_Solid_Vial(
)
def BIOYOND_PolymerStation_Liquid_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建滴定液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Liquid_Vial",
)
def BIOYOND_PolymerStation_Solution_Beaker(
name: str,
diameter: float = 60.0,

View File

@@ -1,89 +1,125 @@
import importlib
import inspect
import json
from typing import Union, Any, Dict
import numpy as np
import traceback
from typing import Union, Any, Dict, List
import networkx as nx
from pylabrobot.resources import ResourceHolder
from unilabos_msgs.msg import Resource
from unilabos.resources.container import RegularContainer
from unilabos.ros.msgs.message_converter import convert_to_ros_msg
from unilabos.ros.nodes.resource_tracker import (
ResourceDictInstance,
ResourceTreeSet,
)
from unilabos.utils.banner_print import print_status
try:
from pylabrobot.resources.resource import Resource as ResourcePLR
except ImportError:
pass
from typing import Union, get_origin
from typing import get_origin
physical_setup_graph: nx.Graph = None
def canonicalize_nodes_data(data: dict, parent_relation: dict = {}) -> dict:
for node in data.get("nodes", []):
def canonicalize_nodes_data(
nodes: List[Dict[str, Any]], parent_relation: Dict[str, List[str]] = {}
) -> ResourceTreeSet:
"""
标准化节点数据,使用 ResourceInstanceDictFlatten 进行规范化并创建 ResourceTreeSet
Args:
nodes: 原始节点列表
parent_relation: 父子关系映射 {parent_id: [child_id1, child_id2, ...]}
Returns:
ResourceTreeSet: 标准化后的资源树集合
"""
print_status(f"{len(nodes)} Resources loaded:", "info")
# 第一步基本预处理处理graphml的label字段
for node in nodes:
if node.get("label") is not None:
id = node.pop("label")
node["id"] = node["name"] = id
if "id" not in node:
node["id"] = node.get("name", "NaN")
if "name" not in node:
node["name"] = node["id"]
if node.get("position") is None:
node["position"] = {
"x": node.pop("x", 0.0),
"y": node.pop("y", 0.0),
"z": node.pop("z", 0.0),
}
if node.get("config") is None:
node["config"] = {}
node["data"] = {}
for k in list(node.keys()):
if k not in [
"id",
"name",
"class",
"type",
"position",
"children",
"parent",
"config",
"data",
]:
if k in ["chemical", "current_volume"]:
if node["data"].get("liquids") is None:
node["data"]["liquids"] = [{}]
if k == "chemical":
node["data"]["liquids"][0]["liquid_name"] = node.pop(k)
elif k == "current_volume":
node["data"]["liquids"][0]["liquid_volume"] = node.pop(k)
elif k == "max_volume":
node["data"]["max_volume"] = node.pop(k)
elif k == "url":
node.pop(k)
else:
node["config"][k] = node.pop(k)
if "class" not in node:
node["class"] = None
if "type" not in node:
node["type"] = (
"container"
if node["class"] is None
else "device" if node["class"] not in ["container", "plate"] else node["class"]
)
if "children" not in node:
node["children"] = []
node_id = node.pop("label")
node["id"] = node["name"] = node_id
id2idx = {node_data["id"]: idx for idx, node_data in enumerate(data["nodes"])}
# 第二步处理parent_relation
id2idx = {node["id"]: idx for idx, node in enumerate(nodes)}
for parent, children in parent_relation.items():
data["nodes"][id2idx[parent]]["children"] = children
for child in children:
data["nodes"][id2idx[child]]["parent"] = parent
return data
if parent in id2idx:
nodes[id2idx[parent]]["children"] = children
for child in children:
if child in id2idx:
nodes[id2idx[child]]["parent"] = parent
# 第三步:使用 ResourceInstanceDictFlatten 标准化每个节点
standardized_instances = []
known_nodes: Dict[str, ResourceDictInstance] = {} # {node_id: ResourceDictInstance}
uuid_to_instance: Dict[str, ResourceDictInstance] = {} # {uuid: ResourceDictInstance}
for node in nodes:
try:
print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info")
# 使用标准化方法
resource_instance = ResourceDictInstance.get_resource_instance_from_dict(node)
known_nodes[node["id"]] = resource_instance
uuid_to_instance[resource_instance.res_content.uuid] = resource_instance
standardized_instances.append(resource_instance)
except Exception as e:
print_status(f"Failed to standardize node {node.get('id', 'unknown')}:\n{traceback.format_exc()}", "error")
continue
# 第四步:建立 parent 和 children 关系
for node in nodes:
node_id = node["id"]
if node_id not in known_nodes:
continue
current_instance = known_nodes[node_id]
# 优先使用 parent_uuid 进行匹配,如果不存在则使用 parent
parent_uuid = node.get("parent_uuid")
parent_id = node.get("parent")
parent_instance = None
# 优先用 parent_uuid 匹配
if parent_uuid and parent_uuid in uuid_to_instance:
parent_instance = uuid_to_instance[parent_uuid]
# 否则用 parent_id 匹配
elif parent_id and parent_id in known_nodes:
parent_instance = known_nodes[parent_id]
# 设置 parent 引用
if parent_instance:
current_instance.res_content.parent = parent_instance.res_content
# 将当前节点添加到父节点的 children 列表
parent_instance.children.append(current_instance)
# 第五步:创建 ResourceTreeSet
resource_tree_set = ResourceTreeSet.from_nested_list(standardized_instances)
return resource_tree_set
def canonicalize_links_ports(data: dict) -> dict:
def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: ResourceTreeSet) -> List[Dict[str, Any]]:
"""
标准化边/连接的端口信息
Args:
links: 原始连接列表
resource_tree_set: 资源树集合用于获取节点的UUID信息
Returns:
标准化后的连接列表
"""
# 构建 id 到 uuid 的映射
id_to_uuid: Dict[str, str] = {}
for node in resource_tree_set.all_nodes:
id_to_uuid[node.res_content.id] = node.res_content.uuid
# 第一遍处理将字符串类型的port转换为字典格式
for link in data.get("links", []):
for link in links:
port = link.get("port")
if link.get("type", "physical") == "physical":
link["type"] = "fluid"
@@ -107,11 +143,11 @@ def canonicalize_links_ports(data: dict) -> dict:
link["port"] = {link["source"]: None, link["target"]: None}
# 构建边字典,键为(source节点, target节点)值为对应的port信息
edges = {(link["source"], link["target"]): link["port"] for link in data.get("links", [])}
edges = {(link["source"], link["target"]): link["port"] for link in links}
# 第二遍处理填充反向边的dest信息
delete_reverses = []
for i, link in enumerate(data.get("links", [])):
for i, link in enumerate(links):
s, t = link["source"], link["target"]
current_port = link["port"]
if current_port.get(t) is None:
@@ -127,9 +163,22 @@ def canonicalize_links_ports(data: dict) -> dict:
# 若不存在反向边,初始化为空结构
current_port[t] = current_port[s]
# 删除已被使用反向端口信息的反向边
data["links"] = [link for i, link in enumerate(data.get("links", [])) if i not in delete_reverses]
standardized_links = [link for i, link in enumerate(links) if i not in delete_reverses]
return data
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
for link in standardized_links:
source_id = link.get("source")
target_id = link.get("target")
# 添加 source_uuid
if source_id and source_id in id_to_uuid:
link["source_uuid"] = id_to_uuid[source_id]
# 添加 target_uuid
if target_id and target_id in id_to_uuid:
link["target_uuid"] = id_to_uuid[target_id]
return standardized_links
def handle_communications(G: nx.Graph):
@@ -151,18 +200,43 @@ def handle_communications(G: nx.Graph):
G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm])
def read_node_link_json(json_info: Union[str, Dict[str, Any]]) -> tuple[nx.Graph, dict]:
def read_node_link_json(
json_info: Union[str, Dict[str, Any]],
) -> tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
"""
读取节点-边的JSON数据并构建图
Args:
json_info: JSON文件路径或字典数据
Returns:
tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
返回NetworkX图对象、资源树集合和标准化后的连接列表
"""
global physical_setup_graph
if isinstance(json_info, str):
data = json.load(open(json_info, encoding="utf-8"))
else:
data = json_info
data = canonicalize_nodes_data(data)
data = canonicalize_links_ports(data)
physical_setup_graph = nx.node_link_graph(data, multigraph=False) # edges="links" 3.6 warning
# 标准化节点数据并创建 ResourceTreeSet
nodes = data.get("nodes", [])
resource_tree_set = canonicalize_nodes_data(nodes)
# 标准化边数据
links = data.get("links", [])
standardized_links = canonicalize_links_ports(links, resource_tree_set)
# 构建 NetworkX 图(需要转换回 dict 格式)
# 从 ResourceTreeSet 获取所有节点
graph_data = {
"nodes": [node.res_content.model_dump(by_alias=True) for node in resource_tree_set.all_nodes],
"links": standardized_links,
}
physical_setup_graph = nx.node_link_graph(graph_data, edges="links", multigraph=False)
handle_communications(physical_setup_graph)
return physical_setup_graph, data
return physical_setup_graph, resource_tree_set, standardized_links
def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -185,7 +259,17 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
return data
def read_graphml(graphml_file):
def read_graphml(graphml_file: str) -> tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
"""
读取GraphML文件并构建图
Args:
graphml_file: GraphML文件路径
Returns:
tuple[nx.Graph, ResourceTreeSet, List[Dict[str, Any]]]:
返回NetworkX图对象、资源树集合和标准化后的连接列表
"""
global physical_setup_graph
G = nx.read_graphml(graphml_file)
@@ -202,12 +286,25 @@ def read_graphml(graphml_file):
G2 = nx.relabel_nodes(G, mapping)
data = nx.node_link_data(G2)
data = canonicalize_nodes_data(data, parent_relation=parent_relation)
data = canonicalize_links_ports(data)
physical_setup_graph = nx.node_link_graph(data, edges="links", multigraph=False) # edges="links" 3.6 warning
# 标准化节点数据并创建 ResourceTreeSet
nodes = data.get("nodes", [])
resource_tree_set = canonicalize_nodes_data(nodes, parent_relation=parent_relation)
# 标准化边数据
links = data.get("links", [])
standardized_links = canonicalize_links_ports(links, resource_tree_set)
# 构建 NetworkX 图(需要转换回 dict 格式)
# 从 ResourceTreeSet 获取所有节点
graph_data = {
"nodes": [node.res_content.model_dump(by_alias=True) for node in resource_tree_set.all_nodes],
"links": standardized_links,
}
physical_setup_graph = nx.node_link_graph(graph_data, link="links", multigraph=False)
handle_communications(physical_setup_graph)
return physical_setup_graph, data
return physical_setup_graph, resource_tree_set, standardized_links
def dict_from_graph(graph: nx.Graph) -> dict:
@@ -229,11 +326,7 @@ def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
is_root[child_id] = False
# 找到根节点并返回
root_nodes = [
node
for node in nodes_list
if is_root.get(node["id"], False) or len(nodes_list) == 1
]
root_nodes = [node for node in nodes_list if is_root.get(node["id"], False) or len(nodes_list) == 1]
# 如果存在多个根节点,返回所有根节点
return root_nodes
@@ -258,11 +351,7 @@ def dict_to_nested_dict(nodes: dict, devices_only: bool = False) -> dict:
node["config"]["children"] = node["children"]
# 找到根节点并返回
root_nodes = {
node["id"]: node
for node in nodes_list
if is_root.get(node["id"], False) or len(nodes_list) == 1
}
root_nodes = {node["id"]: node for node in nodes_list if is_root.get(node["id"], False) or len(nodes_list) == 1}
# 如果存在多个根节点,返回所有根节点
return root_nodes
@@ -337,6 +426,7 @@ def nested_dict_to_list(nested_dict: dict) -> list[dict]: # FIXME 是tree
return result
def convert_resources_to_type(
resources_list: list[dict], resource_type: Union[type, list[type]], *, plr_model: bool = False
) -> Union[list[dict], dict, None, "ResourcePLR"]:
@@ -369,7 +459,9 @@ def convert_resources_to_type(
return None
def convert_resources_from_type(resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False) -> Union[list[dict], dict, None, "ResourcePLR"]:
def convert_resources_from_type(
resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False
) -> Union[list[dict], dict, None, "ResourcePLR"]:
"""
Convert resources from a given type (PyLabRobot or NestedDict) to flattened list of dictionaries.
@@ -432,6 +524,7 @@ def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR":
d = resource_ulab_to_plr_inner(resource)
"""无法通过Resource进行反序列化例如TipSpot必须内部序列化好直接用TipSpot序列化会多参数导致出错"""
from pylabrobot.utils.object_parsing import find_subclass
sub_cls = find_subclass(d["type"], ResourcePLR)
spect = inspect.signature(sub_cls)
if "category" not in spect.parameters:
@@ -456,6 +549,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
else:
print("转换pylabrobot的时候出现未知类型", source)
return "container"
def resource_plr_to_ulab_inner(d: dict, all_states: dict, child=True) -> dict:
r = {
"id": d["name"],
@@ -474,6 +568,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
"data": all_states[d["name"]],
}
return r
d = resource_plr.serialize()
all_states = resource_plr.serialize_all_state()
r = resource_plr_to_ulab_inner(d, all_states, with_children)
@@ -496,31 +591,53 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict =
plr_materials = []
for material in bioyond_materials:
className = type_mapping.get(material.get("typeName"), "RegularContainer") if type_mapping else "RegularContainer"
plr_material: ResourcePLR = initialize_resource({"name": material["name"], "class": className}, resource_type=ResourcePLR)
className = (
type_mapping.get(material.get("typeName"), "RegularContainer") if type_mapping else "RegularContainer"
)
plr_material: ResourcePLR = initialize_resource(
{"name": material["name"], "class": className}, resource_type=ResourcePLR
)
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
# 处理子物料detail
if material.get("detail") and len(material["detail"]) > 0:
child_ids = []
for detail in material["detail"]:
number = (detail.get("z", 0) - 1) * plr_material.num_items_x * plr_material.num_items_y + \
(detail.get("x", 0) - 1) * plr_material.num_items_x + \
(detail.get("y", 0) - 1)
number = (
(detail.get("z", 0) - 1) * plr_material.num_items_x * plr_material.num_items_y
+ (detail.get("x", 0) - 1) * plr_material.num_items_x
+ (detail.get("y", 0) - 1)
)
bottle = plr_material[number]
if detail["name"] in type_mapping:
# plr_material.unassign_child_resource(bottle)
plr_material.sites[number] = None
plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": type_mapping[detail["name"]]}, resource_type=ResourcePLR
)
else:
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "")
bottle.tracker.liquids = [(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)]
else:
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
plr_materials.append(plr_material)
if deck and hasattr(deck, "warehouses"):
for loc in material.get("locations", []):
if hasattr(deck, "warehouses") and loc.get("whName") in deck.warehouses:
warehouse = deck.warehouses[loc["whName"]]
idx = (loc.get("y", 0) - 1) * warehouse.num_items_x * warehouse.num_items_y + \
(loc.get("x", 0) - 1) * warehouse.num_items_x + \
(loc.get("z", 0) - 1)
idx = (
(loc.get("y", 0) - 1) * warehouse.num_items_x * warehouse.num_items_y
+ (loc.get("x", 0) - 1) * warehouse.num_items_x
+ (loc.get("z", 0) - 1)
)
if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
warehouse[idx] = plr_material
@@ -528,6 +645,36 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict =
return plr_materials
def resource_plr_to_bioyond(plr_materials: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]:
bioyond_materials = []
for plr_material in plr_materials:
material = {
"name": plr_material.name,
"typeName": plr_material.__class__.__name__,
"code": plr_material.code,
"quantity": 0,
"detail": [],
"locations": [],
}
if hasattr(plr_material, "capacity") and plr_material.capacity > 1:
for idx in range(plr_material.capacity):
bottle = plr_material[idx]
detail = {
"x": (idx // (plr_material.num_items_x * plr_material.num_items_y)) + 1,
"y": ((idx % (plr_material.num_items_x * plr_material.num_items_y)) // plr_material.num_items_x) + 1,
"z": (idx % plr_material.num_items_x) + 1,
"code": bottle.code if hasattr(bottle, "code") else "",
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
}
material["detail"].append(detail)
material["quantity"] = 1.0
else:
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
material["quantity"] = sum(qty for _, qty in bottle.tracker.liquids) if hasattr(plr_material, "tracker") else 0
bioyond_materials.append(material)
return bioyond_materials
def initialize_resource(resource_config: dict, resource_type: Any = None) -> Union[list[dict], ResourcePLR]:
"""Initializes a resource based on its configuration.
@@ -541,6 +688,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
None
"""
from unilabos.registry.registry import lab_registry
resource_class_config = resource_config.get("class", None)
if resource_class_config is None:
return [resource_config]
@@ -570,7 +718,9 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
r = resource_plr
elif resource_class_config["type"] == "unilabos":
res_instance: RegularContainer = RESOURCE(id=resource_config["name"])
res_instance.ulr_resource = convert_to_ros_msg(Resource, {k:v for k,v in resource_config.items() if k != "class"})
res_instance.ulr_resource = convert_to_ros_msg(
Resource, {k: v for k, v in resource_config.items() if k != "class"}
)
r = [res_instance.get_ulr_resource_as_dict()]
elif isinstance(RESOURCE, dict):
r = [RESOURCE.copy()]

View File

@@ -5,15 +5,19 @@ Automated Liquid Handling Station Resource Classes - Simplified Version
from __future__ import annotations
from typing import Dict, Optional
from typing import Dict, List, Optional, TypeVar, Union, Sequence, Tuple
import pylabrobot
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.container import Container
from pylabrobot.resources.resource_holder import ResourceHolder
from pylabrobot.resources import Resource as ResourcePLR
from pylabrobot.resources import Well, ResourceHolder
from pylabrobot.resources.coordinate import Coordinate
class Bottle(Container):
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
class Bottle(Well):
"""瓶子类 - 简化版,不追踪瓶盖"""
def __init__(
@@ -37,6 +41,8 @@ class Bottle(Container):
max_volume=max_volume,
category=category,
model=model,
bottom_type="flat",
cross_section_type="circle"
)
self.diameter = diameter
self.height = height
@@ -50,13 +56,6 @@ class Bottle(Container):
"barcode": self.barcode,
}
from string import ascii_uppercase as LETTERS
from typing import Dict, List, Optional, Type, TypeVar, Union, Sequence, Tuple
import pylabrobot
from pylabrobot.resources.resource_holder import ResourceHolder
T = TypeVar("T", bound=ResourceHolder)
S = TypeVar("S", bound=ResourceHolder)

View File

@@ -1,25 +1,22 @@
import copy
import json
import os
import threading
import time
from typing import Optional, Dict, Any, List
import rclpy
from unilabos_msgs.srv._serial_command import SerialCommand_Response
from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher
from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ResourceAdd, SerialCommand # type: ignore
from unilabos_msgs.srv import SerialCommand # type: ignore
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.timer import Timer
from unilabos.registry.registry import lab_registry
from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
)
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils import logger
from unilabos.config.config import BasicConfig
@@ -43,9 +40,9 @@ def exit() -> None:
def main(
devices_config: Dict[str, Any] = {},
resources_config: list=[],
resources_edge_config: list=[],
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list[dict] = [],
graph: Optional[Dict[str, Any]] = None,
controllers_config: Dict[str, Any] = {},
bridges: List[Any] = [],
@@ -73,18 +70,22 @@ def main(
if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
resources_list = (
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
if resources_config
else []
)
resource_mesh_manager = ResourceMeshManager(
resources_mesh_config,
resources_config,
resource_tracker = host_node.resource_tracker,
device_id = 'resource_mesh_manager',
resources_list,
resource_tracker=host_node.resource_tracker,
device_id="resource_mesh_manager",
)
joint_republisher = JointRepublisher(
'joint_republisher',
host_node.resource_tracker
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
lh_joint_pub = LiquidHandlerJointPublisher(
resources_config=resources_list, resource_tracker=host_node.resource_tracker
)
lh_joint_pub = LiquidHandlerJointPublisher(resources_config=resources_config,
resource_tracker=host_node.resource_tracker)
executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher)
executor.add_node(lh_joint_pub)
@@ -97,9 +98,9 @@ def main(
def slave(
devices_config: Dict[str, Any] = {},
resources_config=[],
resources_edge_config=[],
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list = [],
graph: Optional[Dict[str, Any]] = None,
controllers_config: Dict[str, Any] = {},
bridges: List[Any] = [],
@@ -113,11 +114,12 @@ def slave(
executor = rclpy.__executor
if not executor:
executor = rclpy.__executor = MultiThreadedExecutor()
devices_config_copy = copy.deepcopy(devices_config)
for device_id, device_config in devices_config.items():
d = initialize_device_from_dict(device_id, device_config)
if d is None:
continue
devices_instances = {}
for device_config in devices_config.root_nodes:
device_id = device_config.res_content.id
if device_config.res_content.type != "device":
d = initialize_device_from_dict(device_id, device_config.get_nested_dict())
devices_instances[device_id] = d
# 默认初始化
# if d is not None and isinstance(d, Node):
# executor.add_node(d)
@@ -129,20 +131,17 @@ def slave(
if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
resource_mesh_manager = ResourceMeshManager(
resources_mesh_config,
resources_config,
resource_tracker= DeviceNodeResourceTracker(),
device_id = 'resource_mesh_manager',
)
joint_republisher = JointRepublisher(
'joint_republisher',
DeviceNodeResourceTracker()
resources_config, # type: ignore FIXME
resource_tracker=DeviceNodeResourceTracker(),
device_id="resource_mesh_manager",
)
joint_republisher = JointRepublisher("joint_republisher", DeviceNodeResourceTracker())
executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher)
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")
thread.start()
@@ -151,25 +150,61 @@ def slave(
sclient.wait_for_service()
request = SerialCommand.Request()
request.command = json.dumps({
"machine_name": BasicConfig.machine_name,
"type": "slave",
"devices_config": devices_config_copy,
"registry_config": lab_registry.obtain_registry_device_info()
}, ensure_ascii=False, cls=TypeEncoder)
request.command = json.dumps(
{
"machine_name": BasicConfig.machine_name,
"type": "slave",
"devices_config": devices_config.dump(),
"registry_config": lab_registry.obtain_registry_device_info(),
},
ensure_ascii=False,
cls=TypeEncoder,
)
response = sclient.call_async(request).result()
logger.info(f"Slave node info updated.")
rclient = n.create_client(ResourceAdd, "/resources/add")
# 使用新的 c2s_update_resource_tree 服务
rclient = n.create_client(SerialCommand, "/c2s_update_resource_tree")
rclient.wait_for_service()
request = ResourceAdd.Request()
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config]
response = rclient.call_async(request).result()
logger.info(f"Slave resource added.")
# 序列化 ResourceTreeSet 为 JSON
if resources_config:
request = SerialCommand.Request()
request.command = json.dumps(
{
"data": {
"data": resources_config.dump(),
"mount_uuid": "",
"first_add": True,
},
"action": "add",
},
ensure_ascii=False,
)
tree_response: SerialCommand_Response = rclient.call_async(request).result()
uuid_mapping = json.loads(tree_response.response)
for node in resources_config.root_nodes:
if node.res_content.type == "device":
for sub_node in node.children:
# 只有二级子设备
if sub_node.res_content.type != "device":
device_tracker = devices_instances[node.res_content.id].resource_tracker
resource_instance = device_tracker.figure_resource(
{"uuid": sub_node.res_content.uuid})
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
else:
logger.error("Slave模式不允许新增非设备节点下的物料")
continue
if tree_response:
logger.info(f"Slave resource tree added. Response: {tree_response.response}")
else:
logger.warning("Slave resource tree add response is None")
else:
logger.info("No resources to add.")
while True:
time.sleep(1)
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,12 @@
import copy
import inspect
import io
import json
import threading
import time
import traceback
import uuid
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, Union
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING
from concurrent.futures import ThreadPoolExecutor
import asyncio
@@ -24,8 +25,6 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import (
convert_resources_to_type,
convert_resources_from_type,
resource_ulab_to_plr,
initialize_resources,
dict_to_tree,
@@ -35,7 +34,6 @@ from unilabos.resources.graphio import (
from unilabos.resources.plr_additional_res_reg import register
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg,
convert_from_ros_msg_with_mapping,
convert_to_ros_msg_with_mapping,
)
@@ -49,12 +47,19 @@ from unilabos_msgs.srv import (
) # type: ignore
from unilabos_msgs.msg import Resource # type: ignore
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
from unilabos.ros.nodes.resource_tracker import (
DeviceNodeResourceTracker,
ResourceTreeSet,
)
from unilabos.ros.x.rclpyx import get_event_loop
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
from unilabos.utils.async_util import run_async_func
from unilabos.utils.import_manager import default_manager
from unilabos.utils.log import info, debug, warning, error, critical, logger, trace
from unilabos.utils.type_check import get_type_class, TypeEncoder, serialize_result_info, get_result_info_str
from unilabos.utils.type_check import get_type_class, TypeEncoder, get_result_info_str
if TYPE_CHECKING:
from pylabrobot.resources import Resource as ResourcePLR
T = TypeVar("T")
@@ -178,7 +183,9 @@ class PropertyPublisher:
try:
self.publisher_ = node.create_publisher(msg_type, f"{name}", 10)
except AttributeError as ex:
self.node.lab_logger().error(f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}")
self.node.lab_logger().error(
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
)
self.timer = node.create_timer(self.timer_period, self.publish_property)
self.__loop = get_event_loop()
str_msg_type = str(msg_type)[8:-2]
@@ -187,48 +194,48 @@ class PropertyPublisher:
def get_property(self):
if asyncio.iscoroutinefunction(self.get_method):
# 如果是异步函数,运行事件循环并等待结果
self.node.lab_logger().trace(f"PropertyPublisher.get_property】获取异步属性: {self.name}")
self.node.lab_logger().trace(f"【.get_property】获取异步属性: {self.name}")
loop = self.__loop
if loop:
future = asyncio.run_coroutine_threadsafe(self.get_method(), loop)
self._value = future.result()
return self._value
else:
self.node.lab_logger().error(f"PropertyPublisher.get_property】事件循环未初始化")
self.node.lab_logger().error(f"【.get_property】事件循环未初始化")
return None
else:
# 如果是同步函数,直接调用并返回结果
self.node.lab_logger().trace(f"PropertyPublisher.get_property】获取同步属性: {self.name}")
self.node.lab_logger().trace(f"【.get_property】获取同步属性: {self.name}")
self._value = self.get_method()
return self._value
async def get_property_async(self):
try:
# 获取异步属性值
self.node.lab_logger().trace(f"PropertyPublisher.get_property_async】异步获取属性: {self.name}")
self.node.lab_logger().trace(f"【.get_property_async】异步获取属性: {self.name}")
self._value = await self.get_method()
except Exception as e:
self.node.lab_logger().error(f"PropertyPublisher.get_property_async】获取异步属性出错: {str(e)}")
self.node.lab_logger().error(f"【.get_property_async】获取异步属性出错: {str(e)}")
def publish_property(self):
try:
self.node.lab_logger().trace(f"PropertyPublisher.publish_property】开始发布属性: {self.name}")
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
value = self.get_property()
if self.print_publish:
self.node.lab_logger().trace(f"PropertyPublisher.publish_property】发布 {self.msg_type}: {value}")
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
if value is not None:
msg = convert_to_ros_msg(self.msg_type, value)
self.publisher_.publish(msg)
self.node.lab_logger().trace(f"PropertyPublisher.publish_property】属性 {self.name} 发布成功")
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
except Exception as e:
self.node.lab_logger().error(f"【PropertyPublisher.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}")
self.node.lab_logger().error(
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
)
def change_frequency(self, period):
# 动态改变定时器频率
self.timer_period = period
self.node.get_logger().info(
f"【PropertyPublisher.change_frequency】修改 {self.name} 定时器周期为: {self.timer_period}"
)
self.node.get_logger().info(f"【.change_frequency】修改 {self.name} 定时器周期为: {self.timer_period}")
# 重置定时器
self.timer.cancel()
@@ -249,9 +256,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
node_name: str
namespace: str
# TODO 要删除,添加时间相关的属性,避免动态添加属性的警告
time_spent = 0.0
time_remaining = 0.0
# 内部共享变量
_time_spent = 0.0
_time_remaining = 0.0
# 是否创建Action
create_action_server = True
def __init__(
@@ -262,7 +270,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
action_value_mappings: Dict[str, Any],
hardware_interface: Dict[str, Any],
print_publish=True,
resource_tracker: Optional["DeviceNodeResourceTracker"] = None,
resource_tracker: "DeviceNodeResourceTracker" = None, # type: ignore
):
"""
初始化ROS2设备节点
@@ -313,7 +321,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 创建动作服务
if self.create_action_server:
for action_name, action_value_mapping in self._action_value_mappings.items():
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith("UniLabJsonCommand"):
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
"UniLabJsonCommand"
):
continue
self.create_ros_action_server(action_name, action_value_mapping)
@@ -325,13 +335,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 创建资源管理客户端
self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add"),
"resource_get": self.create_client(ResourceGet, "/resources/get"),
"resource_get": self.create_client(SerialCommand, "/resources/get"),
"resource_delete": self.create_client(ResourceDelete, "/resources/delete"),
"resource_update": self.create_client(ResourceUpdate, "/resources/update"),
"resource_list": self.create_client(ResourceList, "/resources/list"),
"c2s_update_resource_tree": self.create_client(SerialCommand, "/c2s_update_resource_tree"),
}
def query_host_name_cb(req, res):
def re_register_device(req, res):
self.register_device()
self.lab_logger().info("Host要求重新注册当前节点")
res.response = ""
@@ -380,12 +391,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1:
container_instance = request.resources[0]
container_query_dict: dict = resources
found_resources = self.resource_tracker.figure_resource({"id": container_query_dict["name"]}, try_mode=True)
found_resources = self.resource_tracker.figure_resource(
{"id": container_query_dict["name"]}, try_mode=True
)
if not len(found_resources):
self.resource_tracker.add_resource(container_instance)
logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器")
else:
assert len(found_resources) == 1, f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统"
assert (
len(found_resources) == 1
), f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统"
resource = found_resources[0]
if isinstance(resource, Resource):
regular_container = RegularContainer(resource.id)
@@ -399,12 +414,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
request.resources[0].name = resource["name"]
logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict")
else:
logger.info(f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}")
logger.info(
f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}"
)
response: ResourceAdd.Response = await rclient.call_async(request)
# 应该先add_resource了
final_response = {
"created_resources": [ROS2MessageInstance(i).get_python_dict() for i in request.resources],
"liquid_input_resources": []
"liquid_input_resources": [],
}
res.response = json.dumps(final_response)
# 如果driver自己就有assign的方法那就使用driver自己的assign方法
@@ -423,12 +440,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
)
res.response = get_result_info_str("", True, ret)
except Exception as e:
self.lab_logger().error(f"运行设备的create_resource出错{create_resource_func}\n{traceback.format_exc()}")
self.lab_logger().error(
f"运行设备的create_resource出错{create_resource_func}\n{traceback.format_exc()}"
)
res.response = get_result_info_str(traceback.format_exc(), False, {})
return res
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
if bind_parent_id != self.node_name:
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) # 拿到父节点进行具体assign等操作
resource = self.resource_tracker.figure_resource(
{"name": bind_parent_id}
) # 拿到父节点进行具体assign等操作
# request.resources = [convert_to_ros_msg(Resource, resources)]
try:
@@ -452,9 +473,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
input_wells_ulr = [
convert_to_ros_msg(Resource, resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False)) for r in LIQUID_INPUT_SLOT
convert_to_ros_msg(
Resource,
resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False),
)
for r in LIQUID_INPUT_SLOT
]
final_response["liquid_input_resources"] = [
ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr
]
final_response["liquid_input_resources"] = [ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr]
res.response = json.dumps(final_response)
if isinstance(resource, OTDeck) and "slot" in other_calling_param:
other_calling_param["slot"] = int(other_calling_param["slot"])
@@ -499,16 +526,22 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# noinspection PyTypeChecker
self._service_server: Dict[str, Service] = {
"query_host_name": self.create_service(
"re_register_device": self.create_service(
SerialCommand,
f"/srv{self.namespace}/query_host_name",
query_host_name_cb,
f"/srv{self.namespace}/re_register_device",
re_register_device,
callback_group=self.callback_group,
),
"append_resource": self.create_service(
SerialCommand,
f"/srv{self.namespace}/append_resource",
append_resource,
append_resource, # type: ignore
callback_group=self.callback_group,
),
"s2c_resource_tree": self.create_service(
SerialCommand,
f"/srv{self.namespace}/s2c_resource_tree",
self.s2c_resource_tree, # type: ignore
callback_group=self.callback_group,
),
}
@@ -518,17 +551,208 @@ class BaseROS2DeviceNode(Node, Generic[T]):
rclpy.get_global_executor().add_node(self)
self.lab_logger().debug(f"ROS节点初始化完成")
async def update_resource(self, resources: List[Any]):
r = ResourceUpdate.Request()
unique_resources = []
for resource in resources: # resource是list[ResourcePLR]
# 目前更新资源只支持传入plr的对象后面要更新convert_resources_from_type函数
converted_list = convert_resources_from_type([resource], resource_type=[object], is_plr=True)
unique_resources.extend([convert_to_ros_msg(Resource, converted) for converted in converted_list])
r.resources = unique_resources
response = await self._resource_clients["resource_update"].call_async(r)
async def update_resource(self, resources: List["ResourcePLR"]):
r = SerialCommand.Request()
tree_set = ResourceTreeSet.from_plr_resources(resources)
r.command = json.dumps({"data": {"data": tree_set.dump()}, "action": "update"})
response: SerialCommand_Response = await self._resource_clients["c2s_update_resource_tree"].call_async(r) # type: ignore
try:
uuid_maps = json.loads(response.response)
self.resource_tracker.loop_update_uuid(resources, uuid_maps)
except Exception as e:
self.lab_logger().error(f"更新资源uuid失败: {e}")
self.lab_logger().error(traceback.format_exc())
self.lab_logger().debug(f"资源更新结果: {response}")
async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
"""
处理资源树更新请求
支持三种操作:
- add: 添加新资源到资源树
- update: 更新现有资源
- remove: 从资源树中移除资源
"""
try:
data = json.loads(req.command)
results = []
for i in data:
action = i.get("action") # remove, add, update
resources_uuid: List[str] = i.get("data") # 资源数据
additional_add_params = i.get("additional_add_params", {}) # 额外参数
self.lab_logger().info(
f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}"
)
tree_set = None
if action in ["add", "update"]:
response: SerialCommand.Response = await self._resource_clients[
"c2s_update_resource_tree"
].call_async(
SerialCommand.Request(
command=json.dumps(
{"data": {"data": resources_uuid, "with_children": False}, "action": "get"}
)
)
) # type: ignore
raw_nodes = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_list(raw_nodes)
try:
if action == "add":
# 添加资源到资源跟踪器
plr_resources = tree_set.to_plr_resources()
for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.resource_tracker.add_resource(plr_resource)
parent_uuid = tree.root_node.res_content.parent_uuid
if parent_uuid:
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
if parent_resource is None:
self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
)
else:
try:
# 特殊兼容所有plr的物料的assign方法和create_resource append_resource后期同步
additional_params = {}
site = additional_add_params.get("site", None)
spec = inspect.signature(parent_resource.assign_child_resource)
if "spot" in spec.parameters:
additional_params["spot"] = site
parent_resource.assign_child_resource(
plr_resource, location=None, **additional_params
)
except Exception as e:
self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
)
func = getattr(self.driver_instance, "resource_tree_add", None)
if callable(func):
func(plr_resources)
results.append({"success": True, "action": "add"})
elif action == "update":
# 更新资源
plr_resources = tree_set.to_plr_resources()
for plr_resource, tree in zip(plr_resources, tree_set.trees):
states = plr_resource.serialize_all_state()
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
)
original_instance.load_all_state(states)
self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())}"
)
func = getattr(self.driver_instance, "resource_tree_update", None)
if callable(func):
func(plr_resources)
results.append({"success": True, "action": "update"})
elif action == "remove":
# 移除资源
plr_resources: List[ResourcePLR] = [
self.resource_tracker.uuid_to_resources[i] for i in resources_uuid
]
func = getattr(self.driver_instance, "resource_tree_remove", None)
if callable(func):
func(plr_resources)
for plr_resource in plr_resources:
plr_resource.parent.unassign_child_resource(plr_resource)
self.resource_tracker.remove_resource(plr_resource)
results.append({"success": True, "action": "remove"})
except Exception as e:
error_msg = f"Error processing {action} operation: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
self.lab_logger().error(traceback.format_exc())
results.append({"success": False, "action": action, "error": error_msg})
# 返回处理结果
result_json = {"results": results, "total": len(data)}
res.response = json.dumps(result_json, ensure_ascii=False)
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
except json.JSONDecodeError as e:
error_msg = f"Invalid JSON format: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
self.lab_logger().error(traceback.format_exc())
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
return res
async def transfer_resource_to_another(
self,
plr_resources: List["ResourcePLR"],
target_device_id: str,
target_resources: List["ResourcePLR"],
sites: List[str],
):
# 准备工作
uids = []
target_uids = []
for plr_resource in plr_resources:
uid = getattr(plr_resource, "unilabos_uuid", None)
if uid is None:
raise ValueError(f"来源物料{plr_resource}没有unilabos_uuid属性无法转运")
uids.append(uid)
for target_resource in target_resources:
uid = getattr(target_resource, "unilabos_uuid", None)
if uid is None:
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运")
target_uids.append(uid)
srv_address = f"/srv{target_device_id}/s2c_resource_tree"
sclient = self.create_client(SerialCommand, srv_address)
# 等待服务可用(设置超时)
if not sclient.wait_for_service(timeout_sec=5.0):
self.lab_logger().error(f"[{self.device_id} Node-Resource] Service {srv_address} not available")
raise ValueError(f"[{self.device_id} Node-Resource] Service {srv_address} not available")
# 先从当前节点移除资源
await self.s2c_resource_tree(
SerialCommand_Request(
command=json.dumps([{"action": "remove", "data": uids}], ensure_ascii=False) # 只移除父节点
),
SerialCommand_Response(),
)
# 通知云端转运资源
for plr_resource, target_uid, site in zip(plr_resources, target_uids, sites):
tree_set = ResourceTreeSet.from_plr_resources([plr_resource])
for root_node in tree_set.root_nodes:
root_node.res_content.parent = None
root_node.res_content.parent_uuid = target_uid
r = SerialCommand.Request()
r.command = json.dumps({"data": {"data": tree_set.dump()}, "action": "update"}) # 和Update Resource一致
response: SerialCommand_Response = await self._resource_clients["c2s_update_resource_tree"].call_async(r) # type: ignore
self.lab_logger().info(f"资源云端转运到{target_device_id}结果: {response.response}")
# 创建请求
request = SerialCommand.Request()
request.command = json.dumps(
[
{
"action": "add",
"data": tree_set.all_nodes_uuid, # 只添加父节点,子节点会自动添加
"additional_add_params": {"site": site},
}
],
ensure_ascii=False,
)
future = sclient.call_async(request)
timeout = 30.0
start_time = time.time()
while not future.done():
if time.time() - start_time > timeout:
self.lab_logger().error(
f"[{self.device_id} Node-Resource] Timeout waiting for response from {target_device_id}"
)
return False
time.sleep(0.05)
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
return None
def register_device(self):
"""向注册表中注册设备信息"""
topics_info = self._property_publishers.copy()
@@ -657,7 +881,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
execution_success = False
action_return_value = None
##### self.lab_logger().info(f"执行动作: {action_name}")
##### self.lab_logger().info(f"执行动作: {action_name}")
goal = goal_handle.request
# 从目标消息中提取参数, 并调用对应的方法
@@ -672,7 +896,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info(f"执行序列动作后续步骤: {action}")
self.get_real_function(self.driver_instance, action)[0]()
action_paramtypes = self.get_real_function(self.driver_instance, action_value_mapping["sequence"][0])[1]
action_paramtypes = self.get_real_function(self.driver_instance, action_value_mapping["sequence"][0])[
1
]
else:
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
@@ -684,43 +910,34 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"{action_name} 查询资源状态: Key: {k} Type: {v}")
current_resources: Union[List[Resource], List[List[Resource]]] = []
# TODO: resource后面需要分组
only_one_resource = False
try:
if isinstance(action_kwargs[k], list) and len(action_kwargs[k]) > 1:
for i in action_kwargs[k]:
r = ResourceGet.Request()
r.id = i["id"] # splash optional
r.with_children = True
response = await self._resource_clients["resource_get"].call_async(r)
current_resources.append(response.resources)
else:
only_one_resource = True
r = ResourceGet.Request()
r.id = (
action_kwargs[k]["id"]
if v == "unilabos_msgs/Resource"
else action_kwargs[k][0]["id"]
)
r.with_children = True
response = await self._resource_clients["resource_get"].call_async(r)
current_resources.extend(response.resources)
except Exception:
logger.error(f"资源查询失败,默认使用本地资源")
# 删除对response.resources的检查因为它总是存在
type_hint = action_paramtypes[k]
final_type = get_type_class(type_hint)
if only_one_resource:
resources_list: List[Dict[str, Any]] = [convert_from_ros_msg(rs) for rs in current_resources] # type: ignore
self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源")
final_resource = convert_resources_to_type(resources_list, final_type)
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource并做转换
else:
resources_list: List[List[Dict[str, Any]]] = [[convert_from_ros_msg(rs) for rs in sub_res_list] for sub_res_list in current_resources] # type: ignore
final_resource = [convert_resources_to_type(sub_res_list, final_type)[0] for sub_res_list in resources_list]
try:
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource, try_mode=False)
# 统一处理单个或多个资源
is_sequence = v != "unilabos_msgs/Resource"
resource_inputs = action_kwargs[k] if is_sequence else [action_kwargs[k]]
# 批量查询资源
queried_resources = []
for resource_data in resource_inputs:
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients[
"resource_get"
].call_async(r)
raw_data = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0]
queried_resources.append(plr_resource)
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
# 通过资源跟踪器获取本地实例
final_resources = queried_resources if is_sequence else queried_resources[0]
action_kwargs[k] = self.resource_tracker.figure_resource(final_resources, try_mode=False)
except Exception as e:
self.lab_logger().error(f"{action_name} 物料实例获取失败: {e}\n{traceback.format_exc()}")
error_skip = True
@@ -745,8 +962,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
execution_success = True
except Exception as e:
execution_error = traceback.format_exc()
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
error(traceback.format_exc())
error(
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
)
future.add_done_callback(_handle_future_exception)
except Exception as e:
@@ -754,7 +972,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
execution_success = False
self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}")
else:
##### self.lab_logger().info(f"同步执行动作 {ACTION}")
##### self.lab_logger().info(f"同步执行动作 {ACTION}")
future = self._executor.submit(ACTION, **action_kwargs)
def _handle_future_exception(fut):
@@ -763,7 +981,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
action_return_value = fut.result()
execution_success = True
except Exception as e:
error(f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
execution_error = traceback.format_exc()
error(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
)
future.add_done_callback(_handle_future_exception)
@@ -778,8 +999,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
goal_handle.canceled()
return action_type.Result()
self.time_spent = time.time() - time_start
self.time_remaining = time_overall - self.time_spent
self._time_spent = time.time() - time_start
self._time_remaining = time_overall - self._time_spent
# 发布反馈
feedback_values = {}
@@ -807,7 +1028,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info(f"动作 {action_name} 已取消")
return action_type.Result()
##### self.lab_logger().info(f"动作执行完成: {action_name}")
# self.lab_logger().info(f"动作执行完成: {action_name}")
del future
# 向Host更新物料当前状态
@@ -816,27 +1037,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue
self.lab_logger().info(f"更新资源状态: {k}")
r = ResourceUpdate.Request()
# 仅当action_kwargs[k]不为None时尝试转换
akv = action_kwargs[k] # 已经是完成转换的物料了只需要转换成ros msg Resource了
akv = action_kwargs[k] # 已经是完成转换的物料了
apv = action_paramtypes[k]
final_type = get_type_class(apv)
if final_type is None:
continue
try:
# 去重:使用 seen 集合获取唯一的资源对象
seen = set()
unique_resources = []
for rs in akv:
for rs in akv: # todo: 这里目前只支持plr的类型
res = self.resource_tracker.parent_resource(rs) # 获取 resource 对象
if id(res) not in seen:
seen.add(id(res))
converted_list = convert_resources_from_type([res], final_type)
unique_resources.extend([convert_to_ros_msg(Resource, converted) for converted in converted_list])
unique_resources.append(res)
r.resources = unique_resources
response = await self._resource_clients["resource_update"].call_async(r)
self.lab_logger().debug(f"资源更新结果: {response}")
# 使用新的资源树接口
if unique_resources:
await self.update_resource(unique_resources)
except Exception as e:
self.lab_logger().error(f"资源更新失败: {e}")
self.lab_logger().error(traceback.format_exc())
@@ -860,13 +1079,217 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if attr_name in ["success", "reached_goal"]:
setattr(result_msg, attr_name, True)
elif attr_name == "return_info":
setattr(result_msg, attr_name, get_result_info_str(execution_error, execution_success, action_return_value))
setattr(
result_msg,
attr_name,
get_result_info_str(execution_error, execution_success, action_return_value),
)
##### self.lab_logger().info(f"动作 {action_name} 完成并返回结果")
return result_msg
return execute_callback
def _execute_driver_command(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
)
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(
function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
# 处理 ResourceSlot 类型参数
args_list = default_manager._analyze_method_signature(function)["args"]
for arg in args_list:
arg_name = arg["name"]
arg_type = arg["type"]
# 跳过不在 function_args 中的参数
if arg_name not in function_args:
continue
# 处理单个 ResourceSlot
if arg_type == "unilabos.registry.placeholder_type:ResourceSlot":
resource_data = function_args[arg_name]
if isinstance(resource_data, dict) and "id" in resource_data:
try:
converted_resource = self._convert_resource_sync(resource_data)
function_args[arg_name] = converted_resource
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
# 处理 ResourceSlot 列表
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
resource_slot_type = "unilabos.registry.placeholder_type:ResourceSlot"
if arg_type[0] == "list" and arg_type[1] == resource_slot_type:
resource_list = function_args[arg_name]
if isinstance(resource_list, list):
try:
converted_resources = []
for resource_data in resource_list:
if isinstance(resource_data, dict) and "id" in resource_data:
converted_resource = self._convert_resource_sync(resource_data)
converted_resources.append(converted_resource)
function_args[arg_name] = converted_resources
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
return function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
def _convert_resource_sync(self, resource_data: Dict[str, Any]):
"""同步转换资源数据为实例"""
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
# 同步调用资源查询服务
future = self._resource_clients["resource_get"].call_async(r)
# 等待结果使用while循环每次sleep 0.5秒最多等待5秒
timeout = 30.0
elapsed = 0.0
while not future.done() and elapsed < timeout:
time.sleep(0.05)
elapsed += 0.05
if not future.done():
raise Exception(f"资源查询超时: {resource_data['id']}")
response = future.result()
if response is None:
raise Exception(f"资源查询返回空结果: {resource_data['id']}")
current_resources = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(current_resources)
plr_resource = tree_set.to_plr_resources()[0]
# 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0:
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
return plr_resource
elif len(res) == 1:
return res[0]
else:
raise ValueError(f"资源转换得到多个实例: {res}")
async def _execute_driver_command_async(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(
f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}"
)
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(
function
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
assert asyncio.iscoroutinefunction(
function
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
# 处理 ResourceSlot 类型参数
args_list = default_manager._analyze_method_signature(function)["args"]
for arg in args_list:
arg_name = arg["name"]
arg_type = arg["type"]
# 跳过不在 function_args 中的参数
if arg_name not in function_args:
continue
# 处理单个 ResourceSlot
if arg_type == "unilabos.registry.placeholder_type:ResourceSlot":
resource_data = function_args[arg_name]
if isinstance(resource_data, dict) and "id" in resource_data:
try:
converted_resource = await self._convert_resource_async(resource_data)
function_args[arg_name] = converted_resource
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
# 处理 ResourceSlot 列表
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
resource_slot_type = "unilabos.registry.placeholder_type:ResourceSlot"
if arg_type[0] == "list" and arg_type[1] == resource_slot_type:
resource_list = function_args[arg_name]
if isinstance(resource_list, list):
try:
converted_resources = []
for resource_data in resource_list:
if isinstance(resource_data, dict) and "id" in resource_data:
converted_resource = await self._convert_resource_async(resource_data)
converted_resources.append(converted_resource)
function_args[arg_name] = converted_resources
except Exception as e:
self.lab_logger().error(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
)
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
return await function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
)
async def _convert_resource_async(self, resource_data: Dict[str, Any]):
"""异步转换资源数据为实例"""
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
# 异步调用资源查询服务
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
current_resources = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(current_resources)
plr_resource = tree_set.to_plr_resources()[0]
# 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0:
# todo: 后续通过decoration来区分减少warning
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
return plr_resource
elif len(res) == 1:
return res[0]
else:
raise ValueError(f"资源转换得到多个实例: {res}")
# 异步上下文管理方法
async def __aenter__(self):
"""进入异步上下文"""
@@ -887,9 +1310,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
class DeviceInitError(Exception):
pass
class JsonCommandInitError(Exception):
pass
class ROS2DeviceNode:
"""
ROS2设备节点类
@@ -969,7 +1394,6 @@ class ROS2DeviceNode:
or driver_class.__name__ == "PRCXI9300Handler"
)
# TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建
# 创建设备类实例
if use_pylabrobot_creator:
# 先对pylabrobot的子资源进行加载不然subclass无法认出
@@ -980,11 +1404,18 @@ class ROS2DeviceNode:
)
else:
from unilabos.devices.workstation.workstation_base import WorkstationBase
if issubclass(self._driver_class, WorkstationBase): # 是WorkstationNode的子节点就要调用WorkstationNodeCreator
if issubclass(
self._driver_class, WorkstationBase
): # 是WorkstationNode的子节点就要调用WorkstationNodeCreator
self.driver_is_workstation = True
self._driver_creator = WorkstationNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker)
self._driver_creator = WorkstationNodeCreator(
driver_class, children=children, resource_tracker=self.resource_tracker
)
else:
self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker)
self._driver_creator = DeviceClassCreator(
driver_class, children=children, resource_tracker=self.resource_tracker
)
if driver_is_ros:
driver_params["device_id"] = device_id
@@ -999,6 +1430,7 @@ class ROS2DeviceNode:
self._ros_node = self._driver_instance # type: ignore
elif self.driver_is_workstation:
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
self._ros_node = ROS2WorkstationNode(
protocol_type=driver_params["protocol_type"],
children=children,
@@ -1023,51 +1455,14 @@ class ROS2DeviceNode:
self._ros_node: BaseROS2DeviceNode
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
self.driver_instance._ros_node = self._ros_node # type: ignore
self.driver_instance._execute_driver_command = self._execute_driver_command # type: ignore
self.driver_instance._execute_driver_command_async = self._execute_driver_command_async # type: ignore
self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
self.driver_instance._execute_driver_command_async = self._ros_node._execute_driver_command_async # type: ignore
if hasattr(self.driver_instance, "post_init"):
try:
self.driver_instance.post_init(self._ros_node) # type: ignore
except Exception as e:
self._ros_node.lab_logger().error(f"设备后初始化失败: {e}")
def _execute_driver_command(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}")
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(function), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
return function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}")
async def _execute_driver_command_async(self, string: str):
try:
target = json.loads(string)
except Exception as ex:
try:
target = yaml.safe_load(io.StringIO(string))
except Exception as ex2:
raise JsonCommandInitError(f"执行动作时JSON/YAML解析失败: \n{ex}\n{ex2}\n原内容: {string}\n{traceback.format_exc()}")
try:
function_name = target["function_name"]
function_args = target["function_args"]
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
function = getattr(self.driver_instance, function_name)
assert callable(function), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
assert asyncio.iscoroutinefunction(function), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
return await function(**function_args)
except KeyError as ex:
raise JsonCommandInitError(f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}")
def _start_loop(self):
def run_event_loop():
loop = asyncio.new_event_loop()

View File

@@ -1,5 +1,4 @@
import collections
import copy
from dataclasses import dataclass, field
import json
import threading
@@ -13,16 +12,15 @@ from geometry_msgs.msg import Point
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service
from rosidl_runtime_py import set_message_fields
from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import (
ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
) # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry
@@ -38,11 +36,17 @@ from unilabos.ros.msgs.message_converter import (
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.presets.controller_node import ControllerNode
from unilabos.ros.nodes.resource_tracker import (
ResourceDictInstance,
ResourceTreeSet,
ResourceTreeInstance,
)
from unilabos.utils.exception import DeviceClassInvalid
from unilabos.utils.type_check import serialize_result_info
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
if TYPE_CHECKING:
from unilabos.app.ws_client import QueueItem
from unilabos.app.ws_client import QueueItem, WSResourceChatData
@dataclass
@@ -62,6 +66,7 @@ class HostNode(BaseROS2DeviceNode):
_device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict(
DeviceActionStatus
)
_resource_tracker: ClassVar[DeviceNodeResourceTracker] = DeviceNodeResourceTracker() # 资源管理器实例
@classmethod
def get_instance(cls, timeout=None) -> Optional["HostNode"]:
@@ -72,8 +77,8 @@ class HostNode(BaseROS2DeviceNode):
def __init__(
self,
device_id: str,
devices_config: Dict[str, Any],
resources_config: list,
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list[dict],
physical_setup_graph: Optional[Dict[str, Any]] = None,
controllers_config: Optional[Dict[str, Any]] = None,
@@ -103,7 +108,7 @@ class HostNode(BaseROS2DeviceNode):
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
hardware_interface={},
print_publish=False,
resource_tracker=DeviceNodeResourceTracker(), # host node并不是通过initialize 包一层传进来的
resource_tracker=self._resource_tracker, # host node并不是通过initialize 包一层传进来的
)
# 设置单例实例
@@ -112,7 +117,7 @@ class HostNode(BaseROS2DeviceNode):
# 初始化配置
self.server_latest_timestamp = 0.0 #
self.devices_config = devices_config
self.resources_config = resources_config
self.resources_config = resources_config # 直接保存 ResourceTreeSet
self.resources_edge_config = resources_edge_config
self.physical_setup_graph = physical_setup_graph
if controllers_config is None:
@@ -147,6 +152,24 @@ class HostNode(BaseROS2DeviceNode):
"/devices/host_node/test_latency",
callback_group=self.callback_group,
),
"/devices/host_node/test_resource": ActionClient(
self,
lab_registry.EmptyIn,
"/devices/host_node/test_resource",
callback_group=self.callback_group,
),
"/devices/host_node/_execute_driver_command": ActionClient(
self,
lab_registry.StrSingleInput,
"/devices/host_node/_execute_driver_command",
callback_group=self.callback_group,
),
"/devices/host_node/_execute_driver_command_async": ActionClient(
self,
lab_registry.StrSingleInput,
"/devices/host_node/_execute_driver_command_async",
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = (
{}
@@ -167,11 +190,9 @@ class HostNode(BaseROS2DeviceNode):
self._discover_devices()
# 初始化所有本机设备节点,多一次过滤,防止重复初始化
for device_id, device_config in devices_config.items():
if device_config.get("type", "device") != "device":
self.lab_logger().debug(
f"[Host Node] Skipping type {device_config['type']} {device_id} already existed, skipping."
)
for device_config in devices_config.root_nodes:
device_id = device_config.res_content.id
if device_config.res_content.type != "device":
continue
if device_id not in self.devices_names:
self.initialize_device(device_id, device_config)
@@ -186,58 +207,71 @@ class HostNode(BaseROS2DeviceNode):
].items():
controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config)
resources_config.insert(
0,
{
"id": "host_node",
"name": "host_node",
"parent": None,
"type": "device",
"class": "host_node",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {},
"children": [],
},
)
resource_with_dirs_name = []
resource_ids_to_instance = {i["id"]: i for i in resources_config}
for res in resources_config:
temp_res = res
res_paths = [res]
while temp_res.get("parent"):
temp_res = resource_ids_to_instance[temp_res.get("parent")]
res_paths.append(temp_res)
dirs = "/" + "/".join([res["id"] for res in res_paths[::-1]])
new_res = copy.deepcopy(res)
new_res["data"]["unilabos_dirs"] = dirs
resource_with_dirs_name.append(new_res)
# 创建 host_node 作为一个单独的 ResourceTree
host_node_dict = {
"id": "host_node",
"uuid": str(uuid.uuid4()),
"parent_uuid": "",
"name": "host_node",
"type": "device",
"class": "host_node",
"config": {},
"data": {},
"children": [],
"description": "",
"schema": {},
"model": {},
"icon": "",
}
# 创建 host_node 的 ResourceTree
host_node_instance = ResourceDictInstance.get_resource_instance_from_dict(host_node_dict)
host_node_tree = ResourceTreeInstance(host_node_instance)
resources_config.trees.insert(0, host_node_tree)
try:
for bridge in self.bridges:
if hasattr(bridge, "resource_add"):
if hasattr(bridge, "resource_tree_add") and resources_config:
from unilabos.app.web.client import HTTPClient
client: HTTPClient = bridge
resource_start_time = time.time()
resource_add_res = client.resource_add(add_schema(resources_config))
# DEBUG ONLY
# for i in resource_with_dirs_name:
# http_req = self.bridges[-1].resource_get(i["data"]["unilabos_dirs"], True)
# res = self._resource_get_process(http_req)
# print(res)
# 传递 ResourceTreeSet 对象,在 client 中转换为字典并获取 UUID 映射
uuid_mapping = client.resource_tree_add(resources_config, "", True)
resource_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
)
for edge in self.resources_edge_config:
edge["source_uuid"] = uuid_mapping.get(edge["source_uuid"], edge["source_uuid"])
edge["target_uuid"] = uuid_mapping.get(edge["target_uuid"], edge["target_uuid"])
resource_add_res = client.resource_edge_add(self.resources_edge_config)
resource_edge_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料关系上传 {round(resource_edge_end_time - resource_end_time, 5) * 1000} ms"
)
# resources_config 通过各个设备的 resource_tracker 进行uuid更新利用uuid_mapping
# resources_config 的 root node 是
for tree in resources_config.trees:
node = tree.root_node
if node.res_content.type == "device":
for sub_node in node.children:
# 只有二级子设备
if sub_node.res_content.type != "device":
# slave节点走c2s更新接口拿到add自行update uuid
device_tracker = self.devices_instances[node.res_content.id].resource_tracker
resource_instance = device_tracker.figure_resource(
{"uuid": sub_node.res_content.uuid})
device_tracker.loop_update_uuid(resource_instance, uuid_mapping)
else:
try:
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
self.resource_tracker.add_resource(plr_resource)
except Exception as ex:
self.lab_logger().warning("[Host Node-Resource] 根节点物料序列化失败!")
except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc())
# 创建定时器,定期发现设备
self._discovery_timer = self.create_timer(
discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup()
@@ -286,23 +320,23 @@ class HostNode(BaseROS2DeviceNode):
self.devices_names[edge_device_id] = namespace
self._create_action_clients_for_device(device_id, namespace)
self._online_devices.add(device_key)
sclient = self.create_client(SerialCommand, f"/srv{namespace}/query_host_name")
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
threading.Thread(
target=self._send_re_register,
args=(sclient,),
daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
).start()
elif device_key not in self._online_devices:
# 设备重新上线
self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}")
self._online_devices.add(device_key)
sclient = self.create_client(SerialCommand, f"/srv{namespace}/query_host_name")
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
threading.Thread(
target=self._send_re_register,
args=(sclient,),
daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
).start()
# 检测离线设备
@@ -473,16 +507,13 @@ class HostNode(BaseROS2DeviceNode):
for i in response:
res = json.loads(i)
new_li.append(res)
return {
"resources": new_li,
"liquid_input_resources": new_li
}
return {"resources": new_li, "liquid_input_resources": new_li}
except Exception as ex:
pass
_n = "\n"
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
"""
根据配置初始化设备,
@@ -495,9 +526,8 @@ class HostNode(BaseROS2DeviceNode):
"""
self.lab_logger().info(f"[Host Node] Initializing device: {device_id}")
device_config_copy = copy.deepcopy(device_config)
try:
d = initialize_device_from_dict(device_id, device_config_copy)
d = initialize_device_from_dict(device_id, device_config.get_nested_dict())
except DeviceClassInvalid as e:
self.lab_logger().error(f"[Host Node] Device class invalid: {e}")
d = None
@@ -677,9 +707,7 @@ class HostNode(BaseROS2DeviceNode):
feedback_callback=lambda feedback_msg: self.feedback_callback(item, action_id, feedback_msg),
goal_uuid=goal_uuid_obj,
)
future.add_done_callback(
lambda future: self.goal_response_callback(item, action_id, future)
)
future.add_done_callback(lambda future: self.goal_response_callback(item, action_id, future))
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
"""目标响应回调"""
@@ -793,7 +821,7 @@ class HostNode(BaseROS2DeviceNode):
ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup()
),
"resource_get": self.create_service(
ResourceGet, "/resources/get", self._resource_get_callback, callback_group=ReentrantCallbackGroup()
SerialCommand, "/resources/get", self._resource_get_callback, callback_group=ReentrantCallbackGroup()
),
"resource_delete": self.create_service(
ResourceDelete,
@@ -816,8 +844,125 @@ class HostNode(BaseROS2DeviceNode):
self._node_info_update_callback,
callback_group=ReentrantCallbackGroup(),
),
"c2s_update_resource_tree": self.create_service(
SerialCommand,
"/c2s_update_resource_tree",
self._resource_tree_update_callback,
callback_group=ReentrantCallbackGroup(),
),
}
def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
resource_tree_set = ResourceTreeSet.load(data["data"])
mount_uuid = data["mount_uuid"]
first_add = data["first_add"]
self.lab_logger().info(
f"[Host Node-Resource] Loaded ResourceTreeSet with {len(resource_tree_set.trees)} trees, "
f"{len(resource_tree_set.all_nodes)} total nodes"
)
# 处理资源添加逻辑
success = False
uuid_mapping = {}
if len(self.bridges) > 0:
from unilabos.app.web.client import HTTPClient
client: HTTPClient = self.bridges[-1]
resource_start_time = time.time()
uuid_mapping = client.resource_tree_add(resource_tree_set, mount_uuid, first_add)
success = True
resource_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料创建上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
)
if uuid_mapping:
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
if success:
from unilabos.resources.graphio import physical_setup_graph
# 将资源添加到本地图中
for node in resource_tree_set.all_nodes:
resource_dict = node.res_content.model_dump(by_alias=True)
if resource_dict.get("id") not in physical_setup_graph.nodes:
physical_setup_graph.add_node(resource_dict["id"], **resource_dict)
else:
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
response.response = json.dumps(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
uuid_list: List[str] = data["data"]
with_children: bool = data["with_children"]
from unilabos.app.web.client import http_client
resource_response = http_client.resource_tree_get(uuid_list, with_children)
response.response = json.dumps(resource_response)
def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
"""
子节点通知Host物料树删除
"""
self.lab_logger().info(f"[Host Node-Resource] Resource tree remove request received")
response.response = "OK"
self.lab_logger().info(f"[Host Node-Resource] Resource tree remove completed")
def _resource_tree_action_update_callback(self, data: dict, response: SerialCommand_Response):
"""
子节点通知Host物料树更新
"""
resource_tree_set = ResourceTreeSet.load(data["data"])
self.lab_logger().info(
f"[Host Node-Resource] Loaded ResourceTreeSet with {len(resource_tree_set.trees)} trees, "
f"{len(resource_tree_set.all_nodes)} total nodes"
)
from unilabos.app.web.client import http_client
resource_start_time = time.time()
uuid_mapping = http_client.resource_tree_update(resource_tree_set, "", False)
success = bool(uuid_mapping)
resource_end_time = time.time()
self.lab_logger().info(
f"[Host Node-Resource] 物料更新上传 {round(resource_end_time - resource_start_time, 5) * 1000} ms"
)
if uuid_mapping:
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
response.response = json.dumps(uuid_mapping)
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
"""
子节点通知Host物料树更新
接收序列化的 ResourceTreeSet 数据并进行处理
"""
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
try:
# 解析请求数据
data = json.loads(request.command)
action = data["action"]
data = data["data"]
if action == "add":
self._resource_tree_action_add_callback(data, response)
elif action == "get":
self._resource_tree_action_get_callback(data, response)
elif action == "update":
self._resource_tree_action_update_callback(data, response)
elif action == "remove":
self._resource_tree_action_remove_callback(data, response)
else:
self.lab_logger().error(f"[Host Node-Resource] Invalid action: {action}")
response.response = "ERROR"
except Exception as e:
self.lab_logger().error(f"[Host Node-Resource] Error adding resource tree: {e}")
self.lab_logger().error(traceback.format_exc())
response.response = f"ERROR: {str(e)}"
return response
def _node_info_update_callback(self, request, response):
"""
更新节点信息回调
@@ -888,7 +1033,7 @@ class HostNode(BaseROS2DeviceNode):
resources = [convert_to_ros_msg(Resource, resource) for resource in r]
return resources
def _resource_get_callback(self, request: ResourceGet.Request, response: ResourceGet.Response):
def _resource_get_callback(self, request: SerialCommand.Request, response: SerialCommand.Response):
"""
获取资源回调
@@ -902,14 +1047,12 @@ class HostNode(BaseROS2DeviceNode):
响应对象,包含查询到的资源
"""
try:
http_req = self.bridges[-1].resource_get(request.id, request.with_children)
response.resources = self._resource_get_process(http_req)
data = json.loads(request.command)
http_req = self.bridges[-1].resource_get(data["id"], data["with_children"])
response.response = json.dumps(http_req["data"])
return response
except Exception as e:
self.lab_logger().error(f"[Host Node-Resource] Error retrieving from bridge: {str(e)}")
r = [resource for resource in self.resources_config if resource.get("id") == request.id]
self.lab_logger().debug(f"[Host Node-Resource] Retrieved from local: {len(r)} resources")
response.resources = [convert_to_ros_msg(Resource, resource) for resource in r]
return response
def _resource_delete_callback(self, request, response):
@@ -1094,6 +1237,7 @@ class HostNode(BaseROS2DeviceNode):
else:
self.lab_logger().warning("⚠️ 无法获取服务端任务下发时间,跳过任务延迟分析")
raw_delay_ms = -1
corrected_delay_ms = -1
self.lab_logger().info("=" * 60)
@@ -1110,6 +1254,12 @@ class HostNode(BaseROS2DeviceNode):
"status": "success",
}
def test_resource(self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]):
return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
"devices": [device, *devices],
}
def handle_pong_response(self, pong_data: dict):
"""
处理pong响应
@@ -1129,3 +1279,78 @@ class HostNode(BaseROS2DeviceNode):
)
else:
self.lab_logger().warning("⚠️ 收到无效的Pong响应缺少ping_id")
def notify_resource_tree_update(
self, device_id: str, action: str, resource_uuid_list: List[str]
) -> bool:
"""
通知设备节点更新资源树
Args:
device_id: 目标设备ID
action: 操作类型 "add", "update", "remove"
resource_uuid_list: 资源UUIDs
Returns:
bool: 操作是否成功
"""
try:
# 检查设备是否存在
if device_id not in self.devices_names:
self.lab_logger().error(f"[Host Node-Resource] Device {device_id} not found in devices_names")
return False
namespace = self.devices_names[device_id]
device_key = f"{namespace}/{device_id}"
# 检查设备是否在线
if device_key not in self._online_devices:
self.lab_logger().error(f"[Host Node-Resource] Device {device_key} is offline")
return False
# 构建服务地址
srv_address = f"/srv{namespace}/s2c_resource_tree"
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation")
# 创建服务客户端
sclient = self.create_client(SerialCommand, srv_address)
# 等待服务可用(设置超时)
if not sclient.wait_for_service(timeout_sec=5.0):
self.lab_logger().error(f"[Host Node-Resource] Service {srv_address} not available")
return False
# 构建请求数据
request_data = [
{
"action": action,
"data": resource_uuid_list,
}
]
# 创建请求
request = SerialCommand.Request()
request.command = json.dumps(request_data, ensure_ascii=False)
# 发送异步请求
future = sclient.call_async(request)
# 等待响应
timeout = 30.0
start_time = time.time()
while not future.done():
if time.time() - start_time > timeout:
self.lab_logger().error(f"[Host Node-Resource] Timeout waiting for response from {device_id}")
return False
time.sleep(0.05)
response = future.result()
self.lab_logger().info(
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
)
return True
except Exception as e:
self.lab_logger().error(f"[Host Node-Resource] Error notifying resource tree update: {str(e)}")
self.lab_logger().error(traceback.format_exc())
return False

View File

@@ -29,9 +29,11 @@ from unilabos.utils.type_check import serialize_result_info, get_result_info_str
if TYPE_CHECKING:
from unilabos.devices.workstation.workstation_base import WorkstationBase
class ROS2WorkstationNodeTempError(Exception):
pass
class ROS2WorkstationNode(BaseROS2DeviceNode):
"""
ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。
@@ -63,10 +65,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
driver_instance=driver_instance,
device_id=device_id,
status_types=status_types,
action_value_mappings={
**action_value_mappings,
**self.protocol_action_mappings
},
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
hardware_interface=hardware_interface,
print_publish=print_publish,
resource_tracker=resource_tracker,
@@ -89,7 +88,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
d = self.initialize_device(device_id, device_config)
except Exception as ex:
self.lab_logger().error(
f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}")
f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}"
)
d = None
if d is None:
continue
@@ -109,10 +109,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
if d:
hardware_interface = d.ros_node_instance._hardware_interface
if (
hasattr(d.driver_instance, hardware_interface["name"])
and hasattr(d.driver_instance, hardware_interface["write"])
and (
hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"]))
hasattr(d.driver_instance, hardware_interface["name"])
and hasattr(d.driver_instance, hardware_interface["write"])
and (hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"]))
):
name = getattr(d.driver_instance, hardware_interface["name"])
@@ -130,7 +129,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})"
)
self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}")
self.lab_logger().info(f"ROS2WorkstationNode {device_id} initialized with protocols: {self.protocol_names}")
def _setup_protocol_names(self, protocol_type):
# 处理协议类型
@@ -160,7 +159,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器
for action_name, action_mapping in node._action_value_mappings.items():
if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith(
"UniLabJsonCommand"):
"UniLabJsonCommand"
):
continue
action_id = f"/devices/{device_id_abs}/{action_name}"
if action_id not in self._action_clients:
@@ -245,8 +245,10 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
logs.append(step)
elif isinstance(step, list):
logs.append(step)
self.lab_logger().info(f"Goal received: {protocol_kwargs}, running steps: "
f"{json.dumps(logs, indent=4, ensure_ascii=False)}")
self.lab_logger().info(
f"Goal received: {protocol_kwargs}, running steps: "
f"{json.dumps(logs, indent=4, ensure_ascii=False)}"
)
time_start = time.time()
time_overall = 100
@@ -268,7 +270,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
if not ret_info.get("suc", False):
raise RuntimeError(f"Step {i + 1} failed.")
except ROS2WorkstationNodeTempError as ex:
step_results.append({"step": i + 1, "action": action["action_name"], "result": ex.args[0]})
step_results.append(
{"step": i + 1, "action": action["action_name"], "result": ex.args[0]}
)
elif isinstance(action, list):
# 如果是并行动作,同时执行
actions = action
@@ -307,8 +311,12 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
except Exception as e:
# 捕获并记录错误信息
str_step_results = [
{k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v for k, v in
i.items()} for i in step_results]
{
k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v
for k, v in i.items()
}
for i in step_results
]
execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}"
execution_success = False
self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}")
@@ -381,7 +389,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
"""还没有改过的部分"""
def _setup_hardware_proxy(
self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method
self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method
):
"""为设备设置硬件接口代理"""
# extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])]
@@ -405,17 +413,3 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
if write_method:
# bound_write = MethodType(_write, device.driver_instance)
setattr(device.driver_instance, write_method, _write)
async def _update_resources(self, goal, protocol_kwargs):
"""更新资源状态"""
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
if protocol_kwargs[k] is not None:
try:
r = ResourceUpdate.Request()
r.resources = [
convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k])
]
await self._resource_clients["resource_update"].call_async(r)
except Exception as e:
self.lab_logger().error(f"更新资源失败: {e}")

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
这个模块包含用于创建设备类实例的工厂类。
基础工厂类提供通用的实例创建方法,而特定工厂类提供针对特定设备类的创建方法。
"""
import asyncio
import inspect
import traceback
@@ -53,7 +54,6 @@ class DeviceClassCreator(Generic[T]):
if c["type"] != "device":
self.resource_tracker.add_resource(c)
def create_instance(self, data: Dict[str, Any]) -> T:
"""
创建设备类实例
@@ -118,7 +118,9 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
return nested_dict_to_list(resource), Resource
return resource, source_type
def _process_resource_references(self, data: Any, to_dict=False, states=None, prefix_path="") -> Any:
def _process_resource_references(
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None
) -> Any:
"""
递归处理资源引用替换_resource_child_name对应的资源
@@ -127,11 +129,13 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
to_dict: 是否返回字典形式的资源
states: 用于保存所有资源状态
prefix_path: 当前递归路径
name_to_uuid: name到uuid的映射字典
Returns:
处理后的数据
"""
from pylabrobot.resources import Deck, Resource
if states is None:
states = {}
@@ -146,7 +150,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
target_type = import_manager.get_class(type_path)
contain_model = not issubclass(target_type, Deck)
resource, target_type = self._process_resource_mapping(resource, target_type)
resource_instance: Resource = resource_ulab_to_plr(resource, contain_model)
resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state
states[prefix_path] = resource_instance.serialize_all_state()
# 使用 prefix_path 作为 key 存储资源状态
if to_dict:
@@ -155,6 +159,9 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
return serialized
else:
self.resource_tracker.add_resource(resource_instance)
# 立即设置UUIDstate已经在resource_ulab_to_plr中处理过了
if name_to_uuid:
self.resource_tracker.loop_set_uuid(resource_instance, name_to_uuid)
return resource_instance
except Exception as e:
logger.warning(f"无法导入资源类型 {type_path}: {e}")
@@ -169,12 +176,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
result = {}
for key, value in data.items():
new_prefix = f"{prefix_path}.{key}" if prefix_path else key
result[key] = self._process_resource_references(value, to_dict, states, new_prefix)
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid)
return result
elif isinstance(data, list):
return [
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]")
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
for i, item in enumerate(data)
]
@@ -193,22 +200,42 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
"""
deserialize_error = None
stack = None
# 递归遍历 children 构建 name_to_uuid 映射
def collect_name_to_uuid(children_dict: Dict[str, Any], result: Dict[str, str]):
"""递归遍历嵌套的 children 字典,收集 name 到 uuid 的映射"""
for child in children_dict.values():
if isinstance(child, dict):
result[child["name"]] = child["uuid"]
collect_name_to_uuid(child["children"], result)
name_to_uuid = {}
collect_name_to_uuid(self.children, name_to_uuid)
if self.has_deserialize:
deserialize_method = getattr(self.device_cls, "deserialize")
spect = inspect.signature(deserialize_method)
spec_args = spect.parameters
for param_name, param_value in data.copy().items():
if isinstance(param_value, dict) and "_resource_child_name" in param_value and "_resource_type" not in param_value:
if (
isinstance(param_value, dict)
and "_resource_child_name" in param_value
and "_resource_type" not in param_value
):
arg_value = spec_args[param_name].annotation
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
# 首先处理资源引用
states = {}
processed_data = self._process_resource_references(data, to_dict=True, states=states)
processed_data = self._process_resource_references(
data, to_dict=True, states=states, name_to_uuid=name_to_uuid
)
try:
self.device_instance = deserialize_method(**processed_data)
from pylabrobot.resources import Resource
self.device_instance: Resource = deserialize_method(**processed_data)
self.resource_tracker.loop_set_uuid(self.device_instance, name_to_uuid)
all_states = self.device_instance.serialize_all_state()
for k, v in states.items():
logger.debug(f"PyLabRobot反序列化设置状态{k}")
@@ -217,7 +244,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
v[kk] = vv
self.device_instance.load_all_state(v)
self.resource_tracker.add_resource(self.device_instance)
self.post_create()
self.post_create() # 对应DeviceClassCreator进行调用
return self.device_instance # type: ignore
except Exception as e:
# 先静默继续,尝试另外一种创建方法
@@ -229,12 +256,16 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
spect = inspect.signature(self.device_cls.__init__)
spec_args = spect.parameters
for param_name, param_value in data.copy().items():
if isinstance(param_value, dict) and "_resource_child_name" in param_value and "_resource_type" not in param_value:
if (
isinstance(param_value, dict)
and "_resource_child_name" in param_value
and "_resource_type" not in param_value
):
arg_value = spec_args[param_name].annotation
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
processed_data = self._process_resource_references(data, to_dict=False)
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data)
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid)
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用调用的自身的attach_resource
except Exception as e:
logger.error(f"PyLabRobot创建实例失败: {e}")
logger.error(f"PyLabRobot创建实例堆栈: {traceback.format_exc()}")
@@ -247,22 +278,31 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
return self.device_instance
def post_create(self):
if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(getattr(self.device_instance, "setup")):
if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(
getattr(self.device_instance, "setup")
):
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
def done_cb(*args):
from pylabrobot.resources import set_volume_tracking
# from pylabrobot.resources import set_tip_tracking
set_volume_tracking(enabled=True)
# set_tip_tracking(enabled=True) # 序列化tip_spot has为False
logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成")
from unilabos.config.config import BasicConfig
if BasicConfig.vis_2d_enable:
from pylabrobot.visualizer.visualizer import Visualizer
vis = Visualizer(resource=self.device_instance, open_browser=True)
def vis_done_cb(*args):
logger.info(f"PyLabRobot设备实例开启了Visualizer {self.device_instance}")
ROS2DeviceNode.run_async_func(vis.setup).add_done_callback(vis_done_cb)
logger.debug(f"PyLabRobot设备实例提交开启Visualizer {self.device_instance}")
ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(done_cb)
@@ -299,6 +339,7 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
deck_dict = data.get("deck")
if deck_dict:
from pylabrobot.resources import Deck, Resource
plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker)
deck = plrc.create_instance(deck_dict)
data["deck"] = deck