Merge remote-tracking branch 'upstream/dev' into device_visualization

This commit is contained in:
zhangshixiang
2025-05-06 23:39:22 +08:00
51 changed files with 2792 additions and 201 deletions

View File

@@ -42,10 +42,11 @@ conda env update --file unilabos-[YOUR_OS].yml -n 环境名
# 现阶段,需要安装 `unilabos_msgs` 包
# 可以前往 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等前置
git clone https://github.com/PyLabRobot/pylabrobot
git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo
pip install .[opentrons]
```

View File

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

View File

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

View File

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

View 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: [ '{}' ] }"
```

View File

@@ -6679,8 +6679,7 @@
"plate_well_11_3",
"plate_well_11_4",
"plate_well_11_5",
"plate_well_11_6",
"plate_well_11_7"
"plate_well_11_6"
],
"parent": "deck",
"type": "device",
@@ -10508,45 +10507,6 @@
"pending_liquids": [],
"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": []

View File

@@ -62,6 +62,7 @@
"name": "teaching_carrier",
"sample_id": null,
"children": [
"teaching_carrier_A1"
],
"parent": "deck",
"type": "plate",
@@ -86,6 +87,46 @@
"model": null
},
"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": [

View File

@@ -59,3 +59,5 @@ dependencies:
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs
- pip:
- paho-mqtt

View File

@@ -59,3 +59,5 @@ dependencies:
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs
- pip:
- paho-mqtt

View File

@@ -61,3 +61,5 @@ dependencies:
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs
- pip:
- paho-mqtt

View File

@@ -58,4 +58,6 @@ dependencies:
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs
# ros-humble-unilabos-msgs
- pip:
- paho-mqtt

View File

@@ -7,7 +7,7 @@ from unilabos.utils import logger
def start_backend(
backend: str,
devices_config: dict = {},
resources_config: dict = {},
resources_config: list = [],
graph=None,
controllers_config: dict = {},
bridges=[],

View File

@@ -1,30 +1,24 @@
import argparse
import asyncio
import json
import os
import signal
import sys
import json
import threading
import time
from copy import deepcopy
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__))
ilabos_dir = os.path.dirname(os.path.dirname(current_dir))
if ilabos_dir not in sys.path:
sys.path.append(ilabos_dir)
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
if unilabos_dir not in sys.path:
sys.path.append(unilabos_dir)
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.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():
@@ -83,9 +77,9 @@ def parse_args():
)
parser.add_argument(
"--visual",
choices=["rviz", "web","None"],
default="rviz",
help="选择可视化工具: 'rviz''web''None',默认'rviz'",
choices=["rviz", "web", "deck", "disable"],
default="disable",
help="选择可视化工具: rviz, web, deck(2D bird view)",
)
return parser.parse_args()
@@ -137,7 +131,7 @@ def main():
# 注册表
build_registry(args_dict["registry_path"])
devices_and_resources = None
if args_dict["graph"] is not None:
import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = (
@@ -185,24 +179,22 @@ def main():
signal.signal(signal.SIGTERM, _exit)
mqtt_client.start()
args_dict["resources_mesh_config"] = {}
if args_dict["visual"] != "None":
if args_dict["visual"] == "rviz":
enable_rviz=True
elif args_dict["visual"] == "web":
enable_rviz=False
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
args_dict["resources_mesh_config"] = resource_visualization.resource_model
# 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中
start_backend(**args_dict)
server_thread = threading.Thread(target=start_server)
server_thread.start()
asyncio.set_event_loop(asyncio.new_event_loop())
resource_visualization.start()
while True:
time.sleep(1)
# web visiualize 2D
if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz"
if devices_and_resources is not None:
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict)
server_thread = threading.Thread(target=start_server)
server_thread.start()
asyncio.set_event_loop(asyncio.new_event_loop())
resource_visualization.start()
while True:
time.sleep(1)
else:
start_backend(**args_dict)
start_server()
else:
start_backend(**args_dict)
start_server()

