Files
Uni-Lab-OS/unilabos/devices/zhida_gcms/zhida.py
Xie Qiming ace98a4472 Feature/xprbalance-zhida (#80)
* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance
2025-09-19 11:43:25 +08:00

401 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()