mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
Merge remote-tracking branch 'upstream/dev' into device_visualization
This commit is contained in:
@@ -42,10 +42,11 @@ conda env update --file unilabos-[YOUR_OS].yml -n 环境名
|
|||||||
|
|
||||||
# 现阶段,需要安装 `unilabos_msgs` 包
|
# 现阶段,需要安装 `unilabos_msgs` 包
|
||||||
# 可以前往 Release 页面下载系统对应的包进行安装
|
# 可以前往 Release 页面下载系统对应的包进行安装
|
||||||
conda install ros-humble-unilabos-msgs-0.8.0-xxxxx.tar.bz2
|
conda install ros-humble-unilabos-msgs-0.9.0-xxxxx.tar.bz2
|
||||||
|
|
||||||
# 安装PyLabRobot等前置
|
# 安装PyLabRobot等前置
|
||||||
git clone https://github.com/PyLabRobot/pylabrobot
|
git clone https://github.com/PyLabRobot/pylabrobot plr_repo
|
||||||
|
cd plr_repo
|
||||||
pip install .[opentrons]
|
pip install .[opentrons]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.8.0
|
version: 0.9.0
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
folder: ros-humble-unilabos-msgs/src/work
|
folder: ros-humble-unilabos-msgs/src/work
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.8.0"
|
version: "0.9.0"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.8.0',
|
version='0.9.0',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
5
test/commands/resource_add.md
Normal file
5
test/commands/resource_add.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
使用plr_test.json启动,将Well加入Plate中
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 action send_goal /devices/host_node/add_resource_from_outer unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }"
|
||||||
|
```
|
||||||
@@ -6679,8 +6679,7 @@
|
|||||||
"plate_well_11_3",
|
"plate_well_11_3",
|
||||||
"plate_well_11_4",
|
"plate_well_11_4",
|
||||||
"plate_well_11_5",
|
"plate_well_11_5",
|
||||||
"plate_well_11_6",
|
"plate_well_11_6"
|
||||||
"plate_well_11_7"
|
|
||||||
],
|
],
|
||||||
"parent": "deck",
|
"parent": "deck",
|
||||||
"type": "device",
|
"type": "device",
|
||||||
@@ -10508,45 +10507,6 @@
|
|||||||
"pending_liquids": [],
|
"pending_liquids": [],
|
||||||
"liquid_history": []
|
"liquid_history": []
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "plate_well_11_7",
|
|
||||||
"name": "plate_well_11_7",
|
|
||||||
"sample_id": null,
|
|
||||||
"children": [],
|
|
||||||
"parent": "plate",
|
|
||||||
"type": "device",
|
|
||||||
"class": "",
|
|
||||||
"position": {
|
|
||||||
"x": 109.87,
|
|
||||||
"y": 7.77,
|
|
||||||
"z": 3.03
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"type": "Well",
|
|
||||||
"size_x": 6.86,
|
|
||||||
"size_y": 6.86,
|
|
||||||
"size_z": 10.67,
|
|
||||||
"rotation": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"z": 0,
|
|
||||||
"type": "Rotation"
|
|
||||||
},
|
|
||||||
"category": "well",
|
|
||||||
"model": null,
|
|
||||||
"max_volume": 360,
|
|
||||||
"material_z_thickness": 0.5,
|
|
||||||
"compute_volume_from_height": null,
|
|
||||||
"compute_height_from_volume": null,
|
|
||||||
"bottom_type": "flat",
|
|
||||||
"cross_section_type": "circle"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"liquids": [],
|
|
||||||
"pending_liquids": [],
|
|
||||||
"liquid_history": []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": []
|
"links": []
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"name": "teaching_carrier",
|
"name": "teaching_carrier",
|
||||||
"sample_id": null,
|
"sample_id": null,
|
||||||
"children": [
|
"children": [
|
||||||
|
"teaching_carrier_A1"
|
||||||
],
|
],
|
||||||
"parent": "deck",
|
"parent": "deck",
|
||||||
"type": "plate",
|
"type": "plate",
|
||||||
@@ -86,6 +87,46 @@
|
|||||||
"model": null
|
"model": null
|
||||||
},
|
},
|
||||||
"data": {}
|
"data": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "teaching_carrier_A1",
|
||||||
|
"name": "teaching_carrier_A1",
|
||||||
|
"sample_id": null,
|
||||||
|
"children": [],
|
||||||
|
"parent": "teaching_carrier",
|
||||||
|
"type": "device",
|
||||||
|
"class": "",
|
||||||
|
"position": {
|
||||||
|
"x": 10.87,
|
||||||
|
"y": 70.77,
|
||||||
|
"z": 10
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "TipSpot",
|
||||||
|
"size_x": 6.86,
|
||||||
|
"size_y": 6.86,
|
||||||
|
"size_z": 10.67,
|
||||||
|
"rotation": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"type": "Rotation"
|
||||||
|
},
|
||||||
|
"category": "tip_spot",
|
||||||
|
"model": null,
|
||||||
|
"prototype_tip": {
|
||||||
|
"type": "Tip",
|
||||||
|
"total_tip_length": 39.2,
|
||||||
|
"has_filter": true,
|
||||||
|
"maximal_volume": 20.0,
|
||||||
|
"fitting_depth": 3.29
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"liquids": [],
|
||||||
|
"pending_liquids": [],
|
||||||
|
"liquid_history": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
|
|||||||
@@ -59,3 +59,5 @@ dependencies:
|
|||||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||||
# ilab equipments
|
# ilab equipments
|
||||||
# - ros-humble-unilabos-msgs
|
# - ros-humble-unilabos-msgs
|
||||||
|
- pip:
|
||||||
|
- paho-mqtt
|
||||||
@@ -59,3 +59,5 @@ dependencies:
|
|||||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||||
# ilab equipments
|
# ilab equipments
|
||||||
# - ros-humble-unilabos-msgs
|
# - ros-humble-unilabos-msgs
|
||||||
|
- pip:
|
||||||
|
- paho-mqtt
|
||||||
@@ -61,3 +61,5 @@ dependencies:
|
|||||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||||
# ilab equipments
|
# ilab equipments
|
||||||
# - ros-humble-unilabos-msgs
|
# - ros-humble-unilabos-msgs
|
||||||
|
- pip:
|
||||||
|
- paho-mqtt
|
||||||
@@ -58,4 +58,6 @@ dependencies:
|
|||||||
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64
|
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64
|
||||||
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
|
||||||
# ilab equipments
|
# ilab equipments
|
||||||
# - ros-humble-unilabos-msgs
|
# ros-humble-unilabos-msgs
|
||||||
|
- pip:
|
||||||
|
- paho-mqtt
|
||||||
@@ -7,7 +7,7 @@ from unilabos.utils import logger
|
|||||||
def start_backend(
|
def start_backend(
|
||||||
backend: str,
|
backend: str,
|
||||||
devices_config: dict = {},
|
devices_config: dict = {},
|
||||||
resources_config: dict = {},
|
resources_config: list = [],
|
||||||
graph=None,
|
graph=None,
|
||||||
controllers_config: dict = {},
|
controllers_config: dict = {},
|
||||||
bridges=[],
|
bridges=[],
|
||||||
|
|||||||
@@ -1,30 +1,24 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import json
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from copy import deepcopy
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import rclpy
|
|
||||||
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
|
||||||
|
|
||||||
# 首先添加项目根目录到路径
|
# 首先添加项目根目录到路径
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
ilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||||
if ilabos_dir not in sys.path:
|
if unilabos_dir not in sys.path:
|
||||||
sys.path.append(ilabos_dir)
|
sys.path.append(unilabos_dir)
|
||||||
|
|
||||||
from unilabos.config.config import load_config, BasicConfig, _update_config_from_env
|
from unilabos.config.config import load_config, BasicConfig, _update_config_from_env
|
||||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||||
from unilabos.device_mesh.resource_visalization import ResourceVisualization
|
from unilabos.device_mesh.resource_visalization import ResourceVisualization
|
||||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
|
||||||
from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager
|
|
||||||
from rclpy.executors import MultiThreadedExecutor
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
@@ -83,9 +77,9 @@ def parse_args():
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--visual",
|
"--visual",
|
||||||
choices=["rviz", "web","None"],
|
choices=["rviz", "web", "deck", "disable"],
|
||||||
default="rviz",
|
default="disable",
|
||||||
help="选择可视化工具: 'rviz' 或 'web' 或 'None',默认'rviz'",
|
help="选择可视化工具: rviz, web, deck(2D bird view)",
|
||||||
)
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@@ -137,7 +131,7 @@ def main():
|
|||||||
# 注册表
|
# 注册表
|
||||||
build_registry(args_dict["registry_path"])
|
build_registry(args_dict["registry_path"])
|
||||||
|
|
||||||
|
devices_and_resources = None
|
||||||
if args_dict["graph"] is not None:
|
if args_dict["graph"] is not None:
|
||||||
import unilabos.resources.graphio as graph_res
|
import unilabos.resources.graphio as graph_res
|
||||||
graph_res.physical_setup_graph = (
|
graph_res.physical_setup_graph = (
|
||||||
@@ -185,24 +179,22 @@ def main():
|
|||||||
signal.signal(signal.SIGTERM, _exit)
|
signal.signal(signal.SIGTERM, _exit)
|
||||||
mqtt_client.start()
|
mqtt_client.start()
|
||||||
args_dict["resources_mesh_config"] = {}
|
args_dict["resources_mesh_config"] = {}
|
||||||
|
# web visiualize 2D
|
||||||
if args_dict["visual"] != "None":
|
if args_dict["visual"] != "disable":
|
||||||
if args_dict["visual"] == "rviz":
|
enable_rviz = args_dict["visual"] == "rviz"
|
||||||
enable_rviz=True
|
if devices_and_resources is not None:
|
||||||
elif args_dict["visual"] == "web":
|
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
|
||||||
enable_rviz=False
|
args_dict["resources_mesh_config"] = resource_visualization.resource_model
|
||||||
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
|
start_backend(**args_dict)
|
||||||
|
server_thread = threading.Thread(target=start_server)
|
||||||
args_dict["resources_mesh_config"] = resource_visualization.resource_model
|
server_thread.start()
|
||||||
# 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
|
resource_visualization.start()
|
||||||
start_backend(**args_dict)
|
while True:
|
||||||
server_thread = threading.Thread(target=start_server)
|
time.sleep(1)
|
||||||
server_thread.start()
|
else:
|
||||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
start_backend(**args_dict)
|
||||||
resource_visualization.start()
|
start_server()
|
||||||
while True:
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
else:
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
start_server()
|
start_server()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import List, Dict, Any, Optional
|
|||||||
import requests
|
import requests
|
||||||
from unilabos.utils.log import info
|
from unilabos.utils.log import info
|
||||||
from unilabos.config.config import MQConfig, HTTPConfig
|
from unilabos.config.config import MQConfig, HTTPConfig
|
||||||
|
from unilabos.utils import logger
|
||||||
|
|
||||||
|
|
||||||
class HTTPClient:
|
class HTTPClient:
|
||||||
@@ -102,6 +103,30 @@ class HTTPClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def upload_file(self, file_path: str, scene: str = "models") -> requests.Response:
|
||||||
|
"""
|
||||||
|
上传文件到服务器
|
||||||
|
|
||||||
|
使用multipart/form-data格式上传文件,类似curl -F "files=@filepath"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 要上传的文件路径
|
||||||
|
scene: 上传场景,可选值为"user"或"models",默认为"models"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: API响应对象
|
||||||
|
"""
|
||||||
|
with open(file_path, "rb") as file:
|
||||||
|
files = {"files": file}
|
||||||
|
logger.info(f"上传文件: {file_path} 到 {scene}")
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||||
|
files=files,
|
||||||
|
headers={"Authorization": f"lab {self.auth}"},
|
||||||
|
timeout=30, # 上传文件可能需要更长的超时时间
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# 创建默认客户端实例
|
# 创建默认客户端实例
|
||||||
http_client = HTTPClient()
|
http_client = HTTPClient()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import traceback
|
|||||||
from typing import Dict, Any, Type, TypedDict, Optional
|
from typing import Dict, Any, Type, TypedDict, Optional
|
||||||
|
|
||||||
from rclpy.action import ActionClient, ActionServer
|
from rclpy.action import ActionClient, ActionServer
|
||||||
from rosidl_parser.definition import UnboundedSequence, NamespacedType, BasicType
|
from rosidl_parser.definition import UnboundedSequence, NamespacedType, BasicType, UnboundedString
|
||||||
|
|
||||||
from unilabos.ros.msgs.message_converter import msg_converter_manager
|
from unilabos.ros.msgs.message_converter import msg_converter_manager
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
@@ -74,7 +74,6 @@ def get_yaml_from_goal_type(goal_type) -> str:
|
|||||||
for ind, slot_info in enumerate(goal_type._fields_and_field_types.items()):
|
for ind, slot_info in enumerate(goal_type._fields_and_field_types.items()):
|
||||||
slot_name, slot_type = slot_info
|
slot_name, slot_type = slot_info
|
||||||
type_info = goal_type.SLOT_TYPES[ind]
|
type_info = goal_type.SLOT_TYPES[ind]
|
||||||
default_value = "unknown"
|
|
||||||
if isinstance(type_info, UnboundedSequence):
|
if isinstance(type_info, UnboundedSequence):
|
||||||
inner_type = type_info.value_type
|
inner_type = type_info.value_type
|
||||||
if isinstance(inner_type, NamespacedType):
|
if isinstance(inner_type, NamespacedType):
|
||||||
@@ -83,8 +82,10 @@ def get_yaml_from_goal_type(goal_type) -> str:
|
|||||||
default_value = [get_ros_msg_instance_as_dict(type_class())]
|
default_value = [get_ros_msg_instance_as_dict(type_class())]
|
||||||
elif isinstance(inner_type, BasicType):
|
elif isinstance(inner_type, BasicType):
|
||||||
default_value = [get_default_value_for_ros_type(inner_type.typename)]
|
default_value = [get_default_value_for_ros_type(inner_type.typename)]
|
||||||
|
elif isinstance(inner_type, UnboundedString):
|
||||||
|
default_value = [""]
|
||||||
else:
|
else:
|
||||||
default_value = "unknown"
|
default_value = []
|
||||||
elif isinstance(type_info, NamespacedType):
|
elif isinstance(type_info, NamespacedType):
|
||||||
cls_name = ".".join(type_info.namespaces) + ":" + type_info.name
|
cls_name = ".".join(type_info.namespaces) + ":" + type_info.name
|
||||||
type_class = msg_converter_manager.get_class(cls_name)
|
type_class = msg_converter_manager.get_class(cls_name)
|
||||||
@@ -93,6 +94,8 @@ def get_yaml_from_goal_type(goal_type) -> str:
|
|||||||
default_value = get_ros_msg_instance_as_dict(type_class())
|
default_value = get_ros_msg_instance_as_dict(type_class())
|
||||||
elif isinstance(type_info, BasicType):
|
elif isinstance(type_info, BasicType):
|
||||||
default_value = get_default_value_for_ros_type(type_info.typename)
|
default_value = get_default_value_for_ros_type(type_info.typename)
|
||||||
|
elif isinstance(type_info, UnboundedString):
|
||||||
|
default_value = ""
|
||||||
else:
|
else:
|
||||||
type_class = msg_converter_manager.search_class(slot_type, search_lower=True)
|
type_class = msg_converter_manager.search_class(slot_type, search_lower=True)
|
||||||
if type_class is not None:
|
if type_class is not None:
|
||||||
|
|||||||
304
unilabos/devices/laiyu_add_solid/laiyu.py
Normal file
304
unilabos/devices/laiyu_add_solid/laiyu.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import serial
|
||||||
|
import time
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
class Laiyu:
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, port, baudrate=115200, timeout=0.5):
|
||||||
|
"""
|
||||||
|
初始化串口参数,默认波特率115200,8位数据位、1位停止位、无校验
|
||||||
|
"""
|
||||||
|
self.ser = serial.Serial(port, baudrate=baudrate, timeout=timeout)
|
||||||
|
|
||||||
|
def calculate_crc(self, data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
计算Modbus CRC-16,返回低字节和高字节(little-endian)
|
||||||
|
"""
|
||||||
|
crc = 0xFFFF
|
||||||
|
for pos in data:
|
||||||
|
crc ^= pos
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 0x0001:
|
||||||
|
crc = (crc >> 1) ^ 0xA001
|
||||||
|
else:
|
||||||
|
crc >>= 1
|
||||||
|
return crc.to_bytes(2, byteorder='little')
|
||||||
|
|
||||||
|
def send_command(self, command: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
构造完整指令帧(加上CRC校验),发送指令后一直等待设备响应,直至响应结束或超时(最大3分钟)
|
||||||
|
"""
|
||||||
|
crc = self.calculate_crc(command)
|
||||||
|
full_command = command + crc
|
||||||
|
# 清空接收缓存
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self.ser.write(full_command)
|
||||||
|
print("发送指令:", full_command.hex().upper()) # 打印发送的指令帧
|
||||||
|
|
||||||
|
# 持续等待响应,直到连续0.5秒没有新数据或超时(3分钟)
|
||||||
|
start_time = time.time()
|
||||||
|
last_data_time = time.time()
|
||||||
|
response = bytearray()
|
||||||
|
while True:
|
||||||
|
if self.ser.in_waiting > 0:
|
||||||
|
new_data = self.ser.read(self.ser.in_waiting)
|
||||||
|
response.extend(new_data)
|
||||||
|
last_data_time = time.time()
|
||||||
|
# 如果已有数据,并且0.5秒内无新数据,则认为响应结束
|
||||||
|
if response and (time.time() - last_data_time) > 0.5:
|
||||||
|
break
|
||||||
|
# 超过最大等待时间,退出循环
|
||||||
|
if time.time() - start_time > 180:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
return bytes(response)
|
||||||
|
|
||||||
|
def pick_powder_tube(self, int_input: int) -> bytes:
|
||||||
|
"""
|
||||||
|
拿取粉筒指令:
|
||||||
|
- 功能码06
|
||||||
|
- 寄存器地址0x0037(取粉筒)
|
||||||
|
- 数据:粉筒编号(如1代表A,2代表B,以此类推)
|
||||||
|
示例:拿取A粉筒指令帧:01 06 00 37 00 01 + CRC
|
||||||
|
"""
|
||||||
|
slave_addr = 0x01
|
||||||
|
function_code = 0x06
|
||||||
|
register_addr = 0x0037
|
||||||
|
# 数据部分:粉筒编号转换为2字节大端
|
||||||
|
data = int_input.to_bytes(2, byteorder='big')
|
||||||
|
command = bytes([slave_addr, function_code]) + register_addr.to_bytes(2, byteorder='big') + data
|
||||||
|
return self.send_command(command)
|
||||||
|
|
||||||
|
def put_powder_tube(self, int_input: int) -> bytes:
|
||||||
|
"""
|
||||||
|
放回粉筒指令:
|
||||||
|
- 功能码06
|
||||||
|
- 寄存器地址0x0038(放回粉筒)
|
||||||
|
- 数据:粉筒编号
|
||||||
|
示例:放回A粉筒指令帧:01 06 00 38 00 01 + CRC
|
||||||
|
"""
|
||||||
|
slave_addr = 0x01
|
||||||
|
function_code = 0x06
|
||||||
|
register_addr = 0x0038
|
||||||
|
data = int_input.to_bytes(2, byteorder='big')
|
||||||
|
command = bytes([slave_addr, function_code]) + register_addr.to_bytes(2, byteorder='big') + data
|
||||||
|
return self.send_command(command)
|
||||||
|
|
||||||
|
def reset(self) -> bytes:
|
||||||
|
"""
|
||||||
|
重置指令:
|
||||||
|
- 功能码 0x06
|
||||||
|
- 寄存器地址 0x0042 (示例中用了 00 42)
|
||||||
|
- 数据 0x0001
|
||||||
|
示例发送:01 06 00 42 00 01 E8 1E
|
||||||
|
"""
|
||||||
|
slave_addr = 0x01
|
||||||
|
function_code = 0x06
|
||||||
|
register_addr = 0x0042 # 对应示例中的 00 42
|
||||||
|
payload = (0x0001).to_bytes(2, 'big') # 重置命令
|
||||||
|
|
||||||
|
cmd = (
|
||||||
|
bytes([slave_addr, function_code])
|
||||||
|
+ register_addr.to_bytes(2, 'big')
|
||||||
|
+ payload
|
||||||
|
)
|
||||||
|
return self.send_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def move_to_xyz(self, x: float, y: float, z: float) -> bytes:
|
||||||
|
"""
|
||||||
|
移动到指定位置指令:
|
||||||
|
- 功能码10(写多个寄存器)
|
||||||
|
- 寄存器起始地址0x0030
|
||||||
|
- 寄存器数量:3个(x,y,z)
|
||||||
|
- 字节计数:6
|
||||||
|
- 数据:x,y,z各2字节,单位为0.1mm(例如1mm对应数值10)
|
||||||
|
示例帧:01 10 00 30 00 03 06 00C8 02BC 02EE + CRC
|
||||||
|
"""
|
||||||
|
slave_addr = 0x01
|
||||||
|
function_code = 0x10
|
||||||
|
register_addr = 0x0030
|
||||||
|
num_registers = 3
|
||||||
|
byte_count = num_registers * 2 # 6字节
|
||||||
|
|
||||||
|
# 将mm转换为0.1mm单位(乘以10),转换为2字节大端表示
|
||||||
|
x_val = int(x * 10)
|
||||||
|
y_val = int(y * 10)
|
||||||
|
z_val = int(z * 10)
|
||||||
|
data = x_val.to_bytes(2, 'big') + y_val.to_bytes(2, 'big') + z_val.to_bytes(2, 'big')
|
||||||
|
|
||||||
|
command = (bytes([slave_addr, function_code]) +
|
||||||
|
register_addr.to_bytes(2, 'big') +
|
||||||
|
num_registers.to_bytes(2, 'big') +
|
||||||
|
byte_count.to_bytes(1, 'big') +
|
||||||
|
data)
|
||||||
|
return self.send_command(command)
|
||||||
|
|
||||||
|
def discharge(self, float_in: float) -> bytes:
|
||||||
|
"""
|
||||||
|
出料指令:
|
||||||
|
- 使用写多个寄存器命令(功能码 0x10)
|
||||||
|
- 寄存器起始地址设为 0x0039
|
||||||
|
- 寄存器数量为 0x0002(两个寄存器:出料质量和误差范围)
|
||||||
|
- 字节计数为 0x04(每个寄存器2字节,共4字节)
|
||||||
|
- 数据:出料质量(单位0.1mg,例如10mg对应100,即0x0064)、误差范围固定为0x0005
|
||||||
|
示例发送帧:01 10 00 39 0002 04 00640005 + CRC
|
||||||
|
"""
|
||||||
|
mass = float_in
|
||||||
|
slave_addr = 0x01
|
||||||
|
function_code = 0x10 # 修改为写多个寄存器的功能码
|
||||||
|
start_register = 0x0039 # 寄存器起始地址
|
||||||
|
quantity = 0x0002 # 寄存器数量
|
||||||
|
byte_count = 0x04 # 字节数:2寄存器*2字节=4
|
||||||
|
mass_val = int(mass * 10) # 质量转换,单位0.1mg
|
||||||
|
error_margin = 5 # 固定误差范围,0x0005
|
||||||
|
|
||||||
|
command = (bytes([slave_addr, function_code]) +
|
||||||
|
start_register.to_bytes(2, 'big') +
|
||||||
|
quantity.to_bytes(2, 'big') +
|
||||||
|
byte_count.to_bytes(1, 'big') +
|
||||||
|
mass_val.to_bytes(2, 'big') +
|
||||||
|
error_margin.to_bytes(2, 'big'))
|
||||||
|
return self.send_command(command)
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
示例:这个是标智96孔板的坐标转换,但是不同96孔板的坐标可能不同
|
||||||
|
所以需要根据实际情况进行修改
|
||||||
|
'''
|
||||||
|
|
||||||
|
def move_to_plate(self, string):
|
||||||
|
#只接受两位数的str,比如a1,a2,b1,b2
|
||||||
|
# 解析位置字符串
|
||||||
|
if len(string) != 2 and len(string) != 3:
|
||||||
|
raise ValueError("Invalid plate position")
|
||||||
|
if not string[0].isalpha() or not string[1:].isdigit():
|
||||||
|
raise ValueError("Invalid plate position")
|
||||||
|
a = string[0] # 字母部分s
|
||||||
|
b = string[1:] # 数字部分
|
||||||
|
|
||||||
|
if a.isalpha():
|
||||||
|
a = ord(a.lower()) - ord('a') + 1
|
||||||
|
else:
|
||||||
|
print('1')
|
||||||
|
raise ValueError("Invalid plate position")
|
||||||
|
a = int(a)
|
||||||
|
b = int(b)
|
||||||
|
# max a = 8, max b = 12, 否则报错
|
||||||
|
if a > 8 or b > 12:
|
||||||
|
print('2')
|
||||||
|
raise ValueError("Invalid plate position")
|
||||||
|
# 计算移动到指定位置的坐标
|
||||||
|
# a=1, x=3.0; a=12, x=220.0
|
||||||
|
# b=1, y=62.0; b=8, y=201.0
|
||||||
|
# z = 110.0
|
||||||
|
x = float((b-1) * (220-4.0)/11 + 4.0)
|
||||||
|
y = float((a-1) * (201.0-62.0)/7 + 62.0)
|
||||||
|
z = 110.0
|
||||||
|
# 移动到指定位置
|
||||||
|
resp_move = self.move_to_xyz(x, y, z)
|
||||||
|
print("移动位置响应:", resp_move.hex().upper())
|
||||||
|
# 打印移动到指定位置的坐标
|
||||||
|
print(f"移动到位置:{string},坐标:x={x:.2f}, y={y:.2f}, z={z:.2f}")
|
||||||
|
return resp_move
|
||||||
|
|
||||||
|
def add_powder_tube(self, powder_tube_number, target_tube_position, compound_mass):
|
||||||
|
# 拿取粉筒
|
||||||
|
resp_pick = self.pick_powder_tube(powder_tube_number)
|
||||||
|
print("拿取粉筒响应:", resp_pick.hex().upper())
|
||||||
|
time.sleep(1)
|
||||||
|
# 移动到指定位置
|
||||||
|
self.move_to_plate(target_tube_position)
|
||||||
|
time.sleep(1)
|
||||||
|
# 出料,设定质量
|
||||||
|
resp_discharge = self.discharge(compound_mass)
|
||||||
|
print("出料响应:", resp_discharge.hex().upper())
|
||||||
|
# 使用modbus协议读取实际出料质量
|
||||||
|
# 样例 01 06 00 40 00 64 89 F5,其中 00 64 是实际出料质量,换算为十进制为100,代表10 mg
|
||||||
|
# 从resp_discharge读取实际出料质量
|
||||||
|
# 提取字节4和字节5的两个字节
|
||||||
|
actual_mass_raw = int.from_bytes(resp_discharge[4:6], byteorder='big')
|
||||||
|
# 根据说明,将读取到的数据转换为实际出料质量(mg),这里除以10,例如:0x0064 = 100,转换后为10 mg
|
||||||
|
actual_mass_mg = actual_mass_raw / 10
|
||||||
|
print(f"孔位{target_tube_position},实际出料质量:{actual_mass_mg}mg")
|
||||||
|
time.sleep(1)
|
||||||
|
# 放回粉筒
|
||||||
|
resp_put = self.put_powder_tube(powder_tube_number)
|
||||||
|
print("放回粉筒响应:", resp_put.hex().upper())
|
||||||
|
print(f"放回粉筒{powder_tube_number}")
|
||||||
|
resp_reset = self.reset()
|
||||||
|
return actual_mass_mg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
样例:对单个粉筒进行称量
|
||||||
|
'''
|
||||||
|
|
||||||
|
modbus = Laiyu(port="COM25")
|
||||||
|
|
||||||
|
mass_test = modbus.add_powder_tube(1, 'h12', 6.0)
|
||||||
|
print(f"实际出料质量:{mass_test}mg")
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
样例: 对一份excel文件记录的化合物进行称量
|
||||||
|
'''
|
||||||
|
|
||||||
|
excel_file = r"C:\auto\laiyu\test1.xlsx"
|
||||||
|
# 定义输出文件路径,用于记录实际加样多少
|
||||||
|
output_file = r"C:\auto\laiyu\test_output.xlsx"
|
||||||
|
|
||||||
|
# 定义物料名称和料筒位置关系
|
||||||
|
compound_positions = {
|
||||||
|
'XPhos': '1',
|
||||||
|
'Cu(OTf)2': '2',
|
||||||
|
'CuSO4': '3',
|
||||||
|
'PPh3': '4',
|
||||||
|
}
|
||||||
|
|
||||||
|
# read excel file
|
||||||
|
# excel_file = r"C:\auto\laiyu\test.xlsx"
|
||||||
|
df = pd.read_excel(excel_file, sheet_name='Sheet1')
|
||||||
|
# 读取Excel文件中的数据
|
||||||
|
# 遍历每一行数据
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
# 获取物料名称和质量
|
||||||
|
copper_name = row['copper']
|
||||||
|
copper_mass = row['copper_mass']
|
||||||
|
ligand_name = row['ligand']
|
||||||
|
ligand_mass = row['ligand_mass']
|
||||||
|
target_tube_position = row['position']
|
||||||
|
# 获取物料位置 from compound_positions
|
||||||
|
copper_position = compound_positions.get(copper_name)
|
||||||
|
ligand_position = compound_positions.get(ligand_name)
|
||||||
|
# 判断物料位置是否存在
|
||||||
|
if copper_position is None:
|
||||||
|
print(f"物料位置不存在:{copper_name}")
|
||||||
|
continue
|
||||||
|
if ligand_position is None:
|
||||||
|
print(f"物料位置不存在:{ligand_name}")
|
||||||
|
continue
|
||||||
|
# 加铜
|
||||||
|
copper_actual_mass = modbus.add_powder_tube(int(copper_position), target_tube_position, copper_mass)
|
||||||
|
time.sleep(1)
|
||||||
|
# 加配体
|
||||||
|
ligand_actual_mass = modbus.add_powder_tube(int(ligand_position), target_tube_position, ligand_mass)
|
||||||
|
time.sleep(1)
|
||||||
|
# 保存至df
|
||||||
|
df.at[index, 'copper_actual_mass'] = copper_actual_mass
|
||||||
|
df.at[index, 'ligand_actual_mass'] = ligand_actual_mass
|
||||||
|
|
||||||
|
# 保存修改后的数据到新的Excel文件
|
||||||
|
df.to_excel(output_file, index=False)
|
||||||
|
print(f"已保存到文件:{output_file}")
|
||||||
|
|
||||||
|
# 关闭串口
|
||||||
|
modbus.ser.close()
|
||||||
|
print("串口已关闭")
|
||||||
|
|
||||||
355
unilabos/devices/liquid_handling/action_definition.py
Normal file
355
unilabos/devices/liquid_handling/action_definition.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Sequence, Optional, Literal, Union, Iterator
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pylabrobot.liquid_handling import LiquidHandler
|
||||||
|
from pylabrobot.resources import (
|
||||||
|
Resource,
|
||||||
|
TipRack,
|
||||||
|
Container,
|
||||||
|
Coordinate,
|
||||||
|
Well
|
||||||
|
)
|
||||||
|
|
||||||
|
class DPLiquidHandler(LiquidHandler):
|
||||||
|
"""Extended LiquidHandler with additional operations."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# REMOVE LIQUID --------------------------------------------------
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def remove_liquid(
|
||||||
|
self,
|
||||||
|
vols: List[float],
|
||||||
|
sources: Sequence[Container],
|
||||||
|
waste_liquid: Optional[Container] = None,
|
||||||
|
*,
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Optional[List[Coordinate]] = None,
|
||||||
|
liquid_height: Optional[List[Optional[float]]] = None,
|
||||||
|
blow_out_air_volume: Optional[List[Optional[float]]] = None,
|
||||||
|
spread: Optional[Literal["wide", "tight", "custom"]] = "wide",
|
||||||
|
delays: Optional[List[int]] = None,
|
||||||
|
is_96_well: Optional[bool] = False,
|
||||||
|
top: Optional[List(float)] = None,
|
||||||
|
none_keys: List[str] = []
|
||||||
|
):
|
||||||
|
"""A complete *remove* (aspirate → waste) operation."""
|
||||||
|
trash = self.deck.get_trash_area()
|
||||||
|
try:
|
||||||
|
if is_96_well:
|
||||||
|
pass # This mode is not verified
|
||||||
|
else:
|
||||||
|
if len(vols) != len(sources):
|
||||||
|
raise ValueError("Length of `vols` must match `sources`.")
|
||||||
|
|
||||||
|
for src, vol in zip(sources, vols):
|
||||||
|
self.move_to(src, dis_to_top=top[0] if top else 0)
|
||||||
|
tip = next(self.current_tip)
|
||||||
|
await self.pick_up_tips(tip)
|
||||||
|
await self.aspirate(
|
||||||
|
resources=[src],
|
||||||
|
vols=[vol],
|
||||||
|
use_channels=use_channels, # only aspirate96 used, default to None
|
||||||
|
flow_rates=[flow_rates[0]] if flow_rates else None,
|
||||||
|
offsets=[offsets[0]] if offsets else None,
|
||||||
|
liquid_height=[liquid_height[0]] if liquid_height else None,
|
||||||
|
blow_out_air_volume=blow_out_air_volume[0] if blow_out_air_volume else None,
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
await self.custom_delay(seconds=delays[0] if delays else 0)
|
||||||
|
await self.dispense(
|
||||||
|
resources=waste_liquid,
|
||||||
|
vols=[vol],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[flow_rates[1]] if flow_rates else None,
|
||||||
|
offsets=[offsets[1]] if offsets else None,
|
||||||
|
liquid_height=[liquid_height[1]] if liquid_height else None,
|
||||||
|
blow_out_air_volume=blow_out_air_volume[1] if blow_out_air_volume else None,
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
await self.discard_tips() # For now, each of tips is discarded after use
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Liquid removal failed: {e}") from e
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# ADD LIQUID -----------------------------------------------------
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def add_liquid(
|
||||||
|
self,
|
||||||
|
asp_vols: Union[List[float], float],
|
||||||
|
dis_vols: Union[List[float], float],
|
||||||
|
reagent_sources: Sequence[Container],
|
||||||
|
targets: Sequence[Container],
|
||||||
|
*,
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Optional[List[Coordinate]] = None,
|
||||||
|
liquid_height: Optional[List[Optional[float]]] = None,
|
||||||
|
blow_out_air_volume: Optional[List[Optional[float]]] = None,
|
||||||
|
spread: Optional[Literal["wide", "tight", "custom"]] = "wide",
|
||||||
|
is_96_well: bool = False,
|
||||||
|
delays: Optional[List[int]] = None,
|
||||||
|
mix_time: Optional[int] = None,
|
||||||
|
mix_vol: Optional[int] = None,
|
||||||
|
mix_rate: Optional[int] = None,
|
||||||
|
mix_liquid_height: Optional[float] = None,
|
||||||
|
none_keys: List[str] = []
|
||||||
|
):
|
||||||
|
"""A complete *add* (aspirate reagent → dispense into targets) operation."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_96_well:
|
||||||
|
pass # This mode is not verified.
|
||||||
|
else:
|
||||||
|
if len(asp_vols) != len(targets):
|
||||||
|
raise ValueError("Length of `vols` must match `targets`.")
|
||||||
|
tip = next(self.current_tip)
|
||||||
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
|
for _ in range(len(targets)):
|
||||||
|
await self.aspirate(
|
||||||
|
resources=reagent_sources,
|
||||||
|
vols=[asp_vols[_]],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[flow_rates[0]] if flow_rates else None,
|
||||||
|
offsets=[offsets[0]] if offsets else None,
|
||||||
|
liquid_height=[liquid_height[0]] if liquid_height else None,
|
||||||
|
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None,
|
||||||
|
spread=spread
|
||||||
|
)
|
||||||
|
if delays is not None:
|
||||||
|
await self.custom_delay(seconds=delays[0])
|
||||||
|
await self.dispense(
|
||||||
|
resources=[targets[_]],
|
||||||
|
vols=[dis_vols[_]],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[flow_rates[1]] if flow_rates else None,
|
||||||
|
offsets=[offsets[1]] if offsets else None,
|
||||||
|
blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None,
|
||||||
|
liquid_height=[liquid_height[1]] if liquid_height else None,
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
if delays is not None:
|
||||||
|
await self.custom_delay(seconds=delays[1])
|
||||||
|
await self.mix(
|
||||||
|
targets=targets[_],
|
||||||
|
mix_time=mix_time,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
offsets=offsets if offsets else None,
|
||||||
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
|
mix_rate=mix_rate if mix_rate else None)
|
||||||
|
if delays is not None:
|
||||||
|
await self.custom_delay(seconds=delays[1])
|
||||||
|
await self.touch_tip(targets[_])
|
||||||
|
await self.discard_tips()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Liquid addition failed: {e}") from e
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# TRANSFER LIQUID ------------------------------------------------
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
async def transfer_liquid(
|
||||||
|
self,
|
||||||
|
asp_vols: Union[List[float], float],
|
||||||
|
dis_vols: Union[List[float], float],
|
||||||
|
sources: Sequence[Container],
|
||||||
|
targets: Sequence[Container],
|
||||||
|
tip_racks: Sequence[TipRack],
|
||||||
|
*,
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
asp_flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
dis_flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Optional[List[Coordinate]] = None,
|
||||||
|
touch_tip: bool = False,
|
||||||
|
liquid_height: Optional[List[Optional[float]]] = None,
|
||||||
|
blow_out_air_volume: Optional[List[Optional[float]]] = None,
|
||||||
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
|
is_96_well: bool = False,
|
||||||
|
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
|
||||||
|
mix_times: Optional[List(int)] = None,
|
||||||
|
mix_vol: Optional[int] = None,
|
||||||
|
mix_rate: Optional[int] = None,
|
||||||
|
mix_liquid_height: Optional[float] = None,
|
||||||
|
delays: Optional[List[int]] = None,
|
||||||
|
none_keys: List[str] = []
|
||||||
|
):
|
||||||
|
"""Transfer liquid from each *source* well/plate to the corresponding *target*.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
asp_vols, dis_vols
|
||||||
|
Single volume (µL) or list matching the number of transfers.
|
||||||
|
sources, targets
|
||||||
|
Same‑length sequences of containers (wells or plates). In 96‑well mode
|
||||||
|
each must contain exactly one plate.
|
||||||
|
tip_racks
|
||||||
|
One or more TipRacks providing fresh tips.
|
||||||
|
is_96_well
|
||||||
|
Set *True* to use the 96‑channel head.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 96‑channel head mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if is_96_well:
|
||||||
|
pass # This mode is not verified
|
||||||
|
else:
|
||||||
|
if not (len(asp_vols) == len(sources) and len(dis_vols) == len(targets)):
|
||||||
|
raise ValueError("`sources`, `targets`, and `vols` must have the same length.")
|
||||||
|
|
||||||
|
tip_iter = self.iter_tips(tip_racks)
|
||||||
|
for src, tgt, asp_vol, asp_flow_rate, dis_vol, dis_flow_rate in (
|
||||||
|
zip(sources, targets, asp_vols, asp_flow_rates, dis_vols, dis_flow_rates)):
|
||||||
|
tip = next(tip_iter)
|
||||||
|
await self.pick_up_tips(tip)
|
||||||
|
# Aspirate from source
|
||||||
|
await self.aspirate(
|
||||||
|
resources=[src],
|
||||||
|
vols=[asp_vol],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[asp_flow_rate],
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
self.custom_delay(seconds=delays[0] if delays else 0)
|
||||||
|
# Dispense into target
|
||||||
|
await self.dispense(
|
||||||
|
resources=[tgt],
|
||||||
|
vols=[dis_vol],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[dis_flow_rate],
|
||||||
|
offsets=offsets,
|
||||||
|
liquid_height=liquid_height,
|
||||||
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
await self.mix(
|
||||||
|
targets=[tgt],
|
||||||
|
mix_time=mix_times[0] if mix_times else None,
|
||||||
|
mix_vol=mix_vol[0] if mix_vol else None,
|
||||||
|
mix_rate=mix_rate[0] if mix_rate else None,
|
||||||
|
)
|
||||||
|
if touch_tip:
|
||||||
|
await self.touch_tip(tgt)
|
||||||
|
await self.discard_tips()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Liquid transfer failed: {exc}") from exc
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Helper utilities
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def custom_delay(self, seconds=0, msg=None):
|
||||||
|
"""
|
||||||
|
seconds: seconds to wait
|
||||||
|
msg: information to be printed
|
||||||
|
"""
|
||||||
|
if seconds != None and seconds > 0:
|
||||||
|
if msg:
|
||||||
|
print(f"Waiting time: {msg}")
|
||||||
|
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||||
|
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
if msg:
|
||||||
|
print(f"Done: {msg}")
|
||||||
|
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
|
async def touch_tip(self,
|
||||||
|
targets: Sequence[Container],
|
||||||
|
):
|
||||||
|
"""Touch the tip to the side of the well."""
|
||||||
|
await self.aspirate(
|
||||||
|
resources=[targets],
|
||||||
|
vols=[0],
|
||||||
|
use_channels=None,
|
||||||
|
flow_rates=None,
|
||||||
|
offsets=[Coordinate(x=-targets.get_size_x()/2,y=0,z=0)],
|
||||||
|
liquid_height=None,
|
||||||
|
blow_out_air_volume=None
|
||||||
|
)
|
||||||
|
#await self.custom_delay(seconds=1) # In the simulation, we do not need to wait
|
||||||
|
await self.aspirate(
|
||||||
|
resources=[targets],
|
||||||
|
vols=[0],
|
||||||
|
use_channels=None,
|
||||||
|
flow_rates=None,
|
||||||
|
offsets=[Coordinate(x=targets.get_size_x()/2,y=0,z=0)],
|
||||||
|
liquid_height=None,
|
||||||
|
blow_out_air_volume=None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mix(
|
||||||
|
self,
|
||||||
|
targets: Sequence[Container],
|
||||||
|
mix_time: int = None,
|
||||||
|
mix_vol: Optional[int] = None,
|
||||||
|
height_to_bottom: Optional[float] = None,
|
||||||
|
offsets: Optional[Coordinate] = None,
|
||||||
|
mix_rate: Optional[float] = None,
|
||||||
|
none_keys: List[str] = []
|
||||||
|
):
|
||||||
|
if mix_time is None: # No mixing required
|
||||||
|
return
|
||||||
|
"""Mix the liquid in the target wells."""
|
||||||
|
for _ in range(mix_time):
|
||||||
|
await self.aspirate(
|
||||||
|
resources=[targets],
|
||||||
|
vols=[mix_vol],
|
||||||
|
flow_rates=[mix_rate] if mix_rate else None,
|
||||||
|
offsets=[offsets] if offsets else None,
|
||||||
|
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||||
|
)
|
||||||
|
await self.custom_delay(seconds=1)
|
||||||
|
await self.dispense(
|
||||||
|
resources=[targets],
|
||||||
|
vols=[mix_vol],
|
||||||
|
flow_rates=[mix_rate] if mix_rate else None,
|
||||||
|
offsets=[offsets] if offsets else None,
|
||||||
|
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||||
|
"""Yield tips from a list of TipRacks one-by-one until depleted."""
|
||||||
|
for rack in tip_racks:
|
||||||
|
for tip in rack:
|
||||||
|
yield tip
|
||||||
|
raise RuntimeError("Out of tips!")
|
||||||
|
|
||||||
|
def set_tiprack(self, tip_racks: Sequence[TipRack]):
|
||||||
|
"""Set the tip racks for the liquid handler."""
|
||||||
|
self.tip_racks = tip_racks
|
||||||
|
tip_iter = self.iter_tips(tip_racks)
|
||||||
|
self.current_tip = tip_iter
|
||||||
|
|
||||||
|
async def move_to(self, well: Well, dis_to_top: float = 0 , channel: int = 0):
|
||||||
|
"""
|
||||||
|
Move a single channel to a specific well with a given z-height.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
well : Well
|
||||||
|
The target well.
|
||||||
|
dis_to_top : float
|
||||||
|
Height in mm to move to relative to the well top.
|
||||||
|
channel : int
|
||||||
|
Pipetting channel to move (default: 0).
|
||||||
|
"""
|
||||||
|
await self.prepare_for_manual_channel_operation(channel=channel)
|
||||||
|
abs_loc = well.get_absolute_location()
|
||||||
|
well_height = well.get_absolute_size_z()
|
||||||
|
await self.move_channel_x(channel, abs_loc.x)
|
||||||
|
await self.move_channel_y(channel, abs_loc.y)
|
||||||
|
await self.move_channel_z(channel, abs_loc.z + well_height + dis_to_top)
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
15
unilabos/devices/zhida_hplc/possible_status.txt
Normal file
15
unilabos/devices/zhida_hplc/possible_status.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
(base) PS C:\Users\dell\Desktop> python zhida.py getstatus
|
||||||
|
{
|
||||||
|
"result": "RUN",
|
||||||
|
"message": "AcqTime:3.321049min Vial:1"
|
||||||
|
}
|
||||||
|
(base) PS C:\Users\dell\Desktop> python zhida.py getstatus
|
||||||
|
{
|
||||||
|
"result": "NOTREADY",
|
||||||
|
"message": "AcqTime:0min Vial:1"
|
||||||
|
}
|
||||||
|
(base) PS C:\Users\dell\Desktop> python zhida.py getstatus
|
||||||
|
{
|
||||||
|
"result": "PRERUN",
|
||||||
|
"message": "AcqTime:0min Vial:1"
|
||||||
|
}
|
||||||
112
unilabos/devices/zhida_hplc/zhida.py
Normal file
112
unilabos/devices/zhida_hplc/zhida.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class ZhidaClient:
|
||||||
|
def __init__(self, host='192.168.1.47', port=5792, timeout=10.0):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self.sock = None
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""建立 TCP 连接,并设置超时用于后续 recv/send。"""
|
||||||
|
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||||
|
# 确保后续 recv/send 都会在 timeout 秒后抛 socket.timeout
|
||||||
|
self.sock.settimeout(self.timeout)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭连接。"""
|
||||||
|
if self.sock:
|
||||||
|
self.sock.close()
|
||||||
|
self.sock = None
|
||||||
|
|
||||||
|
def _send_command(self, cmd: dict) -> dict:
|
||||||
|
"""
|
||||||
|
发送一条命令,接收 raw bytes,直到能成功 json.loads。
|
||||||
|
"""
|
||||||
|
if not self.sock:
|
||||||
|
raise ConnectionError("Not connected")
|
||||||
|
|
||||||
|
# 1) 发送 JSON 命令
|
||||||
|
payload = json.dumps(cmd, ensure_ascii=False).encode('utf-8')
|
||||||
|
# 如果服务端需要换行分隔,也可以加上: payload += b'\n'
|
||||||
|
self.sock.sendall(payload)
|
||||||
|
|
||||||
|
# 2) 循环 recv,直到能成功解析完整 JSON
|
||||||
|
buffer = bytearray()
|
||||||
|
start = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
# 对端关闭
|
||||||
|
break
|
||||||
|
buffer.extend(chunk)
|
||||||
|
# 尝试解码、解析
|
||||||
|
text = buffer.decode('utf-8', errors='strict')
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 继续 recv
|
||||||
|
pass
|
||||||
|
except socket.timeout:
|
||||||
|
raise TimeoutError("recv() timed out after {:.1f}s".format(self.timeout))
|
||||||
|
# 可选:防止死循环,整个循环时长超过 2×timeout 就报错
|
||||||
|
if time.time() - start > self.timeout * 2:
|
||||||
|
raise TimeoutError("No complete JSON received after {:.1f}s".format(time.time() - start))
|
||||||
|
|
||||||
|
raise ConnectionError("Connection closed before JSON could be parsed")
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def xxx() -> 类型:
|
||||||
|
# return xxxxxx
|
||||||
|
|
||||||
|
# def send_command(self, ):
|
||||||
|
# self.xxx = dict[xxx]
|
||||||
|
|
||||||
|
# 示例响应回复:
|
||||||
|
# {
|
||||||
|
# "result": "RUN",
|
||||||
|
# "message": "AcqTime:3.321049min Vial:1"
|
||||||
|
# }
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> dict:
|
||||||
|
return self._send_command({"command": "getstatus"})["result"]
|
||||||
|
|
||||||
|
# def get_status(self) -> dict:
|
||||||
|
# print(self._send_command({"command": "getstatus"}))
|
||||||
|
# return self._send_command({"command": "getstatus"})
|
||||||
|
|
||||||
|
def get_methods(self) -> dict:
|
||||||
|
return self._send_command({"command": "getmethods"})
|
||||||
|
|
||||||
|
def start(self, text) -> dict:
|
||||||
|
b64 = base64.b64encode(text.encode('utf-8')).decode('ascii')
|
||||||
|
return self._send_command({"command": "start", "message": b64})
|
||||||
|
|
||||||
|
def abort(self) -> dict:
|
||||||
|
return self._send_command({"command": "abort"})
|
||||||
|
|
||||||
|
"""
|
||||||
|
a,b,c
|
||||||
|
1,2,4
|
||||||
|
2,4,5
|
||||||
|
"""
|
||||||
|
|
||||||
|
client = ZhidaClient()
|
||||||
|
# 连接
|
||||||
|
client.connect()
|
||||||
|
# 获取状态
|
||||||
|
print(client.status)
|
||||||
|
|
||||||
|
|
||||||
|
# 命令格式:python zhida.py <subcommand> [options]
|
||||||
2
unilabos/devices/zhida_hplc/zhida_test_1.csv
Normal file
2
unilabos/devices/zhida_hplc/zhida_test_1.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
SampleName,AcqMethod,RackCode,VialPos,SmplInjVol,OutputFile
|
||||||
|
Sample001,1028-10ul-10min.M,CStk1-01,1,10,DataSET1
|
||||||
|
56
unilabos/registry/devices/laiyu_add_solid.yaml
Normal file
56
unilabos/registry/devices/laiyu_add_solid.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
laiyu_add_solid:
|
||||||
|
description: Laiyu Add Solid
|
||||||
|
class:
|
||||||
|
module: unilabos.devices.laiyu_add_solid.laiyu:Laiyu
|
||||||
|
type: python
|
||||||
|
status_types: {}
|
||||||
|
action_value_mappings:
|
||||||
|
move_to_xyz:
|
||||||
|
type: Point3DSeparateInput
|
||||||
|
goal:
|
||||||
|
x: x
|
||||||
|
y: y
|
||||||
|
z: z
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
pick_powder_tube:
|
||||||
|
type: IntSingleInput
|
||||||
|
goal:
|
||||||
|
int_input: int_input
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
put_powder_tube:
|
||||||
|
type: IntSingleInput
|
||||||
|
goal:
|
||||||
|
int_input: int_input
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
reset:
|
||||||
|
type: EmptyIn
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
add_powder_tube:
|
||||||
|
type: SolidDispenseAddPowderTube
|
||||||
|
goal:
|
||||||
|
powder_tube_number: powder_tube_number
|
||||||
|
target_tube_position: target_tube_position
|
||||||
|
compound_mass: compound_mass
|
||||||
|
feedback: {}
|
||||||
|
result:
|
||||||
|
actual_mass_mg: actual_mass_mg
|
||||||
|
move_to_plate:
|
||||||
|
type: StrSingleInput
|
||||||
|
goal:
|
||||||
|
string: string
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
discharge:
|
||||||
|
type: FloatSingleInput
|
||||||
|
goal:
|
||||||
|
float_input: float_input
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
|
||||||
|
schema:
|
||||||
|
properties: {}
|
||||||
@@ -163,13 +163,134 @@ liquid_handler:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
status:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: 液体处理仪器当前状态
|
description: 液体处理仪器当前状态
|
||||||
required:
|
required:
|
||||||
- status
|
- name
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|
||||||
|
dp_liquid_handler:
|
||||||
|
description: 通用液体处理
|
||||||
|
class:
|
||||||
|
module: unilabos.devices.liquid_handling.action_definition:DPLiquidHandler
|
||||||
|
type: python
|
||||||
|
status_types:
|
||||||
|
status: String
|
||||||
|
action_value_mappings:
|
||||||
|
remove_liquid:
|
||||||
|
type: DPLiquidHandlerRemoveLiquid
|
||||||
|
goal:
|
||||||
|
vols: vols
|
||||||
|
sources: sources
|
||||||
|
waste_liquid: waste_liquid
|
||||||
|
use_channels: use_channels
|
||||||
|
flow_rates: flow_rates
|
||||||
|
offsets: offsets
|
||||||
|
liquid_height: liquid_height
|
||||||
|
blow_out_air_volume: blow_out_air_volume
|
||||||
|
spread: spread
|
||||||
|
delays: delays
|
||||||
|
is_96_well: is_96_well
|
||||||
|
top: top
|
||||||
|
none_keys: none_keys
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
add_liquid:
|
||||||
|
type: DPLiquidHandlerAddLiquid
|
||||||
|
goal:
|
||||||
|
asp_vols: asp_vols
|
||||||
|
dis_vols: dis_vols
|
||||||
|
reagent_sources: reagent_sources
|
||||||
|
targets: targets
|
||||||
|
use_channels: use_channels
|
||||||
|
flow_rates: flow_rates
|
||||||
|
offsets: offsets
|
||||||
|
liquid_height: liquid_height
|
||||||
|
blow_out_air_volume: blow_out_air_volume
|
||||||
|
spread: spread
|
||||||
|
is_96_well: is_96_well
|
||||||
|
mix_time: mix_time
|
||||||
|
mix_vol: mix_vol
|
||||||
|
mix_rate: mix_rate
|
||||||
|
mix_liquid_height: mix_liquid_height
|
||||||
|
none_keys: none_keys
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
transfer_liquid:
|
||||||
|
type: DPLiquidHandlerTransferLiquid
|
||||||
|
goal:
|
||||||
|
asp_vols: asp_vols
|
||||||
|
dis_vols: dis_vols
|
||||||
|
sources: sources
|
||||||
|
targets: targets
|
||||||
|
tip_racks: tip_racks
|
||||||
|
use_channels: use_channels
|
||||||
|
asp_flow_rates: asp_flow_rates
|
||||||
|
dis_flow_rates: dis_flow_rates
|
||||||
|
offsets: offsets
|
||||||
|
touch_tip: touch_tip
|
||||||
|
liquid_height: liquid_height
|
||||||
|
blow_out_air_volume: blow_out_air_volume
|
||||||
|
spread: spread
|
||||||
|
is_96_well: is_96_well
|
||||||
|
mix_stage: mix_stage
|
||||||
|
mix_times: mix_times
|
||||||
|
mix_vol: mix_vol
|
||||||
|
mix_rate: mix_rate
|
||||||
|
mix_liquid_height: mix_liquid_height
|
||||||
|
delays: delays
|
||||||
|
none_keys: none_keys
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
custom_delay:
|
||||||
|
type: DPLiquidHandlerCustomDelay
|
||||||
|
goal:
|
||||||
|
seconds: seconds
|
||||||
|
msg: msg
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
touch_tip:
|
||||||
|
type: DPLiquidHandlerTouchTip
|
||||||
|
goal:
|
||||||
|
targets: targets
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
mix:
|
||||||
|
type: DPLiquidHandlerMix
|
||||||
|
goal:
|
||||||
|
targets: targets
|
||||||
|
mix_time: mix_time
|
||||||
|
mix_vol: mix_vol
|
||||||
|
height_to_bottom: height_to_bottom
|
||||||
|
offsets: offsets
|
||||||
|
mix_rate: mix_rate
|
||||||
|
none_keys: none_keys
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
set_tiprack:
|
||||||
|
type: DPLiquidHandlerSetTiprack
|
||||||
|
goal:
|
||||||
|
tip_racks: tip_racks
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
move_to:
|
||||||
|
type: DPLiquidHandlerMoveTo
|
||||||
|
goal:
|
||||||
|
well: well
|
||||||
|
dis_to_top: dis_to_top
|
||||||
|
channel: channel
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: 物料名
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
|
||||||
liquid_handler.revvity:
|
liquid_handler.revvity:
|
||||||
class:
|
class:
|
||||||
module: unilabos.devices.liquid_handling.revvity:Revvity
|
module: unilabos.devices.liquid_handling.revvity:Revvity
|
||||||
@@ -179,7 +300,7 @@ liquid_handler.revvity:
|
|||||||
action_value_mappings:
|
action_value_mappings:
|
||||||
run:
|
run:
|
||||||
type: WorkStationRun
|
type: WorkStationRun
|
||||||
goal:
|
goal:
|
||||||
wf_name: file_path
|
wf_name: file_path
|
||||||
params: params
|
params: params
|
||||||
resource: resource
|
resource: resource
|
||||||
@@ -187,4 +308,3 @@ liquid_handler.revvity:
|
|||||||
status: status
|
status: status
|
||||||
result:
|
result:
|
||||||
success: success
|
success: success
|
||||||
|
|
||||||
@@ -19,9 +19,6 @@ gripper.mock:
|
|||||||
result:
|
result:
|
||||||
position: position
|
position: position
|
||||||
effort: torque
|
effort: torque
|
||||||
model:
|
|
||||||
type: device
|
|
||||||
mesh: opentrons_liquid_handler
|
|
||||||
|
|
||||||
gripper.misumi_rz:
|
gripper.misumi_rz:
|
||||||
description: Misumi RZ gripper
|
description: Misumi RZ gripper
|
||||||
|
|||||||
27
unilabos/registry/devices/zhida_hplc.yaml
Normal file
27
unilabos/registry/devices/zhida_hplc.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
zhida_hplc:
|
||||||
|
description: Zhida HPLC
|
||||||
|
class:
|
||||||
|
module: unilabos.devices.zhida_hplc.zhida:ZhidaClient
|
||||||
|
type: python
|
||||||
|
status_types:
|
||||||
|
status: String
|
||||||
|
action_value_mappings:
|
||||||
|
start:
|
||||||
|
type: StrSingleInput
|
||||||
|
goal:
|
||||||
|
string: string
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
abort:
|
||||||
|
type: EmptyIn
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
get_methods:
|
||||||
|
type: EmptyIn
|
||||||
|
goal: {}
|
||||||
|
feedback: {}
|
||||||
|
result: {}
|
||||||
|
|
||||||
|
schema:
|
||||||
|
properties: {}
|
||||||
@@ -20,7 +20,43 @@ class Registry:
|
|||||||
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
|
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
|
||||||
if registry_paths:
|
if registry_paths:
|
||||||
self.registry_paths.extend(registry_paths)
|
self.registry_paths.extend(registry_paths)
|
||||||
self.device_type_registry = {}
|
action_type = self._replace_type_with_class(
|
||||||
|
"ResourceCreateFromOuter", "host_node", f"动作 add_resource_from_outer"
|
||||||
|
)
|
||||||
|
schema = ros_action_to_json_schema(action_type)
|
||||||
|
self.device_type_registry = {
|
||||||
|
"host_node": {
|
||||||
|
"description": "UniLabOS主机节点",
|
||||||
|
"class": {
|
||||||
|
"module": "unilabos.ros.nodes.presets.host_node",
|
||||||
|
"type": "python",
|
||||||
|
"status_types": {},
|
||||||
|
"action_value_mappings": {
|
||||||
|
"add_resource_from_outer": {
|
||||||
|
"type": msg_converter_manager.search_class("ResourceCreateFromOuter"),
|
||||||
|
"goal": {
|
||||||
|
"resources": "resources",
|
||||||
|
"device_ids": "device_ids",
|
||||||
|
"bind_parent_ids": "bind_parent_ids",
|
||||||
|
"bind_locations": "bind_locations",
|
||||||
|
"other_calling_params": "other_calling_params",
|
||||||
|
},
|
||||||
|
"feedback": {},
|
||||||
|
"result": {
|
||||||
|
"success": "success"
|
||||||
|
},
|
||||||
|
"schema": schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": False,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"file_path": "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
self.resource_type_registry = {}
|
self.resource_type_registry = {}
|
||||||
self._setup_called = False # 跟踪setup是否已调用
|
self._setup_called = False # 跟踪setup是否已调用
|
||||||
# 其他状态变量
|
# 其他状态变量
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import sys
|
import traceback
|
||||||
|
from unilabos.utils.log import logger
|
||||||
|
|
||||||
resource_schema = {
|
resource_schema = {
|
||||||
"workstation": {"type": "object", "properties": {}},
|
"workstation": {"type": "object", "properties": {}},
|
||||||
@@ -132,7 +132,8 @@ def add_schema(resources_config: list[dict]) -> list[dict]:
|
|||||||
try:
|
try:
|
||||||
if type(resource["children"][0]) == dict:
|
if type(resource["children"][0]) == dict:
|
||||||
resource["children"] = add_schema(resource["children"])
|
resource["children"] = add_schema(resource["children"])
|
||||||
except:
|
except Exception as ex:
|
||||||
sys.exit(0)
|
logger.error("添加物料schema时出错")
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
return resources_config
|
return resources_config
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ROS2DeviceNodeWrapper(ROS2DeviceNode):
|
|||||||
|
|
||||||
def ros2_device_node(
|
def ros2_device_node(
|
||||||
cls: Type[T],
|
cls: Type[T],
|
||||||
|
device_config: Optional[Dict[str, Any]] = None,
|
||||||
status_types: Optional[Dict[str, Any]] = None,
|
status_types: Optional[Dict[str, Any]] = None,
|
||||||
action_value_mappings: Optional[Dict[str, Any]] = None,
|
action_value_mappings: Optional[Dict[str, Any]] = None,
|
||||||
hardware_interface: Optional[Dict[str, Any]] = None,
|
hardware_interface: Optional[Dict[str, Any]] = None,
|
||||||
@@ -30,6 +31,7 @@ def ros2_device_node(
|
|||||||
cls: 要封装的设备类
|
cls: 要封装的设备类
|
||||||
status_types: 需要发布的状态和传感器信息,每个(PROP: TYPE),PROP应该匹配cls.PROP或cls.get_PROP(),
|
status_types: 需要发布的状态和传感器信息,每个(PROP: TYPE),PROP应该匹配cls.PROP或cls.get_PROP(),
|
||||||
TYPE应该是ROS2消息类型。默认为{}。
|
TYPE应该是ROS2消息类型。默认为{}。
|
||||||
|
device_config: 初始化时的config。
|
||||||
action_value_mappings: 设备动作。默认为{}。
|
action_value_mappings: 设备动作。默认为{}。
|
||||||
每个(ACTION: {'type': CMD_TYPE, 'goal': {FIELD: PROP}, 'feedback': {FIELD: PROP}, 'result': {FIELD: PROP}}),
|
每个(ACTION: {'type': CMD_TYPE, 'goal': {FIELD: PROP}, 'feedback': {FIELD: PROP}, 'result': {FIELD: PROP}}),
|
||||||
hardware_interface: 硬件接口配置。默认为{"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}。
|
hardware_interface: 硬件接口配置。默认为{"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}。
|
||||||
@@ -42,6 +44,8 @@ def ros2_device_node(
|
|||||||
# 从属性中自动发现可发布状态
|
# 从属性中自动发现可发布状态
|
||||||
if status_types is None:
|
if status_types is None:
|
||||||
status_types = {}
|
status_types = {}
|
||||||
|
if device_config is None:
|
||||||
|
device_config = {}
|
||||||
if action_value_mappings is None:
|
if action_value_mappings is None:
|
||||||
action_value_mappings = {}
|
action_value_mappings = {}
|
||||||
if hardware_interface is None:
|
if hardware_interface is None:
|
||||||
@@ -73,6 +77,7 @@ def ros2_device_node(
|
|||||||
"__init__": lambda self, *args, **kwargs: init_wrapper(
|
"__init__": lambda self, *args, **kwargs: init_wrapper(
|
||||||
self,
|
self,
|
||||||
driver_class=cls,
|
driver_class=cls,
|
||||||
|
device_config=device_config,
|
||||||
status_types=status_types,
|
status_types=status_types,
|
||||||
action_value_mappings=action_value_mappings,
|
action_value_mappings=action_value_mappings,
|
||||||
hardware_interface=hardware_interface,
|
hardware_interface=hardware_interface,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import rclpy
|
import copy
|
||||||
from rclpy.node import Node
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
|
|
||||||
from unilabos.ros.device_node_wrapper import ros2_device_node
|
from unilabos.ros.device_node_wrapper import ros2_device_node
|
||||||
|
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
from unilabos.utils.import_manager import default_manager
|
from unilabos.utils.import_manager import default_manager
|
||||||
|
|
||||||
@@ -22,17 +22,21 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device
|
|||||||
None
|
None
|
||||||
"""
|
"""
|
||||||
d = None
|
d = None
|
||||||
|
original_device_config = copy.deepcopy(device_config)
|
||||||
device_class_config = device_config["class"]
|
device_class_config = device_config["class"]
|
||||||
if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class
|
if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class
|
||||||
if device_class_config not in lab_registry.device_type_registry:
|
if device_class_config not in lab_registry.device_type_registry:
|
||||||
raise ValueError(f"Device class {device_class_config} not found.")
|
raise ValueError(f"Device class {device_class_config} not found.")
|
||||||
device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"]
|
device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"]
|
||||||
|
else:
|
||||||
|
raise ValueError("不再支持class为字典传入,class必须为注册表中已经提供的设备,您可以新增注册表并通过--registry传入")
|
||||||
if isinstance(device_class_config, dict):
|
if isinstance(device_class_config, dict):
|
||||||
DEVICE = default_manager.get_class(device_class_config["module"])
|
DEVICE = default_manager.get_class(device_class_config["module"])
|
||||||
# 不管是ros2的实例,还是python的,都必须包一次,除了HostNode
|
# 不管是ros2的实例,还是python的,都必须包一次,除了HostNode
|
||||||
DEVICE = ros2_device_node(
|
DEVICE = ros2_device_node(
|
||||||
DEVICE,
|
DEVICE,
|
||||||
status_types=device_class_config.get("status_types", {}),
|
status_types=device_class_config.get("status_types", {}),
|
||||||
|
device_config=original_device_config,
|
||||||
action_value_mappings=device_class_config.get("action_value_mappings", {}),
|
action_value_mappings=device_class_config.get("action_value_mappings", {}),
|
||||||
hardware_interface=device_class_config.get(
|
hardware_interface=device_class_config.get(
|
||||||
"hardware_interface",
|
"hardware_interface",
|
||||||
|
|||||||
@@ -44,18 +44,18 @@ def exit() -> None:
|
|||||||
|
|
||||||
def main(
|
def main(
|
||||||
devices_config: Dict[str, Any] = {},
|
devices_config: Dict[str, Any] = {},
|
||||||
resources_config={},
|
resources_config: list=[],
|
||||||
graph: Optional[Dict[str, Any]] = None,
|
graph: Optional[Dict[str, Any]] = None,
|
||||||
controllers_config: Dict[str, Any] = {},
|
controllers_config: Dict[str, Any] = {},
|
||||||
bridges: List[Any] = [],
|
bridges: List[Any] = [],
|
||||||
visual: str = "None",
|
visual: str = "disable",
|
||||||
resources_mesh_config: dict = {},
|
resources_mesh_config: dict = {},
|
||||||
args: List[str] = ["--log-level", "debug"],
|
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||||
discovery_interval: float = 5.0,
|
discovery_interval: float = 5.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
|
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=rclpy_init_args)
|
||||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||||
# 创建主机节点
|
# 创建主机节点
|
||||||
host_node = HostNode(
|
host_node = HostNode(
|
||||||
@@ -68,21 +68,21 @@ def main(
|
|||||||
discovery_interval,
|
discovery_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
if visual != "None":
|
if visual != "disable":
|
||||||
resource_mesh_manager = ResourceMeshManager(
|
resource_mesh_manager = ResourceMeshManager(
|
||||||
resources_mesh_config,
|
resources_mesh_config,
|
||||||
resources_config,
|
resources_config,
|
||||||
resource_tracker= host_node.resource_tracker,
|
resource_tracker= DeviceNodeResourceTracker(),
|
||||||
device_id = 'resource_mesh_manager',
|
device_id = 'resource_mesh_manager',
|
||||||
)
|
)
|
||||||
joint_republisher = JointRepublisher(
|
joint_republisher = JointRepublisher(
|
||||||
'joint_republisher',
|
'joint_republisher',
|
||||||
host_node.resource_tracker
|
DeviceNodeResourceTracker()
|
||||||
)
|
)
|
||||||
|
|
||||||
executor.add_node(resource_mesh_manager)
|
executor.add_node(resource_mesh_manager)
|
||||||
executor.add_node(joint_republisher)
|
executor.add_node(joint_republisher)
|
||||||
|
|
||||||
thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread")
|
thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread")
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
@@ -96,13 +96,13 @@ def slave(
|
|||||||
graph: Optional[Dict[str, Any]] = None,
|
graph: Optional[Dict[str, Any]] = None,
|
||||||
controllers_config: Dict[str, Any] = {},
|
controllers_config: Dict[str, Any] = {},
|
||||||
bridges: List[Any] = [],
|
bridges: List[Any] = [],
|
||||||
visual: str = "None",
|
visual: str = "disable",
|
||||||
resources_mesh_config: dict = {},
|
resources_mesh_config: dict = {},
|
||||||
args: List[str] = ["--log-level", "debug"],
|
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""从节点函数"""
|
"""从节点函数"""
|
||||||
if not rclpy.ok():
|
if not rclpy.ok():
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=rclpy_init_args)
|
||||||
executor = rclpy.__executor
|
executor = rclpy.__executor
|
||||||
if not executor:
|
if not executor:
|
||||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||||
@@ -153,7 +153,7 @@ def slave(
|
|||||||
logger.info(f"Slave node info updated.")
|
logger.info(f"Slave node info updated.")
|
||||||
|
|
||||||
rclient = n.create_client(ResourceAdd, "/resources/add")
|
rclient = n.create_client(ResourceAdd, "/resources/add")
|
||||||
rclient.wait_for_service() # FIXME 可能一直等待,加一个参数
|
rclient.wait_for_service()
|
||||||
|
|
||||||
request = ResourceAdd.Request()
|
request = ResourceAdd.Request()
|
||||||
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config]
|
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config]
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ basic_type_map = {
|
|||||||
'float32': {'type': 'number'},
|
'float32': {'type': 'number'},
|
||||||
'float64': {'type': 'number'},
|
'float64': {'type': 'number'},
|
||||||
'string': {'type': 'string'},
|
'string': {'type': 'string'},
|
||||||
|
'boolean': {'type': 'boolean'},
|
||||||
'char': {'type': 'string', 'maxLength': 1},
|
'char': {'type': 'string', 'maxLength': 1},
|
||||||
'byte': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
'byte': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ from rclpy.action.server import ServerGoalHandle
|
|||||||
from rclpy.client import Client
|
from rclpy.client import Client
|
||||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||||
from rclpy.service import Service
|
from rclpy.service import Service
|
||||||
|
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||||
|
|
||||||
from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type
|
from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr
|
||||||
from unilabos.ros.msgs.message_converter import (
|
from unilabos.ros.msgs.message_converter import (
|
||||||
convert_to_ros_msg,
|
convert_to_ros_msg,
|
||||||
convert_from_ros_msg,
|
convert_from_ros_msg,
|
||||||
@@ -101,6 +102,7 @@ def init_wrapper(
|
|||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
driver_class: type[T],
|
driver_class: type[T],
|
||||||
|
device_config: Dict[str, Any],
|
||||||
status_types: Dict[str, Any],
|
status_types: Dict[str, Any],
|
||||||
action_value_mappings: Dict[str, Any],
|
action_value_mappings: Dict[str, Any],
|
||||||
hardware_interface: Dict[str, Any],
|
hardware_interface: Dict[str, Any],
|
||||||
@@ -118,6 +120,7 @@ def init_wrapper(
|
|||||||
children = []
|
children = []
|
||||||
kwargs["device_id"] = device_id
|
kwargs["device_id"] = device_id
|
||||||
kwargs["driver_class"] = driver_class
|
kwargs["driver_class"] = driver_class
|
||||||
|
kwargs["device_config"] = device_config
|
||||||
kwargs["driver_params"] = driver_params
|
kwargs["driver_params"] = driver_params
|
||||||
kwargs["status_types"] = status_types
|
kwargs["status_types"] = status_types
|
||||||
kwargs["action_value_mappings"] = action_value_mappings
|
kwargs["action_value_mappings"] = action_value_mappings
|
||||||
@@ -302,10 +305,52 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
res.response = ""
|
res.response = ""
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
||||||
|
# 物料传输到对应的node节点
|
||||||
|
rclient = self.create_client(ResourceAdd, "/resources/add")
|
||||||
|
rclient.wait_for_service()
|
||||||
|
request = ResourceAdd.Request()
|
||||||
|
command_json = json.loads(req.command)
|
||||||
|
namespace = command_json["namespace"]
|
||||||
|
bind_parent_id = command_json["bind_parent_id"]
|
||||||
|
edge_device_id = command_json["edge_device_id"]
|
||||||
|
location = command_json["bind_location"]
|
||||||
|
other_calling_param = command_json["other_calling_param"]
|
||||||
|
resources = command_json["resource"]
|
||||||
|
# 本地拿到这个物料,可能需要先做初始化?
|
||||||
|
if isinstance(resources, list):
|
||||||
|
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
|
||||||
|
else:
|
||||||
|
request.resources = [convert_to_ros_msg(Resource, resources)]
|
||||||
|
response = rclient.call(request)
|
||||||
|
# 应该本地先add_resource?
|
||||||
|
res.response = "OK"
|
||||||
|
# 接下来该根据bind_parent_id进行assign了,目前只有plr可以进行assign,不然没有办法输入到物料系统中
|
||||||
|
resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
|
||||||
|
try:
|
||||||
|
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||||
|
from pylabrobot.resources.deck import Deck
|
||||||
|
from pylabrobot.resources import Coordinate
|
||||||
|
contain_model = not isinstance(resource, Deck)
|
||||||
|
if isinstance(resource, ResourcePLR):
|
||||||
|
# resources.list()
|
||||||
|
plr_instance = resource_ulab_to_plr(resources, contain_model)
|
||||||
|
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param)
|
||||||
|
except ImportError:
|
||||||
|
self.lab_logger().error("Host请求添加物料时,本环境并不存在pylabrobot")
|
||||||
|
except Exception as e:
|
||||||
|
self.lab_logger().error("Host请求添加物料时出错")
|
||||||
|
self.lab_logger().error(traceback.format_exc())
|
||||||
|
return res
|
||||||
|
|
||||||
|
# noinspection PyTypeChecker
|
||||||
self._service_server: Dict[str, Service] = {
|
self._service_server: Dict[str, Service] = {
|
||||||
"query_host_name": self.create_service(
|
"query_host_name": self.create_service(
|
||||||
SerialCommand, f"/srv{self.namespace}/query_host_name", query_host_name_cb, callback_group=self.callback_group
|
SerialCommand, f"/srv{self.namespace}/query_host_name", query_host_name_cb, callback_group=self.callback_group
|
||||||
),
|
),
|
||||||
|
"append_resource": self.create_service(
|
||||||
|
SerialCommand, f"/srv{self.namespace}/append_resource", append_resource, callback_group=self.callback_group
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 向全局在线设备注册表添加设备信息
|
# 向全局在线设备注册表添加设备信息
|
||||||
@@ -437,26 +482,26 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
|
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
|
||||||
self.lab_logger().debug(f"接收到原始目标: {action_kwargs}")
|
self.lab_logger().debug(f"接收到原始目标: {action_kwargs}")
|
||||||
|
# 向Host查询物料当前状态,如果是host本身的增加物料的请求,则直接跳过
|
||||||
# 向Host查询物料当前状态
|
if action_name != "add_resource_from_outer":
|
||||||
for k, v in goal.get_fields_and_field_types().items():
|
for k, v in goal.get_fields_and_field_types().items():
|
||||||
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||||
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
|
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
|
||||||
try:
|
try:
|
||||||
r = ResourceGet.Request()
|
r = ResourceGet.Request()
|
||||||
r.id = action_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else action_kwargs[k][0]["id"]
|
r.id = action_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else action_kwargs[k][0]["id"]
|
||||||
r.with_children = True
|
r.with_children = True
|
||||||
response = await self._resource_clients["resource_get"].call_async(r)
|
response = await self._resource_clients["resource_get"].call_async(r)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error(f"资源查询失败,默认使用本地资源")
|
logger.error(f"资源查询失败,默认使用本地资源")
|
||||||
# 删除对response.resources的检查,因为它总是存在
|
# 删除对response.resources的检查,因为它总是存在
|
||||||
resources_list = [convert_from_ros_msg(rs) for rs in response.resources] # type: ignore # FIXME
|
resources_list = [convert_from_ros_msg(rs) for rs in response.resources] # type: ignore # FIXME
|
||||||
self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源")
|
self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源")
|
||||||
type_hint = action_paramtypes[k]
|
type_hint = action_paramtypes[k]
|
||||||
final_type = get_type_class(type_hint)
|
final_type = get_type_class(type_hint)
|
||||||
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource,并做转换
|
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource,并做转换
|
||||||
final_resource = convert_resources_to_type(resources_list, final_type)
|
final_resource = convert_resources_to_type(resources_list, final_type)
|
||||||
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource)
|
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource)
|
||||||
|
|
||||||
self.lab_logger().info(f"准备执行: {action_kwargs}, 函数: {ACTION.__name__}")
|
self.lab_logger().info(f"准备执行: {action_kwargs}, 函数: {ACTION.__name__}")
|
||||||
time_start = time.time()
|
time_start = time.time()
|
||||||
@@ -527,27 +572,28 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
del future
|
del future
|
||||||
|
|
||||||
# 向Host更新物料当前状态
|
# 向Host更新物料当前状态
|
||||||
for k, v in goal.get_fields_and_field_types().items():
|
if action_name != "add_resource_from_outer":
|
||||||
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
for k, v in goal.get_fields_and_field_types().items():
|
||||||
continue
|
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||||
self.lab_logger().info(f"更新资源状态: {k}")
|
continue
|
||||||
r = ResourceUpdate.Request()
|
self.lab_logger().info(f"更新资源状态: {k}")
|
||||||
# 仅当action_kwargs[k]不为None时尝试转换
|
r = ResourceUpdate.Request()
|
||||||
akv = action_kwargs[k]
|
# 仅当action_kwargs[k]不为None时尝试转换
|
||||||
apv = action_paramtypes[k]
|
akv = action_kwargs[k]
|
||||||
final_type = get_type_class(apv)
|
apv = action_paramtypes[k]
|
||||||
if final_type is None:
|
final_type = get_type_class(apv)
|
||||||
continue
|
if final_type is None:
|
||||||
try:
|
continue
|
||||||
r.resources = [
|
try:
|
||||||
convert_to_ros_msg(Resource, self.resource_tracker.root_resource(rs))
|
r.resources = [
|
||||||
for rs in convert_resources_from_type(akv, final_type) # type: ignore # FIXME # 考虑反查到最大的
|
convert_to_ros_msg(Resource, self.resource_tracker.root_resource(rs))
|
||||||
]
|
for rs in convert_resources_from_type(akv, final_type) # type: ignore # FIXME # 考虑反查到最大的
|
||||||
response = await self._resource_clients["resource_update"].call_async(r)
|
]
|
||||||
self.lab_logger().debug(f"资源更新结果: {response}")
|
response = await self._resource_clients["resource_update"].call_async(r)
|
||||||
except Exception as e:
|
self.lab_logger().debug(f"资源更新结果: {response}")
|
||||||
self.lab_logger().error(f"资源更新失败: {e}")
|
except Exception as e:
|
||||||
self.lab_logger().error(traceback.format_exc())
|
self.lab_logger().error(f"资源更新失败: {e}")
|
||||||
|
self.lab_logger().error(traceback.format_exc())
|
||||||
|
|
||||||
# 发布结果
|
# 发布结果
|
||||||
goal_handle.succeed()
|
goal_handle.succeed()
|
||||||
@@ -627,6 +673,7 @@ class ROS2DeviceNode:
|
|||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
driver_class: Type[T],
|
driver_class: Type[T],
|
||||||
|
device_config: Dict[str, Any],
|
||||||
driver_params: Dict[str, Any],
|
driver_params: Dict[str, Any],
|
||||||
status_types: Dict[str, Any],
|
status_types: Dict[str, Any],
|
||||||
action_value_mappings: Dict[str, Any],
|
action_value_mappings: Dict[str, Any],
|
||||||
@@ -641,6 +688,8 @@ class ROS2DeviceNode:
|
|||||||
Args:
|
Args:
|
||||||
device_id: 设备标识符
|
device_id: 设备标识符
|
||||||
driver_class: 设备类
|
driver_class: 设备类
|
||||||
|
device_config: 原始初始化的json
|
||||||
|
driver_params: driver初始化的参数
|
||||||
status_types: 状态类型映射
|
status_types: 状态类型映射
|
||||||
action_value_mappings: 动作值映射
|
action_value_mappings: 动作值映射
|
||||||
hardware_interface: 硬件接口配置
|
hardware_interface: 硬件接口配置
|
||||||
@@ -657,11 +706,12 @@ class ROS2DeviceNode:
|
|||||||
# 保存设备类是否支持异步上下文
|
# 保存设备类是否支持异步上下文
|
||||||
self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__")
|
self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__")
|
||||||
self._driver_class = driver_class
|
self._driver_class = driver_class
|
||||||
|
self.device_config = device_config
|
||||||
self.driver_is_ros = driver_is_ros
|
self.driver_is_ros = driver_is_ros
|
||||||
self.resource_tracker = DeviceNodeResourceTracker()
|
self.resource_tracker = DeviceNodeResourceTracker()
|
||||||
|
|
||||||
# use_pylabrobot_creator 使用 cls的包路径检测
|
# use_pylabrobot_creator 使用 cls的包路径检测
|
||||||
use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot")
|
use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "DPLiquidHandler"
|
||||||
|
|
||||||
# TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建
|
# TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建
|
||||||
# 创建设备类实例
|
# 创建设备类实例
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import uuid
|
|||||||
from typing import Optional, Dict, Any, List, ClassVar, Set
|
from typing import Optional, Dict, Any, List, ClassVar, Set
|
||||||
|
|
||||||
from action_msgs.msg import GoalStatus
|
from action_msgs.msg import GoalStatus
|
||||||
from unilabos_msgs.msg import Resource # type: ignore
|
from geometry_msgs.msg import Point
|
||||||
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, SerialCommand # type: ignore
|
|
||||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||||
from rclpy.service import Service
|
from rclpy.service import Service
|
||||||
|
from unilabos_msgs.msg import Resource # type: ignore
|
||||||
|
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \
|
||||||
|
SerialCommand # type: ignore
|
||||||
from unique_identifier_msgs.msg import UUID
|
from unique_identifier_msgs.msg import UUID
|
||||||
|
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
@@ -23,11 +25,9 @@ from unilabos.ros.msgs.message_converter import (
|
|||||||
convert_from_ros_msg,
|
convert_from_ros_msg,
|
||||||
convert_to_ros_msg,
|
convert_to_ros_msg,
|
||||||
msg_converter_manager,
|
msg_converter_manager,
|
||||||
ros_action_to_json_schema,
|
|
||||||
)
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||||
from unilabos.utils.type_check import TypeEncoder
|
|
||||||
|
|
||||||
|
|
||||||
class HostNode(BaseROS2DeviceNode):
|
class HostNode(BaseROS2DeviceNode):
|
||||||
@@ -50,7 +50,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
devices_config: Dict[str, Any],
|
devices_config: Dict[str, Any],
|
||||||
resources_config: Any,
|
resources_config: list,
|
||||||
physical_setup_graph: Optional[Dict[str, Any]] = None,
|
physical_setup_graph: Optional[Dict[str, Any]] = None,
|
||||||
controllers_config: Optional[Dict[str, Any]] = None,
|
controllers_config: Optional[Dict[str, Any]] = None,
|
||||||
bridges: Optional[List[Any]] = None,
|
bridges: Optional[List[Any]] = None,
|
||||||
@@ -76,7 +76,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
|
||||||
hardware_interface={},
|
hardware_interface={},
|
||||||
print_publish=False,
|
print_publish=False,
|
||||||
resource_tracker=DeviceNodeResourceTracker(), # host node并不是通过initialize 包一层传进来的
|
resource_tracker=DeviceNodeResourceTracker(), # host node并不是通过initialize 包一层传进来的
|
||||||
@@ -97,15 +97,13 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self.bridges = bridges
|
self.bridges = bridges
|
||||||
|
|
||||||
# 创建设备、动作客户端和目标存储
|
# 创建设备、动作客户端和目标存储
|
||||||
self.devices_names: Dict[str, str] = {} # 存储设备名称和命名空间的映射
|
self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射
|
||||||
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
|
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
|
||||||
self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射
|
self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射
|
||||||
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例
|
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例
|
||||||
self._action_value_mappings: Dict[str, Dict] = (
|
self._action_value_mappings: Dict[str, Dict] = {} # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
|
||||||
{}
|
|
||||||
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
|
|
||||||
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
||||||
self._online_devices: Set[str] = set() # 用于跟踪在线设备
|
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
|
||||||
self._last_discovery_time = 0.0 # 上次设备发现的时间
|
self._last_discovery_time = 0.0 # 上次设备发现的时间
|
||||||
self._discovery_lock = threading.Lock() # 设备发现的互斥锁
|
self._discovery_lock = threading.Lock() # 设备发现的互斥锁
|
||||||
self._subscribed_topics = set() # 用于跟踪已订阅的话题
|
self._subscribed_topics = set() # 用于跟踪已订阅的话题
|
||||||
@@ -259,16 +257,41 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}")
|
self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}")
|
||||||
action_name = action_id[len(namespace) + 1:]
|
action_name = action_id[len(namespace) + 1:]
|
||||||
edge_device_id = namespace[9:]
|
edge_device_id = namespace[9:]
|
||||||
from unilabos.app.mq import mqtt_client
|
# from unilabos.app.mq import mqtt_client
|
||||||
info_with_schema = ros_action_to_json_schema(action_type)
|
# info_with_schema = ros_action_to_json_schema(action_type)
|
||||||
mqtt_client.publish_actions(action_name, {
|
# mqtt_client.publish_actions(action_name, {
|
||||||
"device_id": edge_device_id,
|
# "device_id": edge_device_id,
|
||||||
"action_name": action_name,
|
# "device_type": "",
|
||||||
"schema": info_with_schema,
|
# "action_name": action_name,
|
||||||
})
|
# "schema": info_with_schema,
|
||||||
|
# })
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}")
|
self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}")
|
||||||
|
|
||||||
|
def add_resource_from_outer(self, resources: list["Resource"], device_ids: list[str], bind_parent_ids: list[str], bind_locations: list[Point], other_calling_params: list[str]):
|
||||||
|
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(resources, device_ids, bind_parent_ids, bind_locations, other_calling_params):
|
||||||
|
# 这里要求device_id传入必须是edge_device_id
|
||||||
|
namespace = "/devices/" + device_id
|
||||||
|
srv_address = f"/srv{namespace}/append_resource"
|
||||||
|
sclient = self.create_client(SerialCommand, srv_address)
|
||||||
|
sclient.wait_for_service()
|
||||||
|
request = SerialCommand.Request()
|
||||||
|
request.command = json.dumps({
|
||||||
|
"resource": resource,
|
||||||
|
"namespace": namespace,
|
||||||
|
"edge_device_id": device_id,
|
||||||
|
"bind_parent_id": bind_parent_id,
|
||||||
|
"bind_location": {
|
||||||
|
"x": bind_location.x,
|
||||||
|
"y": bind_location.y,
|
||||||
|
"z": bind_location.z,
|
||||||
|
},
|
||||||
|
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {},
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
response = sclient.call(request)
|
||||||
|
pass
|
||||||
|
pass
|
||||||
|
|
||||||
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
|
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
根据配置初始化设备,
|
根据配置初始化设备,
|
||||||
@@ -297,13 +320,14 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
action_type = action_value_mapping["type"]
|
action_type = action_value_mapping["type"]
|
||||||
self._action_clients[action_id] = ActionClient(self, action_type, action_id)
|
self._action_clients[action_id] = ActionClient(self, action_type, action_id)
|
||||||
self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的
|
self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的
|
||||||
from unilabos.app.mq import mqtt_client
|
# from unilabos.app.mq import mqtt_client
|
||||||
info_with_schema = ros_action_to_json_schema(action_type)
|
# info_with_schema = ros_action_to_json_schema(action_type)
|
||||||
mqtt_client.publish_actions(action_name, {
|
# mqtt_client.publish_actions(action_name, {
|
||||||
"device_id": device_id,
|
# "device_id": device_id,
|
||||||
"action_name": action_name,
|
# "device_type": device_config["class"],
|
||||||
"schema": info_with_schema,
|
# "action_name": action_name,
|
||||||
})
|
# "schema": info_with_schema,
|
||||||
|
# })
|
||||||
else:
|
else:
|
||||||
self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.")
|
self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.")
|
||||||
device_key = f"{self.devices_names[device_id]}/{device_id}" # 这里不涉及二级device_id
|
device_key = f"{self.devices_names[device_id]}/{device_id}" # 这里不涉及二级device_id
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from unilabos.utils.log import logger
|
from unilabos.utils.log import logger
|
||||||
|
|
||||||
|
|
||||||
class DeviceNodeResourceTracker:
|
class DeviceNodeResourceTracker(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.resources = []
|
self.resources = []
|
||||||
@@ -15,44 +15,46 @@ class DeviceNodeResourceTracker:
|
|||||||
return resource
|
return resource
|
||||||
|
|
||||||
def add_resource(self, resource):
|
def add_resource(self, resource):
|
||||||
# 使用内存地址跟踪是否为同一个resource
|
|
||||||
for r in self.resources:
|
for r in self.resources:
|
||||||
if id(r) == id(resource):
|
if id(r) == id(resource):
|
||||||
return
|
return
|
||||||
# 添加资源到跟踪器
|
|
||||||
self.resources.append(resource)
|
self.resources.append(resource)
|
||||||
|
|
||||||
def clear_resource(self):
|
def clear_resource(self):
|
||||||
self.resources = []
|
self.resources = []
|
||||||
|
|
||||||
def figure_resource(self, resource):
|
def figure_resource(self, query_resource):
|
||||||
# 使用内存地址跟踪是否为同一个resource
|
if isinstance(query_resource, list):
|
||||||
if isinstance(resource, list):
|
return [self.figure_resource(r) for r in query_resource]
|
||||||
return [self.figure_resource(r) for r in resource]
|
res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) else None)
|
||||||
res_id = resource.id if hasattr(resource, "id") else None
|
res_name = query_resource.name if hasattr(query_resource, "name") else (query_resource.get("name") if isinstance(query_resource, dict) else None)
|
||||||
res_name = resource.name if hasattr(resource, "name") else None
|
|
||||||
res_identifier = res_id if res_id else res_name
|
res_identifier = res_id if res_id else res_name
|
||||||
identifier_key = "id" if res_id else "name"
|
identifier_key = "id" if res_id else "name"
|
||||||
resource_cls_type = type(resource)
|
resource_cls_type = type(query_resource)
|
||||||
if res_identifier is None:
|
if res_identifier is None:
|
||||||
logger.warning(f"resource {resource} 没有id或name,暂不能对应figure")
|
logger.warning(f"resource {query_resource} 没有id或name,暂不能对应figure")
|
||||||
res_list = []
|
res_list = []
|
||||||
for r in self.resources:
|
for r in self.resources:
|
||||||
res_list.extend(
|
if isinstance(query_resource, dict):
|
||||||
self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(resource, identifier_key))
|
res_list.extend(
|
||||||
)
|
self.loop_find_resource(r, resource_cls_type, identifier_key, query_resource[identifier_key])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
res_list.extend(
|
||||||
|
self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key))
|
||||||
|
)
|
||||||
assert len(res_list) == 1, f"找到多个资源,请检查资源是否唯一: {res_list}"
|
assert len(res_list) == 1, f"找到多个资源,请检查资源是否唯一: {res_list}"
|
||||||
self.root_resource2resource[id(resource)] = res_list[0]
|
self.root_resource2resource[id(query_resource)] = res_list[0]
|
||||||
# 后续加入其他对比方式
|
# 后续加入其他对比方式
|
||||||
return res_list[0]
|
return res_list[0]
|
||||||
|
|
||||||
def loop_find_resource(self, resource, resource_cls_type, identifier_key, compare_value):
|
def loop_find_resource(self, resource, target_resource_cls_type, identifier_key, compare_value):
|
||||||
res_list = []
|
res_list = []
|
||||||
print(resource, resource_cls_type, identifier_key, compare_value)
|
# print(resource, target_resource_cls_type, identifier_key, compare_value)
|
||||||
children = getattr(resource, "children", [])
|
children = getattr(resource, "children", [])
|
||||||
for child in children:
|
for child in children:
|
||||||
res_list.extend(self.loop_find_resource(child, resource_cls_type, identifier_key, compare_value))
|
res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value))
|
||||||
if resource_cls_type == type(resource):
|
if target_resource_cls_type == type(resource) or target_resource_cls_type == dict:
|
||||||
if hasattr(resource, identifier_key):
|
if hasattr(resource, identifier_key):
|
||||||
if getattr(resource, identifier_key) == compare_value:
|
if getattr(resource, identifier_key) == compare_value:
|
||||||
res_list.append(resource)
|
res_list.append(resource)
|
||||||
|
|||||||
@@ -219,12 +219,14 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
|||||||
logger.error(f"PyLabRobot反序列化失败: {deserialize_error}")
|
logger.error(f"PyLabRobot反序列化失败: {deserialize_error}")
|
||||||
logger.error(f"PyLabRobot反序列化堆栈: {stack}")
|
logger.error(f"PyLabRobot反序列化堆栈: {stack}")
|
||||||
|
|
||||||
return self.device_instance
|
return self.device_instance
|
||||||
|
|
||||||
def post_create(self):
|
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
|
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||||
ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(lambda x: logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成"))
|
def done_cb(*args):
|
||||||
|
logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成")
|
||||||
|
ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(done_cb)
|
||||||
|
|
||||||
|
|
||||||
class ProtocolNodeCreator(DeviceClassCreator[T]):
|
class ProtocolNodeCreator(DeviceClassCreator[T]):
|
||||||
|
|||||||
@@ -43,6 +43,25 @@ set(action_files
|
|||||||
"action/LiquidHandlerStamp.action"
|
"action/LiquidHandlerStamp.action"
|
||||||
"action/LiquidHandlerTransfer.action"
|
"action/LiquidHandlerTransfer.action"
|
||||||
|
|
||||||
|
"action/DPLiquidHandlerAddLiquid.action"
|
||||||
|
"action/DPLiquidHandlerCustomDelay.action"
|
||||||
|
"action/DPLiquidHandlerMix.action"
|
||||||
|
"action/DPLiquidHandlerMoveTo.action"
|
||||||
|
"action/DPLiquidHandlerRemoveLiquid.action"
|
||||||
|
"action/DPLiquidHandlerSetTiprack.action"
|
||||||
|
"action/DPLiquidHandlerTouchTip.action"
|
||||||
|
"action/DPLiquidHandlerTransferLiquid.action"
|
||||||
|
|
||||||
|
"action/EmptyIn.action"
|
||||||
|
"action/FloatSingleInput.action"
|
||||||
|
"action/IntSingleInput.action"
|
||||||
|
"action/StrSingleInput.action"
|
||||||
|
"action/Point3DSeparateInput.action"
|
||||||
|
|
||||||
|
"action/ResourceCreateFromOuter.action"
|
||||||
|
|
||||||
|
"action/SolidDispenseAddPowderTube.action"
|
||||||
|
|
||||||
"action/PumpTransfer.action"
|
"action/PumpTransfer.action"
|
||||||
"action/Clean.action"
|
"action/Clean.action"
|
||||||
"action/Separate.action"
|
"action/Separate.action"
|
||||||
|
|||||||
20
unilabos_msgs/action/DPLiquidHandlerAddLiquid.action
Normal file
20
unilabos_msgs/action/DPLiquidHandlerAddLiquid.action
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
float64[] asp_vols
|
||||||
|
float64[] dis_vols
|
||||||
|
Resource[] reagent_sources
|
||||||
|
Resource[] targets
|
||||||
|
int32[] use_channels
|
||||||
|
float64[] flow_rates
|
||||||
|
geometry_msgs/Point[] offsets
|
||||||
|
float64[] liquid_height
|
||||||
|
float64[] blow_out_air_volume
|
||||||
|
string spread
|
||||||
|
bool is_96_well
|
||||||
|
int32 mix_time
|
||||||
|
int32 mix_vol
|
||||||
|
int32 mix_rate
|
||||||
|
float64 mix_liquid_height
|
||||||
|
string[] none_keys
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
6
unilabos_msgs/action/DPLiquidHandlerCustomDelay.action
Normal file
6
unilabos_msgs/action/DPLiquidHandlerCustomDelay.action
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
float64 seconds
|
||||||
|
string msg
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
11
unilabos_msgs/action/DPLiquidHandlerMix.action
Normal file
11
unilabos_msgs/action/DPLiquidHandlerMix.action
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Resource[] targets
|
||||||
|
int32 mix_time
|
||||||
|
int32 mix_vol
|
||||||
|
float64 height_to_bottom
|
||||||
|
geometry_msgs/Point[] offsets
|
||||||
|
float64 mix_rate
|
||||||
|
string[] none_keys
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
7
unilabos_msgs/action/DPLiquidHandlerMoveTo.action
Normal file
7
unilabos_msgs/action/DPLiquidHandlerMoveTo.action
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Resource well
|
||||||
|
float64 dis_to_top
|
||||||
|
int32 channel
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
17
unilabos_msgs/action/DPLiquidHandlerRemoveLiquid.action
Normal file
17
unilabos_msgs/action/DPLiquidHandlerRemoveLiquid.action
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
float64[] vols
|
||||||
|
Resource[] sources
|
||||||
|
Resource waste_liquid
|
||||||
|
int32[] use_channels
|
||||||
|
float64[] flow_rates
|
||||||
|
geometry_msgs/Point[] offsets
|
||||||
|
float64[] liquid_height
|
||||||
|
float64[] blow_out_air_volume
|
||||||
|
string spread
|
||||||
|
int32[] delays
|
||||||
|
bool is_96_well
|
||||||
|
float64[] top
|
||||||
|
string[] none_keys
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
5
unilabos_msgs/action/DPLiquidHandlerSetTiprack.action
Normal file
5
unilabos_msgs/action/DPLiquidHandlerSetTiprack.action
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Resource[] tip_racks
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
5
unilabos_msgs/action/DPLiquidHandlerTouchTip.action
Normal file
5
unilabos_msgs/action/DPLiquidHandlerTouchTip.action
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Resource[] targets
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
25
unilabos_msgs/action/DPLiquidHandlerTransferLiquid.action
Normal file
25
unilabos_msgs/action/DPLiquidHandlerTransferLiquid.action
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
float64[] asp_vols
|
||||||
|
float64[] dis_vols
|
||||||
|
Resource[] sources
|
||||||
|
Resource[] targets
|
||||||
|
Resource[] tip_racks
|
||||||
|
int32[] use_channels
|
||||||
|
float64[] asp_flow_rates
|
||||||
|
float64[] dis_flow_rates
|
||||||
|
geometry_msgs/Point[] offsets
|
||||||
|
bool touch_tip
|
||||||
|
float64[] liquid_height
|
||||||
|
float64[] blow_out_air_volume
|
||||||
|
string spread
|
||||||
|
bool is_96_well
|
||||||
|
string mix_stage
|
||||||
|
int32[] mix_times
|
||||||
|
int32 mix_vol
|
||||||
|
int32 mix_rate
|
||||||
|
float64 mix_liquid_height
|
||||||
|
int32[] delays
|
||||||
|
string[] none_keys
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
|
# 反馈
|
||||||
4
unilabos_msgs/action/EmptyIn.action
Normal file
4
unilabos_msgs/action/EmptyIn.action
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
4
unilabos_msgs/action/FloatSingleInput.action
Normal file
4
unilabos_msgs/action/FloatSingleInput.action
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
float64 float_in
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
4
unilabos_msgs/action/IntSingleInput.action
Normal file
4
unilabos_msgs/action/IntSingleInput.action
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
int32 int_input
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
6
unilabos_msgs/action/Point3DSeparateInput.action
Normal file
6
unilabos_msgs/action/Point3DSeparateInput.action
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
float64 x
|
||||||
|
float64 y
|
||||||
|
float64 z
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
8
unilabos_msgs/action/ResourceCreateFromOuter.action
Normal file
8
unilabos_msgs/action/ResourceCreateFromOuter.action
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Resource[] resources
|
||||||
|
string[] device_ids
|
||||||
|
string[] bind_parent_ids
|
||||||
|
geometry_msgs/Point[] bind_locations
|
||||||
|
string[] other_calling_params
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
7
unilabos_msgs/action/SolidDispenseAddPowderTube.action
Normal file
7
unilabos_msgs/action/SolidDispenseAddPowderTube.action
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
int32 powder_tube_number
|
||||||
|
string target_tube_position
|
||||||
|
float64 compound_mass
|
||||||
|
---
|
||||||
|
float64 actual_mass_mg
|
||||||
|
bool success
|
||||||
|
---
|
||||||
4
unilabos_msgs/action/StrSingleInput.action
Normal file
4
unilabos_msgs/action/StrSingleInput.action
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
string string
|
||||||
|
---
|
||||||
|
bool success
|
||||||
|
---
|
||||||
Reference in New Issue
Block a user