Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance
This commit is contained in:
Xie Qiming
2025-09-19 11:43:25 +08:00
committed by GitHub
parent 41eaa88c6f
commit ace98a4472
20 changed files with 2451 additions and 0 deletions

View File

@@ -0,0 +1,400 @@
#!/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()