diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 9c85ea08..a56d2d46 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -16,7 +16,7 @@ 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 +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 @@ -179,7 +179,7 @@ def main(): signal.signal(signal.SIGTERM, _exit) mqtt_client.start() args_dict["resources_mesh_config"] = {} - + if args_dict["visual"] != "disable": enable_rviz = args_dict["visual"] == "rviz" if devices_and_resources is not None: diff --git a/unilabos/devices/laiyu_add_solid/laiyu.py b/unilabos/devices/laiyu_add_solid/laiyu.py new file mode 100644 index 00000000..0959f9a8 --- /dev/null +++ b/unilabos/devices/laiyu_add_solid/laiyu.py @@ -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("串口已关闭") + diff --git a/unilabos/devices/zhida_hplc/possible_status.txt b/unilabos/devices/zhida_hplc/possible_status.txt new file mode 100644 index 00000000..cf4e9efb --- /dev/null +++ b/unilabos/devices/zhida_hplc/possible_status.txt @@ -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" +} diff --git a/unilabos/devices/zhida_hplc/zhida.py b/unilabos/devices/zhida_hplc/zhida.py new file mode 100644 index 00000000..a6e1f9d3 --- /dev/null +++ b/unilabos/devices/zhida_hplc/zhida.py @@ -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 [options] diff --git a/unilabos/devices/zhida_hplc/zhida_test_1.csv b/unilabos/devices/zhida_hplc/zhida_test_1.csv new file mode 100644 index 00000000..96cef55e --- /dev/null +++ b/unilabos/devices/zhida_hplc/zhida_test_1.csv @@ -0,0 +1,2 @@ +SampleName,AcqMethod,RackCode,VialPos,SmplInjVol,OutputFile +Sample001,1028-10ul-10min.M,CStk1-01,1,10,DataSET1 \ No newline at end of file diff --git a/unilabos/registry/devices/laiyu_add_solid.yaml b/unilabos/registry/devices/laiyu_add_solid.yaml new file mode 100644 index 00000000..35540235 --- /dev/null +++ b/unilabos/registry/devices/laiyu_add_solid.yaml @@ -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: {} \ No newline at end of file diff --git a/unilabos/registry/devices/zhida_hplc.yaml b/unilabos/registry/devices/zhida_hplc.yaml new file mode 100644 index 00000000..ba121ae6 --- /dev/null +++ b/unilabos/registry/devices/zhida_hplc.yaml @@ -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: StringSingleInput + goal: + string: string + feedback: {} + result: {} + abort: + type: EmptyIn + goal: {} + feedback: {} + result: {} + get_methods: + type: EmptyIn + goal: {} + feedback: {} + result: {} + + schema: + properties: {} \ No newline at end of file diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index 944d4a04..2e8b2fbf 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -82,7 +82,7 @@ def main( executor.add_node(resource_mesh_manager) executor.add_node(joint_republisher) - + thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread") thread.start() diff --git a/unilabos_msgs/action/EmptyIn.action b/unilabos_msgs/action/EmptyIn.action new file mode 100644 index 00000000..c44b70c0 --- /dev/null +++ b/unilabos_msgs/action/EmptyIn.action @@ -0,0 +1,4 @@ + +--- + +--- \ No newline at end of file diff --git a/unilabos_msgs/action/FloatSingleInput.action b/unilabos_msgs/action/FloatSingleInput.action new file mode 100644 index 00000000..2542d31f --- /dev/null +++ b/unilabos_msgs/action/FloatSingleInput.action @@ -0,0 +1,4 @@ +float64 float_in +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/IntSingleInput.action b/unilabos_msgs/action/IntSingleInput.action new file mode 100644 index 00000000..0f8b7aaa --- /dev/null +++ b/unilabos_msgs/action/IntSingleInput.action @@ -0,0 +1,4 @@ +int32 int_input +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/Point3DSeparateInput.action b/unilabos_msgs/action/Point3DSeparateInput.action new file mode 100644 index 00000000..4e15e8f8 --- /dev/null +++ b/unilabos_msgs/action/Point3DSeparateInput.action @@ -0,0 +1,6 @@ +float64 x +float64 y +float64 z +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/SolidDispenseAddPowderTube.action b/unilabos_msgs/action/SolidDispenseAddPowderTube.action new file mode 100644 index 00000000..674c4ffc --- /dev/null +++ b/unilabos_msgs/action/SolidDispenseAddPowderTube.action @@ -0,0 +1,7 @@ +int32 powder_tube_number +string target_tube_position +float64 compound_mass +--- +float64 actual_mass_mg +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/StrSingleInput.action b/unilabos_msgs/action/StrSingleInput.action new file mode 100644 index 00000000..bb762a58 --- /dev/null +++ b/unilabos_msgs/action/StrSingleInput.action @@ -0,0 +1,4 @@ +string string +--- +bool success +--- \ No newline at end of file