From ace98a4472019defc64212f143c4eab4260e7a57 Mon Sep 17 00:00:00 2001 From: Xie Qiming <97236197+Andy6M@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:43:25 +0800 Subject: [PATCH] Feature/xprbalance-zhida (#80) * feat(devices): add Zhida GC/MS pretreatment automation workstation * feat(devices): add mettler_toledo xpr balance * balance --- .gitignore | 2 + unilabos-linux-64.yaml | 3 + unilabos-osx-64.yaml | 3 + unilabos-osx-arm64.yaml | 3 + unilabos-win64.yaml | 3 + unilabos/devices/balance/__init__.py | 6 + ...aboratory.Balance.XprXsr.V03.wsdl.template | 51 ++ .../Mettler_Toledo_Balance_ROS2_User_Guide.md | 255 ++++++++ .../balance/mettler_toledo_xpr/README.md | 123 ++++ .../balance/mettler_toledo_xpr/__init__.py | 5 + .../balance/mettler_toledo_xpr/balance.yaml | 256 ++++++++ .../mettler_toledo_xpr/balance_test.json | 25 + .../mettler_toledo_xpr/mettler_toledo_xpr.py | 571 ++++++++++++++++++ .../zhida_gcms/Zhida_GCMS_ROS2_User_Guide.md | 148 +++++ unilabos/devices/zhida_gcms/__init__.py | 0 unilabos/devices/zhida_gcms/device_test.json | 24 + unilabos/devices/zhida_gcms/zhida.py | 400 ++++++++++++ .../devices/zhida_gcms/zhida_gcms-test_1.csv | 2 + unilabos/registry/devices/balance.yaml | 256 ++++++++ unilabos/registry/devices/zhida_gcms.yaml | 315 ++++++++++ 20 files changed, 2451 insertions(+) create mode 100644 unilabos/devices/balance/__init__.py create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/MT.Laboratory.Balance.XprXsr.V03.wsdl.template create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/Mettler_Toledo_Balance_ROS2_User_Guide.md create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/README.md create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/__init__.py create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/balance.yaml create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/balance_test.json create mode 100644 unilabos/devices/balance/mettler_toledo_xpr/mettler_toledo_xpr.py create mode 100644 unilabos/devices/zhida_gcms/Zhida_GCMS_ROS2_User_Guide.md create mode 100644 unilabos/devices/zhida_gcms/__init__.py create mode 100644 unilabos/devices/zhida_gcms/device_test.json create mode 100644 unilabos/devices/zhida_gcms/zhida.py create mode 100644 unilabos/devices/zhida_gcms/zhida_gcms-test_1.csv create mode 100644 unilabos/registry/devices/balance.yaml create mode 100644 unilabos/registry/devices/zhida_gcms.yaml diff --git a/.gitignore b/.gitignore index 5fab5d54..9d40cbca 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,5 @@ local_test2.py ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2 *.bz2 test_config.py + + diff --git a/unilabos-linux-64.yaml b/unilabos-linux-64.yaml index 2604b051..eed2caaf 100644 --- a/unilabos-linux-64.yaml +++ b/unilabos-linux-64.yaml @@ -63,6 +63,9 @@ dependencies: # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ilab equipments - uni-lab::ros-humble-unilabos-msgs + - zeep + - jinja2 + - pprp - pip: - paho-mqtt - opentrons_shared_data diff --git a/unilabos-osx-64.yaml b/unilabos-osx-64.yaml index 2d0c3325..764130f6 100644 --- a/unilabos-osx-64.yaml +++ b/unilabos-osx-64.yaml @@ -62,6 +62,9 @@ dependencies: # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ilab equipments - uni-lab::ros-humble-unilabos-msgs + - zeep + - jinja2 + - pprp - pip: - paho-mqtt - opentrons_shared_data diff --git a/unilabos-osx-arm64.yaml b/unilabos-osx-arm64.yaml index a4e88016..6c2f5a12 100644 --- a/unilabos-osx-arm64.yaml +++ b/unilabos-osx-arm64.yaml @@ -64,6 +64,9 @@ dependencies: # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ilab equipments - uni-lab::ros-humble-unilabos-msgs + - zeep + - jinja2 + - pprp - pip: - paho-mqtt - opentrons_shared_data diff --git a/unilabos-win64.yaml b/unilabos-win64.yaml index 9eb55fd3..d854ae65 100644 --- a/unilabos-win64.yaml +++ b/unilabos-win64.yaml @@ -65,6 +65,9 @@ dependencies: - uni-lab::ros-humble-unilabos-msgs # driver #- crcmod + - zeep + - jinja2 + - pprp - pip: - paho-mqtt - opentrons_shared_data diff --git a/unilabos/devices/balance/__init__.py b/unilabos/devices/balance/__init__.py new file mode 100644 index 00000000..c6271164 --- /dev/null +++ b/unilabos/devices/balance/__init__.py @@ -0,0 +1,6 @@ +# Balance devices module + +# Import balance device modules +from . import mettler_toledo_xpr + +__all__ = ['mettler_toledo_xpr'] \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/MT.Laboratory.Balance.XprXsr.V03.wsdl.template b/unilabos/devices/balance/mettler_toledo_xpr/MT.Laboratory.Balance.XprXsr.V03.wsdl.template new file mode 100644 index 00000000..aacf70c0 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/MT.Laboratory.Balance.XprXsr.V03.wsdl.template @@ -0,0 +1,51 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/Mettler_Toledo_Balance_ROS2_User_Guide.md b/unilabos/devices/balance/mettler_toledo_xpr/Mettler_Toledo_Balance_ROS2_User_Guide.md new file mode 100644 index 00000000..02de76c9 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/Mettler_Toledo_Balance_ROS2_User_Guide.md @@ -0,0 +1,255 @@ +# 梅特勒天平 ROS2 使用指南 / Mettler Toledo Balance ROS2 User Guide + +## 概述 / Overview + +梅特勒托利多XPR/XSR天平驱动支持通过ROS2动作进行操作,包括去皮、清零、读取重量等功能。 + +The Mettler Toledo XPR/XSR balance driver supports operations through ROS2 actions, including tare, zero, weight reading, and other functions. + +## 主要功能 / Main Features + +### 1. 去皮操作 / Tare Operation (`tare`) + +- **功能 / Function**: 执行天平去皮操作 / Perform balance tare operation +- **输入 / Input**: `{"immediate": bool}` - 是否立即去皮 / Whether to tare immediately +- **输出 / Output**: `{"return_info": str, "success": bool}` + +### 2. 清零操作 / Zero Operation (`zero`) + +- **功能 / Function**: 执行天平清零操作 / Perform balance zero operation +- **输入 / Input**: `{"immediate": bool}` - 是否立即清零 / Whether to zero immediately +- **输出 / Output**: `{"return_info": str, "success": bool}` + +### 3. 读取重量 / Read Weight (`read` / `get_weight`) + +- **功能 / Function**: 读取当前天平重量 / Read current balance weight +- **输入 / Input**: 无参数 / No parameters +- **输出 / Output**: `{"return_info": str, "success": bool}` - 包含重量信息 / Contains weight information + + + +## 使用方法 / Usage Methods + +### ROS2命令行使用 / ROS2 Command Line Usage + +### 1. 去皮操作 / Tare Operation + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"tare\", \"params\": {\"immediate\": false}}' +}" +``` + +### 2. 清零操作 / Zero Operation + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"zero\", \"params\": {\"immediate\": false}}' +}" +``` + +### 3. 读取重量 / Read Weight + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"read\"}' +}" +``` + + +### 4. 推荐的去皮读取流程 / Recommended Tare and Read Workflow + +**步骤1: 去皮操作 / Step 1: Tare Operation** +```bash +# 放置空容器后执行去皮 / Execute tare after placing empty container +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"tare\", \"params\": {\"immediate\": false}}' +}" +``` + +**步骤2: 读取净重 / Step 2: Read Net Weight** +```bash +# 添加物质后读取净重 / Read net weight after adding substance +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"read\"}' +}" +``` + +**优势 / Advantages**: +- 可以在去皮和读取之间进行确认 / Can confirm between taring and reading +- 更好的错误处理和调试 / Better error handling and debugging +- 操作流程更加清晰 / Clearer operation workflow + + + +## 命令格式说明 / Command Format Description + +所有命令都使用JSON格式,包含以下字段 / All commands use JSON format with the following fields: + +```json +{ + "command": "命令名称 / Command name", + "params": { + "参数名 / Parameter name": "参数值 / Parameter value" + } +} +``` + +**注意事项 / Notes:** +1. JSON字符串需要正确转义引号 / JSON strings need proper quote escaping +2. 布尔值使用小写(true/false)/ Boolean values use lowercase (true/false) +3. 如果命令不需要参数,可以省略`params`字段 / If command doesn't need parameters, `params` field can be omitted + +## 返回结果 / Return Results + +所有命令都会返回包含以下字段的结果 / All commands return results with the following fields: + +- `success`: 布尔值,表示操作是否成功 / Boolean value indicating operation success +- `return_info`: 字符串,包含操作结果的详细信息 / String containing detailed operation result information + +## 成功执行示例 / Successful Execution Example + +以下是一个成功执行读取重量命令的示例 / Here is an example of successfully executing a weight reading command: + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"read\"}' +}" +``` + +**成功返回结果 / Successful Return Result:** +``` +Waiting for an action server to become available... +Sending goal: + command: '{"command": "read"}' + +Goal accepted :) + +Result: + success: True + return_info: Weight: 0.24866 Milligram + +Goal finished with status: SUCCEEDED +``` + +### Python代码使用 / Python Code Usage + +```python +import rclpy +from rclpy.node import Node +from rclpy.action import ActionClient +from unilabos_msgs.action import SendCmd +import json + +class BalanceController(Node): + """梅特勒天平控制器 / Mettler Balance Controller""" + def __init__(self): + super().__init__('balance_controller') + self._action_client = ActionClient(self, SendCmd, '/devices/BALANCE_STATION/send_cmd') + + def send_command(self, command, params=None): + """发送命令到天平 / Send command to balance""" + goal_msg = SendCmd.Goal() + + cmd_data = {'command': command} + if params: + cmd_data['params'] = params + + goal_msg.command = json.dumps(cmd_data) + + self._action_client.wait_for_server() + future = self._action_client.send_goal_async(goal_msg) + + return future + + def tare_balance(self, immediate=False): + """去皮操作 / Tare operation""" + return self.send_command('tare', {'immediate': immediate}) + + def zero_balance(self, immediate=False): + """清零操作 / Zero operation""" + return self.send_command('zero', {'immediate': immediate}) + + def read_weight(self): + """读取重量 / Read weight""" + return self.send_command('read') + + + + +# 使用示例 / Usage Example +def main(): + rclpy.init() + controller = BalanceController() + + # 去皮操作 / Tare operation + future = controller.tare_balance(immediate=False) + rclpy.spin_until_future_complete(controller, future) + result = future.result().result + print(f"去皮结果 / Tare result: {result.success}, 信息 / Info: {result.return_info}") + + # 读取重量 / Read weight + future = controller.read_weight() + rclpy.spin_until_future_complete(controller, future) + result = future.result().result + print(f"读取结果 / Read result: {result.success}, 信息 / Info: {result.return_info}") + + controller.destroy_node() + rclpy.shutdown() + +if __name__ == '__main__': + main() +``` + +## 使用注意事项 / Usage Notes + +1. **设备连接 / Device Connection**: 确保梅特勒天平设备已连接并可访问 / Ensure Mettler balance device is connected and accessible +2. **命令格式 / Command Format**: JSON字符串需要正确转义引号 / JSON strings need proper quote escaping +3. **参数类型 / Parameter Types**: 布尔值使用小写(true/false)/ Boolean values use lowercase (true/false) +4. **权限 / Permissions**: 确保有操作天平的权限 / Ensure you have permission to operate the balance + +## 故障排除 / Troubleshooting + +### 常见问题 / Common Issues + +1. **JSON格式错误 / JSON Format Error**: 确保JSON字符串格式正确且引号已转义 / Ensure JSON string format is correct and quotes are escaped +2. **未知命令名称 / Unknown Command Name**: 检查命令名称是否正确 / Check if command name is correct +3. **设备连接失败 / Device Connection Failed**: 检查网络连接和设备状态 / Check network connection and device status +4. **操作超时 / Operation Timeout**: 检查设备是否响应正常 / Check if device is responding normally + +### 错误处理 / Error Handling + +如果命令执行失败,返回结果中的`success`字段将为`false`,`return_info`字段将包含错误信息。 + +If command execution fails, the `success` field in the return result will be `false`, and the `return_info` field will contain error information. + +### 调试技巧 / Debugging Tips + +1. 检查设备节点是否正在运行 / Check if device node is running: + ```bash + ros2 node list | grep BALANCE + ``` + +2. 查看可用的action / View available actions: + ```bash + ros2 action list | grep BALANCE + ``` + +3. 检查action接口 / Check action interface: + ```bash + ros2 action info /devices/BALANCE_STATION/send_cmd + ``` + +4. 查看节点日志 / View node logs: + ```bash + ros2 topic echo /rosout + ``` + +## 总结 / Summary + +梅特勒托利多天平设备现在支持 / Mettler Toledo balance device now supports: + +1. 通过ROS2 SendCmd动作进行统一操作 / Unified operations through ROS2 SendCmd actions +2. 完整的天平功能支持(去皮、清零、读重等)/ Complete balance function support (tare, zero, weight reading, etc.) +3. 完善的错误处理和日志记录 / Comprehensive error handling and logging +4. 简化的操作流程和调试方法 / Simplified operation workflow and debugging methods \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/README.md b/unilabos/devices/balance/mettler_toledo_xpr/README.md new file mode 100644 index 00000000..d46a927d --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/README.md @@ -0,0 +1,123 @@ +# Mettler Toledo XPR/XSR Balance Driver + +## 概述 + +本驱动程序为梅特勒托利多XPR/XSR系列天平提供标准接口,支持去皮、清零和重量读取等操作。 + +## ⚠️ 重要说明 - WSDL文件配置 + +### 问题说明 + +本驱动程序需要使用梅特勒托利多官方提供的WSDL文件来与天平通信。由于该WSDL文件包含专有信息,不能随开源项目一起分发。 + +### 配置步骤 + +1. **获取WSDL文件** + - 联系梅特勒托利多技术支持 + - 或从您的天平设备Web界面下载 + - 或从梅特勒托利多官方SDK获取 + +2. **安装WSDL文件** + ```bash + # 将获取的WSDL文件复制到驱动目录 + cp /path/to/your/MT.Laboratory.Balance.XprXsr.V03.wsdl \ + unilabos/devices/balance/mettler_toledo_xpr/ + ``` + +3. **验证安装** + - 确保文件名为:`MT.Laboratory.Balance.XprXsr.V03.wsdl` + - 确保文件包含Jinja2模板变量:`{{host}}`、`{{port}}`、`{{api_path}}` + +### WSDL文件要求 + +- 文件必须是有效的WSDL格式 +- 必须包含SessionService和WeighingService的定义 +- 端点地址应使用模板变量以支持动态IP配置: + ```xml + + + ``` + +### 文件结构 + +``` +mettler_toledo_xpr/ +├── MT.Laboratory.Balance.XprXsr.V03.wsdl # 实际WSDL文件(用户提供) +├── MT.Laboratory.Balance.XprXsr.V03.wsdl.template # 模板文件(仅供参考) +├── mettler_toledo_xpr.py # 驱动程序 +├── balance.yaml # 设备配置 +├── SendCmd_Usage_Guide.md # 使用指南 +└── README.md # 本文件 +``` + +## 使用方法 + +### 基本配置 + +```python +from unilabos.devices.balance.mettler_toledo_xpr import MettlerToledoXPR + +# 创建天平实例 +balance = MettlerToledoXPR( + ip="192.168.1.10", # 天平IP地址 + port=81, # 天平端口 + password="123456", # 天平密码 + timeout=10 # 连接超时时间 +) + +# 执行操作 +balance.tare() # 去皮 +balance.zero() # 清零 +weight = balance.get_weight() # 读取重量 +``` + +### ROS2 SendCmd Action + +详细的ROS2使用方法请参考 [SendCmd_Usage_Guide.md](SendCmd_Usage_Guide.md) + +## 故障排除 + +### 常见错误 + +1. **FileNotFoundError: WSDL template not found** + - 确保WSDL文件已正确放置在驱动目录中 + - 检查文件名是否正确 + +2. **连接失败** + - 检查天平IP地址和端口配置 + - 确保天平Web服务已启用 + - 验证网络连接 + +3. **认证失败** + - 检查天平密码是否正确 + - 确保天平允许Web服务访问 + +### 调试模式 + +```python +import logging +logging.basicConfig(level=logging.DEBUG) + +# 创建天平实例,将显示详细日志 +balance = MettlerToledoXPR(ip="192.168.1.10") +``` + +## 支持的操作 + +- **去皮 (Tare)**: 将当前重量设为零点 +- **清零 (Zero)**: 重新校准零点 +- **读取重量 (Get Weight)**: 获取当前重量值 +- **带去皮读取**: 先去皮再读取重量 +- **连接管理**: 自动连接和断开 + +## 技术支持 + +如果您在配置WSDL文件时遇到问题,请: + +1. 查看梅特勒托利多官方文档 +2. 联系梅特勒托利多技术支持 +3. 在项目GitHub页面提交Issue + +## 许可证 + +本驱动程序遵循项目主许可证。WSDL文件的使用需遵循梅特勒托利多的许可条款。 \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/__init__.py b/unilabos/devices/balance/mettler_toledo_xpr/__init__.py new file mode 100644 index 00000000..80b47f77 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/__init__.py @@ -0,0 +1,5 @@ +# Mettler Toledo XPR Balance Driver Module + +from .mettler_toledo_xpr import MettlerToledoXPR + +__all__ = ['MettlerToledoXPR'] \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/balance.yaml b/unilabos/devices/balance/mettler_toledo_xpr/balance.yaml new file mode 100644 index 00000000..19bc241d --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/balance.yaml @@ -0,0 +1,256 @@ +balance.mettler_toledo_xpr: + category: + - balance + class: + action_value_mappings: + disconnect: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: + success: success + schema: + description: Disconnect from balance + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + success: + description: Whether disconnect was successful + type: boolean + required: + - success + type: object + required: + - goal + type: object + type: UniLabJsonCommand + get_weight: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: + unit: unit + weight: weight + schema: + description: Get current weight reading + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + unit: + description: Weight unit (e.g., g, kg) + type: string + weight: + description: Weight value + type: number + required: + - weight + - unit + type: object + required: + - goal + type: object + type: UniLabJsonCommand + read_with_tare: + feedback: {} + goal: + immediate_tare: immediate_tare + goal_default: + immediate_tare: true + handles: [] + result: + unit: unit + weight: weight + schema: + description: Perform tare then read weight (standard read operation) + properties: + feedback: {} + goal: + properties: + immediate_tare: + default: true + description: Whether to use immediate tare + type: boolean + required: [] + type: object + result: + properties: + unit: + description: Weight unit (e.g., g, kg) + type: string + weight: + description: Weight value after tare + type: number + required: + - weight + - unit + type: object + required: + - goal + type: object + type: UniLabJsonCommand + send_cmd: + feedback: {} + goal: + command: command + goal_default: + command: '' + handles: [] + result: + return_info: return_info + success: success + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: SendCmd_Feedback + type: object + goal: + properties: + command: + type: string + required: + - command + title: SendCmd_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: SendCmd_Result + type: object + required: + - goal + title: SendCmd + type: object + type: SendCmd + tare: + feedback: {} + goal: + immediate: immediate + goal_default: + immediate: false + handles: [] + result: + success: success + schema: + description: Tare operation for balance + properties: + feedback: {} + goal: + properties: + immediate: + default: false + description: Whether to perform immediate tare + type: boolean + required: [] + type: object + result: + properties: + success: + description: Whether tare operation was successful + type: boolean + required: + - success + type: object + required: + - goal + type: object + type: UniLabJsonCommand + zero: + feedback: {} + goal: + immediate: immediate + goal_default: + immediate: false + handles: [] + result: + success: success + schema: + description: Zero operation for balance + properties: + feedback: {} + goal: + properties: + immediate: + default: false + description: Whether to perform immediate zero + type: boolean + required: [] + type: object + result: + properties: + success: + description: Whether zero operation was successful + type: boolean + required: + - success + type: object + required: + - goal + type: object + type: UniLabJsonCommand + module: unilabos.devices.balance.mettler_toledo_xpr.mettler_toledo_xpr:MettlerToledoXPR + status_types: + error_message: str + is_stable: bool + status: str + unit: str + weight: float + type: python + config_info: [] + description: Mettler Toledo XPR/XSR Balance Driver + handles: [] + icon: '' + init_param_schema: + description: MettlerToledoXPR __init__ parameters + properties: + feedback: {} + goal: + description: Initialization parameters for Mettler Toledo XPR balance + properties: + ip: + default: 192.168.1.10 + description: Balance IP address + type: string + password: + default: '123456' + description: Balance password + type: string + port: + default: 81 + description: Balance port number + type: integer + timeout: + default: 10 + description: Connection timeout in seconds + type: integer + required: [] + type: object + result: {} + required: + - goal + title: __init__ command parameters + type: object + version: 1.0.0 \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/balance_test.json b/unilabos/devices/balance/mettler_toledo_xpr/balance_test.json new file mode 100644 index 00000000..b5f93dc1 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/balance_test.json @@ -0,0 +1,25 @@ +{ + "nodes": [ + { + "id": "BALANCE_STATION", + "name": "METTLER_TOLEDO_XPR", + "parent": null, + "type": "device", + "class": "balance.mettler_toledo_xpr", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "ip": "192.168.1.10", + "port": 81, + "password": "123456", + "timeout": 10 + }, + "data": {}, + "children": [] + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/balance/mettler_toledo_xpr/mettler_toledo_xpr.py b/unilabos/devices/balance/mettler_toledo_xpr/mettler_toledo_xpr.py new file mode 100644 index 00000000..e103c191 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/mettler_toledo_xpr.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Mettler Toledo XPR/XSR Balance Driver for Uni-Lab OS + +This driver provides standard interface for Mettler Toledo XPR/XSR balance operations +including tare, zero, and weight reading functions. +""" + +import enum +import base64 +import hashlib +import logging +import time +from pathlib import Path +from decimal import Decimal +from typing import Tuple, Optional + +from jinja2 import Template +from requests import Session +from zeep import Client +from zeep.transports import Transport +import pprp + +# Import UniversalDriver - handle import error gracefully +try: + from unilabos.device_comms.universal_driver import UniversalDriver +except ImportError: + # Fallback for standalone testing + class UniversalDriver: + """Fallback UniversalDriver for standalone testing""" + def __init__(self): + self.success = False + + +class Outcome(enum.Enum): + """Balance operation outcome enumeration""" + SUCCESS = "Success" + ERROR = "Error" + + +class MettlerToledoXPR(UniversalDriver): + """Mettler Toledo XPR/XSR Balance Driver + + Provides standard interface for balance operations including: + - Tare (去皮) + - Zero (清零) + - Weight reading (读数) + """ + + def __init__(self, ip: str = "192.168.1.10", port: int = 81, + password: str = "123456", timeout: int = 10): + """Initialize the balance driver + + Args: + ip: Balance IP address + port: Balance port number + password: Balance password + timeout: Connection timeout in seconds + """ + super().__init__() + + self.ip = ip + self.port = port + self.password = password + self.timeout = timeout + self.api_path = "MT/Laboratory/Balance/XprXsr/V03" + + # Status properties + self._status = "Disconnected" + self._last_weight = 0.0 + self._last_unit = "g" + self._is_stable = False + self._error_message = "" + + # ROS2 action result properties + self.success = False + self.return_info = "" + + # Service objects + self.client = None + self.session_svc = None + self.weighing_svc = None + self.session_id = None + + # WSDL template path + self.wsdl_template = Path(__file__).parent / "MT.Laboratory.Balance.XprXsr.V03.wsdl" + + # Bindings + self.bindings = { + "session": "{http://MT/Laboratory/Balance/XprXsr/V03}BasicHttpBinding_ISessionService", + "weigh": "{http://MT/Laboratory/Balance/XprXsr/V03}BasicHttpBinding_IWeighingService", + } + + # Setup logging + self.logger = logging.getLogger(f"MettlerToledoXPR-{ip}") + + # Initialize connection + self._connect() + + @property + def status(self) -> str: + """Current device status""" + return self._status + + @property + def weight(self) -> float: + """Last measured weight value""" + return self._last_weight + + @property + def unit(self) -> str: + """Weight unit (e.g., 'g', 'kg')""" + return self._last_unit + + @property + def is_stable(self) -> bool: + """Whether the weight reading is stable""" + return self._is_stable + + @property + def error_message(self) -> str: + """Last error message""" + return self._error_message + + def _decrypt_session_id(self, pw: str, enc_sid: str, salt: str) -> str: + """Decrypt session ID using password and salt""" + key = hashlib.pbkdf2_hmac("sha1", pw.encode(), + base64.b64decode(salt), 1000, dklen=32) + plain = pprp.decrypt_sink( + pprp.rijndael_decrypt_gen( + key, pprp.data_source_gen(base64.b64decode(enc_sid)))) + return plain.decode() + + def _render_wsdl(self) -> Path: + """Render WSDL template with current connection parameters""" + if not self.wsdl_template.exists(): + raise FileNotFoundError(f"WSDL template not found: {self.wsdl_template}") + + text = Template(self.wsdl_template.read_text(encoding="utf-8")).render( + host=self.ip, port=self.port, api_path=self.api_path) + + wsdl_path = self.wsdl_template.parent / f"rendered_{self.ip}_{self.port}.wsdl" + wsdl_path.write_text(text, encoding="utf-8") + + return wsdl_path + + def _connect(self): + """Establish connection to the balance""" + try: + self._status = "Connecting" + + # Render WSDL + wsdl_path = self._render_wsdl() + self.logger.info(f"WSDL rendered to {wsdl_path}") + + # Create SOAP client + transport = Transport(session=Session(), timeout=self.timeout) + self.client = Client(wsdl=str(wsdl_path), transport=transport) + + # Create service proxies + base_url = f"http://{self.ip}:{self.port}/{self.api_path}" + self.session_svc = self.client.create_service( + self.bindings["session"], f"{base_url}/SessionService") + self.weighing_svc = self.client.create_service( + self.bindings["weigh"], f"{base_url}/WeighingService") + + self.logger.info("Zeep service proxies created") + + # Open session + self.logger.info("Opening session...") + reply = self.session_svc.OpenSession() + if reply.Outcome != Outcome.SUCCESS.value: + raise RuntimeError(f"OpenSession failed: {getattr(reply, 'ErrorMessage', '')}") + + self.session_id = self._decrypt_session_id( + self.password, reply.SessionId, reply.Salt) + + self.logger.info(f"Session established successfully, SessionId={self.session_id}") + self._status = "Connected" + self._error_message = "" + + except Exception as e: + self._status = "Error" + self._error_message = str(e) + self.logger.error(f"Connection failed: {e}") + raise + + def _ensure_connected(self): + """Ensure the device is connected""" + if self._status != "Connected" or self.session_id is None: + self._connect() + + def tare(self, immediate: bool = False) -> bool: + """Perform tare operation (去皮) + + Args: + immediate: Whether to perform immediate tare + + Returns: + bool: True if successful, False otherwise + """ + try: + self._ensure_connected() + self._status = "Taring" + + self.logger.info(f"Performing tare (immediate={immediate})...") + reply = self.weighing_svc.Tare(self.session_id, immediate) + + if reply.Outcome != Outcome.SUCCESS.value: + error_msg = getattr(reply, 'ErrorMessage', 'Unknown error') + self.logger.error(f"Tare failed: {error_msg}") + self._error_message = f"Tare failed: {error_msg}" + self._status = "Error" + return False + + self.logger.info("Tare completed successfully") + self._status = "Connected" + self._error_message = "" + return True + + except Exception as e: + self.logger.error(f"Tare operation failed: {e}") + self._error_message = str(e) + self._status = "Error" + return False + + def zero(self, immediate: bool = False) -> bool: + """Perform zero operation (清零) + + Args: + immediate: Whether to perform immediate zero + + Returns: + bool: True if successful, False otherwise + """ + try: + self._ensure_connected() + self._status = "Zeroing" + + self.logger.info(f"Performing zero (immediate={immediate})...") + reply = self.weighing_svc.Zero(self.session_id, immediate) + + if reply.Outcome != Outcome.SUCCESS.value: + error_msg = getattr(reply, 'ErrorMessage', 'Unknown error') + self.logger.error(f"Zero failed: {error_msg}") + self._error_message = f"Zero failed: {error_msg}" + self._status = "Error" + return False + + self.logger.info("Zero completed successfully") + self._status = "Connected" + self._error_message = "" + return True + + except Exception as e: + self.logger.error(f"Zero operation failed: {e}") + self._error_message = str(e) + self._status = "Error" + return False + + def get_weight(self) -> float: + """Get current weight reading (读数) + + Returns: + float: Weight value + """ + try: + self._ensure_connected() + self._status = "Reading" + + self.logger.info("Getting weight...") + reply = self.weighing_svc.GetWeight(self.session_id) + + if reply.Outcome != Outcome.SUCCESS.value: + error_msg = getattr(reply, 'ErrorMessage', 'Unknown error') + self.logger.error(f"GetWeight failed: {error_msg}") + self._error_message = f"GetWeight failed: {error_msg}" + self._status = "Error" + return 0.0 + + # Handle different response structures + if hasattr(reply, 'WeightSample'): + # Handle WeightSample structure (most common for XPR) + weight_sample = reply.WeightSample + if hasattr(weight_sample, 'NetWeight'): + weight_val = float(Decimal(weight_sample.NetWeight.Value)) + weight_unit = weight_sample.NetWeight.Unit + elif hasattr(weight_sample, 'GrossWeight'): + weight_val = float(Decimal(weight_sample.GrossWeight.Value)) + weight_unit = weight_sample.GrossWeight.Unit + else: + weight_val = 0.0 + weight_unit = 'g' + is_stable = getattr(weight_sample, 'Stable', True) + elif hasattr(reply, 'Weight'): + weight_val = float(Decimal(reply.Weight.Value)) + weight_unit = reply.Weight.Unit + is_stable = getattr(reply.Weight, 'IsStable', True) + elif hasattr(reply, 'Value'): + weight_val = float(Decimal(reply.Value)) + weight_unit = getattr(reply, 'Unit', 'g') + is_stable = getattr(reply, 'IsStable', True) + else: + # Try to extract from reply attributes + weight_val = float(Decimal(getattr(reply, 'WeightValue', getattr(reply, 'Value', '0')))) + weight_unit = getattr(reply, 'WeightUnit', getattr(reply, 'Unit', 'g')) + is_stable = getattr(reply, 'IsStable', True) + + # Convert to grams for consistent output (ROS2 requirement) + if weight_unit.lower() in ['milligram', 'mg']: + weight_val_grams = weight_val / 1000.0 + elif weight_unit.lower() in ['kilogram', 'kg']: + weight_val_grams = weight_val * 1000.0 + elif weight_unit.lower() in ['gram', 'g']: + weight_val_grams = weight_val + else: + # Default to assuming grams if unit is unknown + weight_val_grams = weight_val + self.logger.warning(f"Unknown weight unit: {weight_unit}, assuming grams") + + # Update internal state (keep original values for reference) + self._last_weight = weight_val + self._last_unit = weight_unit + self._is_stable = is_stable + + self.logger.info(f"Weight: {weight_val_grams} g (original: {weight_val} {weight_unit})") + self._status = "Connected" + self._error_message = "" + + return weight_val_grams + + except Exception as e: + self.logger.error(f"Get weight failed: {e}") + self._error_message = str(e) + self._status = "Error" + return 0.0 + + def get_weight_with_unit(self) -> Tuple[float, str]: + """Get current weight reading with unit (读数含单位) + + Returns: + Tuple[float, str]: Weight value and unit + """ + try: + self._ensure_connected() + self._status = "Reading" + + self.logger.info("Getting weight with unit...") + reply = self.weighing_svc.GetWeight(self.session_id) + + if reply.Outcome != Outcome.SUCCESS.value: + error_msg = getattr(reply, 'ErrorMessage', 'Unknown error') + self.logger.error(f"GetWeight failed: {error_msg}") + self._error_message = f"GetWeight failed: {error_msg}" + self._status = "Error" + return 0.0, "" + + # Handle different response structures + if hasattr(reply, 'WeightSample'): + # Handle WeightSample structure (most common for XPR) + weight_sample = reply.WeightSample + if hasattr(weight_sample, 'NetWeight'): + weight_val = float(Decimal(weight_sample.NetWeight.Value)) + weight_unit = weight_sample.NetWeight.Unit + elif hasattr(weight_sample, 'GrossWeight'): + weight_val = float(Decimal(weight_sample.GrossWeight.Value)) + weight_unit = weight_sample.GrossWeight.Unit + else: + weight_val = 0.0 + weight_unit = 'g' + is_stable = getattr(weight_sample, 'Stable', True) + elif hasattr(reply, 'Weight'): + weight_val = float(Decimal(reply.Weight.Value)) + weight_unit = reply.Weight.Unit + is_stable = getattr(reply.Weight, 'IsStable', True) + elif hasattr(reply, 'Value'): + weight_val = float(Decimal(reply.Value)) + weight_unit = getattr(reply, 'Unit', 'g') + is_stable = getattr(reply, 'IsStable', True) + else: + # Try to extract from reply attributes + weight_val = float(Decimal(getattr(reply, 'WeightValue', getattr(reply, 'Value', '0')))) + weight_unit = getattr(reply, 'WeightUnit', getattr(reply, 'Unit', 'g')) + is_stable = getattr(reply, 'IsStable', True) + + # Update internal state + self._last_weight = weight_val + self._last_unit = weight_unit + self._is_stable = is_stable + + self.logger.info(f"Weight: {weight_val} {weight_unit}") + self._status = "Connected" + self._error_message = "" + + return weight_val, weight_unit + + except Exception as e: + self.logger.error(f"Get weight with unit failed: {e}") + self._error_message = str(e) + self._status = "Error" + return 0.0, "" + + + + + + def send_cmd(self, command: str) -> dict: + """ROS2 SendCmd action handler + + Args: + command: JSON string containing command and parameters + + Returns: + dict: Result containing success status and return_info + """ + return self.execute_command_from_outer(command) + + def execute_command_from_outer(self, command: str) -> dict: + """Execute command from ROS2 SendCmd action + + Args: + command: JSON string containing command and parameters + + Returns: + dict: Result containing success status and return_info + """ + try: + import json + # Parse JSON command + cmd_data = json.loads(command.replace("'", '"').replace("False", "false").replace("True", "true")) + + # Extract command name and parameters + cmd_name = cmd_data.get('command', '') + params = cmd_data.get('params', {}) + + self.logger.info(f"Executing command: {cmd_name} with params: {params}") + + # Execute different commands + if cmd_name == 'tare': + immediate = params.get('immediate', False) + success = self.tare(immediate) + result = { + 'success': success, + 'return_info': f"Tare operation {'successful' if success else 'failed'}" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + + elif cmd_name == 'zero': + immediate = params.get('immediate', False) + success = self.zero(immediate) + result = { + 'success': success, + 'return_info': f"Zero operation {'successful' if success else 'failed'}" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + + elif cmd_name == 'read' or cmd_name == 'get_weight': + try: + self.logger.info(f"Executing {cmd_name} command via ROS2...") + self.logger.info(f"Current status: {self._status}") + + # Use get_weight to get weight value (returns float in grams) + weight_grams = self.get_weight() + self.logger.info(f"get_weight() returned: {weight_grams} g") + + # Get the original weight and unit for display + original_weight = getattr(self, '_last_weight', weight_grams) + original_unit = getattr(self, '_last_unit', 'g') + self.logger.info(f"Original reading: {original_weight} {original_unit}") + + result = { + 'success': True, + 'return_info': f"Weight: {original_weight} {original_unit}" + } + except Exception as e: + self.logger.error(f"Exception in {cmd_name}: {str(e)}") + self.logger.error(f"Exception type: {type(e).__name__}") + import traceback + self.logger.error(f"Traceback: {traceback.format_exc()}") + result = { + 'success': False, + 'return_info': f"Failed to read weight: {str(e)}" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + + + + else: + result = { + 'success': False, + 'return_info': f"Unknown command: {cmd_name}. Available commands: tare, zero, read" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + + except json.JSONDecodeError as e: + self.logger.error(f"JSON parsing failed: {e}") + result = { + 'success': False, + 'return_info': f"JSON parsing failed: {str(e)}" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + except Exception as e: + self.logger.error(f"Command execution failed: {e}") + result = { + 'success': False, + 'return_info': f"Command execution failed: {str(e)}" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + + def __del__(self): + """Cleanup when object is destroyed""" + self.disconnect() + + +if __name__ == "__main__": + # Test the driver + import argparse + + parser = argparse.ArgumentParser(description="Mettler Toledo XPR Balance Driver Test") + parser.add_argument("--ip", default="192.168.1.10", help="Balance IP address") + parser.add_argument("--port", type=int, default=81, help="Balance port") + parser.add_argument("--password", default="123456", help="Balance password") + parser.add_argument("action", choices=["tare", "zero", "read"], + nargs="?", default="read", help="Action to perform") + parser.add_argument("--immediate", action="store_true", help="Use immediate mode") + + args = parser.parse_args() + + # Setup logging + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s") + + # Create driver instance + balance = MettlerToledoXPR(ip=args.ip, port=args.port, password=args.password) + + try: + if args.action == "tare": + success = balance.tare(args.immediate) + print(f"Tare {'successful' if success else 'failed'}") + elif args.action == "zero": + success = balance.zero(args.immediate) + print(f"Zero {'successful' if success else 'failed'}") + else: # read + # Perform tare first, then read weight + if balance.tare(args.immediate): + weight, unit = balance.get_weight_with_unit() + print(f"Weight: {weight} {unit}") + else: + print("Tare operation failed, cannot read weight") + + finally: + balance.disconnect() \ No newline at end of file diff --git a/unilabos/devices/zhida_gcms/Zhida_GCMS_ROS2_User_Guide.md b/unilabos/devices/zhida_gcms/Zhida_GCMS_ROS2_User_Guide.md new file mode 100644 index 00000000..74326c7a --- /dev/null +++ b/unilabos/devices/zhida_gcms/Zhida_GCMS_ROS2_User_Guide.md @@ -0,0 +1,148 @@ +# 智达GCMS ROS2使用指南 / Zhida GCMS ROS2 User Guide + +## 概述 / Overview + +智达GCMS设备支持通过ROS2动作进行操作,包括CSV文件分析启动、设备状态查询等功能。 + +The Zhida GCMS device supports operations through ROS2 actions, including CSV file analysis startup, device status queries, and other functions. + +## 主要功能 / Main Features + +### 1. CSV文件分析启动 / CSV File Analysis Startup (`start_with_csv_file`) + +- **功能 / Function**: 接收CSV文件路径,自动读取文件内容并启动分析 / Receives CSV file path, automatically reads file content and starts analysis +- **输入 / Input**: CSV文件的绝对路径 / Absolute path of CSV file +- **输出 / Output**: `{"return_info": str, "success": bool}` + +### 2. 设备状态查询 / Device Status Query (`get_status`) + +- **功能 / Function**: 获取设备当前运行状态 / Get current device running status +- **输出 / Output**: 设备状态字符串(如"RunSample"、"Idle"等)/ Device status string (e.g., "RunSample", "Idle", etc.) + +### 3. 方法列表查询 / Method List Query (`get_methods`) + +- **功能 / Function**: 获取设备支持的所有方法列表 / Get all method lists supported by the device +- **输出 / Output**: 方法列表字典 / Method list dictionary + +### 4. 放盘操作 / Tray Operation (`put_tray`) + +- **功能 / Function**: 控制设备准备样品托盘 / Control device to prepare sample tray +- **输出 / Output**: 操作结果信息 / Operation result information + +### 5. 停止运行 / Stop Operation (`abort`) + +- **功能 / Function**: 中止当前正在进行的分析任务 / Abort current analysis task in progress +- **输出 / Output**: 操作结果信息 / Operation result information + +### 6. 获取版本信息 / Get Version Information (`get_version`) + +- **功能 / Function**: 查询设备接口版本和固件版本信息 / Query device interface version and firmware version information +- **输出 / Output**: 版本信息字典 / Version information dictionary + +## 使用方法 / Usage Methods + +### ROS2命令行使用 / ROS2 Command Line Usage + +### 1. 查询设备状态 / Query Device Status + +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/get_status unilabos_msgs/action/EmptyIn "{}" +``` + +### 2. 查询方法列表 / Query Method List + +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/get_methods unilabos_msgs/action/EmptyIn "{}" +``` + +### 3. 启动分析 / Start Analysis + +使用CSV文件启动分析 / Start analysis using CSV file: +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/start_with_csv_file unilabos_msgs/action/StrSingleInput "{string: 'D:/path/to/your/samples.csv'}" +``` + +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/start_with_csv_file unilabos_msgs/action/StrSingleInput "{string: 'd:/UniLab/Uni-Lab-OS/unilabos/devices/zhida_gcms/zhida_gcms-test_3.csv'}" + +使用Base64编码数据启动分析 / Start analysis using Base64 encoded data: +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/start unilabos_msgs/action/StrSingleInput "{string: 'U2FtcGxlTmFtZSxBY3FNZXRob2QsUmFja0NvZGUsVmlhbFBvcyxTbXBsSW5qVm9sLE91dHB1dEZpbGU...'}" +``` +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/start unilabos_msgs/action/StrSingleInput "{string: 'U2FtcGxlTmFtZSxBY3FNZXRob2QsUmFja0NvZGUsVmlhbFBvcyxTbXBsSW5qVm9sLE91dHB1dEZpbGUKU2FtcGxlMDAxLDIwMjUwNjA0LXRlc3QsUmFjayAxLDEsMSwvQ2hyb21lbGVvbkxvY2FsL++/veixuO+/vcSyxLzvv73vv73vv73vv73vv73vv73vv73vv73vv70vMjAyNTA2MDQK'}" + +### 4. 放盘操作 / Tray Operation + +**注意 / Note**: 放盘操作是特殊场景下使用的功能,比如机械臂比较短需要让开位置,或者盘支架是可移动的时候,这个指令让进样器也去做相应动作。在当前配置中,空间足够,不需要这个额外的控制组件。 + +**Note**: The tray operation is used in special scenarios, such as when the robotic arm is relatively short and needs to make room, or when the tray bracket is movable, this command makes the injector perform corresponding actions. In the current configuration, the space is sufficient and this additional control component is not needed. + +准备样品托盘 / Prepare sample tray: +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/put_tray unilabos_msgs/action/EmptyIn "{}" +``` + +### 5. 停止运行 / Stop Operation + +中止当前分析任务(注意!运行中发现任务运行中止,需要人工在InLab Solution 二次点击确认)/ Abort current analysis task (Note! If task abortion is detected during operation, manual confirmation is required by clicking twice in InLab Solution): +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/abort unilabos_msgs/action/EmptyIn "{}" +``` + +### 6. 获取版本信息 / Get Version Information + +查询设备版本 / Query device version: +```bash +ros2 action send_goal /devices/ZHIDA_GCMS_STATION/get_version unilabos_msgs/action/EmptyIn "{}" +``` + +### Python代码使用 / Python Code Usage + +```python +from unilabos.devices.zhida_gcms.zhida import ZhidaClient + +# 初始化客户端 / Initialize client +client = ZhidaClient(host='192.168.3.184', port=5792) +client.connect() + +# 使用CSV文件启动分析 / Start analysis using CSV file +result = client.start_with_csv_file('/path/to/your/file.csv') +print(f"成功 / Success: {result['success']}") +print(f"信息 / Info: {result['return_info']}") + +# 查询设备状态 / Query device status +status = client.get_status() +print(f"设备状态 / Device Status: {status}") + +client.close() +``` + +## 使用注意事项 / Usage Notes + +1. **文件路径 / File Path**: 必须使用绝对路径 / Must use absolute path +2. **文件格式 / File Format**: CSV文件必须是UTF-8编码 / CSV file must be UTF-8 encoded +3. **设备连接 / Device Connection**: 确保智达GCMS设备已连接并可访问 / Ensure Zhida GCMS device is connected and accessible +4. **权限 / Permissions**: 确保有读取CSV文件的权限 / Ensure you have permission to read CSV files + +## 故障排除 / Troubleshooting + +### 常见问题 / Common Issues + +1. **文件路径错误 / File Path Error**: 确保使用绝对路径且文件存在 / Ensure using absolute path and file exists +2. **编码问题 / Encoding Issue**: 确保CSV文件是UTF-8编码 / Ensure CSV file is UTF-8 encoded +3. **设备连接 / Device Connection**: 检查网络连接和设备状态 / Check network connection and device status +4. **权限问题 / Permission Issue**: 确保有文件读取权限 / Ensure you have file read permissions + +### 设备状态说明 / Device Status Description + +- `"Idle"`: 设备空闲状态 / Device idle status +- `"RunSample"`: 正在运行样品分析 / Running sample analysis +- `"Error"`: 设备错误状态 / Device error status + +## 总结 / Summary + +智达GCMS设备现在支持 / Zhida GCMS device now supports: + +1. 直接通过ROS2命令输入CSV文件路径启动分析 / Direct CSV file path input via ROS2 commands to start analysis +2. 按需查询设备状态和方法列表 / On-demand device status and method list queries +3. 完善的错误处理和日志记录 / Comprehensive error handling and logging +4. 简化的操作流程 / Simplified operation workflow \ No newline at end of file diff --git a/unilabos/devices/zhida_gcms/__init__.py b/unilabos/devices/zhida_gcms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/zhida_gcms/device_test.json b/unilabos/devices/zhida_gcms/device_test.json new file mode 100644 index 00000000..b9ca9516 --- /dev/null +++ b/unilabos/devices/zhida_gcms/device_test.json @@ -0,0 +1,24 @@ +{ + "nodes": [ + { + "id": "ZHIDA_GCMS_STATION", + "name": "ZHIDA_GCMS", + "parent": null, + "type": "device", + "class": "zhida_gcms", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "host": "192.168.3.184", + "port": 5792, + "timeout": 10.0 + }, + "data": {}, + "children": [] + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/zhida_gcms/zhida.py b/unilabos/devices/zhida_gcms/zhida.py new file mode 100644 index 00000000..7f0cd21e --- /dev/null +++ b/unilabos/devices/zhida_gcms/zhida.py @@ -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() diff --git a/unilabos/devices/zhida_gcms/zhida_gcms-test_1.csv b/unilabos/devices/zhida_gcms/zhida_gcms-test_1.csv new file mode 100644 index 00000000..5e6452e9 --- /dev/null +++ b/unilabos/devices/zhida_gcms/zhida_gcms-test_1.csv @@ -0,0 +1,2 @@ +SampleName,AcqMethod,RackCode,VialPos,SmplInjVol,OutputFile +Sample001,/ChromeleonLocal/�豸�IJļ���������/20250604/20231113.seq/20250604-test,Rack 1,1,1,/ChromeleonLocal/�豸�IJļ���������/20250604 diff --git a/unilabos/registry/devices/balance.yaml b/unilabos/registry/devices/balance.yaml new file mode 100644 index 00000000..10894611 --- /dev/null +++ b/unilabos/registry/devices/balance.yaml @@ -0,0 +1,256 @@ +balance.mettler_toledo_xpr: + category: + - balance + class: + action_value_mappings: + disconnect: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: + success: success + schema: + description: Disconnect from balance + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + success: + description: Whether disconnect was successful + type: boolean + required: + - success + type: object + required: + - goal + type: object + type: UniLabJsonCommand + get_weight: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: + unit: unit + weight: weight + schema: + description: Get current weight reading + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + unit: + description: Weight unit (e.g., g, kg) + type: string + weight: + description: Weight value + type: number + required: + - weight + - unit + type: object + required: + - goal + type: object + type: UniLabJsonCommand + read_with_tare: + feedback: {} + goal: + immediate_tare: immediate_tare + goal_default: + immediate_tare: true + handles: [] + result: + unit: unit + weight: weight + schema: + description: Perform tare then read weight (standard read operation) + properties: + feedback: {} + goal: + properties: + immediate_tare: + default: true + description: Whether to use immediate tare + type: boolean + required: [] + type: object + result: + properties: + unit: + description: Weight unit (e.g., g, kg) + type: string + weight: + description: Weight value after tare + type: number + required: + - weight + - unit + type: object + required: + - goal + type: object + type: UniLabJsonCommand + send_cmd: + feedback: {} + goal: + command: command + goal_default: + command: '' + handles: [] + result: + return_info: return_info + success: success + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: SendCmd_Feedback + type: object + goal: + properties: + command: + type: string + required: + - command + title: SendCmd_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: SendCmd_Result + type: object + required: + - goal + title: SendCmd + type: object + type: SendCmd + tare: + feedback: {} + goal: + immediate: immediate + goal_default: + immediate: false + handles: [] + result: + success: success + schema: + description: Tare operation for balance + properties: + feedback: {} + goal: + properties: + immediate: + default: false + description: Whether to perform immediate tare + type: boolean + required: [] + type: object + result: + properties: + success: + description: Whether tare operation was successful + type: boolean + required: + - success + type: object + required: + - goal + type: object + type: UniLabJsonCommand + zero: + feedback: {} + goal: + immediate: immediate + goal_default: + immediate: false + handles: [] + result: + success: success + schema: + description: Zero operation for balance + properties: + feedback: {} + goal: + properties: + immediate: + default: false + description: Whether to perform immediate zero + type: boolean + required: [] + type: object + result: + properties: + success: + description: Whether zero operation was successful + type: boolean + required: + - success + type: object + required: + - goal + type: object + type: UniLabJsonCommand + module: unilabos.devices.balance.mettler_toledo_xpr.mettler_toledo_xpr:MettlerToledoXPR + status_types: + error_message: str + is_stable: bool + status: str + unit: str + weight: float + type: python + config_info: [] + description: Mettler Toledo XPR/XSR Balance Driver + handles: [] + icon: '' + init_param_schema: + description: MettlerToledoXPR __init__ parameters + properties: + feedback: {} + goal: + description: Initialization parameters for Mettler Toledo XPR balance + properties: + ip: + default: 192.168.1.10 + description: Balance IP address + type: string + password: + default: '123456' + description: Balance password + type: string + port: + default: 81 + description: Balance port number + type: integer + timeout: + default: 10 + description: Connection timeout in seconds + type: integer + required: [] + type: object + result: {} + required: + - goal + title: __init__ command parameters + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/zhida_gcms.yaml b/unilabos/registry/devices/zhida_gcms.yaml new file mode 100644 index 00000000..a73118fa --- /dev/null +++ b/unilabos/registry/devices/zhida_gcms.yaml @@ -0,0 +1,315 @@ +zhida_gcms: + category: + - zhida_gcms + class: + action_value_mappings: + abort: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + auto-close: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: 安全关闭与智达 GCMS 设备的 TCP 连接,释放网络资源。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: close 参数 + type: object + type: UniLabJsonCommand + auto-connect: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: 与智达 GCMS 设备建立 TCP 连接,配置超时参数。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: connect 参数 + type: object + type: UniLabJsonCommand + get_methods: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + get_status: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + get_version: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + put_tray: + feedback: {} + goal: {} + goal_default: {} + handles: [] + result: {} + schema: + description: '放盘操作,准备InLabPAL进样器。注意:此功能仅在特殊场景下使用,例如机械臂比较短需要让开位置,或盘支架可移动时需要进样器配合动作。对于宜宾深势配置,空间足够,不需要此额外控制组件。' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + start: + feedback: {} + goal: + string: string + goal_default: + string: '' + handles: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: StrSingleInput_Feedback + type: object + goal: + properties: + string: + type: string + required: + - string + title: StrSingleInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: StrSingleInput_Result + type: object + required: + - goal + title: StrSingleInput + type: object + type: StrSingleInput + start_with_csv_file: + feedback: {} + goal: + string: string + goal_default: + string: '' + handles: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: StrSingleInput_Feedback + type: object + goal: + properties: + string: + type: string + required: + - string + title: StrSingleInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: StrSingleInput_Result + type: object + required: + - goal + title: StrSingleInput + type: object + type: StrSingleInput + module: unilabos.devices.zhida_gcms.zhida:ZhidaClient + status_types: {} + type: python + config_info: [] + description: 智达气相色谱-质谱联用(GC-MS)分析设备,通过 TCP 通信实现远程控制与状态监控,支持方法管理与任务启动等功能。 + handles: [] + icon: '' + init_param_schema: + feedback: + properties: {} + required: [] + type: object + goal: + properties: + host: + default: 192.168.3.184 + type: string + port: + default: 5792 + type: integer + timeout: + default: 10.0 + type: number + required: [] + type: object + result: + properties: {} + required: [] + type: object + version: 1.0.0