From ee29344c179b5e87f1c32e4c3101c6725d4d19df Mon Sep 17 00:00:00 2001 From: Andy6M Date: Wed, 6 Aug 2025 21:24:13 +0800 Subject: [PATCH] feat(devices): add mettler_toledo xpr 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 | 268 +++++++ .../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 | 654 ++++++++++++++++++ unilabos/devices/zhida_gcms/__init__.py | 0 unilabos/registry/devices/balance.yaml | 256 +++++++ 15 files changed, 1658 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/__init__.py create mode 100644 unilabos/registry/devices/balance.yaml diff --git a/.gitignore b/.gitignore index 5fab5d5..9d40cbc 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 c84e045..7e27d9f 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 ca9a96f..91ce422 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 7f9675d..b56f28a 100644 --- a/unilabos-osx-arm64.yaml +++ b/unilabos-osx-arm64.yaml @@ -65,6 +65,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 b2065a0..c3d09c6 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 0000000..c627116 --- /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 0000000..aacf70c --- /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 0000000..747dc61 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/Mettler_Toledo_Balance_ROS2_User_Guide.md @@ -0,0 +1,268 @@ +# 梅特勒天平 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 + +### 4. 带去皮读取重量 / Read with Tare (`read_with_tare`) + +- **功能 / Function**: 先去皮再读取重量 / Tare first then read weight +- **输入 / Input**: 无参数 / No parameters +- **输出 / Output**: `{"return_info": str, "success": bool}` - 包含去皮后的重量信息 / Contains weight information after taring + +### 5. 断开连接 / Disconnect (`disconnect`) + +- **功能 / Function**: 断开与天平的连接 / Disconnect from the balance +- **输入 / Input**: 无参数 / No parameters +- **输出 / Output**: `{"return_info": str, "success": bool}` - 断开连接结果 / Disconnection result + +## 使用方法 / 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\"}' +}" +``` + +或者使用别名 / Or use alias: + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"get_weight\"}' +}" +``` + +### 4. 带去皮读取重量 / Read with Tare + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"read_with_tare\"}' +}" +``` + +### 5. 断开连接 / Disconnect + +```bash +ros2 action send_goal /devices/BALANCE_STATION/send_cmd unilabos_msgs/action/SendCmd "{ + command: '{\"command\": \"disconnect\"}' +}" +``` + +## 命令格式说明 / 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') + + def read_with_tare(self): + """带去皮读取重量 / Read weight with tare""" + return self.send_command('read_with_tare') + + def disconnect_balance(self): + """断开连接 / Disconnect""" + return self.send_command('disconnect') + +# 使用示例 / 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 0000000..d46a927 --- /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 0000000..80b47f7 --- /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 0000000..19bc241 --- /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 0000000..b5f93dc --- /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 0000000..b7e5100 --- /dev/null +++ b/unilabos/devices/balance/mettler_toledo_xpr/mettler_toledo_xpr.py @@ -0,0 +1,654 @@ +#!/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(): + error_msg = ( + f"WSDL file not found: {self.wsdl_template}\n\n" + "IMPORTANT: You need to obtain the official WSDL file from Mettler Toledo.\n" + "Please follow these steps:\n" + "1. Contact Mettler Toledo support to get the WSDL file\n" + "2. Place it in the driver directory as 'MT.Laboratory.Balance.XprXsr.V03.wsdl'\n" + "3. Ensure it contains Jinja2 template variables: {{host}}, {{port}}, {{api_path}}\n\n" + "For detailed instructions, see the README.md file in the driver directory." + ) + raise FileNotFoundError(error_msg) + + try: + text = Template(self.wsdl_template.read_text(encoding="utf-8")).render( + host=self.ip, port=self.port, api_path=self.api_path) + except Exception as e: + error_msg = ( + f"Failed to render WSDL template: {e}\n\n" + "This usually means the WSDL file doesn't contain the required template variables.\n" + "Please ensure your WSDL file contains: {{host}}, {{port}}, {{api_path}}\n" + "See README.md for detailed configuration instructions." + ) + raise RuntimeError(error_msg) from e + + 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 read_with_tare(self, immediate_tare: bool = True) -> Tuple[float, str]: + """Perform tare then read weight (standard read operation) + + Args: + immediate_tare: Whether to use immediate tare + + Returns: + Tuple[float, str]: Weight value and unit + """ + try: + # Try immediate tare first + if not self.tare(immediate_tare): + # If immediate tare fails and it's an LFT balance, try normal tare + if immediate_tare and "Tare immediate cannot be executed" in self._error_message: + self.logger.warning("LFT balance doesn't support immediate tare, using normal tare") + if not self.tare(False): + return 0.0, "" + else: + return 0.0, "" + + # Small delay to ensure tare is complete + time.sleep(0.5) + + # Get weight + return self.get_weight_with_unit() + + except Exception as e: + self.logger.error(f"Read with tare failed: {e}") + self._error_message = str(e) + self._status = "Error" + return 0.0, "" + + def disconnect(self): + """Disconnect from the balance""" + try: + if self.session_svc and self.session_id: + self.session_svc.CloseSession(self.session_id) + self.logger.info("Session closed") + except Exception as e: + self.logger.warning(f"Error closing session: {e}") + finally: + self.session_id = None + self.session_svc = None + self.weighing_svc = None + self.client = None + self._status = "Disconnected" + + 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 + + elif cmd_name == 'read_with_tare': + try: + weight, unit = self.read_with_tare() + result = { + 'success': True, + 'return_info': f"Weight with tare: {weight} {unit}" + } + except Exception as e: + result = { + 'success': False, + 'return_info': f"Failed to read weight with tare: {str(e)}" + } + # Update instance attributes for ROS2 action system + self.success = result['success'] + self.return_info = result['return_info'] + return result + + elif cmd_name == 'disconnect': + self.disconnect() + result = { + 'success': True, + 'return_info': "Disconnect successful" + } + # 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, read_with_tare, disconnect" + } + # 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 + weight, unit = balance.read_with_tare() + print(f"Weight: {weight} {unit}") + + finally: + balance.disconnect() \ 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 0000000..e69de29 diff --git a/unilabos/registry/devices/balance.yaml b/unilabos/registry/devices/balance.yaml new file mode 100644 index 0000000..1089461 --- /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