View File

@@ -9,6 +9,7 @@ from typing import List, Dict, Any, Optional
import requests
from unilabos.utils.log import info
from unilabos.config.config import MQConfig, HTTPConfig
from unilabos.utils import logger
class HTTPClient:
@@ -102,6 +103,30 @@ class HTTPClient:
)
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()

View File

@@ -8,7 +8,7 @@ import traceback
from typing import Dict, Any, Type, TypedDict, Optional
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.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()):
slot_name, slot_type = slot_info
type_info = goal_type.SLOT_TYPES[ind]
default_value = "unknown"
if isinstance(type_info, UnboundedSequence):
inner_type = type_info.value_type
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())]
elif isinstance(inner_type, BasicType):
default_value = [get_default_value_for_ros_type(inner_type.typename)]
elif isinstance(inner_type, UnboundedString):
default_value = [""]
else:
default_value = "unknown"
default_value = []
elif isinstance(type_info, NamespacedType):
cls_name = ".".join(type_info.namespaces) + ":" + type_info.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())
elif isinstance(type_info, BasicType):
default_value = get_default_value_for_ros_type(type_info.typename)
elif isinstance(type_info, UnboundedString):
default_value = ""
else:
type_class = msg_converter_manager.search_class(slot_type, search_lower=True)
if type_class is not None:

View 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):
"""
初始化串口参数默认波特率1152008位数据位、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代表A2代表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比如a1a2b1b2
# 解析位置字符串
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("串口已关闭")

View 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
Samelength sequences of containers (wells or plates). In 96well mode
each must contain exactly one plate.
tip_racks
One or more TipRacks providing fresh tips.
is_96_well
Set *True* to use the 96channel head.
"""
try:
# ------------------------------------------------------------------
# 96channel 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)

View 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"
}

View 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]

View File

@@ -0,0 +1,2 @@
SampleName,AcqMethod,RackCode,VialPos,SmplInjVol,OutputFile
Sample001,1028-10ul-10min.M,CStk1-01,1,10,DataSET1
1 SampleName AcqMethod RackCode VialPos SmplInjVol OutputFile
2 Sample001 1028-10ul-10min.M CStk1-01 1 10 DataSET1

View 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: {}

View File

@@ -163,13 +163,134 @@ liquid_handler:
schema:
type: object
properties:
status:
name:
type: string
description: 液体处理仪器当前状态
required:
- status
- name
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:
class:
module: unilabos.devices.liquid_handling.revvity:Revvity
@@ -187,4 +308,3 @@ liquid_handler.revvity:
status: status
result:
success: success

View File

@@ -19,9 +19,6 @@ gripper.mock:
result:
position: position
effort: torque
model:
type: device
mesh: opentrons_liquid_handler
gripper.misumi_rz:
description: Misumi RZ gripper

View 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: {}

View File

@@ -20,7 +20,43 @@ class Registry:
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
if 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._setup_called = False # 跟踪setup是否已调用
# 其他状态变量

View File

