diff --git a/.cursor/rules/device-drivers.mdc b/.cursor/rules/device-drivers.mdc new file mode 100644 index 000000000..8adfb33c1 --- /dev/null +++ b/.cursor/rules/device-drivers.mdc @@ -0,0 +1,328 @@ +--- +description: 设备驱动开发规范 +globs: ["unilabos/devices/**/*.py"] +--- + +# 设备驱动开发规范 + +## 目录结构 + +``` +unilabos/devices/ +├── virtual/ # 虚拟设备(用于测试) +│ ├── virtual_stirrer.py +│ └── virtual_centrifuge.py +├── liquid_handling/ # 液体处理设备 +├── balance/ # 天平设备 +├── hplc/ # HPLC设备 +├── pump_and_valve/ # 泵和阀门 +├── temperature/ # 温度控制设备 +├── workstation/ # 工作站(组合设备) +└── ... +``` + +## 设备类完整模板 + +```python +import asyncio +import logging +import time as time_module +from typing import Dict, Any, Optional + +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + + +class MyDevice: + """ + 设备类描述 + + Attributes: + device_id: 设备唯一标识 + config: 设备配置字典 + data: 设备状态数据 + """ + + _ros_node: BaseROS2DeviceNode + + def __init__( + self, + device_id: str = None, + config: Dict[str, Any] = None, + **kwargs + ): + """ + 初始化设备 + + Args: + device_id: 设备ID + config: 配置字典 + **kwargs: 其他参数 + """ + # 兼容不同调用方式 + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.data = {} + + # 从config读取参数 + self.port = self.config.get('port') or kwargs.get('port', 'COM1') + self._max_value = self.config.get('max_value', 1000.0) + + # 初始化日志 + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + + self.logger.info(f"设备 {self.device_id} 已创建") + + def post_init(self, ros_node: BaseROS2DeviceNode): + """ + ROS节点注入 - 在ROS节点创建后调用 + + Args: + ros_node: ROS2设备节点实例 + """ + self._ros_node = ros_node + + async def initialize(self) -> bool: + """ + 初始化设备 - 连接硬件、设置初始状态 + + Returns: + bool: 初始化是否成功 + """ + self.logger.info(f"初始化设备 {self.device_id}") + + try: + # 执行硬件初始化 + # await self._connect_hardware() + + # 设置初始状态 + self.data.update({ + "status": "待机", + "is_running": False, + "current_value": 0.0, + }) + + self.logger.info(f"设备 {self.device_id} 初始化完成") + return True + + except Exception as e: + self.logger.error(f"初始化失败: {e}") + self.data["status"] = f"错误: {e}" + return False + + async def cleanup(self) -> bool: + """ + 清理设备 - 断开连接、释放资源 + + Returns: + bool: 清理是否成功 + """ + self.logger.info(f"清理设备 {self.device_id}") + + self.data.update({ + "status": "离线", + "is_running": False, + }) + + return True + + # ==================== 设备动作 ==================== + + async def execute_action( + self, + param1: float, + param2: str = "", + **kwargs + ) -> bool: + """ + 执行设备动作 + + Args: + param1: 参数1 + param2: 参数2(可选) + + Returns: + bool: 动作是否成功 + """ + # 类型转换和验证 + try: + param1 = float(param1) + except (ValueError, TypeError) as e: + self.logger.error(f"参数类型错误: {e}") + return False + + # 参数验证 + if param1 > self._max_value: + self.logger.error(f"参数超出范围: {param1} > {self._max_value}") + return False + + self.logger.info(f"执行动作: param1={param1}, param2={param2}") + + # 更新状态 + self.data.update({ + "status": "运行中", + "is_running": True, + }) + + # 执行动作(带进度反馈) + duration = 10.0 # 秒 + start_time = time_module.time() + + while True: + elapsed = time_module.time() - start_time + remaining = max(0, duration - elapsed) + progress = min(100, (elapsed / duration) * 100) + + self.data.update({ + "status": f"运行中: {progress:.0f}%", + "remaining_time": remaining, + }) + + if remaining <= 0: + break + + await self._ros_node.sleep(1.0) + + # 完成 + self.data.update({ + "status": "完成", + "is_running": False, + }) + + self.logger.info("动作执行完成") + return True + + # ==================== 状态属性 ==================== + + @property + def status(self) -> str: + """设备状态 - 自动发布为ROS Topic""" + return self.data.get("status", "未知") + + @property + def is_running(self) -> bool: + """是否正在运行""" + return self.data.get("is_running", False) + + @property + def current_value(self) -> float: + """当前值""" + return self.data.get("current_value", 0.0) + + # ==================== 辅助方法 ==================== + + def get_device_info(self) -> Dict[str, Any]: + """获取设备信息""" + return { + "device_id": self.device_id, + "status": self.status, + "is_running": self.is_running, + "current_value": self.current_value, + } + + def __str__(self) -> str: + return f"MyDevice({self.device_id}: {self.status})" +``` + +## 关键规则 + +### 1. 参数处理 + +所有动作方法的参数都可能以字符串形式传入,必须进行类型转换: + +```python +async def my_action(self, value: float, **kwargs) -> bool: + # 始终进行类型转换 + try: + value = float(value) + except (ValueError, TypeError) as e: + self.logger.error(f"参数类型错误: {e}") + return False +``` + +### 2. vessel 参数处理 + +vessel 参数可能是字符串ID或字典: + +```python +def extract_vessel_id(vessel: Union[str, dict]) -> str: + if isinstance(vessel, dict): + return vessel.get("id", "") + return str(vessel) if vessel else "" +``` + +### 3. 状态更新 + +使用 `self.data` 字典存储状态,属性读取状态: + +```python +# 更新状态 +self.data["status"] = "运行中" +self.data["current_speed"] = 300.0 + +# 读取状态(通过属性) +@property +def status(self) -> str: + return self.data.get("status", "待机") +``` + +### 4. 异步等待 + +使用 ROS 节点的 sleep 方法: + +```python +# 正确 +await self._ros_node.sleep(1.0) + +# 避免(除非在纯 Python 测试环境) +await asyncio.sleep(1.0) +``` + +### 5. 进度反馈 + +长时间运行的操作需要提供进度反馈: + +```python +while remaining > 0: + progress = (elapsed / total_time) * 100 + self.data["status"] = f"运行中: {progress:.0f}%" + self.data["remaining_time"] = remaining + + await self._ros_node.sleep(1.0) +``` + +## 虚拟设备 + +虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录: + +- 类名以 `Virtual` 开头 +- 文件名以 `virtual_` 开头 +- 模拟真实设备的行为和时序 +- 使用表情符号增强日志可读性(可选) + +## 工作站设备 + +工作站是组合多个设备的复杂设备: + +```python +from unilabos.devices.workstation.workstation_base import WorkstationBase + +class MyWorkstation(WorkstationBase): + """组合工作站""" + + async def execute_workflow(self, workflow: Dict[str, Any]) -> bool: + """执行工作流""" + pass +``` + +## 设备注册 + +设备类开发完成后,需要在注册表中注册: + +1. 创建/编辑 `unilabos/registry/devices/my_category.yaml` +2. 添加设备配置(参考 `virtual_device.yaml`) +3. 运行 `--complete_registry` 自动生成 schema diff --git a/.cursor/rules/protocol-development.mdc b/.cursor/rules/protocol-development.mdc new file mode 100644 index 000000000..a94f947d8 --- /dev/null +++ b/.cursor/rules/protocol-development.mdc @@ -0,0 +1,240 @@ +--- +description: 协议编译器开发规范 +globs: ["unilabos/compile/**/*.py"] +--- + +# 协议编译器开发规范 + +## 概述 + +协议编译器负责将高级实验操作(如 Stir、Add、Filter)编译为设备可执行的动作序列。 + +## 文件命名 + +- 位置: `unilabos/compile/` +- 命名: `{operation}_protocol.py` +- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py` + +## 协议函数模板 + +```python +from typing import List, Dict, Any, Union +import networkx as nx +import logging + +from .utils.unit_parser import parse_time_input +from .utils.vessel_parser import extract_vessel_id + +logger = logging.getLogger(__name__) + + +def generate_{operation}_protocol( + G: nx.DiGraph, + vessel: Union[str, dict], + param1: Union[str, float] = "0", + param2: float = 0.0, + **kwargs +) -> List[Dict[str, Any]]: + """ + 生成{操作}协议序列 + + Args: + G: 物理拓扑图 (NetworkX DiGraph) + vessel: 容器ID或Resource字典 + param1: 参数1(支持字符串单位,如 "5 min") + param2: 参数2 + **kwargs: 其他参数 + + Returns: + List[Dict]: 动作序列 + + Raises: + ValueError: 参数无效时 + """ + # 1. 提取 vessel_id + vessel_id = extract_vessel_id(vessel) + + # 2. 验证参数 + if not vessel_id: + raise ValueError("vessel 参数不能为空") + + if vessel_id not in G.nodes(): + raise ValueError(f"容器 '{vessel_id}' 不存在于系统中") + + # 3. 解析参数(支持单位) + parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0 + + # 4. 查找设备 + device_id = find_connected_device(G, vessel_id, device_type="my_device") + + # 5. 生成动作序列 + action_sequence = [] + + action = { + "device_id": device_id, + "action_name": "my_action", + "action_kwargs": { + "vessel": {"id": vessel_id}, # 始终使用字典格式 + "param1": float(parsed_param1), + "param2": float(param2), + } + } + action_sequence.append(action) + + logger.info(f"生成协议: {len(action_sequence)} 个动作") + return action_sequence + + +def find_connected_device( + G: nx.DiGraph, + vessel_id: str, + device_type: str = "" +) -> str: + """ + 查找与容器相连的设备 + + Args: + G: 拓扑图 + vessel_id: 容器ID + device_type: 设备类型关键字 + + Returns: + str: 设备ID + """ + # 查找所有匹配类型的设备 + device_nodes = [] + for node in G.nodes(): + node_class = G.nodes[node].get('class', '') or '' + if device_type.lower() in node_class.lower(): + device_nodes.append(node) + + # 检查连接 + if vessel_id and device_nodes: + for device in device_nodes: + if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device): + return device + + # 返回第一个可用设备 + if device_nodes: + return device_nodes[0] + + # 默认设备 + return f"{device_type}_1" +``` + +## 关键规则 + +### 1. vessel 参数处理 + +vessel 参数可能是字符串或字典,需要统一处理: + +```python +def extract_vessel_id(vessel: Union[str, dict]) -> str: + """提取vessel_id""" + if isinstance(vessel, dict): + # 可能是 {"id": "xxx"} 或完整 Resource 对象 + return vessel.get("id", list(vessel.values())[0].get("id", "")) + return str(vessel) if vessel else "" +``` + +### 2. action_kwargs 中的 vessel + +始终使用 `{"id": vessel_id}` 格式传递 vessel: + +```python +# 正确 +"action_kwargs": { + "vessel": {"id": vessel_id}, # 字符串ID包装为字典 +} + +# 避免 +"action_kwargs": { + "vessel": vessel_resource, # 不要传递完整 Resource 对象 +} +``` + +### 3. 单位解析 + +使用 `parse_time_input` 解析时间参数: + +```python +from .utils.unit_parser import parse_time_input + +# 支持格式: "5 min", "1 h", "300", "1.5 hours" +time_seconds = parse_time_input("5 min") # -> 300.0 +time_seconds = parse_time_input(120) # -> 120.0 +time_seconds = parse_time_input("1 h") # -> 3600.0 +``` + +### 4. 参数验证 + +所有参数必须进行验证和类型转换: + +```python +# 验证范围 +if speed < 10.0 or speed > 1500.0: + logger.warning(f"速度 {speed} 超出范围,修正为 300") + speed = 300.0 + +# 类型转换 +param = float(param) if not isinstance(param, (int, float)) else param +``` + +### 5. 日志记录 + +使用项目日志记录器: + +```python +logger = logging.getLogger(__name__) + +def generate_protocol(...): + logger.info(f"开始生成协议...") + logger.debug(f"参数: vessel={vessel_id}, time={time}") + logger.warning(f"参数修正: {old_value} -> {new_value}") +``` + +## 便捷函数 + +为常用操作提供便捷函数: + +```python +def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict], + speed: float = 300.0) -> List[Dict[str, Any]]: + """短时间搅拌(30秒)""" + return generate_stir_protocol(G, vessel, time="30", stir_speed=speed) + +def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict], + time: str = "5 min") -> List[Dict[str, Any]]: + """剧烈搅拌""" + return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0) +``` + +## 测试函数 + +每个协议文件应包含测试函数: + +```python +def test_{operation}_protocol(): + """测试协议生成""" + # 测试参数处理 + vessel_dict = {"id": "flask_1", "name": "反应瓶1"} + vessel_id = extract_vessel_id(vessel_dict) + assert vessel_id == "flask_1" + + # 测试单位解析 + time_s = parse_time_input("5 min") + assert time_s == 300.0 + + +if __name__ == "__main__": + test_{operation}_protocol() +``` + +## 现有协议参考 + +- `stir_protocol.py` - 搅拌操作 +- `add_protocol.py` - 添加物料 +- `filter_protocol.py` - 过滤操作 +- `heatchill_protocol.py` - 加热/冷却 +- `separate_protocol.py` - 分离操作 +- `evaporate_protocol.py` - 蒸发操作 diff --git a/.cursor/rules/registry-config.mdc b/.cursor/rules/registry-config.mdc new file mode 100644 index 000000000..bba2f221d --- /dev/null +++ b/.cursor/rules/registry-config.mdc @@ -0,0 +1,319 @@ +--- +description: 注册表配置规范 (YAML) +globs: ["unilabos/registry/**/*.yaml"] +--- + +# 注册表配置规范 + +## 概述 + +注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。 + +## 目录结构 + +``` +unilabos/registry/ +├── devices/ # 设备类型注册 +│ ├── virtual_device.yaml +│ ├── liquid_handler.yaml +│ └── ... +├── device_comms/ # 通信设备配置 +│ ├── communication_devices.yaml +│ └── modbus_ioboard.yaml +└── resources/ # 资源类型注册 + ├── bioyond/ + ├── organic/ + ├── opentrons/ + └── ... +``` + +## 设备注册表格式 + +### 基本结构 + +```yaml +device_type_id: + # 基本信息 + description: "设备描述" + version: "1.0.0" + category: + - category_name + icon: "icon_device.webp" + + # 类配置 + class: + module: "unilabos.devices.my_module:MyClass" + type: python + + # 状态类型(属性 -> ROS消息类型) + status_types: + status: String + temperature: Float64 + is_running: Bool + + # 动作映射 + action_value_mappings: + action_name: + type: UniLabJsonCommand # 或 UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} + handles: {} +``` + +### action_value_mappings 详细格式 + +```yaml +action_value_mappings: + # 同步动作 + my_sync_action: + type: UniLabJsonCommand + goal: + param1: param1 + param2: param2 + feedback: {} + result: + success: success + message: message + goal_default: + param1: 0.0 + param2: "" + handles: {} + placeholder_keys: + device_param: unilabos_devices # 设备选择器 + resource_param: unilabos_resources # 资源选择器 + schema: + title: "动作名称参数" + description: "动作描述" + type: object + properties: + goal: + type: object + properties: + param1: + type: number + param2: + type: string + required: + - param1 + feedback: {} + result: + type: object + properties: + success: + type: boolean + message: + type: string + required: + - goal + + # 异步动作 + my_async_action: + type: UniLabJsonCommandAsync + goal: {} + feedback: + progress: progress + current_status: status + result: + success: success + schema: {...} +``` + +### 自动生成的动作 + +以 `auto-` 开头的动作由系统自动生成: + +```yaml +action_value_mappings: + auto-initialize: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} + + auto-cleanup: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} +``` + +### handles 配置 + +用于工作流编辑器中的数据流连接: + +```yaml +handles: + input: + - handler_key: "input_resource" + data_type: "resource" + label: "输入资源" + data_source: "handle" + data_key: "resources" + output: + - handler_key: "output_labware" + data_type: "resource" + label: "输出器皿" + data_source: "executor" + data_key: "created_resource.@flatten" +``` + +## 资源注册表格式 + +```yaml +resource_type_id: + description: "资源描述" + version: "1.0.0" + category: + - category_name + icon: "" + handles: [] + init_param_schema: {} + + class: + module: "unilabos.resources.my_module:MyResource" + type: pylabrobot # 或 python +``` + +### PyLabRobot 资源示例 + +```yaml +BIOYOND_Electrolyte_6VialCarrier: + category: + - bottle_carriers + - bioyond + class: + module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier" + type: pylabrobot + version: "1.0.0" +``` + +## 状态类型映射 + +Python 类型到 ROS 消息类型的映射: + +| Python 类型 | ROS 消息类型 | +|------------|-------------| +| `str` | `String` | +| `bool` | `Bool` | +| `int` | `Int64` | +| `float` | `Float64` | +| `list` | `String` (序列化) | +| `dict` | `String` (序列化) | + +## 自动完善注册表 + +使用 `--complete_registry` 参数自动生成 schema: + +```bash +python -m unilabos.app.main --complete_registry +``` + +这会: +1. 扫描设备类的方法签名 +2. 自动生成 `auto-` 前缀的动作 +3. 生成 JSON Schema +4. 更新 YAML 文件 + +## 验证规则 + +1. **device_type_id** 必须唯一 +2. **module** 路径必须正确可导入 +3. **status_types** 的类型必须是有效的 ROS 消息类型 +4. **schema** 必须是有效的 JSON Schema + +## 示例:完整设备配置 + +```yaml +virtual_stirrer: + category: + - virtual_device + description: "虚拟搅拌器设备" + version: "1.0.0" + icon: "icon_stirrer.webp" + handles: [] + init_param_schema: {} + + class: + module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer" + type: python + + status_types: + status: String + operation_mode: String + current_speed: Float64 + is_stirring: Bool + remaining_time: Float64 + + action_value_mappings: + auto-initialize: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: + title: "initialize参数" + type: object + properties: + goal: + type: object + properties: {} + feedback: {} + result: {} + required: + - goal + + stir: + type: UniLabJsonCommandAsync + goal: + stir_time: stir_time + stir_speed: stir_speed + settling_time: settling_time + feedback: + current_speed: current_speed + remaining_time: remaining_time + result: + success: success + goal_default: + stir_time: 60.0 + stir_speed: 300.0 + settling_time: 30.0 + handles: {} + schema: + title: "stir参数" + description: "搅拌操作" + type: object + properties: + goal: + type: object + properties: + stir_time: + type: number + description: "搅拌时间(秒)" + stir_speed: + type: number + description: "搅拌速度(RPM)" + settling_time: + type: number + description: "沉降时间(秒)" + required: + - stir_time + - stir_speed + feedback: + type: object + properties: + current_speed: + type: number + remaining_time: + type: number + result: + type: object + properties: + success: + type: boolean + required: + - goal +``` diff --git a/.cursor/rules/ros-integration.mdc b/.cursor/rules/ros-integration.mdc new file mode 100644 index 000000000..4057b48eb --- /dev/null +++ b/.cursor/rules/ros-integration.mdc @@ -0,0 +1,233 @@ +--- +description: ROS 2 集成开发规范 +globs: ["unilabos/ros/**/*.py", "**/*_node.py"] +--- + +# ROS 2 集成开发规范 + +## 概述 + +Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。 + +## 核心组件 + +### BaseROS2DeviceNode + +设备节点基类,提供: +- ROS Topic 自动发布(状态属性) +- Action Server 自动创建(设备动作) +- 资源管理服务 +- 异步任务调度 + +```python +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +``` + +### 消息转换器 + +```python +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, + convert_from_ros_msg_with_mapping, + msg_converter_manager, + ros_action_to_json_schema, + ros_message_to_json_schema, +) +``` + +## 设备与 ROS 集成 + +### post_init 方法 + +设备类必须实现 `post_init` 方法接收 ROS 节点: + +```python +class MyDevice: + _ros_node: BaseROS2DeviceNode + + def post_init(self, ros_node: BaseROS2DeviceNode): + """ROS节点注入""" + self._ros_node = ros_node +``` + +### 状态属性发布 + +设备的 `@property` 属性会自动发布为 ROS Topic: + +```python +class MyDevice: + @property + def temperature(self) -> float: + return self._temperature + + # 自动发布到 /{namespace}/temperature Topic +``` + +### Topic 配置装饰器 + +```python +from unilabos.utils.decorator import topic_config + +class MyDevice: + @property + @topic_config(period=1.0, print_publish=False, qos=10) + def fast_data(self) -> float: + """高频数据 - 每秒发布一次""" + return self._fast_data + + @property + @topic_config(period=5.0) + def slow_data(self) -> str: + """低频数据 - 每5秒发布一次""" + return self._slow_data +``` + +### 订阅装饰器 + +```python +from unilabos.utils.decorator import subscribe + +class MyDevice: + @subscribe(topic="/external/sensor_data", qos=10) + def on_sensor_data(self, msg): + """订阅外部Topic""" + self._sensor_value = msg.data +``` + +## 异步操作 + +### 使用 ROS 节点睡眠 + +```python +# 推荐:使用ROS节点的睡眠方法 +await self._ros_node.sleep(1.0) + +# 不推荐:直接使用asyncio(可能导致回调阻塞) +await asyncio.sleep(1.0) +``` + +### 获取事件循环 + +```python +from unilabos.ros.x.rclpyx import get_event_loop + +loop = get_event_loop() +``` + +## 消息类型 + +### unilabos_msgs 包 + +```python +from unilabos_msgs.msg import Resource +from unilabos_msgs.srv import ( + ResourceAdd, + ResourceDelete, + ResourceUpdate, + ResourceList, + SerialCommand, +) +from unilabos_msgs.action import SendCmd +``` + +### Resource 消息结构 + +```python +Resource: + id: str + name: str + category: str + type: str + parent: str + children: List[str] + config: str # JSON字符串 + data: str # JSON字符串 + sample_id: str + pose: Pose +``` + +## 日志适配器 + +```python +from unilabos.utils.log import info, debug, warning, error, trace + +class MyDevice: + def __init__(self): + # 创建设备专属日志器 + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") +``` + +ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。 + +## Action Server + +设备动作自动创建为 ROS Action Server: + +```yaml +# 在注册表中配置 +action_value_mappings: + my_action: + type: UniLabJsonCommandAsync # 异步Action + goal: {...} + feedback: {...} + result: {...} +``` + +### Action 类型 + +- **UniLabJsonCommand**: 同步动作 +- **UniLabJsonCommandAsync**: 异步动作(支持feedback) + +## 服务客户端 + +```python +from rclpy.client import Client + +# 调用其他节点的服务 +response = await self._ros_node.call_service( + service_name="/other_node/service", + request=MyServiceRequest(...) +) +``` + +## 命名空间 + +设备节点使用命名空间隔离: + +``` +/{device_id}/ # 设备命名空间 +/{device_id}/status # 状态Topic +/{device_id}/temperature # 温度Topic +/{device_id}/my_action # 动作Server +``` + +## 调试 + +### 查看 Topic + +```bash +ros2 topic list +ros2 topic echo /{device_id}/status +``` + +### 查看 Action + +```bash +ros2 action list +ros2 action info /{device_id}/my_action +``` + +### 查看 Service + +```bash +ros2 service list +ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList +``` + +## 最佳实践 + +1. **状态属性命名**: 使用蛇形命名法(snake_case) +2. **Topic 频率**: 根据数据变化频率调整,避免过高频率 +3. **Action 反馈**: 长时间操作提供进度反馈 +4. **错误处理**: 使用 try-except 捕获并记录错误 +5. **资源清理**: 在 cleanup 方法中正确清理资源 diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc new file mode 100644 index 000000000..73df7b0c8 --- /dev/null +++ b/.cursor/rules/testing-patterns.mdc @@ -0,0 +1,357 @@ +--- +description: 测试开发规范 +globs: ["tests/**/*.py", "**/test_*.py"] +--- + +# 测试开发规范 + +## 目录结构 + +``` +tests/ +├── __init__.py +├── devices/ # 设备测试 +│ └── liquid_handling/ +│ └── test_transfer_liquid.py +├── resources/ # 资源测试 +│ ├── test_bottle_carrier.py +│ └── test_resourcetreeset.py +├── ros/ # ROS消息测试 +│ └── msgs/ +│ ├── test_basic.py +│ ├── test_conversion.py +│ └── test_mapping.py +└── workflow/ # 工作流测试 + └── merge_workflow.py +``` + +## 测试框架 + +使用 pytest 作为测试框架: + +```bash +# 运行所有测试 +pytest tests/ + +# 运行特定测试文件 +pytest tests/resources/test_bottle_carrier.py + +# 运行特定测试函数 +pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier + +# 显示详细输出 +pytest -v tests/ + +# 显示打印输出 +pytest -s tests/ +``` + +## 测试文件模板 + +```python +import pytest +from typing import List, Dict, Any + +# 导入被测试的模块 +from unilabos.resources.bioyond.bottle_carriers import ( + BIOYOND_Electrolyte_6VialCarrier, +) +from unilabos.resources.bioyond.bottles import ( + BIOYOND_PolymerStation_Solid_Vial, +) + + +class TestBottleCarrier: + """BottleCarrier 测试类""" + + def setup_method(self): + """每个测试方法前执行""" + self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier") + + def teardown_method(self): + """每个测试方法后执行""" + pass + + def test_carrier_creation(self): + """测试载架创建""" + assert self.carrier.name == "test_carrier" + assert len(self.carrier.sites) == 6 + + def test_bottle_placement(self): + """测试瓶子放置""" + bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle") + # 测试逻辑... + assert bottle.name == "test_bottle" + + +def test_standalone_function(): + """独立测试函数""" + result = some_function() + assert result is True + + +# 参数化测试 +@pytest.mark.parametrize("input,expected", [ + ("5 min", 300.0), + ("1 h", 3600.0), + ("120", 120.0), + (60, 60.0), +]) +def test_time_parsing(input, expected): + """测试时间解析""" + from unilabos.compile.utils.unit_parser import parse_time_input + assert parse_time_input(input) == expected + + +# 异常测试 +def test_invalid_input_raises_error(): + """测试无效输入抛出异常""" + with pytest.raises(ValueError) as exc_info: + invalid_function("bad_input") + assert "invalid" in str(exc_info.value).lower() + + +# 跳过条件测试 +@pytest.mark.skipif( + not os.environ.get("ROS_DISTRO"), + reason="需要ROS环境" +) +def test_ros_feature(): + """需要ROS环境的测试""" + pass +``` + +## 设备测试 + +### 虚拟设备测试 + +```python +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock + +from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer + + +class TestVirtualStirrer: + """VirtualStirrer 测试""" + + @pytest.fixture + def stirrer(self): + """创建测试用搅拌器""" + device = VirtualStirrer( + device_id="test_stirrer", + config={"max_speed": 1500.0, "min_speed": 50.0} + ) + + # Mock ROS节点 + mock_node = MagicMock() + mock_node.sleep = AsyncMock(return_value=None) + device.post_init(mock_node) + + return device + + @pytest.mark.asyncio + async def test_initialize(self, stirrer): + """测试初始化""" + result = await stirrer.initialize() + assert result is True + assert stirrer.status == "待机中" + + @pytest.mark.asyncio + async def test_stir_action(self, stirrer): + """测试搅拌动作""" + await stirrer.initialize() + + result = await stirrer.stir( + stir_time=5.0, + stir_speed=300.0, + settling_time=2.0 + ) + + assert result is True + assert stirrer.operation_mode == "Completed" + + @pytest.mark.asyncio + async def test_stir_invalid_speed(self, stirrer): + """测试无效速度""" + await stirrer.initialize() + + # 速度超出范围 + result = await stirrer.stir( + stir_time=5.0, + stir_speed=2000.0, # 超过max_speed + settling_time=0.0 + ) + + assert result is False + assert "错误" in stirrer.status +``` + +### 异步测试配置 + +```python +# conftest.py +import pytest +import asyncio + + +@pytest.fixture(scope="session") +def event_loop(): + """创建事件循环""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() +``` + +## 资源测试 + +```python +import pytest +from unilabos.resources.resource_tracker import ( + ResourceTreeSet, + ResourceTreeInstance, +) + + +def test_resource_tree_creation(): + """测试资源树创建""" + tree_set = ResourceTreeSet() + + # 添加资源 + resource = {"id": "res_1", "name": "Resource 1"} + tree_set.add_resource(resource) + + # 验证 + assert len(tree_set.all_nodes) == 1 + assert tree_set.get_resource("res_1") is not None + + +def test_resource_tree_merge(): + """测试资源树合并""" + local_set = ResourceTreeSet() + remote_set = ResourceTreeSet() + + # 设置数据... + + local_set.merge_remote_resources(remote_set) + + # 验证合并结果... +``` + +## ROS 消息测试 + +```python +import pytest +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, + convert_from_ros_msg_with_mapping, + msg_converter_manager, +) + + +def test_message_conversion(): + """测试消息转换""" + # Python -> ROS + python_data = {"id": "test", "value": 42} + ros_msg = convert_to_ros_msg(python_data, MyMsgType) + + assert ros_msg.id == "test" + assert ros_msg.value == 42 + + # ROS -> Python + result = convert_from_ros_msg_with_mapping(ros_msg, mapping) + assert result["id"] == "test" +``` + +## 协议测试 + +```python +import pytest +import networkx as nx +from unilabos.compile.stir_protocol import ( + generate_stir_protocol, + extract_vessel_id, +) + + +@pytest.fixture +def topology_graph(): + """创建测试拓扑图""" + G = nx.DiGraph() + G.add_node("flask_1", **{"class": "flask"}) + G.add_node("stirrer_1", **{"class": "virtual_stirrer"}) + G.add_edge("stirrer_1", "flask_1") + return G + + +def test_generate_stir_protocol(topology_graph): + """测试搅拌协议生成""" + actions = generate_stir_protocol( + G=topology_graph, + vessel="flask_1", + time="5 min", + stir_speed=300.0 + ) + + assert len(actions) == 1 + assert actions[0]["device_id"] == "stirrer_1" + assert actions[0]["action_name"] == "stir" + + +def test_extract_vessel_id(): + """测试vessel_id提取""" + # 字典格式 + assert extract_vessel_id({"id": "flask_1"}) == "flask_1" + + # 字符串格式 + assert extract_vessel_id("flask_2") == "flask_2" + + # 空值 + assert extract_vessel_id("") == "" +``` + +## 测试标记 + +```python +# 慢速测试 +@pytest.mark.slow +def test_long_running(): + pass + +# 需要网络 +@pytest.mark.network +def test_network_call(): + pass + +# 需要ROS +@pytest.mark.ros +def test_ros_feature(): + pass +``` + +运行特定标记的测试: + +```bash +pytest -m "not slow" # 排除慢速测试 +pytest -m ros # 仅ROS测试 +``` + +## 覆盖率 + +```bash +# 生成覆盖率报告 +pytest --cov=unilabos tests/ + +# HTML报告 +pytest --cov=unilabos --cov-report=html tests/ +``` + +## 最佳实践 + +1. **测试命名**: `test_{功能}_{场景}_{预期结果}` +2. **独立性**: 每个测试独立运行,不依赖其他测试 +3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务 +4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码 +5. **fixtures**: 使用 fixtures 共享测试设置 +6. **断言清晰**: 每个断言只验证一件事 diff --git a/.cursor/rules/unilabos-project.mdc b/.cursor/rules/unilabos-project.mdc new file mode 100644 index 000000000..1b6a24ee3 --- /dev/null +++ b/.cursor/rules/unilabos-project.mdc @@ -0,0 +1,353 @@ +--- +description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则 +globs: ["**/*.py", "**/*.yaml", "**/*.json"] +--- + +# Uni-Lab-OS 项目开发规范 + +## 项目概述 + +Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。 + +## 技术栈 + +- **Python 3.11** - 核心开发语言 +- **ROS 2** - 设备通信中间件 (rclpy) +- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge) +- **FastAPI** - Web API 服务 +- **WebSocket** - 实时通信 +- **NetworkX** - 拓扑图管理 +- **YAML** - 配置和注册表定义 +- **PyLabRobot** - 实验室自动化库集成 +- **pytest** - 测试框架 +- **asyncio** - 异步编程 + +## 项目结构 + +``` +unilabos/ +├── app/ # 应用入口、Web服务、后端 +├── compile/ # 协议编译器 (stir, add, filter 等) +├── config/ # 配置管理 +├── devices/ # 设备驱动 (真实/虚拟) +├── device_comms/ # 设备通信协议 +├── device_mesh/ # 3D网格和可视化 +├── registry/ # 设备和资源类型注册表 (YAML) +├── resources/ # 资源定义 +├── ros/ # ROS 2 集成 +├── utils/ # 工具函数 +└── workflow/ # 工作流管理 +``` + +## 代码规范 + +### Python 风格 + +1. **类型注解**:所有函数必须使用类型注解 + ```python + def transfer_liquid( + source: str, + destination: str, + volume: float, + **kwargs + ) -> List[Dict[str, Any]]: + ``` + +2. **Docstring**:使用 Google 风格的文档字符串 + ```python + def initialize(self) -> bool: + """ + 初始化设备 + + Returns: + bool: 初始化是否成功 + """ + ``` + +3. **导入顺序**: + - 标准库 + - 第三方库 + - ROS 相关 (rclpy, unilabos_msgs) + - 项目内部模块 + +### 异步编程 + +1. 设备操作方法使用 `async def` +2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()` +3. 长时间运行操作需提供进度反馈 + +```python +async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool: + """执行搅拌操作""" + start_time = time_module.time() + while True: + elapsed = time_module.time() - start_time + remaining = max(0, stir_time - elapsed) + + self.data.update({ + "remaining_time": remaining, + "status": f"搅拌中: {stir_speed} RPM" + }) + + if remaining <= 0: + break + await self._ros_node.sleep(1.0) + return True +``` + +### 日志规范 + +使用项目自定义日志系统: + +```python +from unilabos.utils.log import logger, info, debug, warning, error, trace + +# 在设备类中使用 +self.logger = logging.getLogger(f"DeviceName.{self.device_id}") +self.logger.info("设备初始化完成") +``` + +## 设备驱动开发 + +### 设备类结构 + +```python +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode + +class MyDevice: + """设备驱动类""" + + _ros_node: BaseROS2DeviceNode + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.data = {} # 设备状态数据 + + def post_init(self, ros_node: BaseROS2DeviceNode): + """ROS节点注入""" + self._ros_node = ros_node + + async def initialize(self) -> bool: + """初始化设备""" + pass + + async def cleanup(self) -> bool: + """清理设备""" + pass + + # 状态属性 - 自动发布为 ROS Topic + @property + def status(self) -> str: + return self.data.get("status", "待机") +``` + +### 状态属性装饰器 + +```python +from unilabos.utils.decorator import topic_config + +class MyDevice: + @property + @topic_config(period=1.0, qos=10) # 每秒发布一次 + def temperature(self) -> float: + return self._temperature +``` + +### 虚拟设备 + +虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py` + +## 注册表配置 + +### 设备注册表 (YAML) + +位置: `unilabos/registry/devices/*.yaml` + +```yaml +my_device_type: + category: + - my_category + description: "设备描述" + version: "1.0.0" + class: + module: "unilabos.devices.my_device:MyDevice" + type: python + status_types: + status: String + temperature: Float64 + action_value_mappings: + auto-initialize: + type: UniLabJsonCommandAsync + goal: {} + feedback: {} + result: {} + schema: {...} +``` + +### 资源注册表 (YAML) + +位置: `unilabos/registry/resources/**/*.yaml` + +```yaml +my_container: + category: + - container + class: + module: "unilabos.resources.my_resource:MyContainer" + type: pylabrobot + version: "1.0.0" +``` + +## 协议编译器 + +位置: `unilabos/compile/*_protocol.py` + +### 协议生成函数模板 + +```python +from typing import List, Dict, Any, Union +import networkx as nx + +def generate_my_protocol( + G: nx.DiGraph, + vessel: Union[str, dict], + param1: float = 0.0, + **kwargs +) -> List[Dict[str, Any]]: + """ + 生成操作协议序列 + + Args: + G: 物理拓扑图 + vessel: 容器ID或字典 + param1: 参数1 + + Returns: + List[Dict]: 动作序列 + """ + # 提取vessel_id + vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "") + + # 查找设备 + device_id = find_connected_device(G, vessel_id) + + # 生成动作 + action_sequence = [{ + "device_id": device_id, + "action_name": "my_action", + "action_kwargs": { + "vessel": {"id": vessel_id}, + "param1": float(param1) + } + }] + + return action_sequence +``` + +## 测试规范 + +### 测试文件位置 + +- 单元测试: `tests/` 目录 +- 设备测试: `tests/devices/` +- 资源测试: `tests/resources/` +- ROS消息测试: `tests/ros/msgs/` + +### 测试命名 + +```python +# tests/devices/my_device/test_my_device.py + +import pytest + +def test_device_initialization(): + """测试设备初始化""" + pass + +def test_device_action(): + """测试设备动作""" + pass +``` + +## 错误处理 + +```python +from unilabos.utils.exception import UniLabException + +try: + result = await device.execute_action() +except ValueError as e: + self.logger.error(f"参数错误: {e}") + self.data["status"] = "错误: 参数无效" + return False +except Exception as e: + self.logger.error(f"执行失败: {e}") + raise +``` + +## 配置管理 + +```python +from unilabos.config.config import BasicConfig, HTTPConfig + +# 读取配置 +port = BasicConfig.port +is_host = BasicConfig.is_host_mode + +# 配置文件: local_config.py +``` + +## 常用工具 + +### 单例模式 + +```python +from unilabos.utils.decorator import singleton + +@singleton +class MyManager: + pass +``` + +### 类型检查 + +```python +from unilabos.utils.type_check import NoAliasDumper + +yaml.dump(data, f, Dumper=NoAliasDumper) +``` + +### 导入管理 + +```python +from unilabos.utils.import_manager import get_class + +device_class = get_class("unilabos.devices.my_device:MyDevice") +``` + +## Git 提交规范 + +提交信息格式: +``` +(): + + +``` + +类型: +- `feat`: 新功能 +- `fix`: 修复bug +- `docs`: 文档更新 +- `refactor`: 重构 +- `test`: 测试相关 +- `chore`: 构建/工具相关 + +示例: +``` +feat(devices): 添加虚拟搅拌器设备 + +- 实现VirtualStirrer类 +- 支持定时搅拌和持续搅拌模式 +- 添加速度验证逻辑 +``` diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..0bd258b56 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,188 @@ +# ============================================================ +# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围 +# ============================================================ + +# ==================== 敏感配置文件 ==================== +# 本地配置(可能包含密钥) +**/local_config.py +test_config.py +local_test*.py + +# 环境变量和密钥 +.env +.env.* +**/.certs/ +*.pem +*.key +credentials.json +secrets.yaml + +# ==================== 二进制和 3D 模型文件 ==================== +# 3D 模型文件(无需索引) +*.stl +*.dae +*.glb +*.gltf +*.obj +*.fbx +*.blend + +# URDF/Xacro 机器人描述文件(大型XML) +*.xacro + +# 图片文件 +*.png +*.jpg +*.jpeg +*.gif +*.webp +*.ico +*.svg +*.bmp + +# 压缩包 +*.zip +*.tar +*.tar.gz +*.tgz +*.bz2 +*.rar +*.7z + +# ==================== Python 生成文件 ==================== +__pycache__/ +*.py[cod] +*$py.class +*.so +*.pyd +*.egg +*.egg-info/ +.eggs/ +dist/ +build/ +*.manifest +*.spec + +# ==================== IDE 和编辑器 ==================== +.idea/ +.vscode/ +*.swp +*.swo +*~ +.#* + +# ==================== 测试和覆盖率 ==================== +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover + +# ==================== 虚拟环境 ==================== +.venv/ +venv/ +env/ +ENV/ + +# ==================== ROS 2 生成文件 ==================== +# ROS 构建目录 +build/ +install/ +log/ +logs/ +devel/ + +# ROS 消息生成 +msg_gen/ +srv_gen/ +msg/*Action.msg +msg/*ActionFeedback.msg +msg/*ActionGoal.msg +msg/*ActionResult.msg +msg/*Feedback.msg +msg/*Goal.msg +msg/*Result.msg +msg/_*.py +srv/_*.py +build_isolated/ +devel_isolated/ + +# ROS 动态配置 +*.cfgc +/cfg/cpp/ +/cfg/*.py + +# ==================== 项目特定目录 ==================== +# 工作数据目录 +unilabos_data/ + +# 临时和输出目录 +temp/ +output/ +cursor_docs/ +configs/ + +# 文档构建 +docs/_build/ +/site + +# ==================== 大型数据文件 ==================== +# 点云数据 +*.pcd + +# GraphML 图形文件 +*.graphml + +# 日志文件 +*.log + +# 数据库 +*.sqlite3 +*.db + +# Jupyter 检查点 +.ipynb_checkpoints/ + +# ==================== 设备网格资源 ==================== +# 3D 网格文件目录(包含大量 STL/DAE 文件) +unilabos/device_mesh/devices/**/*.stl +unilabos/device_mesh/devices/**/*.dae +unilabos/device_mesh/resources/**/*.stl +unilabos/device_mesh/resources/**/*.glb +unilabos/device_mesh/resources/**/*.xacro + +# RViz 配置 +*.rviz + +# ==================== 系统文件 ==================== +.DS_Store +Thumbs.db +desktop.ini + +# ==================== 锁文件 ==================== +poetry.lock +Pipfile.lock +pdm.lock +package-lock.json +yarn.lock + +# ==================== 类型检查缓存 ==================== +.mypy_cache/ +.dmypy.json +.pytype/ +.pyre/ +pyrightconfig.json + +# ==================== 其他 ==================== +# Catkin +CATKIN_IGNORE + +# Eclipse/Qt +.project +.cproject +CMakeLists.txt.user +*.user +qtcreator-* diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..6cd38be82 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +## 设备接入 + +当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。 +该指南包含完整的模板和已有设备接口参考。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名 +- `status` 字符串必须与同类已有设备一致 +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` diff --git a/docs/ai_guides/add_device.md b/docs/ai_guides/add_device.md new file mode 100644 index 000000000..b5ca25c45 --- /dev/null +++ b/docs/ai_guides/add_device.md @@ -0,0 +1,1100 @@ +# Uni-Lab-OS 设备接入指南(AI 专用·自包含版) + +> **本文件是完全自包含的。** 即使你无法访问 Uni-Lab-OS 仓库,也能根据本指南正确生成设备驱动。 +> 如果你能访问仓库,建议搜索 `unilabos/registry/devices/` 目录获取最新的已有设备接口。 +> 最新版本也可通过 GitHub 获取:https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +端到端向导,通过**设备类别(物模型)** 和 **通信协议** 两个维度引导设备接入。 + +--- + +## 第一步:选择设备类别(物模型) + +每种设备类别有标准的属性和动作接口。向用户确认以下信息: + +**Q1: 设备属于哪个类别?** + +| 类别 ID | 说明 | 标准属性 | 标准动作 | +|---|---|---|---| +| `temperature` | 加热/冷却/温控 | `temp`, `temp_target`, `status` | `set_temperature`, `stop` | +| `pump_and_valve` | 泵、阀门、注射器 | 见下方子类型表 | 见下方子类型表 | +| `motor` | 电机、步进马达 | `position`, `status` | `enable`, `move_position`, `move_speed`, `stop` | +| `heaterstirrer` | 加热搅拌一体机 | `temp`, `stir_speed`, `status` | `set_temperature`, `stir`, `stop` | +| `balance` | 天平/称重 | `weight`, `unit`, `status` | `tare`, `read_weight` | +| `sensor` | 传感器(液位/温度/...) | `value`, `level`, `status` | `read_value`, `set_threshold` | +| `liquid_handling` | 液体处理机器人 | `status`, `deck_state` | `transfer_liquid`, `aspirate`, `dispense` | +| `robot_arm` | 机械臂 | `arm_pose`, `arm_status` | `moveit_task`, `pick_and_place` | +| `workstation` | 工作站(组合设备) | `workflow_sequence`, `material_info` | `create_order`, `scheduler_start`/`stop` | +| `virtual` | 虚拟/模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 | +| `custom` | 不属于以上任何类别 | 用户自定义 | 用户自定义 | + +**pump_and_valve 子类型:** 该类别包含差异较大的子类型,下表仅列出**最小通用接口**。具体项目中可能有更多属性和动作,由第四步(对齐同类设备接口)动态发现。 + +| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 | +|---|---|---|---| +| 注射泵(syringe pump) | `status`, `valve_position`, `position`(mL) | `initialize`, `set_valve_position`, `set_position`(mL), `pull_plunger`(mL), `push_plunger`(mL), `stop_operation` | 体积=mL, 速度=mL/s | +| 电磁阀(solenoid valve) | `status`, `valve_position` | `open`, `close`, `set_valve_position` | — | +| 蠕动泵(peristaltic pump) | `status`, `speed` | `start`, `stop`, `set_speed` | 流速=mL/min | + +**单位约定(重要):** 设备对外暴露的属性和动作参数**必须使用用户友好的物理单位**,不能使用原始步数或寄存器值。驱动内部负责在物理单位和硬件原始值之间转换。 + +| 类别 | 位置/体积 | 速度 | 温度 | 其他 | +|---|---|---|---|---| +| pump_and_valve (注射泵) | **mL** | **mL/s** | — | — | +| pump_and_valve (蠕动泵) | — | **mL/min** | — | — | +| motor | **mm** 或 **度** | **mm/s** 或 **RPM** | — | — | +| temperature | — | — | **°C** | — | +| balance | **g** 或 **mg** | — | — | — | +| sensor | 按传感器物理量定 | — | — | — | + +**Q2: 设备英文名称?** (如 `my_heater`,用于类名和文件名) + +--- + +## 第二步:选择通信协议 + +**Q3: 设备使用什么通信协议?** + +| 协议 | config 参数 | 依赖包 | UniLab 现有抽象 | +|---|---|---|---| +| **Serial (RS232/RS485)** | `port`, `baudrate` | `pyserial` | 直接使用 `serial.Serial` | +| **Modbus RTU** | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/`(RTUClient) | +| **Modbus TCP** | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/`(TCPClient) | +| **TCP Socket** | `host`, `port` | stdlib | 直接使用 `socket` | +| **HTTP API** | `url`, `token` | `requests` | `device_comms/rpc.py`(BaseRequest) | +| **OPC UA** | `url` | `opcua` | `device_comms/opcua_client/`(OpcUaClient) | +| **无通信(虚拟)** | 无 | 无 | 无 | + +--- + +## 第三步:收集指令协议(关键) + +物模型定义了设备"应该做什么",通信协议定义了"用什么方式通信",但**具体发什么指令**是硬件厂商私有的,AI 无法凭空生成。必须从以下来源获取: + +**Q4: 指令协议的信息来源?** + +| 来源 | AI 处理方式 | 示例 | +|---|---|---| +| **现成 SDK/驱动代码** | 读取代码,提取指令逻辑,包装进 UniLab 框架 | 用户提供 `.py` 文件或 pip 包名 | +| **协议文档/手册** | 读取文档(PDF/图片/文本),解析指令格式 | 用户提供通信协议手册 | +| **用户口述** | 按描述实现指令编解码 | "设温指令是 `01 06 00 0B` + 温度值 + CRC" | +| **标准协议** | 直接使用标准实现 | 标准 Modbus 寄存器表、SCPI 指令集 | +| **HTTP API 文档** | 读取 API 文档,映射到动作方法 | Swagger/OpenAPI 文档 | + +**根据来源执行对应流程:** + +### 场景 A:用户提供了现成 SDK 或驱动代码 + +1. 读取用户提供的驱动代码 +2. 分析其中的通信逻辑:初始化、指令编码、响应解码 +3. 将核心逻辑包装进 UniLab 设备类框架(加入 `self.data` 状态管理、`@property` 属性等) + +### 场景 B:用户提供了协议文档/手册 + +1. 读取文档(支持 PDF、图片、文本) +2. 从文档中提取: + - **指令格式**(文本型 `SET_TEMP 100\r\n`、二进制帧、Modbus 寄存器地址等) + - **响应格式**(如何解析返回数据) + - **寄存器/地址映射表**(哪个地址对应什么功能) +3. 实现指令编解码方法 + +### 场景 C:用户口头描述指令 + +逐个确认每个物模型动作对应的具体指令: + +``` +对于第一步选定的每个标准动作,询问: +- set_temperature → 硬件指令是什么?(如 Modbus 写寄存器 0x000B) +- read_temperature → 硬件指令是什么?(如 发送 0xfe 0xA2 0x00 0x00) +- stop → 硬件指令是什么? +``` + +### 场景 D:虚拟设备(无实际通信) + +跳过此步骤,动作方法中直接模拟行为(修改 `self.data`,用 `sleep` 模拟耗时)。 + +--- + +## 第四步:对齐同类设备接口(强制) + +第一步给出的是**最小通用接口**。本步骤在此基础上,对照仓库现有注册表,**补充**额外的属性和动作,确保新驱动能无缝替换同类设备。 + +> **此步骤是强制性的,不可跳过。** 跳过此步会导致参数名不匹配、status 字符串不一致、缺失属性等问题,使设备无法在工作流中正确运行。 + +**执行步骤:** + +1. 查阅下方「现有设备接口快照」章节,找到同类别的已有设备接口。如果你能访问仓库,建议直接搜索 `unilabos/registry/devices/` 目录获取最新版本。 + +2. 提取已有设备的**额外接口**(超出第一步最小通用接口的部分): + - **status_types** — 是否有额外属性? + - **action_value_mappings** — 是否有额外动作?**逐个记录参数名和类型** + - **status 字符串** — 已有设备用的是什么值?(如 `"Idle"` / `"Busy"` 还是中文?) + - **单位** — 确认单位是否与第一步约定一致 + +3. 对齐决策: + - 新驱动**必须实现**第一步的最小通用接口 + - 如果已有设备有额外属性/动作,**判断新硬件是否支持**: + - 硬件支持 → **必须实现**(保持接口一致) + - 硬件不支持 → 可提供合理的默认值或空实现,但属性必须存在 + - **参数名必须与已有设备完全一致**(这是最常出错的地方) + - **status 字符串值必须与已有设备一致** + - 可以**增加**新的属性和动作,但最小通用接口不能缺少 + +4. 如果同类别下没有已有设备,跳过对齐,按第一步的最小通用接口即可。 + +**对齐验证清单(完成第五步后必须逐项确认):** + +``` +- [ ] 所有动作方法的参数名与已有设备完全一致(如 volume 而非 volume_ml) +- [ ] status 属性返回的字符串值与已有设备一致(如 "Idle" 而非 "就绪") +- [ ] 已有设备的所有 status_types 字段在新驱动中都有对应 @property +- [ ] 已有设备的所有非 auto- 前缀的 action 在新驱动中都有对应方法 +- [ ] self.data 在 __init__ 中已预填充所有属性字段的默认值 +- [ ] 串口/二进制协议的响应解析先定位帧起始标记,不使用硬编码索引 +``` + +--- + +## 第五步:创建设备驱动文件 + +文件路径:`unilabos/devices//.py` + +### 核心结构 + +设备类 = 物模型标准接口 + 通信协议层 + 具体指令编解码: + +```python +import logging +import time as time_module +from typing import Dict, Any + +try: + from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +except ImportError: + BaseROS2DeviceNode = None + + +class MyDevice: + """设备描述""" + + _ros_node: "BaseROS2DeviceNode" + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + + # self.data 必须预填充所有 @property 对应的字段 + # status 字符串必须与同类已有设备一致(查看第四步) + self.data = { + "status": "Idle", + # "其他属性": 默认值, ← 每个 @property 都要有对应的键 + } + + # --- 通信层初始化(按第二步选择的协议填入)--- + # self.ser = serial.Serial(...) + # self.client = ModbusTcpClient(...) + + def post_init(self, ros_node: "BaseROS2DeviceNode"): + self._ros_node = ros_node + + async def initialize(self) -> bool: + self.data.update({"status": "Idle"}) + return True + + async def cleanup(self) -> bool: + self.data.update({"status": "Offline"}) + return True + + # --- 通信辅助方法(按第三步收集的指令协议实现)--- + # def _send_command(self, cmd: str) -> str: ... + + # --- 物模型标准动作(调用通信辅助方法发送实际指令)--- + # async def set_temperature(self, temp: float, **kwargs) -> bool: ... + + # --- 物模型标准属性 --- + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### 关键规则 + +1. **参数类型转换** — 动作参数可能以字符串传入,必须显式 `float()`/`int()` 转换 +2. **异步等待** — 使用 `await self._ros_node.sleep()`,**禁止** `asyncio.sleep()`,也**禁止** `time.sleep()`(会阻塞事件循环) +3. **状态存储** — 用 `self.data` 字典存储,`@property` 读取并自动广播 +4. **进度反馈** — 长操作需循环更新 `self.data["status"]` 和 `remaining_time` +5. **返回值** — 返回 `bool` 或 `Dict[str, Any]`(含 `success` 字段),会显示在前端 + +### 禁止事项(严格遵守) + +以下是导致设备无法接入的常见错误,**必须逐条检查**: + +1. **禁止重命名模板参数** — 模板中的方法参数名(如 `volume`、`position`、`max_velocity`)是接口契约,框架通过参数名分派调用。**绝对不能**加后缀(如 `volume_ml`)、改名(如 `speed_ml_s`)或用其他"更可读"的名字替代。单位信息写在 docstring 中,不写在参数名中。 +2. **status 字符串必须与同类已有设备一致** — 如果已有设备使用英文(如 pump_and_valve 的 `"Idle"` / `"Busy"`),新驱动**必须使用相同的字符串**,不能改为中文。上层代码可能通过 `status == "Idle"` 来判断状态。 +3. **`self.data` 必须在 `__init__` 中预填充所有属性字段** — 不能用空字典 `{}`。框架在 `initialize()` 之前就可能读取属性值。每个 `@property` 对应的键都必须有初始值。 +4. **禁止跳过第四步** — 对齐同类设备接口是强制步骤,不是可选步骤。缺失的属性和动作会导致设备在工作流中不可互换。 +5. **禁止用硬编码索引解析串口响应** — RS-485 半双工总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 `/`、`0xFE`),再用相对偏移解析。否则所有解析方法(错误码、忙闲判断、数据提取)会同时出错,且部分可能歪打正着,造成隐蔽 bug。 + +### 特殊参数类型 + +需要前端资源/设备选择器时: + +```python +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot + +def transfer(self, source: ResourceSlot, target: ResourceSlot, volume: float) -> Dict[str, Any]: + return {"success": True, "volume": volume} +``` + +| Python 类型 | 前端效果 | +|---|---| +| `ResourceSlot` | 单选资源下拉框 | +| `List[ResourceSlot]` | 多选资源下拉框 | +| `DeviceSlot` | 单选设备下拉框 | +| `List[DeviceSlot]` | 多选设备下拉框 | + +### 设备架构分支 + +| 场景 | 基类 | 说明 | +|---|---|---| +| 简单设备 | 无基类(纯 Python 类) | 大多数情况 | +| 工作站 | `WorkstationBase` | 组合多个子设备,有 Deck | +| 液体处理 | `LiquidHandlerAbstract` | PyLabRobot 集成 | +| Modbus 设备 | 可用 `device_comms/modbus_plc/` | 节点注册 + 工作流 | +| OPC UA 设备 | 可用 `device_comms/opcua_client/` | 节点发现 + CSV 配置 | + +--- + +## 第六步:创建注册表 YAML + +在 `unilabos/registry/devices/` 下创建。 + +### 最小配置(推荐) + +```yaml +my_device: + class: + module: unilabos.devices..:MyDevice + type: python +``` + +启动时 `--complete_registry` 自动生成 `status_types`、`action_value_mappings` 等全部字段。 + +### 手动补充(可选) + +```yaml +my_device: + category: + - temperature + description: "我的温控设备" + class: + module: unilabos.devices.temperature.my_heater:MyHeater + type: python +``` + +### 完整 YAML 结构参考 + +```yaml +my_device: + description: "设备描述" + version: "1.0.0" + category: [my_category] + icon: "" + handles: [] + class: + module: unilabos.devices.my_category.my_device:MyDevice + type: python + status_types: + status: String # str → String + temp: Float64 # float → Float64 + is_running: Bool # bool → Bool + position: Int64 # int → Int64 + action_value_mappings: + my_action: + type: UniLabJsonCommandAsync # 或 UniLabJsonCommand + goal: + param1: param1 + result: + success: success + goal_default: + param1: 0.0 + handles: {} + placeholder_keys: {} + schema: + title: my_action参数 + type: object + properties: + goal: + type: object + properties: + param1: + type: number + required: [param1] + required: [goal] +``` + +### Python → ROS 类型映射 + +| Python | ROS | YAML `status_types` | +|---|---|---| +| `str` | `std_msgs/String` | `String` | +| `bool` | `std_msgs/Bool` | `Bool` | +| `int` | `std_msgs/Int64` | `Int64` | +| `float` | `std_msgs/Float64` | `Float64` | +| `list`/`dict` | `std_msgs/String`(JSON 序列化) | `String` | + +--- + +## 第七步:配置图文件 + +在实验图文件(JSON)中添加设备节点: + +```json +{ + "id": "my_device_1", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "data": {} +} +``` + +`config` 中的参数对应通信协议所需的连接信息,直接传入 `__init__` 的 `config` 字典。 + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.. import " + +# 2. 注册表补全(可选) +unilab -g .json --complete_registry + +# 3. 启动测试 +unilab -g .json +``` + +--- + +## 工作流清单 + +``` +设备接入进度: +- [ ] 1. 确定设备类别(物模型)+ 单位约定 +- [ ] 2. 确定通信协议 +- [ ] 3. 收集指令协议(SDK/文档/口述) +- [ ] 4. 对齐同类设备接口(对照快照或搜索注册表) +- [ ] 5. 创建驱动 unilabos/devices//.py +- [ ] 6. 创建注册表 unilabos/registry/devices/.yaml +- [ ] 7. 配置图文件(如需要) +- [ ] 8. 验证可导入 + 启动测试 +``` + +--- + +## 现有设备接口快照 + +> 以下是仓库中已有设备的接口定义,用于第四步对齐。 +> 如果你能访问仓库,建议搜索 `unilabos/registry/devices/` 获取最新版本。 +> 最新版本也可通过 GitHub 获取: +> https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +### pump_and_valve — 注射泵子类型 + +已有设备:`syringe_pump_with_valve.runze.SY03B-T06` / `SY03B-T08` +驱动类:`unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump` + +**status_types(属性):** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | `"Idle"` / `"Busy"` | +| `valve_position` | `str` | 阀门位置 | +| `position` | `float` | 当前体积 (mL) | +| `max_velocity` | `float` | 最大速度 (mL/s) | +| `mode` | `int` | 运行模式 | +| `plunger_position` | `String` | 活塞位置 | +| `velocity_grade` | `String` | 速度档位 | +| `velocity_init` | `String` | 初始速度 | +| `velocity_end` | `String` | 终止速度 | + +**关键动作方法签名(参数名不可修改):** + +```python +def initialize(self) +def set_valve_position(self, position) # 参数名必须是 position +def set_position(self, position: float, max_velocity: float = None) +def pull_plunger(self, volume: float) # 参数名必须是 volume +def push_plunger(self, volume: float) # 参数名必须是 volume +def set_max_velocity(self, velocity: float) +def set_velocity_grade(self, velocity) +def stop_operation(self) +def send_command(self, full_command: str) +def set_baudrate(self, baudrate) +def close(self) +``` + +### pump_and_valve — 电磁阀子类型 + +已有设备:`solenoid_valve` / `solenoid_valve.mock` +驱动类:`unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `valve_position` | `str` | 阀门位置 | + +**关键动作方法签名:** + +```python +def open(self) +def close(self) +def set_valve_position(self, position) # 参数名是 position +def is_open(self) +def is_closed(self) +def send_command(self, command: str) +``` + +### temperature — 温控设备 + +已有设备:`dalong_heaterstirrer`(加热搅拌器) +驱动类:`unilabos.devices.temperature.dalong:DalongHeaterStirrer` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `temp` | `float` | 当前温度 (°C) | +| `temp_target` | `float` | 目标温度 (°C) | +| `stir_speed` | `float` | 搅拌速度 (RPM) | +| `temp_warning` | `float` | 警告温度 (°C) | + +### motor — 电机设备 + +已有设备:`zdt_x42`(闭环步进电机) +驱动类:`unilabos.devices.motor.zdt_x42:ZDTX42Motor` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `position` | `int` | 当前位置 | + +### sensor — 传感器 + +已有设备:`xkc_level_sensor`(液位传感器) +驱动类:`unilabos.devices.sensor.xkc_level_sensor:XKCLevelSensor` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `level` | `bool` | 液位状态 | +| `rssi` | `int` | 信号强度 | + +--- + +## 物模型代码模板 + +### temperature — 温控设备 + +```python +class MyTemperatureDevice: + """温控设备:加热器、冷却器、恒温槽等""" + + def __init__(self, device_id=None, config=None, **kwargs): + # ... 标准 init ... + self.data = { + "status": "Idle", + "temp": 25.0, + "temp_target": 25.0, + } + + async def set_temperature(self, temp: float, **kwargs) -> bool: + """设定目标温度 (°C)""" + temp = float(temp) + self.data["temp_target"] = temp + # >>> 在此填入实际指令 <<< + return True + + async def stop(self, **kwargs) -> bool: + self.data["status"] = "Idle" + # >>> 在此填入实际指令 <<< + return True + + @property + def temp(self) -> float: + return self.data.get("temp", 0.0) + + @property + def temp_target(self) -> float: + return self.data.get("temp_target", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### pump_and_valve — 注射泵 + +> **严禁重命名参数!** 下方模板中的参数名(`volume`、`position`、`max_velocity` 等)是接口契约。禁止加后缀(如 ~~`volume_ml`~~)、改名(如 ~~`speed_ml_s`~~)或用其他名字替代。单位信息写在 docstring 里,不写在参数名中。 + +```python +class MySyringePump: + """注射泵设备 — 含阀门控制""" + + def __init__(self, device_id=None, config=None, **kwargs): + # ... 标准 init ... + self.max_volume = float(config.get("max_volume", 25.0)) + self.total_steps = 6000 + self.data = { + "status": "Idle", # 必须用英文 "Idle" / "Busy" + "valve_position": "I", + "position": 0.0, # 当前体积位置 (mL) + # 第四步可能要求补充更多字段(如 max_velocity, mode 等) + } + + def initialize(self): + # >>> 发送初始化指令 <<< + return response + + def set_valve_position(self, position): + """设置阀门位置。参数名必须是 position""" + # >>> 发送阀门指令 <<< + return response + + def set_position(self, position: float, max_velocity: float = None): + """移动到绝对体积位置 (mL)。参数名 position / max_velocity 不可修改""" + pos_step = int(float(position) / self.max_volume * self.total_steps) + # >>> 发送绝对位置指令 <<< + return response + + def pull_plunger(self, volume: float): + """吸液 (mL)。参数名必须是 volume""" + pos_step = int(float(volume) / self.max_volume * self.total_steps) + # >>> 发送相对吸液指令 <<< + return response + + def push_plunger(self, volume: float): + """排液 (mL)。参数名必须是 volume""" + pos_step = int(float(volume) / self.max_volume * self.total_steps) + # >>> 发送相对排液指令 <<< + return response + + def stop_operation(self): + # >>> 发送终止指令 <<< + return response + + def close(self): + self.hardware_interface.close() + + @property + def status(self) -> str: + return self._status # "Idle" 或 "Busy" + + @property + def valve_position(self) -> str: + return self._valve_position + + @property + def position(self) -> float: + """当前体积位置 (mL)""" + return self._position +``` + +### pump_and_valve — 电磁阀 + +```python +class MySolenoidValve: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "valve_position": "closed"} + + async def open(self, **kwargs) -> bool: + return True + + async def close(self, **kwargs) -> bool: + return True + + async def set_valve_position(self, position: str, **kwargs) -> bool: + self.data["valve_position"] = str(position) + return True + + @property + def valve_position(self) -> str: + return self.data.get("valve_position", "closed") + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### pump_and_valve — 蠕动泵 + +```python +class MyPeristalticPump: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "speed": 0.0, "direction": "CW"} + + async def set_speed(self, speed: float, **kwargs) -> bool: + """设置流速 (mL/min)""" + self.data["speed"] = float(speed) + return True + + async def stop(self, **kwargs) -> bool: + self.data["speed"] = 0.0 + self.data["status"] = "Idle" + return True + + @property + def speed(self) -> float: + return self.data.get("speed", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### motor — 电机设备 + +```python +class MyMotor: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "position": 0, "speed": 0.0} + + async def enable(self, **kwargs) -> bool: + self.data["status"] = "Enabled" + return True + + async def move_position(self, position: int, speed: float = 100.0, **kwargs) -> bool: + position, speed = int(position), float(speed) + return True + + async def move_speed(self, speed: float, **kwargs) -> bool: + self.data["speed"] = float(speed) + return True + + async def stop(self, **kwargs) -> bool: + self.data["status"] = "Idle" + self.data["speed"] = 0.0 + return True + + @property + def position(self) -> int: + return self.data.get("position", 0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### heaterstirrer — 加热搅拌 + +```python +class MyHeaterStirrer: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = { + "status": "Idle", "temp": 25.0, "temp_target": 25.0, + "stir_speed": 0.0, "is_stirring": False, + } + + async def set_temperature(self, temp: float, **kwargs) -> bool: + self.data["temp_target"] = float(temp) + return True + + async def stir(self, stir_speed: float, stir_time: float = 0, settling_time: float = 0, **kwargs) -> bool: + self.data["stir_speed"] = float(stir_speed) + self.data["is_stirring"] = True + if stir_time > 0: + start = time_module.time() + while time_module.time() - start < stir_time: + self.data["remaining_time"] = max(0, stir_time - (time_module.time() - start)) + await self._ros_node.sleep(1.0) + self.data["is_stirring"] = False + return True + + async def stop(self, **kwargs) -> bool: + self.data.update({"status": "Idle", "stir_speed": 0.0, "is_stirring": False}) + return True + + @property + def temp(self) -> float: + return self.data.get("temp", 25.0) + + @property + def stir_speed(self) -> float: + return self.data.get("stir_speed", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### balance — 天平 + +```python +class MyBalance: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "weight": 0.0, "unit": "g", "stable": True} + + def read_weight(self, **kwargs) -> Dict[str, Any]: + return {"success": True, "weight_g": self.data["weight"], "stable": self.data["stable"]} + + def tare(self, **kwargs) -> Dict[str, Any]: + self.data["weight"] = 0.0 + return {"success": True, "message": "去皮完成"} + + @property + def weight(self) -> float: + return self.data.get("weight", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### sensor — 传感器 + +```python +class MySensor: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "value": 0.0, "level": False} + + def read_value(self, **kwargs) -> Dict[str, Any]: + return {"success": True, "value": self.data["value"]} + + async def wait_for_level(self, target_level: bool = True, timeout: float = 60.0, **kwargs) -> bool: + start = time_module.time() + while time_module.time() - start < float(timeout): + if self.data["level"] == bool(target_level): + return True + await self._ros_node.sleep(0.5) + return False + + @property + def value(self) -> float: + return self.data.get("value", 0.0) + + @property + def level(self) -> bool: + return self.data.get("level", False) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +--- + +## 指令协议模式 + +通信协议解决"用什么方式通信",指令协议解决"发什么内容"。 + +### 模式 1:文本指令 + +```python +def _send_command(self, cmd: str) -> str: + self.ser.write(f"{cmd}\r\n".encode()) + return self.ser.readline().decode().strip() +``` + +### 模式 2:自定义二进制帧 + +```python +def _build_frame(self, func_code: int, data: bytes) -> bytes: + frame = bytearray([0xFE, func_code]) + bytearray(data) + while len(frame) < 5: + frame.append(0x00) + checksum = sum(frame[1:]) % 256 + frame.append(checksum) + return bytes(frame) + +def _send_frame(self, func_code: int, data: bytes) -> bytes: + frame = self._build_frame(func_code, data) + self.ser.write(frame) + return self.ser.read(6) +``` + +### 模式 3:Modbus 寄存器读写 + +```python +REGISTER_MAP = { + "temp_target": {"addr": 0x000B, "scale": 10}, + "temp_current": {"addr": 0x0001, "scale": 10}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + temp = float(temp) + reg = REGISTER_MAP["temp_target"] + value = int(temp * reg["scale"]) & 0xFFFF + self.client.write_register(reg["addr"], value, slave=self.slave_id) + self.data["temp_target"] = temp + return True +``` + +### 模式 4:JSON/REST API + +```python +API_MAP = { + "set_temperature": {"method": "POST", "endpoint": "/api/temperature", "body_key": "target"}, + "get_status": {"method": "GET", "endpoint": "/api/status"}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + api = API_MAP["set_temperature"] + resp = self._post(api["endpoint"], {api["body_key"]: float(temp)}) + return resp.get("success", False) +``` + +### 模式 5:SDK 封装 + +```python +from my_device_sdk import DeviceController + +class MyDevice: + def __init__(self, device_id=None, config=None, **kwargs): + self.controller = DeviceController(port=config.get('port', 'COM1')) + self.data = {"status": "Idle"} + + def set_temperature(self, temp: float, **kwargs) -> bool: + self.controller.set_target_temp(float(temp)) + return True +``` + +--- + +## 通信协议代码片段 + +### Serial(RS232 / RS485) + +```python +import serial + +self.ser = serial.Serial( + port=self.config.get('port', 'COM1'), + baudrate=self.config.get('baudrate', 9600), + timeout=self.config.get('timeout', 1), +) + +# cleanup: +if hasattr(self, 'ser') and self.ser.is_open: + self.ser.close() +``` + +**串口响应解析健壮性(重要):** RS-485 半双工总线上,设备响应前经常有前导垃圾字节(TX 回声、总线噪声等)。**禁止用硬编码索引直接解析原始响应**,必须先定位帧起始标记: + +```python +# ✗ 错误 — 假设响应从 index 0 开始,前导垃圾字节会导致所有解析偏移 +status_byte = ord(response[2]) +data = response[3:etx_pos] + +# ✓ 正确 — 先找到帧起始标记,再用相对偏移解析 +def _normalize_response(self, raw: str, start_marker: str = "/") -> str: + """去除帧起始标记之前的垃圾字节""" + pos = raw.find(start_marker) + return raw[pos:] if pos >= 0 else raw + +# 在 _send_command 返回前调用: +resp_str = self._normalize_response(resp_str) +``` + +同理,二进制帧协议也必须先查找帧头字节(如 `0xFE`),不能假设 `response[0]` 就是帧头。 + +### Modbus RTU + +```python +from pymodbus.client import ModbusSerialClient + +self.client = ModbusSerialClient( + port=self.config.get('port', 'COM1'), + baudrate=self.config.get('baudrate', 9600), + timeout=self.config.get('timeout', 1), +) +self.client.connect() +self.slave_id = self.config.get('slave_id', 1) +``` + +### Modbus TCP + +```python +from pymodbus.client import ModbusTcpClient + +self.client = ModbusTcpClient( + host=self.config.get('host', '192.168.1.100'), + port=self.config.get('port', 502), +) +self.client.connect() +self.slave_id = self.config.get('slave_id', 1) +``` + +### TCP Socket + +```python +import socket + +self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +self.sock.settimeout(self.config.get('timeout', 5)) +self.sock.connect((self.config['host'], self.config['port'])) +``` + +### HTTP API + +```python +import requests + +self.base_url = self.config.get('url', 'http://localhost:8080') +self.session = requests.Session() +``` + +### OPC UA + +```python +from opcua import Client + +self.opc_client = Client(self.config.get('url', 'opc.tcp://localhost:4840')) +self.opc_client.connect() +``` + +--- + +## 常见错误(必读) + +以下是历史上导致设备无法接入的真实案例,**生成代码后必须逐条对照检查**: + +### 错误 1:重命名模板参数名 + +```python +# ✗ 错误 +async def pull_plunger(self, volume_ml: float, speed_ml_s: float = None, **kwargs): +# ✓ 正确 +async def pull_plunger(self, volume: float, **kwargs): + +# ✗ 错误 +async def set_position(self, position_ml: float, speed_ml_s: float = None, **kwargs): +# ✓ 正确 +async def set_position(self, position: float, max_velocity: float = None, **kwargs): + +# ✗ 错误 +async def set_valve_position(self, valve_position: int, **kwargs): +# ✓ 正确 +async def set_valve_position(self, position, **kwargs): +``` + +### 错误 2:status 字符串使用中文 + +```python +# ✗ 错误 +self.data["status"] = "就绪" +# ✓ 正确 +self.data["status"] = "Idle" +``` + +### 错误 3:self.data 初始化为空字典 + +```python +# ✗ 错误 +self.data = {} +# ✓ 正确 +self.data = {"status": "Idle", "valve_position": "I", "position": 0.0, "max_velocity": 0.0} +``` + +### 错误 4:跳过第四步,缺失已有设备的属性 + +```python +# ✓ 即使硬件不直接支持,也要提供属性(返回默认值) +@property +def max_velocity(self) -> float: + return self.data.get("max_velocity", 0.0) +``` + +### 错误 5:在 async 方法中使用 time.sleep() + +```python +# ✗ 错误 +time.sleep(0.5) +# ✓ 正确 +await self._ros_node.sleep(0.5) +``` + +### 错误 6:用硬编码索引解析串口响应 + +```python +# ✗ 错误 — RS-485 响应前有回声/噪声字节时,所有索引偏移,解析全部出错 +# 而且 _parse_error / _is_busy 可能歪打正着返回"正确"结果, +# 导致轮询失效(永远认为设备空闲)、错误被吞、状态查询异常 +status_byte = ord(response[2]) +data = response[3:etx_pos] + +# ✓ 正确 — 先定位帧起始标记(如 /、0xFE 等),再用相对偏移 +start = response.find("/") +if start >= 0: + response = response[start:] +status_byte = ord(response[2]) +data = response[3:etx_pos] +``` + +**规则:** 串口协议解析必须先定位帧起始标记,禁止假设 `response[0]` 就是帧头。 + +--- + +## 返回值设计 + +```python +return { + "success": True, + "message": "操作完成", + "temperature_celsius": 25.5, +} +``` + +--- + +## 图文件:工作站配置 + +工作站需要 `deck` 和 `children`: + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "children": ["my_deck"], + "config": {}, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module:MyDeck" + } + } + }, + { + "id": "my_deck", + "type": "deck", + "class": "MyDeckClass", + "parent": "my_station", + "children": [], + "config": {"type": "MyDeckClass", "setup": true} + } + ] +} +``` diff --git a/docs/ai_guides/agent_prompt_template.md b/docs/ai_guides/agent_prompt_template.md new file mode 100644 index 000000000..99dee3386 --- /dev/null +++ b/docs/ai_guides/agent_prompt_template.md @@ -0,0 +1,344 @@ +# Uni-Lab-OS 设备接入 Agent — 提示词模板 + +> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。 +> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。 + +--- + +## 系统提示词模板 + +以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。 + +--- + +### 开始复制 ↓ + +``` +你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。 + +你能做的事: +- 根据用户描述,生成完整的设备驱动代码(Python)、注册表(YAML)和实验图文件(JSON) +- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式 +- 诊断已有驱动代码的接口对齐问题 + +你不能做的事: +- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取) +- 替代真实硬件联调测试 + +## 知识来源 + +{{KNOWLEDGE_LOADING}} + +## 工作流程 + +当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。 + +### 阶段 1:设备画像(交互) + +向用户收集以下三个信息,可以一次性提问: + +1. **设备类别** — 属于以下哪一种? + - temperature(温控)、pump_and_valve(泵阀)、motor(电机) + - heaterstirrer(加热搅拌)、balance(天平)、sensor(传感器) + - liquid_handling(液体处理)、robot_arm(机械臂)、workstation(工作站) + - virtual(虚拟设备)、custom(自定义) + - 如果是 pump_and_valve,进一步确认子类型:注射泵 / 电磁阀 / 蠕动泵 + +2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b) + +3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟) + +⏸️ **暂停:等待用户回答后继续** + +### 阶段 2:指令协议收集(交互) + +根据上一步确定的通信协议,引导用户提供指令信息: + +- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑 +- 如果用户有 **协议文档**:请用户提供文档(PDF/图片/文本),你从中解析指令格式 +- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令 +- 如果是 **标准协议**(Modbus 寄存器表、SCPI):请用户提供寄存器/指令映射 +- 如果是 **虚拟设备**:跳过此阶段 + +⏸️ **暂停:确认已获取足够的指令协议信息** + +### 阶段 3:确认摘要 + +在开始生成代码前,向用户展示你的理解摘要: + +``` +设备接入摘要: +- 设备名称: +- 设备类别:) +- 通信协议: +- 指令来源: +- 将要实现的属性: +- 将要实现的动作: +- 同类已有设备:(将对齐其接口) +``` + +⏸️ **暂停:用户确认"没问题"后再生成代码** + +### 阶段 4:自动生成(无需暂停) + +按以下顺序自动执行: + +1. **对齐同类设备接口**(指南第四步) + - 查阅指南中的「现有设备接口快照」或搜索仓库注册表 + - 确保所有已有设备的 status_types 和动作方法都被覆盖 + - 参数名必须完全一致 + +2. **生成驱动代码** — `unilabos/devices//.py` + +3. **生成注册表** — `unilabos/registry/devices/.yaml`(最小配置) + +4. **生成图文件** — `unilabos/test/experiments/graph_example_.json` + +### 阶段 5:验证输出 + +生成完成后,逐项检查对齐验证清单并展示结果: + +``` +对齐验证清单: +- [x] 所有动作方法的参数名与已有设备完全一致 +- [x] status 属性返回的字符串值与已有设备一致 +- [x] 已有设备的所有 status_types 字段都有对应 @property +- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法 +- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值 +- [x] 串口/二进制协议的响应解析先定位帧起始标记 +``` + +如果有未通过的项,主动修复后再展示。 + +## 硬约束(违反任何一条都会导致设备接入失败) + +1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity)是接口契约,框架通过参数名分派调用。绝不能加后缀(如 volume_ml)、改名(如 speed_ml_s)。单位写在 docstring 中。 + +2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。 + +3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。 + +4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。 + +5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE),禁止用硬编码索引直接解析。 + +6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。 + +7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位(mL、°C、RPM),驱动内部负责转换到硬件原始值(步数、Hz、寄存器值)。 + +## 代码骨架参考 + +所有设备驱动遵循以下结构: + +```python +import logging +import time as time_module +from typing import Dict, Any + +try: + from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +except ImportError: + BaseROS2DeviceNode = None + +class MyDevice: + _ros_node: "BaseROS2DeviceNode" + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + self.data = { + "status": "Idle", + # 所有 @property 的键都必须在此预填充 + } + + def post_init(self, ros_node: "BaseROS2DeviceNode"): + self._ros_node = ros_node + + async def initialize(self) -> bool: + self.data["status"] = "Idle" + return True + + async def cleanup(self) -> bool: + self.data["status"] = "Offline" + return True + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +## 注册表最小配置 + +```yaml +my_device: + class: + module: unilabos.devices..:MyDevice + type: python +``` + +启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。 + +## 图文件模板 + +```json +{ + "nodes": [ + { + "id": "my_device_1", + "name": "设备名称", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": {}, + "data": {} + } + ] +} +``` + +## 现有设备接口快照(对齐用) + +对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本: +https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +### pump_and_valve — 注射泵 + +已有设备:syringe_pump_with_valve.runze.SY03B-T06 + +属性:status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String) + +方法签名(参数名不可改): +- initialize() +- set_valve_position(position) +- set_position(position: float, max_velocity: float = None) +- pull_plunger(volume: float) +- push_plunger(volume: float) +- set_max_velocity(velocity: float) +- set_velocity_grade(velocity) +- stop_operation() + +### pump_and_valve — 电磁阀 + +属性:status(str), valve_position(str) +方法:open(), close(), set_valve_position(position), is_open(), is_closed() + +### temperature + +属性:status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C) + +### motor + +属性:status(str), position(int) + +### sensor + +属性:level(bool), rssi(int) +``` + +### 结束复制 ↑ + +--- + +## `{{KNOWLEDGE_LOADING}}` 变量替换 + +根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容: + +### 方案 A:有知识库(Custom GPT / Claude Project) + +``` +你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。 +执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。 +本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。 +``` + +### 方案 B:有联网能力 + +``` +执行工作流前,从以下 URL 获取完整的设备接入指南: +https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md + +该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。 +如果无法访问 URL,使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。 +``` + +### 方案 C:无知识库、无联网 + +``` +完整的设备接入指南需要用户在对话中提供。 +如果用户未主动提供,请在阶段 1 开始前询问: +"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。" + +本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。 +但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。 +``` + +--- + +## 各平台配置指南 + +### OpenAI Custom GPT + +1. 进入 https://chat.openai.com/gpts/editor +2. **Name**:Uni-Lab-OS 设备接入助手 +3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。 +4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A +5. **Knowledge**:上传 `docs/ai_guides/add_device.md` +6. **Capabilities**:开启 Code Interpreter(用于代码验证) +7. **Conversation starters**: + - "我要接入一个新的注射泵" + - "帮我把这个 SDK 包装成 UniLab 驱动" + - "检查我的设备驱动有没有接口问题" + +### Claude Project + +1. 创建新 Project +2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A +3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md` + +### API Agent(LangChain / AutoGen / 自建框架) + +```python +system_prompt = """ +<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B> +""" + +# 如果框架支持工具调用,可注册以下工具: +tools = [ + { + "name": "fetch_device_guide", + "description": "获取最新的 Uni-Lab-OS 设备接入指南", + "url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md" + }, + { + "name": "fetch_registry", + "description": "获取最新的设备注册表", + "url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml" + }, +] +``` + +### Cursor Agent Mode + +无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力(Grep 搜索注册表、AskQuestion 收集信息等)。 + +### 纯网页对话(ChatGPT / Claude 无 Project) + +1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C) +2. 第二条消息上传或粘贴 `add_device.md` +3. 第三条消息开始描述设备 + +--- + +## 维护说明 + +- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分 +- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分 +- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分 +- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护 diff --git a/docs/developer_guide/add_old_device.md b/docs/developer_guide/add_old_device.md index 583d0bb24..40507ce83 100644 --- a/docs/developer_guide/add_old_device.md +++ b/docs/developer_guide/add_old_device.md @@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例: - 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py` - 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py` -- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py` +- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework` --- ## 其他工业通信协议:CANopen, Ethernet, OPCUA... -【敬请期待】 +Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比,OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`。 + +其他协议(CANopen、EtherCAT 等)【敬请期待】 ## 没有接口的老设备老软件:使用 PyWinAuto diff --git a/docs/developer_guide/plc_framework.md b/docs/developer_guide/plc_framework.md new file mode 100644 index 000000000..b0e34928c --- /dev/null +++ b/docs/developer_guide/plc_framework.md @@ -0,0 +1,281 @@ +# PLC 设备接管框架 + +> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"(连接并控制)PLC 设备。 + +## 什么是"PLC 接管"? + +**PLC**(可编程逻辑控制器)是工厂设备的控制核心,驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行: + +``` +UniLab-OS(Python) ←通信协议→ PLC ←电信号→ 电机/气缸/传感器 +``` + +UniLab-OS 提供两套接管框架,对应两种工业协议: + +| 框架 | 协议 | 应用项目 | 核心文件 | +| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- | +| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` | +| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` | + +两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。 + +--- + +## 核心概念 + +### 节点(Node) + +节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。 + +| 属性 | 说明 | 示例 | +| ---- | -------------------------------------- | -------------------- | +| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` | +| 地址 | PLC 内存地址 | `0x0064` | +| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` | + +``` +PLC 内存空间 +├── Coil 区: True / False ← 控制开关量(启动/停止/复位) +├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置) +└── Input Reg: 99.8, 42 … ← 只读传感器数据 +``` + +### 动作生命周期(Action Lifecycle) + +每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性: + +```python +try: + init(...) # 写入参数(速度、位置等)— 可选 + start(...) # 发触发信号 + 轮询等待完成 + stop(...) # 复位触发信号(成功时执行) +except: + is_err = True +finally: + cleanup(...) # 无论成败都执行,作为安全兜底 +``` + +| 阶段 | 何时执行 | 典型内容 | +| --------- | ----------------------- | ------------------------------------ | +| `init` | 成功路径(可选) | 写运动速度 = 20.0 | +| `start` | 成功路径 | 写启动位 = True,等待完成位 = True | +| `stop` | 成功路径 | 写启动位 = False(正常复位) | +| `cleanup` | **无论成败**(finally) | 安全兜底复位,防止异常时设备持续运动 | + +> **为什么 `cleanup` 无论成败都执行?** +> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过,PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。 + +--- + +## Modbus 框架 + +**核心文件**:`unilabos/device_comms/modbus_plc/client.py` +**参考实现**:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` + +### 连接与节点注册 + +```python +from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient + +# 1. 建立 TCP 连接 +client = TCPClient(addr="172.16.28.102", port=502) +client.client.connect() + +# 2. 从 CSV 加载节点定义 +nodes = BaseClient.load_csv("coin_cell_assembly_b.csv") + +# 3. 注册节点,之后可按名称访问 +client.register_node_list(nodes) + +# 访问节点 +client.use_node('COIL_SYS_START_CMD').write(True) +value, err = client.use_node('COIL_SYS_START_STATUS').read(1) +``` + +**CSV 格式**(`Name` / `DeviceType` / `Address` / `DataType`): + +| Name | DeviceType | Address | DataType | +| ------------------ | ------------- | ------- | -------- | +| COIL_SYS_START_CMD | coil | 100 | INT16 | +| REG_SPEED | hold_register | 200 | FLOAT32 | + +### 三段式接管流程(扣式电池工站) + +PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例: + +``` +Python PLC + │── 写 HAND_CMD = True ─────────>│ 切换到手动模式 + │<─ 读 HAND_STATUS = True ────────│ 确认进入手动 + │── 写 INIT_CMD = True ──────────>│ 执行初始化 + │<─ 读 INIT_STATUS = True ─────────│ 初始化完成 + │── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号) + │── 写 INIT_CMD = False ──────────>│ 复位 + │── 写 AUTO_CMD = True ───────────>│ 切换自动模式 + │<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪 + │── 写 AUTO_CMD = False ──────────>│ 复位 + │── 写 START_CMD = True ──────────>│ 开始运行 + │<─ 读 START_STATUS = True ────────│ 运行确认 + │── 写 START_CMD = False ──────────>│ 复位 +``` + +> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。 + +### JSON 配置方式 + +Modbus 框架支持纯 JSON 配置,无需手写 Python 流程: + +```json +{ + "register_node_list_from_csv_path": {"path": "M01.csv"}, + "create_flow": [ + { + "name": "归位", + "action": [{ + "address_function_to_create": [ + {"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true}, + {"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1}, + {"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1} + ], + "create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0}, + "create_start_function": { + "func_name": "idel_position", + "write_functions": ["pos_tip"], + "condition_functions": ["pos_tip_read", "manual_stop"], + "stop_condition_expression": "pos_tip_read[0] and manual_stop[0]" + }, + "create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false}, + "create_cleanup_function": null + }] + } + ], + "execute_flow": ["归位"] +} +``` + +执行: + +```python +client.execute_procedure_from_json(json_data) +``` + +--- + +## OPC UA 框架 + +**核心文件**:`unilabos/devices/workstation/post_process/post_process.py` +**参考实现**:`unilabos/devices/workstation/post_process/opcua_huairou.json` + +### 与 Modbus 的主要区别 + +| 特性 | Modbus | OPC UA | +| ---------- | -------------------- | --------------------------------- | +| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 | +| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) | +| 节点标识 | 数字地址(如 `100`) | 字符串 NodeId(如 `ns=2;s=速度`) | +| 断线处理 | 无 | **后台监控线程**自动重连 | +| 认证安全 | 无 | 支持用户名/密码 | +| 工作流调用 | 手动调用 | **自动注册为实例方法** | + +### 连接与节点发现 + +```python +from unilabos.devices.workstation.post_process.post_process import OpcUaClient + +client = OpcUaClient( + url="opc.tcp://192.168.1.100:4840", + username="admin", # 可选 + password="123456", # 可选 + config_path="opcua_huairou.json", # 自动加载工作流配置 + cache_timeout=5.0, # 节点值缓存 5 秒 + subscription_interval=500, # 每 500ms 接收推送 +) + +# 节点自动通过订阅保持最新值,读取直接查本地缓存 +value = client.get_node_value("grab_complete") +``` + +### JSON 配置方式 + +```json +{ + "register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"}, + "create_flow": [ + { + "name": "trigger_grab_action", + "description": "触发反应罐及原料罐抓取动作", + "parameters": ["reaction_tank_number", "raw_tank_number"], + "action": [{ + "init_function": { + "func_name": "init_grab_params", + "write_nodes": ["reaction_tank_number", "raw_tank_number"] + }, + "start_function": { + "func_name": "start_grab", + "write_nodes": {"grab_trigger": true}, + "condition_nodes": ["grab_complete"], + "stop_condition_expression": "grab_complete == True", + "timeout_seconds": 999999.0 + }, + "stop_function": { + "func_name": "stop_grab", + "write_nodes": {"grab_trigger": false} + } + }] + } + ] +} +``` + +配置加载后,工作流自动注册为实例方法: + +```python +# 直接调用,传入参数,框架自动写入对应节点 +client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3) +``` + +--- + +## 新增设备快速上手 + +### 使用 Modbus 框架 + +``` +1. 从 PLC 工程师处拿到地址表,按格式填写 CSV(Name/DeviceType/Address/DataType) +2. 继承 BaseClient,在 __init__ 中连接并注册节点 +3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动) +4. 或直接编写 JSON 配置,调用 execute_procedure_from_json() +``` + +### 使用 OPC UA 框架 + +``` +1. 确认设备支持 OPC UA,拿到服务器 URL(格式:opc.tcp://IP:PORT) +2. 准备 CSV 节点定义文件(可选,也可让框架自动发现) +3. 编写 JSON 配置:定义 parameters、init/start/stop 函数 +4. 实例化 OpcUaClient,传入 config_path,直接调用自动注册的工作流方法 +``` + +--- + +## 常见问题 + +**Q: `node {name} is not registered` 报错?** +A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`。 + +**Q: 程序卡死在 `while not status(): sleep(1)`?** +A: PLC 未返回预期完成信号。检查:PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。 + +**Q: OPC UA 连接成功但读不到节点?** +A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。 + +**Q: 应该选 Modbus 还是 OPC UA?** +A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。 + +--- + +## 下一步 + +- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点 +- {doc}`add_action` - 为设备添加可调度的动作指令 +- {doc}`add_yaml` - 编写设备注册表 YAML 文件 diff --git a/docs/index.md b/docs/index.md index 6326bb8ce..d8f033b30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,9 @@ developer_guide/http_api.md developer_guide/networking_overview.md developer_guide/add_device.md developer_guide/add_action.md +developer_guide/add_old_device.md +developer_guide/plc_framework.md +developer_guide/add_protocol.md developer_guide/add_registry.md developer_guide/add_yaml.md developer_guide/action_includes.md diff --git a/unilabos/devices/liquid_handling/laiyu/backend/__init__.py b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py index 4bf29392d..b7e1b34a6 100644 --- a/unilabos/devices/liquid_handling/laiyu/backend/__init__.py +++ b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py @@ -1,9 +1,7 @@ """ LaiYu液体处理设备后端模块 - -提供设备后端接口和实现 """ -from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend +from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend -__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend'] \ No newline at end of file +__all__ = ['UniLiquidHandlerLaiyuBackend'] diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py deleted file mode 100644 index 5e8041c00..000000000 --- a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -LaiYu液体处理设备后端实现 - -提供设备的后端接口和控制逻辑 -""" - -import logging -from typing import Dict, Any, Optional, List -from abc import ABC, abstractmethod - -# 尝试导入PyLabRobot后端 -try: - from pylabrobot.liquid_handling.backends import LiquidHandlerBackend - PYLABROBOT_AVAILABLE = True -except ImportError: - PYLABROBOT_AVAILABLE = False - # 创建模拟后端基类 - class LiquidHandlerBackend: - def __init__(self, name: str): - self.name = name - self.is_connected = False - - def connect(self): - """连接设备""" - pass - - def disconnect(self): - """断开连接""" - pass - - -class LaiYuLiquidBackend(LiquidHandlerBackend): - """LaiYu液体处理设备后端""" - - def __init__(self, name: str = "LaiYu_Liquid_Backend"): - """ - 初始化LaiYu液体处理设备后端 - - Args: - name: 后端名称 - """ - if PYLABROBOT_AVAILABLE: - # PyLabRobot 的 LiquidHandlerBackend 不接受参数 - super().__init__() - else: - # 模拟版本接受 name 参数 - super().__init__(name) - - self.name = name - self.logger = logging.getLogger(__name__) - self.is_connected = False - self.device_info = { - "name": "LaiYu液体处理设备", - "version": "1.0.0", - "manufacturer": "LaiYu", - "model": "LaiYu_Liquid_Handler" - } - - def connect(self) -> bool: - """ - 连接到LaiYu液体处理设备 - - Returns: - bool: 连接是否成功 - """ - try: - self.logger.info("正在连接到LaiYu液体处理设备...") - # 这里应该实现实际的设备连接逻辑 - # 目前返回模拟连接成功 - self.is_connected = True - self.logger.info("成功连接到LaiYu液体处理设备") - return True - except Exception as e: - self.logger.error(f"连接LaiYu液体处理设备失败: {e}") - self.is_connected = False - return False - - def disconnect(self) -> bool: - """ - 断开与LaiYu液体处理设备的连接 - - Returns: - bool: 断开连接是否成功 - """ - try: - self.logger.info("正在断开与LaiYu液体处理设备的连接...") - # 这里应该实现实际的设备断开连接逻辑 - self.is_connected = False - self.logger.info("成功断开与LaiYu液体处理设备的连接") - return True - except Exception as e: - self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}") - return False - - def is_device_connected(self) -> bool: - """ - 检查设备是否已连接 - - Returns: - bool: 设备是否已连接 - """ - return self.is_connected - - def get_device_info(self) -> Dict[str, Any]: - """ - 获取设备信息 - - Returns: - Dict[str, Any]: 设备信息字典 - """ - return self.device_info.copy() - - def home_device(self) -> bool: - """ - 设备归零操作 - - Returns: - bool: 归零是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行归零操作") - return False - - try: - self.logger.info("正在执行设备归零操作...") - # 这里应该实现实际的设备归零逻辑 - self.logger.info("设备归零操作完成") - return True - except Exception as e: - self.logger.error(f"设备归零操作失败: {e}") - return False - - def aspirate(self, volume: float, location: Dict[str, Any]) -> bool: - """ - 吸液操作 - - Args: - volume: 吸液体积 (微升) - location: 吸液位置信息 - - Returns: - bool: 吸液是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行吸液操作") - return False - - try: - self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}") - # 这里应该实现实际的吸液逻辑 - self.logger.info("吸液操作完成") - return True - except Exception as e: - self.logger.error(f"吸液操作失败: {e}") - return False - - def dispense(self, volume: float, location: Dict[str, Any]) -> bool: - """ - 排液操作 - - Args: - volume: 排液体积 (微升) - location: 排液位置信息 - - Returns: - bool: 排液是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行排液操作") - return False - - try: - self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}") - # 这里应该实现实际的排液逻辑 - self.logger.info("排液操作完成") - return True - except Exception as e: - self.logger.error(f"排液操作失败: {e}") - return False - - def pick_up_tip(self, location: Dict[str, Any]) -> bool: - """ - 取枪头操作 - - Args: - location: 枪头位置信息 - - Returns: - bool: 取枪头是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行取枪头操作") - return False - - try: - self.logger.info(f"正在执行取枪头操作: 位置={location}") - # 这里应该实现实际的取枪头逻辑 - self.logger.info("取枪头操作完成") - return True - except Exception as e: - self.logger.error(f"取枪头操作失败: {e}") - return False - - def drop_tip(self, location: Dict[str, Any]) -> bool: - """ - 丢弃枪头操作 - - Args: - location: 丢弃位置信息 - - Returns: - bool: 丢弃枪头是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行丢弃枪头操作") - return False - - try: - self.logger.info(f"正在执行丢弃枪头操作: 位置={location}") - # 这里应该实现实际的丢弃枪头逻辑 - self.logger.info("丢弃枪头操作完成") - return True - except Exception as e: - self.logger.error(f"丢弃枪头操作失败: {e}") - return False - - def move_to(self, location: Dict[str, Any]) -> bool: - """ - 移动到指定位置 - - Args: - location: 目标位置信息 - - Returns: - bool: 移动是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行移动操作") - return False - - try: - self.logger.info(f"正在移动到位置: {location}") - # 这里应该实现实际的移动逻辑 - self.logger.info("移动操作完成") - return True - except Exception as e: - self.logger.error(f"移动操作失败: {e}") - return False - - def get_status(self) -> Dict[str, Any]: - """ - 获取设备状态 - - Returns: - Dict[str, Any]: 设备状态信息 - """ - return { - "connected": self.is_connected, - "device_info": self.device_info, - "status": "ready" if self.is_connected else "disconnected" - } - - # PyLabRobot 抽象方法实现 - def stop(self): - """停止所有操作""" - self.logger.info("停止所有操作") - pass - - @property - def num_channels(self) -> int: - """返回通道数量""" - return 1 # 单通道移液器 - - def can_pick_up_tip(self, tip_rack, tip_position) -> bool: - """检查是否可以拾取吸头""" - return True # 简化实现,总是返回True - - def pick_up_tips(self, tip_rack, tip_positions): - """拾取多个吸头""" - self.logger.info(f"拾取吸头: {tip_positions}") - pass - - def drop_tips(self, tip_rack, tip_positions): - """丢弃多个吸头""" - self.logger.info(f"丢弃吸头: {tip_positions}") - pass - - def pick_up_tips96(self, tip_rack): - """拾取96个吸头""" - self.logger.info("拾取96个吸头") - pass - - def drop_tips96(self, tip_rack): - """丢弃96个吸头""" - self.logger.info("丢弃96个吸头") - pass - - def aspirate96(self, volume, plate, well_positions): - """96通道吸液""" - self.logger.info(f"96通道吸液: 体积={volume}") - pass - - def dispense96(self, volume, plate, well_positions): - """96通道排液""" - self.logger.info(f"96通道排液: 体积={volume}") - pass - - def pick_up_resource(self, resource, location): - """拾取资源""" - self.logger.info(f"拾取资源: {resource}") - pass - - def drop_resource(self, resource, location): - """放置资源""" - self.logger.info(f"放置资源: {resource}") - pass - - def move_picked_up_resource(self, resource, location): - """移动已拾取的资源""" - self.logger.info(f"移动资源: {resource} 到 {location}") - pass - - -def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend: - """ - 创建LaiYu液体处理设备后端实例 - - Args: - name: 后端名称 - - Returns: - LaiYuLiquidBackend: 后端实例 - """ - return LaiYuLiquidBackend(name) \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py index 9e824e1bd..24c075dd5 100644 --- a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py +++ b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py @@ -1,385 +1,307 @@ - -import json +"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式 + +硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致: + 1. XYZController(auto_connect=True) — 先开串口 + 2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁 + 3. home_all_axes() + pipette.initialize() +""" + +import logging from typing import List, Optional, Union -from pylabrobot.liquid_handling.backends.backend import ( - LiquidHandlerBackend, -) +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, ) from pylabrobot.resources import Resource, Tip -import rclpy -from rclpy.node import Node -from sensor_msgs.msg import JointState -import time -from rclpy.action import ActionClient -from unilabos_msgs.action import SendCmd -import re +from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController +from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import ( + PipetteController, + TipStatus, +) -from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher -from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus +logger = logging.getLogger(__name__) class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): - """Chatter box backend for device-free testing. Prints out all operations.""" - - _pip_length = 5 - _vol_length = 8 - _resource_length = 20 - _offset_length = 16 - _flow_rate_length = 10 - _blowout_length = 10 - _lld_z_length = 10 - _kwargs_length = 15 - _tip_type_length = 12 - _max_volume_length = 16 - _fitting_depth_length = 20 - _tip_length_length = 16 - # _pickup_method_length = 20 - _filter_length = 10 - - def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"): - """Initialize a chatter box backend.""" - super().__init__() - self._num_channels = num_channels - self.tip_length = tip_length - self.total_height = total_height -# rclpy.init() - if not rclpy.ok(): - rclpy.init() - self.joint_state_publisher = None - self.hardware_interface = PipetteController(port=port) - - async def setup(self): - # self.joint_state_publisher = JointStatePublisher() - # self.hardware_interface.xyz_controller.connect_device() - # self.hardware_interface.xyz_controller.home_all_axes() - await super().setup() - self.hardware_interface.connect() - self.hardware_interface.initialize() - - print("Setting up the liquid handler.") - - async def stop(self): - print("Stopping the liquid handler.") - - def serialize(self) -> dict: - return {**super().serialize(), "num_channels": self.num_channels} - - def pipette_aspirate(self, volume: float, flow_rate: float): - - self.hardware_interface.pipette.set_max_speed(flow_rate) - res = self.hardware_interface.pipette.aspirate(volume=volume) - - if not res: - self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}") - return - - self.hardware_interface.current_volume += volume - - def pipette_dispense(self, volume: float, flow_rate: float): - - self.hardware_interface.pipette.set_max_speed(flow_rate) - res = self.hardware_interface.pipette.dispense(volume=volume) - if not res: - self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}") - return - self.hardware_interface.current_volume -= volume - - @property - def num_channels(self) -> int: - return self._num_channels - - async def assigned_resource_callback(self, resource: Resource): - print(f"Resource {resource.name} was assigned to the liquid handler.") - - async def unassigned_resource_callback(self, name: str): - print(f"Resource {name} was unassigned from the liquid handler.") - - async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): - print("Picking up tips:") - # print(ops.tip) - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " - f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(header) - - for op, channel in zip(ops, use_channels): - offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" - row = ( - f" p{channel}: " - f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(row) - # print(op.resource.get_absolute_location()) - - self.tip_length = ops[0].tip.total_tip_length - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print("moving") - self.hardware_interface._update_tip_status() - if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: - print("已有枪头,无需重复拾取") - return - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100) - # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels) - # goback() - - - - - async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): - print("Dropping tips:") - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " - f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(header) - - for op, channel in zip(ops, use_channels): - offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" - row = ( - f" p{channel}: " - f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}" - ) - # print(row) - - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20 - # print(x, y, z) - # print("moving") - self.hardware_interface._update_tip_status() - if self.hardware_interface.tip_status == TipStatus.NO_TIP: - print("无枪头,无需丢弃") - return - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.hardware_interface.eject_tip - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - **backend_kwargs, - ): - print("Aspirating:") - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{'liquids':<20}" # TODO: add liquids - ) - for key in backend_kwargs: - header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:] - # print(header) - - for o, p in zip(ops, use_channels): - offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" - row = ( - f" p{p}: " - f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{o.liquids if o.liquids is not None else 'none'}" - ) - for key, value in backend_kwargs.items(): - if isinstance(value, list) and all(isinstance(v, bool) for v in value): - value = "".join("T" if v else "F" for v in value) - if isinstance(value, list): - value = "".join(map(str, value)) - row += f" {value:<15}" - # print(row) - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print(x, y, z) - # print("moving") - - # 判断枪头是否存在 - self.hardware_interface._update_tip_status() - if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: - print("无枪头,无法吸液") - return - # 判断吸液量是否超过枪头容量 - flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500 - blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0 - if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume: - self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}") - return - - # 移动到吸液位置 - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) - - - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) - if blow_out_air_volume >0: - self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate) - - - - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - **backend_kwargs, - ): - # print("Dispensing:") - header = ( - f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " - f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{'liquids':<20}" # TODO: add liquids - ) - for key in backend_kwargs: - header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:] - # print(header) - - for o, p in zip(ops, use_channels): - offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" - row = ( - f" p{p}: " - f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} " - f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " - f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " - f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} " - f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " - # f"{o.liquids if o.liquids is not None else 'none'}" - ) - for key, value in backend_kwargs.items(): - if isinstance(value, list) and all(isinstance(v, bool) for v in value): - value = "".join("T" if v else "F" for v in value) - if isinstance(value, list): - value = "".join(map(str, value)) - row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}" - # print(row) - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - offset_xyz = ops[0].offset - x = coordinate.x + offset_xyz.x - y = coordinate.y + offset_xyz.y - z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print(x, y, z) - # print("moving") - - # 判断枪头是否存在 - self.hardware_interface._update_tip_status() - if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: - print("无枪头,无法排液") - return - # 判断排液量是否超过枪头容量 - flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500 - blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0 - if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0: - self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0") - return - - - # 移动到排液位置 - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) - self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) - - - self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) - if blow_out_air_volume > 0: - self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate) - # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels) - - async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): - print(f"Picking up tips from {pickup.resource.name}.") - - async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): - print(f"Dropping tips to {drop.resource.name}.") - - async def aspirate96( - self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - ): - if isinstance(aspiration, MultiHeadAspirationPlate): - resource = aspiration.wells[0].parent - else: - resource = aspiration.container - print(f"Aspirating {aspiration.volume} from {resource}.") - - async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): - if isinstance(dispense, MultiHeadDispensePlate): - resource = dispense.wells[0].parent - else: - resource = dispense.container - print(f"Dispensing {dispense.volume} to {resource}.") - - async def pick_up_resource(self, pickup: ResourcePickup): - print(f"Picking up resource: {pickup}") - - async def move_picked_up_resource(self, move: ResourceMove): - print(f"Moving picked up resource: {move}") - - async def drop_resource(self, drop: ResourceDrop): - print(f"Dropping resource: {drop}") - - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - return True - + """LaiYu 硬件后端 — PLR Backend 接口实现""" + + def __init__( + self, + num_channels: int = 1, + tip_length: float = 0, + total_height: float = 310, + port: str = "/dev/ttyUSB0", + baudrate: int = 115200, + pipette_address: int = 4, + ): + super().__init__() + self._num_channels = num_channels + self.tip_length = tip_length + self.total_height = total_height + + # 保存配置,延迟到 setup() 再创建硬件对象 + self._port = port + self._baudrate = baudrate + self._pipette_address = pipette_address + + self._xyz: Optional[XYZController] = None + self._pipette_ctrl: Optional[PipetteController] = None + self._ros_node = None + + # ------------------------------------------------------------------ lifecycle + + def post_init(self, ros_node): + """接收 ROS 节点引用(由 Handler.post_init 调用)""" + self._ros_node = ros_node + + async def setup(self): + """按路径 B 顺序初始化硬件""" + await super().setup() + + # 1. XYZ 先开串口 + self._xyz = XYZController( + port=self._port, + baudrate=self._baudrate, + auto_connect=True, + ) + if not self._xyz.is_connected: + raise RuntimeError("XYZ 控制器连接失败") + + # 2. PipetteController 共享 XYZ 串口 + self._pipette_ctrl = PipetteController( + port=self._port, + address=self._pipette_address, + ) + self._pipette_ctrl.connect_shared( + serial_conn=self._xyz.serial_conn, + serial_lock=self._xyz.serial_lock, + xyz_controller=self._xyz, + ) + + # 3. 回零 + 移液器初始化 + self._xyz.home_all_axes() + self._pipette_ctrl.initialize() + + logger.info("LaiYu 后端硬件初始化完成") + + async def stop(self): + """正确断开硬件""" + try: + if self._pipette_ctrl: + self._pipette_ctrl.disconnect_shared() + if self._xyz: + self._xyz.disconnect() + logger.info("LaiYu 后端硬件已断开") + except Exception as e: + logger.error(f"停止后端失败: {e}") + + # ------------------------------------------------------------------ helpers + + def _plr_to_machine_coords(self, resource, offset): + """PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)""" + coordinate = resource.get_absolute_location(x="c", y="c") + x = coordinate.x + offset.x + y = coordinate.y + offset.y + z_plr = coordinate.z + offset.z + return x, -y, self.total_height - (z_plr + self.tip_length) + + def _pipette_aspirate(self, volume: float, flow_rate: float): + self._pipette_ctrl.pipette.set_max_speed(flow_rate) + res = self._pipette_ctrl.pipette.aspirate(volume=volume) + if not res: + logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}") + return + self._pipette_ctrl.current_volume += volume + + def _pipette_dispense(self, volume: float, flow_rate: float): + self._pipette_ctrl.pipette.set_max_speed(flow_rate) + res = self._pipette_ctrl.pipette.dispense(volume=volume) + if not res: + logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}") + return + self._pipette_ctrl.current_volume -= volume + + # ------------------------------------------------------------------ properties + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} + + @property + def num_channels(self) -> int: + return self._num_channels + + # ------------------------------------------------------------------ resource callbacks + + async def assigned_resource_callback(self, resource: Resource): + logger.info(f"Resource {resource.name} was assigned to the liquid handler.") + + async def unassigned_resource_callback(self, name: str): + logger.info(f"Resource {name} was unassigned from the liquid handler.") + + # ------------------------------------------------------------------ pick_up_tips + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): + tip = ops[0].tip + self.tip_length = tip.total_tip_length + x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED: + logger.warning("已有枪头,无需重复拾取") + return + + try: + # 1. 移到枪头正上方 + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200) + # 2. 下压到套枪头深度(fitting_depth 是枪头套入长度) + z_pickup = z_top + tip.fitting_depth + self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100) + # 3. 退回安全高度 + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height, speed=100 + ) + except Exception as e: + logger.error(f"pick_up_tips 移动失败: {e}") + raise + + # ------------------------------------------------------------------ drop_tips + + async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): + x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + z -= 20 # 额外下移补偿 + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status == TipStatus.NO_TIP: + logger.warning("无枪头,无需丢弃") + return + + try: + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200) + self._pipette_ctrl.eject_tip() # 修复: 原来缺少 () + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height + ) + except Exception as e: + logger.error(f"drop_tips 失败: {e}") + raise + + # ------------------------------------------------------------------ aspirate + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + **backend_kwargs, + ): + x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED: + raise RuntimeError("无枪头,无法吸液") + + flow_rate = backend_kwargs.get("flow_rate", 500) + blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0) + + if ( + self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume + > self._pipette_ctrl.max_volume + ): + raise RuntimeError( + f"吸液量超过枪头容量: " + f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}" + ) + + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200) + self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) + + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height + ) + if blow_out_air_volume > 0: + self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate) + + # ------------------------------------------------------------------ dispense + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + **backend_kwargs, + ): + x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset) + + self._pipette_ctrl._update_tip_status() + if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED: + raise RuntimeError("无枪头,无法排液") + + flow_rate = backend_kwargs.get("flow_rate", 500) + blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0) + + if ( + self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0 + ): + raise RuntimeError( + f"排液量超过当前体积: " + f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0" + ) + + self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200) + self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) + + self._xyz.move_to_work_coord_safe( + z=self._xyz.machine_config.safe_z_height + ) + if blow_out_air_volume > 0: + self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate) + + # ------------------------------------------------------------------ 96-channel stubs + + async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): + logger.info(f"Picking up tips from {pickup.resource.name}.") + + async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): + logger.info(f"Dropping tips to {drop.resource.name}.") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + if isinstance(aspiration, MultiHeadAspirationPlate): + resource = aspiration.wells[0].parent + else: + resource = aspiration.container + logger.info(f"Aspirating {aspiration.volume} from {resource}.") + + async def dispense96( + self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] + ): + if isinstance(dispense, MultiHeadDispensePlate): + resource = dispense.wells[0].parent + else: + resource = dispense.container + logger.info(f"Dispensing {dispense.volume} to {resource}.") + + async def pick_up_resource(self, pickup: ResourcePickup): + logger.info(f"Picking up resource: {pickup}") + + async def move_picked_up_resource(self, move: ResourceMove): + logger.info(f"Moving picked up resource: {move}") + + async def drop_resource(self, drop: ResourceDrop): + logger.info(f"Dropping resource: {drop}") + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return True diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py index 17a47df1c..da08d3d7a 100644 --- a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py +++ b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py @@ -5,20 +5,15 @@ 封装SOPA移液器的高级控制功能 """ -# 添加项目根目录到Python路径以解决模块导入问题 import sys import os -from tkinter import N -from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException +_current_file = os.path.abspath(__file__) +_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file))))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) -# 无论如何都添加项目根目录到路径 -current_file = os.path.abspath(__file__) -# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py -# 向上5级到 .../Uni-Lab-OS -project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))) -# 强制添加项目根目录到sys.path的开头 -sys.path.insert(0, project_root) +from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException import time import logging @@ -153,7 +148,7 @@ def connect(self) -> bool: logger.error("移液器连接失败") return False logger.info("移液器连接成功") - + # 连接XYZ步进电机控制器(如果提供了端口) if self.xyz_port != self.pipette_port: try: @@ -172,24 +167,62 @@ def connect(self) -> bool: try: self.xyz_controller = XYZController(self.xyz_port, auto_connect=False) self.xyz_controller.serial_conn = self.pipette.serial_port + self.xyz_controller.serial_lock = self.pipette.lock self.xyz_controller.is_connected = True + logger.info("XYZ控制器与移液器共享串口和互斥锁") except Exception as e: - logger.info("未配置XYZ步进电机端口,跳过运动控制器连接") - + logger.warning(f"共享端口 XYZ 控制器创建失败: {e}") + self.xyz_controller = None + self.xyz_connected = False + return True except Exception as e: logger.error(f"设备连接失败: {e}") return False + def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool: + """使用已连接的串口和XYZ控制器(路径 B 模式:XYZ 先开串口,移液器共享) + + Args: + serial_conn: 已打开的串口连接(来自 XYZController) + serial_lock: 串口互斥锁(来自 XYZController) + xyz_controller: 已连接的 XYZController 实例 + """ + try: + self.pipette.serial_port = serial_conn + self.pipette.lock = serial_lock + self.pipette.is_connected = True + + self.xyz_controller = xyz_controller + self.xyz_connected = True + + logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口") + return True + except Exception as e: + logger.error(f"connect_shared 失败: {e}") + return False + + def disconnect_shared(self) -> None: + """释放共享串口引用(与 connect_shared 对称)。 + + 注意:不关闭串口本身,串口由 XYZController 负责关闭。 + """ + try: + self.pipette.serial_port = None + self.pipette.lock = None + self.pipette.is_connected = False + self.xyz_controller = None + self.xyz_connected = False + logger.info("移液控制器已释放共享串口引用") + except Exception as e: + logger.error(f"disconnect_shared 失败: {e}") + def initialize(self) -> bool: """初始化移液器""" try: if self.pipette.initialize(): logger.info("移液器初始化成功") - # 检查枪头状态 self._update_tip_status() - self.xyz_controller.home_all_axes() - self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0) return True return False except Exception as e: @@ -198,56 +231,58 @@ def initialize(self) -> bool: def disconnect(self): """断开连接""" - # 断开移液器连接 + if self.xyz_controller and self.xyz_connected: + if self.xyz_port != self.pipette_port: + try: + self.xyz_controller.disconnect() + logger.info("XYZ 步进电机已断开") + except Exception as e: + logger.error(f"断开 XYZ 步进电机失败: {e}") + else: + self.xyz_controller.serial_conn = None + self.xyz_connected = False + self.xyz_controller = None + self.pipette.disconnect() logger.info("移液器已断开") - - # 断开 XYZ 步进电机连接 - if self.xyz_controller and self.xyz_connected: - try: - self.xyz_controller.disconnect() - self.xyz_connected = False - logger.info("XYZ 步进电机已断开") - except Exception as e: - logger.error(f"断开 XYZ 步进电机失败: {e}") def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool: """ 检查 XYZ 轴移动的安全性 - + Args: axis: 电机轴 target_position: 目标位置(步数) - + Returns: 是否安全 """ try: # 获取当前电机状态 motor_position = self.xyz_controller.get_motor_status(axis) - + # 检查电机状态是否正常 (不是碰撞停止或限位停止) - if motor_position.status in [MotorStatus.COLLISION_STOP, - MotorStatus.FORWARD_LIMIT_STOP, + if motor_position.status in [MotorStatus.COLLISION_STOP, + MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP]: logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}") return False - + # 检查位置限制 (扩大安全范围以适应实际硬件) # 步进电机的位置范围通常很大,这里设置更合理的范围 if target_position < -500000 or target_position > 500000: logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}") return False - + # 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm) current_position = motor_position.steps move_distance = abs(target_position - current_position) if move_distance > 20000: logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步") return False - + return True - + except Exception as e: logger.error(f"安全检查失败: {e}") return False @@ -255,48 +290,48 @@ def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool: def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool: """ Z轴相对移动 - + Args: distance_mm: 移动距离(mm),正值向下,负值向上 speed: 移动速度(rpm) acceleration: 加速度(rpm/s) - + Returns: 移动是否成功 """ if not self.xyz_controller or not self.xyz_connected: logger.error("XYZ 步进电机未连接,无法执行移动") return False - + try: # 参数验证 if abs(distance_mm) > 15.0: logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm") return False - + if speed < 100 or speed > 5000: logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000") return False - + # 获取当前 Z 轴位置 current_status = self.xyz_controller.get_motor_status(MotorAxis.Z) current_z_position = current_status.steps - + # 计算移动距离对应的步数 (1mm = 1638.4步) mm_to_steps = 1638.4 move_distance_steps = int(distance_mm * mm_to_steps) - + # 计算目标位置 target_z_position = current_z_position + move_distance_steps - + # 安全检查 if not self._check_xyz_safety(MotorAxis.Z, target_z_position): logger.error("Z轴移动安全检查失败") return False - + logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)") logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步") - + # 执行移动 success = self.xyz_controller.move_to_position( axis=MotorAxis.Z, @@ -305,28 +340,28 @@ def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: i acceleration=acceleration, precision=50 ) - + if not success: logger.error("Z轴移动命令发送失败") return False - + # 等待移动完成 if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0): logger.error("Z轴移动超时") return False - + # 验证移动结果 final_status = self.xyz_controller.get_motor_status(MotorAxis.Z) final_position = final_status.steps position_error = abs(final_position - target_z_position) - + logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步") - + if position_error > 100: logger.warning(f"Z轴位置误差较大: {position_error}步") - + return True - + except ModbusException as e: logger.error(f"Modbus通信错误: {e}") return False @@ -337,21 +372,20 @@ def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: i def emergency_stop(self) -> bool: """ 紧急停止所有运动 - + Returns: 停止是否成功 """ success = True - - # 停止移液器操作 + try: - if self.pipette and self.connected: - # 这里可以添加移液器的紧急停止逻辑 + if self.pipette and self.pipette.is_connected: + self.pipette.emergency_stop() logger.info("移液器紧急停止") except Exception as e: logger.error(f"移液器紧急停止失败: {e}") success = False - + # 停止 XYZ 轴运动 try: if self.xyz_controller and self.xyz_connected: @@ -360,7 +394,7 @@ def emergency_stop(self) -> bool: except Exception as e: logger.error(f"XYZ 轴紧急停止失败: {e}") success = False - + return success def pickup_tip(self) -> bool: @@ -376,7 +410,7 @@ def pickup_tip(self) -> bool: return True logger.info("开始装载枪头 - Z轴向下移动10mm") - + # 使用相对移动方法,向下移动10mm if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500): # 更新枪头状态 @@ -688,31 +722,31 @@ def reset_statistics(self): if __name__ == "__main__": # 配置日志 import logging - + # 设置日志级别 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) - + def interactive_test(): """交互式测试模式 - 适用于已连接的设备""" print("\n" + "=" * 60) print("🧪 移液器交互式测试模式") print("=" * 60) - + # 获取用户输入的连接参数 print("\n📡 设备连接配置:") port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340" address_input = input("请输入移液器设备地址 (默认: 4): ").strip() address = int(address_input) if address_input else 4 - + # 询问是否连接 XYZ 步进电机控制器 xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower() xyz_port = None if xyz_enable not in ['n', 'no']: xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340" - + try: # 创建移液控制器实例 if xyz_port: @@ -721,21 +755,21 @@ def interactive_test(): else: print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...") pipette = PipetteController(port=port, address=address) - + # 连接设备 print("\n📞 连接移液器设备...") if not pipette.connect(): print("❌ 设备连接失败,请检查连接") return print("✅ 设备连接成功") - + # 初始化设备 print("\n🚀 初始化设备...") if not pipette.initialize(): print("❌ 设备初始化失败") return print("✅ 设备初始化成功") - + # 交互式菜单 while True: print("\n" + "=" * 50) @@ -755,9 +789,9 @@ def interactive_test(): print("99. 🚨 紧急停止") print("0. 🚪 退出程序") print("=" * 50) - + choice = input("\n请选择操作 (0-12, 99): ").strip() - + if choice == "0": print("\n👋 退出程序...") break @@ -773,7 +807,7 @@ def interactive_test(): # print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}") print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}") print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}") - + elif choice == "2": # 装载枪头 print("\n🔧 装载枪头...") @@ -781,14 +815,14 @@ def interactive_test(): print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)") else: print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载") - + if pipette.pickup_tip(): print("✅ 枪头装载成功") if pipette.xyz_connected: print("📍 Z 轴已移动到装载位置") else: print("❌ 枪头装载失败") - + elif choice == "3": # 弹出枪头 print("\n🗑️ 弹出枪头...") @@ -796,7 +830,7 @@ def interactive_test(): print("✅ 枪头弹出成功") else: print("❌ 枪头弹出失败") - + elif choice == "4": # 吸液操作 try: @@ -810,7 +844,7 @@ def interactive_test(): print("❌ 吸液失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "5": # 排液操作 try: @@ -824,7 +858,7 @@ def interactive_test(): print("❌ 排液失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "6": # 混合操作 try: @@ -838,7 +872,7 @@ def interactive_test(): print("❌ 混合失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "7": # 液体转移 try: @@ -846,7 +880,7 @@ def interactive_test(): source = input("源孔位 (可选, 如A1): ").strip() or None dest = input("目标孔位 (可选, 如B1): ").strip() or None new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n' - + print(f"\n🔄 执行液体转移 ({volume}ul)...") if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip): print("✅ 液体转移完成") @@ -854,7 +888,7 @@ def interactive_test(): print("❌ 液体转移失败") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "8": # 设置液体类型 print("\n🧪 可用液体类型:") @@ -864,16 +898,16 @@ def interactive_test(): "3": (LiquidClass.VISCOUS, "粘稠液体"), "4": (LiquidClass.VOLATILE, "挥发性液体") } - + for key, (liquid_class, description) in liquid_options.items(): print(f" {key}. {description}") - + liquid_choice = input("请选择液体类型 (1-4): ").strip() if liquid_choice in liquid_options: liquid_class, description = liquid_options[liquid_choice] pipette.set_liquid_class(liquid_class) print(f"✅ 液体类型设置为: {description}") - + # 显示参数 params = pipette.liquid_params print(f"📋 参数设置:") @@ -883,7 +917,7 @@ def interactive_test(): print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}") else: print("❌ 无效选择") - + elif choice == "9": # 自定义参数 try: @@ -892,19 +926,19 @@ def interactive_test(): dispense_speed = input("排液速度 (默认800): ").strip() air_gap = input("空气间隙 (ul, 默认10.0): ").strip() pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y' - + custom_params = LiquidParameters( aspirate_speed=int(aspirate_speed) if aspirate_speed else 500, dispense_speed=int(dispense_speed) if dispense_speed else 800, air_gap=float(air_gap) if air_gap else 10.0, pre_wet=pre_wet ) - + pipette.set_custom_parameters(custom_params) print("✅ 自定义参数设置完成") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "10": # 校准体积 try: @@ -914,12 +948,12 @@ def interactive_test(): print(f"✅ 校准完成,校准系数: {actual/expected:.3f}") except ValueError: print("❌ 请输入有效的数字") - + elif choice == "11": # 重置统计 pipette.reset_statistics() print("✅ 统计信息已重置") - + elif choice == "12": # 液体类型测试 print("\n🧪 液体类型参数对比:") @@ -929,7 +963,7 @@ def interactive_test(): (LiquidClass.VISCOUS, "粘稠液体"), (LiquidClass.VOLATILE, "挥发性液体") ] - + for liquid_class, description in liquid_tests: params = pipette.LIQUID_PARAMS[liquid_class] print(f"\n📋 {description} ({liquid_class.value}):") @@ -938,7 +972,7 @@ def interactive_test(): print(f" 💨 空气间隙: {params.air_gap}ul") print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}") print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s") - + elif choice == "99": # 紧急停止 print("\n🚨 执行紧急停止...") @@ -949,19 +983,19 @@ def interactive_test(): else: print("❌ 紧急停止执行失败") print("⚠️ 请手动检查设备状态并采取必要措施") - + # 紧急停止后询问是否继续 continue_choice = input("\n是否继续操作?(y/n): ").strip().lower() if continue_choice != 'y': print("🚪 退出程序") break - + else: print("❌ 无效选择,请重新输入") - + # 等待用户确认继续 input("\n按回车键继续...") - + except KeyboardInterrupt: print("\n\n⚠️ 用户中断操作") except Exception as e: @@ -974,19 +1008,19 @@ def interactive_test(): print("✅ 连接已断开") except: print("⚠️ 断开连接时出现问题") - + def demo_test(): """演示测试模式 - 完整功能演示""" print("\n" + "=" * 60) print("🎬 移液控制器演示测试") print("=" * 60) - + try: # 创建移液控制器实例 print("1. 🔧 创建移液控制器实例...") pipette = PipetteController(port="/dev/ttyUSB0", address=4) print("✅ 移液控制器实例创建成功") - + # 连接设备 print("\n2. 📞 连接移液器设备...") if pipette.connect(): @@ -994,7 +1028,7 @@ def demo_test(): else: print("❌ 设备连接失败") return False - + # 初始化设备 print("\n3. 🚀 初始化设备...") if pipette.initialize(): @@ -1002,19 +1036,19 @@ def demo_test(): else: print("❌ 设备初始化失败") return False - + # 装载枪头 print("\n4. 🔧 装载枪头...") if pipette.pickup_tip(): print("✅ 枪头装载成功") else: print("❌ 枪头装载失败") - + # 设置液体类型 print("\n5. 🧪 设置液体类型为血清...") pipette.set_liquid_class(LiquidClass.SERUM) print("✅ 液体类型设置完成") - + # 吸液操作 print("\n6. 💧 执行吸液操作...") volume_to_aspirate = 100.0 @@ -1023,7 +1057,7 @@ def demo_test(): print(f"📊 当前体积: {pipette.current_volume}ul") else: print("❌ 吸液失败") - + # 排液操作 print("\n7. 💦 执行排液操作...") volume_to_dispense = 50.0 @@ -1032,14 +1066,14 @@ def demo_test(): print(f"📊 剩余体积: {pipette.current_volume}ul") else: print("❌ 排液失败") - + # 混合操作 print("\n8. 🌀 执行混合操作...") if pipette.mix(cycles=3, volume=30.0): print("✅ 混合完成") else: print("❌ 混合失败") - + # 获取状态信息 print("\n9. 📊 获取设备状态...") status = pipette.get_status() @@ -1052,30 +1086,30 @@ def demo_test(): # print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}") print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}") print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}") - + # 弹出枪头 print("\n10. 🗑️ 弹出枪头...") if pipette.eject_tip(): print("✅ 枪头弹出成功") else: print("❌ 枪头弹出失败") - + print("\n" + "=" * 60) print("✅ 移液控制器演示测试完成") print("=" * 60) - + return True - + except Exception as e: print(f"\n❌ 测试过程中发生异常: {e}") return False - + finally: # 断开连接 print("\n📞 断开连接...") pipette.disconnect() print("✅ 连接已断开") - + # 主程序入口 print("🧪 移液器控制器测试程序") print("=" * 40) @@ -1083,9 +1117,9 @@ def demo_test(): print("2. 🎬 演示测试") print("0. 🚪 退出") print("=" * 40) - + mode = input("请选择测试模式 (0-2): ").strip() - + if mode == "1": interactive_test() elif mode == "2": @@ -1094,7 +1128,7 @@ def demo_test(): print("👋 再见!") else: print("❌ 无效选择") - + print("\n🎉 程序结束!") print("\n💡 使用说明:") print("1. 确保移液器硬件已正确连接") diff --git a/unilabos/devices/liquid_handling/laiyu/laiyu.py b/unilabos/devices/liquid_handling/laiyu/laiyu.py index 0d7074a76..8591c8885 100644 --- a/unilabos/devices/liquid_handling/laiyu/laiyu.py +++ b/unilabos/devices/liquid_handling/laiyu/laiyu.py @@ -13,7 +13,7 @@ SingleChannelDispense, PickupTipRack, DropTipRack, - MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend, + MultiHeadAspirationPlate, ) from pylabrobot.liquid_handling.standard import ( MultiHeadAspirationContainer, @@ -41,12 +41,6 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float): super().__init__(name, size_x, size_y, size_z) self.name = name -class TransformXYZBackend(LiquidHandlerBackend): - def __init__(self, name: str, host: str, port: int, timeout: float): - super().__init__() - self.host = host - self.port = port - self.timeout = timeout class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend): def __init__(self, name: str, channel_num: int): @@ -86,7 +80,9 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: class TransformXYZHandler(LiquidHandlerAbstract): support_touch_tip = False - def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs): + def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, + serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4, + total_height: float = 310, **backend_kwargs): # Handle case where deck is passed as a dict (from serialization) if isinstance(deck, dict): # Try to create a TransformXYZDeck from the dict @@ -102,11 +98,22 @@ def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeou deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100) if simulator: - self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num) + self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num) else: - self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout) + self._unilabos_backend = UniLiquidHandlerLaiyuBackend( + num_channels=channel_num, + total_height=total_height, + port=serial_port, + baudrate=baudrate, + pipette_address=pipette_address, + ) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) + def post_init(self, ros_node): + super().post_init(ros_node) + if hasattr(self._unilabos_backend, 'post_init'): + self._unilabos_backend.post_init(ros_node) + async def add_liquid( self, asp_vols: Union[List[float], float], @@ -128,7 +135,25 @@ async def add_liquid( mix_liquid_height: Optional[float] = None, none_keys: List[str] = [], ): - pass + return await super().add_liquid( + asp_vols=asp_vols, + dis_vols=dis_vols, + reagent_sources=reagent_sources, + targets=targets, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + is_96_well=is_96_well, + delays=delays, + mix_time=mix_time, + mix_vol=mix_vol, + mix_rate=mix_rate, + mix_liquid_height=mix_liquid_height, + none_keys=none_keys, + ) async def aspirate( self, @@ -142,7 +167,17 @@ async def aspirate( spread: Literal["wide", "tight", "custom"] = "wide", **backend_kwargs, ): - pass + return await super().aspirate( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + **backend_kwargs, + ) async def dispense( self, @@ -156,7 +191,17 @@ async def dispense( spread: Literal["wide", "tight", "custom"] = "wide", **backend_kwargs, ): - pass + return await super().dispense( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + **backend_kwargs, + ) async def drop_tips( self, @@ -166,7 +211,13 @@ async def drop_tips( allow_nonzero_volume: bool = False, **backend_kwargs, ): - pass + return await super().drop_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + **backend_kwargs, + ) async def mix( self, @@ -178,7 +229,15 @@ async def mix( mix_rate: Optional[float] = None, none_keys: List[str] = [], ): - pass + return await super().mix( + targets=targets, + mix_time=mix_time, + mix_vol=mix_vol, + height_to_bottom=height_to_bottom, + offsets=offsets, + mix_rate=mix_rate, + none_keys=none_keys, + ) async def pick_up_tips( self, @@ -187,7 +246,12 @@ async def pick_up_tips( offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): - pass + return await super().pick_up_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + **backend_kwargs, + ) async def transfer_liquid( self, @@ -214,5 +278,26 @@ async def transfer_liquid( delays: Optional[List[int]] = None, none_keys: List[str] = [], ): - pass - \ No newline at end of file + return await super().transfer_liquid( + sources=sources, + targets=targets, + tip_racks=tip_racks, + use_channels=use_channels, + asp_vols=asp_vols, + dis_vols=dis_vols, + asp_flow_rates=asp_flow_rates, + dis_flow_rates=dis_flow_rates, + offsets=offsets, + touch_tip=touch_tip, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + is_96_well=is_96_well, + mix_stage=mix_stage, + mix_times=mix_times, + mix_vol=mix_vol, + mix_rate=mix_rate, + mix_liquid_height=mix_liquid_height, + delays=delays, + none_keys=none_keys, + ) diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index ec936175a..48ec082b0 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -57,6 +57,18 @@ class TransferLiquidReturn(TypedDict): targets: List[List[ResourceDict]] + +class SetLiquidReturn(TypedDict): + wells: list + volumes: list + + +class SetLiquidFromPlateReturn(TypedDict): + plate: list + wells: list + volumes: list + + class LiquidHandlerMiddleware(LiquidHandler): def __init__( self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs diff --git a/unilabos/devices/motor/ZDT_X42.py b/unilabos/devices/motor/ZDT_X42.py new file mode 100644 index 000000000..0d1566c36 --- /dev/null +++ b/unilabos/devices/motor/ZDT_X42.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +""" +ZDT X42 Closed-Loop Stepper Motor Driver +RS485 Serial Communication via USB-Serial Converter + +- Baudrate: 115200 +""" + +import serial +import time +import threading +import struct +import logging +from typing import Optional, Any + +try: + from unilabos.device_comms.universal_driver import UniversalDriver +except ImportError: + class UniversalDriver: + def __init__(self, *args, **kwargs): + self.logger = logging.getLogger(self.__class__.__name__) + def execute_command_from_outer(self, command: Any): pass + +from serial.rs485 import RS485Settings + + +class ZDTX42Driver(UniversalDriver): + """ + ZDT X42 闭环步进电机驱动器 + + 支持功能: + - 速度模式运行 + - 位置模式运行 (相对/绝对) + - 位置读取和清零 + - 使能/禁用控制 + + 通信协议: + - 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B] + - 响应长度根据功能码决定 + """ + + def __init__( + self, + port: str, + baudrate: int = 115200, + device_id: int = 1, + timeout: float = 0.5, + debug: bool = False + ): + """ + 初始化 ZDT X42 电机驱动 + + Args: + port: 串口设备路径 + baudrate: 波特率 (默认 115200) + device_id: 设备地址 (1-255) + timeout: 通信超时时间(秒) + debug: 是否启用调试输出 + """ + super().__init__() + self.id = device_id + self.debug = debug + self.lock = threading.RLock() + self.status = "idle" # 对应注册表中的 status (str) + self.position = 0 # 对应注册表中的 position (int) + + try: + self.ser = serial.Serial( + port=port, + baudrate=baudrate, + timeout=timeout, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE + ) + + # 启用 RS485 模式 + try: + self.ser.rs485_mode = RS485Settings( + rts_level_for_tx=True, + rts_level_for_rx=False + ) + except Exception: + pass # RS485 模式是可选的 + + self.logger.info( + f"ZDT X42 Motor connected: {port} " + f"(Baud: {baudrate}, ID: {device_id})" + ) + # 自动使能电机,确保初始状态可运动 + self.enable(True) + + # 启动背景轮询线程,确保 position 实时刷新 + self._stop_event = threading.Event() + self._polling_thread = threading.Thread( + target=self._update_loop, + name=f"ZDTPolling_{port}", + daemon=True + ) + self._polling_thread.start() + except Exception as e: + self.logger.error(f"Failed to open serial port {port}: {e}") + self.ser = None + + def _update_loop(self): + """背景循环读取电机位置""" + while not self._stop_event.is_set(): + try: + self.get_position() + except Exception as e: + if self.debug: + self.logger.error(f"Polling error: {e}") + time.sleep(1.0) # 每1秒刷新一次位置数据 + + def _send(self, func_code: int, payload: list) -> bytes: + """ + 发送指令并接收响应 + + Args: + func_code: 功能码 + payload: 数据负载 (list of bytes) + + Returns: + 响应数据 (bytes) + """ + if not self.ser: + self.logger.error("Serial port not available") + return b"" + + with self.lock: + # 清空输入缓冲区 + self.ser.reset_input_buffer() + + # 构建消息: [ID] [功能码] [数据...] [校验位=0x6B] + message = bytes([self.id, func_code] + payload + [0x6B]) + + # 发送 + self.ser.write(message) + + # 根据功能码决定响应长度 + # 查询类指令返回 10 字节,控制类指令返回 4 字节 + read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4 + response = self.ser.read(read_len) + + # 调试输出 + if self.debug: + sent_hex = message.hex().upper() + recv_hex = response.hex().upper() if response else 'TIMEOUT' + print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}") + + return response + + def enable(self, on: bool = True) -> bool: + """ + 使能/禁用电机 + + Args: + on: True=使能(锁轴), False=禁用(松轴) + + Returns: + 是否成功 + """ + state = 1 if on else 0 + resp = self._send(0xF3, [0xAB, state, 0]) + return len(resp) >= 4 + + def move_speed( + self, + speed_rpm: int, + direction: str = "CW", + acceleration: int = 10 + ) -> bool: + """ + 速度模式运行 + + Args: + speed_rpm: 转速 (RPM) + direction: 方向 ("CW"=顺时针, "CCW"=逆时针) + acceleration: 加速度 (0-255) + + Returns: + 是否成功 + """ + dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1 + speed_bytes = struct.pack('>H', int(speed_rpm)) + self.status = f"moving@{speed_rpm}rpm" + resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0]) + return len(resp) >= 4 + + def move_position( + self, + pulses: int, + speed_rpm: int, + direction: str = "CW", + acceleration: int = 10, + absolute: bool = False + ) -> bool: + """ + 位置模式运行 + + Args: + pulses: 脉冲数 + speed_rpm: 转速 (RPM) + direction: 方向 ("CW"=顺时针, "CCW"=逆时针) + acceleration: 加速度 (0-255) + absolute: True=绝对位置, False=相对位置 + + Returns: + 是否成功 + """ + dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1 + speed_bytes = struct.pack('>H', int(speed_rpm)) + self.status = f"moving_to_{pulses}" + pulse_bytes = struct.pack('>I', int(pulses)) + abs_flag = 1 if absolute else 0 + + payload = [ + dir_val, + speed_bytes[0], speed_bytes[1], + acceleration, + pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3], + abs_flag, + 0 + ] + + resp = self._send(0xFD, payload) + return len(resp) >= 4 + + def stop(self) -> bool: + """ + 停止电机 + + Returns: + 是否成功 + """ + self.status = "idle" + resp = self._send(0xFE, [0x98, 0]) + return len(resp) >= 4 + + def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool: + """ + 电机旋转 1/4 圈 (阻塞式) + 假设电机细分为 3200 脉冲/圈,1/4 圈 = 800 脉冲 + """ + pulses = 800 + success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False) + + if success: + # 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60)) + # 1/4 rev / (RPM/60) = 15.0 / RPM + estimated_time = 15.0 / max(1, speed_rpm) + time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲 + self.status = "idle" + + return success + + def wait_time(self, duration_s: float) -> bool: + """ + 等待指定时间 (秒) + """ + self.logger.info(f"Waiting for {duration_s} seconds...") + time.sleep(duration_s) + return True + + def set_zero(self) -> bool: + """ + 清零当前位置 + + Returns: + 是否成功 + """ + resp = self._send(0x0A, []) + return len(resp) >= 4 + + def get_position(self) -> Optional[int]: + """ + 读取当前位置 (脉冲数) + + Returns: + 当前位置脉冲数,失败返回 None + """ + resp = self._send(0x32, []) + + if len(resp) >= 8: + # 响应格式: [ID] [Func] [符号位] [数值4字节] [校验] + sign = resp[2] # 0=正, 1=负 + value = struct.unpack('>I', resp[3:7])[0] + self.position = -value if sign == 1 else value + + if self.debug: + print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}") + + return self.position + + self.logger.warning("Failed to read position") + return None + + def close(self): + """关闭串口连接并停止线程""" + if hasattr(self, '_stop_event'): + self._stop_event.set() + + if self.ser and self.ser.is_open: + self.ser.close() + self.logger.info("Serial port closed") + + +# ============================================================ +# 测试和调试代码 +# ============================================================ + +def test_motor(): + """基础功能测试""" + logging.basicConfig(level=logging.INFO) + + print("="*60) + print("ZDT X42 电机驱动测试") + print("="*60) + + driver = ZDTX42Driver( + port="/dev/tty.usbserial-3110", + baudrate=115200, + device_id=2, + debug=True + ) + + if not driver.ser: + print("❌ 串口打开失败") + return + + try: + # 测试 1: 读取位置 + print("\n[1] 读取当前位置") + pos = driver.get_position() + print(f"✓ 当前位置: {pos} 脉冲") + + # 测试 2: 使能 + print("\n[2] 使能电机") + driver.enable(True) + time.sleep(0.3) + print("✓ 电机已锁定") + + # 测试 3: 相对位置运动 + print("\n[3] 相对位置运动 (1000脉冲)") + driver.move_position(pulses=1000, speed_rpm=60, direction="CW") + time.sleep(2) + pos = driver.get_position() + print(f"✓ 新位置: {pos}") + + # 测试 4: 速度运动 + print("\n[4] 速度模式 (30RPM, 3秒)") + driver.move_speed(speed_rpm=30, direction="CW") + time.sleep(3) + driver.stop() + pos = driver.get_position() + print(f"✓ 停止后位置: {pos}") + + # 测试 5: 禁用 + print("\n[5] 禁用电机") + driver.enable(False) + print("✓ 电机已松开") + + print("\n" + "="*60) + print("✅ 测试完成") + print("="*60) + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + finally: + driver.close() + + +if __name__ == "__main__": + test_motor() diff --git a/unilabos/devices/separator/chinwe.py b/unilabos/devices/separator/chinwe.py index 8beac447f..b8c36a727 100644 --- a/unilabos/devices/separator/chinwe.py +++ b/unilabos/devices/separator/chinwe.py @@ -623,6 +623,119 @@ def wait_time(self, duration: int) -> bool: time.sleep(duration) return True + def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700, + max_cycles: int = 0, timeout: int = 300) -> bool: + """ + 分液步骤 - 液位传感器与电机联动 + 当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数 + 当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数 + + :param motor_id: 电机ID (必须在初始化时配置的motor_ids中) + :param speed: 电机转速 (RPM) + :param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈) + :param max_cycles: 最大执行循环次数 (0=无限制,默认0) + :param timeout: 整体超时时间 (秒) + :return: 成功返回True,超时或失败返回False + """ + motor_id = int(motor_id) + speed = int(speed) + pulses = int(pulses) + max_cycles = int(max_cycles) + timeout = int(timeout) + + # 检查电机是否存在 + if motor_id not in self.motors: + self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}") + return False + + # 检查传感器是否可用 + if not self.sensor: + self.logger.error("Sensor not initialized") + return False + + motor = self.motors[motor_id] + + # 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突 + self.logger.info("Stopping polling thread for separation_step...") + self._stop_event.set() + if self._poll_thread and self._poll_thread.is_alive(): + self._poll_thread.join(timeout=2.0) + + # 使能电机 + self.logger.info(f"Enabling motor {motor_id}...") + motor.enable(True) + time.sleep(0.2) + + self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, " + f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s") + + # 记录上一次的液位状态 + last_level = None + cycle_count = 0 + start_time = time.time() + error_count = 0 + + try: + while True: + # 检查超时 + if time.time() - start_time > timeout: + self.logger.warning(f"Separation step timeout after {timeout} seconds") + return False + + # 检查循环次数限制 + if max_cycles > 0 and cycle_count >= max_cycles: + self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}") + return True + + # 读取传感器数据 + data = self.sensor.read_level() + + if data is None: + error_count += 1 + if error_count > 5: + self.logger.warning("Sensor read failed multiple times, retrying...") + error_count = 0 + time.sleep(0.5) + continue + + error_count = 0 + current_level = data['level'] + rssi = data['rssi'] + + # 检测状态变化 (包括首次检测) + if current_level != last_level: + cycle_count += 1 + + if current_level: + # 有液 -> 电机顺时针旋转 + self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), " + f"rotating motor {motor_id} clockwise {pulses} pulses") + motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False) + + # 等待电机完成 (预估时间) + estimated_time = 15.0 / max(1, speed) + time.sleep(estimated_time + 0.5) + + else: + # 无液 -> 电机逆时针旋转 + self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), " + f"rotating motor {motor_id} counter-clockwise {pulses} pulses") + motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False) + + # 等待电机完成 (预估时间) + estimated_time = 15.0 / max(1, speed) + time.sleep(estimated_time + 0.5) + + # 更新状态 + last_level = current_level + + # 轮询间隔 + time.sleep(0.1) + finally: + # 恢复轮询线程 + self.logger.info("Restarting polling thread...") + self._start_polling() + def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool: """支持标准 JSON 指令调用""" return super().execute_command_from_outer(command_dict) diff --git a/unilabos/devices/separator/xkc_sensor.py b/unilabos/devices/separator/xkc_sensor.py new file mode 100644 index 000000000..c954a2e02 --- /dev/null +++ b/unilabos/devices/separator/xkc_sensor.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +""" +XKC RS485 液位传感器 (Modbus RTU) + +说明: + 1. 遵循 Modbus-RTU 协议。 + 2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。 + 3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。 + 4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。 +""" + +import struct +import threading +import time +import logging +import serial +from typing import Optional, Dict, Any, List + +from unilabos.device_comms.universal_driver import UniversalDriver + +class TransportManager: + """ + 统一通信管理类。 + 仅支持 串口 (Serial/有线) 连接。 + """ + def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None): + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.logger = logger + self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突 + + self.serial = None + self._connect_serial() + + def _connect_serial(self): + try: + self.serial = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=self.timeout + ) + except Exception as e: + raise ConnectionError(f"Serial open failed: {e}") + + def close(self): + """关闭连接""" + if self.serial and self.serial.is_open: + self.serial.close() + + def clear_buffer(self): + """清空缓冲区 (Thread-safe)""" + with self.lock: + if self.serial: + self.serial.reset_input_buffer() + + def write(self, data: bytes): + """发送原始字节""" + with self.lock: + if self.serial: + self.serial.write(data) + + def read(self, size: int) -> bytes: + """读取指定长度字节""" + if self.serial: + return self.serial.read(size) + return b'' + +class XKCSensorDriver(UniversalDriver): + """XKC RS485 液位传感器 (Modbus RTU)""" + + def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6, + threshold: int = 300, timeout: float = 3.0, debug: bool = False): + super().__init__() + self.port = port + self.baudrate = baudrate + self.device_id = device_id + self.threshold = threshold + self.timeout = timeout + self.debug = debug + self.level = False + self.rssi = 0 + self.status = {"level": self.level, "rssi": self.rssi} + + try: + self.transport = TransportManager(port, baudrate, timeout, logger=self.logger) + self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})") + except Exception as e: + self.logger.error(f"Failed to connect XKCSensorDriver: {e}") + self.transport = None + + # 启动背景轮询线程,确保 status 实时刷新 + self._stop_event = threading.Event() + self._polling_thread = threading.Thread( + target=self._update_loop, + name=f"XKCPolling_{port}", + daemon=True + ) + if self.transport: + self._polling_thread.start() + + def _update_loop(self): + """背景循环读取传感器数据""" + while not self._stop_event.is_set(): + try: + self.read_level() + except Exception as e: + if self.debug: + self.logger.error(f"Polling error: {e}") + time.sleep(2.0) # 每2秒刷新一次数据 + + def _crc(self, data: bytes) -> bytes: + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 + else: crc >>= 1 + return struct.pack(' Optional[Dict[str, Any]]: + """ + 读取液位。 + 返回: {'level': bool, 'rssi': int} + """ + if not self.transport: + return None + + with self.transport.lock: + self.transport.clear_buffer() + # Modbus Read Registers: 01 03 00 01 00 02 CRC + payload = struct.pack('>HH', 0x0001, 0x0002) + msg = struct.pack('BB', self.device_id, 0x03) + payload + msg += self._crc(msg) + + if self.debug: + self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}") + + self.transport.write(msg) + + # Read header + h = self.transport.read(3) # Addr, Func, Len + if self.debug: + self.logger.info(f"RX Header: {h.hex().upper()}") + + if len(h) < 3: return None + length = h[2] + + # Read body + CRC + body = self.transport.read(length + 2) + if self.debug: + self.logger.info(f"RX Body+CRC: {body.hex().upper()}") + if len(body) < length + 2: + # Firmware bug fix specific to some modules + if len(body) == 4 and length == 4: + pass + else: + return None + + data = body[:-2] + # 根据手册说明: + # 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液) + # 寄存器 0x0002 (data[2:4]): 信号强度 RSSI + + hw_level = False + rssi = 0 + + if len(data) >= 4: + hw_level = ((data[0] << 8) | data[1]) == 1 + rssi = (data[2] << 8) | data[3] + elif len(data) == 2: + # 兼容模式: 某些老固件可能只返回 1 个寄存器 + rssi = (data[0] << 8) | data[1] + hw_level = rssi > self.threshold + else: + return None + + # 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验 + # 注意: 如果用户显式设置了 THRESHOLD,我们可以在逻辑中做权衡 + self.level = hw_level or (rssi > self.threshold) + self.rssi = rssi + result = { + 'level': self.level, + 'rssi': self.rssi + } + self.status = result + return result + + def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool: + """ + 等待液位达到目标状态 (阻塞式) + """ + self.logger.info(f"Waiting for level: {target_state}") + start_time = time.time() + while (time.time() - start_time) < timeout: + res = self.read_level() + if res and res.get('level') == target_state: + return True + time.sleep(0.5) + self.logger.warning(f"Wait level timeout ({timeout}s)") + return False + + def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool: + """ + 实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。 + 一旦检测到符合目标状态,立即返回。 + + Args: + target_state: True 为“有液”, False 为“无液” + timeout: 最大等待时间(秒) + """ + state_str = "有液" if target_state else "无液" + self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)") + + start_time = time.time() + while (time.time() - start_time) < timeout: + res = self.read_level() # 内部已更新 self.level 和 self.rssi + if res: + current_level = res.get('level') + current_rssi = res.get('rssi') + if current_level == target_state: + self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})") + return True + + if self.debug: + self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}") + + time.sleep(0.2) # 高频采样 + + self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)") + return False + + def set_threshold(self, threshold: int): + """设置液位判定阈值""" + self.threshold = int(threshold) + self.logger.info(f"Threshold updated to: {self.threshold}") + + def change_device_id(self, new_id: int) -> bool: + """ + 修改设备的 Modbus 从站地址。 + 寄存器: 0x0004, 功能码: 0x06 + """ + if not (1 <= new_id <= 254): + self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.") + return False + + self.logger.info(f"Changing device ID from {self.device_id} to {new_id}") + success = self._write_single_register(0x0004, new_id) + if success: + self.device_id = new_id # 更新内存中的地址 + self.logger.info(f"Device ID update command sent successfully (target {new_id}).") + return success + + def change_baudrate(self, baud_code: int) -> bool: + """ + 更改通讯波特率 (寄存器: 0x0005)。 + 设置成功后传感器 LED 会闪烁,通常无数据返回。 + + 波特率代码对照表 (16进制): + 05: 2400 + 06: 4800 + 07: 9600 (默认) + 08: 14400 + 09: 19200 + 0A: 28800 + 0C: 57600 + 0D: 115200 + 0E: 128000 + 0F: 256000 + """ + self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})") + # 写入寄存器 0x0005 + self._write_single_register(0x0005, baud_code) + self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.") + return True + + def factory_reset(self) -> bool: + """ + 恢复出厂设置 (通过广播地址 FF)。 + 设置地址为 01,逻辑为向 0x0004 写入 0x0002 + """ + self.logger.info("Sending factory reset command via broadcast address FF...") + # 广播指令通常无回显 + self._write_single_register(0x0004, 0x0002, slave_id=0xFF) + self.logger.info("Factory reset command sent. Device address should be 01 now.") + return True + + def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool: + """内部辅助函数: Modbus 功能码 06 写单个寄存器""" + if not self.transport: return False + + target_id = slave_id if slave_id is not None else self.device_id + msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value) + msg += self._crc(msg) + + with self.transport.lock: + self.transport.clear_buffer() + if self.debug: + self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}") + + self.transport.write(msg) + + # 广播地址、波特率修改或厂家特定指令可能无回显 + if target_id == 0xFF or reg_addr == 0x0005: + time.sleep(0.5) + return True + + # 等待返回 (正常应返回相同报文) + resp = self.transport.read(len(msg)) + if self.debug: + self.logger.info(f"RX Write Response: {resp.hex().upper()}") + + return resp == msg + + def close(self): + if self.transport: + self.transport.close() + +if __name__ == "__main__": + # 快速实例化测试 + import logging + # 减少冗余日志,仅显示重要信息 + logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + + # 硬件配置 (根据实际情况修改) + TEST_PORT = "/dev/tty.usbserial-3110" + SLAVE_ID = 1 + THRESHOLD = 300 + + print("\n" + "="*50) + print(f" XKC RS485 传感器独立测试程序") + print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}") + print("="*50) + + sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False) + + try: + if sensor.transport: + print(f"\n开始实时连续采样测试 (持续 15 秒)...") + print(f"按 Ctrl+C 可提前停止\n") + + start_time = time.time() + duration = 15 + count = 0 + + while time.time() - start_time < duration: + count += 1 + res = sensor.read_level() + if res: + rssi = res['rssi'] + level = res['level'] + status_str = "【有液】" if level else "【无液】" + # 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史) + # 为了方便查看变化,我们直接打印 + elapsed = time.time() - start_time + print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}") + else: + print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)") + + time.sleep(0.5) # 每秒采样 2 次 + + print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---") + + # [3] 测试动态修改阈值 + print(f"\n[3] 动态修改阈值演示...") + new_threshold = 400 + sensor.set_threshold(new_threshold) + res = sensor.read_level() + if res: + print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}") + sensor.set_threshold(THRESHOLD) # 还原 + + except KeyboardInterrupt: + print("\n[!] 用户中断测试") + except Exception as e: + print(f"\n[!] 测试运行出错: {e}") + finally: + sensor.close() + print("\n--- 测试程序已退出 ---\n") diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 327d8195c..60c18e1e7 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -258,7 +258,7 @@ def sync_to_external(self, resource: Any) -> bool: logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库") # 第1步:从配置中获取仓库配置 - warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {}) # 确定目标仓库名称 parent_name = None diff --git a/unilabos/registry/devices/chinwe.yaml b/unilabos/registry/devices/chinwe.yaml index ac4d00bb0..551846927 100644 --- a/unilabos/registry/devices/chinwe.yaml +++ b/unilabos/registry/devices/chinwe.yaml @@ -336,6 +336,47 @@ separator.chinwe: title: pump_valve参数 type: object type: UniLabJsonCommand + separation_step: + goal: + max_cycles: 0 + motor_id: 5 + pulses: 700 + speed: 60 + timeout: 300 + handles: {} + schema: + description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针) + properties: + goal: + properties: + max_cycles: + default: 0 + description: 最大循环次数 (0=无限制) + type: integer + motor_id: + default: '5' + description: 选择电机 + enum: + - '4' + - '5' + title: '注: 4=搅拌, 5=旋钮' + type: string + pulses: + default: 700 + description: 每次旋转脉冲数 (约1/4圈) + type: integer + speed: + default: 60 + description: 电机转速 (RPM) + type: integer + timeout: + default: 300 + description: 超时时间 (秒) + type: integer + required: + - motor_id + type: object + type: UniLabJsonCommand wait_sensor_level: feedback: {} goal: diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 4d2f72884..cbf04aa7d 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -6973,7 +6973,7 @@ liquid_handler.laiyu: properties: channel_num: default: 1 - type: string + type: integer deck: type: object host: @@ -6984,10 +6984,25 @@ liquid_handler.laiyu: type: integer simulator: default: true - type: string + type: boolean timeout: default: 10.0 type: number + serial_port: + default: /dev/ttyUSB0 + description: 硬件串口端口(非 simulator 模式下使用) + type: string + baudrate: + default: 115200 + type: integer + pipette_address: + default: 4 + description: SOPA 移液器 RS485 地址 + type: integer + total_height: + default: 310 + description: 龙门架总高度 (mm),用于坐标转换 + type: number required: - deck type: object diff --git a/unilabos/registry/devices/motor.yaml b/unilabos/registry/devices/motor.yaml new file mode 100644 index 000000000..7b603ae53 --- /dev/null +++ b/unilabos/registry/devices/motor.yaml @@ -0,0 +1,286 @@ +motor.zdt_x42: + category: + - motor + class: + action_value_mappings: + auto-enable: + feedback: {} + goal: {} + goal_default: + 'on': true + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。 + properties: + feedback: {} + goal: + properties: + 'on': + default: true + type: boolean + required: [] + type: object + result: {} + required: + - goal + title: enable参数 + type: object + type: UniLabJsonCommand + auto-get_position: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 获取当前电机脉冲位置。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + position: + type: integer + type: object + required: + - goal + title: get_position参数 + type: object + type: UniLabJsonCommand + auto-move_position: + feedback: {} + goal: {} + goal_default: + absolute: false + acceleration: 10 + direction: CW + pulses: 1000 + speed_rpm: 60 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。 + properties: + feedback: {} + goal: + properties: + absolute: + default: false + type: boolean + acceleration: + default: 10 + maximum: 255 + minimum: 0 + type: integer + direction: + default: CW + enum: + - CW + - CCW + type: string + pulses: + default: 1000 + type: integer + speed_rpm: + default: 60 + minimum: 0 + type: integer + required: + - pulses + - speed_rpm + type: object + result: {} + required: + - goal + title: move_position参数 + type: object + type: UniLabJsonCommand + auto-move_speed: + feedback: {} + goal: {} + goal_default: + acceleration: 10 + direction: CW + speed_rpm: 60 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 速度模式运行。控制电机以指定转速和方向持续转动。 + properties: + feedback: {} + goal: + properties: + acceleration: + default: 10 + maximum: 255 + minimum: 0 + type: integer + direction: + default: CW + enum: + - CW + - CCW + type: string + speed_rpm: + default: 60 + minimum: 0 + type: integer + required: + - speed_rpm + type: object + result: {} + required: + - goal + title: move_speed参数 + type: object + type: UniLabJsonCommand + auto-rotate_quarter: + feedback: {} + goal: {} + goal_default: + direction: CW + speed_rpm: 60 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 电机旋转 1/4 圈 (阻塞式)。 + properties: + feedback: {} + goal: + properties: + direction: + default: CW + enum: + - CW + - CCW + type: string + speed_rpm: + default: 60 + minimum: 1 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: rotate_quarter参数 + type: object + type: UniLabJsonCommand + auto-set_zero: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 将当前电机位置设为零点。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: set_zero参数 + type: object + type: UniLabJsonCommand + auto-stop: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 立即停止电机运动。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: stop参数 + type: object + type: UniLabJsonCommand + auto-wait_time: + feedback: {} + goal: {} + goal_default: + duration_s: 1.0 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 等待指定时间 (秒)。 + properties: + feedback: {} + goal: + properties: + duration_s: + default: 1.0 + minimum: 0 + type: number + required: + - duration_s + type: object + result: {} + required: + - goal + title: wait_time参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver + status_types: + position: int + status: str + type: python + config_info: [] + description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。 + handles: [] + icon: '' + init_param_schema: + config: + properties: + baudrate: + default: 115200 + type: integer + debug: + default: false + type: boolean + device_id: + default: 1 + type: integer + port: + type: string + timeout: + default: 0.5 + type: number + required: + - port + type: object + data: + properties: + position: + type: integer + status: + type: string + required: + - status + - position + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/sensor.yaml b/unilabos/registry/devices/sensor.yaml new file mode 100644 index 000000000..81d05b0fb --- /dev/null +++ b/unilabos/registry/devices/sensor.yaml @@ -0,0 +1,148 @@ +sensor.xkc_rs485: + category: + - sensor + - separator + class: + action_value_mappings: + auto-change_baudrate: + goal: + baud_code: 7 + handles: {} + schema: + description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800, + 07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000, + 0F=256000' + properties: + goal: + properties: + baud_code: + description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)' + type: integer + required: + - baud_code + type: object + type: UniLabJsonCommand + auto-change_device_id: + goal: + new_id: 1 + handles: {} + schema: + description: 修改传感器的 Modbus 从站地址 + properties: + goal: + properties: + new_id: + description: 新的从站地址 (1-254) + maximum: 254 + minimum: 1 + type: integer + required: + - new_id + type: object + type: UniLabJsonCommand + auto-factory_reset: + goal: {} + handles: {} + schema: + description: 恢复出厂设置 (地址重置为 01) + properties: + goal: + type: object + type: UniLabJsonCommand + auto-read_level: + goal: {} + handles: {} + schema: + description: 直接读取当前液位及信号强度 + properties: + goal: + type: object + type: object + type: UniLabJsonCommand + auto-set_threshold: + goal: + threshold: 300 + handles: {} + schema: + description: 设置液位判定阈值 + properties: + goal: + properties: + threshold: + type: integer + required: + - threshold + type: object + type: UniLabJsonCommand + auto-wait_for_liquid: + goal: + target_state: true + timeout: 120 + handles: {} + schema: + description: 实时检测电导率(RSSI)并等待用户指定的状态 + properties: + goal: + properties: + target_state: + default: true + description: 目标状态 (True=有液, False=无液) + type: boolean + timeout: + default: 120 + description: 超时时间 (秒) + required: + - target_state + type: object + type: UniLabJsonCommand + auto-wait_level: + goal: + level: true + timeout: 10 + handles: {} + schema: + description: 等待液位达到目标状态 + properties: + goal: + properties: + level: + type: boolean + timeout: + type: number + required: + - level + type: object + type: UniLabJsonCommand + module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver + status_types: + level: bool + rssi: int + type: python + config_info: [] + description: XKC RS485 非接触式液位传感器 (Modbus RTU) + handles: [] + icon: '' + init_param_schema: + config: + properties: + baudrate: + default: 9600 + type: integer + debug: + default: false + type: boolean + device_id: + default: 1 + type: integer + port: + type: string + threshold: + default: 300 + type: integer + timeout: + default: 3.0 + type: number + required: + - port + type: object + version: 1.0.0 diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py index d79b84959..e1932b207 100644 --- a/unilabos/resources/bioyond/bottle_carriers.py +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -1,4 +1,4 @@ -from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d +from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d, Container from unilabos.resources.itemized_carrier import BottleCarrier from unilabos.resources.bioyond.bottles import ( @@ -9,6 +9,28 @@ BIOYOND_PolymerStation_Reagent_Bottle, BIOYOND_PolymerStation_Flask, ) + + +def BIOYOND_PolymerStation_Tip(name: str, size_x: float = 8.0, size_y: float = 8.0, size_z: float = 50.0) -> Container: + """创建单个枪头资源 + + Args: + name: 枪头名称 + size_x: 枪头宽度 (mm) + size_y: 枪头长度 (mm) + size_z: 枪头高度 (mm) + + Returns: + Container: 枪头容器 + """ + return Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="tip", + model="BIOYOND_PolymerStation_Tip", + ) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial @@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier: carrier.num_items_z = 1 carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1") return carrier + + +def BIOYOND_PolymerStation_TipBox( + name: str, + size_x: float = 127.76, # 枪头盒宽度 + size_y: float = 85.48, # 枪头盒长度 + size_z: float = 100.0, # 枪头盒高度 + barcode: str = None, +) -> BottleCarrier: + """创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构 + + Args: + name: 枪头盒名称 + size_x: 枪头盒宽度 (mm) + size_y: 枪头盒长度 (mm) + size_z: 枪头盒高度 (mm) + barcode: 条形码 + + Returns: + BottleCarrier: 包含24个枪头孔位的枪头盒载架 + + 布局说明: + - 4行×6列 (A-D, 1-6) + - 枪头孔位间距: 18mm (x方向) × 18mm (y方向) + - 起始位置居中对齐 + - 索引顺序: 列优先 (0=A1, 1=B1, 2=C1, 3=D1, 4=A2, ...) + """ + # 枪头孔位参数 + num_cols = 6 # 1-6 (x方向) + num_rows = 4 # A-D (y方向) + tip_diameter = 8.0 # 枪头孔位直径 + tip_spacing_x = 18.0 # 列间距 (增加到18mm,更宽松) + tip_spacing_y = 18.0 # 行间距 (增加到18mm,更宽松) + + # 计算起始位置 (居中对齐) + total_width = (num_cols - 1) * tip_spacing_x + tip_diameter + total_height = (num_rows - 1) * tip_spacing_y + tip_diameter + start_x = (size_x - total_width) / 2 + start_y = (size_y - total_height) / 2 + + # 使用 create_ordered_items_2d 创建孔位 + # create_ordered_items_2d 返回的 key 是数字索引: 0, 1, 2, ... + # 顺序是列优先: 先y后x (即 0=A1, 1=B1, 2=C1, 3=D1, 4=A2, 5=B2, ...) + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=num_cols, + num_items_y=num_rows, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=tip_spacing_x, + item_dy=tip_spacing_y, + size_x=tip_diameter, + size_y=tip_diameter, + size_z=50.0, # 枪头深度 + ) + + # 更新 sites 中每个 ResourceHolder 的名称 + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + # 创建枪头盒载架 + # 注意:不设置 category,使用默认的 "bottle_carrier",这样前端会显示为完整的矩形载架 + tip_box = BottleCarrier( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + sites=sites, # 直接使用数字索引的 sites + model="BIOYOND_PolymerStation_TipBox", + ) + + # 设置自定义属性 + tip_box.barcode = barcode + tip_box.tip_count = 24 # 4行×6列 + tip_box.num_items_x = num_cols + tip_box.num_items_y = num_rows + tip_box.num_items_z = 1 + + # ⭐ 枪头盒不需要放入子资源 + # 与其他 carrier 不同,枪头盒在 Bioyond 中是一个整体 + # 不需要追踪每个枪头的状态,保持为空的 ResourceHolder 即可 + # 这样前端会显示24个空槽位,可以用于放置枪头 + + return tip_box diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py index 7045d8b72..73343bc68 100644 --- a/unilabos/resources/bioyond/bottles.py +++ b/unilabos/resources/bioyond/bottles.py @@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox( size_z: float = 100.0, # 枪头盒高度 barcode: str = None, ): - """创建4×6枪头盒 (24个枪头) + """创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构 + + 注意:此函数已弃用,请使用 bottle_carriers.py 中的版本 Args: name: 枪头盒名称 @@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox( barcode: 条形码 Returns: - TipBoxCarrier: 包含24个枪头孔位的枪头盒 + BottleCarrier: 包含24个枪头孔位的枪头盒载架 """ - from pylabrobot.resources import Container, Coordinate - - # 创建枪头盒容器 - tip_box = Container( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category="tip_rack", - model="BIOYOND_PolymerStation_TipBox_4x6", - ) - - # 设置自定义属性 - tip_box.barcode = barcode - tip_box.tip_count = 24 # 4行×6列 - tip_box.num_items_x = 6 # 6列 - tip_box.num_items_y = 4 # 4行 - - # 创建24个枪头孔位 (4行×6列) - # 假设孔位间距为 9mm - tip_spacing_x = 9.0 # 列间距 - tip_spacing_y = 9.0 # 行间距 - start_x = 14.38 # 第一个孔位的x偏移 - start_y = 11.24 # 第一个孔位的y偏移 - - for row in range(4): # A, B, C, D - for col in range(6): # 1-6 - spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6 - x = start_x + col * tip_spacing_x - y = start_y + row * tip_spacing_y - - # 创建枪头孔位容器 - tip_spot = Container( - name=spot_name, - size_x=8.0, # 单个枪头孔位大小 - size_y=8.0, - size_z=size_z - 10.0, # 略低于盒子高度 - category="tip_spot", - ) - - # 添加到枪头盒 - tip_box.assign_child_resource( - tip_spot, - location=Coordinate(x=x, y=y, z=0) - ) - - return tip_box + # 重定向到 bottle_carriers.py 中的实现 + from unilabos.resources.bioyond.bottle_carriers import BIOYOND_PolymerStation_TipBox as TipBox_Carrier + return TipBox_Carrier(name=name, size_x=size_x, size_y=size_y, size_z=size_z, barcode=barcode) def BIOYOND_PolymerStation_Flask( diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index b3ad7368b..239e2f487 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -797,9 +797,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st bottle = plr_material[number] = initialize_resource( {"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR ) - bottle.tracker.liquids = [ - (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) - ] + # 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well) + # ResourceHolder 等不支持液体追踪的容器跳过 + if hasattr(bottle, "tracker"): + bottle.tracker.liquids = [ + (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) + ] bottle.code = detail.get("code", "") logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})") else: @@ -808,9 +811,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st # 只对有 capacity 属性的容器(液体容器)处理液体追踪 if hasattr(plr_material, 'capacity'): bottle = plr_material[0] if plr_material.capacity > 0 else plr_material - bottle.tracker.liquids = [ - (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) - ] + # 确保 bottle 有 tracker 属性才设置液体信息 + if hasattr(bottle, "tracker"): + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) @@ -839,24 +844,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st wh_name = loc.get("whName") logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})") + # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) + # 必须在warehouse映射之前先获取坐标,以便后续调整 + x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) + y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) + z = loc.get("z", 1) # 层号 (1-based, 通常为1) + # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" - # 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 + # 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧 if wh_name == "堆栈1": - x_val = loc.get("x", 1) - if 1 <= x_val <= 4: + if 1 <= y <= 4: wh_name = "堆栈1左" - elif 5 <= x_val <= 8: + elif 5 <= y <= 8: wh_name = "堆栈1右" + y = y - 4 # 调整列号: 5-8映射到1-4 else: - logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右") + logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围,无法映射到堆栈1左或堆栈1右") continue # 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射 if wh_name == "站内Tip盒堆栈": - y_val = loc.get("y", 1) - if y_val == 1: + if y == 1: wh_name = "站内Tip盒堆栈(右)" - elif y_val in [2, 3]: + elif y in [2, 3]: wh_name = "站内Tip盒堆栈(左)" y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列 @@ -864,15 +874,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st warehouse = deck.warehouses[wh_name] logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") - # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) - x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) - y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) - z = loc.get("z", 1) # 层号 (1-based, 通常为1) - - # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) - if wh_name == "堆栈1右": - y = y - 4 # 将5-8映射到1-4 - # 特殊处理竖向warehouse(站内试剂存放堆栈、测量小瓶仓库) # 这些warehouse使用 vertical-col-major 布局 if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]: diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index fe55c39e5..93b03399e 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -179,6 +179,11 @@ def assign_child_resource( idx = i break + if idx is None: + # 反序列化时无法匹配 site(名称或坐标均不符)。 + # WareHouse 通过 sites 追踪占用,无需将子资源加入 PLR 子树,直接跳过避免命名冲突。 + return + if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") location = list(self.child_locations.values())[idx] diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py index 1c019dedf..4dc1c4b55 100644 --- a/unilabos/resources/plr_additional_res_reg.py +++ b/unilabos/resources/plr_additional_res_reg.py @@ -18,3 +18,9 @@ def register(): from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend + # noinspection PyUnresolvedReferences + from unilabos.resources.bioyond.decks import ( + BIOYOND_PolymerReactionStation_Deck, + BIOYOND_PolymerPreparationStation_Deck, + BIOYOND_YB_Deck, + ) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 3fb945b64..1b4d85621 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -423,6 +423,7 @@ def replace_plr_type(source: str): "deck": "deck", "tip_rack": "tip_rack", "tip_spot": "tip_spot", + "tip": "tip", # 添加 tip 类型支持 "tube": "tube", "bottle_carrier": "bottle_carrier", "material_hole": "material_hole", @@ -605,11 +606,19 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): }, "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, "category": res.config.get("category", plr_type), - "children": [node_to_plr_dict(child, has_model) for child in node.children], + # WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。 + # 将 WareHouse 子节点排除在外,避免同名载架出现在多个 WareHouse 下时 + # PLR _check_naming_conflicts 报命名冲突。 + "children": [] if res.type == "warehouse" else [node_to_plr_dict(child, has_model) for child in node.children], "parent_name": res.parent_instance_name, } if has_model: d["model"] = res.config.get("model", None) + # 仅当 PLR dict 中含有子节点时才禁用 setup(), + # 防止 setup() 预分配子资源后 PLR deserialize 再次分配同名资源产生命名冲突。 + # 若 children 为空,则保留 setup=True,依赖 setup() 来初始化仓库。 + if "setup" in d and d.get("children"): + d["setup"] = False return d plr_resources = [] @@ -862,13 +871,34 @@ def merge_remote_resources(self, remote_tree_set: "ResourceTreeSet") -> "Resourc f"已存在,跳过" ) + # 移除本地有但远端已不存在的物料(以远端为准) + remote_material_names = {m.res_content.name for m in remote_child.children} + removed_count = 0 + for child in list(local_sub_device.children): + if child.res_content.name not in remote_material_names: + local_sub_device.children.remove(child) + removed_count += 1 + logger.info( + f"移除远端已不存在的物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'" + ) + if added_count > 0: logger.info( f"Device '{remote_root_id}/{remote_child_name}': " f"从远端同步了 {added_count} 个物料子树" ) + if removed_count > 0: + logger.info( + f"Device '{remote_root_id}/{remote_child_name}': " + f"移除了 {removed_count} 个远端已删除的物料" + ) else: # 二级物料已存在,比较三级子节点是否缺失 + if remote_child_name not in local_children_map: + logger.warning( + f"物料 '{remote_root_id}/{remote_child_name}' 在远端存在但本地不存在,跳过" + ) + continue local_material = local_children_map[remote_child_name] local_material_children_map = {child.res_content.name: child for child in local_material.children} @@ -884,11 +914,28 @@ def merge_remote_resources(self, remote_tree_set: "ResourceTreeSet") -> "Resourc f"物料 '{remote_root_id}/{remote_child_name}/{remote_sub_name}' " f"已存在,跳过" ) + + # 移除本地有但远端已不存在的子物料(以远端为准) + remote_sub_names = {s.res_content.name for s in remote_child.children} + removed_count = 0 + for child in list(local_material.children): + if child.res_content.name not in remote_sub_names: + local_material.children.remove(child) + removed_count += 1 + logger.info( + f"移除远端已不存在的子物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'" + ) + if added_count > 0: logger.info( f"物料 '{remote_root_id}/{remote_child_name}': " f"从远端同步了 {added_count} 个子物料" ) + if removed_count > 0: + logger.info( + f"物料 '{remote_root_id}/{remote_child_name}': " + f"移除了 {removed_count} 个远端已删除的子物料" + ) else: # 情况1: 一级节点是物料(不是 device) # 检查是否已存在 @@ -1329,6 +1376,16 @@ def figure_resource( else: res_list.extend(self.loop_find_resource(r, type(query_resource), "unilabos_uuid", res_uuid)) + # 同一资源对象可能通过"直接注册"和"作为父资源子节点"被搜索到两次,按对象 id 去重 + seen_ids: set = set() + deduped = [] + for item in res_list: + oid = id(item[1]) + if oid not in seen_ids: + seen_ids.add(oid) + deduped.append(item) + res_list = deduped + if not try_mode: assert len(res_list) > 0, f"没有找到资源 (uuid={res_uuid}),请检查资源是否存在" assert len(res_list) == 1, f"通过uuid={res_uuid} 找到多个资源,请检查资源是否唯一: {res_list}" @@ -1365,6 +1422,14 @@ def figure_resource( r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key) ) ) + seen_ids2: set = set() + deduped2 = [] + for item in res_list: + oid = id(item[1]) + if oid not in seen_ids2: + seen_ids2.add(oid) + deduped2.append(item) + res_list = deduped2 if not try_mode: assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在" assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}" diff --git a/unilabos/test/experiments/dispensing_station_bioyond.json b/unilabos/test/experiments/dispensing_station_bioyond.json index a6bd53327..28d2d98d2 100644 --- a/unilabos/test/experiments/dispensing_station_bioyond.json +++ b/unilabos/test/experiments/dispensing_station_bioyond.json @@ -15,92 +15,92 @@ "z": 0 }, "config": { - "api_key": "YOUR_API_KEY", - "api_host": "http://your-api-host:port", + "api_key": "", + "api_host": "http://:", "material_type_mappings": { "BIOYOND_PolymerStation_1FlaskCarrier": [ "烧杯", - "uuid-placeholder-flask" + "" ], "BIOYOND_PolymerStation_1BottleCarrier": [ "试剂瓶", - "uuid-placeholder-bottle" + "" ], "BIOYOND_PolymerStation_6StockCarrier": [ "分装板", - "uuid-placeholder-stock-6" + "" ], "BIOYOND_PolymerStation_Liquid_Vial": [ "10%分装小瓶", - "uuid-placeholder-liquid-vial" + "" ], "BIOYOND_PolymerStation_Solid_Vial": [ "90%分装小瓶", - "uuid-placeholder-solid-vial" + "" ], "BIOYOND_PolymerStation_8StockCarrier": [ "样品板", - "uuid-placeholder-stock-8" + "" ], "BIOYOND_PolymerStation_Solid_Stock": [ "样品瓶", - "uuid-placeholder-solid-stock" + "" ] }, "warehouse_mapping": { "粉末堆栈": { - "uuid": "uuid-placeholder-powder-stack", + "uuid": "", "site_uuids": { - "A01": "uuid-placeholder-powder-A01", - "A02": "uuid-placeholder-powder-A02", - "A03": "uuid-placeholder-powder-A03", - "A04": "uuid-placeholder-powder-A04", - "B01": "uuid-placeholder-powder-B01", - "B02": "uuid-placeholder-powder-B02", - "B03": "uuid-placeholder-powder-B03", - "B04": "uuid-placeholder-powder-B04", - "C01": "uuid-placeholder-powder-C01", - "C02": "uuid-placeholder-powder-C02", - "C03": "uuid-placeholder-powder-C03", - "C04": "uuid-placeholder-powder-C04", - "D01": "uuid-placeholder-powder-D01", - "D02": "uuid-placeholder-powder-D02", - "D03": "uuid-placeholder-powder-D03", - "D04": "uuid-placeholder-powder-D04" + "A01": "", + "A02": "", + "A03": "", + "A04": "", + "B01": "", + "B02": "", + "B03": "", + "B04": "", + "C01": "", + "C02": "", + "C03": "", + "C04": "", + "D01": "", + "D02": "", + "D03": "", + "D04": "" } }, "溶液堆栈": { - "uuid": "uuid-placeholder-liquid-stack", + "uuid": "", "site_uuids": { - "A01": "uuid-placeholder-liquid-A01", - "A02": "uuid-placeholder-liquid-A02", - "A03": "uuid-placeholder-liquid-A03", - "A04": "uuid-placeholder-liquid-A04", - "B01": "uuid-placeholder-liquid-B01", - "B02": "uuid-placeholder-liquid-B02", - "B03": "uuid-placeholder-liquid-B03", - "B04": "uuid-placeholder-liquid-B04", - "C01": "uuid-placeholder-liquid-C01", - "C02": "uuid-placeholder-liquid-C02", - "C03": "uuid-placeholder-liquid-C03", - "C04": "uuid-placeholder-liquid-C04", - "D01": "uuid-placeholder-liquid-D01", - "D02": "uuid-placeholder-liquid-D02", - "D03": "uuid-placeholder-liquid-D03", - "D04": "uuid-placeholder-liquid-D04" + "A01": "", + "A02": "", + "A03": "", + "A04": "", + "B01": "", + "B02": "", + "B03": "", + "B04": "", + "C01": "", + "C02": "", + "C03": "", + "C04": "", + "D01": "", + "D02": "", + "D03": "", + "D04": "" } }, "试剂堆栈": { - "uuid": "uuid-placeholder-reagent-stack", + "uuid": "", "site_uuids": { - "A01": "uuid-placeholder-reagent-A01", - "A02": "uuid-placeholder-reagent-A02", - "A03": "uuid-placeholder-reagent-A03", - "A04": "uuid-placeholder-reagent-A04", - "B01": "uuid-placeholder-reagent-B01", - "B02": "uuid-placeholder-reagent-B02", - "B03": "uuid-placeholder-reagent-B03", - "B04": "uuid-placeholder-reagent-B04" + "A01": "", + "A02": "", + "A03": "", + "A04": "", + "B01": "", + "B02": "", + "B03": "", + "B04": "" } } }, @@ -156,4 +156,4 @@ "data": {} } ] -} \ No newline at end of file +} diff --git a/unilabos/test/experiments/xkc_sensor_test.json b/unilabos/test/experiments/xkc_sensor_test.json new file mode 100644 index 000000000..ef50ddefb --- /dev/null +++ b/unilabos/test/experiments/xkc_sensor_test.json @@ -0,0 +1,29 @@ +{ + "nodes": [ + { + "id": "Liquid_Sensor_1", + "name": "XKC Sensor", + "children": [], + "parent": null, + "type": "device", + "class": "sensor.xkc_rs485", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "port": "/dev/tty.usbserial-3110", + "baudrate": 9600, + "device_id": 1, + "threshold": 300, + "timeout": 3.0 + }, + "data": { + "level": false, + "rssi": 0 + } + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/test/experiments/zdt_motor_test.json b/unilabos/test/experiments/zdt_motor_test.json new file mode 100644 index 000000000..692e40ef4 --- /dev/null +++ b/unilabos/test/experiments/zdt_motor_test.json @@ -0,0 +1,28 @@ +{ + "nodes": [ + { + "id": "ZDT_Motor", + "name": "ZDT Motor", + "children": [], + "parent": null, + "type": "device", + "class": "motor.zdt_x42", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "port": "/dev/tty.usbserial-3110", + "baudrate": 115200, + "device_id": 1, + "debug": true + }, + "data": { + "position": 0, + "status": "idle" + } + } + ], + "links": [] +} \ No newline at end of file