mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
* feat(devices): add Zhida GC/MS pretreatment automation workstation * feat(devices): add mettler_toledo xpr balance * balance
401 lines
15 KiB
Python
401 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
智达GCMS设备驱动
|
||
|
||
支持智达GCMS设备的TCP通信协议,包括状态查询、方法获取、样品分析等功能。
|
||
通信协议版本:1.0.1
|
||
"""
|
||
|
||
import base64
|
||
import json
|
||
import socket
|
||
import time
|
||
import os
|
||
from pathlib import Path
|
||
|
||
|
||
class ZhidaClient:
|
||
def __init__(self, host='192.168.3.184', port=5792, timeout=10.0):
|
||
# 如果部署在智达GCMS上位机本地,可使用localhost: host='127.0.0.1'
|
||
"""
|
||
初始化智达GCMS客户端
|
||
|
||
Args:
|
||
host (str): 设备IP地址,本地部署时可使用'127.0.0.1'
|
||
port (int): 通信端口,默认5792
|
||
timeout (float): 超时时间,单位秒
|
||
"""
|
||
self.host = host
|
||
self.port = port
|
||
self.timeout = timeout
|
||
self.sock = None
|
||
self._ros_node = None # ROS节点引用,由框架设置
|
||
|
||
def post_init(self, ros_node):
|
||
"""
|
||
ROS节点初始化后的回调方法,用于建立设备连接
|
||
|
||
Args:
|
||
ros_node: ROS节点实例
|
||
"""
|
||
self._ros_node = ros_node
|
||
try:
|
||
self.connect()
|
||
ros_node.lab_logger().info(f"智达GCMS设备连接成功: {self.host}:{self.port}")
|
||
except Exception as e:
|
||
ros_node.lab_logger().error(f"智达GCMS设备连接失败: {e}")
|
||
# 不抛出异常,允许节点继续运行,后续可以重试连接
|
||
|
||
def connect(self):
|
||
"""
|
||
建立TCP连接到智达GCMS设备
|
||
|
||
Raises:
|
||
ConnectionError: 连接失败时抛出
|
||
"""
|
||
try:
|
||
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||
# 确保后续 recv/send 都会在 timeout 秒后抛 socket.timeout
|
||
self.sock.settimeout(self.timeout)
|
||
except Exception as e:
|
||
raise ConnectionError(f"Failed to connect to {self.host}:{self.port} - {str(e)}")
|
||
|
||
def close(self):
|
||
"""
|
||
关闭与智达GCMS设备的TCP连接
|
||
"""
|
||
if self.sock:
|
||
try:
|
||
self.sock.close()
|
||
except Exception:
|
||
pass # 忽略关闭时的错误
|
||
finally:
|
||
self.sock = None
|
||
|
||
def _send_command(self, cmd: dict) -> dict:
|
||
"""
|
||
发送命令到智达GCMS设备并接收响应
|
||
|
||
Args:
|
||
cmd (dict): 要发送的命令字典
|
||
|
||
Returns:
|
||
dict: 设备响应的JSON数据
|
||
|
||
Raises:
|
||
ConnectionError: 连接错误
|
||
TimeoutError: 超时错误
|
||
"""
|
||
if not self.sock:
|
||
raise ConnectionError("Not connected to device")
|
||
|
||
try:
|
||
# 发送JSON命令(UTF-8编码)
|
||
payload = json.dumps(cmd, ensure_ascii=False).encode('utf-8')
|
||
self.sock.sendall(payload)
|
||
|
||
# 循环接收数据直到能成功解析完整JSON
|
||
buffer = bytearray()
|
||
start = time.time()
|
||
while True:
|
||
try:
|
||
chunk = self.sock.recv(4096)
|
||
if not chunk:
|
||
# 对端关闭连接,尝试解析已接收的数据
|
||
if buffer:
|
||
try:
|
||
text = buffer.decode('utf-8', errors='strict')
|
||
return json.loads(text)
|
||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
pass
|
||
break
|
||
buffer.extend(chunk)
|
||
|
||
# 尝试解码和解析JSON
|
||
text = buffer.decode('utf-8', errors='strict')
|
||
try:
|
||
return json.loads(text)
|
||
except json.JSONDecodeError:
|
||
# JSON不完整,继续接收
|
||
pass
|
||
|
||
except socket.timeout:
|
||
# 超时时,尝试解析已接收的数据
|
||
if buffer:
|
||
try:
|
||
text = buffer.decode('utf-8', errors='strict')
|
||
return json.loads(text)
|
||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
pass
|
||
raise TimeoutError(f"recv() timed out after {self.timeout:.1f}s")
|
||
|
||
# 防止死循环,总时长超过2倍超时时间就报错
|
||
if time.time() - start > self.timeout * 2:
|
||
# 最后尝试解析已接收的数据
|
||
if buffer:
|
||
try:
|
||
text = buffer.decode('utf-8', errors='strict')
|
||
return json.loads(text)
|
||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
pass
|
||
raise TimeoutError(f"No complete JSON received after {time.time() - start:.1f}s")
|
||
|
||
# 连接关闭,如果有数据则尝试解析
|
||
if buffer:
|
||
try:
|
||
text = buffer.decode('utf-8', errors='strict')
|
||
return json.loads(text)
|
||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
pass
|
||
|
||
raise ConnectionError("Connection closed before JSON could be parsed")
|
||
|
||
except Exception as e:
|
||
if isinstance(e, (ConnectionError, TimeoutError)):
|
||
raise
|
||
else:
|
||
raise ConnectionError(f"Command send failed: {str(e)}")
|
||
|
||
def get_status(self) -> str:
|
||
"""
|
||
获取设备状态
|
||
|
||
Returns:
|
||
str: 设备状态 (Idle|Offline|Error|Busy|RunSample|Unknown)
|
||
"""
|
||
if not self.sock:
|
||
# 尝试重新连接
|
||
try:
|
||
self.connect()
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().info("智达GCMS设备重新连接成功")
|
||
except Exception as e:
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().warning(f"智达GCMS设备连接失败: {e}")
|
||
return "Offline"
|
||
|
||
try:
|
||
response = self._send_command({"command": "getstatus"})
|
||
return response.get("result", "Unknown")
|
||
except Exception as e:
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().warning(f"获取设备状态失败: {e}")
|
||
return "Error"
|
||
|
||
def get_methods(self) -> dict:
|
||
"""
|
||
获取当前Project的方法列表
|
||
|
||
Returns:
|
||
dict: 包含方法列表的响应
|
||
"""
|
||
if not self.sock:
|
||
try:
|
||
self.connect()
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().info("智达GCMS设备重新连接成功")
|
||
except Exception as e:
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().warning(f"智达GCMS设备连接失败: {e}")
|
||
return {"error": "Device not connected"}
|
||
|
||
try:
|
||
return self._send_command({"command": "getmethods"})
|
||
except Exception as e:
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().warning(f"获取方法列表失败: {e}")
|
||
return {"error": str(e)}
|
||
|
||
|
||
|
||
def get_version(self) -> dict:
|
||
"""
|
||
获取接口版本和InLabPAL固件版本
|
||
|
||
Returns:
|
||
dict: 响应格式 {"result": "OK|Error", "message": "Interface:x.x.x;FW:x.x.x.xxx"}
|
||
"""
|
||
return self._send_command({"command": "version"})
|
||
|
||
def put_tray(self) -> dict:
|
||
"""
|
||
放盘操作,准备InLabPAL进样器
|
||
|
||
注意:此功能仅在特殊场景下使用,例如:
|
||
- 机械臂比较短,需要让开一个位置
|
||
- 盘支架是可移动的,需要进样器配合做动作
|
||
|
||
对于宜宾深势这套配置,空间足够,不需要这个额外的控制组件。
|
||
|
||
Returns:
|
||
dict: 响应格式 {"result": "OK|Error", "message": "ready_info|error_info"}
|
||
"""
|
||
return self._send_command({"command": "puttray"})
|
||
|
||
def start_with_csv_file(self, string: str = None, csv_file_path: str = None) -> dict:
|
||
"""
|
||
使用CSV文件启动分析(支持ROS2动作调用)
|
||
|
||
Args:
|
||
string (str): CSV文件路径(ROS2参数名)
|
||
csv_file_path (str): CSV文件路径(兼容旧接口)
|
||
|
||
Returns:
|
||
dict: ROS2动作结果格式 {"return_info": str, "success": bool}
|
||
|
||
Raises:
|
||
FileNotFoundError: CSV文件不存在
|
||
Exception: 文件读取或通信错误
|
||
"""
|
||
try:
|
||
# 支持两种参数传递方式:ROS2的string参数和直接的csv_file_path参数
|
||
file_path = string if string is not None else csv_file_path
|
||
if file_path is None:
|
||
error_msg = "未提供CSV文件路径参数"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
# 使用Path对象进行更健壮的文件处理
|
||
csv_path = Path(file_path)
|
||
if not csv_path.exists():
|
||
error_msg = f"CSV文件不存在: {file_path}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
# 读取CSV文件内容(UTF-8编码,替换未知字符)
|
||
csv_content = csv_path.read_text(encoding="utf-8", errors="replace")
|
||
|
||
# 转换为Base64编码
|
||
b64_content = base64.b64encode(csv_content.encode('utf-8')).decode('ascii')
|
||
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().info(f"正在发送CSV文件到智达GCMS: {file_path}")
|
||
self._ros_node.lab_logger().info(f"Base64编码长度: {len(b64_content)} 字符")
|
||
|
||
# 发送start命令
|
||
response = self._send_command({
|
||
"command": "start",
|
||
"message": b64_content
|
||
})
|
||
|
||
# 转换为ROS2动作结果格式
|
||
if response.get("result") == "OK":
|
||
success_msg = f"智达GCMS分析启动成功: {response.get('message', 'Unknown')}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().info(success_msg)
|
||
return {"return_info": success_msg, "success": True}
|
||
else:
|
||
error_msg = f"智达GCMS分析启动失败: {response.get('message', 'Unknown error')}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
except Exception as e:
|
||
error_msg = f"CSV文件处理失败: {str(e)}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
def start(self, string: str = None, text: str = None) -> dict:
|
||
"""
|
||
使用Base64编码数据启动分析(支持ROS2动作调用)
|
||
|
||
Args:
|
||
string (str): Base64编码的CSV数据(ROS2参数名)
|
||
text (str): Base64编码的CSV数据(兼容旧接口)
|
||
|
||
Returns:
|
||
dict: ROS2动作结果格式 {"return_info": str, "success": bool}
|
||
"""
|
||
try:
|
||
# 支持两种参数传递方式:ROS2的string参数和原有的text参数
|
||
b64_content = string if string is not None else text
|
||
if b64_content is None:
|
||
error_msg = "未提供Base64编码数据参数"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().info(f"正在发送Base64数据到智达GCMS")
|
||
self._ros_node.lab_logger().info(f"Base64编码长度: {len(b64_content)} 字符")
|
||
|
||
# 发送start命令
|
||
response = self._send_command({
|
||
"command": "start",
|
||
"message": b64_content
|
||
})
|
||
|
||
# 转换为ROS2动作结果格式
|
||
if response.get("result") == "OK":
|
||
success_msg = f"智达GCMS分析启动成功: {response.get('message', 'Unknown')}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().info(success_msg)
|
||
return {"return_info": success_msg, "success": True}
|
||
else:
|
||
error_msg = f"智达GCMS分析启动失败: {response.get('message', 'Unknown error')}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
except Exception as e:
|
||
error_msg = f"Base64数据处理失败: {str(e)}"
|
||
if self._ros_node:
|
||
self._ros_node.lab_logger().error(error_msg)
|
||
return {"return_info": error_msg, "success": False}
|
||
|
||
def abort(self) -> dict:
|
||
"""
|
||
停止当前运行的分析
|
||
|
||
Returns:
|
||
dict: 响应格式 {"result": "OK|Error", "message": "error_info"}
|
||
"""
|
||
return self._send_command({"command": "abort"})
|
||
|
||
|
||
def test_zhida_client():
|
||
"""
|
||
测试智达GCMS客户端功能
|
||
"""
|
||
client = ZhidaClient()
|
||
|
||
try:
|
||
# 连接设备
|
||
print("Connecting to Zhida GCMS...")
|
||
client.connect()
|
||
print("Connected successfully!")
|
||
|
||
# 获取设备状态
|
||
print(f"Device status: {client.status}")
|
||
|
||
# 获取版本信息
|
||
version_info = client.get_version()
|
||
print(f"Version info: {version_info}")
|
||
|
||
# 获取方法列表
|
||
methods = client.get_methods()
|
||
print(f"Available methods: {methods}")
|
||
|
||
# 测试CSV文件发送(如果文件存在)
|
||
csv_file = Path(__file__).parent / "zhida_gcms-test_1.csv"
|
||
if csv_file.exists():
|
||
print(f"Testing CSV file: {csv_file}")
|
||
result = client.start_with_csv_file(str(csv_file))
|
||
print(f"Start result: {result}")
|
||
|
||
except Exception as e:
|
||
print(f"Error: {str(e)}")
|
||
|
||
finally:
|
||
# 关闭连接
|
||
client.close()
|
||
print("Connection closed.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
test_zhida_client()
|