@@ -1,5 +1,5 @@
import sys
import traceback
from unilabos.utils.log import logger
resource_schema = {
"workstation": {"type": "object", "properties": {}},
@@ -132,7 +132,8 @@ def add_schema(resources_config: list[dict]) -> list[dict]:
try:
if type(resource["children"][0]) == dict:
resource["children"] = add_schema(resource["children"])
except:
sys.exit(0)
except Exception as ex:
logger.error("添加物料schema时出错")
traceback.print_exc()
return resources_config

View File

@@ -18,6 +18,7 @@ class ROS2DeviceNodeWrapper(ROS2DeviceNode):
def ros2_device_node(
cls: Type[T],
device_config: Optional[Dict[str, Any]] = None,
status_types: Optional[Dict[str, Any]] = None,
action_value_mappings: Optional[Dict[str, Any]] = None,
hardware_interface: Optional[Dict[str, Any]] = None,
@@ -30,6 +31,7 @@ def ros2_device_node(
cls: 要封装的设备类
status_types: 需要发布的状态和传感器信息,每个(PROP: TYPE)PROP应该匹配cls.PROP或cls.get_PROP()
TYPE应该是ROS2消息类型。默认为{}
device_config: 初始化时的config。
action_value_mappings: 设备动作。默认为{}
每个(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": []}。
@@ -42,6 +44,8 @@ def ros2_device_node(
# 从属性中自动发现可发布状态
if status_types is None:
status_types = {}
if device_config is None:
device_config = {}
if action_value_mappings is None:
action_value_mappings = {}
if hardware_interface is None:
@@ -73,6 +77,7 @@ def ros2_device_node(
"__init__": lambda self, *args, **kwargs: init_wrapper(
self,
driver_class=cls,
device_config=device_config,
status_types=status_types,
action_value_mappings=action_value_mappings,
hardware_interface=hardware_interface,

View File

@@ -1,9 +1,9 @@
import rclpy
from rclpy.node import Node
import copy
from typing import Optional
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.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
from unilabos.utils import logger
from unilabos.utils.import_manager import default_manager
@@ -22,17 +22,21 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device
None
"""
d = None
original_device_config = copy.deepcopy(device_config)
device_class_config = device_config["class"]
if isinstance(device_class_config, str): # 如果是字符串则直接去lab_registry中查找获取class
if device_class_config not in lab_registry.device_type_registry:
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"]
else:
raise ValueError("不再支持class为字典传入class必须为注册表中已经提供的设备您可以新增注册表并通过--registry传入")
if isinstance(device_class_config, dict):
DEVICE = default_manager.get_class(device_class_config["module"])
# 不管是ros2的实例还是python的都必须包一次除了HostNode
DEVICE = ros2_device_node(
DEVICE,
status_types=device_class_config.get("status_types", {}),
device_config=original_device_config,
action_value_mappings=device_class_config.get("action_value_mappings", {}),
hardware_interface=device_class_config.get(
"hardware_interface",

View File

@@ -44,18 +44,18 @@ def exit() -> None:
def main(
devices_config: Dict[str, Any] = {},
resources_config={},
resources_config: list=[],
graph: Optional[Dict[str, Any]] = None,
controllers_config: Dict[str, Any] = {},
bridges: List[Any] = [],
visual: str = "None",
visual: str = "disable",
resources_mesh_config: dict = {},
args: List[str] = ["--log-level", "debug"],
rclpy_init_args: List[str] = ["--log-level", "debug"],
discovery_interval: float = 5.0,
) -> None:
"""主函数"""
rclpy.init(args=args)
rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor = MultiThreadedExecutor()
# 创建主机节点
host_node = HostNode(
@@ -68,16 +68,16 @@ def main(
discovery_interval,
)
if visual != "None":
if visual != "disable":
resource_mesh_manager = ResourceMeshManager(
resources_mesh_config,
resources_config,
resource_tracker= host_node.resource_tracker,
resource_tracker= DeviceNodeResourceTracker(),
device_id = 'resource_mesh_manager',
)
joint_republisher = JointRepublisher(
'joint_republisher',
host_node.resource_tracker
DeviceNodeResourceTracker()
)
executor.add_node(resource_mesh_manager)
@@ -96,13 +96,13 @@ def slave(
graph: Optional[Dict[str, Any]] = None,
controllers_config: Dict[str, Any] = {},
bridges: List[Any] = [],
visual: str = "None",
visual: str = "disable",
resources_mesh_config: dict = {},
args: List[str] = ["--log-level", "debug"],
rclpy_init_args: List[str] = ["--log-level", "debug"],
) -> None:
"""从节点函数"""
if not rclpy.ok():
rclpy.init(args=args)
rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor
if not executor:
executor = rclpy.__executor = MultiThreadedExecutor()
@@ -153,7 +153,7 @@ def slave(
logger.info(f"Slave node info updated.")
rclient = n.create_client(ResourceAdd, "/resources/add")
rclient.wait_for_service() # FIXME 可能一直等待,加一个参数
rclient.wait_for_service()
request = ResourceAdd.Request()
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config]

View File

@@ -566,6 +566,7 @@ basic_type_map = {
'float32': {'type': 'number'},
'float64': {'type': 'number'},
'string': {'type': 'string'},
'boolean': {'type': 'boolean'},
'char': {'type': 'string', 'maxLength': 1},
'byte': {'type': 'integer', 'minimum': 0, 'maximum': 255},
}

View File

@@ -15,8 +15,9 @@ from rclpy.action.server import ServerGoalHandle
from rclpy.client import Client
from rclpy.callback_groups import ReentrantCallbackGroup
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 (
convert_to_ros_msg,
convert_from_ros_msg,
@@ -101,6 +102,7 @@ def init_wrapper(
self,
device_id: str,
driver_class: type[T],
device_config: Dict[str, Any],
status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any],
hardware_interface: Dict[str, Any],
@@ -118,6 +120,7 @@ def init_wrapper(
children = []
kwargs["device_id"] = device_id
kwargs["driver_class"] = driver_class
kwargs["device_config"] = device_config
kwargs["driver_params"] = driver_params
kwargs["status_types"] = status_types
kwargs["action_value_mappings"] = action_value_mappings
@@ -302,10 +305,52 @@ class BaseROS2DeviceNode(Node, Generic[T]):
res.response = ""
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] = {
"query_host_name": self.create_service(
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"])
self.lab_logger().debug(f"接收到原始目标: {action_kwargs}")
# 向Host查询物料当前状态
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
try:
r = ResourceGet.Request()
r.id = action_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else action_kwargs[k][0]["id"]
r.with_children = True
response = await self._resource_clients["resource_get"].call_async(r)
except Exception:
logger.error(f"资源查询失败,默认使用本地资源")
# 删除对response.resources的检查因为它总是存在
resources_list = [convert_from_ros_msg(rs) for rs in response.resources] # type: ignore # FIXME
self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源")
type_hint = action_paramtypes[k]
final_type = get_type_class(type_hint)
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource并做转换
final_resource = convert_resources_to_type(resources_list, final_type)
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource)
# 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过
if action_name != "add_resource_from_outer":
for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
try:
r = ResourceGet.Request()
r.id = action_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else action_kwargs[k][0]["id"]
r.with_children = True
response = await self._resource_clients["resource_get"].call_async(r)
except Exception:
logger.error(f"资源查询失败,默认使用本地资源")
# 删除对response.resources的检查因为它总是存在
resources_list = [convert_from_ros_msg(rs) for rs in response.resources] # type: ignore # FIXME
self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源")
type_hint = action_paramtypes[k]
final_type = get_type_class(type_hint)
# 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource并做转换
final_resource = convert_resources_to_type(resources_list, final_type)
action_kwargs[k] = self.resource_tracker.figure_resource(final_resource)
self.lab_logger().info(f"准备执行: {action_kwargs}, 函数: {ACTION.__name__}")
time_start = time.time()
@@ -527,27 +572,28 @@ class BaseROS2DeviceNode(Node, Generic[T]):
del future
# 向Host更新物料当前状态
for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue
self.lab_logger().info(f"更新资源状态: {k}")
r = ResourceUpdate.Request()
# 仅当action_kwargs[k]不为None时尝试转换
akv = action_kwargs[k]
apv = action_paramtypes[k]
final_type = get_type_class(apv)
if final_type is None:
continue
try:
r.resources = [
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}")
except Exception as e:
self.lab_logger().error(f"资源更新失败: {e}")
self.lab_logger().error(traceback.format_exc())
if action_name != "add_resource_from_outer":
for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue
self.lab_logger().info(f"更新资源状态: {k}")
r = ResourceUpdate.Request()
# 仅当action_kwargs[k]不为None时尝试转换
akv = action_kwargs[k]
apv = action_paramtypes[k]
final_type = get_type_class(apv)
if final_type is None:
continue
try:
r.resources = [
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}")
except Exception as e:
self.lab_logger().error(f"资源更新失败: {e}")
self.lab_logger().error(traceback.format_exc())
# 发布结果
goal_handle.succeed()
@@ -627,6 +673,7 @@ class ROS2DeviceNode:
self,
device_id: str,
driver_class: Type[T],
device_config: Dict[str, Any],
driver_params: Dict[str, Any],
status_types: Dict[str, Any],
action_value_mappings: Dict[str, Any],
@@ -641,6 +688,8 @@ class ROS2DeviceNode:
Args:
device_id: 设备标识符
driver_class: 设备类
device_config: 原始初始化的json
driver_params: driver初始化的参数
status_types: 状态类型映射
action_value_mappings: 动作值映射
hardware_interface: 硬件接口配置
@@ -657,11 +706,12 @@ class ROS2DeviceNode:
# 保存设备类是否支持异步上下文
self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__")
self._driver_class = driver_class
self.device_config = device_config
self.driver_is_ros = driver_is_ros
self.resource_tracker = DeviceNodeResourceTracker()
# 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进行创建
# 创建设备类实例

View File

@@ -7,11 +7,13 @@ import uuid
from typing import Optional, Dict, Any, List, ClassVar, Set
from action_msgs.msg import GoalStatus
from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, SerialCommand # type: ignore
from geometry_msgs.msg import Point
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service
from 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 unilabos.registry.registry import lab_registry
@@ -23,11 +25,9 @@ from unilabos.ros.msgs.message_converter import (
convert_from_ros_msg,
convert_to_ros_msg,
msg_converter_manager,
ros_action_to_json_schema,
)
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.presets.controller_node import ControllerNode
from unilabos.utils.type_check import TypeEncoder
class HostNode(BaseROS2DeviceNode):
@@ -50,7 +50,7 @@ class HostNode(BaseROS2DeviceNode):
self,
device_id: str,
devices_config: Dict[str, Any],
resources_config: Any,
resources_config: list,
physical_setup_graph: Optional[Dict[str, Any]] = None,
controllers_config: Optional[Dict[str, Any]] = None,
bridges: Optional[List[Any]] = None,
@@ -76,7 +76,7 @@ class HostNode(BaseROS2DeviceNode):
driver_instance=self,
device_id=device_id,
status_types={},
action_value_mappings={},
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
hardware_interface={},
print_publish=False,
resource_tracker=DeviceNodeResourceTracker(), # host node并不是通过initialize 包一层传进来的
@@ -97,15 +97,13 @@ class HostNode(BaseROS2DeviceNode):
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.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = (
{}
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
self._action_value_mappings: Dict[str, Dict] = {} # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
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._discovery_lock = threading.Lock() # 设备发现的互斥锁
self._subscribed_topics = set() # 用于跟踪已订阅的话题
@@ -259,16 +257,41 @@ class HostNode(BaseROS2DeviceNode):
self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}")
action_name = action_id[len(namespace) + 1:]
edge_device_id = namespace[9:]
from unilabos.app.mq import mqtt_client
info_with_schema = ros_action_to_json_schema(action_type)
mqtt_client.publish_actions(action_name, {
"device_id": edge_device_id,
"action_name": action_name,
"schema": info_with_schema,
})
# from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type)
# mqtt_client.publish_actions(action_name, {
# "device_id": edge_device_id,
# "device_type": "",
# "action_name": action_name,
# "schema": info_with_schema,
# })
except Exception as 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:
"""
根据配置初始化设备,
@@ -297,13 +320,14 @@ class HostNode(BaseROS2DeviceNode):
action_type = action_value_mapping["type"]
self._action_clients[action_id] = ActionClient(self, action_type, action_id)
self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的
from unilabos.app.mq import mqtt_client
info_with_schema = ros_action_to_json_schema(action_type)
mqtt_client.publish_actions(action_name, {
"device_id": device_id,
"action_name": action_name,
"schema": info_with_schema,
})
# from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type)
# mqtt_client.publish_actions(action_name, {
# "device_id": device_id,
# "device_type": device_config["class"],
# "action_name": action_name,
# "schema": info_with_schema,
# })
else:
self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.")
device_key = f"{self.devices_names[device_id]}/{device_id}" # 这里不涉及二级device_id

View File

@@ -1,7 +1,7 @@
from unilabos.utils.log import logger
class DeviceNodeResourceTracker:
class DeviceNodeResourceTracker(object):
def __init__(self):
self.resources = []
@@ -15,44 +15,46 @@ class DeviceNodeResourceTracker:
return resource
def add_resource(self, resource):
# 使用内存地址跟踪是否为同一个resource
for r in self.resources:
if id(r) == id(resource):
return
# 添加资源到跟踪器
self.resources.append(resource)
def clear_resource(self):
self.resources = []
def figure_resource(self, resource):
# 使用内存地址跟踪是否为同一个resource
if isinstance(resource, list):
return [self.figure_resource(r) for r in resource]
res_id = resource.id if hasattr(resource, "id") else None
res_name = resource.name if hasattr(resource, "name") else None
def figure_resource(self, query_resource):
if isinstance(query_resource, list):
return [self.figure_resource(r) for r in query_resource]
res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) 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_identifier = res_id if res_id else res_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:
logger.warning(f"resource {resource} 没有id或name暂不能对应figure")
logger.warning(f"resource {query_resource} 没有id或name暂不能对应figure")
res_list = []
for r in self.resources:
res_list.extend(
self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(resource, identifier_key))
)
if isinstance(query_resource, dict):
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}"
self.root_resource2resource[id(resource)] = res_list[0]
self.root_resource2resource[id(query_resource)] = 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 = []
print(resource, resource_cls_type, identifier_key, compare_value)
# print(resource, target_resource_cls_type, identifier_key, compare_value)
children = getattr(resource, "children", [])
for child in children:
res_list.extend(self.loop_find_resource(child, resource_cls_type, identifier_key, compare_value))
if resource_cls_type == type(resource):
res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value))
if target_resource_cls_type == type(resource) or target_resource_cls_type == dict:
if hasattr(resource, identifier_key):
if getattr(resource, identifier_key) == compare_value:
res_list.append(resource)

View File

@@ -219,12 +219,14 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
logger.error(f"PyLabRobot反序列化失败: {deserialize_error}")
logger.error(f"PyLabRobot反序列化堆栈: {stack}")
return self.device_instance
return self.device_instance
def post_create(self):
if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(getattr(self.device_instance, "setup")):
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]):

View File

@@ -43,6 +43,25 @@ set(action_files
"action/LiquidHandlerStamp.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/Clean.action"
"action/Separate.action"

View 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
---
# 反馈

View File

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

View 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
---
# 反馈

View File

@@ -0,0 +1,7 @@
Resource well
float64 dis_to_top
int32 channel
---
bool success
---
# 反馈

View 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
---
# 反馈

View File

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

View File

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

View 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
---
# 反馈

View File

@@ -0,0 +1,4 @@
---
---

View File

@@ -0,0 +1,4 @@
float64 float_in
---
bool success
---

View File

@@ -0,0 +1,4 @@
int32 int_input
---
bool success
---

View File

@@ -0,0 +1,6 @@
float64 x
float64 y
float64 z
---
bool success
---

View 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
---

View File

@@ -0,0 +1,7 @@
int32 powder_tube_number
string target_tube_position
float64 compound_mass
---
float64 actual_mass_mg
bool success
---

View File

@@ -0,0 +1,4 @@
string string
---
bool success
---