diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index e37e3ab17..57318aae7 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos - version: 0.11.1 + version: 0.11.3 source: path: ../../unilabos @@ -54,7 +54,7 @@ requirements: - pymodbus - matplotlib - pylibftdi - - uni-lab::unilabos-env ==0.11.1 + - uni-lab::unilabos-env ==0.11.3 about: repository: https://github.com/deepmodeling/Uni-Lab-OS diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index 13ee9f88e..c41dac2bb 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.11.1 + version: 0.11.3 build: noarch: generic diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index 7202ad9fe..b3a839908 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos-full - version: 0.11.1 + version: 0.11.3 build: noarch: generic @@ -11,7 +11,7 @@ build: requirements: run: # Base unilabos package (includes unilabos-env) - - uni-lab::unilabos ==0.11.1 + - uni-lab::unilabos ==0.11.3 # Documentation tools - sphinx - sphinx_rtd_theme 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/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md index 61b6252e6..b294dc18a 100644 --- a/.cursor/skills/add-device/SKILL.md +++ b/.cursor/skills/add-device/SKILL.md @@ -5,9 +5,98 @@ description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses # 添加新设备到 Uni-Lab-OS -**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。 +本 Skill 是自包含的设备接入指南,不依赖外部文档。迁移给别人时,只复制 `.cursor/skills/add-device/SKILL.md` 即可获得核心规则、模板、验证方式和常见错误清单。 -该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。 +开始实现前,仍应搜索 `unilabos/devices/` 获取同类别已有设备的接口、参数名、状态字符串和返回值风格作为参考。 + +--- + +## 接入工作流 + +按下面顺序推进,并在工作中维护进度: + +```text +设备接入进度: +- [ ] 1. 确定设备类别(物模型)和对外单位 +- [ ] 2. 确定通信协议 +- [ ] 3. 收集指令协议(SDK、厂商文档、寄存器表、HTTP API、用户口述) +- [ ] 4. 对齐同类设备接口(搜索 unilabos/devices/) +- [ ] 5. 创建驱动 unilabos/devices//.py +- [ ] 6. 验证可导入、注册表扫描、启动测试 +- [ ] 7. 如需要,配置实验图文件 +``` + +## 设备类别(物模型) + +优先使用已有类别。只有确实无法归类时才使用 `custom`。 + +| 类别 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`, `scheduler_stop` | +| `virtual` | 虚拟、模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 | +| `custom` | 不属于以上类别 | 用户自定义 | 用户自定义 | + +`pump_and_valve` 子类型: + +| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 | +|---|---|---|---| +| 注射泵(syringe pump) | `status`, `valve_position`, `position` | `initialize`, `set_valve_position`, `set_position`, `pull_plunger`, `push_plunger`, `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 | + +对外暴露的属性和动作参数必须使用用户友好的物理单位(mL、ul、degC、RPM 等),硬件原始值转换放在驱动内部。 + +## 通信协议和指令来源 + +先确认通信方式,再确认具体指令协议。物模型只定义设备“应该做什么”,不会告诉你硬件“具体发什么字节/请求”。 + +| 协议 | 常用 config 参数 | 常用依赖 | 现有抽象 | +|---|---|---|---| +| Serial (RS232/RS485) | `port`, `baudrate`, `timeout` | `pyserial` | 直接使用 `serial.Serial` | +| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| TCP Socket | `host`, `port`, `timeout` | stdlib | 直接使用 `socket` | +| HTTP API | `url`, `token`, `timeout` | `requests` | `device_comms/rpc.py` | +| OPC UA | `url` | `opcua` | `device_comms/opcua_client/` | +| 无通信(虚拟) | 无 | 无 | 在动作中模拟行为 | + +必须从以下来源之一获得指令细节: + +| 来源 | 处理方式 | +|---|---| +| 现成 SDK/驱动代码 | 读取代码,提取指令逻辑,包装进 Uni-Lab-OS 类 | +| 协议文档/手册 | 解析命令、响应、校验、寄存器、错误码 | +| 用户口述 | 按描述实现指令编解码,标出不确定点 | +| 标准协议 | 使用标准实现,例如 Modbus 寄存器表、SCPI | +| 虚拟设备 | 跳过硬件通信,在动作方法中维护模拟状态 | + +## 对齐已有实现(强制) + +实现前必须搜索 `unilabos/devices/` 中同类别设备: + +- 参数名必须与已有设备保持一致;动作方法参数名是接口契约,不要随意改成 `volume_ml`、`target_temp_c` 这类新名字。 +- `status` 字符串值要和同类设备一致,优先使用英文稳定值,例如 `Idle`、`Running`、`Error`。 +- 状态属性用 `@property` + `@topic_config()` 明确声明。 +- 返回值使用结构化 dict,至少包含 `success`,需要给前端展示的信息放在 `message`、`data`、`error` 等字段。 + +## 架构选择 + +| 场景 | 推荐方式 | +|---|---| +| 简单设备 | 纯 Python 类 + `@device` | +| 工作站/组合设备 | `WorkstationBase` 或项目内已有工作站模式 | +| 液体处理 | `LiquidHandlerAbstract` / PyLabRobot 相关模式 | +| Modbus 设备 | 复用 `device_comms/modbus_plc/` 或项目内 Modbus 示例 | +| OPC UA 设备 | 复用 `device_comms/opcua_client/` | +| 外部独立包 | 使用 `create-device-package` skill | --- @@ -20,10 +109,10 @@ from unilabos.registry.decorators import device # 单设备 @device( - id="my_device.vendor", # 注册表唯一标识(必填) + id="my_device_vendor", # 注册表唯一标识(必填,只能包含英文、数字、下划线) category=["temperature"], # 分类标签列表(必填) description="设备描述", # 设备描述 - display_name="显示名称", # UI 显示名称(默认用 id) + displayname="显示名称", # UI 显示名称(默认用 id) icon="DeviceIcon.webp", # 图标文件名 version="1.0.0", # 版本号 device_type="python", # "python" 或 "ros2" @@ -34,15 +123,20 @@ from unilabos.registry.decorators import device # 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置) @device( - ids=["pump.vendor.model_A", "pump.vendor.model_B"], + ids=["pump_vendor_model_A", "pump_vendor_model_B"], id_meta={ - "pump.vendor.model_A": {"handles": [...], "description": "型号 A"}, - "pump.vendor.model_B": {"handles": [...], "description": "型号 B"}, + "pump_vendor_model_A": {"handles": [...], "description": "型号 A", "displayname": "泵型号 A"}, + "pump_vendor_model_B": {"handles": [...], "description": "型号 B", "displayname": "泵型号 B"}, }, category=["pump_and_valve"], ) ``` +**ID 与显示名规则:** +- `id` / `ids` 是注册表稳定标识,只能包含英文大小写字母、数字、下划线,推荐格式为 `vendor_model` 或 `category_vendor_model`。 +- `id` / `ids` 不能包含中文、空格、短横线、点号或其他符号;不要把中文设备名放进 id。 +- 中文名、品牌型号展示名、UI 友好名称使用 `displayname`,不要塞进 `id`。 + ### @action — 动作方法装饰器 ```python @@ -71,6 +165,45 @@ from unilabos.registry.decorators import action - `_` 开头的方法 → 不扫描 - `@not_action` 标记的方法 → 排除 +### 参数文档 → JSON Schema 元数据 + +在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息: + +```python +""" +Args: + param[显示名称]: 参数说明,会写入 JSON Schema 的 description。 +""" +``` + +- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。 +- `:` 后面的说明会写入 goal property 的 `description`。 +- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。 +- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。 + +### 特殊参数类型:ResourceSlot / DeviceSlot + +需要前端选择资源或设备时,用特殊类型注解,registry 会自动生成 `placeholder_keys`: + +```python +from typing import List +from unilabos.registry.placeholder_type import DeviceSlot, ResourceSlot + +@action(description="转移液体") +def transfer(self, source: ResourceSlot, target: ResourceSlot, volume_ul: float) -> dict: + """ + Args: + source[源资源]: 源容器或孔位。 + target[目标资源]: 目标容器或孔位。 + volume_ul[体积(ul)]: 转移体积。 + """ + return {"success": True} + +@action(description="同步设备") +def sync_devices(self, devices: List[DeviceSlot]) -> dict: + return {"success": True, "count": len(devices)} +``` + ### @topic_config — 状态属性配置 ```python @@ -105,15 +238,40 @@ import logging from typing import Any, Dict, Optional from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.registry.decorators import device, action, topic_config, not_action +from unilabos.registry.decorators import action, device, not_action, topic_config -@device(id="my_device", category=["my_category"], description="设备描述") +@device( + id="my_device", + category=["my_category"], + description="设备描述", + displayname="设备显示名", +) class MyDevice: + """设备类说明。""" + _ros_node: BaseROS2DeviceNode - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + port: str = "COM1", + baudrate: int = 9600, + timeout: float = 1.0, + **kwargs, + ): + """ + 初始化设备。 + + Args: + device_id[设备ID]: 设备实例 ID,默认使用 my_device。 + port[串口]: 设备串口号,例如 COM1 或 /dev/ttyUSB0。 + baudrate[波特率]: 串口波特率。 + timeout[超时时间(s)]: 通信超时时间,单位秒。 + """ self.device_id = device_id or "my_device" - self.config = config or {} + self.port = port + self.baudrate = baudrate + self.timeout = timeout self.logger = logging.getLogger(f"MyDevice.{self.device_id}") self.data: Dict[str, Any] = {"status": "Idle"} @@ -133,7 +291,13 @@ class MyDevice: @action(description="执行操作") def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]: - """带 @action 装饰器 → 注册为 'my_action' 动作""" + """ + 带 @action 装饰器 → 注册为 'my_action' 动作。 + + Args: + param[操作数值]: 操作使用的数值参数。 + name[操作名称]: 操作名称或备注。 + """ return {"success": True} def get_info(self) -> Dict[str, Any]: @@ -154,7 +318,159 @@ class MyDevice: ### 要点 - `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部 -- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)` +- `__init__` 中需要现场配置的参数按基础类型显式展开,例如 `port: str`、`baudrate: int`、`timeout: float`、`enabled: bool`;不要把所有配置塞进单个 `config: dict` +- `__init__` 保留 `device_id` 和 `**kwargs` 兼容运行时注入,但不要把 `**kwargs` 当成主要配置入口 - `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` - 运行时状态存储在 `self.data` 字典中 - 设备文件放在 `unilabos/devices//` 目录下 + +--- + +## 通信实现片段 + +Serial 文本指令: + +```python +def _send_command(self, cmd: str) -> str: + self.ser.write(f"{cmd}\r\n".encode()) + return self.ser.readline().decode().strip() +``` + +RS-485 响应解析要先定位帧头,不要用硬编码索引直接解析原始响应: + +```python +def _normalize_response(self, raw: str, start_marker: str = "/") -> str: + pos = raw.find(start_marker) + return raw[pos:] if pos >= 0 else raw +``` + +自定义二进制帧: + +```python +def _build_frame(self, func_code: int, data: bytes) -> bytes: + frame = bytearray([0xFE, func_code]) + bytearray(data) + checksum = sum(frame[1:]) % 256 + frame.append(checksum) + return bytes(frame) +``` + +Modbus 寄存器映射: + +```python +REGISTER_MAP = { + "temp_target": {"addr": 0x000B, "scale": 10}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + reg = REGISTER_MAP["temp_target"] + value = int(float(temp) * reg["scale"]) & 0xFFFF + self.client.write_register(reg["addr"], value, slave=self.slave_id) + self.data["temp_target"] = temp + return True +``` + +HTTP API 映射: + +```python +API_MAP = { + "set_temperature": { + "method": "POST", + "endpoint": "/api/temperature", + "body_key": "target", + }, +} +``` + +SDK 封装: + +```python +from my_device_sdk import DeviceController + +class MyDevice: + def __init__(self, device_id=None, port: str = "COM1", timeout: float = 1.0, **kwargs): + self.controller = DeviceController(port=port, timeout=timeout) +``` + +--- + +## 验证 + +无需手写注册表 YAML。`@device` 装饰器 + AST 扫描会在启动或检查时生成注册表条目。 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.. import " + +# 2. 启动测试 +unilab -g .json + +# 3. 仅检查注册表 +unilab --check_mode --skip_env_check +``` + +仅在旧代码无 `@device`、需要覆盖特殊字段、或做 `--complete_registry` 旧设备补全时,才考虑 YAML。新设备默认不要手写 YAML。 + +## 图文件节点模板 + +实验图 JSON 中的 `class` 对应 `@device(id=...)`。`config` 中的字段应对应 `__init__` 的同名基础类型参数,不要只定义一个 `config: dict` 参数承载所有配置: + +```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": {} +} +``` + +工作站需要同时配置 `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", + "config": {"type": "MyDeckClass", "setup": true} + } + ] +} +``` + +--- + +## 常见错误清单 + +- 缺少 `@device`:设备不会被 AST 扫描发现。 +- `@device(id=...)` 使用中文、点号、短横线或空格:id 必须只包含英文、数字、下划线,显示名称用 `displayname`。 +- 只有 `@property` 没有 `@topic_config()`:属性不会稳定广播到 `status_types`。 +- `post_init` 没有 `@not_action`:会被误暴露为动作。 +- `self.data = {}`:空字典会导致属性读取和 schema 初始数据不稳定,必须预填充每个状态键。 +- 动作参数重命名:不要把同类设备已有的 `volume` 改成 `volume_ml`,参数名是接口契约。 +- `status` 使用中文或临时文本:前端和工作流依赖稳定英文状态值。 +- async 方法中使用 `time.sleep()`:应使用 `await self._ros_node.sleep(seconds)`。 +- 硬编码串口响应索引:RS-485 响应前可能有噪声字节,应先定位帧头。 +- 把硬件寄存器单位暴露给用户:对外使用物理单位,驱动内部做 scale 转换。 diff --git a/.cursor/skills/filter-workflow-by-tags/SKILL.md b/.cursor/skills/filter-workflow-by-tags/SKILL.md new file mode 100644 index 000000000..6cedd7c49 --- /dev/null +++ b/.cursor/skills/filter-workflow-by-tags/SKILL.md @@ -0,0 +1,450 @@ +--- +name: filter-workflow-by-tags +description: Query backend workflow list, aggregate all tags, and filter workflows by domain/scenario requirements using tags. Use when the user wants to search workflows, find workflows by tags, list available workflow tags, filter workflows by category/domain/scenario, or mentions 工作流筛选/标签查询/workflow tags/按领域查找工作流. +--- +# Uni-Lab 工作流标签筛选指南 + +通过 Uni-Lab 云端 API 查询工作流列表,汇总所有可用标签(tags),并根据领域和场景要求筛选工作流。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 + +## 使用模式识别 + +**用户可能一开始就给出场景目标**(如"我要做有机合成实验"、"找柱层析相关的 protocol")。此时: + +1. **识别场景关键词** → 映射到可能的 tags(如 synthesis、organic、chromatography、purification) +2. **直接执行完整流程**(获取 ak/sk/addr → 拉取所有工作流 → 汇总 tags → 按场景筛选) +3. **展示筛选结果** → 引导用户从候选 workflow 中**选择明确的实验 protocol** +4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接“与其他 Skill 的协作” + +**如果用户未给场景目标**,则按标准 checklist 询问筛选条件。 + +--- + +## 前置条件 + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token: + +```bash +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" +``` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------- | ------------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. lab_uuid(实验室 UUID) + +如果用户未提供 `lab_uuid`,通过以下 API 自动获取: + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 即为 `lab_uuid`。 + +**三项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态: + +- `lab_uuid` — 实验室 UUID +- `all_workflows` — 完整工作流列表(分页获取后缓存到内存或临时文件) +- `all_tags` — 所有工作流的标签汇总 + +--- + +## API 端点 + +### 查询工作流列表(支持分页) + +``` +GET $BASE/api/v1/lab/workflow/owner/list?page=&page_size=&lab_uuid=$lab_uuid +``` + +**参数:** + +- `page` — 页码,从 1 开始 +- `page_size` — 每页数量,建议 1000 +- `lab_uuid` — 实验室 UUID + +**返回结构:** + +```json +{ + "code": 0, + "data": { + "has_more": true, + "data": [ + { + "uuid": "9661bba2-1b9f-4687-a63d-910245df174b", + "name": "Untitled", + "description": "", + "user_id": "114211", + "published": false, + "tags": null + }, + { + "uuid": "e0436638-190b-46bc-b1a1-2711d9602f6a", + "name": "Synthesis v2", + "user_id": "114211", + "published": true, + "tags": ["synthesis", "organic"] + } + ] + } +} +``` + +**字段说明:** + +- `has_more` — 若为 `true`,需要继续请求 `page+1` +- `tags` — 可能为 `null`、空数组或字符串数组;聚合时必须容忍 `null` + +### 启动工作流(直接运行) + +``` +POST $BASE/api/v1/lab/workflow//run +``` + +**用途:** 直接启动一个 workflow 的默认执行(使用模板中预设的参数),无需创建 notebook。适用于快速测试或无参数变化的重复执行。 + +**请求体:** 空 JSON `{}` 或省略 + +**返回:** + +```json +{ + "code": 0, + "data": "" +} +``` + +- `run_uuid` — 本次执行的唯一标识(不是 notebook UUID) + +**注意:** + +- 该接口会使用 workflow 模板中保存的默认参数直接执行 +- 如果 workflow 需要动态参数(如 CSV 路径、样品 UUID),应使用 `POST /lab/notebook` 创建 notebook 并传入 `node_params` +- 返回的 `run_uuid` 可直接传入下方「查询任务状态」接口查询实时进度 + +### 查询任务状态 + +``` +GET $BASE/api/v1/lab/mcp/task/ +``` + +**用途:** 查询由 `POST /lab/workflow//run` 返回的 `run_uuid`(即 task_uuid)的实时执行状态,包括整体状态和每个节点(JOS:Job On Station)的执行详情。 + +**路径参数:** + +- `task_uuid` — 等同于启动工作流接口返回的 `run_uuid` + +**返回:** + +```json +{ + "code": 0, + "data": { + "status": "running", + "jos_status": [ + { + "uuid": "d0e24bfe-8d99-450e-b19d-f25849dfbaad", + "node_name": "PRCXI_BioER_96_wellplate_slot_1", + "action_name": "create_resource", + "status": "success", + "return_info": { + "suc": true, + "error": "", + "return_value": { ... } + } + }, + { + "uuid": "...", + "node_name": "...", + "action_name": "transfer_liquid", + "status": "pending", + "return_info": null + } + ] + } +} +``` + +**字段说明:** + +- `data.status` — 整体任务状态 + - `running` — 正在执行(至少一个节点 pending 或 running) + - `success` — 全部节点成功 + - `failed` — 有节点失败 +- `data.jos_status[]` — 节点级执行列表(按执行顺序) + - `uuid` — 节点执行实例 UUID + - `node_name` — 节点名称(资源/设备名或工位名) + - `action_name` — 动作类型(`create_resource`、`transfer_liquid`、`centrifuge`、等) + - `status` — 节点状态:`success`、`pending`、`running`、`failed` + - `return_info` — 执行返回,失败时 `suc=false` 且 `error` 有错误信息 + +**注意:** + +- 此接口的 `task_uuid` **是** `POST /lab/workflow//run` 返回的 `run_uuid`,二者为同一个 ID 的不同称呼 +- **不要**把 notebook UUID(`POST /lab/notebook` 返回)传进来——那条路径用 `GET /lab/notebook/status` 查询 +- `jos_status` 数组按节点执行顺序给出;从 pending 数量可以估算剩余进度 +- 返回体可能较大(`return_info.return_value` 中可能包含完整 resource tree),可在脚本中只提取 `status` + `node_name` + `action_name` 做摘要 + +**状态轮询示例:** + +```bash +# 每 5 秒轮询一次直至完成 +TASK="b183d97e-d2b5-4b24-b14b-820df04d87c0" +while :; do + st=$(curl -s -X GET "$BASE/api/v1/lab/mcp/task/$TASK" -H "$AUTH" \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; \ + print(d['status'], '|', sum(1 for j in d['jos_status'] if j['status']=='success'), '/', len(d['jos_status']))") + echo "$(date +%H:%M:%S) $st" + [[ "$st" == success* || "$st" == failed* ]] && break + sleep 5 +done +``` + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 0: 识别用户是否已给出场景目标(如"有机合成"、"柱层析") + - 若已给出 → 记录场景关键词,自动进入后续步骤 + - 若未给出 → 在 Step 6 询问用户 +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid(如用户未提供) +- [ ] Step 4: 分页获取所有工作流(从 page=1 开始直到 has_more=false) +- [ ] Step 5: 汇总所有非空 tags → 生成 all_tags(去重、排序、附出现次数) +- [ ] Step 6: 根据场景关键词(Step 0 或新询问)在 all_tags 中做语义映射 → 确定候选 tags + - 若语义映射不唯一,列出候选 tags 让用户确认 +- [ ] Step 7: 按候选 tags 筛选工作流(默认 any 模式,召回优先) +- [ ] Step 8: 展示筛选结果(uuid、name、description、tags、published) +- [ ] Step 9: 引导用户从结果中选择**明确的实验 protocol** + - 若结果只有 1 条 → 直接确认该 workflow_uuid + - 若结果 2–10 条 → 让用户按编号选择 + - 若结果过多 → 提示收紧条件(加 tag、切换 all 模式、仅 published) + - 若结果为空 → 放宽条件(去掉最稀有 tag)或提示用户换关键词 +- [ ] Step 10: 记录用户选中的 workflow_uuid,并提示提交实验或查看详情 +``` + +--- + +## 推荐路径:使用脚本 + +同目录下提供 `scripts/filter_workflows.py`,一次完成分页抓取、标签聚合与筛选: + +```bash +# 1. 仅汇总标签(不筛选) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --summary-only + +# 2. 按标签筛选(ANY 模式:包含任一) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis organic \ + --mode any + +# 3. 按标签筛选(ALL 模式:必须同时包含) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis organic \ + --mode all \ + --output filtered.json + +# 4. 仅筛选已发布 +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis \ + --published-only +``` + +**`--auth` 参数说明**:传入 `Authorization` 头中 `Lab` 之后的 base64 token(不带 `Lab ` 前缀),脚本内部会自动补上 scheme。 + +**输出结构:** + +```json +{ + "total_workflows": 150, + "tag_counts": {"synthesis": 12, "organic": 8, "analysis": 5}, + "all_tags": ["analysis", "organic", "synthesis"], + "filter": {"tags": ["synthesis", "organic"], "mode": "any"}, + "filtered_workflows": [ + {"uuid": "...", "name": "...", "description": "...", "tags": [...], "published": true} + ] +} +``` + +--- + +## 手动路径:curl + jq + +如果脚本不可用或环境缺少 Python,可用 shell 实现。 + +### 1. 分页抓取(写入 `all_workflows.json`) + +```bash +page=1 +echo "[]" > all_workflows.json + +while :; do + resp=$(curl -s -X GET \ + "$BASE/api/v1/lab/workflow/owner/list?page=$page&page_size=1000&lab_uuid=$lab_uuid" \ + -H "$AUTH") + + page_data=$(echo "$resp" | jq -c '.data.data // []') + jq -c --argjson p "$page_data" '. + $p' all_workflows.json > _tmp.json && mv _tmp.json all_workflows.json + + has_more=$(echo "$resp" | jq -r '.data.has_more') + [ "$has_more" != "true" ] && break + page=$((page + 1)) +done + +echo "Total: $(jq 'length' all_workflows.json)" +``` + +### 2. 汇总所有标签(含出现次数) + +```bash +jq '[.[].tags // [] | .[]] | group_by(.) | map({tag: .[0], count: length}) | sort_by(-.count)' \ + all_workflows.json +``` + +### 3. 按标签筛选 + +```bash +# ANY:包含任一指定标签 +jq --argjson want '["synthesis","organic"]' \ + '[.[] | select((.tags // []) | any(. as $t | $want | index($t)))]' \ + all_workflows.json + +# ALL:同时包含所有指定标签 +jq --argjson want '["synthesis","organic"]' \ + '[.[] | select(($want | all(. as $w | (.tags // []) | index($w))))]' \ + all_workflows.json +``` + +--- + +## 筛选策略 + +agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺序选择 tag: + +1. **优先用户显式指定的 tags**:若用户明确给出标签词,直接精确匹配。 +2. **从 all_tags 中做语义映射**:若用户描述是自然语言(如"有机合成、柱层析"),在 all_tags 中找语义相关项(如 `synthesis`、`organic`、`chromatography`)。必要时展示候选 tag 让用户确认。 +3. **模式选择**: + - 默认 `any`(更多召回),给出 tag 集合的并集匹配 + - 用户强调"必须同时满足"时用 `all` +4. **空结果兜底**:如果筛选为空,放宽条件(去掉最稀有 tag、切换 any 模式),并提醒用户。 + +--- + +## 引导到明确的 Protocol + +筛选完成后,**最终目标是让用户确认一个具体的 workflow_uuid**,而不是停留在"一堆候选"上。按结果数量采取不同策略: + +| 结果数量 | 策略 | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 0 条 | 放宽筛选(去掉最稀有 tag → 切换 any 模式 → 去掉 `--published-only`)。仍为空则提示换关键词,或列出 `all_tags` 让用户重新选。 | +| 1 条 | 直接确认:"找到唯一匹配:`` (uuid ``),是否用它?"用户确认后记录 `workflow_uuid`。 | +| 2–10 条 | 编号列表展示,让用户选编号。每项给出 name、tags、description 摘要、published 状态。 | +| 10–30 条 | 先展示 tag 分布帮助用户进一步收紧:列出匹配结果中最常见的子标签,提示"加一个 tag 可将结果缩小到 N 条"。 | +| >30 条 | 强制要求用户补充条件:仅 published、指定具体 tag 组合、或按名称关键词过滤。 | + +**确认 workflow 后**: + +1. 将 `workflow_uuid` 写入 session state +2. 提示用户下一步可用的 skill: + - 提交实验 → 引导到“与其他 Skill 的协作” + - 查看 workflow 详细节点 → `GET /api/v1/lab/workflow/template/detail/` +3. 若用户想换一个,回到筛选步骤。 + +--- + +## 展示结果 + +推荐格式(表格 + 汇总统计): + +``` +共 150 个工作流,其中 32 个匹配筛选条件 [tags: synthesis OR organic] + +| UUID (短) | 名称 | Tags | 已发布 | +|-----------|--------------------------|------------------------------|--------| +| e0436638 | Synthesis v2 | synthesis, organic | ✓ | +| 5b60dbb8 | Grignard Protocol | synthesis, organometallic | ✓ | +| ... | ... | ... | ... | + +所有可用标签(按频次): + synthesis (12), organic (8), analysis (5), purification (4), ... +``` + +如果用户下一步想执行某工作流 → 引导到“与其他 Skill 的协作”。 + +--- + +## 常见问题 + +### Q: tags 为 null 的工作流要不要展示? + +默认**不展示**在筛选结果中(因为无法按 tag 匹配)。但在 `--summary-only` 或无筛选条件时,这些工作流仍会计入总数,并在输出中单独列出"未打标签"计数。 + +### Q: 如何按名称/描述做模糊匹配? + +脚本未内置,但可在 jq 中组合: + +```bash +jq '[.[] | select((.name + " " + (.description // "")) | test("organic"; "i"))]' all_workflows.json +``` + +### Q: `page_size=1000` 是否会被服务端限制? + +接口通常允许最大 1000;如果返回量少于 1000 且 `has_more=false`,说明已到末页。极端情况下若服务端返回错误,可降到 200 或 500 再试。 + +### Q: 工作流数量极大(>10k)怎么办? + +1. 先跑 `--summary-only` 了解 tag 分布 +2. 提示用户先限定 `--published-only` 或指定 tag +3. 考虑将 `all_workflows.json` 缓存到本地,下次直接复用 + +--- + +## 与其他 Skill 的协作 + +- 正常情况下,找到 workflow 之后可以直接用它提交实验(启动工作流的 api 端点 POST $BASE/api/v1/lab/workflow//run,不用别的 skill) +- **仅当需要进行多次实验时,使用 batch-submit-experiment** — 筛选到目标工作流后,`workflow_uuid` 直接用于实验提交 + +## 脚本依赖 + +`scripts/filter_workflows.py` 仅使用 Python 标准库(`urllib`、`json`、`argparse`),无需额外安装。 diff --git a/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py b/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py new file mode 100755 index 000000000..87bb0b1cf --- /dev/null +++ b/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""分页拉取 Uni-Lab 工作流列表,汇总 tags 并按 tag 筛选。 + +使用示例: + python filter_workflows.py \ + --auth \ + --base https://leap-lab.test.bohrium.com \ + --lab-uuid a9059772-... \ + --tags synthesis organic --mode any + +仅依赖 Python 标准库。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter + + +def fetch_all_workflows(base: str, auth_token: str, lab_uuid: str, page_size: int = 1000) -> list[dict]: + """分页拉取所有 owner 工作流,直到 has_more=false。""" + workflows: list[dict] = [] + page = 1 + while True: + query = urllib.parse.urlencode( + {"page": page, "page_size": page_size, "lab_uuid": lab_uuid} + ) + url = f"{base.rstrip('/')}/api/v1/lab/workflow/owner/list?{query}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Lab {auth_token}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + sys.exit(f"[ERROR] HTTP {e.code} on page {page}: {e.read().decode('utf-8', 'ignore')}") + except urllib.error.URLError as e: + sys.exit(f"[ERROR] URL error on page {page}: {e.reason}") + + if payload.get("code") != 0: + sys.exit(f"[ERROR] API returned non-zero code: {payload}") + + data = payload.get("data") or {} + page_items = data.get("data") or [] + workflows.extend(page_items) + + if not data.get("has_more"): + break + page += 1 + # 防御性兜底,避免接口异常导致无限循环 + if page > 1000: + print(f"[WARN] page count exceeded 1000, stopping early", file=sys.stderr) + break + + return workflows + + +def aggregate_tags(workflows: list[dict]) -> tuple[list[str], dict[str, int], int]: + """返回 (sorted_tags, tag_counts, untagged_count)。""" + counter: Counter[str] = Counter() + untagged = 0 + for wf in workflows: + tags = wf.get("tags") + if not tags: + untagged += 1 + continue + for t in tags: + if isinstance(t, str) and t.strip(): + counter[t.strip()] += 1 + return sorted(counter.keys()), dict(counter), untagged + + +def filter_workflows( + workflows: list[dict], + want_tags: list[str], + mode: str, + published_only: bool, +) -> list[dict]: + """按 tag 筛选。mode 取值 any / all。""" + want_set = {t.strip() for t in want_tags if t.strip()} + out: list[dict] = [] + for wf in workflows: + if published_only and not wf.get("published"): + continue + if not want_set: + out.append(wf) + continue + tags = wf.get("tags") or [] + tag_set = {t for t in tags if isinstance(t, str)} + if mode == "all": + if want_set.issubset(tag_set): + out.append(wf) + else: # any + if want_set & tag_set: + out.append(wf) + return out + + +def project_workflow(wf: dict) -> dict: + """精简输出字段。""" + return { + "uuid": wf.get("uuid"), + "name": wf.get("name"), + "description": wf.get("description", ""), + "tags": wf.get("tags") or [], + "published": bool(wf.get("published")), + "user_id": wf.get("user_id"), + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Fetch & filter Uni-Lab workflows by tags.") + p.add_argument("--auth", required=True, help="Base64 token (the part after `Lab `).") + p.add_argument("--base", required=True, help="Base URL, e.g. https://leap-lab.test.bohrium.com") + p.add_argument("--lab-uuid", required=True, help="Lab UUID.") + p.add_argument("--tags", nargs="*", default=[], help="Tags to filter by (space separated).") + p.add_argument( + "--mode", + choices=["any", "all"], + default="any", + help="any: workflow contains at least one tag; all: workflow contains every tag.", + ) + p.add_argument("--published-only", action="store_true", help="Only include published workflows.") + p.add_argument("--page-size", type=int, default=1000, help="Page size, default 1000.") + p.add_argument( + "--summary-only", + action="store_true", + help="Print tag summary without applying filter (still fetches everything).", + ) + p.add_argument("--output", help="Write JSON result to this path. If omitted, print to stdout.") + return p.parse_args() + + +def main() -> None: + args = parse_args() + workflows = fetch_all_workflows( + base=args.base, + auth_token=args.auth, + lab_uuid=args.lab_uuid, + page_size=args.page_size, + ) + sorted_tags, tag_counts, untagged = aggregate_tags(workflows) + + if args.summary_only: + result = { + "total_workflows": len(workflows), + "untagged_count": untagged, + "tag_counts": tag_counts, + "all_tags": sorted_tags, + } + else: + filtered = filter_workflows( + workflows, + want_tags=args.tags, + mode=args.mode, + published_only=args.published_only, + ) + result = { + "total_workflows": len(workflows), + "untagged_count": untagged, + "tag_counts": tag_counts, + "all_tags": sorted_tags, + "filter": { + "tags": args.tags, + "mode": args.mode, + "published_only": args.published_only, + }, + "matched_count": len(filtered), + "filtered_workflows": [project_workflow(wf) for wf in filtered], + } + + payload = json.dumps(result, ensure_ascii=False, indent=2) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload) + print(f"Wrote {len(workflows)} workflows summary → {args.output}", file=sys.stderr) + else: + print(payload) + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/virtual-workbench/SKILL.md b/.cursor/skills/virtual-workbench/SKILL.md index 8f7aa0fef..1c295ffd6 100644 --- a/.cursor/skills/virtual-workbench/SKILL.md +++ b/.cursor/skills/virtual-workbench/SKILL.md @@ -10,7 +10,8 @@ description: Operate Virtual Workbench via REST API — prepare materials, move - **device_id**: `virtual_workbench` - **Python 源码**: `unilabos/devices/virtual/workbench.py` - **设备类**: `VirtualWorkbench` -- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`) +- **当前纳入动作**: 5 个(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`) +- **暂跳过动作**: `manual_confirm`、扣电测试 `test`(需要启用时先从最新注册表重新提取 schema) - **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行) ### 典型工作流程 @@ -151,7 +152,8 @@ curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ | `auto-start_heating` | `UniLabJsonCommand` | | `auto-move_to_output` | `UniLabJsonCommand` | | `transfer` | `UniLabJsonCommandAsync` | -| `manual_confirm` | `UniLabJsonCommand` | + +> `manual_confirm` 和扣电测试 `test` 当前不纳入本 skill 的推荐操作范围;不要基于历史 JSON 直接调用,需先重新生成并校验 schema。 ### 10. 查询任务状态 @@ -225,11 +227,9 @@ curl -s -X PUT "$BASE/api/v1/edge/material/node" \ | `transfer` | `resource` | ResourceSlot | 待转移物料数组 | | `transfer` | `target_device` | DeviceSlot | 目标设备路径 | | `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 | -| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 | -| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 | -| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 | > `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。 +> `manual_confirm` 先跳过,不维护其 Slot 字段表。 --- @@ -270,3 +270,13 @@ prepare_materials (count=5) ``` 创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。 + +`start_heating` 完成后还需要继续连接到 `move_to_output`,否则加热完成的物料不会移出加热台: + +| source action | source handle | target action | target handle | 传递参数 | +| ------------- | ------------- | ------------- | ------------- | -------- | +| `auto-prepare_materials` | `channel_N` | `auto-move_to_heating_station` | `material_input` | `material_number` | +| `auto-move_to_heating_station` | `heating_station_output` | `auto-start_heating` | `station_id_input` | `station_id` | +| `auto-move_to_heating_station` | `material_number_output` | `auto-start_heating` | `material_number_input` | `material_number` | +| `auto-start_heating` | `heating_done_station` | `auto-move_to_output` | `output_station_input` | `station_id` | +| `auto-start_heating` | `heating_done_material` | `auto-move_to_output` | `output_material_input` | `material_number` | diff --git a/.cursor/skills/virtual-workbench/action-index.md b/.cursor/skills/virtual-workbench/action-index.md index f67d9a917..7b3401fa3 100644 --- a/.cursor/skills/virtual-workbench/action-index.md +++ b/.cursor/skills/virtual-workbench/action-index.md @@ -1,6 +1,8 @@ # Action Index — virtual_workbench -6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 +当前纳入 5 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +暂跳过:`manual_confirm`、扣电测试 `test`。这两个动作需要启用时,先从最新 `req_device_registry_upload.json` 重新提取 schema 并校验参数。 --- @@ -60,17 +62,18 @@ --- -## 人工确认 +## 暂跳过动作 ### `manual_confirm` -创建人工确认节点,等待用户手动确认后继续(含物料转移上下文) +创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)。当前先不纳入推荐操作范围。 - **action_type**: `UniLabJsonCommand` - **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) -- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids` -- **占位符字段**: - - `resource` — **ResourceSlot**,物料数组 - - `target_device` — **DeviceSlot**,目标设备路径 - - `mount_resource` — **ResourceSlot**,目标孔位数组 - - `assignee_user_ids` — `unilabos_manual_confirm` 类型 +- **状态**: 暂跳过。源码参数已包含扣电测试相关字段,历史 JSON 可能过期;需要启用时重新提取 schema。 + +### `test` + +启动扣电测试。当前先不纳入本 skill。 + +- **状态**: 暂跳过。需要启用时从注册表生成 `actions/test.json` 后再补充索引。 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/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 402edc26f..2b227db1e 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 - name: Setup Miniforge - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true @@ -38,7 +38,7 @@ jobs: - name: Install ROS dependencies, uv and unilabos-msgs run: | echo Installing ROS dependencies... - mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y + mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y - name: Install pip dependencies and unilabos run: | diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index ed45db9d4..2730ff4d1 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -1,6 +1,10 @@ name: Build Conda-Pack Environment on: + # 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack + workflow_run: + workflows: ["UniLabOS Conda Build"] + types: [completed] workflow_dispatch: inputs: branch: @@ -21,6 +25,16 @@ on: jobs: build-conda-pack: + if: | + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_run' + ) + env: + BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }} + PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }} strategy: fail-fast: false matrix: @@ -29,7 +43,7 @@ jobs: platform: linux-64 env_file: unilabos-linux-64.yaml script_ext: sh - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 env_file: unilabos-osx-64.yaml script_ext: sh @@ -54,7 +68,9 @@ jobs: id: should_build shell: bash run: | - if [[ -z "${{ github.event.inputs.platforms }}" ]]; then + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then + echo "should_build=true" >> $GITHUB_OUTPUT + elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then echo "should_build=true" >> $GITHUB_OUTPUT elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then echo "should_build=true" >> $GITHUB_OUTPUT @@ -65,17 +81,17 @@ jobs: - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: - ref: ${{ github.event.inputs.branch }} + ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Setup Miniforge (with mamba) if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -86,13 +102,13 @@ jobs: run: | echo Installing unilabos and dependencies to unilab environment... echo Using mamba for faster and more reliable dependency resolution... - echo Build full: ${{ github.event.inputs.build_full }} - if "${{ github.event.inputs.build_full }}"=="true" ( + echo Build full: ${{ env.BUILD_FULL }} + if "${{ env.BUILD_FULL }}"=="true" ( echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y ) else ( echo Installing unilabos ^(minimal package^)... - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y ) - name: Install conda-pack, unilabos and dependencies (Unix) @@ -101,13 +117,13 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - echo "Build full: ${{ github.event.inputs.build_full }}" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo "Build full: ${{ env.BUILD_FULL }}" + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Installing unilabos-full (complete package)..." - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y else echo "Installing unilabos (minimal package)..." - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y fi - name: Get latest ros-humble-unilabos-msgs version (Windows) @@ -134,27 +150,27 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Checking for available ros-humble-unilabos-msgs versions... - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed echo. echo Updating ros-humble-unilabos-msgs to latest version... - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version - name: Check for newer ros-humble-unilabos-msgs (Unix) if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' shell: bash run: | echo "Checking for available ros-humble-unilabos-msgs versions..." - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed" + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed" echo "" echo "Updating ros-humble-unilabos-msgs to latest version..." - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version" + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version" - name: Install latest unilabos from source (Windows) if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Uninstalling existing unilabos... mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip - echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})... + echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})... mamba run -n unilab pip install . echo Verifying installation... mamba run -n unilab pip show unilabos @@ -165,7 +181,7 @@ jobs: run: | echo "Uninstalling existing unilabos..." mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..." + echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..." mamba run -n unilab pip install . echo "Verifying installation..." mamba run -n unilab pip show unilabos @@ -226,7 +242,9 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Packing unilab environment with conda-pack... - mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i" + echo Packing environment at: %UNILAB_PREFIX% + mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo Pack file created: dir unilab-env-${{ matrix.platform }}.tar.gz @@ -235,8 +253,9 @@ jobs: shell: bash run: | echo "Packing unilab environment with conda-pack..." - mamba install conda-pack -c conda-forge -y - conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')" + echo "Packing environment at: $UNILAB_PREFIX" + mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo "Pack file created:" ls -lh unilab-env-${{ matrix.platform }}.tar.gz @@ -267,7 +286,7 @@ jobs: rem Create README using Python script echo Creating: README.txt - python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt + python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt echo. echo Distribution package contents: @@ -303,7 +322,7 @@ jobs: # Create README using Python script echo "Creating: README.txt" - python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt + python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt echo "" echo "Distribution package contents:" @@ -314,7 +333,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' uses: actions/upload-artifact@v6 with: - name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} path: dist-package/ retention-days: 90 if-no-files-found: error @@ -326,9 +345,9 @@ jobs: echo Build Summary echo ========================================== echo Platform: ${{ matrix.platform }} - echo Branch: ${{ github.event.inputs.branch }} + echo Branch: ${{ env.PACKAGE_REF }} echo Python version: 3.11.14 - if "${{ github.event.inputs.build_full }}"=="true" ( + if "${{ env.BUILD_FULL }}"=="true" ( echo Package: unilabos-full ^(complete^) ) else ( echo Package: unilabos ^(minimal^) @@ -337,7 +356,7 @@ jobs: echo Distribution package contents: dir dist-package echo. - echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} echo. echo After download, extract the ZIP and run: echo install_unilab.bat @@ -351,9 +370,9 @@ jobs: echo "Build Summary" echo "==========================================" echo "Platform: ${{ matrix.platform }}" - echo "Branch: ${{ github.event.inputs.branch }}" + echo "Branch: ${{ env.PACKAGE_REF }}" echo "Python version: 3.11.14" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Package: unilabos-full (complete)" else echo "Package: unilabos (minimal)" @@ -362,7 +381,7 @@ jobs: echo "Distribution package contents:" ls -lh dist-package/ echo "" - echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}" + echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}" echo "" echo "After download:" echo " install_unilab.sh" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index f3ac4d11f..3e4b07ddf 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -51,12 +51,12 @@ jobs: fetch-depth: 0 - name: Setup Miniforge (with mamba) - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -66,7 +66,7 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y - name: Install latest unilabos from source run: | @@ -84,7 +84,7 @@ jobs: - name: Setup Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 if: | github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') @@ -105,7 +105,7 @@ jobs: test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing" - name: Upload build artifacts - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 if: | github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') @@ -125,4 +125,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 4e1cf4f7b..5d3496a08 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -10,6 +10,9 @@ on: # 支持 tag 推送(不依赖 CI Check) push: tags: ['v*'] + # GitHub Release 发布时自动构建并上传 + release: + types: [published] # 手动触发 workflow_dispatch: inputs: @@ -60,7 +63,7 @@ jobs: - os: ubuntu-latest platform: linux-64 env_file: unilabos-linux-64.yaml - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 env_file: unilabos-osx-64.yaml - os: macos-latest # ARM64 @@ -80,7 +83,7 @@ jobs: - uses: actions/checkout@v6 with: # 如果是 workflow_run 触发,使用触发 CI Check 的 commit - ref: ${{ github.event.workflow_run.head_sha || github.ref }} + ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -96,12 +99,14 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,defaults + miniforge-version: latest + use-mamba: true + python-version: '3.11.14' + channels: conda-forge,robostack-staging channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,24 +115,29 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y' + else + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + fi + echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" + echo "PYTHONUTF8=1" >> "$GITHUB_ENV" + echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' run: | conda info - conda list | grep -E "(rattler-build|anaconda-client)" + conda list -n build-env | grep -E "(rattler-build|anaconda-client)" + conda run -n build-env rattler-build --version + conda run -n build-env anaconda --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - name: Build conda package if: steps.should_build.outputs.should_build == 'true' run: | - if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then - rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge - else - rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge - fi + conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml --target-platform ${{ matrix.platform }} -c robostack -c robostack-staging -c conda-forge - name: List built packages if: steps.should_build.outputs.should_build == 'true' @@ -157,9 +167,15 @@ jobs: retention-days: 30 - name: Upload to Anaconda.org (unilab organization) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'release' || + startsWith(github.ref, 'refs/tags/') || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | for package in $(find ./output -name "*.conda"); do echo "Uploading $package to unilab organization..." - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index d116a67ee..f269606c3 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,14 +1,10 @@ name: UniLabOS Conda Build on: - # 在 CI Check 成功后自动触发 + # 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发 workflow_run: - workflows: ["CI Check"] + workflows: ["Multi-Platform Conda Build"] types: [completed] - branches: [main, dev] - # 标签推送时直接触发(发布版本) - push: - tags: ['v*'] # 手动触发 workflow_dispatch: inputs: @@ -33,37 +29,37 @@ on: type: boolean jobs: - # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) - wait-for-ci: + # 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发) + wait-for-upstream: runs-on: ubuntu-latest if: github.event_name == 'workflow_run' outputs: should_continue: ${{ steps.check.outputs.should_continue }} steps: - - name: Check CI status + - name: Check upstream workflow status id: check run: | - if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then echo "should_continue=true" >> $GITHUB_OUTPUT - echo "CI Check passed, proceeding with build" + echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build" else echo "should_continue=false" >> $GITHUB_OUTPUT - echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" + echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build" fi build: - needs: [wait-for-ci] - # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 + needs: [wait-for-upstream] + # 运行条件:workflow_run 触发且上游成功,或者手动触发 if: | always() && - (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') + (needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true') strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux-64 - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 - os: macos-latest # ARM64 platform: osx-arm64 @@ -79,7 +75,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用触发 CI Check 的 commit + # 如果是 workflow_run 触发,使用上游 conda 包构建的 commit ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 @@ -96,12 +92,14 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,uni-lab,defaults + miniforge-version: latest + use-mamba: true + python-version: '3.11.14' + channels: conda-forge,robostack-staging,uni-lab channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,20 +108,29 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y' + else + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + fi + echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" + echo "PYTHONUTF8=1" >> "$GITHUB_ENV" + echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' run: | conda info - conda list | grep -E "(rattler-build|anaconda-client)" + conda list -n build-env | grep -E "(rattler-build|anaconda-client)" + conda run -n build-env rattler-build --version + conda run -n build-env anaconda --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}" + echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" echo "Building packages:" echo " - unilabos-env (environment dependencies)" echo " - unilabos (with pip package)" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then echo " - unilabos-full (complete package)" fi @@ -131,14 +138,19 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | echo "Building unilabos-env (conda environment dependencies)..." - rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge + conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge - name: Upload unilabos-env to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos-env to uni-lab organization..." for package in $(find ./output -name "unilabos-env*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: Build unilabos (with pip package) @@ -146,33 +158,40 @@ jobs: run: | echo "Building unilabos package..." # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 - rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/base/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos to uni-lab organization..." for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: Build unilabos-full - Only when explicitly requested if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' run: | echo "Building unilabos-full package on ${{ matrix.platform }}..." - rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/full/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos-full to Anaconda.org (if enabled) if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' && github.event.inputs.upload_to_anaconda == 'true' run: | echo "Uploading unilabos-full to uni-lab organization..." for package in $(find ./output -name "unilabos-full*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: List built packages 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_PLC.md b/docs/developer_guide/add_PLC.md new file mode 100644 index 000000000..e4f6bb6d7 --- /dev/null +++ b/docs/developer_guide/add_PLC.md @@ -0,0 +1,611 @@ +# PLC 通信标准与设备驱动编写指南(基于 AI4M 工站) + +> 本文档以 `unilabos/devices/workstation/AI4M`(水凝胶检测工站)为参考实现, +> 介绍如何将 PLC 控制的实验设备接入 Uni-Lab-OS:包含通信协议选型、节点表标准、 +> 通信基类、设备驱动、Registry 配置以及调试方法。 +> +> 阅读对象:负责现场调试与设备接入的同学。 + +--- + +## 0. 总览:一台 PLC 设备从硬件到云端的链路 + +``` + PLC(西门子 / 倍福 / 三菱 / 汇川 / 国产 PLC ...) + ▲ + │ 各家 PLC 私有协议(S7 / Modbus / EtherCAT ...) + │ + ┌──────────┴──────────┐ + │ OPC UA Server │ ← 统一在 PLC 侧或独立网关上配置 + │ (内置或 KEPServer)│ + └──────────┬──────────┘ + │ OPC UA over TCP(标准协议) + │ + ┌──────────┴──────────┐ + │ Uni-Lab 设备驱动 │ ← 本教程主体 + │ AI4MDevice │ + │ ├─ base_opcua_client.py 通信基类 + │ ├─ opcua_nodes_*.csv 节点表(标准) + │ └─ AI4M.py 动作函数 + └──────────┬──────────┘ + │ ROS2 Action / 云端 HTTP + ▼ + 实验记录本 / 云端调度 +``` + +**统一约定**:所有 PLC 设备**只暴露 OPC UA 接口**给 Uni-Lab,PC 端不直接处理 S7 / Modbus 等底层协议。 +这是 Uni-Lab 在工站类设备上的 PLC 通信标准。 + +--- + +## 1. 为什么选 OPC UA 作为标准? + +| 维度 | 自研 TCP/串口协议 | Modbus | **OPC UA** | +|---|---|---|---| +| 厂家无关 | ✗ | 部分 | **✓** | +| 自带类型系统 | ✗ | ✗(裸寄存器) | **✓(Boolean/Int16/Float...)** | +| 命名空间 / 节点树 | ✗ | ✗(地址=魔数) | **✓(带名字、可分组)** | +| 订阅推送 | ✗ | ✗ | **✓(DataChange Notification)** | +| 鉴权 / 加密 | 自己造 | ✗ | **✓** | +| 与 PLC 工程师沟通成本 | 高 | 中 | **低(按变量名沟通)** | + +实际接入时,PLC 工程师只需要在 PLC 侧把约定的"上位通讯变量"暴露到 OPC UA Server, +我们在 PC 侧就能用 `节点名 + 数据类型` 直接读写,不用管底层是 S7 还是 Modbus。 + +--- + +## 2. 节点表标准:`opcua_nodes_xxx.csv` + +PLC 侧暴露的所有变量统一**用一张 CSV 表**描述,这是 PC 端和 PLC 端**唯一的接口契约**。 +位置示例:`unilabos/devices/workstation/AI4M/opcua_nodes_AI4M.csv`。 + +### 2.1 列定义 + +| 列名 | 是否必填 | 说明 | +|---|---|---| +| `Name` | ✅ | 节点名(PLC 工程师在 PLC 项目中真实使用的变量名,通常是中文/原始名) | +| `EnglishName` | 推荐 | 英文别名,**PC 端代码全部用这个名字**调用 | +| `NodeType` | ✅ | `VARIABLE`(变量)或 `METHOD`(方法),AI4M 全部用变量 | +| `DataType` | ✅ | `BOOLEAN` / `INT16` / `INT32` / `FLOAT` / `DOUBLE` / `STRING` ... | +| `NodeLanguage` | 推荐 | `Chinese` / `English`,配合 `EnglishName` 做映射 | +| `NodeId` | ✅ | OPC UA 标准 NodeId,格式 `ns=;s=` 或 `ns=;i=` | + +### 2.2 真实样例(节选自 `opcua_nodes_AI4M.csv`) + +| Name | EnglishName | NodeType | DataType | NodeLanguage | NodeId | +|---|---|---|---|---|---| +| 机器人空闲 | `robot_ready` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|机器人空闲` | +| 机器人取烧杯编号 | `robot_pick_beaker_id` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|机器人取烧杯编号` | +| 检测1请求参数 | `station_1_request_params` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1请求参数` | +| 检测1工艺完成 | `station_1_process_complete` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1工艺完成` | +| 磁力搅拌参数设置_C[0].搅拌速度 | `mag_stirrer_c0_stir_speed` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|磁力搅拌参数设置_C[0].搅拌速度` | +| 报警复位 | `alarm_reset` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|报警复位` | + +### 2.3 设计规范(必读) + +1. **命名按"角色-编号-属性"分层**,便于代码批量寻址: + - `mag_stirrer_c{0..4}_stir_speed`(搅拌仪 0~4 的搅拌速度) + - `station_{1..3}_process_complete`(检测站 1~3 的完成信号) + - `robot_rack_pick_beaker_{1..5}_complete`(取烧杯 1~5 的完成信号) + + 这样在驱动里可以直接 `f"mag_stirrer_c{idx}_stir_speed"` 拼出节点名。 + +2. **数据类型与 PLC 侧严格一致**: + - `BOOL` → `BOOLEAN`,`INT/WORD` → `INT16/UINT16`,`DINT` → `INT32`,`REAL` → `FLOAT`。 + - 类型不一致会触发 `BadTypeMismatch`,写入失败。 + +3. **NodeId 必须从 PLC 工程或 OPC UA Server 中导出**,不要自己拼。 + 常见格式: + - 西门子 1500:`ns=4;s=上位通讯变量|<变量名>` + - 倍福 TwinCAT:`ns=4;s=PLC1.MAIN.<变量名>` + - KEPServerEX:`ns=2;s=Channel1.Device1.` + +4. **每个工站一个独立 CSV**,不要共用。 + AI4M 中真机用 `opcua_nodes_AI4M.csv`,仿真用 `opcua_nodes_AI4M_sim.csv`。 + +--- + +## 3. 通信基类架构 + +文件:`unilabos/devices/workstation/AI4M/base_opcua_client.py` + +整个通信层分两层: + +``` +BaseOpcUaClient # 最小可用:连接 + 节点注册 + 读写 + 方法调用 + ▲ + │ 继承 + │ +OpcUaClientWithSubscription # 生产可用:+ 订阅推送 + 缓存 + 自动重连 + ▲ + │ 继承 + │ +AI4MDevice # 业务驱动:在它之上写设备动作函数 +``` + +### 3.1 `BaseOpcUaClient` 核心能力 + +```python +class BaseOpcUaClient(UniversalDriver): + client: Optional[Client] = None + _node_registry: Dict[str, OpcUaNodeBase] = {} # name -> Variable/Method + _name_mapping: Dict[str, str] = {} # 英文名 -> 中文名 + _reverse_mapping: Dict[str, str] = {} # 中文名 -> 英文名 + _found_node_objects: Dict[str, Any] = {} # 缓存 ua.Node 用于订阅 + + @classmethod + def load_csv(cls, file_path) -> Tuple[List[OpcUaNode], dict, dict]: ... + def register_node_list(self, node_list) -> "BaseOpcUaClient": ... + def use_node(self, name) -> OpcUaNodeBase: ... + def read_node(self, node_name: str) -> str: ... # 返回 JSON + def write_node(self, json_input: str) -> str: ... + def call_method(self, node_name, *args) -> Tuple[Any, bool]: ... +``` + +它做的事情可以归纳为四步: + +1. **`load_csv`**:读取节点表,建立 `Name ↔ EnglishName` 双向映射。 +2. **`register_node_list`**:把节点登记进 `_variables_to_find` 待查找列表。 +3. **`_connect` → `_find_nodes`**:连上 OPC UA 后,按 `NodeId` 把每个节点解析成 `Variable` / `Method` 对象,放进 `_node_registry`。 +4. **`use_node(name)`**:业务代码取节点的唯一入口,**支持中英文混用**,找不到会自动重试一次。 + +### 3.2 `OpcUaClientWithSubscription` 增强能力 + +在 `BaseOpcUaClient` 基础上提供三个生产环境必备的能力: + +#### a) 订阅缓存(高频读零开销) + +```python +def _setup_subscriptions(self): + self._subscription = self.client.create_subscription( + self._subscription_interval, # 默认 500ms + SubscriptionHandler(self), + ) + for node_name, node in self._node_registry.items(): + if node.type == NodeType.VARIABLE and node.node_id: + handle = self._subscription.subscribe_data_change(ua_node) + self._subscription_handles[node_name] = handle +``` + +当 PLC 侧变量变化时,`datachange_notification` 回调会把新值写进 `self._node_values[name]`, +后续 `get_node_value` 优先读缓存——**业务代码可以放心地写 `while not self.get_node_value(...): time.sleep(1)` 而不用担心 OPC UA 频繁请求**。 + +#### b) 智能缓存的 `get_node_value` + +```python +def get_node_value(self, name, use_cache=True, force_read=False): + # 1. 中英文名归一化 + chinese_name = self._name_mapping.get(name, name) + + # 2. force_read=True 强制透传到 OPC UA Server + if force_read: ... + + # 3. 命中订阅推送 → 直接返回缓存 + # 4. 命中按需读 + 未过期(cache_timeout=5s)→ 返回缓存 + # 5. 否则发起 read 并更新缓存 +``` + +#### c) 连接监控 + 自动重连 + +后台线程每 30s 调一次 `client.get_namespace_array()` 探活,断线则自动 `disconnect → connect → 重新订阅`,最多重试 5 次。 + +### 3.3 数据类型 / 节点类型 + +`unilabos/device_comms/opcua_client/node/uniopcua.py`: + +```python +class DataType(Enum): + BOOLEAN = VariantType.Boolean + INT16 = VariantType.Int16 + INT32 = VariantType.Int32 + FLOAT = VariantType.Float + STRING = VariantType.String + # ... + +class NodeType(Enum): + VARIABLE = NodeClass.Variable + METHOD = NodeClass.Method + OBJECT = NodeClass.Object +``` + +`Variable.write()` 内部会按 `DataType` 做强制类型转换, +所以 CSV 里的 `DataType` 列就是"PC 端转换写入值的类型说明书"。 + +--- + +## 4. 编写设备驱动:以 `AI4MDevice` 为例 + +文件:`unilabos/devices/workstation/AI4M/AI4M.py` + +### 4.1 继承通信基类,最小骨架 + +```python +from typing import Optional +from unilabos.devices.workstation.AI4M.base_opcua_client import OpcUaClientWithSubscription + +class AI4MDevice(OpcUaClientWithSubscription): + def __init__( + self, + url: str, # opc.tcp://192.168.1.10:4840 + deck: Optional[AI4M_deck] = None, # 物料台面(资源树) + csv_path: str = None, # 节点表 CSV + username: str = None, + password: str = None, + use_subscription: bool = True, + cache_timeout: float = 5.0, + subscription_interval: int = 500, + *args, **kwargs, + ): + super().__init__( + url=url, username=username, password=password, + use_subscription=use_subscription, + cache_timeout=cache_timeout, + subscription_interval=subscription_interval, + *args, **kwargs, + ) + + # 物料台面初始化(见教程 4. 物料系统) + self.deck = deck or AI4M_deck(setup=True) + self._robot_lock = threading.Lock() + + # 关键:加载节点表 + if csv_path: + self.load_nodes_from_csv(csv_path) +``` + +`load_nodes_from_csv` 会一次性完成:解析 CSV → 注册节点 → 解析 NodeId → 建立订阅, +**之后整个驱动都通过 `self.get_node_value(name)` / `self.set_node_value(name, value)` 操作 PLC**。 + +### 4.2 PLC 通信的核心模式:握手协议(Handshake) + +PLC 编程的本质是"扫描周期 + 状态机",PC 端**绝对不能用 fire-and-forget 的方式发指令**。 +和 PLC 配合的标准模式是 **"PC 写指令 → PC 等待 PLC 回执 → PC 复位指令"**。 + +AI4M 中所有 `trigger_*` 函数都遵循以下三种握手范式之一: + +#### 范式 A:脉冲触发 + 完成信号(最常用) + +```python +def trigger_init(self) -> dict: + # ① 复位上一轮残留 + self.set_node_value("alarm_reset", True); time.sleep(1.0) + self.set_node_value("alarm_reset", False) + self.set_node_value("manual_auto_switch", False) + + # ② 等待 PLC 退出自动模式 + while self.get_node_value("auto_mode"): + time.sleep(1.0) + + # ③ 发起初始化脉冲(True → False) + self.set_node_value("initialize", True); time.sleep(1.0) + self.set_node_value("initialize", False) + + # ④ 等待 PLC 给出完成信号 + while not self.get_node_value("init finished"): + time.sleep(1.0) + + return {"message": "设备初始化完成"} +``` + +要点: +- **"PC 写一个 BOOL 拉高再拉低"** 模拟脉冲,PLC 用上升沿触发动作。 +- **`get_node_value` 要在 while 循环里轮询**,配合订阅缓存基本无压力。 +- **每个动作必须有"开始"和"完成"两个独立的 BOOL 节点**,不能复用。 + +#### 范式 B:参数下发 + 请求/已执行/完成 三步握手(带数据的工艺) + +```python +def trigger_station_process(self, station_id: int, mag_stir_speed: int, ...): + request_node = f"station_{station_id}_request_params" + params_received_node = f"station_{station_id}_params_received" + start_node = f"station_{station_id}_start" + complete_node = f"station_{station_id}_process_complete" + + # ① PC 复位三个状态位(避免上一轮影响) + self._reset_station_process_flags(station_id) + + # ② 等 PLC 主动请求参数(PLC 准备好了才接收) + while not self.get_node_value(request_node): + time.sleep(1.0) + + # ③ PC 下发参数(注意:PLC 内部数组是 0-based,PC 暴露给用户是 1-based) + station_idx = station_id - 1 + self.set_node_value(f"mag_stirrer_c{station_idx}_stir_speed", mag_stir_speed) + self.set_node_value(f"mag_stirrer_c{station_idx}_heat_temp", mag_stir_heat_temp) + self.set_node_value(f"mag_stirrer_c{station_idx}_time_set", mag_stir_time_set) + self.set_node_value(f"syringe_pump_{station_idx}_abs_position_set", syringe_pump_abs_pos) + + # ④ PC 通知 PLC "参数已就绪",等 PLC 回复"已执行" + self.set_node_value(start_node, True) + while not self.get_node_value(params_received_node): + time.sleep(1.0) + + # ⑤ 等 PLC 完成整个工艺 + while not self.get_node_value(complete_node): + time.sleep(5.0) + + self.set_node_value(start_node, False) # 复位,方便下一轮 + return {"station_id": station_id, "message": "..."} +``` + +四个状态位的语义: + +| 信号 | 方向 | 含义 | +|---|---|---| +| `station_X_request_params` | **PLC → PC** | "我准备好了,把参数给我" | +| `station_X_start` | **PC → PLC** | "参数我已经写好了,开干" | +| `station_X_params_received` | **PLC → PC** | "参数我已经吃下了" | +| `station_X_process_complete` | **PLC → PC** | "工艺已经做完" | + +**这是 PLC 通信教科书级别的标准范式**,所有带数据下发的动作都建议照抄。 + +#### 范式 C:编号下发 + 编号对应的完成信号(多目标互锁) + +```python +def trigger_robot_pick_beaker(self, pick_beaker_id: int, place_station_id: int = None, ...): + # ① 等机器人空闲(互锁) + while not self.get_node_value("robot_ready"): + time.sleep(1.0) + + # ② 阶段一:下发"取哪一杯"编号 + 等"取这一杯完成" + pick_complete_node = f"robot_rack_pick_beaker_{pick_beaker_id}_complete" + self.set_node_value("robot_pick_beaker_id", pick_beaker_id) + while not self.get_node_value(pick_complete_node): + time.sleep(1.0) + + # ③ 阶段二:下发"放到哪个工站"编号 + 等"放完成" + place_complete_node = f"robot_place_station_{place_station_id}_complete" + self._reset_station_process_flags(place_station_id) + self.set_node_value("robot_place_station_id", place_station_id) + while not self.get_node_value(place_complete_node): + time.sleep(1.0) +``` + +要点: +- **同一个动作的多个目标用"编号变量 + 编号对应的完成信号"实现**,不要每个目标都开一个开始位。 +- **配合 Python 端 `threading.Lock()` 做软互锁**,避免多个线程争抢机器人。 +- **每个阶段有独立的完成信号**,串行等待,不能合并。 + +### 4.3 一些容易踩坑的细节 + +1. **节点名映射** + `set_node_value("alarm_reset", True)` 实际写入的是 CSV 中文名 `报警复位`, + `get_node_value` 同理。**业务代码全部用 EnglishName**,不要直接用中文。 + +2. **PLC 数组索引和 PC 不一致** + AI4M 里 PC 暴露 `station_id ∈ {1, 2, 3}`,但 PLC 内部数组是 `C[0..2]`, + 驱动里要做 `station_idx = station_id - 1`,**这种映射只在驱动层做一次**, + 不要让上层(registry / 实验记录本)感知。 + +3. **订阅模式下 BOOL 节点的边沿同步** + 订阅有 ~500ms 延迟。如果你刚 `set_node_value(x, True)` 就立刻 `get_node_value(x)`, + 读到的可能还是 `False`(订阅还没推回来)。 + 解决方案:**写完后用 `force_read=True` 透传一次** 或加一段 `time.sleep`。 + +4. **永远不要忘记复位** + `start` 拉 True 后必须有地方拉回 False,否则下一轮 PLC 上升沿不触发。 + AI4M 在 `_reset_station_process_flags` 中统一做: + + ```python + def _reset_station_process_flags(self, station_id: int) -> None: + self.set_node_value(f"station_{station_id}_process_complete", False) + self.set_node_value(f"station_{station_id}_start", False) + self.set_node_value(f"station_{station_id}_params_received", False) + ``` + +5. **耗时长的等待 sleep 加大** + 工艺等待用 `time.sleep(5.0)`,机器人等待用 `time.sleep(1.0)`,初始化等待 `time.sleep(1.0)`, + 不要全部用 0.1s 轮询,会把日志刷爆。 + +--- + +## 5. 把驱动接到 Uni-Lab:Registry + Graph + +### 5.1 Registry YAML(动作 schema) + +文件:`unilabos/registry/devices/AI4M_station.yaml` + +```yaml +AI4M_station: + category: [AI4M_station] + class: + module: unilabos.devices.workstation.AI4M.AI4M:AI4MDevice # ← 入口类 + type: python + action_value_mappings: + auto-trigger_init: + schema: + description: 设备初始化... + properties: + goal: { properties: {}, required: [], type: object } + result: + properties: { message: { type: string } } + required: [message] + type: object + type: object + type: UniLabJsonCommand + + auto-trigger_station_process: + always_free: true + schema: + description: 执行检测工艺流程 + properties: + goal: + properties: + station_id: { type: integer, description: 检测编号 1-3 } + mag_stir_stir_speed: { type: integer } + mag_stir_heat_temp: { type: integer } + mag_stir_time_set: { type: integer } + syringe_pump_abs_position_set:{ type: integer } + required: [station_id, mag_stir_stir_speed, mag_stir_heat_temp, + mag_stir_time_set, syringe_pump_abs_position_set] + type: object + result: { ... } + type: UniLabJsonCommand + + init_param_schema: + config: + type: object + required: [url] + properties: + url: { type: string, description: OPC UA 服务器地址 } + csv_path: { type: string, description: 节点配置 CSV 路径 } + deck: { type: string, description: 资源树配置 } + username: { type: string } + password: { type: string } + use_subscription: { type: boolean, default: true } + cache_timeout: { type: number, default: 5.0 } + subscription_interval: { type: integer, default: 500 } +``` + +规则总结: +- `class.module` 指向驱动类(`module:ClassName`)。 +- `action_value_mappings` 中的 key 形如 `auto-<方法名>`,对应驱动里的同名 Python 方法。 +- `schema.goal` 自动转成 ROS2 Action 的 goal 消息,`schema.result` 转 result。 +- `init_param_schema.config` 对应 `__init__` 的入参,**所有需要现场改的参数都要列出来**(最重要的就是 `url` 和 `csv_path`)。 +- `always_free: true` 表示该动作不占用工站独占锁(多检测站可并发执行)。 + +### 5.2 Graph JSON(实例化) + +文件:`unilabos/devices/workstation/AI4M/AI4M.json` + +```json +{ + "nodes": [ + { + "id": "AI4M_station", + "name": "AI4M_station", + "type": "device", + "class": "AI4M_station", + "children": ["AI4M_deck"], + "parent": null, + "config": { + "url": "opc.tcp://192.168.1.10:4840", + "csv_path": "opcua_nodes_AI4M.csv", + "deck": { + "data": { + "_resource_child_name": "AI4M_deck", + "_resource_type": "unilabos.devices.workstation.AI4M.decks:AI4M_deck" + } + } + } + }, + { + "id": "AI4M_deck", + "type": "deck", + "class": "AI4M_deck", + "parent": "AI4M_station", + "config": { "type": "AI4M_deck" } + } + ] +} +``` + +要点: +- `class` 必须和 Registry YAML 的顶层 key 完全一致(`AI4M_station`)。 +- `config` 字段**逐字传给驱动 `__init__`**,所以 Graph JSON = "现场参数表"。 +- 多套相同设备时拷贝一份,把 `id` / `url` 改掉即可(参考 `AI4M002_station`)。 + +### 5.3 启动命令(来自 `start.md`) + +```cmd +# 真机 +python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4M.json ` + --ak --sk --upload_registry --addr --disable_browser + +# 仿真(KEPServerEX 跑在本机 49320 端口) +python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4Msim.json ` + --ak --sk --upload_registry --disable_browser +``` + +`--upload_registry` 会把 `AI4M_station.yaml` 的 schema 上传到云端, +之后实验记录本就能看到所有 `auto-*` 动作。 + +--- + +## 6. 调试方法 + +### 6.1 用 KEPServerEX 仿真 PLC + +不带 PLC 的开发机上,可以用 KEPServerEX(或 `python-opcua` 自建 server)模拟。 +AI4M 提供了一份仿真节点表 `opcua_nodes_AI4M_sim.csv`,**只改 NodeId 不改语义**, +所以驱动代码无需任何改动即可在本机调试。 + +### 6.2 单独跑驱动(不开 ROS) + +在驱动文件末尾的 `if __name__ == '__main__':` 段: + +```python +if __name__ == '__main__': + A4 = AI4MDevice( + url="opc.tcp://192.168.1.10:4840", + csv_path="opcua_nodes_AI4M.csv", + ) + A4.trigger_init() + print("初始化完成") + A4.trigger_robot_pick_beaker(1, 1) +``` + +**新动作上线前一定要在这里裸跑一遍**,确认握手时序正确,再往上接 ROS。 + +### 6.3 看日志判断卡在哪 + +`base_opcua_client.py` 的日志已经覆盖了所有关键节点: + +``` +✓ 客户端已连接! +✓ 找到变量节点: 'robot_ready', NodeId: ns=4;s=... +✓ 已订阅节点: robot_ready +✓ 节点查找完成:所有 142 个节点均已找到 +``` + +如果看到 `⚠ 以下 N 个节点未找到`,**99% 是 CSV 里的 NodeId 写错了**,回去对一下 PLC 工程导出的 NodeId。 + +### 6.4 检查节点是否能直接读写 + +```python +# 透传读,绕过订阅缓存 +A4.get_node_value("robot_ready", force_read=True) + +# 直接读 JSON 形式(适合从 HTTP/调试面板调) +A4.read_node("robot_ready") + +# 写 +A4.set_node_value("alarm_reset", True) +A4.write_node('{"node_name": "alarm_reset", "value": false}') +``` + +--- + +## 7. 接入新 PLC 设备的 Checklist + +接到一台新工站时,按下面顺序做就能保证不漏: + +- [ ] 1. 让 PLC 工程师把上位通讯变量整理到 OPC UA Server,导出 NodeId 清单。 +- [ ] 2. 在 `unilabos/devices/workstation/<设备名>/` 下新建目录,复制 `AI4M/base_opcua_client.py` 不动。 +- [ ] 3. 整理 `opcua_nodes_<设备名>.csv`,6 列填齐,并补上 `EnglishName`。 +- [ ] 4. 在该目录写设备驱动 `<设备名>.py`,继承 `OpcUaClientWithSubscription`: + - [ ] `__init__` 调用 `super().__init__` + `self.load_nodes_from_csv(csv_path)`。 + - [ ] 每个动作函数用范式 A/B/C 写握手协议。 + - [ ] 每个动作函数都返回 `dict`,至少含 `message` 字段。 +- [ ] 5. 在 `unilabos/registry/devices/` 下新建 `<设备名>_station.yaml`,配置 `init_param_schema` 和 `action_value_mappings`。 +- [ ] 6. 在该目录新建 `<设备名>.json`(Graph),填好 `url` 和 `csv_path`。 +- [ ] 7. 用 `if __name__ == '__main__':` 单独跑驱动确认握手 OK。 +- [ ] 8. 用 `python unilabos/app/main.py -g --upload_registry ...` 上线,到实验记录本下发动作回归。 + +--- + +## 8. 参考实现速查 + +| 关注点 | 在 AI4M 中看哪里 | +|---|---| +| OPC UA 通信基类 | `base_opcua_client.py` | +| 节点定义类型系统 | `unilabos/device_comms/opcua_client/node/uniopcua.py` | +| 节点表 CSV 标准 | `opcua_nodes_AI4M.csv` | +| 设备驱动入口类 | `AI4M.py: AI4MDevice` | +| 握手范式 A(脉冲+完成) | `AI4M.py: trigger_init` | +| 握手范式 B(请求/参数/完成) | `AI4M.py: trigger_station_process` | +| 握手范式 C(编号+完成) | `AI4M.py: trigger_robot_pick_beaker` | +| 自动模式批量参数下发 | `AI4M.py: download_auto_params` | +| Registry schema | `unilabos/registry/devices/AI4M_station.yaml` | +| Graph 实例化 | `AI4M.json` / `AI4Msim.json` | +| 启动命令 | `start.md` | diff --git a/docs/developer_guide/add_device.md b/docs/developer_guide/add_device.md index 15ba4e087..c2c6d293a 100644 --- a/docs/developer_guide/add_device.md +++ b/docs/developer_guide/add_device.md @@ -17,7 +17,12 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用, ```python from unilabos.registry.decorators import device, topic_config -@device(id="mock_gripper", category=["gripper"], description="Mock Gripper") +@device( + id="mock_gripper", + category=["gripper"], + description="Mock Gripper", + displayname="模拟夹爪", +) class MockGripper: def __init__(self): self._position: float = 0.0 @@ -188,7 +193,12 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构: from typing import Dict, Any from unilabos.registry.decorators import device, topic_config -@device(id="my_device", category=["general"], description="My Device") +@device( + id="my_device", + category=["general"], + description="My Device", + displayname="我的设备", +) class MyDevice: """设备类文档字符串 @@ -929,14 +939,21 @@ class MyDevice: ```python from unilabos.registry.decorators import device -@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp") +@device( + id="my_heating_device", + category=["heating"], + description="My Heating Device", + displayname="加热设备", + icon="heater.webp", +) class MyDevice: ... ``` -- `id`:设备唯一标识符,用于注册表匹配 +- `id`:设备唯一标识符,用于注册表匹配;只能包含英文大小写字母、数字、下划线,不能包含中文、空格、短横线、点号或其他符号 - `category`:分类列表,前端用于分组显示 - `description`:设备描述 +- `displayname`:设备显示名称,用于 UI 展示中文名或更友好的名称;不要把显示名写进 `id` - `icon`:图标文件名(可选) ### 2. 使用 `@topic_config` 声明需要广播的状态 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/recipes/conda_build_config.yaml b/recipes/conda_build_config.yaml index 8e95491c8..c8915207a 100644 --- a/recipes/conda_build_config.yaml +++ b/recipes/conda_build_config.yaml @@ -1,5 +1,5 @@ channel_sources: - - robostack,robostack-staging,conda-forge,defaults + - robostack,robostack-staging,conda-forge gazebo: - '11' diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 0a59a2e97..f821c118d 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.11.1 + version: 0.11.3 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index f54f1eb75..18c724f1b 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.11.1" + version: "0.11.3" source: path: ../.. diff --git a/setup.py b/setup.py index 4053388ea..8ada8c207 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name=package_name, - version='0.11.1', + version='0.11.3', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index fee46bd8c..1bebb74e8 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.11.1" +__version__ = "0.11.3" diff --git a/unilabos/app/community_packages.py b/unilabos/app/community_packages.py new file mode 100644 index 000000000..b8139af1c --- /dev/null +++ b/unilabos/app/community_packages.py @@ -0,0 +1,370 @@ +import hashlib +import json +import shutil +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from unilabos.utils import logger +from unilabos.utils.banner_print import print_status + + +COMMUNITY_PREFIX = "community." +COMMUNITY_CACHE_DIR = "community_devices" +MANIFEST_FILENAME = "manifest.json" + + +class CommunityPackageError(RuntimeError): + """Raised when a graph references community packages that cannot be loaded.""" + + +@dataclass +class CommunityPackagePrepareResult: + devices_dirs: List[str] = field(default_factory=list) + aliases: Dict[str, str] = field(default_factory=dict) + classes: List[str] = field(default_factory=list) + + +def extract_community_classes(graph_data: Optional[Dict[str, Any]]) -> List[str]: + if not graph_data: + return [] + + result: List[str] = [] + for node in graph_data.get("nodes", []): + if not isinstance(node, dict): + continue + class_name = node.get("class") + if isinstance(class_name, str) and class_name.startswith(COMMUNITY_PREFIX): + result.append(class_name) + return sorted(set(result)) + + +def community_namespace(class_name: str) -> str: + parts = class_name.split(".") + if len(parts) < 2 or parts[0] != "community": + raise ValueError(f"Invalid community class: {class_name}") + return ".".join(parts[:2]) + + +def infer_alias_target(class_name: str) -> str: + namespace = community_namespace(class_name) + prefix = namespace + "." + if class_name.startswith(prefix) and len(class_name) > len(prefix): + return class_name[len(prefix):] + return class_name.rsplit(".", 1)[-1] + + +def load_manifest(working_dir: str | Path) -> Dict[str, Any]: + manifest_path = _manifest_path(working_dir) + if not manifest_path.is_file(): + return {"packages": {}} + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + data.setdefault("packages", {}) + return data + except Exception as exc: + logger.warning(f"[CommunityPackage] manifest 读取失败: {exc}") + return {"packages": {}} + + +def save_manifest(working_dir: str | Path, manifest: Dict[str, Any]) -> None: + manifest_path = _manifest_path(working_dir) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + tmp = manifest_path.with_suffix(".tmp") + tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(manifest_path) + + +def prepare_community_packages( + graph_data: Optional[Dict[str, Any]], + working_dir: str | Path, + http_client: Any = None, +) -> CommunityPackagePrepareResult: + classes = extract_community_classes(graph_data) + if not classes: + return CommunityPackagePrepareResult() + + print_status(f"发现 community 设备引用: {', '.join(classes)}", "info") + manifest = load_manifest(working_dir) + packages = manifest.setdefault("packages", {}) + remote_items = _resolve_remote_packages(classes, manifest, http_client) + + devices_dirs: List[str] = [] + aliases: Dict[str, str] = {} + missing_namespaces = {community_namespace(class_name) for class_name in classes} + + for item in remote_items: + package_dir = _ensure_remote_item_cached(item, working_dir, manifest, http_client=http_client) + if package_dir: + devices_dirs.append(str(package_dir)) + + namespace = item.get("class_namespace") or (item.get("package_info") or {}).get("class_namespace") + if namespace: + missing_namespaces.discard(namespace) + aliases.update(_normalize_aliases(item, classes)) + + for namespace in list(missing_namespaces): + cached = packages.get(namespace) + if not cached: + continue + package_dir = Path(cached.get("package_dir", "")) + if package_dir.is_dir(): + devices_dirs.append(str(package_dir)) + missing_namespaces.discard(namespace) + cached_aliases = cached.get("aliases") or {} + aliases.update({str(k): str(v) for k, v in cached_aliases.items()}) + + for class_name in classes: + aliases.setdefault(class_name, infer_alias_target(class_name)) + + if missing_namespaces: + raise CommunityPackageError( + "无法加载 community 设备包: " + + ", ".join(sorted(missing_namespaces)) + + "。请检查网络、后端 resolve 接口或本地缓存。" + ) + + devices_dirs = _dedupe_existing_dirs(devices_dirs) + if devices_dirs: + print_status(f"community 设备包挂载目录: {', '.join(devices_dirs)}", "info") + + save_manifest(working_dir, manifest) + return CommunityPackagePrepareResult(devices_dirs=devices_dirs, aliases=aliases, classes=classes) + + +def apply_community_aliases(registry: Any, aliases: Dict[str, str]) -> None: + if not aliases: + return + + added: List[str] = [] + for alias, target in aliases.items(): + if alias in registry.device_type_registry or alias in registry.resource_type_registry: + continue + if target in registry.device_type_registry: + registry.device_type_registry[alias] = registry.device_type_registry[target] + added.append(alias) + elif target in registry.resource_type_registry: + registry.resource_type_registry[alias] = registry.resource_type_registry[target] + added.append(alias) + else: + logger.warning(f"[CommunityPackage] alias 目标不存在: {alias} -> {target}") + + if added: + print_status(f"已注册 community class alias: {', '.join(sorted(added))}", "info") + + +def _resolve_remote_packages(classes: List[str], manifest: Dict[str, Any], http_client: Any) -> List[Dict[str, Any]]: + if http_client is None: + return [] + try: + current_packages = [] + for namespace, info in (manifest.get("packages") or {}).items(): + current_packages.append( + { + "class_namespace": namespace, + "version": info.get("version"), + "sha256": info.get("sha256"), + } + ) + + response = http_client.resolve_community_packages(classes, current_packages=current_packages) + data = response.get("data", response) if isinstance(response, dict) else [] + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + except Exception as exc: + logger.warning(f"[CommunityPackage] 远端 resolve 失败,将尝试本地缓存: {exc}") + return [] + + +def _ensure_remote_item_cached( + item: Dict[str, Any], + working_dir: str | Path, + manifest: Dict[str, Any], + http_client: Any = None, +) -> Optional[Path]: + package_info = item.get("package_info") or item + namespace = item.get("class_namespace") or package_info.get("class_namespace") + if not namespace: + return None + + packages = manifest.setdefault("packages", {}) + cached = packages.get(namespace) or {} + version = str(package_info.get("version") or cached.get("version") or "unknown") + sha256 = str(package_info.get("sha256") or cached.get("sha256") or "") + cached_dir = Path(cached.get("package_dir", "")) + if cached_dir.is_dir() and cached.get("version") == version and cached.get("sha256", "") == sha256: + return cached_dir + + download_url = package_info.get("download_url") + if not download_url: + if cached_dir.is_dir() and package_info.get("allow_cached_fallback"): + logger.warning(f"[CommunityPackage] {namespace} 无下载地址,使用旧缓存") + return cached_dir + raise CommunityPackageError(f"community package {namespace} 缺少 download_url") + + package_dir = _download_and_extract_package(download_url, working_dir, namespace, version, sha256, http_client) + pyproject = _find_pyproject(package_dir) + pyproject_meta = read_pyproject_metadata(pyproject) + aliases = _normalize_aliases(item, []) + + packages[namespace] = { + "class_namespace": namespace, + "version": version, + "sha256": sha256, + "download_url": download_url, + "package_dir": str(package_dir), + "pyproject": pyproject_meta, + "aliases": aliases, + } + (package_dir / "package_info.json").write_text( + json.dumps(package_info, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return package_dir + + +def _download_and_extract_package( + download_url: str, + working_dir: str | Path, + namespace: str, + version: str, + expected_sha256: str = "", + http_client: Any = None, +) -> Path: + import requests + + normalized = _normalize_package_dir_name(namespace) + target_root = Path(working_dir) / COMMUNITY_CACHE_DIR / normalized / version + package_dir = target_root / "package" + tmp_root = Path(tempfile.mkdtemp(prefix=f"{normalized}-{version}-", dir=str(_cache_root(working_dir)))) + archive_path = tmp_root / "package.archive" + + try: + print_status(f"下载 community 设备包 {namespace}@{version}", "info") + requester = getattr(http_client, "_session", None) or requests + with requester.get(download_url, stream=True, timeout=(5, 120)) as response: + response.raise_for_status() + with archive_path.open("wb") as f: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + + if expected_sha256: + actual = "sha256:" + _sha256_file(archive_path) + if actual != expected_sha256: + raise CommunityPackageError(f"{namespace}@{version} sha256 不匹配: {actual} != {expected_sha256}") + + extract_root = tmp_root / "extract" + extract_root.mkdir(parents=True, exist_ok=True) + _extract_archive(archive_path, extract_root) + pyproject = _find_pyproject(extract_root) + source_root = pyproject.parent + + if target_root.exists(): + shutil.rmtree(target_root) + target_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_root, package_dir) + return package_dir + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + +def _normalize_aliases(item: Dict[str, Any], classes: Iterable[str]) -> Dict[str, str]: + raw_aliases = item.get("aliases") or {} + aliases = {str(k): str(v) for k, v in raw_aliases.items()} if isinstance(raw_aliases, dict) else {} + + namespace = item.get("class_namespace") or (item.get("package_info") or {}).get("class_namespace") + if namespace: + for class_name in classes: + if class_name.startswith(namespace + "."): + aliases.setdefault(class_name, infer_alias_target(class_name)) + return aliases + + +def read_pyproject_metadata(pyproject_path: Path) -> Dict[str, str]: + text = pyproject_path.read_text(encoding="utf-8") + result: Dict[str, str] = {} + in_project = False + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_project = line == "[project]" + continue + if not in_project or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key in {"name", "version"}: + result[key] = value + return result + + +def _manifest_path(working_dir: str | Path) -> Path: + return _cache_root(working_dir) / MANIFEST_FILENAME + + +def _cache_root(working_dir: str | Path) -> Path: + root = Path(working_dir) / COMMUNITY_CACHE_DIR + root.mkdir(parents=True, exist_ok=True) + return root + + +def _normalize_package_dir_name(namespace: str) -> str: + return namespace.replace(COMMUNITY_PREFIX, "", 1).replace(".", "-").replace("_", "-") + + +def _dedupe_existing_dirs(paths: Iterable[str]) -> List[str]: + result: List[str] = [] + seen = set() + for path in paths: + resolved = str(Path(path).resolve()) + if resolved in seen or not Path(resolved).is_dir(): + continue + seen.add(resolved) + result.append(resolved) + return result + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _extract_archive(archive_path: Path, target_dir: Path) -> None: + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path) as zf: + for member in zf.namelist(): + _assert_safe_archive_member(target_dir, member) + zf.extractall(target_dir) + return + if tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path) as tf: + for member in tf.getmembers(): + _assert_safe_archive_member(target_dir, member.name) + tf.extractall(target_dir) + return + raise CommunityPackageError("community package 只支持 zip/tar/tar.gz 格式") + + +def _assert_safe_archive_member(target_dir: Path, member_name: str) -> None: + target_root = target_dir.resolve() + target_path = (target_dir / member_name).resolve() + if target_root != target_path and target_root not in target_path.parents: + raise CommunityPackageError(f"community package 包含非法路径: {member_name}") + + +def _find_pyproject(root: Path) -> Path: + candidates = sorted(root.rglob("pyproject.toml")) + if not candidates: + raise CommunityPackageError(f"community package 解压后未找到 pyproject.toml: {root}") + return candidates[0] diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 8de9a75ff..682df84d1 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -1,5 +1,6 @@ import argparse import asyncio +import json import os import platform import shutil @@ -243,7 +244,16 @@ def parse_args(): "--addr", type=str, default="https://leap-lab.bohrium.com/api/v1", - help="Laboratory backend address", + help="Laboratory backend address (API)", + ) + parser.add_argument( + "--schedule_addr", + type=str, + default="", + help=( + "Schedule WebSocket address. If empty, derived from --addr: " + "port +1 when --addr has a port, otherwise the same host is used." + ), ) parser.add_argument( "--skip_env_check", @@ -339,6 +349,29 @@ def parse_args(): return parser +def _resolve_graph_file_path(file_path: str | None) -> str | None: + if file_path is None: + return None + if os.path.isfile(file_path): + return file_path + temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) + if os.path.isfile(temp_file_path): + print_status(f"使用相对路径{temp_file_path}", "info") + return temp_file_path + return file_path + + +def _load_graph_json_preview(file_path: str | None) -> Dict[str, Any] | None: + if not file_path or not file_path.endswith(".json") or not os.path.isfile(file_path): + return None + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f) + except Exception as exc: + print_status(f"预读取 graph JSON 失败,跳过 community 包解析: {exc}", "warning") + return None + + def main(): """主函数""" # 解析命令行参数 @@ -436,7 +469,7 @@ def main(): load_config_from_file(config_path) # 根据配置重新设置日志级别 - from unilabos.utils.log import configure_logger, logger + from unilabos.utils.log import configure_logger, configure_comm_logger, logger if hasattr(BasicConfig, "log_level"): logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.") @@ -444,6 +477,11 @@ def main(): if file_path is not None: logger.info(f"[LOG_FILE] {file_path}") + # 为服务端通信(WebSocket)配置独立日志,避免与主日志混在一起,便于排查通信机制 + comm_log_path = configure_comm_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) + if comm_log_path is not None: + logger.info(f"[COMM_LOG_FILE] {comm_log_path}") + if args.addr != parser.get_default("addr"): if args.addr == "test": print_status("使用测试环境地址", "info") @@ -457,6 +495,11 @@ def main(): else: HTTPConfig.remote_addr = args.addr + # schedule 通道地址:显式指定则直接使用,否则在连接时从 remote_addr 派生 + if args_dict.get("schedule_addr", ""): + HTTPConfig.schedule_addr = args_dict["schedule_addr"] + print_status(f"使用独立 schedule 地址: {HTTPConfig.schedule_addr}", "info") + # 设置BasicConfig参数 if args_dict.get("ak", ""): BasicConfig.ak = args_dict.get("ak", "") @@ -504,6 +547,52 @@ def main(): # 显示启动横幅 print_unilab_banner(args_dict) + # Step -1: 预读取 graph 中的 community.* class,并在 build_registry 前挂载社区设备包 + if not check_mode and not workflow_upload: + startup_json_preview = None + graph_file_path = _resolve_graph_file_path(args_dict.get("graph") or BasicConfig.startup_json_path) + args_dict["_graph_file_path"] = graph_file_path + graph_preview = _load_graph_json_preview(graph_file_path) + + http_client_for_community = None + if BasicConfig.ak and BasicConfig.sk: + from unilabos.app.web import http_client as _http_client_for_community + + http_client_for_community = _http_client_for_community + if graph_preview is None and graph_file_path is None: + startup_json_preview = http_client_for_community.request_startup_json() + args_dict["_startup_json"] = startup_json_preview + graph_preview = startup_json_preview + + if graph_preview: + from unilabos.app.community_packages import ( + CommunityPackageError, + apply_community_aliases, + prepare_community_packages, + ) + + try: + community_result = prepare_community_packages( + graph_preview, + working_dir=BasicConfig.working_dir, + http_client=http_client_for_community, + ) + except CommunityPackageError as exc: + print_status(str(exc), "error") + os._exit(1) + + if community_result.devices_dirs: + existing_devices_dirs = args_dict.get("devices") or [] + args_dict["devices"] = existing_devices_dirs + community_result.devices_dirs + if not skip_env_check: + from unilabos.utils.environment_check import check_device_package_requirements + + if not check_device_package_requirements(args_dict["devices"]): + print_status("community 设备包依赖检查失败,程序退出", "error") + os._exit(1) + args_dict["_community_aliases"] = community_result.aliases + args_dict["_apply_community_aliases"] = apply_community_aliases + # Step 0: AST 分析优先 + YAML 注册表加载 # check_mode 和 upload_registry 都会执行实际 import 验证 devices_dirs = args_dict.get("devices", None) @@ -517,6 +606,9 @@ def main(): complete_registry=complete_registry, external_only=external_only, ) + apply_community_aliases = args_dict.get("_apply_community_aliases") + if apply_community_aliases: + apply_community_aliases(lab_registry, args_dict.get("_community_aliases") or {}) # Check mode: 注册表验证完成后直接退出 if check_mode: @@ -567,9 +659,13 @@ def main(): graph: nx.Graph resource_tree_set: ResourceTreeSet resource_links: List[Dict[str, Any]] - request_startup_json = http_client.request_startup_json() + request_startup_json = args_dict.get("_startup_json") + if request_startup_json is None: + request_startup_json = http_client.request_startup_json() - file_path = args_dict.get("graph", BasicConfig.startup_json_path) + file_path = args_dict.get("_graph_file_path") + if file_path is None: + file_path = _resolve_graph_file_path(args_dict.get("graph") or BasicConfig.startup_json_path) if file_path is None: if not request_startup_json: print_status( @@ -580,11 +676,6 @@ def main(): print_status("联网获取设备加载文件成功", "info") graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json) else: - if not os.path.isfile(file_path): - temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) - if os.path.isfile(temp_file_path): - print_status(f"使用相对路径{temp_file_path}", "info") - file_path = temp_file_path if file_path.endswith(".json"): graph, resource_tree_set, resource_links = read_node_link_json(file_path) else: diff --git a/unilabos/app/model.py b/unilabos/app/model.py index f80ce35a0..3a031aaaf 100644 --- a/unilabos/app/model.py +++ b/unilabos/app/model.py @@ -59,6 +59,7 @@ class JobAddReq(BaseModel): task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") node_id: str = Field(examples=["node_id"], description="node uuid", default="") + notebook_id: str = Field(examples=["notebook_id"], description="notebook uuid", default="") server_info: dict = Field( examples=[{"send_timestamp": 1717000000.0}], description="server info (auto-generated if empty)", diff --git a/unilabos/app/utils.py b/unilabos/app/utils.py index f6114a13c..a225e3ae3 100644 --- a/unilabos/app/utils.py +++ b/unilabos/app/utils.py @@ -10,29 +10,170 @@ import sys +_PATCH_MARKER = "# UniLabOS DLL Patch" +_PATCH_END_MARKER = "# End UniLabOS DLL Patch" + +# 75 = EX_TEMPFAIL: 临时失败、重试即可,避免与业务退出码冲突 +_RESTART_EXIT_CODE = 75 + + +def _build_dll_patch(lib_bin: str, preload_pyd: str = "") -> str: + """生成一段加在目标文件顶部的 DLL 加载补丁源码。 + + - 始终把 ``lib_bin`` 加入 DLL 搜索路径,并把 handle 挂在模块属性上, + 防止 GC 清掉搜索路径(``os.add_dll_directory`` 的句柄被回收时 + 目录会被移除)。 + - 可选地用 ``ctypes.CDLL`` 预加载一个 .pyd,把它的依赖 DLL 提前装入 + 进程内存,作为 ``rclpy._rclpy_pybind11`` 这类首次加载点的兜底。 + """ + # 用 repr() 序列化路径:Python 解析 repr 的结果会还原成原始字符串, + # 不需要也不能再叠加 raw-string 前缀(叠了反而会让 \\ 变成两个反斜杠)。 + lines = [ + _PATCH_MARKER, + "import os as _ulab_os", + f"_ulab_p = {lib_bin!r}", + 'if hasattr(_ulab_os, "add_dll_directory") and _ulab_os.path.isdir(_ulab_p):', + " try: _UNILAB_DLL_HANDLE = _ulab_os.add_dll_directory(_ulab_p)", + " except Exception: _UNILAB_DLL_HANDLE = None", + ] + if preload_pyd: + lines.extend( + [ + "import ctypes as _ulab_ctypes", + f"try: _ulab_ctypes.CDLL({preload_pyd!r})", + "except Exception: pass", + ] + ) + lines.append(_PATCH_END_MARKER) + return "\n".join(lines) + "\n" + + +def _apply_dll_patch(file_path: str, lib_bin: str, preload_pyd: str = "") -> bool: + """把 DLL 补丁前置到 ``file_path``。文件不存在或已打过补丁则返回 False。""" + if not os.path.isfile(file_path): + return False + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + if _PATCH_MARKER in content: + return False + shutil.copy2(file_path, file_path + ".bak") + with open(file_path, "w", encoding="utf-8") as f: + f.write(_build_dll_patch(lib_bin, preload_pyd) + content) + return True + + +def _print_restart_banner(patched_files): + """打印重启提示并以 EX_TEMPFAIL 退出。 + + - 不使用 ANSI 颜色码:Windows 旧版 cmd / PowerShell 5 默认不开 VT 处理, + 会把 ``\\033[1;33m`` 当做字面字符显示,反而让用户看不到正文。 + - 同时写入 stderr 与 stdout:某些上层 launcher / supervisor 只重定向 + 其中一路,写两遍能保证用户至少看到一份。 + - 写入前防御性把流切到 UTF-8 with replace:``main.py`` 里已经做过一次, + 但本模块也可能被绕过 ``main.py`` 的代码路径直接 import;reconfigure + 失败也只是退回 errors=replace,不影响整体流程。 + """ + if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + + bar = "#" * 78 + files_lines = [f"[UniLabOS] - {p}" for p in patched_files] + body = "\n".join( + [ + "", + bar, + bar, + "##", + "## [UniLabOS] Windows + conda 下检测到 DLL 加载失败,已自动打补丁。", + "## [UniLabOS] DLL load failure detected on Windows + conda;", + "## [UniLabOS] the following files have been auto-patched:", + "##", + *[f"## {line}" for line in files_lines], + "##", + "## [UniLabOS] 当前进程的 rclpy 状态已损坏,补丁需要在新进程才生效。", + "## [UniLabOS] The current process is unusable; the patch only takes", + "## [UniLabOS] effect on a fresh process.", + "##", + "## >>> 请重新运行刚才的命令 / Please re-run the same command. <<<", + "##", + bar, + bar, + "", + ] + ) + + for stream in (sys.stderr, sys.stdout): + try: + stream.write(body) + stream.flush() + except Exception: + try: + print(body, file=stream) + except Exception: + pass + + sys.exit(_RESTART_EXIT_CODE) + + def patch_rclpy_dll_windows(): - """在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁""" + """在 Windows + conda 环境下修复 rclpy / rosidl typesupport 的 DLL 加载。 + + 背景:conda 安装的 ros 系列包,其原生扩展依赖 ``$CONDA_PREFIX/Library/bin`` + 下的 DLL;只有 conda 环境被正确激活、且 PATH 中含 ``Library/bin`` 时, + ``os.add_dll_directory`` 才能找到它们。当从快捷方式 / IDE / 子进程 / + 没激活的 shell 启动 ``unilab`` 时,会出现 ``DLL load failed``。 + + 本函数会: + 1) 修补 ``rclpy/impl/implementation_singleton.py`` —— rclpy 自身的 C 扩展入口; + 2) 修补 ``rpyutils/add_dll_directories.py`` —— 所有 ``*_s__rosidl_typesupport_c.pyd`` + (``geometry_msgs`` / ``std_msgs`` / ``sensor_msgs`` 等)的统一加载入口。 + + 打完补丁后**必须重启进程**才能生效(当前进程的 rclpy 已经发生过 + ``ImportError``,子模块仍处于损坏状态)。因此函数会主动退出,并在 + stdout/stderr 同时打印明显的重启提示,避免用户被后续报错淹没。 + """ if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): return + try: - import rclpy + import rclpy # noqa: F401 return except ImportError as e: if not str(e).startswith("DLL load failed"): return + cp = os.environ["CONDA_PREFIX"] - impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") - pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) - if not os.path.exists(impl) or not pyd: + lib_bin = os.path.join(cp, "Library", "bin") + site_packages = os.path.join(cp, "Lib", "site-packages") + if not os.path.isdir(lib_bin): return - with open(impl, "r", encoding="utf-8") as f: - content = f.read() - lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/") - patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' - shutil.copy2(impl, impl + ".bak") - with open(impl, "w", encoding="utf-8") as f: - f.write(patch + content) + + patched = [] + + # 1) rclpy 自身的入口 + rclpy_impl = os.path.join(site_packages, "rclpy", "impl", "implementation_singleton.py") + rclpy_pyd_matches = glob.glob(os.path.join(site_packages, "rclpy", "_rclpy_pybind11*.pyd")) + rclpy_pyd = rclpy_pyd_matches[0] if rclpy_pyd_matches else "" + if rclpy_pyd and _apply_dll_patch(rclpy_impl, lib_bin, preload_pyd=rclpy_pyd): + patched.append(rclpy_impl) + + # 2) rpyutils —— 所有 rosidl typesupport pyd 的加载点;放在 rclpy 之后 + # 例:geometry_msgs/geometry_msgs_s__rosidl_typesupport_c.pyd + rpyutils_dll = os.path.join(site_packages, "rpyutils", "add_dll_directories.py") + if _apply_dll_patch(rpyutils_dll, lib_bin): + patched.append(rpyutils_dll) + + if not patched: + # 已经打过补丁但 rclpy 仍然加载失败:原因不是缺 DLL 搜索路径, + # 不要再次打补丁污染文件,让上层看到真实的 ImportError。 + return + + _print_restart_banner(patched) patch_rclpy_dll_windows() diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 527b813ed..e9846bb3c 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -376,6 +376,34 @@ def request_startup_json(self) -> Optional[Dict[str, Any]]: logger.error(f"响应内容: {response.text}") return None + def resolve_community_packages( + self, + classes: List[str], + current_packages: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """ + 根据 graph 中的 community.* class 解析需要加载的社区设备包。 + """ + payload = { + "classes": classes, + "machine_name": BasicConfig.machine_name, + "current_packages": current_packages or [], + } + req_path = os.path.join(BasicConfig.working_dir, "req_community_package_resolve.json") + with open(req_path, "w", encoding="utf-8") as f: + f.write(json.dumps(payload, ensure_ascii=False, indent=4)) + response = self._session.post( + f"{self.remote_addr}/lab/square/community-packages/resolve", + json=payload, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=(5, 30), + ) + res_path = os.path.join(BasicConfig.working_dir, "res_community_package_resolve.json") + with open(res_path, "w", encoding="utf-8") as f: + f.write(f"{response.status_code}" + "\n" + response.text) + response.raise_for_status() + return response.json() + def workflow_import( self, name: str, diff --git a/unilabos/app/web/controller.py b/unilabos/app/web/controller.py index 6a01645cd..147b4d207 100644 --- a/unilabos/app/web/controller.py +++ b/unilabos/app/web/controller.py @@ -320,6 +320,7 @@ def job_add(req: JobAddReq) -> JobData: action_name=action_name, task_id=task_id, job_id=job_id, + notebook_id=req.notebook_id, device_action_key=device_action_key, ) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 4823a2323..da39f5ce4 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -17,9 +17,10 @@ import traceback import websockets import ssl as ssl_module +import copy from queue import Queue, Empty from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Tuple from urllib.parse import urlparse from enum import Enum @@ -31,7 +32,12 @@ from unilabos.utils.type_check import serialize_result_info from unilabos.app.communication import BaseCommunicationClient from unilabos.config.config import WSConfig, HTTPConfig, BasicConfig -from unilabos.utils import logger +from unilabos.utils.log import get_comm_logger + +# 服务端通信专用 logger:独立成文件(unilabos_data/logs/ws_comm_*.log), +# 全量 TRACE 落本地、微秒级时间戳 + 线程名,便于排查通信/queue 时序问题。 +# 未调用 configure_comm_logger 时安全回退到根 logger。 +logger = get_comm_logger() def format_job_log(job_id: str, task_id: str = "", device_id: str = "", action_name: str = "") -> str: @@ -59,6 +65,7 @@ class QueueItem: action_name: str task_id: str job_id: str + notebook_id: str device_action_key: str next_run_time: float = 0 # 下次执行时间戳 retry_count: int = 0 # 重试次数 @@ -71,6 +78,7 @@ class JobInfo: job_id: str task_id: str device_id: str + notebook_id: str action_name: str device_action_key: str status: JobStatus @@ -101,6 +109,17 @@ class WebSocketMessage: timestamp: float = field(default_factory=time.time) +@dataclass +class JobStartCacheEntry: + """job_start幂等缓存项""" + + request_data: Dict[str, Any] + response_message: Optional[Dict[str, Any]] = None + response_status: str = "" + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + class WSResourceChatData(TypedDict): uuid: str device_uuid: str @@ -125,6 +144,28 @@ def add_queue_request(self, job_info: JobInfo) -> bool: """ with self.lock: device_key = job_info.device_action_key + existing_job = self.all_jobs.get(job_info.job_id) + if existing_job is not None: + if job_info.task_id != existing_job.task_id: + logger.warning( + "[DeviceActionManager] Duplicate job_id has different task_id: " + f"{job_info.job_id[:8]} old={existing_job.task_id[:8]} new={job_info.task_id[:8]}" + ) + return False + if job_info.notebook_id and not existing_job.notebook_id: + existing_job.notebook_id = job_info.notebook_id + existing_job.update_timestamp() + job_log = format_job_log( + existing_job.job_id, + existing_job.task_id, + existing_job.device_id, + existing_job.action_name, + ) + logger.info( + f"[DeviceActionManager] Duplicate queue request ignored for job {job_log}, " + f"status={existing_job.status}" + ) + return existing_job.status == JobStatus.READY # 总是将job添加到all_jobs中 self.all_jobs[job_info.job_id] = job_info @@ -364,7 +405,56 @@ def cancel_jobs_by_task_id(self, task_id: str) -> List[str]: return cancelled_job_ids - def check_ready_timeouts(self) -> List[JobInfo]: + def refresh_ready_timeouts(self, timeout_seconds: float = 10, reason: str = "") -> int: + """将 READY 任务的超时时间刷新到至少 now + timeout_seconds。""" + if timeout_seconds <= 0: + return 0 + + refreshed_count = 0 + now = time.time() + min_timeout = now + timeout_seconds + + with self.lock: + ready_candidates = list(self.active_jobs.values()) + for job in self.all_jobs.values(): + if job.always_free and job.status == JobStatus.READY and job not in ready_candidates: + ready_candidates.append(job) + + for job_info in ready_candidates: + if job_info.status != JobStatus.READY or job_info.ready_timeout is None: + continue + if job_info.ready_timeout >= min_timeout: + continue + + old_timeout = job_info.ready_timeout + job_info.ready_timeout = min_timeout + job_info.update_timestamp() + refreshed_count += 1 + + job_log = format_job_log( + job_info.job_id, + job_info.task_id, + job_info.device_id, + job_info.action_name, + ) + logger.info( + "[DeviceActionManager] Refreshed READY timeout for job %s from %.3f to %.3f%s", + job_log, + old_timeout, + job_info.ready_timeout, + f" ({reason})" if reason else "", + ) + + logger.info( + "[DeviceActionManager] READY timeout refresh window %.1fs; refreshed %s READY job(s)%s", + timeout_seconds, + refreshed_count, + f" ({reason})" if reason else "", + ) + + return refreshed_count + + def check_ready_timeouts(self, is_connected: bool = True) -> List[JobInfo]: """检查READY状态超时的任务,仅检测不处理""" timeout_jobs = [] @@ -381,6 +471,24 @@ def check_ready_timeouts(self) -> List[JobInfo]: # 找到所有超时的READY任务(只检测,不处理) for job_info in ready_candidates: + if job_info.status != JobStatus.READY: + continue + if not is_connected: + min_timeout = time.time() + 10 + if job_info.ready_timeout is not None and job_info.ready_timeout < min_timeout: + old_timeout = job_info.ready_timeout + job_info.ready_timeout = min_timeout + job_info.update_timestamp() + job_log = format_job_log( + job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name + ) + logger.info( + "[DeviceActionManager] WebSocket disconnected, keep READY job %s alive: %.3f -> %.3f", + job_log, + old_timeout, + job_info.ready_timeout, + ) + continue if job_info.is_ready_timeout(): timeout_jobs.append(job_info) job_log = format_job_log( @@ -478,8 +586,8 @@ async def _connection_handler(self): self.websocket_url, ssl=ssl_context, open_timeout=20, - ping_interval=WSConfig.ping_interval, - ping_timeout=10, + ping_interval=WSConfig.ws_ping_interval, + ping_timeout=WSConfig.ws_ping_timeout, close_timeout=5, additional_headers={ "Authorization": f"Lab {BasicConfig.auth_secret()}", @@ -492,12 +600,15 @@ async def _connection_handler(self): self.reconnect_count = 0 logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}") + self.device_manager.refresh_ready_timeouts(10, reason="websocket connected") # 启动发送协程 send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task") - # 每次连接(含重连)后重新向服务端注册, + # 每次连接(含重连)后尝试向服务端注册, # 否则服务端不知道客户端已上线,不会推送消息。 + # 注意:publish_host_ready 内部带就绪门禁——HostNode 未初始化完成时会自动延后, + # 首连若设备尚未就绪则不会在此发送,待 HostNode 初始化完成后由其回调补发。 if self.websocket_client: self.websocket_client.publish_host_ready() @@ -539,7 +650,10 @@ async def _connection_handler(self): self.reconnect_count += 1 backoff = WSConfig.reconnect_interval logger.info( - f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})" + "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)", + backoff, + self.reconnect_count, + WSConfig.max_reconnect_attempts, ) await asyncio.sleep(backoff) else: @@ -559,6 +673,7 @@ async def _message_handler(self): async for message in self.websocket: try: + logger.trace(f"[WS_RECV] {message}") data = json.loads(message) message_type = data.get("action", "") message_data = data.get("data") @@ -610,9 +725,10 @@ async def _send_handler(self): try: message_str = json.dumps(msg, ensure_ascii=False) await self.websocket.send(message_str) - # logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501 + logger.trace(f"[WS_SEND] {message_str}") except Exception as e: logger.error(f"[MessageProcessor] Failed to send message: {str(e)}") + logger.error(f"[WS_SEND_FAILED] {msg}") logger.error(traceback.format_exc()) break @@ -703,11 +819,61 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): action_name = data.get("action_name", "") task_id = data.get("task_id", "") job_id = data.get("job_id", "") + notebook_id = data.get("notebook_id", "") if not all([device_id, action_name, task_id, job_id]): logger.error("[MessageProcessor] Missing required fields in query_action_state") return + job_log = format_job_log(job_id, task_id, device_id, action_name) + + # 1) 该 job 仍在设备管理器中(READY/QUEUE/STARTED):不重复入队。 + # READY/STARTED 表示同一个 job 已被本地接收/执行,断线重连后仍回复 free, + # 让服务端继续发送 job_start;重复 job_start 会被幂等缓存拦截或回放结果。 + # QUEUE 表示该 job 尚未轮到执行,仍回复 busy。 + # 完成后 end_job 会把 job 从管理器移除,故运行中的 job 一定能在此命中。 + existing_job = self.device_manager.get_job_info(job_id) + if existing_job and existing_job.job_id == job_id and existing_job.task_id == task_id: + if existing_job.status in (JobStatus.READY, JobStatus.STARTED): + response_type, free, need_more = "query_action_status", True, 0 + elif existing_job.status == JobStatus.QUEUE: + response_type, free, need_more = "query_action_status", False, 10 + else: + response_type, free, need_more = "job_call_back_status", False, 10 + await self._send_action_state_response( + existing_job.device_id, + existing_job.action_name, + existing_job.task_id, + existing_job.job_id, + response_type, + free, + need_more, + notebook_id=existing_job.notebook_id or notebook_id, + ) + logger.trace( + f"[MessageProcessor] query_action_state {job_log} 返回当前状态 {existing_job.status}" + ) + return + + # 2) 不在管理器、但已 job_start 过(已完成被移除,多为断线重连后服务端重查): + # 回复 free,让服务端继续走 job_start,真正结果由 _handle_job_start 命中缓存回放。 + if self.websocket_client and self.websocket_client.is_job_cached(job_id, task_id): + self.websocket_client.log_cached_job(job_id, task_id, source="query_action_state") + await self._send_action_state_response( + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, + notebook_id=notebook_id, + ) + logger.info( + f"[MessageProcessor] [缓存复用] query_action_state {job_log} 命中缓存(已完成),回复 free" + ) + return + device_action_key = f"/devices/{device_id}/{action_name}" # 检查action是否为always_free @@ -718,6 +884,7 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): job_id=job_id, task_id=task_id, device_id=device_id, + notebook_id=notebook_id, action_name=action_name, device_action_key=device_action_key, status=JobStatus.QUEUE, @@ -728,17 +895,30 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): # 添加到设备管理器 can_start_immediately = self.device_manager.add_queue_request(job_info) - job_log = format_job_log(job_id, task_id, device_id, action_name) if can_start_immediately: # 可以立即开始 await self._send_action_state_response( - device_id, action_name, task_id, job_id, "query_action_status", True, 0 + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, + notebook_id=notebook_id, ) logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") else: # 需要排队 await self._send_action_state_response( - device_id, action_name, task_id, job_id, "query_action_status", False, 10 + device_id, + action_name, + task_id, + job_id, + "query_action_status", + False, + 10, + notebook_id=notebook_id, ) logger.trace(f"[MessageProcessor] Job {job_log} queued") @@ -749,15 +929,43 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): async def _handle_job_start(self, data: Dict[str, Any]): """处理job_start消息""" try: + data = dict(data or {}) if not data.get("sample_material"): data["sample_material"] = {} req = JobAddReq(**data) job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) + if self.websocket_client: + # 幂等缓存:首次 job_start 登记缓存并真正执行; + # 重复的 (task_id, job_id) 则假装执行——直接回放之前缓存的结果,不再下发设备动作。 + is_new_request = self.websocket_client.register_job_start_request(data) + if not is_new_request: + self.websocket_client.log_cached_job(req.job_id, req.task_id, source="job_start") + replayed = self.websocket_client.replay_cached_job_start_response(req.job_id, req.task_id) + if replayed: + logger.info( + f"[MessageProcessor] [缓存复用] job_start {job_log} 命中缓存,假装执行并回放缓存结果" + ) + else: + logger.info( + f"[MessageProcessor] [缓存复用] job_start {job_log} 命中缓存但暂无结果" + f"(原任务仍在执行),跳过重复执行" + ) + return + # 服务端对always_free动作可能跳过query_action_state直接发job_start, # 此时job尚未注册,需要自动补注册 existing_job = self.device_manager.get_job_info(req.job_id) + if existing_job and existing_job.task_id != req.task_id: + logger.warning( + "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " + "job=%s old_task=%s new_task=%s", + req.job_id[:8], + existing_job.task_id[:8], + req.task_id[:8], + ) + return if not existing_job: action_name = req.action device_action_key = f"/devices/{req.device_id}/{action_name}" @@ -768,6 +976,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): job_id=req.job_id, task_id=req.task_id, device_id=req.device_id, + notebook_id=req.notebook_id, action_name=action_name, device_action_key=device_action_key, status=JobStatus.QUEUE, @@ -775,11 +984,16 @@ async def _handle_job_start(self, data: Dict[str, Any]): always_free=True, ) self.device_manager.add_queue_request(job_info) + existing_job = job_info logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") else: logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") return + if existing_job and req.notebook_id and not existing_job.notebook_id: + existing_job.notebook_id = req.notebook_id + notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "") + success = self.device_manager.start_job(req.job_id) if not success: logger.error(f"[MessageProcessor] Failed to start job {job_log}") @@ -795,6 +1009,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): action_name=req.action, task_id=req.task_id, job_id=req.job_id, + notebook_id=notebook_id, device_action_key=device_action_key, ) @@ -834,6 +1049,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): "job_id": req.job_id, "task_id": req.task_id, "device_id": req.device_id, + "notebook_id": queue_item.notebook_id, "action_name": req.action, "status": "failed", "feedback_data": {}, @@ -855,6 +1071,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): "query_action_status", True, 0, + notebook_id=next_job.notebook_id, ) next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -1004,11 +1221,16 @@ def _notify_resource_tree(dev_id, act, item_list): success = host_node.notify_resource_tree_update(dev_id, act, item_list) - if success: + if success is True: logger.info( f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, " f"items: {len(item_list)}" ) + elif success is None: + logger.info( + f"[MessageProcessor] Resource tree {act} skipped for device {dev_id}: " + "在线增加设备暂不支持" + ) else: logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}") @@ -1032,6 +1254,11 @@ async def _handle_device_manage(self, device_list: list[ResourceDictType], actio for item in device_list: target_node_id = item.get("target_node_id", "host_node") + if action == "add": + logger.info( + f"[DeviceManage] 在线增加设备暂不支持,跳过 add_device: {item.get('id', '')}" + ) + continue def _notify(target_id: str, act: str, cfg: ResourceDictType): try: @@ -1101,7 +1328,15 @@ def do_cleanup(): logger.info(f"[MessageProcessor] Restart cleanup scheduled") async def _send_action_state_response( - self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int + self, + device_id: str, + action_name: str, + task_id: str, + job_id: str, + typ: str, + free: bool, + need_more: int, + notebook_id: str = "", ): """发送动作状态响应""" message = { @@ -1112,6 +1347,7 @@ async def _send_action_state_response( "action_name": action_name, "task_id": task_id, "job_id": job_id, + "notebook_id": notebook_id, "free": free, "need_more": need_more + 1, }, @@ -1183,7 +1419,9 @@ def _run(self): while self.is_running: try: # 检查READY状态超时的任务 - timeout_jobs = self.device_manager.check_ready_timeouts() + timeout_jobs = self.device_manager.check_ready_timeouts( + is_connected=self.message_processor.is_connected() + ) if timeout_jobs: logger.info(f"[QueueProcessor] Found {len(timeout_jobs)} READY jobs that timed out") # 为超时的job发布失败状态,通过正常job完成流程处理 @@ -1194,6 +1432,7 @@ def _run(self): action_name=timeout_job.action_name, task_id=timeout_job.task_id, job_id=timeout_job.job_id, + notebook_id=timeout_job.notebook_id, device_action_key=timeout_job.device_action_key, ) # 发布超时失败状态,这会触发正常的job完成流程 @@ -1252,6 +1491,7 @@ def _send_running_status(self): "action_name": job_info.action_name, "task_id": job_info.task_id, "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, "free": False, "need_more": 10 + 1, }, @@ -1291,6 +1531,7 @@ def _send_busy_status(self): "action_name": job_info.action_name, "task_id": job_info.task_id, "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, "free": False, "need_more": 10 + 1, }, @@ -1336,12 +1577,15 @@ def handle_job_completed(self, job_id: str, status: str) -> None: "action_name": next_job.action_name, "task_id": next_job.task_id, "job_id": next_job.job_id, + "notebook_id": next_job.notebook_id, "free": True, "need_more": 0, }, } self.message_processor.send_message(message) - # next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) + # next_job_log = format_job_log( + # next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name + # ) # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") # 立即触发下一轮状态检查 @@ -1379,6 +1623,12 @@ def __init__(self): self._job_running_last_sent: Dict[str, tuple] = {} self._job_running_debounce_interval: float = 10.0 # 秒 + # job_start幂等缓存: {(task_id, job_id): JobStartCacheEntry} + self._job_start_cache: Dict[Tuple[str, str], JobStartCacheEntry] = {} + self._job_start_cache_lock = threading.RLock() + self._job_start_cache_ttl_seconds: float = 24 * 60 * 60 + self._job_start_cache_max_entries: int = 1024 + # 设置相互引用 self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_websocket_client(self) @@ -1387,23 +1637,169 @@ def __init__(self): logger.info(f"[WebSocketClient] Client_id: {self.client_id}") def _build_websocket_url(self) -> Optional[str]: - """构建WebSocket连接URL""" + """构建 schedule 通道的 WebSocket 连接 URL + + 地址来源优先级: + 1. HTTPConfig.schedule_addr(--schedule_addr 显式指定)→ 直接使用,不做端口偏移 + 2. HTTPConfig.remote_addr(--addr)派生:带端口则 +1,否则沿用原 netloc + """ + # 1. 显式 schedule 地址 + if HTTPConfig.schedule_addr: + parsed = urlparse(HTTPConfig.schedule_addr) + scheme = "wss" if parsed.scheme in ("https", "wss") else "ws" + return f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" + + # 2. 从 api 地址派生 if not HTTPConfig.remote_addr: return None parsed = urlparse(HTTPConfig.remote_addr) - - if parsed.scheme == "https": - scheme = "wss" - else: - scheme = "ws" + scheme = "wss" if parsed.scheme == "https" else "ws" if ":" in parsed.netloc and parsed.port is not None: - url = f"{scheme}://{parsed.hostname}:{parsed.port + 1}/api/v1/ws/schedule" - else: - url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" + return f"{scheme}://{parsed.hostname}:{parsed.port + 1}/api/v1/ws/schedule" + return f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" - return url + @staticmethod + def _job_start_cache_key(job_id: str, task_id: str) -> Optional[Tuple[str, str]]: + if not job_id or not task_id: + return None + return task_id, job_id + + def _prune_job_start_cache_locked(self) -> None: + now = time.time() + expired_keys = [ + key + for key, entry in self._job_start_cache.items() + if now - entry.updated_at > self._job_start_cache_ttl_seconds + ] + for key in expired_keys: + self._job_start_cache.pop(key, None) + + overflow = len(self._job_start_cache) - self._job_start_cache_max_entries + if overflow <= 0: + return + + oldest_keys = sorted(self._job_start_cache, key=lambda key: self._job_start_cache[key].updated_at)[:overflow] + for key in oldest_keys: + self._job_start_cache.pop(key, None) + + def register_job_start_request(self, request_data: Dict[str, Any]) -> bool: + """登记job_start请求;返回False表示同一(task_id, job_id)已处理过。""" + key = self._job_start_cache_key(request_data.get("job_id", ""), request_data.get("task_id", "")) + if key is None: + return True + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is not None: + cached.updated_at = time.time() + if cached.request_data != request_data: + logger.warning( + "[WebSocketClient] Duplicate job_start has different payload for " + f"job={key[1][:8]}, task={key[0][:8]}" + ) + return False + + self._job_start_cache[key] = JobStartCacheEntry(request_data=copy.deepcopy(request_data)) + self._prune_job_start_cache_locked() + return True + + def is_job_cached(self, job_id: str, task_id: str) -> bool: + """判断同一 (task_id, job_id) 是否已 job_start 过(已登记进幂等缓存)。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return False + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is None: + return False + cached.updated_at = time.time() + return True + + def log_cached_job(self, job_id: str, task_id: str, source: str = "") -> None: + """打印命中缓存的 job 内容(请求 + 已缓存结果),便于核对复用的数据。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None: + return + request_data = copy.deepcopy(cached.request_data) + response_message = copy.deepcopy(cached.response_message) + response_status = cached.response_status + + result_repr = json.dumps(response_message, ensure_ascii=False) if response_message else "none" + logger.info( + f"[WebSocketClient] [缓存复用] 命中缓存 source={source} job={job_id[:8]} task={task_id[:8]} " + f"status={response_status or 'none'} " + f"request={json.dumps(request_data, ensure_ascii=False)} " + f"result={result_repr}" + ) + + def get_cached_job_start_response_status(self, job_id: str, task_id: str) -> str: + """获取同一job_start已缓存的回复状态。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return "" + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is None: + return "" + cached.updated_at = time.time() + return cached.response_status + + def cache_job_start_response(self, item: QueueItem, message: Dict[str, Any], status: str) -> None: + """缓存同一 (task_id, job_id) 的 job 结果(最新 job_status),供重复请求复用回放。""" + key = self._job_start_cache_key(item.job_id, item.task_id) + if key is None: + return + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None: + cached = JobStartCacheEntry(request_data={}) + self._job_start_cache[key] = cached + + cached.response_message = copy.deepcopy(message) + cached.response_status = status + cached.updated_at = time.time() + self._prune_job_start_cache_locked() + + def replay_cached_job_start_response(self, job_id: str, task_id: str) -> bool: + """回放同一 (task_id, job_id) 已缓存的最终结果。 + + 仅当已缓存到 success/failed 的终态结果时才回放;若原任务仍在执行 + (只缓存了 running 中间态),返回 False,由调用方决定如何处理。 + """ + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return False + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None or cached.response_message is None: + return False + if cached.response_status not in ("success", "failed"): + return False + message = copy.deepcopy(cached.response_message) + status = cached.response_status + cached.updated_at = time.time() + + sent = self.message_processor.send_message(message) + if sent: + logger.info( + f"[WebSocketClient] [缓存复用] 回放缓存结果 job={job_id[:8]} task={task_id[:8]} " + f"status={status} payload={json.dumps(message, ensure_ascii=False)}" + ) + return sent def start(self) -> None: """启动WebSocket客户端""" @@ -1474,10 +1870,6 @@ def publish_job_status( self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None ) -> None: """发布作业状态,拦截最终结果(给HostNode调用的接口)""" - if not self.is_connected(): - logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") - return - job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) # 拦截最终结果状态,与原版本逻辑一致 @@ -1493,6 +1885,17 @@ def publish_job_status( self.queue_processor.handle_job_completed(item.job_id, status) + cached_status = self.get_cached_job_start_response_status(item.job_id, item.task_id) + if cached_status in ["success", "failed"]: + # 断线重连时,旧 READY 占位可能在结果已回放后触发 timeout failed。 + # 已有终态时不允许重复终态覆盖缓存或再次发送,success 也不允许被 failed 降级。 + if cached_status == "success" or cached_status == status: + logger.warning( + f"[WebSocketClient] Skipped duplicate terminal job status for {job_log}: " + f"cached={cached_status}, incoming={status}" + ) + return + # running状态按job_id做debounce,内容变化时仍然上报 if status == "running": now = time.time() @@ -1510,6 +1913,7 @@ def publish_job_status( "job_id": item.job_id, "task_id": item.task_id, "device_id": item.device_id, + "notebook_id": item.notebook_id, "action_name": item.action_name, "status": status, "feedback_data": feedback_data, @@ -1517,6 +1921,12 @@ def publish_job_status( "timestamp": time.time(), }, } + self.cache_job_start_response(item, message, status) + + if not self.is_connected(): + logger.debug(f"[WebSocketClient] Not connected, cached job status for job {job_log} - {status}") + return + self.message_processor.send_message(message) logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") @@ -1555,43 +1965,50 @@ def publish_host_ready(self) -> None: logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") return + # 仅在 HostNode 初始化完成(设备已就绪)后才向服务端注册。 + # get_instance(0) 在未就绪时立即返回 None;此时必须延后发送, + # 否则会发出 devices=[] 的空 host_ready,令服务端误判节点已就绪而过早调度, + # 进而触发 READY 超时与启动期频繁断链重连。 + host_node = HostNode.get_instance(0) + if host_node is None: + logger.info("[WebSocketClient] Host node 尚未就绪,延后发送 host_ready(待初始化完成后再注册)") + return + # 收集设备信息 devices = [] machine_name = BasicConfig.machine_name try: - host_node = HostNode.get_instance(0) - if host_node: - # 获取设备信息 - for device_id, namespace in host_node.devices_names.items(): - device_key = ( - f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" - ) - is_online = device_key in host_node._online_devices - - # 获取设备的动作信息 - actions = {} - for action_id, client in host_node._action_clients.items(): - # action_id 格式: /namespace/device_id/action_name - if device_id in action_id: - action_name = action_id.split("/")[-1] - actions[action_name] = { - "action_path": action_id, - "action_type": str(type(client).__name__), - } - - devices.append( - { - "device_id": device_id, - "namespace": namespace, - "device_key": device_key, - "is_online": is_online, - "machine_name": host_node.device_machine_names.get(device_id, machine_name), - "actions": actions, + # 获取设备信息 + for device_id, namespace in host_node.devices_names.items(): + device_key = ( + f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + ) + is_online = device_key in host_node._online_devices + + # 获取设备的动作信息 + actions = {} + for action_id, client in host_node._action_clients.items(): + # action_id 格式: /namespace/device_id/action_name + if device_id in action_id: + action_name = action_id.split("/")[-1] + actions[action_name] = { + "action_path": action_id, + "action_type": str(type(client).__name__), } - ) - logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") + devices.append( + { + "device_id": device_id, + "namespace": namespace, + "device_key": device_key, + "is_online": is_online, + "machine_name": host_node.device_machine_names.get(device_id, machine_name), + "actions": actions, + } + ) + + logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") except Exception as e: logger.warning(f"[WebSocketClient] Error collecting device info: {e}") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index d8d000e25..be1f54cea 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -41,12 +41,17 @@ def auth_secret(cls): class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 20 # ping间隔(秒) + # 注意:字段名带 ws_ 前缀,是为了让旧客户端遗留的 local_config 中旧字段(ping_interval/ping_timeout)失效, + # 从而强制采用下面的新默认值。请勿改回旧名。 + ws_ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ws_ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait # HTTP配置 class HTTPConfig: remote_addr = "https://leap-lab.bohrium.com/api/v1" + # schedule 通道(WebSocket)地址;为空时从 remote_addr 派生:带端口则 +1,否则沿用原 netloc + schedule_addr = "" # ROS配置 @@ -77,7 +82,7 @@ def _update_config_from_env(): if not env_key.startswith(prefix): continue try: - key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix + key_path = env_key[len(prefix):] # Remove UNILAB_ prefix class_field = key_path.upper().split("_", 1) if len(class_field) != 2: logger.warning(f"[ENV] 环境变量格式不正确:{env_key}") diff --git a/unilabos/config/example_config.py b/unilabos/config/example_config.py index b096e410c..d59d8b525 100644 --- a/unilabos/config/example_config.py +++ b/unilabos/config/example_config.py @@ -9,4 +9,5 @@ class BasicConfig: class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 30 # ping间隔(秒) \ No newline at end of file + ws_ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ws_ping_timeout = 7 # pong等待超时(秒),对齐服务端 PongWait 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/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index c70c8f66a..80ae1bdf0 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -14,20 +14,30 @@ import logging import time -from typing import Dict, Any, Optional, List from dataclasses import dataclass from enum import Enum from threading import Lock, RLock +from typing import Any, Dict, List, Optional, cast from typing_extensions import TypedDict from unilabos.registry.decorators import ( - device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType + ActionInputHandle, + ActionOutputHandle, + DataSource, + NodeType, + action, + device, + not_action, + topic_config, ) from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode -from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet - +from unilabos.resources.resource_tracker import ( + SampleUUIDsType, + LabSample, + ResourceTreeSet, +) # ============ TypedDict 返回类型定义 ============ @@ -112,6 +122,7 @@ class HeatingStation: @device( id="virtual_workbench", + display_name="虚拟工作台", category=["virtual_device"], description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing", ) @@ -137,7 +148,19 @@ class VirtualWorkbench: HEATING_TIME: float = 60.0 # 加热时间(秒) NUM_HEATING_STATIONS: int = 3 # 加热台数量 - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, + **kwargs, + ): + """ + 初始化虚拟工作台。 + + Args: + device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。 + config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。 + """ # 处理可能的不同调用方式 if device_id is None and "id" in kwargs: device_id = kwargs.pop("id") @@ -151,9 +174,13 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A self.data: Dict[str, Any] = {} # 从config中获取可配置参数 - self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)) + self.ARM_OPERATION_TIME = float( + self.config.get("arm_operation_time", self.ARM_OPERATION_TIME) + ) self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME)) - self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)) + self.NUM_HEATING_STATIONS = int( + self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS) + ) # 机械臂状态和锁 self._arm_lock = Lock() @@ -162,7 +189,8 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A # 加热台状态 self._heating_stations: Dict[int, HeatingStation] = { - i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1) + i: HeatingStation(station_id=i) + for i in range(1, self.NUM_HEATING_STATIONS + 1) } self._stations_lock = RLock() @@ -292,45 +320,113 @@ def _release_arm(self): self.logger.info(f"机械臂已释放 (完成: {task})") @action( - always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={ - "assignee_user_ids": "unilabos_manual_confirm" - }, goal_default={ - "timeout_seconds": 3600, - "assignee_user_ids": [] - }, feedback_interval=300, + always_free=True, + node_type=NodeType.MANUAL_CONFIRM, + placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}, + goal_default={"timeout_seconds": 3600, "assignee_user_ids": []}, + feedback_interval=300, handles=[ - ActionInputHandle(key="target_device", data_type="device_id", - label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), - ActionInputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), - ActionInputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), - - ActionInputHandle(key="collector_mass", data_type="collector_mass", - label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), - ActionInputHandle(key="active_material", data_type="active_material", - label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), - ActionInputHandle(key="capacity", data_type="capacity", - label="克容量", data_key="capacity", data_source=DataSource.HANDLE), - ActionInputHandle(key="battery_system", data_type="battery_system", - label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.HANDLE, + ), # transfer使用 - ActionOutputHandle(key="target_device", data_type="device_id", - label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR), + ActionOutputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource.@flatten", + data_source=DataSource.EXECUTOR, + ), # test使用 - ActionOutputHandle(key="collector_mass", data_type="collector_mass", - label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="active_material", data_type="active_material", - label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="capacity", data_type="capacity", - label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="battery_system", data_type="battery_system", - label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR), - ] + ActionOutputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.EXECUTOR, + ), + ], ) def manual_confirm( self, @@ -343,67 +439,156 @@ def manual_confirm( battery_system: List[str], timeout_seconds: int, assignee_user_ids: list[str], - **kwargs + **kwargs, ) -> dict: """ - timeout_seconds: 超时时间(秒),默认3600秒 - collector_mass: 极流体质量 - active_material: 活性物质含量 - capacity: 克容量(mAh/g) - battery_system: 电池体系 - 修改的结果无效,是只读的 + 人工确认资源转移和扣电测试参数。 + + Args: + resource[待转移资源]: 需要人工确认的资源列表。 + target_device[目标设备]: 资源要转移到的目标设备 ID。 + mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + timeout_seconds[超时时间]: 人工确认超时时间,单位秒。 + assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。 + + Note: + 修改的结果无效,是只读的。 """ - resource = ResourceTreeSet.from_plr_resources(resource).dump() - mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump() + resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump() + mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump() kwargs.update(locals()) kwargs.pop("kwargs") kwargs.pop("self") + kwargs["resource"] = resource_tree + kwargs["mount_resource"] = mount_resource_tree + kwargs.pop("resource_tree") + kwargs.pop("mount_resource_tree") return kwargs @action( description="转移物料", handles=[ - ActionInputHandle(key="target_device", data_type="device_id", - label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), - ActionInputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), - ActionInputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), - ] + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ], ) - async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]): - future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, + async def transfer( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + ): + """ + 转移资源到目标设备。 + + Args: + resource[待转移资源]: 待转移的资源列表。 + target_device[目标设备]: 接收资源的目标设备 ID。 + mount_resource[目标孔位]: 目标设备上的挂载孔位列表。 + """ + future = ROS2DeviceNode.run_async_func( + self._ros_node.transfer_resource_to_another, + True, **{ "plr_resources": resource, "target_device_id": target_device, "target_resources": mount_resource, "sites": [None] * len(mount_resource), - }) + }, + ) result = await future return result - @action( description="扣电测试启动", handles=[ - ActionInputHandle(key="resource", data_type="resource", - label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), - ActionInputHandle(key="mount_resource", data_type="resource", - label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), - - ActionInputHandle(key="collector_mass", data_type="collector_mass", - label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), - ActionInputHandle(key="active_material", data_type="active_material", - label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), - ActionInputHandle(key="capacity", data_type="capacity", - label="克容量", data_key="capacity", data_source=DataSource.HANDLE), - ActionInputHandle(key="battery_system", data_type="battery_system", - label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), - ] + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.HANDLE, + ), + ], ) async def test( - self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str] + self, + resource: List[ResourceSlot], + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: list[str], ): + """ + 启动扣电测试。 + + Args: + resource[待测试资源]: 需要进行扣电测试的资源列表。 + mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + """ print(resource) print(mount_resource) print(collector_mass) @@ -415,16 +600,11 @@ async def test( auto_prefix=True, description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", handles=[ - ActionOutputHandle(key="channel_1", data_type="workbench_material", - label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_2", data_type="workbench_material", - label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_3", data_type="workbench_material", - label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_4", data_type="workbench_material", - label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_5", data_type="workbench_material", - label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501 ], ) def prepare_materials( @@ -437,6 +617,9 @@ def prepare_materials( 作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。 输出5个handle (material_1 ~ material_5), 分别对应实验1~5。 + + Args: + count[物料数量]: 要生成的物料数量,默认生成 5 个。 """ materials = [i for i in range(1, count + 1)] @@ -457,7 +640,11 @@ def prepare_materials( LabSample( sample_uuid=sample_uuid, oss_path="", - extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}), + extra=( + {"material_uuid": content} + if isinstance(content, str) + else (content.serialize() if content else {}) + ), ) for sample_uuid, content in sample_uuids.items() ], @@ -467,12 +654,27 @@ def prepare_materials( auto_prefix=True, description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID", handles=[ - ActionInputHandle(key="material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_station_output", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="material_number_output", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_station_output", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="material_number_output", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def move_to_heating_station( @@ -484,6 +686,9 @@ def move_to_heating_station( 将物料从An位置移动到加热台 多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台 + + Args: + material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。 """ material_id = f"A{material_number}" task_desc = f"移动{material_id}到加热台" @@ -546,7 +751,8 @@ def move_to_heating_station( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -569,7 +775,8 @@ def move_to_heating_station( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -581,14 +788,34 @@ def move_to_heating_station( always_free=True, description="启动指定加热台的加热程序", handles=[ - ActionInputHandle(key="station_id_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="material_number_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_done_station", data_type="workbench_station", - label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="heating_done_material", data_type="workbench_material", - label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="station_id_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="material_number_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_done_station", + data_type="workbench_station", + label="加热完成-加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="heating_done_material", + data_type="workbench_material", + label="加热完成-物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def start_heating( @@ -599,6 +826,10 @@ def start_heating( ) -> StartHeatingResult: """ 启动指定加热台的加热程序 + + Args: + station_id[加热台ID]: 要启动加热的加热台编号。 + material_number[物料编号]: 当前加热台上的物料编号。 """ self.logger.info(f"[加热台{station_id}] 开始加热") @@ -615,7 +846,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -638,7 +870,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -658,7 +891,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -698,7 +932,9 @@ def start_heating( self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") if time.time() - last_countdown_log >= 5.0: - self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s") + self.logger.info( + f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s" + ) last_countdown_log = time.time() if elapsed >= self.HEATING_TIME: @@ -715,7 +951,9 @@ def start_heating( self._active_tasks[material_id]["status"] = "heating_completed" self._update_data_status(f"加热台{station_id}加热完成") - self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)") + self.logger.info( + f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)" + ) return { "success": True, @@ -729,7 +967,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -740,10 +979,20 @@ def start_heating( auto_prefix=True, description="将物料从加热台移动到输出位置Cn", handles=[ - ActionInputHandle(key="output_station_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="output_material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), + ActionInputHandle( + key="output_station_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="output_material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), ], ) def move_to_output( @@ -754,6 +1003,10 @@ def move_to_output( ) -> MoveToOutputResult: """ 将物料从加热台移动到输出位置Cn + + Args: + station_id[加热台ID]: 已完成加热的加热台编号。 + material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。 """ output_number = material_number @@ -770,7 +1023,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -794,7 +1048,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -814,7 +1069,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -896,7 +1152,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() 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/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 62cd2dbed..eb4b5bf9b 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -20,6 +20,7 @@ import ast import hashlib import json +import re import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -32,12 +33,22 @@ MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_FILES = 1000 # 最大扫描文件数量 -_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增 +_CACHE_VERSION = 3 # 缓存格式版本号,格式变更时递增 +_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # 合法的装饰器来源模块 _REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators" +def _validate_device_ids(device_ids: List[str]) -> None: + invalid_ids = [device_id for device_id in device_ids if not _DEVICE_ID_RE.fullmatch(device_id)] + if invalid_ids: + raise ValueError( + "@device id 只能包含英文、数字、下划线: " + + ", ".join(repr(device_id) for device_id in invalid_ids) + ) + + # --------------------------------------------------------------------------- # File-level cache helpers # --------------------------------------------------------------------------- @@ -258,8 +269,6 @@ def _collect_results(futures_dict: Dict): } - - # --------------------------------------------------------------------------- # File-level parsing # --------------------------------------------------------------------------- @@ -344,14 +353,16 @@ def _parse_file( did = device_args.get("id") or device_args.get("device_id") device_ids = [did] if did else [f"{module_path}:{node.name}"] + _validate_device_ids(device_ids) id_meta = device_args.get("id_meta") or {} + display_name = device_args.get("displayname") or device_args.get("display_name", "") base_meta = { "class_name": node.name, "module": f"{module_path}:{node.name}", "file_path": str(filepath).replace("\\", "/"), "category": device_args.get("category", []), "description": device_args.get("description", ""), - "display_name": device_args.get("display_name", ""), + "display_name": display_name, "icon": device_args.get("icon", ""), "version": device_args.get("version", "1.0.0"), "device_type": _detect_class_type(node, import_map), @@ -361,6 +372,7 @@ def _parse_file( "actions": class_body.get("actions", {}), "status_properties": class_body.get("status_properties", {}), "init_params": class_body.get("init_params", []), + "init_docstring": class_body.get("init_docstring"), "auto_methods": class_body.get("auto_methods", {}), "import_map": import_map, } @@ -368,9 +380,12 @@ def _parse_file( meta = dict(base_meta) meta["device_id"] = did overrides = id_meta.get(did, {}) - for key in ("handles", "description", "icon", "model", "hardware_interface"): + for key in ("handles", "description", "display_name", "displayname", "icon", "model", "hardware_interface"): if key in overrides: - meta[key] = overrides[key] + if key == "displayname": + meta["display_name"] = overrides[key] + else: + meta[key] = overrides[key] devices.append(meta) # --- @resource on classes --- @@ -497,7 +512,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]: return import_map - # --------------------------------------------------------------------------- # Decorator finding & argument extraction # --------------------------------------------------------------------------- @@ -768,6 +782,7 @@ def _extract_class_body( "actions": {}, # method_name -> action_info "status_properties": {}, # prop_name -> status_info "init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...] + "init_docstring": None, "auto_methods": {}, # method_name -> method_info (no @action decorator) } @@ -780,6 +795,7 @@ def _extract_class_body( # --- __init__ --- if method_name == "__init__": result["init_params"] = _extract_method_params(item, import_map) + result["init_docstring"] = ast.get_docstring(item) continue # --- Skip private/dunder --- diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 1dffe1697..927376e66 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -12,9 +12,10 @@ ) @device( - id="solenoid_valve.mock", + id="solenoid_valve_mock", category=["pump_and_valve"], description="模拟电磁阀设备", + displayname="模拟电磁阀", handles=[ InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH), OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH), @@ -46,11 +47,13 @@ def is_open(self): from enum import Enum from functools import wraps +import re from typing import Any, Callable, Dict, List, Optional, TypeVar from pydantic import BaseModel, ConfigDict, Field F = TypeVar("F", bound=Callable[..., Any]) +_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # --------------------------------------------------------------------------- # 枚举 @@ -248,6 +251,7 @@ def device( category: Optional[List[str]] = None, description: str = "", display_name: str = "", + displayname: str = "", icon: str = "", version: str = "1.0.0", handles: Optional[List[_DeviceHandleBase]] = None, @@ -270,7 +274,8 @@ def device( id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model) category: 设备分类标签列表 (必填) description: 设备描述 - display_name: 人类可读的设备显示名称,缺失时默认使用 id + displayname: 人类可读的设备显示名称,缺失时默认使用 id + display_name: 兼容旧代码的显示名称参数;新代码优先使用 displayname icon: 图标路径 version: 版本号 handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用) @@ -290,13 +295,21 @@ def device( else: raise ValueError("@device 必须提供 id 或 ids") + invalid_ids = [did for did in device_ids if not _DEVICE_ID_RE.fullmatch(did)] + if invalid_ids: + raise ValueError( + "@device id 只能包含英文、数字、下划线: " + + ", ".join(repr(did) for did in invalid_ids) + ) + if category is None: raise ValueError("@device category 必填") + resolved_display_name = displayname or display_name base_meta = { "category": category, "description": description, - "display_name": display_name, + "display_name": resolved_display_name, "icon": icon, "version": version, "handles": _device_handles_to_list(handles), @@ -505,12 +518,14 @@ def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, overrides = id_meta[device_id] result = dict(base) result["device_id"] = device_id - for key in ["handles", "description", "icon", "model"]: + for key in ["handles", "description", "display_name", "displayname", "icon", "model"]: if key in overrides: val = overrides[key] if key == "handles" and isinstance(val, list): # handles 必须是 Handle 对象列表 result[key] = [h.to_registry_dict() for h in val] + elif key == "displayname": + result["display_name"] = val else: result[key] = val return result diff --git a/unilabos/registry/devices/Qone_nmr.yaml b/unilabos/registry/devices/Qone_nmr.yaml index 5c5f1f8a9..fd2761e48 100644 --- a/unilabos/registry/devices/Qone_nmr.yaml +++ b/unilabos/registry/devices/Qone_nmr.yaml @@ -51,14 +51,18 @@ Qone_nmr: properties: check_interval: default: 60 + description: 检查间隔时间(秒),默认60秒 type: string expected_count: default: 1 + description: 期望生成的.nmr文件数量,默认1个 type: string monitor_dir: + description: 要监督的目录路径,如果未指定则使用self.monitor_directory type: string stability_checks: default: 3 + description: 文件大小稳定性检查次数,默认3次 type: string required: [] type: object @@ -85,11 +89,14 @@ Qone_nmr: goal: properties: output_dir: + description: 输出目录(如果未指定,使用self.output_directory) type: string string_list: + description: 字符串列表 type: string txt_encoding: default: utf-8 + description: 文件编码 type: string required: - string_list @@ -151,6 +158,13 @@ Qone_nmr: additionalProperties: false properties: string: + description: '包含多个字符串的输入数据,支持两种格式: + + 1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30" + + 2. 换行分隔:如 "A 1 B 2 C 3 + + X 10 Y 20 Z 30"' type: string title: StrSingleInput_Goal type: object diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index f57cd35c5..6b2d1b172 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -491,14 +491,17 @@ bioyond_cell: goal: properties: material_names: + description: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2] items: type: string type: array type_id: default: 3a190ca0-b2f6-9aeb-8067-547e72c11469 + description: 物料类型ID type: string warehouse_name: default: 粉末加样头堆栈 + description: 目标仓库名(用于取位置信息) type: string required: [] type: object @@ -527,12 +530,16 @@ bioyond_cell: goal: properties: location_name_or_id: + description: 具体库位名称(如 A01)或库位 UUID,由用户指定。 type: string material_name: + description: 物料名称(会优先匹配配置模板)。 type: string type_id: + description: 物料类型 ID(若为空则尝试从配置推断)。 type: string warehouse_name: + description: 需要入库的仓库名称;若为空则仅创建不入库。 type: string required: - material_name @@ -661,15 +668,20 @@ bioyond_cell: goal: properties: board_type: + description: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板" type: string bottle_type: + description: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)" type: string location_code: + description: 库位编号,例如 "A01" type: string name: + description: 物料名称 type: string warehouse_name: default: 手动堆栈 + description: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等 type: string required: - name @@ -1956,19 +1968,19 @@ bioyond_cell: properties: source_wh_id: default: 3a19debc-84b4-0359-e2d4-b3beea49348b - description: 来源仓库ID + description: 来源仓库 Id (默认为3号仓库) type: string source_x: default: 1 - description: 来源位置X坐标 + description: 来源位置 X 坐标 type: integer source_y: default: 1 - description: 来源位置Y坐标 + description: 来源位置 Y 坐标 type: integer source_z: default: 1 - description: 来源位置Z坐标 + description: 来源位置 Z 坐标 type: integer required: [] type: object @@ -2061,9 +2073,11 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code @@ -2092,12 +2106,15 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string poll_interval: default: 0.5 + description: 轮询间隔(秒),默认 0.5 秒 type: number timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code @@ -2154,10 +2171,15 @@ bioyond_cell: config: properties: bioyond_config: + description: '从 JSON 文件加载的 bioyond 配置字典 + + 包含 api_host, api_key, HTTP_host, HTTP_port 等配置' type: object deck: + description: Deck 配置(可选,会从 JSON 中自动处理) type: string protocol_type: + description: 协议类型(可选) type: string required: [] type: object diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index 547b54ffb..21f36e162 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -47,8 +47,10 @@ bioyond_dispensing_station: goal: properties: report_request: + description: WorkstationReportRequest 对象,包含任务完成信息 type: string used_materials: + description: 物料使用记录列表 type: string required: - report_request @@ -102,6 +104,7 @@ bioyond_dispensing_station: goal: properties: material_name: + description: 物料名称 type: string required: - material_name @@ -611,10 +614,10 @@ bioyond_dispensing_station: goal: properties: target_device_id: - description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备) + description: 目标反应站设备ID(所有转移组使用同一个设备) type: string transfer_groups: - description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组 + description: '转移任务组列表,每组包含:' type: array required: - target_device_id @@ -694,10 +697,13 @@ bioyond_dispensing_station: config: properties: config: + description: 配置字典,应包含material_type_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object 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/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index df5a35085..b692506ce 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -150,15 +150,15 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + description: 电池压制力 (N) type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + description: 是否忽略电池清洁 type: boolean battery_pressure_mode: default: true @@ -166,29 +166,29 @@ coincellassemblyworkstation_device: type: boolean dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + description: 二次滴液第一次排液体积 (μL) type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + description: 电解液添加模式 (False=单次滴液, True=二次滴液) type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + description: 二次滴液吸液时机 (False=正常吸液, True=先吸液) type: boolean elec_num: description: 电解液瓶数 type: string elec_use_num: - description: 每瓶电解液组装电池数 + description: 每瓶电解液组装的电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + description: 电解液吸液量 (μL) type: integer file_path: default: /Users/sml/work @@ -196,7 +196,7 @@ coincellassemblyworkstation_device: type: string fujipian_juzhendianwei: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 负极片矩阵点位 type: integer fujipian_panshu: default: 0 @@ -204,7 +204,7 @@ coincellassemblyworkstation_device: type: integer gemo_juzhendianwei: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 隔膜矩阵点位 type: integer gemopanshu: default: 0 @@ -216,7 +216,7 @@ coincellassemblyworkstation_device: type: boolean qiangtou_juzhendianwei: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 枪头盒矩阵点位 type: integer required: - elec_num @@ -308,7 +308,13 @@ coincellassemblyworkstation_device: properties: material_search_enable: default: false - description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻) + description: '是否启用物料搜寻功能。 + + 设备初始化后会弹出物料搜寻确认弹窗, + + 此参数控制自动点击''是''(启用)或''否''(不启用)。 + + 默认为False(不启用物料搜寻)。' type: boolean required: [] type: object @@ -547,15 +553,15 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + description: 电池压制力 (N) type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + description: 是否忽略电池清洁 type: boolean battery_pressure_mode: default: true @@ -563,29 +569,29 @@ coincellassemblyworkstation_device: type: boolean dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + description: 二次滴液第一次排液体积 (μL) type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + description: 电解液添加模式 (False=单次滴液, True=二次滴液) type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + description: 二次滴液吸液时机 (False=正常吸液, True=先吸液) type: boolean elec_num: - description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数 + description: 电解液瓶数 type: string elec_use_num: - description: 每瓶电解液组装电池数 + description: 每瓶电解液组装的电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + description: 电解液吸液量 (μL) type: integer file_path: default: /Users/sml/work @@ -593,7 +599,7 @@ coincellassemblyworkstation_device: type: string fujipian_juzhendianwei: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 负极片矩阵点位 type: integer fujipian_panshu: default: 0 @@ -601,7 +607,7 @@ coincellassemblyworkstation_device: type: integer gemo_juzhendianwei: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 隔膜矩阵点位 type: integer gemopanshu: default: 0 @@ -613,7 +619,7 @@ coincellassemblyworkstation_device: type: boolean qiangtou_juzhendianwei: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + description: 枪头盒矩阵点位 type: integer required: - elec_num diff --git a/unilabos/registry/devices/laiyu_liquid_test.yaml b/unilabos/registry/devices/laiyu_liquid_test.yaml index 6d87f4291..e3494cac1 100644 --- a/unilabos/registry/devices/laiyu_liquid_test.yaml +++ b/unilabos/registry/devices/laiyu_liquid_test.yaml @@ -18,6 +18,7 @@ xyz_stepper_controller: goal: properties: degrees: + description: 角度值 type: number required: - degrees @@ -44,6 +45,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -71,6 +73,7 @@ xyz_stepper_controller: properties: enable: default: true + description: True为使能,False为失能 type: boolean required: [] type: object @@ -99,9 +102,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object enable: default: true + description: True为使能,False为失能 type: boolean required: - axis @@ -152,6 +157,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -183,16 +189,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object position: + description: 目标位置(步数) type: integer precision: default: 100 + description: 到位精度 type: integer speed: default: 5000 + description: 运行速度(rpm) type: integer required: - axis @@ -225,16 +236,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object degrees: + description: 目标角度(度) type: number precision: default: 100 + description: 精度 type: integer speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -267,16 +283,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object precision: default: 100 + description: 精度 type: integer revolutions: + description: 目标圈数 type: number speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -309,15 +330,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 运行速度 type: integer x: + description: X轴目标位置 type: integer y: + description: Y轴目标位置 type: integer z: + description: Z轴目标位置 type: integer required: [] type: object @@ -350,15 +376,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_deg: + description: X轴目标角度(度) type: number y_deg: + description: Y轴目标角度(度) type: number z_deg: + description: Z轴目标角度(度) type: number required: [] type: object @@ -391,15 +422,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_rev: + description: X轴目标圈数 type: number y_rev: + description: Y轴目标圈数 type: number z_rev: + description: Z轴目标圈数 type: number required: [] type: object @@ -427,6 +463,7 @@ xyz_stepper_controller: goal: properties: revolutions: + description: 圈数 type: number required: - revolutions @@ -456,10 +493,13 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object speed: + description: 运行速度(rpm),正值正转,负值反转 type: integer required: - axis @@ -487,6 +527,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -513,6 +554,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -564,9 +606,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object timeout: default: 30.0 + description: 超时时间(秒) type: number required: - axis @@ -591,11 +635,14 @@ xyz_stepper_controller: properties: baudrate: default: 115200 + description: 波特率 type: integer port: + description: 串口端口名 type: string timeout: default: 1.0 + description: 通信超时时间 type: number required: - port diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 4d2f72884..5cbd080e6 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -510,9 +510,11 @@ liquid_handler: goal: properties: msg: + description: information to be printed type: string seconds: default: 0 + description: seconds to wait type: string required: [] type: object @@ -2963,15 +2965,22 @@ liquid_handler: additionalProperties: false properties: channel: + description: int maximum: 2147483647 minimum: -2147483648 type: integer dis_to_top: + description: 'float + + Height in mm to move to relative to the well top.' maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number well: additionalProperties: false + description: 'Well + + The target well.' properties: category: type: string @@ -4829,11 +4838,13 @@ liquid_handler: config: properties: backend: + description: Backend to use. type: object channel_num: default: 8 type: integer deck: + description: Deck to use. type: object simulator: default: false @@ -4883,14 +4894,17 @@ liquid_handler.biomek: bind_parent_id: type: string liquid_input_slot: + description: 液体输入槽列表 items: type: integer type: array liquid_type: + description: 液体类型列表 items: type: string type: array liquid_volume: + description: 液体体积列表 items: type: integer type: array @@ -4901,6 +4915,7 @@ liquid_handler.biomek: type: object type: array slot_on_deck: + description: 甲板上的槽位 type: integer required: - resource_tracker @@ -5036,20 +5051,27 @@ liquid_handler.biomek: additionalProperties: false properties: none_keys: + description: 需要设置为None的键列表 items: type: string type: array protocol_author: + description: 协议作者 type: string protocol_date: + description: 协议日期 type: string protocol_description: + description: 协议描述 type: string protocol_name: + description: 协议名称 type: string protocol_type: + description: 协议类型 type: string protocol_version: + description: 协议版本 type: string title: LiquidHandlerProtocolCreation_Goal type: object @@ -6973,7 +6995,7 @@ liquid_handler.laiyu: properties: channel_num: default: 1 - type: string + type: integer deck: type: object host: @@ -6984,10 +7006,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/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 4f3b972ad..63411b539 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -87,7 +87,7 @@ neware_battery_test_system: properties: filepath: default: bts_status.json - description: 输出JSON文件路径 + description: 输出文件路径 type: string required: [] type: object @@ -146,7 +146,7 @@ neware_battery_test_system: goal: properties: plate_num: - description: 盘号 (1 或 2),如果为null则返回所有盘的状态 + description: 盘号 (1 或 2),如果为None则返回所有盘的状态 type: integer required: [] type: object @@ -237,11 +237,11 @@ neware_battery_test_system: goal: properties: csv_path: - description: 输入CSV文件的绝对路径 + description: 输入CSV文件路径 type: string output_dir: default: . - description: 输出目录(用于存储XML和备份文件),默认当前目录 + description: 输出目录,用于存储XML文件和备份,默认当前目录 type: string required: - csv_path @@ -302,14 +302,14 @@ neware_battery_test_system: goal: properties: backup_dir: - description: 备份目录路径(默认使用最近一次submit_from_csv的backup_dir) + description: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir type: string file_pattern: default: '*' - description: 文件通配符模式,例如 *.csv 或 Battery_*.nda + description: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件) type: string oss_prefix: - description: OSS对象路径前缀(默认使用self.oss_prefix) + description: OSS 对象前缀,默认使用类初始化时的配置 type: string required: [] type: object @@ -336,19 +336,25 @@ neware_battery_test_system: config: properties: devtype: + description: 设备类型标识 type: string ip: + description: TCP服务器IP地址 type: string machine_id: default: 1 + description: 机器ID type: integer oss_prefix: default: neware_backup + description: OSS对象路径前缀,默认"neware_backup" type: string oss_upload_enabled: default: false + description: 是否启用OSS上传功能,默认False type: boolean port: + description: TCP端口 type: integer size_x: default: 50 @@ -360,6 +366,7 @@ neware_battery_test_system: default: 20 type: number timeout: + description: 通信超时时间(秒) type: integer required: [] type: object diff --git a/unilabos/registry/devices/organic_miscellaneous.yaml b/unilabos/registry/devices/organic_miscellaneous.yaml index c1290beae..dc81b6710 100644 --- a/unilabos/registry/devices/organic_miscellaneous.yaml +++ b/unilabos/registry/devices/organic_miscellaneous.yaml @@ -207,8 +207,12 @@ separator.homemade: goal: properties: condition: + description: The condition to be monitored, either 'delta' or 'time'. type: string value: + description: 'The threshold value for the condition. + + `delta > 0.05`, `time > 60`' type: string required: - condition @@ -305,12 +309,17 @@ separator.homemade: event: type: string settling_time: + description: The duration for which to settle after stirring, in + seconds. Defaults to 10. type: string stir_speed: + description: The speed of stirring, in RPM. Defaults to 300. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number stir_time: + description: The duration for which to stir, in seconds. Defaults + to 10. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number diff --git a/unilabos/registry/devices/pump_and_valve.yaml b/unilabos/registry/devices/pump_and_valve.yaml index 95a082d53..25d647f7c 100644 --- a/unilabos/registry/devices/pump_and_valve.yaml +++ b/unilabos/registry/devices/pump_and_valve.yaml @@ -456,6 +456,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -481,6 +482,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -687,8 +689,10 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position @@ -1003,6 +1007,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1028,6 +1033,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1234,8 +1240,10 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index 1372140d2..7ab22df61 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -32,7 +32,7 @@ reaction_station.bioyond: type: integer end_point: default: 0 - description: 终点计时点 (Start=开始前, End=结束后) + description: 终点计时点 (Start=0, End=1) type: integer end_step_key: default: '' @@ -40,11 +40,11 @@ reaction_station.bioyond: type: string start_point: default: 0 - description: 起点计时点 (Start=开始前, End=结束后) + description: 起点计时点 (Start=0, End=1) type: integer start_step_key: default: '' - description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择) + description: 起点步骤Key (可选, 默认为空则自动选择) type: string required: - duration @@ -91,6 +91,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 订单参数的JSON字符串 type: string required: - json_str @@ -117,6 +118,7 @@ reaction_station.bioyond: goal: properties: workflow_ids: + description: 要删除的工作流ID数组 items: type: string type: array @@ -145,6 +147,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 'JSON格式的字符串,包含:' type: string required: - json_str @@ -197,6 +200,7 @@ reaction_station.bioyond: goal: properties: web_workflow_json: + description: JSON 格式的网页工作流列表 type: string required: - web_workflow_json @@ -228,8 +232,10 @@ reaction_station.bioyond: goal: properties: reactor_id: + description: 反应器编号 (1-5) type: integer temperature: + description: 目标温度 (°C) type: number required: - reactor_id @@ -257,6 +263,7 @@ reaction_station.bioyond: goal: properties: preintake_id: + description: 通量ID type: string required: - preintake_id @@ -338,6 +345,7 @@ reaction_station.bioyond: goal: properties: value: + description: 工作流 ID 列表 items: type: string type: array @@ -365,6 +373,7 @@ reaction_station.bioyond: goal: properties: workflow_id: + description: 工作流ID type: string required: - workflow_id @@ -424,11 +433,11 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称(不能为空) + description: 物料名称(液体种类) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '90' @@ -436,14 +445,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液公式(mL) + description: 分液量(μL) type: string required: - assign_material_name @@ -525,11 +534,11 @@ reaction_station.bioyond: properties: assign_material_name: default: BAPP - description: 物料名称 + description: 物料名称(试剂瓶位) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -537,15 +546,15 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(int类型, 1=否, 2=是) type: integer volume: default: '350' - description: 分液公式(mL) + description: 分液质量(g) type: string required: [] type: object @@ -593,26 +602,28 @@ reaction_station.bioyond: description: 物料名称 type: string solvents: - description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' + description: '溶剂信息的字典或JSON字符串(可选),格式如下: + + {' type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度设定(C) type: number time: default: '360' - description: 观察时间(分钟),默认360 + description: 观察时间(分钟) type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是),默认NO + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液量(mL)。可直接提供,或通过solvents参数自动计算 + description: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算) type: string required: - assign_material_name @@ -671,33 +682,32 @@ reaction_station.bioyond: description: 物料名称 type: string extracted_actuals: - description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定) + description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume type: string feeding_order_data: - description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例: - {"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}' + description: feeding_order JSON字符串或对象,用于获取m二酐值 type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度(C) type: number time: default: '90' - description: 观察时间(分钟),默认90 + description: 观察时间(分钟) type: string titration_type: default: '2' - description: 是否滴定(NO=否, YES=是),默认YES + description: 是否滴定(NO=1, YES=2),默认2 type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 + description: 分液公式(μL),如果提供则直接使用,否则自动计算 type: string x_value: - description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 + description: 手工输入的x值,格式如 "1-2-3" type: string required: - assign_material_name @@ -738,7 +748,7 @@ reaction_station.bioyond: type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '0' @@ -746,14 +756,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL) + description: 分液公式(μL) type: string required: - volume_formula @@ -786,7 +796,7 @@ reaction_station.bioyond: description: 任务名称 type: string workflow_name: - description: 工作流名称 + description: 合并后的工作流名称 type: string required: - workflow_name @@ -819,15 +829,15 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称 + description: 物料名称(不能为空) type: string cutoff: default: '900000' - description: 粘度上限 + description: 粘度上限(需为有效数字字符串,默认 "900000") type: string temperature: default: -10.0 - description: 温度设定(°C) + description: 温度设定(C,范围:-50.00 至 100.00) type: number required: - assign_material_name @@ -909,11 +919,11 @@ reaction_station.bioyond: description: 物料名称(用于获取试剂瓶位ID) type: string material_id: - description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟) + description: 粉末类型ID, Salt=1, Flour=2, BTDA=3 type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -921,7 +931,7 @@ reaction_station.bioyond: type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer required: - material_id @@ -945,10 +955,13 @@ reaction_station.bioyond: config: properties: config: + description: 配置字典,应包含workflow_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index d48746772..b96e53412 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -198,6 +198,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes option, target, + speed, lift_height, mt_height type: string title: SendCmd_Goal type: object @@ -241,6 +243,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -284,6 +288,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml index 74b01e806..14539321d 100644 --- a/unilabos/registry/devices/robot_linear_motion.yaml +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -709,6 +709,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes option, target, + speed, lift_height, mt_height type: string title: SendCmd_Goal type: object @@ -752,6 +754,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -795,6 +799,7 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object 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/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index b828c6d29..a34d6f556 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -2179,6 +2179,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 端口号 (1-8) type: integer required: - port_number @@ -2225,6 +2226,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 目标端口号 (1-8) type: integer required: - port_number @@ -2261,6 +2263,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -2304,6 +2307,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -4215,6 +4219,7 @@ virtual_solenoid_valve: additionalProperties: false properties: string: + description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"' type: string title: StrSingleInput_Goal type: object @@ -4258,6 +4263,7 @@ virtual_solenoid_valve: additionalProperties: false properties: command: + description: '"OPEN"/"CLOSED" 或其他控制命令' type: string title: SendCmd_Goal type: object @@ -4418,16 +4424,20 @@ virtual_solid_dispenser: event: type: string mass: + description: 质量字符串 (如 "2.9 g") type: string mol: + description: 摩尔数字符串 (如 "0.12 mol") type: string purpose: + description: 添加目的 type: string rate_spec: type: string ratio: type: string reagent: + description: 试剂名称 type: string stir: type: boolean @@ -4439,6 +4449,7 @@ virtual_solid_dispenser: type: string vessel: additionalProperties: false + description: 目标容器 properties: category: type: string @@ -5568,8 +5579,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 拉取速度 (ml/s) type: number volume: + description: 要拉取的体积 (ml) type: number required: - volume @@ -5596,8 +5609,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 推出速度 (ml/s) type: number volume: + description: 要推出的体积 (ml) type: number required: - volume @@ -5693,10 +5708,12 @@ virtual_transfer_pump: additionalProperties: false properties: max_velocity: + description: 移动速度 (ml/s) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number position: + description: 目标位置 (ml) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -5845,8 +5862,10 @@ virtual_transfer_pump: config: properties: config: + description: 配置字典,包含max_volume, port等参数 type: object device_id: + description: 设备ID type: string required: [] type: object diff --git a/unilabos/registry/devices/xrd_d7mate.yaml b/unilabos/registry/devices/xrd_d7mate.yaml index 2b49ae552..38e31718a 100644 --- a/unilabos/registry/devices/xrd_d7mate.yaml +++ b/unilabos/registry/devices/xrd_d7mate.yaml @@ -409,11 +409,11 @@ xrd_d7mate: properties: end_theta: default: 80.0 - description: 结束角度(≥5.5°,且必须大于start_theta) + description: 结束角度(≥5.5°,且必须大于 start_theta) type: number exp_time: default: 0.1 - description: 曝光时间(0.1-5.0秒) + description: 曝光时间(0.1-5.0 秒) type: number increment: default: 0.05 @@ -421,7 +421,7 @@ xrd_d7mate: type: number sample_id: default: '' - description: 样品标识符 + description: 样品名称 type: string start_theta: default: 10.0 @@ -433,7 +433,7 @@ xrd_d7mate: type: string wait_minutes: default: 3.0 - description: 允许上样后等待分钟数 + description: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟) type: number required: [] title: StartWorkflow_Goal @@ -492,12 +492,15 @@ xrd_d7mate: properties: host: default: 127.0.0.1 + description: 设备IP地址 type: string port: default: 6001 + description: 通信端口,默认6001 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/devices/zhida_gcms.yaml b/unilabos/registry/devices/zhida_gcms.yaml index 37adbd795..b10b29ad9 100644 --- a/unilabos/registry/devices/zhida_gcms.yaml +++ b/unilabos/registry/devices/zhida_gcms.yaml @@ -217,6 +217,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: Base64编码的CSV数据(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -257,6 +258,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: CSV文件路径(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -289,12 +291,15 @@ zhida_gcms: properties: host: default: 192.168.3.184 + description: 设备IP地址,本地部署时可使用'127.0.0.1' type: string port: default: 5792 + description: 通信端口,默认5792 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index aa3db9b2f..75677b4f2 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -271,6 +271,7 @@ def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only= registry_cache.pkl 一个文件中,删除即可完全重置。 """ import time as _time + from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION from unilabos.registry.ast_registry_scanner import scan_directory scan_t0 = _time.perf_counter() @@ -286,6 +287,10 @@ def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only= # ---- 统一缓存:一个 pkl 包含所有数据 ---- unified_cache = self._load_config_cache() ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}}) + if ast_cache.get("version") != AST_SCAN_CACHE_VERSION: + ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}} + unified_cache["_ast_scan"] = ast_cache + unified_cache.pop("_build_results", None) # 默认:扫描 unilabos 包所在的父目录 pkg_root = Path(__file__).resolve().parent.parent # .../unilabos @@ -561,13 +566,47 @@ def _generate_schema_from_info( return prop_schema + @staticmethod + def _apply_docstring_param_metadata( + schema: Dict[str, Any], + doc_info: Dict[str, Any], + field_to_param: Optional[Dict[str, str]] = None, + apply_defaults: bool = False, + ) -> None: + """Apply parsed docstring display names and descriptions to schema properties.""" + if not schema or not doc_info: + return + + props = schema.get("properties", {}) + if not isinstance(props, dict): + return + + param_descs = doc_info.get("params", {}) or {} + param_display_names = doc_info.get("param_display_names", {}) or {} + for field_name, prop_schema in props.items(): + if not isinstance(prop_schema, dict): + continue + param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name + if not isinstance(param_name, str): + continue + param_name = param_name.removesuffix("[]") + if param_name in param_display_names: + prop_schema["title"] = param_display_names[param_name] + elif apply_defaults and not prop_schema.get("title"): + prop_schema["title"] = field_name + + if param_name in param_descs: + prop_schema["description"] = param_descs[param_name] + elif apply_defaults and "description" not in prop_schema: + prop_schema["description"] = "" + def _generate_unilab_json_command_schema( self, method_args: list, docstring: Optional[str] = None, import_map: Optional[Dict[str, str]] = None, + apply_doc_defaults: bool = False, ) -> Dict[str, Any]: """根据方法参数和 docstring 生成 UniLabJsonCommand schema""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema = { "type": "object", @@ -598,12 +637,10 @@ def _generate_unilab_json_command_schema( param_name, param_type, param_default, import_map=import_map ) - if param_name in param_descs: - schema["properties"][param_name]["description"] = param_descs[param_name] - if param_required: schema["required"].append(param_name) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults) return schema def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]: @@ -799,6 +836,7 @@ def _build_json_command_entry(method_name, method_info, action_args=None): type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand" params = method_info.get("params", []) method_doc = method_info.get("docstring") + method_doc_info = parse_docstring(method_doc) goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap) if action_args is not None: @@ -828,7 +866,11 @@ def _build_json_command_entry(method_name, method_info, action_args=None): # action handles: 从 @action(handles=[...]) 提取并转换为标准格式 raw_handles = (action_args or {}).get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充 pk = detect_placeholder_keys(params) @@ -847,7 +889,12 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "goal": goal, "feedback": (action_args or {}).get("feedback") or {}, "result": (action_args or {}).get("result") or {}, - "schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema), + "schema": wrap_action_schema( + goal_schema, + action_name, + description=(action_args or {}).get("description") or method_doc_info.get("description", ""), + result_schema=result_schema, + ), "goal_default": goal_default, "handles": handles, "placeholder_keys": pk, @@ -886,7 +933,11 @@ def _build_json_command_entry(method_name, method_info, action_args=None): action_name = f"auto-{action_name}" raw_handles = action_args.get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) method_params = method_info.get("params", []) @@ -979,7 +1030,10 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "schema": schema, "goal_default": goal_default, "handles": handles, - "placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})}, + "placeholder_keys": { + **detect_placeholder_keys(method_params), + **(action_args.get("placeholder_keys") or {}), + }, } if action_args.get("always_free") or method_info.get("always_free"): action_entry["always_free"] = True @@ -988,13 +1042,22 @@ def _build_json_command_entry(method_name, method_info, action_args=None): nt = normalize_enum_value(action_args.get("node_type"), NodeType) if nt: action_entry["node_type"] = nt + goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {}) + self._apply_docstring_param_metadata( + goal_schema_for_docs, + parse_docstring(method_info.get("docstring")), + goal, + apply_defaults=True, + ) action_value_mappings[action_name] = action_entry action_value_mappings = dict(sorted(action_value_mappings.items())) # --- init_param_schema = { config: , data: } --- init_params = ast_meta.get("init_params", []) - config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap) + config_schema = self._generate_schema_from_ast_params( + init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap + ) data_schema = self._generate_status_schema_from_ast( ast_meta.get("status_properties", {}), imap ) @@ -1042,7 +1105,6 @@ def _generate_schema_from_ast_params( ) -> Dict[str, Any]: """Generate JSON Schema from AST-extracted parameter list.""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema: Dict[str, Any] = { "type": "object", @@ -1072,12 +1134,10 @@ def _generate_schema_from_ast_params( pname, ptype, pdefault, import_map ) - if pname in param_descs: - schema["properties"][pname]["description"] = param_descs[pname] - if prequired: schema["required"].append(pname) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True) return schema def _generate_status_schema_from_ast( @@ -1807,7 +1867,7 @@ def _load_single_device_file( else: action_key = f"auto-{k}" goal_schema = self._generate_unilab_json_command_schema( - v["args"], import_map=enhanced_import_map + v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map ) ret_type = v.get("return_type", "") result_schema = None @@ -1816,7 +1876,13 @@ def _load_single_device_file( "result", ret_type, None, import_map=enhanced_import_map ) old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {}) - new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema) + doc_info = parse_docstring(v.get("docstring")) + new_schema = wrap_action_schema( + goal_schema, + action_key, + description=doc_info.get("description", ""), + result_schema=result_schema, + ) old_schema = old_cfg.get("schema", {}) if old_schema: preserve_field_descriptions(new_schema, old_schema) @@ -1882,6 +1948,12 @@ def _load_single_device_file( merged_pk = dict(old_cfg.get("placeholder_keys", {})) merged_pk.update(detect_placeholder_keys(v["args"])) + goal_schema_for_docs = ( + entry_schema.get("properties", {}).get("goal", {}) + if isinstance(entry_schema, dict) + else {} + ) + self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal) entry = { "type": entry_type, @@ -1902,7 +1974,8 @@ def _load_single_device_file( device_config["init_param_schema"] = {} init_schema = self._generate_unilab_json_command_schema( - enhanced_info["init_params"], "__init__", + enhanced_info["init_params"], + docstring=enhanced_info.get("init_docstring"), import_map=enhanced_import_map, ) device_config["init_param_schema"]["config"] = init_schema @@ -1949,7 +2022,9 @@ def _load_single_device_file( action_str_type_mapping[action_type_str] = target_type if target_type is not None: try: - action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict() + action_config["goal_default"] = ROS2MessageInstance( + target_type.Goal() + ).get_python_dict() except Exception: action_config["goal_default"] = {} prev_schema = action_config.get("schema", {}) @@ -2141,6 +2216,7 @@ def obtain_registry_device_info(self): "unilabos_device_id": { "type": "string", "default": "", + "title": "设备ID", "description": "UniLabOS设备ID,用于指定执行动作的具体设备实例", }, **schema["properties"]["goal"]["properties"], @@ -2212,7 +2288,14 @@ def get_yaml_output(self, device_id: str) -> str: lab_registry = Registry() -def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False): +def build_registry( + registry_paths=None, + devices_dirs=None, + upload_registry=False, + check_mode=False, + complete_registry=False, + external_only=False, +): """ 构建或获取Registry单例实例 """ @@ -2226,7 +2309,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False if path not in current_paths: lab_registry.registry_paths.append(path) - lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only) + lab_registry.setup( + devices_dirs=devices_dirs, + upload_registry=upload_registry, + complete_registry=complete_registry, + external_only=external_only, + ) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) lab_registry.resolve_all_types() diff --git a/unilabos/registry/utils.py b/unilabos/registry/utils.py index eb342c5c7..6b1acb2be 100644 --- a/unilabos/registry/utils.py +++ b/unilabos/registry/utils.py @@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception): # --------------------------------------------------------------------------- _SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$") +_PARAM_HEADER_RE = re.compile( + r"^\s*(?P\w[\w]*)\s*(?:\[(?P[^\]]+)\])?(?:\s*\([^)]*\))?\s*$" +) + + +def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]: + """Parse ``name[display_name]`` or Google-style ``name (type)``.""" + match = _PARAM_HEADER_RE.match(param_part.strip()) + if not match: + return param_part.strip().split("(")[0].strip(), None + + display_name = match.group("display_name") + if display_name is not None: + display_name = display_name.strip() or None + return match.group("name").strip(), display_name def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: """ - 解析 Google-style docstring,提取描述和参数说明。 + 解析 docstring,提取描述和参数说明。 + + 支持: + - Google-style ``Args:`` / ``Parameters:`` 小节 + - 直接参数行 ``field: desc`` + - 带显示名参数行 ``field[Display Name]: desc`` Returns: - {"description": "短描述", "params": {"param1": "参数1描述", ...}} + { + "description": "短描述", + "params": {"param1": "参数1描述", ...}, + "param_display_names": {"param1": "显示名", ...}, + } """ - result: Dict[str, Any] = {"description": "", "params": {}} + result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}} if not docstring: return result @@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: if not lines: return result - result["description"] = lines[0].strip() - in_args = False + current_section: Optional[str] = None current_param: Optional[str] = None + current_display_name: Optional[str] = None current_desc_parts: list = [] - for line in lines[1:]: + def flush_current_param() -> None: + nonlocal current_param, current_display_name, current_desc_parts + if current_param is None: + return + result["params"][current_param] = "\n".join(current_desc_parts).strip() + if current_display_name: + result["param_display_names"][current_param] = current_display_name + current_param = None + current_display_name = None + current_desc_parts = [] + + first_line = lines[0].strip() + start_index = 0 + if not _SECTION_RE.match(first_line) and ":" not in first_line: + result["description"] = first_line + start_index = 1 + + for line in lines[start_index:]: stripped = line.strip() + if not stripped: + if current_param is not None: + current_desc_parts.append("") + continue + section_match = _SECTION_RE.match(stripped) if section_match: - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() - current_param = None - current_desc_parts = [] - section_name = section_match.group(1).lower() - in_args = section_name in ("args", "arguments", "parameters", "params") + flush_current_param() + current_section = section_match.group(1).lower() + in_args = current_section in ("args", "arguments", "parameters", "params") continue - if not in_args: + parse_as_param = in_args or current_section is None + if not parse_as_param: continue - if ":" in stripped and not stripped.startswith(" "): - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + if ":" in stripped: + flush_current_param() param_part, _, desc_part = stripped.partition(":") - param_name = param_part.strip().split("(")[0].strip() + param_name, display_name = _parse_docstring_param_header(param_part) current_param = param_name + current_display_name = display_name current_desc_parts = [desc_part.strip()] elif current_param is not None: aline = line @@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: aline = aline[1:] current_desc_parts.append(aline.strip()) - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + flush_current_param() return result 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/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 72514b99e..3177d61b4 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -45,6 +45,7 @@ ) from unilabos.resources.plr_additional_res_reg import register from unilabos.ros.msgs.message_converter import ( + String, convert_to_ros_msg, convert_from_ros_msg_with_mapping, convert_to_ros_msg_with_mapping, @@ -250,7 +251,8 @@ def __init__( ): self.node = node self.name = name - self.msg_type = msg_type + self.msg_type = self._normalize_msg_type(msg_type) + self.original_msg_type = msg_type self.get_method = get_method self.timer_period = initial_period self.print_publish = print_publish @@ -258,16 +260,36 @@ def __init__( self._value = None try: - self.publisher_ = node.create_publisher(msg_type, f"{name}", qos) + self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos) except Exception as e: self.node.lab_logger().error( - f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {e}" + f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败," + f"可能由于注册表有误,类型: {msg_type},错误: {e}" ) + self.msg_type = String + try: + self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos) + self.node.lab_logger().warning( + f"属性 {name} 的发布类型已降级为 String,原始类型: {msg_type}" + ) + except Exception: + self.publisher_ = None self.timer = node.create_timer(self.timer_period, self.publish_property) self.__loop = ROS2DeviceNode.get_asyncio_loop() - str_msg_type = str(msg_type)[8:-2] + str_msg_type = str(self.msg_type)[8:-2] self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") + @staticmethod + def _normalize_msg_type(msg_type): + if msg_type in (dict, list, tuple, set) or msg_type in ("dict", "list", "tuple", "set"): + return String + return msg_type + + def _normalize_value(self, value): + if self.msg_type is String and isinstance(value, (dict, list, tuple, set)): + return json.dumps(value, ensure_ascii=False, cls=TypeEncoder) + return value + def get_property(self): if asyncio.iscoroutinefunction(self.get_method): # 如果是异步函数,运行事件循环并等待结果 @@ -302,12 +324,16 @@ def publish_property(self): pass # self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}") if value is not None: + if self.publisher_ is None: + return + value = self._normalize_value(value) msg = convert_to_ros_msg(self.msg_type, value) self.publisher_.publish(msg) # self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功") except Exception as e: + topic = getattr(self.publisher_, "topic", self.name) self.node.lab_logger().error( - f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}" + f"【.publish_property】发布属性 {topic} 出错: {str(e)}\n{traceback.format_exc()}" ) def change_frequency(self, period): @@ -1721,8 +1747,14 @@ def _publish_feedback(): _poll_future = Future() def _on_sync_done(fut): - if not _poll_future.done(): - _poll_future.set_result(None) + async def _wake(): + if not _poll_future.done(): + _poll_future.set_result(None) + + # ThreadPoolExecutor callbacks run outside the rclpy executor. + # Wake the awaiting action coroutine from the executor thread; + # otherwise it may only resume when the executor naturally wakes up. + rclpy.get_global_executor().create_task(_wake()) future.add_done_callback(_on_sync_done) await _poll_future diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 26b925bb6..9e34c16b4 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -1691,7 +1691,9 @@ def handle_pong_response(self, pong_data: dict): else: self.lab_logger().warning("⚠️ 收到无效的Pong响应(缺少ping_id)") - def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid_list: List[str]) -> bool: + def notify_resource_tree_update( + self, device_id: str, action: str, resource_uuid_list: List[str] + ) -> Optional[bool]: """ 通知设备节点更新资源树 @@ -1701,13 +1703,14 @@ def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid resource_uuid_list: 资源UUIDs Returns: - bool: 操作是否成功 + True if the update completed, False if it failed, None if it was intentionally skipped. """ try: - # 检查设备是否存在 if device_id not in self.devices_names: - self.lab_logger().error(f"[Host Node-Resource] Device {device_id} not found in devices_names") - return False + self.lab_logger().info( + f"[Host Node-Resource] 在线增加设备暂不支持,跳过设备 {device_id} 的资源树 {action} 更新" + ) + return None namespace = self.devices_names[device_id] device_key = f"{namespace}/{device_id}" 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 diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 18b5f1583..5dcff22f9 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -33,10 +33,83 @@ def _is_chinese_locale() -> bool: def _has_uv() -> bool: global _USE_UV if _USE_UV is None: - _USE_UV = shutil.which("uv") is not None + uv_path = shutil.which("uv") + if not uv_path: + _USE_UV = False + else: + try: + result = subprocess.run([uv_path, "--version"], capture_output=True, text=True, timeout=10) + _USE_UV = result.returncode == 0 + except Exception: + _USE_UV = False return _USE_UV +def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: + if installer == "uv": + # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。 + # 显式 --python sys.executable 让 uv 把当前解释器(conda/venv/system 都行) + # 视为目标环境,绕开 venv 检测。 + cmd = ["uv", "pip", "install", "--python", sys.executable] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + +def _installer_candidates() -> List[str]: + installers: List[str] = [] + if _has_uv(): + installers.append("uv") + installers.append("pip") + return installers + + +def _git_url_from_requirement(requirement: str) -> Optional[str]: + if not requirement.startswith("git+"): + return None + return requirement[4:].split("#", 1)[0] + + +def _repo_dir_name(git_url: str) -> str: + repo_name = git_url.rstrip("/").rsplit("/", 1)[-1] + return repo_name[:-4] if repo_name.endswith(".git") else repo_name + + +def _print_manual_git_install_hint(requirement: str) -> None: + git_url = _git_url_from_requirement(requirement) + if not git_url: + return + + repo_dir = _repo_dir_name(git_url) + install_cmd = ( + f'uv pip install --python "{sys.executable}" -e .' + if _has_uv() + else f"{sys.executable} -m pip install -e ." + ) + if _is_chinese_locale() and not _has_uv(): + install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" + + print_status("Git 依赖自动安装失败,通常是网络连接被重置或代码托管站点暂时不可达。", "warning") + print_status("可以手动拉取代码后在本地安装:", "warning") + print_status(f" git clone {git_url}", "warning") + print_status(f" cd {repo_dir}", "warning") + print_status(" git pull", "warning") + print_status(f" {install_cmd}", "warning") + print_status(f"如果目录 {repo_dir} 已存在,直接进入该目录执行 git pull 后再安装。", "warning") + print_status("如果 git clone 仍失败,请切换网络/代理,或从浏览器下载源码后进入源码目录执行本地安装命令。", "warning") + + def _install_packages( packages: List[str], upgrade: bool = False, @@ -53,7 +126,7 @@ def _install_packages( return True is_chinese = _is_chinese_locale() - use_uv = _has_uv() + installers = _installer_candidates() failed: List[str] = [] for pkg in packages: @@ -63,35 +136,30 @@ def _install_packages( else: print_status(f"正在{action_word} {pkg}...", "info") - if use_uv: - cmd = ["uv", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - else: - cmd = [sys.executable, "-m", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - if result.returncode == 0: - installer = "uv" if use_uv else "pip" - print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") - else: - stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" - print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error") - failed.append(pkg) - except subprocess.TimeoutExpired: - print_status(f"× {pkg} {action_word}超时 (300s)", "error") - failed.append(pkg) - except Exception as e: - print_status(f"× {pkg} {action_word}异常: {e}", "error") + pkg_installed = False + last_error = "unknown error" + + for installer in installers: + cmd = _install_command(installer, pkg, upgrade, is_chinese) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") + pkg_installed = True + break + + last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" + print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning") + except subprocess.TimeoutExpired: + last_error = "timeout after 300s" + print_status(f"× {pkg} {action_word}超时 (via {installer}, 300s)", "warning") + except Exception as e: + last_error = str(e) + print_status(f"× {pkg} {action_word}异常 (via {installer}): {e}", "warning") + + if not pkg_installed: + print_status(f"× {pkg} {action_word}失败: {last_error}", "error") + _print_manual_git_install_hint(pkg) failed.append(pkg) if failed: diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index 7fe2f501e..8d0e8bf10 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -206,6 +206,7 @@ def get_enhanced_class_info(self, module_path: str, **_kwargs) -> Dict[str, Any] "ast_analysis_success": False, "import_map": {}, "init_params": [], + "init_docstring": None, "status_methods": {}, "action_methods": {}, } @@ -251,6 +252,7 @@ def get_enhanced_class_info(self, module_path: str, **_kwargs) -> Dict[str, Any] # 映射到统一字段名(与 registry.py complete_registry 消费端一致) result["init_params"] = body.get("init_params", []) + result["init_docstring"] = body.get("init_docstring") result["status_methods"] = body.get("status_properties", {}) result["action_methods"] = { k: { diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index da085f147..25fe99cb0 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -63,10 +63,31 @@ class ColoredFormatter(logging.Formatter): "DATE": "\033[37m", # 日期始终使用灰色 } - def __init__(self, use_colors=True): + def __init__(self, use_colors=True, microseconds=False, show_thread=False): super().__init__() # 强制启用颜色 self.use_colors = use_colors + # microseconds: 保留微秒级时间戳(默认毫秒),便于精确排查时序 + self.microseconds = microseconds + # show_thread: 输出线程名,便于区分 queue/收发等并发逻辑 + self.show_thread = show_thread + + def _format_datetime(self, record) -> str: + """构建时间戳字符串,可选微秒级精度""" + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f") + if not self.microseconds: + datetime_str = datetime_str[:-3] # 截断到毫秒 + return datetime_str + "]" + + def _format_right_info(self, record) -> str: + """构建右侧的线程/函数/模块定位信息""" + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + thread_part = f" [{record.threadName}]" if self.show_thread else "" + return f"{thread_part} [{func_line}] [{module_path}]" def format(self, record): # 检查是否有自定义堆栈信息 @@ -87,15 +108,10 @@ def format(self, record): reset = self.COLORS["RESET"] # 日期格式 - datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + datetime_str = self._format_datetime(record) - # 模块和函数信息 - filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) - if "/" in filename: - filename = filename.split("/")[-1] - module_path = f"{record.name}.{filename}" - func_line = f"{record.funcName}:{record.lineno}" - right_info = f" [{func_line}] [{module_path}]" + # 线程、模块和函数信息 + right_info = self._format_right_info(record) # 主要消息 main_msg = record.getMessage() @@ -123,13 +139,8 @@ def format(self, record): def _format_basic(self, record): """基本格式化,不包含颜色""" - datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" - filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) - if "/" in filename: - filename = filename.split("/")[-1] - module_path = f"{record.name}.{filename}" - func_line = f"{record.funcName}:{record.lineno}" - right_info = f" [{func_line}] [{module_path}]" + datetime_str = self._format_datetime(record) + right_info = self._format_right_info(record) formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}" @@ -152,6 +163,26 @@ def formatException(self, exc_info): return formatted_exc +def _to_numeric_level(loglevel, default=logging.DEBUG) -> int: + """将日志级别(字符串/常量)统一转换为数字级别。 + + Args: + loglevel: 'TRACE'/'DEBUG'/'INFO'/... 字符串,或 logging 常量,或 None + default: 解析失败或为 None 时使用的默认级别 + """ + if loglevel is None: + return default + if isinstance(loglevel, str): + if loglevel.upper() == "TRACE": + return TRACE_LEVEL + numeric_level = getattr(logging, loglevel.upper(), None) + if not isinstance(numeric_level, int): + print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") + return default + return numeric_level + return loglevel + + # 配置日志处理器 def configure_logger(loglevel=None, working_dir=None): """配置日志记录器 @@ -164,18 +195,7 @@ def configure_logger(loglevel=None, working_dir=None): root_logger = logging.getLogger() root_logger.setLevel(TRACE_LEVEL) # 设置日志级别 - numeric_level = logging.DEBUG - if loglevel is not None: - if isinstance(loglevel, str): - # 将字符串转换为logging级别 - if loglevel.upper() == "TRACE": - numeric_level = TRACE_LEVEL - else: - numeric_level = getattr(logging, loglevel.upper(), None) - if not isinstance(numeric_level, int): - print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") - else: - numeric_level = loglevel + numeric_level = _to_numeric_level(loglevel) # 移除已存在的处理器 for handler in root_logger.handlers[:]: @@ -217,6 +237,96 @@ def configure_logger(loglevel=None, working_dir=None): return log_filepath +# ============================================================================ +# 服务端通信(WebSocket)独立日志 +# 单独成文件、全量保留到本地、微秒级时间戳 + 线程名,便于排查通信/queue 时序问题 +# ============================================================================ +COMM_LOGGER_NAME = "unilabos.comm" +_comm_file_handler: "logging.Handler | None" = None # 便于重启时清理 websockets 库 handler + + +def _attach_trace_method(target_logger: logging.Logger) -> logging.Logger: + """为指定 logger 附加 .trace 方法,行为与模块级 trace 一致。 + + 通过 stacklevel=2 跳过本包装函数,使日志定位到真实调用处而非此处。 + """ + if not hasattr(target_logger, "trace"): + def _trace(msg, *args, _lg=target_logger, **kwargs): + kwargs.setdefault("stacklevel", 2) + _lg.log(TRACE_LEVEL, msg, *args, **kwargs) + + target_logger.trace = _trace # type: ignore[attr-defined] + return target_logger + + +def get_comm_logger() -> logging.Logger: + """获取通信专用 logger。 + + 未调用 ``configure_comm_logger`` 之前,该 logger 没有独立 handler 且 + ``propagate=True``,会回退到根 logger,行为与现状一致(安全降级)。 + """ + return _attach_trace_method(logging.getLogger(COMM_LOGGER_NAME)) + + +def configure_comm_logger(working_dir=None, loglevel=None): + """为服务端通信(WebSocket)配置独立日志,复用 ``ColoredFormatter`` 逻辑。 + + - 独立文件:``/logs/ws_comm_<日期 时间>.log``,TRACE 全量落本地 + - 微秒级时间戳 + 线程名,便于排查 queue 机制、收发时序与并发标识 + - ``propagate=False``,与主日志解耦,避免日志混在一起 + - 控制台仍保留实时输出(级别与主控制台一致),不丢失现有可见性 + - 同步把 ``websockets`` 库自身的协议日志(握手/ping/pong/关闭)落到同一文件 + + Args: + working_dir: 工作目录(``unilabos_data``),None 时不写文件 + loglevel: 控制台日志级别,与主日志保持一致 + + Returns: + 日志文件绝对路径(未配置文件时为 None) + """ + global _comm_file_handler + + comm_logger = get_comm_logger() + comm_logger.setLevel(TRACE_LEVEL) + comm_logger.propagate = False # 与根 logger 解耦,单独成文件 + + # 移除旧 handler,支持重启重复调用 + for handler in comm_logger.handlers[:]: + comm_logger.removeHandler(handler) + handler.close() + + # 控制台 handler:保留实时可见性,带线程名便于现场观察 + console_handler = logging.StreamHandler() + console_handler.setLevel(_to_numeric_level(loglevel)) + console_handler.setFormatter(ColoredFormatter(use_colors=True, show_thread=True)) + comm_logger.addHandler(console_handler) + + log_filepath = None + if working_dir is not None: + logs_dir = os.path.join(working_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + + log_filename = "ws_comm_" + datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log" + log_filepath = os.path.join(logs_dir, log_filename) + + file_handler = logging.FileHandler(log_filepath, encoding="utf-8") + file_handler.setLevel(TRACE_LEVEL) # 全量保留到本地 + # 文件不带颜色,开启微秒精度 + 线程名 + file_handler.setFormatter(ColoredFormatter(use_colors=False, microseconds=True, show_thread=True)) + comm_logger.addHandler(file_handler) + + # websockets 库自身日志(协议层)也归集到同一文件,方便排查链路问题; + # 保持其 propagate=True,不影响主日志原有行为。 + ws_lib_logger = logging.getLogger("websockets") + if _comm_file_handler is not None and _comm_file_handler in ws_lib_logger.handlers: + ws_lib_logger.removeHandler(_comm_file_handler) + ws_lib_logger.addHandler(file_handler) + _comm_file_handler = file_handler + + comm_logger.info(f"[CommLogger] 通信日志已初始化,文件: {log_filepath}") + return log_filepath + + # 配置日志系统 configure_logger() diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index 175521172..3e49ed044 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.11.1 + 0.11.3 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln