From 227ff1284abe1f8f372064b393d09cda64032377 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Tue, 19 Aug 2025 21:35:27 +0800 Subject: [PATCH 01/13] add workstation template and battery example --- .../coin_cell_assembly_workstation.py | 454 ++++++ unilabos/device_comms/workstation_base.py | 1302 +++++++++++++++++ .../device_comms/workstation_communication.py | 600 ++++++++ .../workstation_material_management.py | 583 ++++++++ 4 files changed, 2939 insertions(+) create mode 100644 unilabos/device_comms/coin_cell_assembly_workstation.py create mode 100644 unilabos/device_comms/workstation_base.py create mode 100644 unilabos/device_comms/workstation_communication.py create mode 100644 unilabos/device_comms/workstation_material_management.py diff --git a/unilabos/device_comms/coin_cell_assembly_workstation.py b/unilabos/device_comms/coin_cell_assembly_workstation.py new file mode 100644 index 00000000..62d9b09c --- /dev/null +++ b/unilabos/device_comms/coin_cell_assembly_workstation.py @@ -0,0 +1,454 @@ +""" +纽扣电池组装工作站 +Coin Cell Assembly Workstation + +继承工作站基类,实现纽扣电池特定功能 +""" +from typing import Dict, Any, List, Optional, Union + +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo +from unilabos.device_comms.workstation_communication import ( + WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication +) +from unilabos.device_comms.workstation_material_management import ( + MaterialManagementBase, CoinCellMaterialManagement +) +from unilabos.utils.log import logger + + +class CoinCellAssemblyWorkstation(WorkstationBase): + """纽扣电池组装工作站 + + 基于工作站基类,实现纽扣电池制造的特定功能: + 1. 纽扣电池特定的通信协议 + 2. 纽扣电池物料管理(料板、极片、电池等) + 3. 电池制造工作流 + 4. 质量检查工作流 + """ + + def __init__( + self, + device_id: str, + children: Dict[str, Dict[str, Any]], + protocol_type: Union[str, List[str]] = "BatteryManufacturingProtocol", + resource_tracker: Optional[DeviceNodeResourceTracker] = None, + modbus_config: Optional[Dict[str, Any]] = None, + deck_config: Optional[Dict[str, Any]] = None, + csv_path: str = "./coin_cell_assembly.csv", + *args, + **kwargs, + ): + # 设置通信配置 + modbus_config = modbus_config or {"host": "127.0.0.1", "port": 5021} + self.communication_config = CommunicationConfig( + protocol=CommunicationProtocol.MODBUS_TCP, + host=modbus_config["host"], + port=modbus_config["port"], + timeout=modbus_config.get("timeout", 5.0), + retry_count=modbus_config.get("retry_count", 3) + ) + + # 设置台面配置 + self.deck_config = deck_config or { + "size_x": 1620.0, + "size_y": 1270.0, + "size_z": 500.0 + } + + # CSV地址映射文件路径 + self.csv_path = csv_path + + # 创建资源跟踪器(如果没有提供) + if resource_tracker is None: + from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker + resource_tracker = DeviceNodeResourceTracker() + + # 初始化基类 + super().__init__( + device_id=device_id, + children=children, + protocol_type=protocol_type, + resource_tracker=resource_tracker, + communication_config=self.communication_config, + deck_config=self.deck_config, + *args, + **kwargs + ) + + logger.info(f"纽扣电池组装工作站 {device_id} 初始化完成") + + def _create_communication_module(self) -> WorkstationCommunicationBase: + """创建纽扣电池通信模块""" + return CoinCellCommunication( + communication_config=self.communication_config, + csv_path=self.csv_path + ) + + def _create_material_management_module(self) -> MaterialManagementBase: + """创建纽扣电池物料管理模块""" + return CoinCellMaterialManagement( + device_id=self.device_id, + deck_config=self.deck_config, + resource_tracker=self.resource_tracker, + children_config=self.children + ) + + def _register_supported_workflows(self): + """注册纽扣电池工作流""" + # 电池制造工作流 + self.supported_workflows["battery_manufacturing"] = WorkflowInfo( + name="battery_manufacturing", + description="纽扣电池制造工作流", + estimated_duration=300.0, # 5分钟 + required_materials=["cathode_sheet", "anode_sheet", "separator", "electrolyte"], + output_product="coin_cell_battery", + parameters_schema={ + "type": "object", + "properties": { + "electrolyte_num": { + "type": "integer", + "description": "电解液瓶数", + "minimum": 1, + "maximum": 32 + }, + "electrolyte_volume": { + "type": "number", + "description": "电解液体积 (μL)", + "minimum": 0.1, + "maximum": 100.0 + }, + "assembly_pressure": { + "type": "number", + "description": "组装压力 (N)", + "minimum": 100.0, + "maximum": 5000.0 + }, + "cathode_material": { + "type": "string", + "description": "正极材料类型", + "enum": ["LiFePO4", "LiCoO2", "NCM", "LMO"] + }, + "anode_material": { + "type": "string", + "description": "负极材料类型", + "enum": ["Graphite", "LTO", "Silicon"] + } + }, + "required": ["electrolyte_num", "electrolyte_volume", "assembly_pressure"] + } + ) + + # 质量检查工作流 + self.supported_workflows["quality_inspection"] = WorkflowInfo( + name="quality_inspection", + description="产品质量检查工作流", + estimated_duration=60.0, # 1分钟 + required_materials=["finished_battery"], + output_product="quality_report", + parameters_schema={ + "type": "object", + "properties": { + "test_voltage": { + "type": "boolean", + "description": "是否测试电压", + "default": True + }, + "test_capacity": { + "type": "boolean", + "description": "是否测试容量", + "default": False + }, + "voltage_threshold": { + "type": "number", + "description": "电压阈值 (V)", + "minimum": 2.0, + "maximum": 4.5, + "default": 3.0 + } + } + } + ) + + # 设备初始化工作流 + self.supported_workflows["device_initialization"] = WorkflowInfo( + name="device_initialization", + description="设备初始化工作流", + estimated_duration=30.0, # 30秒 + required_materials=[], + output_product="ready_status", + parameters_schema={ + "type": "object", + "properties": { + "auto_mode": { + "type": "boolean", + "description": "是否启用自动模式", + "default": True + } + } + } + ) + + # ============ 纽扣电池特定方法 ============ + + def get_electrode_sheet_inventory(self) -> Dict[str, int]: + """获取极片库存统计""" + try: + sheets = self.material_management.find_electrode_sheets() + inventory = {} + + for sheet in sheets: + material_type = getattr(sheet, 'material_type', 'unknown') + inventory[material_type] = inventory.get(material_type, 0) + 1 + + return inventory + + except Exception as e: + logger.error(f"获取极片库存失败: {e}") + return {} + + def get_battery_production_statistics(self) -> Dict[str, Any]: + """获取电池生产统计""" + try: + production_data = self.communication.get_production_data() + + # 添加物料统计 + electrode_inventory = self.get_electrode_sheet_inventory() + battery_count = len(self.material_management.find_batteries()) + + return { + **production_data, + "electrode_inventory": electrode_inventory, + "finished_battery_count": battery_count, + "material_plates": len(self.material_management.find_material_plates()), + "press_slots": len(self.material_management.find_press_slots()) + } + + except Exception as e: + logger.error(f"获取生产统计失败: {e}") + return {"error": str(e)} + + def create_new_battery(self, battery_spec: Dict[str, Any]) -> Optional[str]: + """创建新电池资源""" + try: + from unilabos.device_comms.button_battery_station import Battery + import uuid + + battery_id = f"battery_{uuid.uuid4().hex[:8]}" + + battery = Battery( + name=battery_id, + diameter=battery_spec.get("diameter", 20.0), + height=battery_spec.get("height", 3.2), + max_volume=battery_spec.get("max_volume", 100.0), + barcode=battery_spec.get("barcode", "") + ) + + # 添加到物料管理系统 + self.material_management.plr_resources[battery_id] = battery + self.material_management.resource_tracker.add_resource(battery) + + logger.info(f"创建新电池资源: {battery_id}") + return battery_id + + except Exception as e: + logger.error(f"创建电池资源失败: {e}") + return None + + def find_available_press_slot(self) -> Optional[str]: + """查找可用的压制槽""" + try: + press_slots = self.material_management.find_press_slots() + + for slot in press_slots: + if hasattr(slot, 'has_battery') and not slot.has_battery(): + return slot.name + + return None + + except Exception as e: + logger.error(f"查找可用压制槽失败: {e}") + return None + + def get_glove_box_environment(self) -> Dict[str, Any]: + """获取手套箱环境数据""" + try: + device_status = self.communication.get_device_status() + environment = device_status.get("environment", {}) + + return { + "pressure": environment.get("glove_box_pressure", 0.0), + "o2_content": environment.get("o2_content", 0.0), + "water_content": environment.get("water_content", 0.0), + "is_safe": ( + environment.get("o2_content", 0.0) < 10.0 and # 氧气含量 < 10ppm + environment.get("water_content", 0.0) < 1.0 # 水分含量 < 1ppm + ) + } + + except Exception as e: + logger.error(f"获取手套箱环境失败: {e}") + return {"error": str(e)} + + def start_data_export(self, file_path: str) -> bool: + """开始生产数据导出""" + try: + return self.communication.start_data_export(file_path, export_interval=5.0) + except Exception as e: + logger.error(f"启动数据导出失败: {e}") + return False + + def stop_data_export(self) -> bool: + """停止生产数据导出""" + try: + return self.communication.stop_data_export() + except Exception as e: + logger.error(f"停止数据导出失败: {e}") + return False + + # ============ 重写基类方法以支持纽扣电池特定功能 ============ + + def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: + """启动工作流(重写以支持纽扣电池特定预处理)""" + try: + # 进行纽扣电池特定的预检查 + if workflow_type == "battery_manufacturing": + # 检查手套箱环境 + env = self.get_glove_box_environment() + if not env.get("is_safe", False): + logger.error("手套箱环境不安全,无法启动电池制造工作流") + return False + + # 检查是否有可用的压制槽 + available_slot = self.find_available_press_slot() + if not available_slot: + logger.error("没有可用的压制槽,无法启动电池制造工作流") + return False + + # 检查极片库存 + electrode_inventory = self.get_electrode_sheet_inventory() + if not electrode_inventory.get("cathode", 0) > 0 or not electrode_inventory.get("anode", 0) > 0: + logger.error("极片库存不足,无法启动电池制造工作流") + return False + + # 调用基类方法 + return super().start_workflow(workflow_type, parameters) + + except Exception as e: + logger.error(f"启动纽扣电池工作流失败: {e}") + return False + + # ============ 纽扣电池特定状态属性 ============ + + @property + def electrode_sheet_count(self) -> int: + """极片总数""" + try: + return len(self.material_management.find_electrode_sheets()) + except: + return 0 + + @property + def battery_count(self) -> int: + """电池总数""" + try: + return len(self.material_management.find_batteries()) + except: + return 0 + + @property + def available_press_slots(self) -> int: + """可用压制槽数""" + try: + press_slots = self.material_management.find_press_slots() + available = 0 + for slot in press_slots: + if hasattr(slot, 'has_battery') and not slot.has_battery(): + available += 1 + return available + except: + return 0 + + @property + def environment_status(self) -> Dict[str, Any]: + """环境状态""" + return self.get_glove_box_environment() + + +# ============ 工厂函数 ============ + +def create_coin_cell_workstation( + device_id: str, + config_file: str, + modbus_host: str = "127.0.0.1", + modbus_port: int = 5021, + csv_path: str = "./coin_cell_assembly.csv" +) -> CoinCellAssemblyWorkstation: + """工厂函数:创建纽扣电池组装工作站 + + Args: + device_id: 设备ID + config_file: 配置文件路径(JSON格式) + modbus_host: Modbus主机地址 + modbus_port: Modbus端口 + csv_path: 地址映射CSV文件路径 + + Returns: + CoinCellAssemblyWorkstation: 工作站实例 + """ + import json + + try: + # 加载配置文件 + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + # 提取配置 + children = config.get("children", {}) + deck_config = config.get("deck_config", {}) + + # 创建工作站 + workstation = CoinCellAssemblyWorkstation( + device_id=device_id, + children=children, + modbus_config={ + "host": modbus_host, + "port": modbus_port + }, + deck_config=deck_config, + csv_path=csv_path + ) + + logger.info(f"纽扣电池工作站创建成功: {device_id}") + return workstation + + except Exception as e: + logger.error(f"创建纽扣电池工作站失败: {e}") + raise + + +if __name__ == "__main__": + # 示例用法 + workstation = create_coin_cell_workstation( + device_id="coin_cell_station_01", + config_file="./button_battery_workstation.json", + modbus_host="127.0.0.1", + modbus_port=5021 + ) + + # 启动电池制造工作流 + success = workstation.start_workflow( + "battery_manufacturing", + { + "electrolyte_num": 16, + "electrolyte_volume": 50.0, + "assembly_pressure": 2000.0, + "cathode_material": "LiFePO4", + "anode_material": "Graphite" + } + ) + + if success: + print("电池制造工作流启动成功") + else: + print("电池制造工作流启动失败") diff --git a/unilabos/device_comms/workstation_base.py b/unilabos/device_comms/workstation_base.py new file mode 100644 index 00000000..7b61c17b --- /dev/null +++ b/unilabos/device_comms/workstation_base.py @@ -0,0 +1,1302 @@ +""" +工作站基类 +Workstation Base Class + +集成通信、物料管理和工作流的工作站基类 +融合子设备管理、动态工作流注册等高级功能 +""" +import asyncio +import json +import time +import traceback +from typing import Dict, Any, List, Optional, Union, Callable +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + +from rclpy.action import ActionServer, ActionClient +from rclpy.action.server import ServerGoalHandle +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.service import Service +from unilabos_msgs.srv import SerialCommand +from unilabos_msgs.msg import Resource + +from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.device_comms.workstation_communication import WorkstationCommunicationBase, CommunicationConfig +from unilabos.device_comms.workstation_material_management import MaterialManagementBase +from unilabos.ros.msgs.message_converter import convert_to_ros_msg, convert_from_ros_msg +from unilabos.utils.log import logger +from unilabos.utils.type_check import serialize_result_info + + +class DeviceType(Enum): + """设备类型枚举""" + LOGICAL = "logical" # 逻辑设备 + COMMUNICATION = "communication" # 通信设备 (modbus/opcua/serial) + PROTOCOL = "protocol" # 协议设备 + + +@dataclass +class CommunicationInterface: + """通信接口配置""" + device_id: str # 通信设备ID + read_method: str # 读取方法名 + write_method: str # 写入方法名 + protocol_type: str # 协议类型 (modbus/opcua/serial) + config: Dict[str, Any] # 协议特定配置 + + +@dataclass +class WorkflowStep: + """工作流步骤定义""" + device_id: str + action_name: str + action_kwargs: Dict[str, Any] + depends_on: Optional[List[str]] = None # 依赖的步骤ID + step_id: Optional[str] = None + timeout: Optional[float] = None + retry_count: int = 0 + + +@dataclass +class WorkflowDefinition: + """工作流定义""" + name: str + description: str + steps: List[WorkflowStep] + input_schema: Dict[str, Any] + output_schema: Dict[str, Any] + metadata: Dict[str, Any] + + +class WorkflowStatus(Enum): + """工作流状态""" + IDLE = "idle" + INITIALIZING = "initializing" + RUNNING = "running" + PAUSED = "paused" + STOPPING = "stopping" + STOPPED = "stopped" + ERROR = "error" + COMPLETED = "completed" + + +@dataclass +class WorkflowInfo: + """工作流信息""" + name: str + description: str + estimated_duration: float # 预估持续时间(秒) + required_materials: List[str] # 所需物料类型 + output_product: str # 输出产品类型 + parameters_schema: Dict[str, Any] # 参数架构 + + +class WorkstationBase(ROS2ProtocolNode, ABC): + """工作站基类 + + 提供工作站的核心功能: + 1. 通信转发 - 与PLC/设备的通信接口 + 2. 物料管理 - 基于PyLabRobot的物料系统 + 3. 工作流控制 - 支持动态注册和静态预定义工作流 + 4. 子设备管理 - 继承自ROS2ProtocolNode的设备管理能力 + 5. 状态监控 - 设备状态和生产数据监控 + 6. 调试接口 - 单点控制和紧急操作 + """ + + def __init__( + self, + device_id: str, + children: Dict[str, Dict[str, Any]], + protocol_type: Union[str, List[str]], + resource_tracker: DeviceNodeResourceTracker, + communication_config: CommunicationConfig, + deck_config: Optional[Dict[str, Any]] = None, + communication_interfaces: Optional[Dict[str, CommunicationInterface]] = None, + *args, + **kwargs, + ): + # 保存工作站特定配置 + self.communication_config = communication_config + self.deck_config = deck_config or {"size_x": 1000.0, "size_y": 1000.0, "size_z": 500.0} + self.communication_interfaces = communication_interfaces or {} + + # 工作流状态 - 支持静态和动态工作流 + self.current_workflow_status = WorkflowStatus.IDLE + self.current_workflow_info = None + self.workflow_start_time = None + self.workflow_parameters = {} + + # 支持的工作流(静态预定义) + self.supported_workflows: Dict[str, WorkflowInfo] = {} + + # 动态注册的工作流 + self.registered_workflows: Dict[str, WorkflowDefinition] = {} + self._workflow_action_servers: Dict[str, ActionServer] = {} + + # 初始化基类 - ROS2ProtocolNode会处理子设备初始化 + super().__init__( + device_id=device_id, + children=children, + protocol_type=protocol_type, + resource_tracker=resource_tracker, + *args, + **kwargs + ) + + # 工作站特有的设备分类 (基于已初始化的sub_devices) + self.communication_devices: Dict[str, Any] = {} + self.logical_devices: Dict[str, Any] = {} + self._classify_devices() + + # 初始化工作站模块 + self.communication: WorkstationCommunicationBase = self._create_communication_module() + self.material_management: MaterialManagementBase = self._create_material_management_module() + + # 设置工作站特定的通信接口 + self._setup_workstation_communication_interfaces() + + # 注册支持的工作流 + self._register_supported_workflows() + + # 创建工作站ROS服务 + self._create_workstation_services() + + # 启动状态监控 + self._start_status_monitoring() + + logger.info(f"增强工作站基类 {device_id} 初始化完成") + + def _classify_devices(self): + """基于已初始化的设备进行分类""" + for device_id, device in self.sub_devices.items(): + device_config = self.children.get(device_id, {}) + device_type = DeviceType(device_config.get("device_type", "logical")) + + if device_type == DeviceType.COMMUNICATION: + self.communication_devices[device_id] = device + logger.info(f"通信设备 {device_id} 已分类") + elif device_type == DeviceType.LOGICAL: + self.logical_devices[device_id] = device + logger.info(f"逻辑设备 {device_id} 已分类") + + def _setup_workstation_communication_interfaces(self): + """设置工作站特定的通信接口代理""" + for logical_device_id, logical_device in self.logical_devices.items(): + # 检查是否有配置的通信接口 + interface_config = self.communication_interfaces.get(logical_device_id) + if not interface_config: + continue + + comm_device = self.communication_devices.get(interface_config.device_id) + if not comm_device: + logger.error(f"通信设备 {interface_config.device_id} 不存在") + continue + + # 设置工作站级别的通信代理 + self._setup_workstation_hardware_proxy( + logical_device, + comm_device, + interface_config + ) + + def _setup_workstation_hardware_proxy(self, logical_device, comm_device, interface: CommunicationInterface): + """为逻辑设备设置工作站级通信代理""" + try: + # 获取通信设备的读写方法 + read_func = getattr(comm_device.driver_instance, interface.read_method, None) + write_func = getattr(comm_device.driver_instance, interface.write_method, None) + + if read_func: + setattr(logical_device.driver_instance, 'comm_read', read_func) + if write_func: + setattr(logical_device.driver_instance, 'comm_write', write_func) + + # 设置通信配置 + setattr(logical_device.driver_instance, 'comm_config', interface.config) + setattr(logical_device.driver_instance, 'comm_protocol', interface.protocol_type) + + logger.info(f"为逻辑设备 {logical_device.device_id} 设置工作站通信代理 -> {comm_device.device_id}") + + except Exception as e: + logger.error(f"设置工作站通信代理失败: {e}") + + @abstractmethod + def _create_communication_module(self) -> WorkstationCommunicationBase: + """创建通信模块 - 子类必须实现""" + pass + + @abstractmethod + def _create_material_management_module(self) -> MaterialManagementBase: + """创建物料管理模块 - 子类必须实现""" + pass + + @abstractmethod + def _register_supported_workflows(self): + """注册支持的工作流 - 子类必须实现""" + pass + + def _create_workstation_services(self): + """创建工作站ROS服务""" + self._workstation_services = { + # 动态工作流管理服务 + "register_workflow": self.create_service( + SerialCommand, + f"/srv{self.namespace}/register_workflow", + self._handle_register_workflow, + callback_group=self.callback_group, + ), + "unregister_workflow": self.create_service( + SerialCommand, + f"/srv{self.namespace}/unregister_workflow", + self._handle_unregister_workflow, + callback_group=self.callback_group, + ), + "list_workflows": self.create_service( + SerialCommand, + f"/srv{self.namespace}/list_workflows", + self._handle_list_workflows, + callback_group=self.callback_group, + ), + + # 增强物料管理服务 + "create_resource": self.create_service( + SerialCommand, + f"/srv{self.namespace}/create_resource", + self._handle_create_resource, + callback_group=self.callback_group, + ), + "delete_resource": self.create_service( + SerialCommand, + f"/srv{self.namespace}/delete_resource", + self._handle_delete_resource, + callback_group=self.callback_group, + ), + "update_resource": self.create_service( + SerialCommand, + f"/srv{self.namespace}/update_resource", + self._handle_update_resource, + callback_group=self.callback_group, + ), + "get_resource": self.create_service( + SerialCommand, + f"/srv{self.namespace}/get_resource", + self._handle_get_resource, + callback_group=self.callback_group, + ), + + # 工作站特有服务 + "start_workflow": self.create_service( + SerialCommand, + f"/srv{self.namespace}/start_workflow", + self._handle_start_workflow, + callback_group=self.callback_group, + ), + "stop_workflow": self.create_service( + SerialCommand, + f"/srv{self.namespace}/stop_workflow", + self._handle_stop_workflow, + callback_group=self.callback_group, + ), + "get_workflow_status": self.create_service( + SerialCommand, + f"/srv{self.namespace}/get_workflow_status", + self._handle_get_workflow_status, + callback_group=self.callback_group, + ), + "get_device_status": self.create_service( + SerialCommand, + f"/srv{self.namespace}/get_device_status", + self._handle_get_device_status, + callback_group=self.callback_group, + ), + "get_production_data": self.create_service( + SerialCommand, + f"/srv{self.namespace}/get_production_data", + self._handle_get_production_data, + callback_group=self.callback_group, + ), + "get_material_inventory": self.create_service( + SerialCommand, + f"/srv{self.namespace}/get_material_inventory", + self._handle_get_material_inventory, + callback_group=self.callback_group, + ), + "find_materials": self.create_service( + SerialCommand, + f"/srv{self.namespace}/find_materials", + self._handle_find_materials, + callback_group=self.callback_group, + ), + "write_register": self.create_service( + SerialCommand, + f"/srv{self.namespace}/write_register", + self._handle_write_register, + callback_group=self.callback_group, + ), + "read_register": self.create_service( + SerialCommand, + f"/srv{self.namespace}/read_register", + self._handle_read_register, + callback_group=self.callback_group, + ), + "emergency_stop": self.create_service( + SerialCommand, + f"/srv{self.namespace}/emergency_stop", + self._handle_emergency_stop, + callback_group=self.callback_group, + ), + } + + def _start_status_monitoring(self): + """启动状态监控""" + # 这里可以启动定期状态查询线程 + # 目前简化为按需查询 + pass + + # ============ 工作流控制接口 ============ + + def _handle_start_workflow(self, request, response): + """处理启动工作流请求""" + try: + import json + + # 解析请求参数 + params = json.loads(request.data) if request.data else {} + workflow_type = params.get("workflow_type", "") + workflow_parameters = params.get("parameters", {}) + + if not workflow_type: + response.success = False + response.message = "缺少工作流类型参数" + return response + + if workflow_type not in self.supported_workflows: + response.success = False + response.message = f"不支持的工作流类型: {workflow_type}" + return response + + if self.current_workflow_status != WorkflowStatus.IDLE: + response.success = False + response.message = f"当前状态不允许启动工作流: {self.current_workflow_status.value}" + return response + + # 启动工作流 + success = self.start_workflow(workflow_type, workflow_parameters) + + response.success = success + response.message = "工作流启动成功" if success else "工作流启动失败" + response.data = json.dumps({ + "workflow_type": workflow_type, + "status": self.current_workflow_status.value, + "estimated_duration": self.supported_workflows[workflow_type].estimated_duration + }) + + except Exception as e: + logger.error(f"处理启动工作流请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + def _handle_stop_workflow(self, request, response): + """处理停止工作流请求""" + try: + import json + + params = json.loads(request.data) if request.data else {} + emergency = params.get("emergency", False) + + success = self.stop_workflow(emergency) + + response.success = success + response.message = "工作流停止成功" if success else "工作流停止失败" + response.data = json.dumps({ + "status": self.current_workflow_status.value, + "emergency": emergency + }) + + except Exception as e: + logger.error(f"处理停止工作流请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + def _handle_get_workflow_status(self, request, response): + """处理获取工作流状态请求""" + try: + import json + import time + + status_info = { + "status": self.current_workflow_status.value, + "workflow_info": self.current_workflow_info.name if self.current_workflow_info else None, + "start_time": self.workflow_start_time, + "parameters": self.workflow_parameters, + "supported_workflows": { + name: { + "description": info.description, + "estimated_duration": info.estimated_duration, + "required_materials": info.required_materials, + "output_product": info.output_product + } + for name, info in self.supported_workflows.items() + } + } + + # 如果工作流正在运行,添加进度信息 + if self.current_workflow_status == WorkflowStatus.RUNNING and self.workflow_start_time: + elapsed_time = time.time() - self.workflow_start_time + estimated_duration = self.current_workflow_info.estimated_duration if self.current_workflow_info else 0 + progress = min(elapsed_time / estimated_duration * 100, 99) if estimated_duration > 0 else 0 + + status_info.update({ + "elapsed_time": elapsed_time, + "estimated_remaining": max(estimated_duration - elapsed_time, 0), + "progress_percent": progress + }) + + # 查询PLC状态 + plc_status = self.communication.get_workflow_status() + status_info["plc_status"] = plc_status + + response.success = True + response.message = "获取状态成功" + response.data = json.dumps(status_info) + + except Exception as e: + logger.error(f"处理获取工作流状态请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + # ============ 设备状态接口 ============ + + def _handle_get_device_status(self, request, response): + """处理获取设备状态请求""" + try: + import json + + # 从通信模块获取设备状态 + device_status = self.communication.get_device_status() + + response.success = True + response.message = "获取设备状态成功" + response.data = json.dumps(device_status) + + except Exception as e: + logger.error(f"处理获取设备状态请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + def _handle_get_production_data(self, request, response): + """处理获取生产数据请求""" + try: + import json + + # 从通信模块获取生产数据 + production_data = self.communication.get_production_data() + + response.success = True + response.message = "获取生产数据成功" + response.data = json.dumps(production_data) + + except Exception as e: + logger.error(f"处理获取生产数据请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + # ============ 物料管理接口 ============ + + def _handle_get_material_inventory(self, request, response): + """处理获取物料库存请求""" + try: + import json + + # 从物料管理模块获取库存 + inventory = self.material_management.get_material_inventory() + deck_state = self.material_management.get_deck_state() + + result = { + "inventory": inventory, + "deck_state": deck_state + } + + response.success = True + response.message = "获取物料库存成功" + response.data = json.dumps(result) + + except Exception as e: + logger.error(f"处理获取物料库存请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + def _handle_find_materials(self, request, response): + """处理查找物料请求""" + try: + import json + + params = json.loads(request.data) if request.data else {} + material_type = params.get("material_type", "") + category = params.get("category", "") + name_pattern = params.get("name_pattern", "") + + found_materials = [] + + if material_type: + materials = self.material_management.find_materials_by_type(material_type) + found_materials.extend([self.material_management.convert_to_unilab_format(m) for m in materials]) + + if category: + materials = self.material_management.resource_tracker.find_by_category(category) + found_materials.extend([self.material_management.convert_to_unilab_format(m) for m in materials]) + + if name_pattern: + materials = self.material_management.resource_tracker.find_by_name_pattern(name_pattern) + found_materials.extend([self.material_management.convert_to_unilab_format(m) for m in materials]) + + response.success = True + response.message = f"找到 {len(found_materials)} 个物料" + response.data = json.dumps({"materials": found_materials}) + + except Exception as e: + logger.error(f"处理查找物料请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + # ============ 调试控制接口 ============ + + def _handle_write_register(self, request, response): + """处理写寄存器请求""" + try: + import json + from unilabos.device_comms.modbus_plc.node.modbus import DataType, WorderOrder + + params = json.loads(request.data) if request.data else {} + register_name = params.get("register_name", "") + value = params.get("value") + data_type_str = params.get("data_type", "") + word_order_str = params.get("word_order", "") + + if not register_name or value is None: + response.success = False + response.message = "缺少寄存器名称或值" + return response + + # 转换数据类型和字节序 + data_type = DataType[data_type_str] if data_type_str else None + word_order = WorderOrder[word_order_str] if word_order_str else None + + success = self.communication.write_register(register_name, value, data_type, word_order) + + response.success = success + response.message = "写寄存器成功" if success else "写寄存器失败" + response.data = json.dumps({ + "register_name": register_name, + "value": value, + "data_type": data_type_str, + "word_order": word_order_str + }) + + except Exception as e: + logger.error(f"处理写寄存器请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + def _handle_read_register(self, request, response): + """处理读寄存器请求""" + try: + import json + from unilabos.device_comms.modbus_plc.node.modbus import DataType, WorderOrder + + params = json.loads(request.data) if request.data else {} + register_name = params.get("register_name", "") + count = params.get("count", 1) + data_type_str = params.get("data_type", "") + word_order_str = params.get("word_order", "") + + if not register_name: + response.success = False + response.message = "缺少寄存器名称" + return response + + # 转换数据类型和字节序 + data_type = DataType[data_type_str] if data_type_str else None + word_order = WorderOrder[word_order_str] if word_order_str else None + + value, error = self.communication.read_register(register_name, count, data_type, word_order) + + response.success = not error + response.message = "读寄存器成功" if not error else "读寄存器失败" + response.data = json.dumps({ + "register_name": register_name, + "value": value, + "error": error, + "data_type": data_type_str, + "word_order": word_order_str + }) + + except Exception as e: + logger.error(f"处理读寄存器请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + def _handle_emergency_stop(self, request, response): + """处理紧急停止请求""" + try: + import json + + # 立即停止工作流 + success = self.stop_workflow(emergency=True) + + # 更新状态 + if success: + self.current_workflow_status = WorkflowStatus.STOPPED + + response.success = success + response.message = "紧急停止成功" if success else "紧急停止失败" + response.data = json.dumps({ + "status": self.current_workflow_status.value, + "timestamp": time.time() + }) + + except Exception as e: + logger.error(f"处理紧急停止请求失败: {e}") + response.success = False + response.message = f"处理请求失败: {str(e)}" + + return response + + # ============ 工作流控制方法 ============ + + def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: + """启动工作流""" + try: + if workflow_type not in self.supported_workflows: + logger.error(f"不支持的工作流类型: {workflow_type}") + return False + + if self.current_workflow_status != WorkflowStatus.IDLE: + logger.error(f"当前状态不允许启动工作流: {self.current_workflow_status}") + return False + + # 更新状态 + self.current_workflow_status = WorkflowStatus.INITIALIZING + self.current_workflow_info = self.supported_workflows[workflow_type] + self.workflow_parameters = parameters or {} + + # 通过通信模块启动工作流 + success = self.communication.start_workflow(workflow_type, self.workflow_parameters) + + if success: + self.current_workflow_status = WorkflowStatus.RUNNING + self.workflow_start_time = time.time() + logger.info(f"工作流启动成功: {workflow_type}") + else: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作流启动失败: {workflow_type}") + + return success + + except Exception as e: + logger.error(f"启动工作流失败: {e}") + self.current_workflow_status = WorkflowStatus.ERROR + return False + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: + logger.warning("没有正在运行的工作流") + return True + + # 更新状态 + self.current_workflow_status = WorkflowStatus.STOPPING + + # 通过通信模块停止工作流 + success = self.communication.stop_workflow(emergency) + + if success: + self.current_workflow_status = WorkflowStatus.STOPPED + logger.info(f"工作流停止成功 (紧急: {emergency})") + else: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作流停止失败 (紧急: {emergency})") + + return success + + except Exception as e: + logger.error(f"停止工作流失败: {e}") + self.current_workflow_status = WorkflowStatus.ERROR + return False + + # ============ 状态属性 ============ + + @property + def is_busy(self) -> bool: + """是否忙碌""" + return self.current_workflow_status in [ + WorkflowStatus.INITIALIZING, + WorkflowStatus.RUNNING, + WorkflowStatus.STOPPING + ] + + @property + def is_ready(self) -> bool: + """是否就绪""" + return self.current_workflow_status == WorkflowStatus.IDLE + + @property + def has_error(self) -> bool: + """是否有错误""" + return self.current_workflow_status == WorkflowStatus.ERROR + + @property + def communication_status(self) -> Dict[str, Any]: + """通信状态""" + return { + "is_connected": self.communication.is_connected, + "host": self.communication.config.host, + "port": self.communication.config.port, + "protocol": self.communication.config.protocol.value + } + + @property + def material_status(self) -> Dict[str, Any]: + """物料状态""" + return { + "total_resources": len(self.material_management.plr_resources), + "inventory": self.material_management.get_material_inventory(), + "deck_size": { + "x": self.material_management.plr_deck.size_x, + "y": self.material_management.plr_deck.size_y, + "z": self.material_management.plr_deck.size_z + } + } + + # ============ 增强物料管理接口 ============ + + def _handle_create_resource(self, request, response): + """处理创建物料请求""" + try: + data = json.loads(request.data) if request.data else {} + result = self.create_resource( + resource_data=data.get("resource_data"), + parent_id=data.get("parent_id"), + location=data.get("location"), + metadata=data.get("metadata", {}) + ) + response.success = True + response.message = "创建物料成功" + response.data = serialize_result_info("", True, result) + except Exception as e: + error_msg = f"创建物料失败: {e}\n{traceback.format_exc()}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + def create_resource(self, resource_data: Dict[str, Any], parent_id: Optional[str] = None, + location: Optional[Dict[str, float]] = None, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """创建物料资源""" + try: + # 验证资源数据 + if not self._validate_resource_data(resource_data): + raise ValueError("无效的资源数据") + + # 添加到本地资源跟踪器 + resource = convert_to_ros_msg(Resource, resource_data) + self.resource_tracker.add_resource(resource) + + # 如果有父节点,建立关联 + if parent_id: + self._link_resource_to_parent(resource_data["id"], parent_id, location) + + # 同步到全局资源管理器 + self._sync_resource_to_global(resource, "create") + + logger.info(f"物料 {resource_data['id']} 创建成功") + return {"resource_id": resource_data["id"], "status": "created"} + + except Exception as e: + logger.error(f"创建物料失败: {e}") + raise + + def _handle_delete_resource(self, request, response): + """处理删除物料请求""" + try: + data = json.loads(request.data) if request.data else {} + result = self.delete_resource(data.get("resource_id")) + response.success = True + response.message = "删除物料成功" + response.data = serialize_result_info("", True, result) + except Exception as e: + error_msg = f"删除物料失败: {e}\n{traceback.format_exc()}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + def delete_resource(self, resource_id: str) -> Dict[str, Any]: + """删除物料资源""" + try: + # 从本地资源跟踪器删除 + resources = self.resource_tracker.figure_resource({"id": resource_id}) + if not resources: + raise ValueError(f"资源 {resource_id} 不存在") + + # 同步到全局资源管理器 + self._sync_resource_to_global(resources[0], "delete") + + logger.info(f"物料 {resource_id} 删除成功") + return {"resource_id": resource_id, "status": "deleted"} + + except Exception as e: + logger.error(f"删除物料失败: {e}") + raise + + def _handle_update_resource(self, request, response): + """处理更新物料请求""" + try: + data = json.loads(request.data) if request.data else {} + result = self.update_resource( + resource_id=data.get("resource_id"), + updates=data.get("updates", {}) + ) + response.success = True + response.message = "更新物料成功" + response.data = serialize_result_info("", True, result) + except Exception as e: + error_msg = f"更新物料失败: {e}\n{traceback.format_exc()}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + def update_resource(self, resource_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + """更新物料资源""" + try: + # 查找资源 + resources = self.resource_tracker.figure_resource({"id": resource_id}) + if not resources: + raise ValueError(f"资源 {resource_id} 不存在") + + resource = resources[0] + + # 更新资源数据 + if isinstance(resource, Resource): + if "data" in updates: + current_data = json.loads(resource.data) if resource.data else {} + current_data.update(updates["data"]) + resource.data = json.dumps(current_data) + + for key, value in updates.items(): + if key != "data" and hasattr(resource, key): + setattr(resource, key, value) + + # 同步到全局资源管理器 + self._sync_resource_to_global(resource, "update") + + logger.info(f"物料 {resource_id} 更新成功") + return {"resource_id": resource_id, "status": "updated"} + + except Exception as e: + logger.error(f"更新物料失败: {e}") + raise + + def _handle_get_resource(self, request, response): + """处理获取物料请求""" + try: + data = json.loads(request.data) if request.data else {} + result = self.get_resource( + resource_id=data.get("resource_id"), + with_children=data.get("with_children", False) + ) + response.success = True + response.message = "获取物料成功" + response.data = serialize_result_info("", True, result) + except Exception as e: + error_msg = f"获取物料失败: {e}\n{traceback.format_exc()}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + def get_resource(self, resource_id: str, with_children: bool = False) -> Dict[str, Any]: + """获取物料资源""" + try: + resources = self.resource_tracker.figure_resource({"id": resource_id}) + if not resources: + raise ValueError(f"资源 {resource_id} 不存在") + + resource = resources[0] + + # 转换为字典格式 + if isinstance(resource, Resource): + result = convert_from_ros_msg(resource) + else: + result = resource + + # 如果需要包含子资源 + if with_children: + children = self._get_child_resources(resource_id) + result["children"] = children + + return result + + except Exception as e: + logger.error(f"获取物料失败: {e}") + raise + + # ============ 动态工作流管理接口 ============ + + def _handle_register_workflow(self, request, response): + """处理注册工作流请求""" + try: + data = json.loads(request.data) if request.data else {} + result = self.register_workflow( + workflow_name=data.get("workflow_name"), + workflow_definition=data.get("workflow_definition"), + action_type=data.get("action_type") + ) + response.success = True + response.message = "注册工作流成功" + response.data = serialize_result_info("", True, result) + except Exception as e: + error_msg = f"注册工作流失败: {e}\n{traceback.format_exc()}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + def register_workflow(self, workflow_name: str, workflow_definition: Dict[str, Any], + action_type: Optional[str] = None) -> Dict[str, Any]: + """注册工作流并创建对应的ROS Action""" + try: + # 验证工作流定义 + if not self._validate_workflow_definition(workflow_definition): + raise ValueError("无效的工作流定义") + + # 创建工作流定义对象 + workflow = WorkflowDefinition( + name=workflow_name, + description=workflow_definition.get("description", ""), + steps=[WorkflowStep(**step) for step in workflow_definition.get("steps", [])], + input_schema=workflow_definition.get("input_schema", {}), + output_schema=workflow_definition.get("output_schema", {}), + metadata=workflow_definition.get("metadata", {}) + ) + + # 存储工作流定义 + self.registered_workflows[workflow_name] = workflow + + # 创建对应的ROS Action Server + self._create_workflow_action_server(workflow_name, workflow, action_type) + + logger.info(f"工作流 {workflow_name} 注册成功") + return {"workflow_name": workflow_name, "status": "registered"} + + except Exception as e: + logger.error(f"注册工作流失败: {e}") + raise + + def _create_workflow_action_server(self, workflow_name: str, workflow: WorkflowDefinition, action_type: Optional[str]): + """为工作流创建ROS Action Server""" + try: + # 如果没有指定action_type,使用默认的SendCmd + if not action_type: + from unilabos_msgs.action import SendCmd + action_type_class = SendCmd + else: + # 动态导入指定的action类型 + action_type_class = self._import_action_type(action_type) + + # 创建Action Server + self._workflow_action_servers[workflow_name] = ActionServer( + self, + action_type_class, + workflow_name, + execute_callback=self._create_workflow_execute_callback(workflow), + callback_group=ReentrantCallbackGroup(), + ) + + logger.info(f"为工作流 {workflow_name} 创建Action Server") + + except Exception as e: + logger.error(f"创建工作流Action Server失败: {e}") + raise + + def _create_workflow_execute_callback(self, workflow: WorkflowDefinition): + """创建工作流执行回调""" + async def execute_workflow(goal_handle: ServerGoalHandle): + execution_error = "" + execution_success = False + workflow_return_value = None + + try: + logger.info(f"开始执行工作流: {workflow.name}") + + # 解析输入参数 + goal = goal_handle.request + workflow_kwargs = self._parse_workflow_input(goal, workflow.input_schema) + + # 执行工作流步骤 + step_results = [] + for step in workflow.steps: + # 检查依赖 + if step.depends_on: + self._wait_for_dependencies(step.depends_on, step_results) + + # 执行步骤 + step_result = await self._execute_workflow_step(step, workflow_kwargs) + step_results.append(step_result) + + # 发布反馈 + feedback = self._create_workflow_feedback(workflow, step_results) + goal_handle.publish_feedback(feedback) + + execution_success = True + workflow_return_value = { + "workflow_name": workflow.name, + "steps_completed": len(step_results), + "results": step_results + } + + goal_handle.succeed() + + except Exception as e: + execution_error = traceback.format_exc() + execution_success = False + logger.error(f"工作流执行失败: {e}") + goal_handle.abort() + + # 创建结果 + result = goal_handle._action_type.Result() + result.success = execution_success + + # 如果有return_info字段,设置详细信息 + if hasattr(result, 'return_info'): + result.return_info = serialize_result_info(execution_error, execution_success, workflow_return_value) + + return result + + return execute_workflow + + async def _execute_workflow_step(self, step: WorkflowStep, workflow_kwargs: Dict[str, Any]) -> Dict[str, Any]: + """执行单个工作流步骤 - 使用父类的execute_single_action方法""" + try: + # 替换参数中的变量 + resolved_kwargs = self._resolve_step_kwargs(step.action_kwargs, workflow_kwargs) + + # 使用父类的execute_single_action方法执行动作 + result = await self.execute_single_action( + device_id=step.device_id, + action_name=step.action_name, + action_kwargs=resolved_kwargs + ) + + return { + "step_id": step.step_id or f"{step.device_id}_{step.action_name}", + "device_id": step.device_id, + "action_name": step.action_name, + "status": "success", + "result": result + } + + except Exception as e: + logger.error(f"步骤执行失败: {step.step_id}, 错误: {e}") + return { + "step_id": step.step_id or f"{step.device_id}_{step.action_name}", + "device_id": step.device_id, + "action_name": step.action_name, + "status": "failed", + "error": str(e) + } + + def _handle_unregister_workflow(self, request, response): + """处理注销工作流请求""" + try: + data = json.loads(request.data) if request.data else {} + workflow_name = data.get("workflow_name") + + if workflow_name in self.registered_workflows: + del self.registered_workflows[workflow_name] + + if workflow_name in self._workflow_action_servers: + # 销毁Action Server + del self._workflow_action_servers[workflow_name] + + result = {"workflow_name": workflow_name, "status": "unregistered"} + response.success = True + response.message = "注销工作流成功" + response.data = serialize_result_info("", True, result) + else: + raise ValueError(f"工作流 {workflow_name} 不存在") + + except Exception as e: + error_msg = f"注销工作流失败: {e}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + def _handle_list_workflows(self, request, response): + """处理列出工作流请求""" + try: + # 静态预定义工作流 + static_workflows = [] + for name, workflow in self.supported_workflows.items(): + static_workflows.append({ + "name": name, + "type": "static", + "description": workflow.description, + "estimated_duration": workflow.estimated_duration, + "required_materials": workflow.required_materials, + "output_product": workflow.output_product + }) + + # 动态注册工作流 + dynamic_workflows = [] + for name, workflow in self.registered_workflows.items(): + dynamic_workflows.append({ + "name": name, + "type": "dynamic", + "description": workflow.description, + "step_count": len(workflow.steps), + "metadata": workflow.metadata + }) + + result = { + "static_workflows": static_workflows, + "dynamic_workflows": dynamic_workflows, + "total_count": len(static_workflows) + len(dynamic_workflows) + } + response.success = True + response.message = "列出工作流成功" + response.data = serialize_result_info("", True, result) + except Exception as e: + error_msg = f"列出工作流失败: {e}" + logger.error(error_msg) + response.success = False + response.message = error_msg + response.data = serialize_result_info(error_msg, False, {}) + return response + + # ============ 辅助方法 ============ + + def _validate_resource_data(self, resource_data: Dict[str, Any]) -> bool: + """验证资源数据""" + required_fields = ["id", "name", "type"] + return all(field in resource_data for field in required_fields) + + def _validate_workflow_definition(self, workflow_def: Dict[str, Any]) -> bool: + """验证工作流定义""" + required_fields = ["steps"] + return all(field in workflow_def for field in required_fields) + + def _sync_resource_to_global(self, resource: Resource, operation: str): + """同步资源到全局管理器""" + # 实现与全局资源管理器的同步逻辑 + pass + + def _link_resource_to_parent(self, resource_id: str, parent_id: str, location: Optional[Dict[str, float]]): + """将资源链接到父节点""" + # 实现资源父子关系的建立逻辑 + pass + + def _get_child_resources(self, resource_id: str) -> List[Dict[str, Any]]: + """获取子资源""" + # 实现获取子资源的逻辑 + return [] + + def _import_action_type(self, action_type: str): + """动态导入Action类型""" + # 实现动态导入逻辑 + from unilabos_msgs.action import SendCmd + return SendCmd + + def _parse_workflow_input(self, goal, input_schema: Dict[str, Any]) -> Dict[str, Any]: + """解析工作流输入""" + # 根据input_schema解析goal中的参数 + return {} + + def _wait_for_dependencies(self, dependencies: List[str], completed_steps: List[Dict[str, Any]]): + """等待依赖步骤完成""" + # 实现依赖等待逻辑 + pass + + def _resolve_step_kwargs(self, action_kwargs: Dict[str, Any], workflow_kwargs: Dict[str, Any]) -> Dict[str, Any]: + """解析步骤参数中的变量""" + # 实现参数变量替换逻辑 + return action_kwargs + + def _create_workflow_feedback(self, workflow: WorkflowDefinition, step_results: List[Dict[str, Any]]): + """创建工作流反馈""" + # 创建反馈消息 + return None + + # ============ 增强状态属性 ============ + + @property + def communication_device_count(self) -> int: + """通信设备数量""" + return len(self.communication_devices) + + @property + def logical_device_count(self) -> int: + """逻辑设备数量""" + return len(self.logical_devices) + + @property + def active_dynamic_workflows(self) -> int: + """活跃动态工作流数量""" + return len([server for server in self._workflow_action_servers.values() if server]) + + @property + def total_workflow_count(self) -> int: + """总工作流数量""" + return len(self.supported_workflows) + len(self.registered_workflows) + + @property + def workstation_resource_count(self) -> int: + """工作站资源数量""" + return len(self.resource_tracker.figure_resource({})) + + @property + def workstation_status_summary(self) -> Dict[str, Any]: + """工作站状态摘要""" + return { + "workflow_status": self.current_workflow_status.value, + "is_busy": self.is_busy, + "is_ready": self.is_ready, + "has_error": self.has_error, + "total_devices": len(self.sub_devices), + "communication_devices": self.communication_device_count, + "logical_devices": self.logical_device_count, + "total_workflows": self.total_workflow_count, + "active_workflows": self.active_dynamic_workflows, + "total_resources": self.workstation_resource_count, + "communication_status": self.communication_status, + "material_status": self.material_status + } diff --git a/unilabos/device_comms/workstation_communication.py b/unilabos/device_comms/workstation_communication.py new file mode 100644 index 00000000..3067b802 --- /dev/null +++ b/unilabos/device_comms/workstation_communication.py @@ -0,0 +1,600 @@ +""" +工作站通信基类 +Workstation Communication Base Class + +从具体设备驱动中抽取通用通信模式 +""" +import json +import time +import threading +from typing import Dict, Any, Optional, Callable, Union, List +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + +from unilabos.device_comms.modbus_plc.client import TCPClient as ModbusTCPClient +from unilabos.device_comms.modbus_plc.node.modbus import DataType, WorderOrder +from unilabos.utils.log import logger + + +class CommunicationProtocol(Enum): + """通信协议类型""" + MODBUS_TCP = "modbus_tcp" + MODBUS_RTU = "modbus_rtu" + SERIAL = "serial" + ETHERNET = "ethernet" + + +@dataclass +class CommunicationConfig: + """通信配置""" + protocol: CommunicationProtocol + host: str + port: int + timeout: float = 5.0 + retry_count: int = 3 + extra_params: Dict[str, Any] = None + + +class WorkstationCommunicationBase(ABC): + """工作站通信基类 + + 定义工作站通信的标准接口: + 1. 状态查询 - 定期获取设备状态 + 2. 命令下发 - 发送控制指令 + 3. 数据采集 - 收集生产数据 + 4. 紧急控制 - 单点调试控制 + """ + + def __init__(self, communication_config: CommunicationConfig): + self.config = communication_config + self.client = None + self.is_connected = False + self.last_status = {} + self.data_export_thread = None + self.data_export_running = False + + # 状态缓存 + self._status_cache = {} + self._last_update_time = 0 + self._cache_timeout = 1.0 # 缓存1秒 + + self._initialize_communication() + + @abstractmethod + def _initialize_communication(self): + """初始化通信连接""" + pass + + @abstractmethod + def _load_address_mapping(self) -> Dict[str, Any]: + """加载地址映射表""" + pass + + def connect(self) -> bool: + """建立连接""" + try: + if self.config.protocol == CommunicationProtocol.MODBUS_TCP: + self.client = ModbusTCPClient( + addr=self.config.host, + port=self.config.port + ) + self.client.client.connect() + + # 等待连接建立 + count = 100 + while count > 0: + count -= 1 + if self.client.client.is_socket_open(): + self.is_connected = True + logger.info(f"工作站通信连接成功: {self.config.host}:{self.config.port}") + return True + time.sleep(0.1) + + if not self.client.client.is_socket_open(): + raise ConnectionError(f"无法连接到工作站: {self.config.host}:{self.config.port}") + + else: + raise NotImplementedError(f"协议 {self.config.protocol} 暂未实现") + + except Exception as e: + logger.error(f"工作站通信连接失败: {e}") + self.is_connected = False + return False + + def disconnect(self): + """断开连接""" + try: + if self.client and hasattr(self.client, 'client'): + self.client.client.close() + self.is_connected = False + logger.info("工作站通信连接已断开") + except Exception as e: + logger.error(f"断开连接时出错: {e}") + + # ============ 标准工作流接口 ============ + + def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: + """启动工作流""" + try: + if not self.is_connected: + logger.error("通信未连接,无法启动工作流") + return False + + logger.info(f"启动工作流: {workflow_type}, 参数: {parameters}") + return self._execute_start_workflow(workflow_type, parameters or {}) + + except Exception as e: + logger.error(f"启动工作流失败: {e}") + return False + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if not self.is_connected: + logger.error("通信未连接,无法停止工作流") + return False + + logger.info(f"停止工作流 (紧急: {emergency})") + return self._execute_stop_workflow(emergency) + + except Exception as e: + logger.error(f"停止工作流失败: {e}") + return False + + def get_workflow_status(self) -> Dict[str, Any]: + """获取工作流状态""" + try: + if not self.is_connected: + return {"error": "通信未连接"} + + return self._query_workflow_status() + + except Exception as e: + logger.error(f"查询工作流状态失败: {e}") + return {"error": str(e)} + + # ============ 设备状态查询接口 ============ + + def get_device_status(self, force_refresh: bool = False) -> Dict[str, Any]: + """获取设备状态(带缓存)""" + current_time = time.time() + + if not force_refresh and (current_time - self._last_update_time) < self._cache_timeout: + return self._status_cache + + try: + if not self.is_connected: + return {"error": "通信未连接"} + + status = self._query_device_status() + self._status_cache = status + self._last_update_time = current_time + return status + + except Exception as e: + logger.error(f"查询设备状态失败: {e}") + return {"error": str(e)} + + def get_production_data(self) -> Dict[str, Any]: + """获取生产数据""" + try: + if not self.is_connected: + return {"error": "通信未连接"} + + return self._query_production_data() + + except Exception as e: + logger.error(f"查询生产数据失败: {e}") + return {"error": str(e)} + + # ============ 单点控制接口(调试用)============ + + def write_register(self, register_name: str, value: Any, data_type: DataType = None, word_order: WorderOrder = None) -> bool: + """写寄存器(单点控制)""" + try: + if not self.is_connected: + logger.error("通信未连接,无法写寄存器") + return False + + return self._write_single_register(register_name, value, data_type, word_order) + + except Exception as e: + logger.error(f"写寄存器失败: {e}") + return False + + def read_register(self, register_name: str, count: int = 1, data_type: DataType = None, word_order: WorderOrder = None) -> tuple: + """读寄存器(单点控制)""" + try: + if not self.is_connected: + logger.error("通信未连接,无法读寄存器") + return None, True + + return self._read_single_register(register_name, count, data_type, word_order) + + except Exception as e: + logger.error(f"读寄存器失败: {e}") + return None, True + + # ============ 数据导出功能 ============ + + def start_data_export(self, file_path: str, export_interval: float = 1.0) -> bool: + """开始数据导出""" + try: + if self.data_export_running: + logger.warning("数据导出已在运行") + return False + + self.data_export_file = file_path + self.data_export_interval = export_interval + self.data_export_running = True + + # 创建CSV文件并写入表头 + self._initialize_export_file(file_path) + + # 启动数据收集线程 + self.data_export_thread = threading.Thread(target=self._data_export_worker) + self.data_export_thread.daemon = True + self.data_export_thread.start() + + logger.info(f"数据导出已启动: {file_path}") + return True + + except Exception as e: + logger.error(f"启动数据导出失败: {e}") + return False + + def stop_data_export(self) -> bool: + """停止数据导出""" + try: + if not self.data_export_running: + logger.warning("数据导出未运行") + return False + + self.data_export_running = False + + if self.data_export_thread and self.data_export_thread.is_alive(): + self.data_export_thread.join(timeout=5.0) + + logger.info("数据导出已停止") + return True + + except Exception as e: + logger.error(f"停止数据导出失败: {e}") + return False + + def _data_export_worker(self): + """数据导出工作线程""" + while self.data_export_running: + try: + data = self.get_production_data() + self._append_to_export_file(data) + time.sleep(self.data_export_interval) + except Exception as e: + logger.error(f"数据导出工作线程错误: {e}") + + # ============ 抽象方法 - 子类必须实现 ============ + + @abstractmethod + def _execute_start_workflow(self, workflow_type: str, parameters: Dict[str, Any]) -> bool: + """执行启动工作流命令""" + pass + + @abstractmethod + def _execute_stop_workflow(self, emergency: bool) -> bool: + """执行停止工作流命令""" + pass + + @abstractmethod + def _query_workflow_status(self) -> Dict[str, Any]: + """查询工作流状态""" + pass + + @abstractmethod + def _query_device_status(self) -> Dict[str, Any]: + """查询设备状态""" + pass + + @abstractmethod + def _query_production_data(self) -> Dict[str, Any]: + """查询生产数据""" + pass + + @abstractmethod + def _write_single_register(self, register_name: str, value: Any, data_type: DataType, word_order: WorderOrder) -> bool: + """写单个寄存器""" + pass + + @abstractmethod + def _read_single_register(self, register_name: str, count: int, data_type: DataType, word_order: WorderOrder) -> tuple: + """读单个寄存器""" + pass + + @abstractmethod + def _initialize_export_file(self, file_path: str): + """初始化导出文件""" + pass + + @abstractmethod + def _append_to_export_file(self, data: Dict[str, Any]): + """追加数据到导出文件""" + pass + + +class CoinCellCommunication(WorkstationCommunicationBase): + """纽扣电池组装系统通信类 + + 从 coin_cell_assembly_system 抽取的通信功能 + """ + + def __init__(self, communication_config: CommunicationConfig, csv_path: str = "./coin_cell_assembly.csv"): + self.csv_path = csv_path + super().__init__(communication_config) + + def _initialize_communication(self): + """初始化通信连接""" + # 加载节点映射 + try: + nodes = self.client.load_csv(self.csv_path) if self.client else [] + if self.client: + self.client.register_node_list(nodes) + except Exception as e: + logger.error(f"加载节点映射失败: {e}") + + def _load_address_mapping(self) -> Dict[str, Any]: + """加载地址映射表""" + # 从CSV文件加载地址映射 + return {} + + def _execute_start_workflow(self, workflow_type: str, parameters: Dict[str, Any]) -> bool: + """执行启动工作流命令""" + if workflow_type == "battery_manufacturing": + # 发送电池制造启动命令 + return self._start_battery_manufacturing(parameters) + else: + logger.error(f"不支持的工作流类型: {workflow_type}") + return False + + def _start_battery_manufacturing(self, parameters: Dict[str, Any]) -> bool: + """启动电池制造工作流""" + try: + # 1. 设置参数 + if "electrolyte_num" in parameters: + self.client.use_node('REG_MSG_ELECTROLYTE_NUM').write(parameters["electrolyte_num"]) + + if "electrolyte_volume" in parameters: + self.client.use_node('REG_MSG_ELECTROLYTE_VOLUME').write( + parameters["electrolyte_volume"], + data_type=DataType.FLOAT32, + word_order=WorderOrder.LITTLE + ) + + if "assembly_pressure" in parameters: + self.client.use_node('REG_MSG_ASSEMBLY_PRESSURE').write( + parameters["assembly_pressure"], + data_type=DataType.FLOAT32, + word_order=WorderOrder.LITTLE + ) + + # 2. 发送启动命令 + self.client.use_node('COIL_SYS_START_CMD').write(True) + + # 3. 确认启动成功 + time.sleep(0.5) + status, read_err = self.client.use_node('COIL_SYS_START_STATUS').read(1) + return not read_err and status[0] + + except Exception as e: + logger.error(f"启动电池制造工作流失败: {e}") + return False + + def _execute_stop_workflow(self, emergency: bool) -> bool: + """执行停止工作流命令""" + try: + if emergency: + # 紧急停止 + self.client.use_node('COIL_SYS_RESET_CMD').write(True) + else: + # 正常停止 + self.client.use_node('COIL_SYS_STOP_CMD').write(True) + + time.sleep(0.5) + status, read_err = self.client.use_node('COIL_SYS_STOP_STATUS').read(1) + return not read_err and status[0] + + except Exception as e: + logger.error(f"停止工作流失败: {e}") + return False + + def _query_workflow_status(self) -> Dict[str, Any]: + """查询工作流状态""" + try: + status = {} + + # 读取系统状态 + start_status, _ = self.client.use_node('COIL_SYS_START_STATUS').read(1) + stop_status, _ = self.client.use_node('COIL_SYS_STOP_STATUS').read(1) + auto_status, _ = self.client.use_node('COIL_SYS_AUTO_STATUS').read(1) + init_status, _ = self.client.use_node('COIL_SYS_INIT_STATUS').read(1) + + status.update({ + "is_running": start_status[0] if start_status else False, + "is_stopped": stop_status[0] if stop_status else False, + "is_auto_mode": auto_status[0] if auto_status else False, + "is_initialized": init_status[0] if init_status else False, + }) + + return status + + except Exception as e: + logger.error(f"查询工作流状态失败: {e}") + return {"error": str(e)} + + def _query_device_status(self) -> Dict[str, Any]: + """查询设备状态""" + try: + status = {} + + # 读取位置信息 + x_pos, _ = self.client.use_node('REG_DATA_AXIS_X_POS').read(2, word_order=WorderOrder.LITTLE) + y_pos, _ = self.client.use_node('REG_DATA_AXIS_Y_POS').read(2, word_order=WorderOrder.LITTLE) + z_pos, _ = self.client.use_node('REG_DATA_AXIS_Z_POS').read(2, word_order=WorderOrder.LITTLE) + + # 读取环境数据 + pressure, _ = self.client.use_node('REG_DATA_GLOVE_BOX_PRESSURE').read(2, word_order=WorderOrder.LITTLE) + o2_content, _ = self.client.use_node('REG_DATA_GLOVE_BOX_O2_CONTENT').read(2, word_order=WorderOrder.LITTLE) + water_content, _ = self.client.use_node('REG_DATA_GLOVE_BOX_WATER_CONTENT').read(2, word_order=WorderOrder.LITTLE) + + status.update({ + "axis_position": { + "x": x_pos[0] if x_pos else 0.0, + "y": y_pos[0] if y_pos else 0.0, + "z": z_pos[0] if z_pos else 0.0, + }, + "environment": { + "glove_box_pressure": pressure[0] if pressure else 0.0, + "o2_content": o2_content[0] if o2_content else 0.0, + "water_content": water_content[0] if water_content else 0.0, + } + }) + + return status + + except Exception as e: + logger.error(f"查询设备状态失败: {e}") + return {"error": str(e)} + + def _query_production_data(self) -> Dict[str, Any]: + """查询生产数据""" + try: + data = {} + + # 读取生产统计 + coin_cell_num, _ = self.client.use_node('REG_DATA_ASSEMBLY_COIN_CELL_NUM').read(1) + assembly_time, _ = self.client.use_node('REG_DATA_ASSEMBLY_TIME').read(2, word_order=WorderOrder.LITTLE) + voltage, _ = self.client.use_node('REG_DATA_OPEN_CIRCUIT_VOLTAGE').read(2, word_order=WorderOrder.LITTLE) + + # 读取当前产品信息 + coin_cell_code, _ = self.client.use_node('REG_DATA_COIN_CELL_CODE').read(20) # 假设是字符串 + electrolyte_code, _ = self.client.use_node('REG_DATA_ELECTROLYTE_CODE').read(20) + + data.update({ + "production_count": coin_cell_num[0] if coin_cell_num else 0, + "assembly_time": assembly_time[0] if assembly_time else 0.0, + "open_circuit_voltage": voltage[0] if voltage else 0.0, + "current_battery_code": self._decode_string(coin_cell_code) if coin_cell_code else "", + "current_electrolyte_code": self._decode_string(electrolyte_code) if electrolyte_code else "", + "timestamp": time.time(), + }) + + return data + + except Exception as e: + logger.error(f"查询生产数据失败: {e}") + return {"error": str(e)} + + def _write_single_register(self, register_name: str, value: Any, data_type: DataType = None, word_order: WorderOrder = None) -> bool: + """写单个寄存器""" + try: + kwargs = {"value": value} + if data_type: + kwargs["data_type"] = data_type + if word_order: + kwargs["word_order"] = word_order + + result = self.client.use_node(register_name).write(**kwargs) + return result + + except Exception as e: + logger.error(f"写寄存器 {register_name} 失败: {e}") + return False + + def _read_single_register(self, register_name: str, count: int = 1, data_type: DataType = None, word_order: WorderOrder = None) -> tuple: + """读单个寄存器""" + try: + kwargs = {"count": count} + if data_type: + kwargs["data_type"] = data_type + if word_order: + kwargs["word_order"] = word_order + + value, error = self.client.use_node(register_name).read(**kwargs) + return value, error + + except Exception as e: + logger.error(f"读寄存器 {register_name} 失败: {e}") + return None, True + + def _initialize_export_file(self, file_path: str): + """初始化导出文件""" + import csv + try: + with open(file_path, 'w', newline='', encoding='utf-8') as csvfile: + fieldnames = [ + 'timestamp', 'production_count', 'assembly_time', + 'open_circuit_voltage', 'battery_code', 'electrolyte_code', + 'axis_x', 'axis_y', 'axis_z', 'glove_box_pressure', + 'o2_content', 'water_content' + ] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + except Exception as e: + logger.error(f"初始化导出文件失败: {e}") + + def _append_to_export_file(self, data: Dict[str, Any]): + """追加数据到导出文件""" + import csv + try: + with open(self.data_export_file, 'a', newline='', encoding='utf-8') as csvfile: + fieldnames = [ + 'timestamp', 'production_count', 'assembly_time', + 'open_circuit_voltage', 'battery_code', 'electrolyte_code', + 'axis_x', 'axis_y', 'axis_z', 'glove_box_pressure', + 'o2_content', 'water_content' + ] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + row = { + 'timestamp': data.get('timestamp', time.time()), + 'production_count': data.get('production_count', 0), + 'assembly_time': data.get('assembly_time', 0.0), + 'open_circuit_voltage': data.get('open_circuit_voltage', 0.0), + 'battery_code': data.get('current_battery_code', ''), + 'electrolyte_code': data.get('current_electrolyte_code', ''), + } + + # 添加位置数据 + axis_pos = data.get('axis_position', {}) + row.update({ + 'axis_x': axis_pos.get('x', 0.0), + 'axis_y': axis_pos.get('y', 0.0), + 'axis_z': axis_pos.get('z', 0.0), + }) + + # 添加环境数据 + env = data.get('environment', {}) + row.update({ + 'glove_box_pressure': env.get('glove_box_pressure', 0.0), + 'o2_content': env.get('o2_content', 0.0), + 'water_content': env.get('water_content', 0.0), + }) + + writer.writerow(row) + + except Exception as e: + logger.error(f"追加数据到导出文件失败: {e}") + + def _decode_string(self, data_list: List[int]) -> str: + """将寄存器数据解码为字符串""" + try: + # 假设每个寄存器包含2个字符(16位) + chars = [] + for value in data_list: + if value == 0: + break + chars.append(chr(value & 0xFF)) + if (value >> 8) & 0xFF != 0: + chars.append(chr((value >> 8) & 0xFF)) + return ''.join(chars).rstrip('\x00') + except: + return "" diff --git a/unilabos/device_comms/workstation_material_management.py b/unilabos/device_comms/workstation_material_management.py new file mode 100644 index 00000000..a9229130 --- /dev/null +++ b/unilabos/device_comms/workstation_material_management.py @@ -0,0 +1,583 @@ +""" +工作站物料管理基类 +Workstation Material Management Base Class + +基于PyLabRobot的物料管理系统 +""" +from typing import Dict, Any, List, Optional, Union, Type +from abc import ABC, abstractmethod +import json + +from pylabrobot.resources import ( + Resource as PLRResource, + Container, + Deck, + Coordinate as PLRCoordinate, +) + +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.utils.log import logger +from unilabos.resources.graphio import resource_plr_to_ulab, resource_ulab_to_plr + + +class MaterialManagementBase(ABC): + """物料管理基类 + + 定义工作站物料管理的标准接口: + 1. 物料初始化 - 根据配置创建物料资源 + 2. 物料追踪 - 实时跟踪物料位置和状态 + 3. 物料查找 - 按类型、位置、状态查找物料 + 4. 物料转换 - PyLabRobot与UniLab资源格式转换 + """ + + def __init__( + self, + device_id: str, + deck_config: Dict[str, Any], + resource_tracker: DeviceNodeResourceTracker, + children_config: Dict[str, Dict[str, Any]] = None + ): + self.device_id = device_id + self.deck_config = deck_config + self.resource_tracker = resource_tracker + self.children_config = children_config or {} + + # 创建主台面 + self.plr_deck = self._create_deck() + + # 扩展ResourceTracker + self._extend_resource_tracker() + + # 注册deck到resource tracker + self.resource_tracker.add_resource(self.plr_deck) + + # 初始化子资源 + self.plr_resources = {} + self._initialize_materials() + + def _create_deck(self) -> Deck: + """创建主台面""" + return Deck( + name=f"{self.device_id}_deck", + size_x=self.deck_config.get("size_x", 1000.0), + size_y=self.deck_config.get("size_y", 1000.0), + size_z=self.deck_config.get("size_z", 500.0), + origin=PLRCoordinate(0, 0, 0) + ) + + def _extend_resource_tracker(self): + """扩展ResourceTracker以支持PyLabRobot特定功能""" + + def find_by_type(resource_type): + """按类型查找资源""" + return self._find_resources_by_type_recursive(self.plr_deck, resource_type) + + def find_by_category(category: str): + """按类别查找资源""" + found = [] + for resource in self._get_all_resources(): + if hasattr(resource, 'category') and resource.category == category: + found.append(resource) + return found + + def find_by_name_pattern(pattern: str): + """按名称模式查找资源""" + import re + found = [] + for resource in self._get_all_resources(): + if re.search(pattern, resource.name): + found.append(resource) + return found + + # 动态添加方法到resource_tracker + self.resource_tracker.find_by_type = find_by_type + self.resource_tracker.find_by_category = find_by_category + self.resource_tracker.find_by_name_pattern = find_by_name_pattern + + def _find_resources_by_type_recursive(self, resource, target_type): + """递归查找指定类型的资源""" + found = [] + if isinstance(resource, target_type): + found.append(resource) + + # 递归查找子资源 + children = getattr(resource, "children", []) + for child in children: + found.extend(self._find_resources_by_type_recursive(child, target_type)) + + return found + + def _get_all_resources(self) -> List[PLRResource]: + """获取所有资源""" + all_resources = [] + + def collect_resources(resource): + all_resources.append(resource) + children = getattr(resource, "children", []) + for child in children: + collect_resources(child) + + collect_resources(self.plr_deck) + return all_resources + + def _initialize_materials(self): + """初始化物料""" + try: + # 确定创建顺序,确保父资源先于子资源创建 + creation_order = self._determine_creation_order() + + # 按顺序创建资源 + for resource_id in creation_order: + config = self.children_config[resource_id] + self._create_plr_resource(resource_id, config) + + logger.info(f"物料管理系统初始化完成,共创建 {len(self.plr_resources)} 个资源") + + except Exception as e: + logger.error(f"物料初始化失败: {e}") + + def _determine_creation_order(self) -> List[str]: + """确定资源创建顺序""" + order = [] + visited = set() + + def visit(resource_id: str): + if resource_id in visited: + return + visited.add(resource_id) + + config = self.children_config.get(resource_id, {}) + parent_id = config.get("parent") + + # 如果有父资源,先访问父资源 + if parent_id and parent_id in self.children_config: + visit(parent_id) + + order.append(resource_id) + + for resource_id in self.children_config: + visit(resource_id) + + return order + + def _create_plr_resource(self, resource_id: str, config: Dict[str, Any]): + """创建PyLabRobot资源""" + try: + resource_type = config.get("type", "unknown") + data = config.get("data", {}) + location_config = config.get("location", {}) + + # 创建位置坐标 + location = PLRCoordinate( + x=location_config.get("x", 0.0), + y=location_config.get("y", 0.0), + z=location_config.get("z", 0.0) + ) + + # 根据类型创建资源 + resource = self._create_resource_by_type(resource_id, resource_type, config, data, location) + + if resource: + # 设置父子关系 + parent_id = config.get("parent") + if parent_id and parent_id in self.plr_resources: + parent_resource = self.plr_resources[parent_id] + parent_resource.assign_child_resource(resource, location) + else: + # 直接放在deck上 + self.plr_deck.assign_child_resource(resource, location) + + # 保存资源引用 + self.plr_resources[resource_id] = resource + + # 注册到resource tracker + self.resource_tracker.add_resource(resource) + + logger.debug(f"创建资源成功: {resource_id} ({resource_type})") + + except Exception as e: + logger.error(f"创建资源失败 {resource_id}: {e}") + + @abstractmethod + def _create_resource_by_type( + self, + resource_id: str, + resource_type: str, + config: Dict[str, Any], + data: Dict[str, Any], + location: PLRCoordinate + ) -> Optional[PLRResource]: + """根据类型创建资源 - 子类必须实现""" + pass + + # ============ 物料查找接口 ============ + + def find_materials_by_type(self, material_type: str) -> List[PLRResource]: + """按材料类型查找物料""" + return self.resource_tracker.find_by_category(material_type) + + def find_material_by_id(self, resource_id: str) -> Optional[PLRResource]: + """按ID查找物料""" + return self.plr_resources.get(resource_id) + + def find_available_positions(self, position_type: str) -> List[PLRResource]: + """查找可用位置""" + positions = self.resource_tracker.find_by_category(position_type) + available = [] + + for pos in positions: + if hasattr(pos, 'is_available') and pos.is_available(): + available.append(pos) + elif hasattr(pos, 'children') and len(pos.children) == 0: + available.append(pos) + + return available + + def get_material_inventory(self) -> Dict[str, int]: + """获取物料库存统计""" + inventory = {} + + for resource in self._get_all_resources(): + if hasattr(resource, 'category'): + category = resource.category + inventory[category] = inventory.get(category, 0) + 1 + + return inventory + + # ============ 物料状态更新接口 ============ + + def update_material_location(self, material_id: str, new_location: PLRCoordinate) -> bool: + """更新物料位置""" + try: + material = self.find_material_by_id(material_id) + if material: + material.location = new_location + return True + return False + except Exception as e: + logger.error(f"更新物料位置失败: {e}") + return False + + def move_material(self, material_id: str, target_container_id: str) -> bool: + """移动物料到目标容器""" + try: + material = self.find_material_by_id(material_id) + target = self.find_material_by_id(target_container_id) + + if material and target: + # 从原位置移除 + if material.parent: + material.parent.unassign_child_resource(material) + + # 添加到新位置 + target.assign_child_resource(material) + return True + + return False + + except Exception as e: + logger.error(f"移动物料失败: {e}") + return False + + # ============ 资源转换接口 ============ + + def convert_to_unilab_format(self, plr_resource: PLRResource) -> Dict[str, Any]: + """将PyLabRobot资源转换为UniLab格式""" + return resource_plr_to_ulab(plr_resource) + + def convert_from_unilab_format(self, unilab_resource: Dict[str, Any]) -> PLRResource: + """将UniLab格式转换为PyLabRobot资源""" + return resource_ulab_to_plr(unilab_resource) + + def get_deck_state(self) -> Dict[str, Any]: + """获取Deck状态""" + try: + return { + "deck_info": { + "name": self.plr_deck.name, + "size": { + "x": self.plr_deck.size_x, + "y": self.plr_deck.size_y, + "z": self.plr_deck.size_z + }, + "children_count": len(self.plr_deck.children) + }, + "resources": { + resource_id: self.convert_to_unilab_format(resource) + for resource_id, resource in self.plr_resources.items() + }, + "inventory": self.get_material_inventory() + } + except Exception as e: + logger.error(f"获取Deck状态失败: {e}") + return {"error": str(e)} + + # ============ 数据持久化接口 ============ + + def save_state_to_file(self, file_path: str) -> bool: + """保存状态到文件""" + try: + state = self.get_deck_state() + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(state, f, indent=2, ensure_ascii=False) + logger.info(f"状态已保存到: {file_path}") + return True + except Exception as e: + logger.error(f"保存状态失败: {e}") + return False + + def load_state_from_file(self, file_path: str) -> bool: + """从文件加载状态""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + state = json.load(f) + + # 重新创建资源 + self._recreate_resources_from_state(state) + logger.info(f"状态已从文件加载: {file_path}") + return True + + except Exception as e: + logger.error(f"加载状态失败: {e}") + return False + + def _recreate_resources_from_state(self, state: Dict[str, Any]): + """从状态重新创建资源""" + # 清除现有资源 + self.plr_resources.clear() + self.plr_deck.children.clear() + + # 从状态重新创建 + resources_data = state.get("resources", {}) + for resource_id, resource_data in resources_data.items(): + try: + plr_resource = self.convert_from_unilab_format(resource_data) + self.plr_resources[resource_id] = plr_resource + self.plr_deck.assign_child_resource(plr_resource) + except Exception as e: + logger.error(f"重新创建资源失败 {resource_id}: {e}") + + +class CoinCellMaterialManagement(MaterialManagementBase): + """纽扣电池物料管理类 + + 从 button_battery_station 抽取的物料管理功能 + """ + + def _create_resource_by_type( + self, + resource_id: str, + resource_type: str, + config: Dict[str, Any], + data: Dict[str, Any], + location: PLRCoordinate + ) -> Optional[PLRResource]: + """根据类型创建纽扣电池相关资源""" + + # 导入纽扣电池资源类 + from unilabos.device_comms.button_battery_station import ( + MaterialPlate, PlateSlot, ClipMagazine, BatteryPressSlot, + TipBox64, WasteTipBox, BottleRack, Battery, ElectrodeSheet + ) + + try: + if resource_type == "material_plate": + return self._create_material_plate(resource_id, config, data, location) + + elif resource_type == "plate_slot": + return self._create_plate_slot(resource_id, config, data, location) + + elif resource_type == "clip_magazine": + return self._create_clip_magazine(resource_id, config, data, location) + + elif resource_type == "battery_press_slot": + return self._create_battery_press_slot(resource_id, config, data, location) + + elif resource_type == "tip_box": + return self._create_tip_box(resource_id, config, data, location) + + elif resource_type == "waste_tip_box": + return self._create_waste_tip_box(resource_id, config, data, location) + + elif resource_type == "bottle_rack": + return self._create_bottle_rack(resource_id, config, data, location) + + elif resource_type == "battery": + return self._create_battery(resource_id, config, data, location) + + else: + logger.warning(f"未知的资源类型: {resource_type}") + return None + + except Exception as e: + logger.error(f"创建资源失败 {resource_id} ({resource_type}): {e}") + return None + + def _create_material_plate(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建料板""" + from unilabos.device_comms.button_battery_station import MaterialPlate, ElectrodeSheet + + plate = MaterialPlate( + name=resource_id, + size_x=config.get("size_x", 80.0), + size_y=config.get("size_y", 80.0), + size_z=config.get("size_z", 10.0), + hole_diameter=config.get("hole_diameter", 15.0), + hole_depth=config.get("hole_depth", 8.0), + hole_spacing_x=config.get("hole_spacing_x", 20.0), + hole_spacing_y=config.get("hole_spacing_y", 20.0), + number=data.get("number", "") + ) + plate.location = location + + # 如果有预填充的极片数据,创建极片 + electrode_sheets = data.get("electrode_sheets", []) + for i, sheet_data in enumerate(electrode_sheets): + if i < len(plate.children): # 确保不超过洞位数量 + hole = plate.children[i] + sheet = ElectrodeSheet( + name=f"{resource_id}_sheet_{i}", + diameter=sheet_data.get("diameter", 14.0), + thickness=sheet_data.get("thickness", 0.1), + mass=sheet_data.get("mass", 0.01), + material_type=sheet_data.get("material_type", "cathode"), + info=sheet_data.get("info", "") + ) + hole.place_electrode_sheet(sheet) + + return plate + + def _create_plate_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建板槽位""" + from unilabos.device_comms.button_battery_station import PlateSlot + + slot = PlateSlot( + name=resource_id, + max_plates=config.get("max_plates", 8) + ) + slot.location = location + return slot + + def _create_clip_magazine(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建子弹夹""" + from unilabos.device_comms.button_battery_station import ClipMagazine + + magazine = ClipMagazine( + name=resource_id, + size_x=config.get("size_x", 150.0), + size_y=config.get("size_y", 100.0), + size_z=config.get("size_z", 50.0), + hole_diameter=config.get("hole_diameter", 15.0), + hole_depth=config.get("hole_depth", 40.0), + hole_spacing=config.get("hole_spacing", 25.0), + max_sheets_per_hole=config.get("max_sheets_per_hole", 100) + ) + magazine.location = location + return magazine + + def _create_battery_press_slot(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建电池压制槽""" + from unilabos.device_comms.button_battery_station import BatteryPressSlot + + slot = BatteryPressSlot( + name=resource_id, + diameter=config.get("diameter", 20.0), + depth=config.get("depth", 15.0) + ) + slot.location = location + return slot + + def _create_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建枪头盒""" + from unilabos.device_comms.button_battery_station import TipBox64 + + tip_box = TipBox64( + name=resource_id, + size_x=config.get("size_x", 127.8), + size_y=config.get("size_y", 85.5), + size_z=config.get("size_z", 60.0), + with_tips=data.get("with_tips", True) + ) + tip_box.location = location + return tip_box + + def _create_waste_tip_box(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建废枪头盒""" + from unilabos.device_comms.button_battery_station import WasteTipBox + + waste_box = WasteTipBox( + name=resource_id, + size_x=config.get("size_x", 127.8), + size_y=config.get("size_y", 85.5), + size_z=config.get("size_z", 60.0), + max_tips=config.get("max_tips", 100) + ) + waste_box.location = location + return waste_box + + def _create_bottle_rack(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建瓶架""" + from unilabos.device_comms.button_battery_station import BottleRack + + rack = BottleRack( + name=resource_id, + size_x=config.get("size_x", 210.0), + size_y=config.get("size_y", 140.0), + size_z=config.get("size_z", 100.0), + bottle_diameter=config.get("bottle_diameter", 30.0), + bottle_height=config.get("bottle_height", 100.0), + position_spacing=config.get("position_spacing", 35.0) + ) + rack.location = location + return rack + + def _create_battery(self, resource_id: str, config: Dict[str, Any], data: Dict[str, Any], location: PLRCoordinate): + """创建电池""" + from unilabos.device_comms.button_battery_station import Battery + + battery = Battery( + name=resource_id, + diameter=config.get("diameter", 20.0), + height=config.get("height", 3.2), + max_volume=config.get("max_volume", 100.0), + barcode=data.get("barcode", "") + ) + battery.location = location + return battery + + # ============ 纽扣电池特定查找方法 ============ + + def find_material_plates(self): + """查找所有料板""" + from unilabos.device_comms.button_battery_station import MaterialPlate + return self.resource_tracker.find_by_type(MaterialPlate) + + def find_batteries(self): + """查找所有电池""" + from unilabos.device_comms.button_battery_station import Battery + return self.resource_tracker.find_by_type(Battery) + + def find_electrode_sheets(self): + """查找所有极片""" + found = [] + plates = self.find_material_plates() + for plate in plates: + for hole in plate.children: + if hasattr(hole, 'has_electrode_sheet') and hole.has_electrode_sheet(): + found.append(hole._electrode_sheet) + return found + + def find_plate_slots(self): + """查找所有板槽位""" + from unilabos.device_comms.button_battery_station import PlateSlot + return self.resource_tracker.find_by_type(PlateSlot) + + def find_clip_magazines(self): + """查找所有子弹夹""" + from unilabos.device_comms.button_battery_station import ClipMagazine + return self.resource_tracker.find_by_type(ClipMagazine) + + def find_press_slots(self): + """查找所有压制槽""" + from unilabos.device_comms.button_battery_station import BatteryPressSlot + return self.resource_tracker.find_by_type(BatteryPressSlot) From 9f823a4198b64557af95c61bdabac109b1aec3a6 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 21 Aug 2025 10:05:58 +0800 Subject: [PATCH 02/13] update workstation base --- unilabos/device_comms/workstation_base.py | 858 ++++++++++++++++-- .../device_comms/workstation_http_service.py | 605 ++++++++++++ unilabos/ros/nodes/presets/protocol_node.py | 135 ++- 3 files changed, 1508 insertions(+), 90 deletions(-) create mode 100644 unilabos/device_comms/workstation_http_service.py diff --git a/unilabos/device_comms/workstation_base.py b/unilabos/device_comms/workstation_base.py index 7b61c17b..887d56e5 100644 --- a/unilabos/device_comms/workstation_base.py +++ b/unilabos/device_comms/workstation_base.py @@ -25,6 +25,9 @@ from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker from unilabos.device_comms.workstation_communication import WorkstationCommunicationBase, CommunicationConfig from unilabos.device_comms.workstation_material_management import MaterialManagementBase +from unilabos.device_comms.workstation_http_service import ( + WorkstationHTTPService, WorkstationReportRequest, MaterialUsage +) from unilabos.ros.msgs.message_converter import convert_to_ros_msg, convert_from_ros_msg from unilabos.utils.log import logger from unilabos.utils.type_check import serialize_result_info @@ -114,6 +117,7 @@ class WorkstationBase(ROS2ProtocolNode, ABC): communication_config: CommunicationConfig, deck_config: Optional[Dict[str, Any]] = None, communication_interfaces: Optional[Dict[str, CommunicationInterface]] = None, + http_service_config: Optional[Dict[str, Any]] = None, # 新增:HTTP服务配置 *args, **kwargs, ): @@ -122,6 +126,18 @@ class WorkstationBase(ROS2ProtocolNode, ABC): self.deck_config = deck_config or {"size_x": 1000.0, "size_y": 1000.0, "size_z": 500.0} self.communication_interfaces = communication_interfaces or {} + # HTTP服务配置 - 现在专门用于报送接收 + self.http_service_config = http_service_config or { + "enabled": True, + "host": "127.0.0.1", + "port": 8081 # 默认使用8081端口作为报送接收服务 + } + + # 错误处理和动作追踪 + self.current_action_context = None # 当前正在执行的动作上下文 + self.error_history = [] # 错误历史记录 + self.action_results = {} # 动作结果缓存 + # 工作流状态 - 支持静态和动态工作流 self.current_workflow_status = WorkflowStatus.IDLE self.current_workflow_info = None @@ -135,28 +151,27 @@ class WorkstationBase(ROS2ProtocolNode, ABC): self.registered_workflows: Dict[str, WorkflowDefinition] = {} self._workflow_action_servers: Dict[str, ActionServer] = {} - # 初始化基类 - ROS2ProtocolNode会处理子设备初始化 + # 初始化基类 - ROS2ProtocolNode会处理所有设备管理 super().__init__( device_id=device_id, children=children, protocol_type=protocol_type, resource_tracker=resource_tracker, + workstation_config={ + 'communication_interfaces': communication_interfaces, + 'deck_config': self.deck_config + }, *args, **kwargs ) - # 工作站特有的设备分类 (基于已初始化的sub_devices) - self.communication_devices: Dict[str, Any] = {} - self.logical_devices: Dict[str, Any] = {} - self._classify_devices() + # 使用父类的设备分类结果(不再重复分类) + # self.communication_devices 和 self.logical_devices 由 ROS2ProtocolNode 提供 # 初始化工作站模块 self.communication: WorkstationCommunicationBase = self._create_communication_module() self.material_management: MaterialManagementBase = self._create_material_management_module() - # 设置工作站特定的通信接口 - self._setup_workstation_communication_interfaces() - # 注册支持的工作流 self._register_supported_workflows() @@ -166,62 +181,12 @@ class WorkstationBase(ROS2ProtocolNode, ABC): # 启动状态监控 self._start_status_monitoring() + # 启动HTTP报送接收服务 + self.http_service = None + self._start_http_service() + logger.info(f"增强工作站基类 {device_id} 初始化完成") - def _classify_devices(self): - """基于已初始化的设备进行分类""" - for device_id, device in self.sub_devices.items(): - device_config = self.children.get(device_id, {}) - device_type = DeviceType(device_config.get("device_type", "logical")) - - if device_type == DeviceType.COMMUNICATION: - self.communication_devices[device_id] = device - logger.info(f"通信设备 {device_id} 已分类") - elif device_type == DeviceType.LOGICAL: - self.logical_devices[device_id] = device - logger.info(f"逻辑设备 {device_id} 已分类") - - def _setup_workstation_communication_interfaces(self): - """设置工作站特定的通信接口代理""" - for logical_device_id, logical_device in self.logical_devices.items(): - # 检查是否有配置的通信接口 - interface_config = self.communication_interfaces.get(logical_device_id) - if not interface_config: - continue - - comm_device = self.communication_devices.get(interface_config.device_id) - if not comm_device: - logger.error(f"通信设备 {interface_config.device_id} 不存在") - continue - - # 设置工作站级别的通信代理 - self._setup_workstation_hardware_proxy( - logical_device, - comm_device, - interface_config - ) - - def _setup_workstation_hardware_proxy(self, logical_device, comm_device, interface: CommunicationInterface): - """为逻辑设备设置工作站级通信代理""" - try: - # 获取通信设备的读写方法 - read_func = getattr(comm_device.driver_instance, interface.read_method, None) - write_func = getattr(comm_device.driver_instance, interface.write_method, None) - - if read_func: - setattr(logical_device.driver_instance, 'comm_read', read_func) - if write_func: - setattr(logical_device.driver_instance, 'comm_write', write_func) - - # 设置通信配置 - setattr(logical_device.driver_instance, 'comm_config', interface.config) - setattr(logical_device.driver_instance, 'comm_protocol', interface.protocol_type) - - logger.info(f"为逻辑设备 {logical_device.device_id} 设置工作站通信代理 -> {comm_device.device_id}") - - except Exception as e: - logger.error(f"设置工作站通信代理失败: {e}") - @abstractmethod def _create_communication_module(self) -> WorkstationCommunicationBase: """创建通信模块 - 子类必须实现""" @@ -355,6 +320,579 @@ class WorkstationBase(ROS2ProtocolNode, ABC): # 目前简化为按需查询 pass + def _start_http_service(self): + """启动HTTP报送接收服务""" + try: + if not self.http_service_config.get("enabled", True): + logger.info("HTTP报送接收服务已禁用") + return + + host = self.http_service_config.get("host", "127.0.0.1") + port = self.http_service_config.get("port", 8081) + + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=host, + port=port + ) + + self.http_service.start() + logger.info(f"工作站 {self.device_id} HTTP报送接收服务启动成功: {self.http_service.service_url}") + + except Exception as e: + logger.error(f"启动HTTP报送接收服务失败: {e}") + self.http_service = None + + def _stop_http_service(self): + """停止HTTP报送接收服务""" + try: + if self.http_service: + self.http_service.stop() + self.http_service = None + logger.info("HTTP报送接收服务已停止") + except Exception as e: + logger.error(f"停止HTTP报送接收服务失败: {e}") + + # ============ 报送处理方法 ============ + + def process_material_change_report(self, report) -> Dict[str, Any]: + """处理物料变更报送 - 同步到 ResourceTracker 并发送 ROS2 更新""" + try: + logger.info(f"处理物料变更报送: {report.workstation_id} -> {report.resource_id} ({report.change_type})") + + # 增加接收计数 + self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 + + # 准备变更数据 + changes = { + 'workstation_id': report.workstation_id, + 'timestamp': report.timestamp, + 'change_type': report.change_type, + 'resource_id': report.resource_id + } + + # 添加额外的变更信息 + if hasattr(report, 'new_location'): + changes['location'] = { + 'x': getattr(report.new_location, 'x', 0), + 'y': getattr(report.new_location, 'y', 0), + 'z': getattr(report.new_location, 'z', 0) + } + + if hasattr(report, 'quantity'): + changes['quantity'] = report.quantity + + if hasattr(report, 'status'): + changes['status'] = report.status + + # 同步到 ResourceTracker + sync_success = self.resource_tracker.update_material_state( + report.resource_id, + changes, + report.change_type + ) + + result = { + 'processed': True, + 'resource_id': report.resource_id, + 'change_type': report.change_type, + 'next_actions': [], + 'tracker_sync': sync_success + } + + # 发送 ROS2 ResourceUpdate 请求到 host node + if sync_success: + try: + self._send_resource_update_to_host(report.resource_id, changes) + result['ros_update_sent'] = True + result['next_actions'].append('ros_update_completed') + except Exception as e: + logger.warning(f"发送ROS2资源更新失败: {e}") + result['ros_update_sent'] = False + result['warnings'] = [f"ROS2更新失败: {str(e)}"] + + # 根据变更类型处理 + if report.change_type == 'created': + result['next_actions'].append('sync_to_global_registry') + self._handle_material_created(report) + + elif report.change_type == 'updated': + result['next_actions'].append('update_local_state') + self._handle_material_updated(report) + + elif report.change_type == 'moved': + result['next_actions'].append('update_location_tracking') + self._handle_material_moved(report) + + elif report.change_type == 'consumed': + result['next_actions'].append('update_inventory') + self._handle_material_consumed(report) + + elif report.change_type == 'completed': + result['next_actions'].append('trigger_next_workflow') + self._handle_material_completed(report) + + # 更新本地物料管理系统(如果存在) + if hasattr(self, 'material_management'): + try: + self.material_management.sync_external_material_change(report) + except Exception as e: + logger.warning(f"同步物料变更到本地管理系统失败: {e}") + + return result + + except Exception as e: + logger.error(f"处理物料变更报送失败: {e}") + return { + 'processed': False, + 'error': str(e), + 'next_actions': ['retry_processing'] + } + + def process_workflow_status_report(self, workstation_id: str, workflow_id: str, + status: str, data: Dict[str, Any]) -> Dict[str, Any]: + """处理工作流状态报送""" + try: + logger.info(f"处理工作流状态报送: {workstation_id} -> {workflow_id} ({status})") + + # 增加接收计数 + self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 + + result = { + 'processed': True, + 'workflow_id': workflow_id, + 'status': status + } + + # 这里可以添加工作流状态同步逻辑 + # 例如:更新本地工作流状态、触发后续动作等 + + return result + + except Exception as e: + logger.error(f"处理工作流状态报送失败: {e}") + return {'processed': False, 'error': str(e)} + + # ============ 统一报送处理方法(基于LIMS协议规范) ============ + + def process_step_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: + """处理步骤完成报送(统一LIMS协议规范)- 同步到 ResourceTracker""" + try: + data = request.data + logger.info(f"处理步骤完成报送: {data['orderCode']} - {data['stepName']} (步骤ID: {data['stepId']})") + + # 增加接收计数 + self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 + + # 同步步骤信息到 ResourceTracker + step_changes = { + 'order_code': data['orderCode'], + 'order_name': data.get('orderName', ''), + 'step_name': data['stepName'], + 'step_id': data['stepId'], + 'sample_id': data['sampleId'], + 'start_time': data['startTime'], + 'end_time': data['endTime'], + 'execution_status': data.get('executionStatus', 'completed'), + 'status': 'step_completed', + 'last_updated': request.request_time + } + + # 更新 ResourceTracker 中的样本状态 + sample_sync_success = False + if data['sampleId']: + sample_sync_success = self.resource_tracker.update_material_state( + data['sampleId'], + { + 'current_step': data['stepName'], + 'step_status': 'completed', + 'last_step_time': data['endTime'], + 'execution_status': data.get('executionStatus', 'completed') + }, + 'step_finished' + ) + + result = { + 'processed': True, + 'order_code': data['orderCode'], + 'step_id': data['stepId'], + 'step_name': data['stepName'], + 'sample_id': data['sampleId'], + 'start_time': data['startTime'], + 'end_time': data['endTime'], + 'execution_status': data.get('executionStatus', 'completed'), + 'next_actions': [], + 'sample_sync': sample_sync_success + } + + # 发送 ROS2 ResourceUpdate 到 host node + if sample_sync_success and data['sampleId']: + try: + self._send_resource_update_to_host(data['sampleId'], step_changes) + result['ros_update_sent'] = True + result['next_actions'].append('ros_step_update_completed') + except Exception as e: + logger.warning(f"发送ROS2步骤完成更新失败: {e}") + result['ros_update_sent'] = False + result['warnings'] = [f"ROS2更新失败: {str(e)}"] + + # 处理步骤完成逻辑 + try: + # 更新步骤状态 + result['next_actions'].append('update_step_status') + + # 检查是否触发后续步骤 + result['next_actions'].append('check_next_step') + + # 更新通量进度 + result['next_actions'].append('update_sample_progress') + + # 记录步骤完成事件 + self._record_step_completion(data) + + except Exception as e: + logger.warning(f"步骤完成处理过程中出现警告: {e}") + result['warnings'] = result.get('warnings', []) + [str(e)] + + return result + + except Exception as e: + logger.error(f"处理步骤完成报送失败: {e}") + return { + 'processed': False, + 'error': str(e), + 'next_actions': ['retry_processing'] + } + + def process_sample_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: + """处理通量完成报送(统一LIMS协议规范)""" + try: + data = request.data + logger.info(f"处理通量完成报送: {data['orderCode']} - 通量ID: {data['sampleId']} (状态: {data['Status']})") + + # 增加接收计数 + self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 + + result = { + 'processed': True, + 'order_code': data['orderCode'], + 'sample_id': data['sampleId'], + 'status': data['Status'], + 'start_time': data['startTime'], + 'end_time': data['endTime'], + 'next_actions': [] + } + + # 根据通量状态处理 + status = int(data['Status']) + if status == 20: # 完成 + result['next_actions'].extend(['update_sample_completed', 'check_order_completion']) + self._record_sample_completion(data, 'completed') + elif status == -2: # 异常停止 + result['next_actions'].extend(['log_sample_error', 'trigger_error_handling']) + self._record_sample_completion(data, 'error') + elif status == -3: # 人工停止或取消 + result['next_actions'].extend(['log_sample_cancelled', 'update_order_status']) + self._record_sample_completion(data, 'cancelled') + elif status == 10: # 开始 + result['next_actions'].append('update_sample_started') + self._record_sample_start(data) + elif status == 2: # 进样 + result['next_actions'].append('update_sample_intake') + self._record_sample_intake(data) + + return result + + except Exception as e: + logger.error(f"处理通量完成报送失败: {e}") + return { + 'processed': False, + 'error': str(e), + 'next_actions': ['retry_processing'] + } + + def process_order_finish_report(self, request: WorkstationReportRequest, used_materials: List[MaterialUsage]) -> Dict[str, Any]: + """处理任务完成报送(统一LIMS协议规范)""" + try: + data = request.data + logger.info(f"处理任务完成报送: {data['orderCode']} - {data['orderName']} (状态: {data['status']})") + + # 增加接收计数 + self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 + + result = { + 'processed': True, + 'order_code': data['orderCode'], + 'order_name': data['orderName'], + 'status': data['status'], + 'start_time': data['startTime'], + 'end_time': data['endTime'], + 'used_materials_count': len(used_materials), + 'next_actions': [] + } + + # 根据任务状态处理 + status = int(data['status']) + if status == 30: # 完成 + result['next_actions'].extend([ + 'update_order_completed', + 'process_material_usage', + 'generate_completion_report' + ]) + self._record_order_completion(data, used_materials, 'completed') + elif status == -11: # 异常停止 + result['next_actions'].extend([ + 'log_order_error', + 'trigger_error_handling', + 'process_partial_material_usage' + ]) + self._record_order_completion(data, used_materials, 'error') + elif status == -12: # 人工停止或取消 + result['next_actions'].extend([ + 'log_order_cancelled', + 'revert_material_reservations' + ]) + self._record_order_completion(data, used_materials, 'cancelled') + + # 处理物料使用记录 + if used_materials: + material_usage_result = self._process_material_usage(used_materials) + result['material_usage'] = material_usage_result + + return result + + except Exception as e: + logger.error(f"处理任务完成报送失败: {e}") + return { + 'processed': False, + 'error': str(e), + 'next_actions': ['retry_processing'] + } + + # ============ 具体的报送处理方法 ============ + + def _handle_material_created(self, report): + """处理物料创建报送""" + try: + # 已废弃的方法,保留用于兼容性 + logger.debug(f"处理物料创建: {getattr(report, 'resource_id', 'unknown')}") + except Exception as e: + logger.error(f"处理物料创建失败: {e}") + + def _handle_material_updated(self, report): + """处理物料更新报送""" + try: + logger.debug(f"处理物料更新: {getattr(report, 'resource_id', 'unknown')}") + except Exception as e: + logger.error(f"处理物料更新失败: {e}") + + def _handle_material_moved(self, report): + """处理物料移动报送""" + try: + logger.debug(f"处理物料移动: {getattr(report, 'resource_id', 'unknown')}") + except Exception as e: + logger.error(f"处理物料移动失败: {e}") + + def _handle_material_consumed(self, report): + """处理物料消耗报送""" + try: + logger.debug(f"处理物料消耗: {getattr(report, 'resource_id', 'unknown')}") + except Exception as e: + logger.error(f"处理物料消耗失败: {e}") + + def _handle_material_completed(self, report): + """处理物料完成报送""" + try: + logger.debug(f"处理物料完成: {getattr(report, 'resource_id', 'unknown')}") + except Exception as e: + logger.error(f"处理物料完成失败: {e}") + + # ============ 工作流控制接口 ============ + def handle_external_error(self, error_request): + """处理外部错误请求""" + try: + logger.error(f"收到外部错误处理请求: {getattr(error_request, 'error_type', 'unknown')}") + return { + 'success': True, + 'message': "错误已记录", + 'error_code': 'OK' + } + except Exception as e: + logger.error(f"处理外部错误失败: {e}") + return { + 'success': False, + 'message': f"错误处理失败: {str(e)}", + 'error_code': 'ERROR_HANDLING_FAILED' + } + + def _process_error_handling(self, error_request, error_record): + """处理具体的错误类型""" + return {'success': True, 'actions_taken': ['已转换为统一报送']} + """处理具体的错误类型""" + try: + result = {'success': True, 'actions_taken': []} + + # 1. 如果有特定动作ID,标记该动作失败 + if error_request.action_id: + self._mark_action_failed(error_request.action_id, error_request.error_message) + result['actions_taken'].append(f"标记动作 {error_request.action_id} 为失败") + + # 2. 如果有工作流ID,停止相关工作流 + if error_request.workflow_id: + self._handle_workflow_error(error_request.workflow_id, error_request.error_message) + result['actions_taken'].append(f"处理工作流 {error_request.workflow_id} 错误") + + # 3. 根据错误类型执行特定处理 + error_type = error_request.error_type.lower() + + if error_type in ['material_error', 'resource_error']: + # 物料相关错误 + material_result = self._handle_material_error(error_request) + result['actions_taken'].extend(material_result.get('actions', [])) + + elif error_type in ['device_error', 'communication_error']: + # 设备通信错误 + device_result = self._handle_device_error(error_request) + result['actions_taken'].extend(device_result.get('actions', [])) + + elif error_type in ['workflow_error', 'process_error']: + # 工作流程错误 + workflow_result = self._handle_process_error(error_request) + result['actions_taken'].extend(workflow_result.get('actions', [])) + + else: + # 通用错误处理 + result['actions_taken'].append("执行通用错误处理") + + # 4. 如果是严重错误,触发紧急停止 + if error_request.error_type.lower() in ['critical_error', 'safety_error', 'emergency']: + self._trigger_emergency_stop(error_request.error_message) + result['actions_taken'].append("触发紧急停止") + + result['message'] = "错误处理完成" + return result + + except Exception as e: + logger.error(f"错误处理过程失败: {e}") + return { + 'success': False, + 'message': f"错误处理过程失败: {str(e)}", + 'error_code': 'ERROR_PROCESSING_FAILED' + } + + def _mark_action_failed(self, action_id: str, error_message: str): + """标记指定动作为失败""" + try: + # 创建失败结果 + failed_result = { + 'success': False, + 'error': True, + 'error_message': error_message, + 'timestamp': time.time(), + 'marked_by_external_error': True + } + + # 存储到动作结果缓存 + self.action_results[action_id] = failed_result + + # 如果当前有正在执行的动作,更新其状态 + if self.current_action_context and self.current_action_context.get('action_id') == action_id: + self.current_action_context['failed'] = True + self.current_action_context['error_message'] = error_message + + logger.info(f"动作 {action_id} 已标记为失败: {error_message}") + + except Exception as e: + logger.error(f"标记动作失败时出错: {e}") + + def _handle_workflow_error(self, workflow_id: str, error_message: str): + """处理工作流错误""" + try: + # 如果是当前正在运行的工作流 + if (self.current_workflow_info and + self.current_workflow_info.get('id') == workflow_id): + + # 停止当前工作流 + self.stop_workflow(emergency=True) + logger.info(f"因外部错误停止工作流 {workflow_id}: {error_message}") + + except Exception as e: + logger.error(f"处理工作流错误失败: {e}") + + def _handle_material_error(self, error_request): + """处理物料相关错误(已废弃,请使用统一报送接口)""" + return {'success': True, 'message': '物料错误已记录'} + """处理物料相关错误""" + actions = [] + try: + # 可以触发物料重新扫描、位置重置等 + if error_request.context and 'resource_id' in error_request.context: + resource_id = error_request.context['resource_id'] + # 触发物料状态更新 + actions.append(f"更新物料 {resource_id} 状态") + + actions.append("执行物料错误恢复流程") + + except Exception as e: + logger.error(f"处理物料错误失败: {e}") + + return {'actions': actions} + + def _handle_device_error(self, error_request): + """处理设备相关错误(已废弃,请使用统一报送接口)""" + return {'success': True, 'message': '设备错误已记录'} + """处理设备错误""" + actions = [] + try: + if error_request.device_id: + # 重置设备连接 + actions.append(f"重置设备 {error_request.device_id} 连接") + + # 如果是通信设备,重新建立连接 + if error_request.device_id in self.communication_devices: + actions.append(f"重新建立通信设备 {error_request.device_id} 连接") + + actions.append("执行设备错误恢复流程") + + except Exception as e: + logger.error(f"处理设备错误失败: {e}") + + return {'actions': actions} + + def _handle_process_error(self, error_request): + """处理流程相关错误(已废弃,请使用统一报送接口)""" + return {'success': True, 'message': '流程错误已记录'} + """处理工作流程错误""" + actions = [] + try: + # 暂停当前工作流 + if self.current_workflow_status not in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: + actions.append("暂停当前工作流") + + actions.append("执行工作流程错误恢复") + + except Exception as e: + logger.error(f"处理工作流程错误失败: {e}") + + return {'actions': actions} + + def _trigger_emergency_stop(self, reason: str): + """触发紧急停止""" + try: + logger.critical(f"触发紧急停止: {reason}") + + # 停止所有工作流 + self.stop_workflow(emergency=True) + + # 设置错误状态 + self.current_workflow_status = WorkflowStatus.ERROR + + # 可以在这里添加更多紧急停止逻辑 + # 例如:断开设备连接、保存当前状态等 + + except Exception as e: + logger.error(f"执行紧急停止失败: {e}") + # ============ 工作流控制接口 ============ def _handle_start_workflow(self, request, response): @@ -1298,5 +1836,199 @@ class WorkstationBase(ROS2ProtocolNode, ABC): "active_workflows": self.active_dynamic_workflows, "total_resources": self.workstation_resource_count, "communication_status": self.communication_status, - "material_status": self.material_status + "material_status": self.material_status, + "http_service_running": self.http_service.is_running if self.http_service else False } + + # ============ 增强动作执行 - 支持错误处理和追踪 ============ + + async def execute_single_action(self, device_id, action_name, action_kwargs): + """执行单个动作 - 增强版,支持错误处理和动作追踪""" + # 构建动作ID + if device_id in ["", None, "self"]: + action_id = f"/devices/{self.device_id}/{action_name}" + else: + action_id = f"/devices/{device_id}/{action_name}" + + # 设置动作上下文 + self.current_action_context = { + 'action_id': action_id, + 'device_id': device_id, + 'action_name': action_name, + 'action_kwargs': action_kwargs, + 'start_time': time.time(), + 'failed': False, + 'error_message': None + } + + try: + # 检查是否已被外部标记为失败 + if action_id in self.action_results: + cached_result = self.action_results[action_id] + if cached_result.get('marked_by_external_error'): + logger.warning(f"动作 {action_id} 已被外部标记为失败") + return self._create_failed_result(cached_result['error_message']) + + # 检查动作客户端是否存在 + if action_id not in self._action_clients: + error_msg = f"找不到动作客户端: {action_id}" + self.lab_logger().error(error_msg) + return self._create_failed_result(error_msg) + + # 发送动作请求 + action_client = self._action_clients[action_id] + goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) + + self.lab_logger().debug(f"发送动作请求到: {action_id}") + action_client.wait_for_server() + + # 等待动作完成 + request_future = action_client.send_goal_async(goal_msg) + handle = await request_future + + if not handle.accepted: + error_msg = f"动作请求被拒绝: {action_name}" + self.lab_logger().error(error_msg) + return self._create_failed_result(error_msg) + + # 在执行过程中检查是否被外部标记为失败 + result_future = await handle.get_result_async() + + # 再次检查是否在执行过程中被标记为失败 + if self.current_action_context.get('failed'): + error_msg = self.current_action_context.get('error_message', '动作被外部标记为失败') + logger.warning(f"动作 {action_id} 在执行过程中被标记为失败: {error_msg}") + return self._create_failed_result(error_msg) + + result = result_future.result + + # 存储成功结果 + self.action_results[action_id] = { + 'success': True, + 'result': result, + 'timestamp': time.time(), + 'execution_time': time.time() - self.current_action_context['start_time'] + } + + self.lab_logger().debug(f"动作完成: {action_name}") + return result + + except Exception as e: + error_msg = f"动作执行异常: {str(e)}" + logger.error(f"执行动作 {action_id} 失败: {e}\n{traceback.format_exc()}") + return self._create_failed_result(error_msg) + + finally: + # 清理动作上下文 + self.current_action_context = None + + def _create_failed_result(self, error_message: str): + """创建失败结果对象""" + # 这需要根据具体的动作类型来创建相应的结果对象 + # 这里返回一个通用的失败标识 + class FailedResult: + def __init__(self, error_msg): + self.success = False + self.return_info = json.dumps({ + "suc": False, + "error": True, + "error_message": error_msg, + "timestamp": time.time() + }) + + return FailedResult(error_message) + + def __del__(self): + """析构函数 - 清理HTTP服务""" + try: + self._stop_http_service() + self._stop_reporting_service() + except: + pass + + # ============ LIMS辅助方法 ============ + + def _record_step_completion(self, step_data: Dict[str, Any]): + """记录步骤完成事件""" + try: + logger.debug(f"记录步骤完成: {step_data['stepName']} - {step_data['stepId']}") + # 这里可以添加步骤完成的记录逻辑 + # 例如:更新数据库、发送通知等 + except Exception as e: + logger.error(f"记录步骤完成失败: {e}") + + def _record_sample_completion(self, sample_data: Dict[str, Any], completion_type: str): + """记录通量完成事件""" + try: + logger.debug(f"记录通量完成: {sample_data['sampleId']} - {completion_type}") + # 这里可以添加通量完成的记录逻辑 + except Exception as e: + logger.error(f"记录通量完成失败: {e}") + + def _record_sample_start(self, sample_data: Dict[str, Any]): + """记录通量开始事件""" + try: + logger.debug(f"记录通量开始: {sample_data['sampleId']}") + # 这里可以添加通量开始的记录逻辑 + except Exception as e: + logger.error(f"记录通量开始失败: {e}") + + def _record_sample_intake(self, sample_data: Dict[str, Any]): + """记录通量进样事件""" + try: + logger.debug(f"记录通量进样: {sample_data['sampleId']}") + # 这里可以添加通量进样的记录逻辑 + except Exception as e: + logger.error(f"记录通量进样失败: {e}") + + def _record_order_completion(self, order_data: Dict[str, Any], used_materials: List, completion_type: str): + """记录任务完成事件""" + try: + logger.debug(f"记录任务完成: {order_data['orderCode']} - {completion_type}") + # 这里可以添加任务完成的记录逻辑 + # 包括物料使用记录的处理 + except Exception as e: + logger.error(f"记录任务完成失败: {e}") + + def _process_material_usage(self, used_materials: List) -> Dict[str, Any]: + """处理物料使用记录""" + try: + logger.debug(f"处理物料使用记录: {len(used_materials)} 条") + + processed_materials = [] + for material in used_materials: + material_record = { + 'material_id': material.materialId, + 'location_id': material.locationId, + 'type_mode': material.typeMode, + 'used_quantity': material.usedQuantity, + 'processed_time': time.time() + } + processed_materials.append(material_record) + + # 更新库存 + self._update_material_inventory(material) + + return { + 'processed_count': len(processed_materials), + 'materials': processed_materials, + 'success': True + } + + except Exception as e: + logger.error(f"处理物料使用记录失败: {e}") + return { + 'processed_count': 0, + 'materials': [], + 'success': False, + 'error': str(e) + } + + def _update_material_inventory(self, material): + """更新物料库存""" + try: + # 这里可以添加库存更新逻辑 + # 例如:调用库存管理系统API、更新本地缓存等 + logger.debug(f"更新物料库存: {material.materialId} - 使用量: {material.usedQuantity}") + except Exception as e: + logger.error(f"更新物料库存失败: {e}") diff --git a/unilabos/device_comms/workstation_http_service.py b/unilabos/device_comms/workstation_http_service.py new file mode 100644 index 00000000..3805d2ce --- /dev/null +++ b/unilabos/device_comms/workstation_http_service.py @@ -0,0 +1,605 @@ +""" +工作站HTTP服务模块 +Workstation HTTP Service Module + +统一的工作站报送接收服务,基于LIMS协议规范: +1. 步骤完成报送 - POST /report/step_finish +2. 通量完成报送 - POST /report/sample_finish +3. 任务完成报送 - POST /report/order_finish +4. 批量更新报送 - POST /report/batch_update +5. 物料变更报送 - POST /report/material_change +6. 错误处理报送 - POST /report/error_handling +7. 健康检查和状态查询 + +统一使用LIMS协议字段规范,简化接口避免功能重复 +""" +import json +import threading +import time +import traceback +from typing import Dict, Any, Optional, List +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse +from dataclasses import dataclass, asdict +from datetime import datetime + +from unilabos.utils.log import logger + + +@dataclass +class WorkstationReportRequest: + """统一工作站报送请求(基于LIMS协议规范)""" + token: str # 授权令牌 + request_time: str # 请求时间,格式:2024-12-12 12:12:12.xxx + data: Dict[str, Any] # 报送数据 + + +@dataclass +class MaterialUsage: + """物料使用记录""" + materialId: str # 物料Id(GUID) + locationId: str # 库位Id(GUID) + typeMode: str # 物料类型(样品1、试剂2、耗材0) + usedQuantity: float # 使用的数量(数字) + + +@dataclass +class HttpResponse: + """HTTP响应""" + success: bool + message: str + data: Optional[Dict[str, Any]] = None + acknowledgment_id: Optional[str] = None + + +class WorkstationHTTPHandler(BaseHTTPRequestHandler): + """工作站HTTP请求处理器""" + + def __init__(self, workstation_instance, *args, **kwargs): + self.workstation = workstation_instance + super().__init__(*args, **kwargs) + + def do_POST(self): + """处理POST请求 - 统一的工作站报送接口""" + try: + # 解析请求路径 + parsed_path = urlparse(self.path) + endpoint = parsed_path.path + + # 读取请求体 + content_length = int(self.headers.get('Content-Length', 0)) + if content_length > 0: + post_data = self.rfile.read(content_length) + request_data = json.loads(post_data.decode('utf-8')) + else: + request_data = {} + + logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}") + + # 统一的报送端点路由(基于LIMS协议规范) + if endpoint == '/report/step_finish': + response = self._handle_step_finish_report(request_data) + elif endpoint == '/report/sample_finish': + response = self._handle_sample_finish_report(request_data) + elif endpoint == '/report/order_finish': + response = self._handle_order_finish_report(request_data) + elif endpoint == '/report/batch_update': + response = self._handle_batch_update_report(request_data) + # 扩展报送端点 + elif endpoint == '/report/material_change': + response = self._handle_material_change_report(request_data) + elif endpoint == '/report/error_handling': + response = self._handle_error_handling_report(request_data) + # 保留LIMS协议端点以兼容现有系统 + elif endpoint == '/LIMS/step_finish': + response = self._handle_step_finish_report(request_data) + elif endpoint == '/LIMS/preintake_finish': + response = self._handle_sample_finish_report(request_data) + elif endpoint == '/LIMS/order_finish': + response = self._handle_order_finish_report(request_data) + else: + response = HttpResponse( + success=False, + message=f"不支持的报送端点: {endpoint}", + data={"supported_endpoints": [ + "/report/step_finish", + "/report/sample_finish", + "/report/order_finish", + "/report/batch_update", + "/report/material_change", + "/report/error_handling" + ]} + ) + + # 发送响应 + self._send_response(response) + + except Exception as e: + logger.error(f"处理工作站报送失败: {e}\\n{traceback.format_exc()}") + error_response = HttpResponse( + success=False, + message=f"请求处理失败: {str(e)}" + ) + self._send_response(error_response) + + def do_GET(self): + """处理GET请求 - 健康检查和状态查询""" + try: + parsed_path = urlparse(self.path) + endpoint = parsed_path.path + + if endpoint == '/status': + response = self._handle_status_check() + elif endpoint == '/health': + response = HttpResponse(success=True, message="服务健康") + else: + response = HttpResponse( + success=False, + message=f"不支持的查询端点: {endpoint}", + data={"supported_endpoints": ["/status", "/health"]} + ) + + self._send_response(response) + + except Exception as e: + logger.error(f"GET请求处理失败: {e}") + error_response = HttpResponse( + success=False, + message=f"GET请求处理失败: {str(e)}" + ) + self._send_response(error_response) + + def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理步骤完成报送(统一LIMS协议规范)""" + try: + # 验证基本字段 + required_fields = ['token', 'request_time', 'data'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 验证data字段内容 + data = request_data['data'] + data_required_fields = ['orderCode', 'orderName', 'stepName', 'stepId', 'sampleId', 'startTime', 'endTime'] + if data_missing_fields := [field for field in data_required_fields if field not in data]: + return HttpResponse( + success=False, + message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" + ) + + # 创建统一请求对象 + report_request = WorkstationReportRequest( + token=request_data['token'], + request_time=request_data['request_time'], + data=data + ) + + # 调用工作站处理方法 + result = self.workstation.process_step_finish_report(report_request) + + return HttpResponse( + success=True, + message=f"步骤完成报送已处理: {data['stepName']} ({data['orderCode']})", + acknowledgment_id=f"STEP_{int(time.time() * 1000)}_{data['stepId']}", + data=result + ) + + except Exception as e: + logger.error(f"处理步骤完成报送失败: {e}") + return HttpResponse( + success=False, + message=f"步骤完成报送处理失败: {str(e)}" + ) + + def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理通量完成报送(统一LIMS协议规范)""" + try: + # 验证基本字段 + required_fields = ['token', 'request_time', 'data'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 验证data字段内容 + data = request_data['data'] + data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'Status'] + if data_missing_fields := [field for field in data_required_fields if field not in data]: + return HttpResponse( + success=False, + message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" + ) + + # 创建统一请求对象 + report_request = WorkstationReportRequest( + token=request_data['token'], + request_time=request_data['request_time'], + data=data + ) + + # 调用工作站处理方法 + result = self.workstation.process_sample_finish_report(report_request) + + status_names = { + "0": "待生产", "2": "进样", "10": "开始", + "20": "完成", "-2": "异常停止", "-3": "人工停止" + } + status_desc = status_names.get(str(data['Status']), f"状态{data['Status']}") + + return HttpResponse( + success=True, + message=f"通量完成报送已处理: {data['sampleId']} ({data['orderCode']}) - {status_desc}", + acknowledgment_id=f"SAMPLE_{int(time.time() * 1000)}_{data['sampleId']}", + data=result + ) + + except Exception as e: + logger.error(f"处理通量完成报送失败: {e}") + return HttpResponse( + success=False, + message=f"通量完成报送处理失败: {str(e)}" + ) + + def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理任务完成报送(统一LIMS协议规范)""" + try: + # 验证基本字段 + required_fields = ['token', 'request_time', 'data'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 验证data字段内容 + data = request_data['data'] + data_required_fields = ['orderCode', 'orderName', 'startTime', 'endTime', 'status'] + if data_missing_fields := [field for field in data_required_fields if field not in data]: + return HttpResponse( + success=False, + message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" + ) + + # 处理物料使用记录 + used_materials = [] + if 'usedMaterials' in data: + for material_data in data['usedMaterials']: + material = MaterialUsage( + materialId=material_data.get('materialId', ''), + locationId=material_data.get('locationId', ''), + typeMode=material_data.get('typeMode', ''), + usedQuantity=material_data.get('usedQuantity', 0.0) + ) + used_materials.append(material) + + # 创建统一请求对象 + report_request = WorkstationReportRequest( + token=request_data['token'], + request_time=request_data['request_time'], + data=data + ) + + # 调用工作站处理方法 + result = self.workstation.process_order_finish_report(report_request, used_materials) + + status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"} + status_desc = status_names.get(str(data['status']), f"状态{data['status']}") + + return HttpResponse( + success=True, + message=f"任务完成报送已处理: {data['orderName']} ({data['orderCode']}) - {status_desc}", + acknowledgment_id=f"ORDER_{int(time.time() * 1000)}_{data['orderCode']}", + data=result + ) + + except Exception as e: + logger.error(f"处理任务完成报送失败: {e}") + return HttpResponse( + success=False, + message=f"任务完成报送处理失败: {str(e)}" + ) + + def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理批量报送""" + try: + step_updates = request_data.get('step_updates', []) + sample_updates = request_data.get('sample_updates', []) + order_updates = request_data.get('order_updates', []) + + results = { + 'step_results': [], + 'sample_results': [], + 'order_results': [], + 'total_processed': 0, + 'total_failed': 0 + } + + # 处理批量步骤更新 + for step_data in step_updates: + try: + step_data['token'] = request_data.get('token', step_data.get('token')) + step_data['request_time'] = request_data.get('request_time', step_data.get('request_time')) + result = self._handle_step_finish_report(step_data) + results['step_results'].append(result) + if result.success: + results['total_processed'] += 1 + else: + results['total_failed'] += 1 + except Exception as e: + results['step_results'].append(HttpResponse(success=False, message=str(e))) + results['total_failed'] += 1 + + # 处理批量通量更新 + for sample_data in sample_updates: + try: + sample_data['token'] = request_data.get('token', sample_data.get('token')) + sample_data['request_time'] = request_data.get('request_time', sample_data.get('request_time')) + result = self._handle_sample_finish_report(sample_data) + results['sample_results'].append(result) + if result.success: + results['total_processed'] += 1 + else: + results['total_failed'] += 1 + except Exception as e: + results['sample_results'].append(HttpResponse(success=False, message=str(e))) + results['total_failed'] += 1 + + # 处理批量任务更新 + for order_data in order_updates: + try: + order_data['token'] = request_data.get('token', order_data.get('token')) + order_data['request_time'] = request_data.get('request_time', order_data.get('request_time')) + result = self._handle_order_finish_report(order_data) + results['order_results'].append(result) + if result.success: + results['total_processed'] += 1 + else: + results['total_failed'] += 1 + except Exception as e: + results['order_results'].append(HttpResponse(success=False, message=str(e))) + results['total_failed'] += 1 + + return HttpResponse( + success=results['total_failed'] == 0, + message=f"批量报送处理完成: {results['total_processed']} 成功, {results['total_failed']} 失败", + acknowledgment_id=f"BATCH_{int(time.time() * 1000)}", + data=results + ) + + except Exception as e: + logger.error(f"处理批量报送失败: {e}") + return HttpResponse( + success=False, + message=f"批量报送处理失败: {str(e)}" + ) + + def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理物料变更报送""" + try: + # 验证必需字段 + required_fields = ['workstation_id', 'timestamp', 'resource_id', 'change_type'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 调用工作站的处理方法 + result = self.workstation.process_material_change_report(request_data) + + return HttpResponse( + success=True, + message=f"物料变更报送已处理: {request_data['resource_id']} ({request_data['change_type']})", + acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{request_data['resource_id']}", + data=result + ) + + except Exception as e: + logger.error(f"处理物料变更报送失败: {e}") + return HttpResponse( + success=False, + message=f"物料变更报送处理失败: {str(e)}" + ) + + def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse: + """处理错误处理报送""" + try: + # 验证必需字段 + required_fields = ['workstation_id', 'timestamp', 'error_type', 'error_message'] + if missing_fields := [field for field in required_fields if field not in request_data]: + return HttpResponse( + success=False, + message=f"缺少必要字段: {', '.join(missing_fields)}" + ) + + # 调用工作站的处理方法 + result = self.workstation.handle_external_error(request_data) + + return HttpResponse( + success=True, + message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}", + acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}", + data=result + ) + + except Exception as e: + logger.error(f"处理错误处理报送失败: {e}") + return HttpResponse( + success=False, + message=f"错误处理报送处理失败: {str(e)}" + ) + + def _handle_status_check(self) -> HttpResponse: + """处理状态查询""" + try: + return HttpResponse( + success=True, + message="工作站报送服务正常运行", + data={ + "workstation_id": self.workstation.device_id, + "service_type": "unified_reporting_service", + "uptime": time.time() - getattr(self.workstation, '_start_time', time.time()), + "reports_received": getattr(self.workstation, '_reports_received_count', 0), + "supported_endpoints": [ + "POST /report/step_finish", + "POST /report/sample_finish", + "POST /report/order_finish", + "POST /report/batch_update", + "POST /report/material_change", + "POST /report/error_handling", + "GET /status", + "GET /health" + ] + } + ) + except Exception as e: + logger.error(f"处理状态查询失败: {e}") + return HttpResponse( + success=False, + message=f"状态查询失败: {str(e)}" + ) + + def _send_response(self, response: HttpResponse): + """发送响应""" + try: + # 设置响应状态码 + status_code = 200 if response.success else 400 + self.send_response(status_code) + + # 设置响应头 + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type') + self.end_headers() + + # 发送响应体 + response_json = json.dumps(asdict(response), ensure_ascii=False, indent=2) + self.wfile.write(response_json.encode('utf-8')) + + except Exception as e: + logger.error(f"发送响应失败: {e}") + + def log_message(self, format, *args): + """重写日志方法""" + logger.debug(f"HTTP请求: {format % args}") + + +class WorkstationHTTPService: + """工作站HTTP服务""" + + def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 8080): + self.workstation = workstation_instance + self.host = host + self.port = port + self.server = None + self.server_thread = None + self.running = False + + # 初始化统计信息 + self.workstation._start_time = time.time() + self.workstation._reports_received_count = 0 + + def start(self): + """启动HTTP服务""" + try: + # 创建处理器工厂函数 + def handler_factory(*args, **kwargs): + return WorkstationHTTPHandler(self.workstation, *args, **kwargs) + + # 创建HTTP服务器 + self.server = HTTPServer((self.host, self.port), handler_factory) + + # 在单独线程中运行服务器 + self.server_thread = threading.Thread( + target=self._run_server, + daemon=True, + name=f"WorkstationHTTP-{self.workstation.device_id}" + ) + + self.running = True + self.server_thread.start() + + logger.info(f"工作站HTTP报送服务已启动: http://{self.host}:{self.port}") + logger.info("统一的报送端点 (基于LIMS协议规范):") + logger.info(" - POST /report/step_finish # 步骤完成报送") + logger.info(" - POST /report/sample_finish # 通量完成报送") + logger.info(" - POST /report/order_finish # 任务完成报送") + logger.info(" - POST /report/batch_update # 批量更新报送") + logger.info("扩展报送端点:") + logger.info(" - POST /report/material_change # 物料变更报送") + logger.info(" - POST /report/error_handling # 错误处理报送") + logger.info("兼容端点:") + logger.info(" - POST /LIMS/step_finish # 兼容LIMS步骤完成") + logger.info(" - POST /LIMS/preintake_finish # 兼容LIMS通量完成") + logger.info(" - POST /LIMS/order_finish # 兼容LIMS任务完成") + logger.info("服务端点:") + logger.info(" - GET /status # 服务状态查询") + logger.info(" - GET /health # 健康检查") + + except Exception as e: + logger.error(f"启动HTTP服务失败: {e}") + raise + + def stop(self): + """停止HTTP服务""" + try: + if self.running and self.server: + self.running = False + self.server.shutdown() + self.server.server_close() + + if self.server_thread and self.server_thread.is_alive(): + self.server_thread.join(timeout=5.0) + + logger.info("工作站HTTP报送服务已停止") + + except Exception as e: + logger.error(f"停止HTTP服务失败: {e}") + + def _run_server(self): + """运行HTTP服务器""" + try: + while self.running: + self.server.handle_request() + except Exception as e: + if self.running: # 只在非正常停止时记录错误 + logger.error(f"HTTP服务运行错误: {e}") + + @property + def is_running(self) -> bool: + """检查服务是否正在运行""" + return self.running and self.server_thread and self.server_thread.is_alive() + + @property + def service_url(self) -> str: + """获取服务URL""" + return f"http://{self.host}:{self.port}" + + +# 导出主要类 - 保持向后兼容 +@dataclass +class MaterialChangeReport: + """已废弃:物料变更报送,请使用统一的WorkstationReportRequest""" + pass + + +@dataclass +class TaskExecutionReport: + """已废弃:任务执行报送,请使用统一的WorkstationReportRequest""" + pass + + +# 导出列表 +__all__ = [ + 'WorkstationReportRequest', + 'MaterialUsage', + 'HttpResponse', + 'WorkstationHTTPService', + # 向后兼容 + 'MaterialChangeReport', + 'TaskExecutionReport' +] diff --git a/unilabos/ros/nodes/presets/protocol_node.py b/unilabos/ros/nodes/presets/protocol_node.py index 23462142..c0ed6849 100644 --- a/unilabos/ros/nodes/presets/protocol_node.py +++ b/unilabos/ros/nodes/presets/protocol_node.py @@ -43,6 +43,7 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): children: dict, protocol_type: Union[str, list[str]], resource_tracker: DeviceNodeResourceTracker, + workstation_config: dict = None, # 新增:工作站配置 *args, **kwargs, ): @@ -50,6 +51,8 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): # 初始化其它属性 self.children = children + self.workstation_config = workstation_config or {} # 新增:保存工作站配置 + self.communication_interfaces = self.workstation_config.get('communication_interfaces', {}) # 从工作站配置获取通信接口 self._busy = False self.sub_devices = {} self._goals = {} @@ -69,57 +72,135 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): # 初始化子设备 self.communication_node_id_to_instance = {} + self._initialize_child_devices() + + # 设置硬件接口代理 + self._setup_hardware_proxies() + self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}") + + def _initialize_child_devices(self): + """初始化子设备 - 重构为更清晰的方法""" + # 设备分类字典 - 统一管理 + self.communication_devices = {} + self.logical_devices = {} + for device_id, device_config in self.children.items(): if device_config.get("type", "device") != "device": self.lab_logger().debug( f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." ) continue + try: d = self.initialize_device(device_id, device_config) + if d is None: + continue + + # 统一的设备分类逻辑 + device_type = device_config.get("device_type", "logical") + + # 兼容旧的ID匹配方式和新的配置方式 + if device_type == "communication" or "serial_" in device_id or "io_" in device_id: + self.communication_node_id_to_instance[device_id] = d # 保持向后兼容 + self.communication_devices[device_id] = d # 新的统一方式 + self.lab_logger().info(f"通信设备 {device_id} 初始化并分类成功") + elif device_type == "logical": + self.logical_devices[device_id] = d + self.lab_logger().info(f"逻辑设备 {device_id} 初始化并分类成功") + else: + # 默认作为逻辑设备处理 + self.logical_devices[device_id] = d + self.lab_logger().info(f"设备 {device_id} 作为逻辑设备处理") + except Exception as ex: self.lab_logger().error(f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}") - d = None - if d is None: - continue - - if "serial_" in device_id or "io_" in device_id: - self.communication_node_id_to_instance[device_id] = d - continue + def _setup_hardware_proxies(self): + """设置硬件接口代理 - 重构为独立方法,支持工作站配置""" + # 1. 传统的协议节点硬件代理设置 for device_id, device_config in self.children.items(): if device_config.get("type", "device") != "device": continue + # 设置硬件接口代理 if device_id not in self.sub_devices: self.lab_logger().error(f"[Protocol Node] {device_id} 还没有正确初始化,跳过...") continue + d = self.sub_devices[device_id] if d: - hardware_interface = d.ros_node_instance._hardware_interface - if ( - hasattr(d.driver_instance, hardware_interface["name"]) - and hasattr(d.driver_instance, hardware_interface["write"]) - and (hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"])) - ): + self._setup_device_hardware_proxy(device_id, d) + + # 2. 工作站配置的通信接口代理设置 + if hasattr(self, 'communication_interfaces') and self.communication_interfaces: + self._setup_workstation_communication_interfaces() - name = getattr(d.driver_instance, hardware_interface["name"]) - read = hardware_interface.get("read", None) - write = hardware_interface.get("write", None) + self.lab_logger().info(f"ROS2ProtocolNode {self.device_id} initialized with protocols: {self.protocol_names}") - # 如果硬件接口是字符串,通过通信设备提供 - if isinstance(name, str) and name in self.sub_devices: - communicate_device = self.sub_devices[name] - communicate_hardware_info = communicate_device.ros_node_instance._hardware_interface - self._setup_hardware_proxy(d, self.sub_devices[name], read, write) - self.lab_logger().info( - f"\n通信代理:为子设备{device_id}\n " - f"添加了{read}方法(来源:{name} {communicate_hardware_info['write']}) \n " - f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})" - ) + def _setup_workstation_communication_interfaces(self): + """设置工作站特定的通信接口代理""" + for logical_device_id, logical_device in self.logical_devices.items(): + # 检查是否有配置的通信接口 + interface_config = getattr(self, 'communication_interfaces', {}).get(logical_device_id) + if not interface_config: + continue + + comm_device = self.communication_devices.get(interface_config.device_id) + if not comm_device: + self.lab_logger().error(f"通信设备 {interface_config.device_id} 不存在") + continue + + # 设置工作站级别的通信代理 + self._setup_workstation_hardware_proxy( + logical_device, + comm_device, + interface_config + ) - self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}") + def _setup_workstation_hardware_proxy(self, logical_device, comm_device, interface_config): + """为逻辑设备设置工作站级通信代理""" + try: + # 获取通信设备的读写方法 + read_func = getattr(comm_device.driver_instance, interface_config.read_method, None) + write_func = getattr(comm_device.driver_instance, interface_config.write_method, None) + + if read_func: + setattr(logical_device.driver_instance, 'comm_read', read_func) + if write_func: + setattr(logical_device.driver_instance, 'comm_write', write_func) + + # 设置通信配置 + setattr(logical_device.driver_instance, 'comm_config', interface_config.config) + setattr(logical_device.driver_instance, 'comm_protocol', interface_config.protocol_type) + + self.lab_logger().info(f"为逻辑设备 {logical_device.device_id} 设置工作站通信代理 -> {comm_device.device_id}") + + except Exception as e: + self.lab_logger().error(f"设置工作站通信代理失败: {e}") + + def _setup_device_hardware_proxy(self, device_id: str, device): + """为单个设备设置硬件接口代理""" + hardware_interface = device.ros_node_instance._hardware_interface + if ( + hasattr(device.driver_instance, hardware_interface["name"]) + and hasattr(device.driver_instance, hardware_interface["write"]) + and (hardware_interface["read"] is None or hasattr(device.driver_instance, hardware_interface["read"])) + ): + name = getattr(device.driver_instance, hardware_interface["name"]) + read = hardware_interface.get("read", None) + write = hardware_interface.get("write", None) + + # 如果硬件接口是字符串,通过通信设备提供 + if isinstance(name, str) and name in self.sub_devices: + communicate_device = self.sub_devices[name] + communicate_hardware_info = communicate_device.ros_node_instance._hardware_interface + self._setup_hardware_proxy(device, self.sub_devices[name], read, write) + self.lab_logger().info( + f"\n通信代理:为子设备{device_id}\n " + f"添加了{read}方法(来源:{name} {communicate_hardware_info['write']}) \n " + f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})" + ) def _setup_protocol_names(self, protocol_type): # 处理协议类型 From 14bc2e6cdab2897f275293bc2a880c05a51dea40 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Thu, 21 Aug 2025 10:09:57 +0800 Subject: [PATCH 03/13] Create workstation_architecture.md --- .../workstation_architecture.md | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 docs/developer_guide/workstation_architecture.md diff --git a/docs/developer_guide/workstation_architecture.md b/docs/developer_guide/workstation_architecture.md new file mode 100644 index 00000000..3bdb52e2 --- /dev/null +++ b/docs/developer_guide/workstation_architecture.md @@ -0,0 +1,378 @@ +# 工作站基础架构设计文档 + +## 1. 整体架构图 + +```mermaid +graph TB + subgraph "工作站基础架构" + WB[WorkstationBase] + WB --> |继承| RPN[ROS2ProtocolNode] + WB --> |组合| WCB[WorkstationCommunicationBase] + WB --> |组合| MMB[MaterialManagementBase] + WB --> |组合| WHS[WorkstationHTTPService] + end + + subgraph "通信层实现" + WCB --> |实现| PLC[PLCCommunication] + WCB --> |实现| SER[SerialCommunication] + WCB --> |实现| ETH[EthernetCommunication] + end + + subgraph "物料管理实现" + MMB --> |实现| PLR[PyLabRobotMaterialManager] + MMB --> |实现| BIO[BioyondMaterialManager] + MMB --> |实现| SIM[SimpleMaterialManager] + end + + subgraph "HTTP服务" + WHS --> |处理| LIMS[LIMS协议报送] + WHS --> |处理| MAT[物料变更报送] + WHS --> |处理| ERR[错误处理报送] + end + + subgraph "具体工作站实现" + WB --> |继承| WS1[PLCWorkstation] + WB --> |继承| WS2[ReportingWorkstation] + WB --> |继承| WS3[HybridWorkstation] + end + + subgraph "外部系统" + EXT1[PLC设备] --> |通信| PLC + EXT2[外部工作站] --> |HTTP报送| WHS + EXT3[LIMS系统] --> |HTTP报送| WHS + EXT4[Bioyond物料系统] --> |查询| BIO + end +``` + +## 2. 类关系图 + +```mermaid +classDiagram + class WorkstationBase { + <> + +device_id: str + +communication: WorkstationCommunicationBase + +material_management: MaterialManagementBase + +http_service: WorkstationHTTPService + +workflow_status: WorkflowStatus + +supported_workflows: Dict + + +_create_communication_module()* + +_create_material_management_module()* + +_register_supported_workflows()* + + +process_step_finish_report() + +process_sample_finish_report() + +process_order_finish_report() + +process_material_change_report() + +handle_external_error() + + +start_workflow() + +stop_workflow() + +get_workflow_status() + +get_device_status() + } + + class ROS2ProtocolNode { + +sub_devices: Dict + +protocol_names: List + +execute_single_action() + +create_ros_action_server() + +initialize_device() + } + + class WorkstationCommunicationBase { + <> + +config: CommunicationConfig + +is_connected: bool + +connect() + +disconnect() + +start_workflow()* + +stop_workflow()* + +get_device_status()* + +write_register() + +read_register() + } + + class MaterialManagementBase { + <> + +device_id: str + +deck_config: Dict + +resource_tracker: DeviceNodeResourceTracker + +plr_deck: Deck + +find_materials_by_type() + +update_material_location() + +convert_to_unilab_format() + +_create_resource_by_type()* + } + + class WorkstationHTTPService { + +workstation_instance: WorkstationBase + +host: str + +port: int + +start() + +stop() + +_handle_step_finish_report() + +_handle_material_change_report() + } + + class PLCWorkstation { + +plc_config: Dict + +modbus_client: ModbusTCPClient + +_create_communication_module() + +_create_material_management_module() + +_register_supported_workflows() + } + + class ReportingWorkstation { + +report_handlers: Dict + +_create_communication_module() + +_create_material_management_module() + +_register_supported_workflows() + } + + WorkstationBase --|> ROS2ProtocolNode + WorkstationBase *-- WorkstationCommunicationBase + WorkstationBase *-- MaterialManagementBase + WorkstationBase *-- WorkstationHTTPService + + PLCWorkstation --|> WorkstationBase + ReportingWorkstation --|> WorkstationBase + + WorkstationCommunicationBase <|-- PLCCommunication + WorkstationCommunicationBase <|-- DummyCommunication + + MaterialManagementBase <|-- PyLabRobotMaterialManager + MaterialManagementBase <|-- SimpleMaterialManager +``` + +## 3. 工作站启动时序图 + +```mermaid +sequenceDiagram + participant APP as Application + participant WS as WorkstationBase + participant COMM as CommunicationModule + participant MAT as MaterialManager + participant HTTP as HTTPService + participant ROS as ROS2ProtocolNode + + APP->>WS: 创建工作站实例 + WS->>ROS: 初始化ROS2ProtocolNode + ROS->>ROS: 初始化子设备 + ROS->>ROS: 设置硬件接口代理 + + WS->>COMM: _create_communication_module() + COMM->>COMM: 初始化通信配置 + COMM->>COMM: 建立PLC/串口连接 + COMM-->>WS: 返回通信模块实例 + + WS->>MAT: _create_material_management_module() + MAT->>MAT: 创建PyLabRobot Deck + MAT->>MAT: 初始化物料资源 + MAT->>MAT: 注册到ResourceTracker + MAT-->>WS: 返回物料管理实例 + + WS->>WS: _register_supported_workflows() + WS->>WS: _create_workstation_services() + WS->>HTTP: _start_http_service() + HTTP->>HTTP: 创建HTTP服务器 + HTTP->>HTTP: 启动监听线程 + HTTP-->>WS: HTTP服务启动完成 + + WS-->>APP: 工作站初始化完成 +``` + +## 4. 工作流执行时序图 + +```mermaid +sequenceDiagram + participant EXT as ExternalSystem + participant WS as WorkstationBase + participant COMM as CommunicationModule + participant MAT as MaterialManager + participant ROS as ROS2ProtocolNode + participant DEV as SubDevice + + EXT->>WS: start_workflow(type, params) + WS->>WS: 验证工作流类型 + WS->>COMM: start_workflow(type, params) + COMM->>COMM: 发送启动命令到PLC + COMM-->>WS: 启动成功 + + WS->>WS: 更新workflow_status = RUNNING + + loop 工作流步骤执行 + WS->>ROS: execute_single_action(device_id, action, params) + ROS->>DEV: 发送ROS Action请求 + DEV->>DEV: 执行设备动作 + DEV-->>ROS: 返回执行结果 + ROS-->>WS: 返回动作结果 + + WS->>MAT: update_material_location(material_id, location) + MAT->>MAT: 更新PyLabRobot资源状态 + MAT-->>WS: 更新完成 + end + + WS->>COMM: get_workflow_status() + COMM->>COMM: 查询PLC状态寄存器 + COMM-->>WS: 返回状态信息 + + WS->>WS: 更新workflow_status = COMPLETED + WS-->>EXT: 工作流执行完成 +``` + +## 5. HTTP报送处理时序图 + +```mermaid +sequenceDiagram + participant EXT as ExternalWorkstation + participant HTTP as HTTPService + participant WS as WorkstationBase + participant MAT as MaterialManager + participant DB as DataStorage + + EXT->>HTTP: POST /report/step_finish + HTTP->>HTTP: 解析请求数据 + HTTP->>HTTP: 验证LIMS协议字段 + HTTP->>WS: process_step_finish_report(request) + + WS->>WS: 增加接收计数 + WS->>WS: 记录步骤完成事件 + WS->>MAT: 更新相关物料状态 + MAT->>MAT: 更新PyLabRobot资源 + MAT-->>WS: 更新完成 + + WS->>DB: 保存报送记录 + DB-->>WS: 保存完成 + + WS-->>HTTP: 返回处理结果 + HTTP->>HTTP: 构造HTTP响应 + HTTP-->>EXT: 200 OK + acknowledgment_id + + Note over EXT,DB: 类似处理sample_finish, order_finish, material_change等报送 +``` + +## 6. 错误处理时序图 + +```mermaid +sequenceDiagram + participant DEV as Device + participant WS as WorkstationBase + participant COMM as CommunicationModule + participant HTTP as HTTPService + participant EXT as ExternalSystem + + DEV->>WS: 设备错误事件 + WS->>WS: handle_external_error(error_data) + WS->>WS: 记录错误历史 + + alt 关键错误 + WS->>COMM: emergency_stop() + COMM->>COMM: 发送紧急停止命令 + WS->>WS: 更新workflow_status = ERROR + else 普通错误 + WS->>WS: 标记动作失败 + WS->>WS: 触发重试逻辑 + end + + WS->>HTTP: 记录错误报送 + HTTP->>EXT: 主动通知错误状态 + + WS-->>DEV: 错误处理完成 +``` + +## 7. 典型工作站实现示例 + +### 7.1 PLC工作站实现 + +```python +class PLCWorkstation(WorkstationBase): + def _create_communication_module(self): + return PLCCommunication(self.communication_config) + + def _create_material_management_module(self): + return PyLabRobotMaterialManager( + self.device_id, + self.deck_config, + self.resource_tracker + ) + + def _register_supported_workflows(self): + self.supported_workflows = { + "battery_assembly": WorkflowInfo(...), + "quality_check": WorkflowInfo(...) + } +``` + +### 7.2 报送接收工作站实现 + +```python +class ReportingWorkstation(WorkstationBase): + def _create_communication_module(self): + return DummyCommunication(self.communication_config) + + def _create_material_management_module(self): + return SimpleMaterialManager( + self.device_id, + self.deck_config, + self.resource_tracker + ) + + def _register_supported_workflows(self): + self.supported_workflows = { + "data_collection": WorkflowInfo(...), + "report_processing": WorkflowInfo(...) + } +``` + +## 8. 核心接口说明 + +### 8.1 必须实现的抽象方法 +- `_create_communication_module()`: 创建通信模块 +- `_create_material_management_module()`: 创建物料管理模块 +- `_register_supported_workflows()`: 注册支持的工作流 + +### 8.2 可重写的报送处理方法 +- `process_step_finish_report()`: 步骤完成处理 +- `process_sample_finish_report()`: 样本完成处理 +- `process_order_finish_report()`: 订单完成处理 +- `process_material_change_report()`: 物料变更处理 +- `handle_external_error()`: 错误处理 + +### 8.3 工作流控制接口 +- `start_workflow()`: 启动工作流 +- `stop_workflow()`: 停止工作流 +- `get_workflow_status()`: 获取状态 + +## 9. 配置参数说明 + +```python +workstation_config = { + "communication_config": { + "protocol": "modbus_tcp", + "host": "192.168.1.100", + "port": 502 + }, + "deck_config": { + "size_x": 1000.0, + "size_y": 1000.0, + "size_z": 500.0 + }, + "http_service_config": { + "enabled": True, + "host": "127.0.0.1", + "port": 8081 + }, + "communication_interfaces": { + "logical_device_1": CommunicationInterface(...) + } +} +``` + +这个架构设计支持: +1. **灵活的通信方式**: 通过CommunicationBase支持PLC、串口、以太网等 +2. **多样的物料管理**: 支持PyLabRobot、Bioyond、简单物料系统 +3. **统一的HTTP报送**: 基于LIMS协议的标准化报送接口 +4. **完整的工作流控制**: 支持动态和静态工作流 +5. **强大的错误处理**: 多层次的错误处理和恢复机制 From ae3c1100ae0aa3d185cdfff1369ade0617f61502 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Fri, 22 Aug 2025 06:43:43 +0800 Subject: [PATCH 04/13] =?UTF-8?q?refactor:=20workstation=5Fbase=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=E4=BB=85=E5=90=AB=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E9=80=9A=E4=BF=A1=E5=92=8C=E5=AD=90?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E4=BA=A4=E7=BB=99=20Protoc?= =?UTF-8?q?olNode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/device_comms/workstation_base.py | 2034 ----------------- .../device_comms/workstation_communication.py | 600 ----- .../devices/work_station/workstation_base.py | 460 ++++ .../work_station}/workstation_http_service.py | 0 .../workstation_material_management.py | 0 unilabos/ros/nodes/base_device_node.py | 45 +- unilabos/ros/nodes/presets/protocol_node.py | 59 +- 7 files changed, 561 insertions(+), 2637 deletions(-) delete mode 100644 unilabos/device_comms/workstation_base.py delete mode 100644 unilabos/device_comms/workstation_communication.py create mode 100644 unilabos/devices/work_station/workstation_base.py rename unilabos/{device_comms => devices/work_station}/workstation_http_service.py (100%) rename unilabos/{device_comms => devices/work_station}/workstation_material_management.py (100%) diff --git a/unilabos/device_comms/workstation_base.py b/unilabos/device_comms/workstation_base.py deleted file mode 100644 index 887d56e5..00000000 --- a/unilabos/device_comms/workstation_base.py +++ /dev/null @@ -1,2034 +0,0 @@ -""" -工作站基类 -Workstation Base Class - -集成通信、物料管理和工作流的工作站基类 -融合子设备管理、动态工作流注册等高级功能 -""" -import asyncio -import json -import time -import traceback -from typing import Dict, Any, List, Optional, Union, Callable -from abc import ABC, abstractmethod -from dataclasses import dataclass -from enum import Enum - -from rclpy.action import ActionServer, ActionClient -from rclpy.action.server import ServerGoalHandle -from rclpy.callback_groups import ReentrantCallbackGroup -from rclpy.service import Service -from unilabos_msgs.srv import SerialCommand -from unilabos_msgs.msg import Resource - -from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker -from unilabos.device_comms.workstation_communication import WorkstationCommunicationBase, CommunicationConfig -from unilabos.device_comms.workstation_material_management import MaterialManagementBase -from unilabos.device_comms.workstation_http_service import ( - WorkstationHTTPService, WorkstationReportRequest, MaterialUsage -) -from unilabos.ros.msgs.message_converter import convert_to_ros_msg, convert_from_ros_msg -from unilabos.utils.log import logger -from unilabos.utils.type_check import serialize_result_info - - -class DeviceType(Enum): - """设备类型枚举""" - LOGICAL = "logical" # 逻辑设备 - COMMUNICATION = "communication" # 通信设备 (modbus/opcua/serial) - PROTOCOL = "protocol" # 协议设备 - - -@dataclass -class CommunicationInterface: - """通信接口配置""" - device_id: str # 通信设备ID - read_method: str # 读取方法名 - write_method: str # 写入方法名 - protocol_type: str # 协议类型 (modbus/opcua/serial) - config: Dict[str, Any] # 协议特定配置 - - -@dataclass -class WorkflowStep: - """工作流步骤定义""" - device_id: str - action_name: str - action_kwargs: Dict[str, Any] - depends_on: Optional[List[str]] = None # 依赖的步骤ID - step_id: Optional[str] = None - timeout: Optional[float] = None - retry_count: int = 0 - - -@dataclass -class WorkflowDefinition: - """工作流定义""" - name: str - description: str - steps: List[WorkflowStep] - input_schema: Dict[str, Any] - output_schema: Dict[str, Any] - metadata: Dict[str, Any] - - -class WorkflowStatus(Enum): - """工作流状态""" - IDLE = "idle" - INITIALIZING = "initializing" - RUNNING = "running" - PAUSED = "paused" - STOPPING = "stopping" - STOPPED = "stopped" - ERROR = "error" - COMPLETED = "completed" - - -@dataclass -class WorkflowInfo: - """工作流信息""" - name: str - description: str - estimated_duration: float # 预估持续时间(秒) - required_materials: List[str] # 所需物料类型 - output_product: str # 输出产品类型 - parameters_schema: Dict[str, Any] # 参数架构 - - -class WorkstationBase(ROS2ProtocolNode, ABC): - """工作站基类 - - 提供工作站的核心功能: - 1. 通信转发 - 与PLC/设备的通信接口 - 2. 物料管理 - 基于PyLabRobot的物料系统 - 3. 工作流控制 - 支持动态注册和静态预定义工作流 - 4. 子设备管理 - 继承自ROS2ProtocolNode的设备管理能力 - 5. 状态监控 - 设备状态和生产数据监控 - 6. 调试接口 - 单点控制和紧急操作 - """ - - def __init__( - self, - device_id: str, - children: Dict[str, Dict[str, Any]], - protocol_type: Union[str, List[str]], - resource_tracker: DeviceNodeResourceTracker, - communication_config: CommunicationConfig, - deck_config: Optional[Dict[str, Any]] = None, - communication_interfaces: Optional[Dict[str, CommunicationInterface]] = None, - http_service_config: Optional[Dict[str, Any]] = None, # 新增:HTTP服务配置 - *args, - **kwargs, - ): - # 保存工作站特定配置 - self.communication_config = communication_config - self.deck_config = deck_config or {"size_x": 1000.0, "size_y": 1000.0, "size_z": 500.0} - self.communication_interfaces = communication_interfaces or {} - - # HTTP服务配置 - 现在专门用于报送接收 - self.http_service_config = http_service_config or { - "enabled": True, - "host": "127.0.0.1", - "port": 8081 # 默认使用8081端口作为报送接收服务 - } - - # 错误处理和动作追踪 - self.current_action_context = None # 当前正在执行的动作上下文 - self.error_history = [] # 错误历史记录 - self.action_results = {} # 动作结果缓存 - - # 工作流状态 - 支持静态和动态工作流 - self.current_workflow_status = WorkflowStatus.IDLE - self.current_workflow_info = None - self.workflow_start_time = None - self.workflow_parameters = {} - - # 支持的工作流(静态预定义) - self.supported_workflows: Dict[str, WorkflowInfo] = {} - - # 动态注册的工作流 - self.registered_workflows: Dict[str, WorkflowDefinition] = {} - self._workflow_action_servers: Dict[str, ActionServer] = {} - - # 初始化基类 - ROS2ProtocolNode会处理所有设备管理 - super().__init__( - device_id=device_id, - children=children, - protocol_type=protocol_type, - resource_tracker=resource_tracker, - workstation_config={ - 'communication_interfaces': communication_interfaces, - 'deck_config': self.deck_config - }, - *args, - **kwargs - ) - - # 使用父类的设备分类结果(不再重复分类) - # self.communication_devices 和 self.logical_devices 由 ROS2ProtocolNode 提供 - - # 初始化工作站模块 - self.communication: WorkstationCommunicationBase = self._create_communication_module() - self.material_management: MaterialManagementBase = self._create_material_management_module() - - # 注册支持的工作流 - self._register_supported_workflows() - - # 创建工作站ROS服务 - self._create_workstation_services() - - # 启动状态监控 - self._start_status_monitoring() - - # 启动HTTP报送接收服务 - self.http_service = None - self._start_http_service() - - logger.info(f"增强工作站基类 {device_id} 初始化完成") - - @abstractmethod - def _create_communication_module(self) -> WorkstationCommunicationBase: - """创建通信模块 - 子类必须实现""" - pass - - @abstractmethod - def _create_material_management_module(self) -> MaterialManagementBase: - """创建物料管理模块 - 子类必须实现""" - pass - - @abstractmethod - def _register_supported_workflows(self): - """注册支持的工作流 - 子类必须实现""" - pass - - def _create_workstation_services(self): - """创建工作站ROS服务""" - self._workstation_services = { - # 动态工作流管理服务 - "register_workflow": self.create_service( - SerialCommand, - f"/srv{self.namespace}/register_workflow", - self._handle_register_workflow, - callback_group=self.callback_group, - ), - "unregister_workflow": self.create_service( - SerialCommand, - f"/srv{self.namespace}/unregister_workflow", - self._handle_unregister_workflow, - callback_group=self.callback_group, - ), - "list_workflows": self.create_service( - SerialCommand, - f"/srv{self.namespace}/list_workflows", - self._handle_list_workflows, - callback_group=self.callback_group, - ), - - # 增强物料管理服务 - "create_resource": self.create_service( - SerialCommand, - f"/srv{self.namespace}/create_resource", - self._handle_create_resource, - callback_group=self.callback_group, - ), - "delete_resource": self.create_service( - SerialCommand, - f"/srv{self.namespace}/delete_resource", - self._handle_delete_resource, - callback_group=self.callback_group, - ), - "update_resource": self.create_service( - SerialCommand, - f"/srv{self.namespace}/update_resource", - self._handle_update_resource, - callback_group=self.callback_group, - ), - "get_resource": self.create_service( - SerialCommand, - f"/srv{self.namespace}/get_resource", - self._handle_get_resource, - callback_group=self.callback_group, - ), - - # 工作站特有服务 - "start_workflow": self.create_service( - SerialCommand, - f"/srv{self.namespace}/start_workflow", - self._handle_start_workflow, - callback_group=self.callback_group, - ), - "stop_workflow": self.create_service( - SerialCommand, - f"/srv{self.namespace}/stop_workflow", - self._handle_stop_workflow, - callback_group=self.callback_group, - ), - "get_workflow_status": self.create_service( - SerialCommand, - f"/srv{self.namespace}/get_workflow_status", - self._handle_get_workflow_status, - callback_group=self.callback_group, - ), - "get_device_status": self.create_service( - SerialCommand, - f"/srv{self.namespace}/get_device_status", - self._handle_get_device_status, - callback_group=self.callback_group, - ), - "get_production_data": self.create_service( - SerialCommand, - f"/srv{self.namespace}/get_production_data", - self._handle_get_production_data, - callback_group=self.callback_group, - ), - "get_material_inventory": self.create_service( - SerialCommand, - f"/srv{self.namespace}/get_material_inventory", - self._handle_get_material_inventory, - callback_group=self.callback_group, - ), - "find_materials": self.create_service( - SerialCommand, - f"/srv{self.namespace}/find_materials", - self._handle_find_materials, - callback_group=self.callback_group, - ), - "write_register": self.create_service( - SerialCommand, - f"/srv{self.namespace}/write_register", - self._handle_write_register, - callback_group=self.callback_group, - ), - "read_register": self.create_service( - SerialCommand, - f"/srv{self.namespace}/read_register", - self._handle_read_register, - callback_group=self.callback_group, - ), - "emergency_stop": self.create_service( - SerialCommand, - f"/srv{self.namespace}/emergency_stop", - self._handle_emergency_stop, - callback_group=self.callback_group, - ), - } - - def _start_status_monitoring(self): - """启动状态监控""" - # 这里可以启动定期状态查询线程 - # 目前简化为按需查询 - pass - - def _start_http_service(self): - """启动HTTP报送接收服务""" - try: - if not self.http_service_config.get("enabled", True): - logger.info("HTTP报送接收服务已禁用") - return - - host = self.http_service_config.get("host", "127.0.0.1") - port = self.http_service_config.get("port", 8081) - - self.http_service = WorkstationHTTPService( - workstation_instance=self, - host=host, - port=port - ) - - self.http_service.start() - logger.info(f"工作站 {self.device_id} HTTP报送接收服务启动成功: {self.http_service.service_url}") - - except Exception as e: - logger.error(f"启动HTTP报送接收服务失败: {e}") - self.http_service = None - - def _stop_http_service(self): - """停止HTTP报送接收服务""" - try: - if self.http_service: - self.http_service.stop() - self.http_service = None - logger.info("HTTP报送接收服务已停止") - except Exception as e: - logger.error(f"停止HTTP报送接收服务失败: {e}") - - # ============ 报送处理方法 ============ - - def process_material_change_report(self, report) -> Dict[str, Any]: - """处理物料变更报送 - 同步到 ResourceTracker 并发送 ROS2 更新""" - try: - logger.info(f"处理物料变更报送: {report.workstation_id} -> {report.resource_id} ({report.change_type})") - - # 增加接收计数 - self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 - - # 准备变更数据 - changes = { - 'workstation_id': report.workstation_id, - 'timestamp': report.timestamp, - 'change_type': report.change_type, - 'resource_id': report.resource_id - } - - # 添加额外的变更信息 - if hasattr(report, 'new_location'): - changes['location'] = { - 'x': getattr(report.new_location, 'x', 0), - 'y': getattr(report.new_location, 'y', 0), - 'z': getattr(report.new_location, 'z', 0) - } - - if hasattr(report, 'quantity'): - changes['quantity'] = report.quantity - - if hasattr(report, 'status'): - changes['status'] = report.status - - # 同步到 ResourceTracker - sync_success = self.resource_tracker.update_material_state( - report.resource_id, - changes, - report.change_type - ) - - result = { - 'processed': True, - 'resource_id': report.resource_id, - 'change_type': report.change_type, - 'next_actions': [], - 'tracker_sync': sync_success - } - - # 发送 ROS2 ResourceUpdate 请求到 host node - if sync_success: - try: - self._send_resource_update_to_host(report.resource_id, changes) - result['ros_update_sent'] = True - result['next_actions'].append('ros_update_completed') - except Exception as e: - logger.warning(f"发送ROS2资源更新失败: {e}") - result['ros_update_sent'] = False - result['warnings'] = [f"ROS2更新失败: {str(e)}"] - - # 根据变更类型处理 - if report.change_type == 'created': - result['next_actions'].append('sync_to_global_registry') - self._handle_material_created(report) - - elif report.change_type == 'updated': - result['next_actions'].append('update_local_state') - self._handle_material_updated(report) - - elif report.change_type == 'moved': - result['next_actions'].append('update_location_tracking') - self._handle_material_moved(report) - - elif report.change_type == 'consumed': - result['next_actions'].append('update_inventory') - self._handle_material_consumed(report) - - elif report.change_type == 'completed': - result['next_actions'].append('trigger_next_workflow') - self._handle_material_completed(report) - - # 更新本地物料管理系统(如果存在) - if hasattr(self, 'material_management'): - try: - self.material_management.sync_external_material_change(report) - except Exception as e: - logger.warning(f"同步物料变更到本地管理系统失败: {e}") - - return result - - except Exception as e: - logger.error(f"处理物料变更报送失败: {e}") - return { - 'processed': False, - 'error': str(e), - 'next_actions': ['retry_processing'] - } - - def process_workflow_status_report(self, workstation_id: str, workflow_id: str, - status: str, data: Dict[str, Any]) -> Dict[str, Any]: - """处理工作流状态报送""" - try: - logger.info(f"处理工作流状态报送: {workstation_id} -> {workflow_id} ({status})") - - # 增加接收计数 - self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 - - result = { - 'processed': True, - 'workflow_id': workflow_id, - 'status': status - } - - # 这里可以添加工作流状态同步逻辑 - # 例如:更新本地工作流状态、触发后续动作等 - - return result - - except Exception as e: - logger.error(f"处理工作流状态报送失败: {e}") - return {'processed': False, 'error': str(e)} - - # ============ 统一报送处理方法(基于LIMS协议规范) ============ - - def process_step_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: - """处理步骤完成报送(统一LIMS协议规范)- 同步到 ResourceTracker""" - try: - data = request.data - logger.info(f"处理步骤完成报送: {data['orderCode']} - {data['stepName']} (步骤ID: {data['stepId']})") - - # 增加接收计数 - self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 - - # 同步步骤信息到 ResourceTracker - step_changes = { - 'order_code': data['orderCode'], - 'order_name': data.get('orderName', ''), - 'step_name': data['stepName'], - 'step_id': data['stepId'], - 'sample_id': data['sampleId'], - 'start_time': data['startTime'], - 'end_time': data['endTime'], - 'execution_status': data.get('executionStatus', 'completed'), - 'status': 'step_completed', - 'last_updated': request.request_time - } - - # 更新 ResourceTracker 中的样本状态 - sample_sync_success = False - if data['sampleId']: - sample_sync_success = self.resource_tracker.update_material_state( - data['sampleId'], - { - 'current_step': data['stepName'], - 'step_status': 'completed', - 'last_step_time': data['endTime'], - 'execution_status': data.get('executionStatus', 'completed') - }, - 'step_finished' - ) - - result = { - 'processed': True, - 'order_code': data['orderCode'], - 'step_id': data['stepId'], - 'step_name': data['stepName'], - 'sample_id': data['sampleId'], - 'start_time': data['startTime'], - 'end_time': data['endTime'], - 'execution_status': data.get('executionStatus', 'completed'), - 'next_actions': [], - 'sample_sync': sample_sync_success - } - - # 发送 ROS2 ResourceUpdate 到 host node - if sample_sync_success and data['sampleId']: - try: - self._send_resource_update_to_host(data['sampleId'], step_changes) - result['ros_update_sent'] = True - result['next_actions'].append('ros_step_update_completed') - except Exception as e: - logger.warning(f"发送ROS2步骤完成更新失败: {e}") - result['ros_update_sent'] = False - result['warnings'] = [f"ROS2更新失败: {str(e)}"] - - # 处理步骤完成逻辑 - try: - # 更新步骤状态 - result['next_actions'].append('update_step_status') - - # 检查是否触发后续步骤 - result['next_actions'].append('check_next_step') - - # 更新通量进度 - result['next_actions'].append('update_sample_progress') - - # 记录步骤完成事件 - self._record_step_completion(data) - - except Exception as e: - logger.warning(f"步骤完成处理过程中出现警告: {e}") - result['warnings'] = result.get('warnings', []) + [str(e)] - - return result - - except Exception as e: - logger.error(f"处理步骤完成报送失败: {e}") - return { - 'processed': False, - 'error': str(e), - 'next_actions': ['retry_processing'] - } - - def process_sample_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: - """处理通量完成报送(统一LIMS协议规范)""" - try: - data = request.data - logger.info(f"处理通量完成报送: {data['orderCode']} - 通量ID: {data['sampleId']} (状态: {data['Status']})") - - # 增加接收计数 - self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 - - result = { - 'processed': True, - 'order_code': data['orderCode'], - 'sample_id': data['sampleId'], - 'status': data['Status'], - 'start_time': data['startTime'], - 'end_time': data['endTime'], - 'next_actions': [] - } - - # 根据通量状态处理 - status = int(data['Status']) - if status == 20: # 完成 - result['next_actions'].extend(['update_sample_completed', 'check_order_completion']) - self._record_sample_completion(data, 'completed') - elif status == -2: # 异常停止 - result['next_actions'].extend(['log_sample_error', 'trigger_error_handling']) - self._record_sample_completion(data, 'error') - elif status == -3: # 人工停止或取消 - result['next_actions'].extend(['log_sample_cancelled', 'update_order_status']) - self._record_sample_completion(data, 'cancelled') - elif status == 10: # 开始 - result['next_actions'].append('update_sample_started') - self._record_sample_start(data) - elif status == 2: # 进样 - result['next_actions'].append('update_sample_intake') - self._record_sample_intake(data) - - return result - - except Exception as e: - logger.error(f"处理通量完成报送失败: {e}") - return { - 'processed': False, - 'error': str(e), - 'next_actions': ['retry_processing'] - } - - def process_order_finish_report(self, request: WorkstationReportRequest, used_materials: List[MaterialUsage]) -> Dict[str, Any]: - """处理任务完成报送(统一LIMS协议规范)""" - try: - data = request.data - logger.info(f"处理任务完成报送: {data['orderCode']} - {data['orderName']} (状态: {data['status']})") - - # 增加接收计数 - self._reports_received_count = getattr(self, '_reports_received_count', 0) + 1 - - result = { - 'processed': True, - 'order_code': data['orderCode'], - 'order_name': data['orderName'], - 'status': data['status'], - 'start_time': data['startTime'], - 'end_time': data['endTime'], - 'used_materials_count': len(used_materials), - 'next_actions': [] - } - - # 根据任务状态处理 - status = int(data['status']) - if status == 30: # 完成 - result['next_actions'].extend([ - 'update_order_completed', - 'process_material_usage', - 'generate_completion_report' - ]) - self._record_order_completion(data, used_materials, 'completed') - elif status == -11: # 异常停止 - result['next_actions'].extend([ - 'log_order_error', - 'trigger_error_handling', - 'process_partial_material_usage' - ]) - self._record_order_completion(data, used_materials, 'error') - elif status == -12: # 人工停止或取消 - result['next_actions'].extend([ - 'log_order_cancelled', - 'revert_material_reservations' - ]) - self._record_order_completion(data, used_materials, 'cancelled') - - # 处理物料使用记录 - if used_materials: - material_usage_result = self._process_material_usage(used_materials) - result['material_usage'] = material_usage_result - - return result - - except Exception as e: - logger.error(f"处理任务完成报送失败: {e}") - return { - 'processed': False, - 'error': str(e), - 'next_actions': ['retry_processing'] - } - - # ============ 具体的报送处理方法 ============ - - def _handle_material_created(self, report): - """处理物料创建报送""" - try: - # 已废弃的方法,保留用于兼容性 - logger.debug(f"处理物料创建: {getattr(report, 'resource_id', 'unknown')}") - except Exception as e: - logger.error(f"处理物料创建失败: {e}") - - def _handle_material_updated(self, report): - """处理物料更新报送""" - try: - logger.debug(f"处理物料更新: {getattr(report, 'resource_id', 'unknown')}") - except Exception as e: - logger.error(f"处理物料更新失败: {e}") - - def _handle_material_moved(self, report): - """处理物料移动报送""" - try: - logger.debug(f"处理物料移动: {getattr(report, 'resource_id', 'unknown')}") - except Exception as e: - logger.error(f"处理物料移动失败: {e}") - - def _handle_material_consumed(self, report): - """处理物料消耗报送""" - try: - logger.debug(f"处理物料消耗: {getattr(report, 'resource_id', 'unknown')}") - except Exception as e: - logger.error(f"处理物料消耗失败: {e}") - - def _handle_material_completed(self, report): - """处理物料完成报送""" - try: - logger.debug(f"处理物料完成: {getattr(report, 'resource_id', 'unknown')}") - except Exception as e: - logger.error(f"处理物料完成失败: {e}") - - # ============ 工作流控制接口 ============ - def handle_external_error(self, error_request): - """处理外部错误请求""" - try: - logger.error(f"收到外部错误处理请求: {getattr(error_request, 'error_type', 'unknown')}") - return { - 'success': True, - 'message': "错误已记录", - 'error_code': 'OK' - } - except Exception as e: - logger.error(f"处理外部错误失败: {e}") - return { - 'success': False, - 'message': f"错误处理失败: {str(e)}", - 'error_code': 'ERROR_HANDLING_FAILED' - } - - def _process_error_handling(self, error_request, error_record): - """处理具体的错误类型""" - return {'success': True, 'actions_taken': ['已转换为统一报送']} - """处理具体的错误类型""" - try: - result = {'success': True, 'actions_taken': []} - - # 1. 如果有特定动作ID,标记该动作失败 - if error_request.action_id: - self._mark_action_failed(error_request.action_id, error_request.error_message) - result['actions_taken'].append(f"标记动作 {error_request.action_id} 为失败") - - # 2. 如果有工作流ID,停止相关工作流 - if error_request.workflow_id: - self._handle_workflow_error(error_request.workflow_id, error_request.error_message) - result['actions_taken'].append(f"处理工作流 {error_request.workflow_id} 错误") - - # 3. 根据错误类型执行特定处理 - error_type = error_request.error_type.lower() - - if error_type in ['material_error', 'resource_error']: - # 物料相关错误 - material_result = self._handle_material_error(error_request) - result['actions_taken'].extend(material_result.get('actions', [])) - - elif error_type in ['device_error', 'communication_error']: - # 设备通信错误 - device_result = self._handle_device_error(error_request) - result['actions_taken'].extend(device_result.get('actions', [])) - - elif error_type in ['workflow_error', 'process_error']: - # 工作流程错误 - workflow_result = self._handle_process_error(error_request) - result['actions_taken'].extend(workflow_result.get('actions', [])) - - else: - # 通用错误处理 - result['actions_taken'].append("执行通用错误处理") - - # 4. 如果是严重错误,触发紧急停止 - if error_request.error_type.lower() in ['critical_error', 'safety_error', 'emergency']: - self._trigger_emergency_stop(error_request.error_message) - result['actions_taken'].append("触发紧急停止") - - result['message'] = "错误处理完成" - return result - - except Exception as e: - logger.error(f"错误处理过程失败: {e}") - return { - 'success': False, - 'message': f"错误处理过程失败: {str(e)}", - 'error_code': 'ERROR_PROCESSING_FAILED' - } - - def _mark_action_failed(self, action_id: str, error_message: str): - """标记指定动作为失败""" - try: - # 创建失败结果 - failed_result = { - 'success': False, - 'error': True, - 'error_message': error_message, - 'timestamp': time.time(), - 'marked_by_external_error': True - } - - # 存储到动作结果缓存 - self.action_results[action_id] = failed_result - - # 如果当前有正在执行的动作,更新其状态 - if self.current_action_context and self.current_action_context.get('action_id') == action_id: - self.current_action_context['failed'] = True - self.current_action_context['error_message'] = error_message - - logger.info(f"动作 {action_id} 已标记为失败: {error_message}") - - except Exception as e: - logger.error(f"标记动作失败时出错: {e}") - - def _handle_workflow_error(self, workflow_id: str, error_message: str): - """处理工作流错误""" - try: - # 如果是当前正在运行的工作流 - if (self.current_workflow_info and - self.current_workflow_info.get('id') == workflow_id): - - # 停止当前工作流 - self.stop_workflow(emergency=True) - logger.info(f"因外部错误停止工作流 {workflow_id}: {error_message}") - - except Exception as e: - logger.error(f"处理工作流错误失败: {e}") - - def _handle_material_error(self, error_request): - """处理物料相关错误(已废弃,请使用统一报送接口)""" - return {'success': True, 'message': '物料错误已记录'} - """处理物料相关错误""" - actions = [] - try: - # 可以触发物料重新扫描、位置重置等 - if error_request.context and 'resource_id' in error_request.context: - resource_id = error_request.context['resource_id'] - # 触发物料状态更新 - actions.append(f"更新物料 {resource_id} 状态") - - actions.append("执行物料错误恢复流程") - - except Exception as e: - logger.error(f"处理物料错误失败: {e}") - - return {'actions': actions} - - def _handle_device_error(self, error_request): - """处理设备相关错误(已废弃,请使用统一报送接口)""" - return {'success': True, 'message': '设备错误已记录'} - """处理设备错误""" - actions = [] - try: - if error_request.device_id: - # 重置设备连接 - actions.append(f"重置设备 {error_request.device_id} 连接") - - # 如果是通信设备,重新建立连接 - if error_request.device_id in self.communication_devices: - actions.append(f"重新建立通信设备 {error_request.device_id} 连接") - - actions.append("执行设备错误恢复流程") - - except Exception as e: - logger.error(f"处理设备错误失败: {e}") - - return {'actions': actions} - - def _handle_process_error(self, error_request): - """处理流程相关错误(已废弃,请使用统一报送接口)""" - return {'success': True, 'message': '流程错误已记录'} - """处理工作流程错误""" - actions = [] - try: - # 暂停当前工作流 - if self.current_workflow_status not in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: - actions.append("暂停当前工作流") - - actions.append("执行工作流程错误恢复") - - except Exception as e: - logger.error(f"处理工作流程错误失败: {e}") - - return {'actions': actions} - - def _trigger_emergency_stop(self, reason: str): - """触发紧急停止""" - try: - logger.critical(f"触发紧急停止: {reason}") - - # 停止所有工作流 - self.stop_workflow(emergency=True) - - # 设置错误状态 - self.current_workflow_status = WorkflowStatus.ERROR - - # 可以在这里添加更多紧急停止逻辑 - # 例如:断开设备连接、保存当前状态等 - - except Exception as e: - logger.error(f"执行紧急停止失败: {e}") - - # ============ 工作流控制接口 ============ - - def _handle_start_workflow(self, request, response): - """处理启动工作流请求""" - try: - import json - - # 解析请求参数 - params = json.loads(request.data) if request.data else {} - workflow_type = params.get("workflow_type", "") - workflow_parameters = params.get("parameters", {}) - - if not workflow_type: - response.success = False - response.message = "缺少工作流类型参数" - return response - - if workflow_type not in self.supported_workflows: - response.success = False - response.message = f"不支持的工作流类型: {workflow_type}" - return response - - if self.current_workflow_status != WorkflowStatus.IDLE: - response.success = False - response.message = f"当前状态不允许启动工作流: {self.current_workflow_status.value}" - return response - - # 启动工作流 - success = self.start_workflow(workflow_type, workflow_parameters) - - response.success = success - response.message = "工作流启动成功" if success else "工作流启动失败" - response.data = json.dumps({ - "workflow_type": workflow_type, - "status": self.current_workflow_status.value, - "estimated_duration": self.supported_workflows[workflow_type].estimated_duration - }) - - except Exception as e: - logger.error(f"处理启动工作流请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - def _handle_stop_workflow(self, request, response): - """处理停止工作流请求""" - try: - import json - - params = json.loads(request.data) if request.data else {} - emergency = params.get("emergency", False) - - success = self.stop_workflow(emergency) - - response.success = success - response.message = "工作流停止成功" if success else "工作流停止失败" - response.data = json.dumps({ - "status": self.current_workflow_status.value, - "emergency": emergency - }) - - except Exception as e: - logger.error(f"处理停止工作流请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - def _handle_get_workflow_status(self, request, response): - """处理获取工作流状态请求""" - try: - import json - import time - - status_info = { - "status": self.current_workflow_status.value, - "workflow_info": self.current_workflow_info.name if self.current_workflow_info else None, - "start_time": self.workflow_start_time, - "parameters": self.workflow_parameters, - "supported_workflows": { - name: { - "description": info.description, - "estimated_duration": info.estimated_duration, - "required_materials": info.required_materials, - "output_product": info.output_product - } - for name, info in self.supported_workflows.items() - } - } - - # 如果工作流正在运行,添加进度信息 - if self.current_workflow_status == WorkflowStatus.RUNNING and self.workflow_start_time: - elapsed_time = time.time() - self.workflow_start_time - estimated_duration = self.current_workflow_info.estimated_duration if self.current_workflow_info else 0 - progress = min(elapsed_time / estimated_duration * 100, 99) if estimated_duration > 0 else 0 - - status_info.update({ - "elapsed_time": elapsed_time, - "estimated_remaining": max(estimated_duration - elapsed_time, 0), - "progress_percent": progress - }) - - # 查询PLC状态 - plc_status = self.communication.get_workflow_status() - status_info["plc_status"] = plc_status - - response.success = True - response.message = "获取状态成功" - response.data = json.dumps(status_info) - - except Exception as e: - logger.error(f"处理获取工作流状态请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - # ============ 设备状态接口 ============ - - def _handle_get_device_status(self, request, response): - """处理获取设备状态请求""" - try: - import json - - # 从通信模块获取设备状态 - device_status = self.communication.get_device_status() - - response.success = True - response.message = "获取设备状态成功" - response.data = json.dumps(device_status) - - except Exception as e: - logger.error(f"处理获取设备状态请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - def _handle_get_production_data(self, request, response): - """处理获取生产数据请求""" - try: - import json - - # 从通信模块获取生产数据 - production_data = self.communication.get_production_data() - - response.success = True - response.message = "获取生产数据成功" - response.data = json.dumps(production_data) - - except Exception as e: - logger.error(f"处理获取生产数据请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - # ============ 物料管理接口 ============ - - def _handle_get_material_inventory(self, request, response): - """处理获取物料库存请求""" - try: - import json - - # 从物料管理模块获取库存 - inventory = self.material_management.get_material_inventory() - deck_state = self.material_management.get_deck_state() - - result = { - "inventory": inventory, - "deck_state": deck_state - } - - response.success = True - response.message = "获取物料库存成功" - response.data = json.dumps(result) - - except Exception as e: - logger.error(f"处理获取物料库存请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - def _handle_find_materials(self, request, response): - """处理查找物料请求""" - try: - import json - - params = json.loads(request.data) if request.data else {} - material_type = params.get("material_type", "") - category = params.get("category", "") - name_pattern = params.get("name_pattern", "") - - found_materials = [] - - if material_type: - materials = self.material_management.find_materials_by_type(material_type) - found_materials.extend([self.material_management.convert_to_unilab_format(m) for m in materials]) - - if category: - materials = self.material_management.resource_tracker.find_by_category(category) - found_materials.extend([self.material_management.convert_to_unilab_format(m) for m in materials]) - - if name_pattern: - materials = self.material_management.resource_tracker.find_by_name_pattern(name_pattern) - found_materials.extend([self.material_management.convert_to_unilab_format(m) for m in materials]) - - response.success = True - response.message = f"找到 {len(found_materials)} 个物料" - response.data = json.dumps({"materials": found_materials}) - - except Exception as e: - logger.error(f"处理查找物料请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - # ============ 调试控制接口 ============ - - def _handle_write_register(self, request, response): - """处理写寄存器请求""" - try: - import json - from unilabos.device_comms.modbus_plc.node.modbus import DataType, WorderOrder - - params = json.loads(request.data) if request.data else {} - register_name = params.get("register_name", "") - value = params.get("value") - data_type_str = params.get("data_type", "") - word_order_str = params.get("word_order", "") - - if not register_name or value is None: - response.success = False - response.message = "缺少寄存器名称或值" - return response - - # 转换数据类型和字节序 - data_type = DataType[data_type_str] if data_type_str else None - word_order = WorderOrder[word_order_str] if word_order_str else None - - success = self.communication.write_register(register_name, value, data_type, word_order) - - response.success = success - response.message = "写寄存器成功" if success else "写寄存器失败" - response.data = json.dumps({ - "register_name": register_name, - "value": value, - "data_type": data_type_str, - "word_order": word_order_str - }) - - except Exception as e: - logger.error(f"处理写寄存器请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - def _handle_read_register(self, request, response): - """处理读寄存器请求""" - try: - import json - from unilabos.device_comms.modbus_plc.node.modbus import DataType, WorderOrder - - params = json.loads(request.data) if request.data else {} - register_name = params.get("register_name", "") - count = params.get("count", 1) - data_type_str = params.get("data_type", "") - word_order_str = params.get("word_order", "") - - if not register_name: - response.success = False - response.message = "缺少寄存器名称" - return response - - # 转换数据类型和字节序 - data_type = DataType[data_type_str] if data_type_str else None - word_order = WorderOrder[word_order_str] if word_order_str else None - - value, error = self.communication.read_register(register_name, count, data_type, word_order) - - response.success = not error - response.message = "读寄存器成功" if not error else "读寄存器失败" - response.data = json.dumps({ - "register_name": register_name, - "value": value, - "error": error, - "data_type": data_type_str, - "word_order": word_order_str - }) - - except Exception as e: - logger.error(f"处理读寄存器请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - def _handle_emergency_stop(self, request, response): - """处理紧急停止请求""" - try: - import json - - # 立即停止工作流 - success = self.stop_workflow(emergency=True) - - # 更新状态 - if success: - self.current_workflow_status = WorkflowStatus.STOPPED - - response.success = success - response.message = "紧急停止成功" if success else "紧急停止失败" - response.data = json.dumps({ - "status": self.current_workflow_status.value, - "timestamp": time.time() - }) - - except Exception as e: - logger.error(f"处理紧急停止请求失败: {e}") - response.success = False - response.message = f"处理请求失败: {str(e)}" - - return response - - # ============ 工作流控制方法 ============ - - def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: - """启动工作流""" - try: - if workflow_type not in self.supported_workflows: - logger.error(f"不支持的工作流类型: {workflow_type}") - return False - - if self.current_workflow_status != WorkflowStatus.IDLE: - logger.error(f"当前状态不允许启动工作流: {self.current_workflow_status}") - return False - - # 更新状态 - self.current_workflow_status = WorkflowStatus.INITIALIZING - self.current_workflow_info = self.supported_workflows[workflow_type] - self.workflow_parameters = parameters or {} - - # 通过通信模块启动工作流 - success = self.communication.start_workflow(workflow_type, self.workflow_parameters) - - if success: - self.current_workflow_status = WorkflowStatus.RUNNING - self.workflow_start_time = time.time() - logger.info(f"工作流启动成功: {workflow_type}") - else: - self.current_workflow_status = WorkflowStatus.ERROR - logger.error(f"工作流启动失败: {workflow_type}") - - return success - - except Exception as e: - logger.error(f"启动工作流失败: {e}") - self.current_workflow_status = WorkflowStatus.ERROR - return False - - def stop_workflow(self, emergency: bool = False) -> bool: - """停止工作流""" - try: - if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: - logger.warning("没有正在运行的工作流") - return True - - # 更新状态 - self.current_workflow_status = WorkflowStatus.STOPPING - - # 通过通信模块停止工作流 - success = self.communication.stop_workflow(emergency) - - if success: - self.current_workflow_status = WorkflowStatus.STOPPED - logger.info(f"工作流停止成功 (紧急: {emergency})") - else: - self.current_workflow_status = WorkflowStatus.ERROR - logger.error(f"工作流停止失败 (紧急: {emergency})") - - return success - - except Exception as e: - logger.error(f"停止工作流失败: {e}") - self.current_workflow_status = WorkflowStatus.ERROR - return False - - # ============ 状态属性 ============ - - @property - def is_busy(self) -> bool: - """是否忙碌""" - return self.current_workflow_status in [ - WorkflowStatus.INITIALIZING, - WorkflowStatus.RUNNING, - WorkflowStatus.STOPPING - ] - - @property - def is_ready(self) -> bool: - """是否就绪""" - return self.current_workflow_status == WorkflowStatus.IDLE - - @property - def has_error(self) -> bool: - """是否有错误""" - return self.current_workflow_status == WorkflowStatus.ERROR - - @property - def communication_status(self) -> Dict[str, Any]: - """通信状态""" - return { - "is_connected": self.communication.is_connected, - "host": self.communication.config.host, - "port": self.communication.config.port, - "protocol": self.communication.config.protocol.value - } - - @property - def material_status(self) -> Dict[str, Any]: - """物料状态""" - return { - "total_resources": len(self.material_management.plr_resources), - "inventory": self.material_management.get_material_inventory(), - "deck_size": { - "x": self.material_management.plr_deck.size_x, - "y": self.material_management.plr_deck.size_y, - "z": self.material_management.plr_deck.size_z - } - } - - # ============ 增强物料管理接口 ============ - - def _handle_create_resource(self, request, response): - """处理创建物料请求""" - try: - data = json.loads(request.data) if request.data else {} - result = self.create_resource( - resource_data=data.get("resource_data"), - parent_id=data.get("parent_id"), - location=data.get("location"), - metadata=data.get("metadata", {}) - ) - response.success = True - response.message = "创建物料成功" - response.data = serialize_result_info("", True, result) - except Exception as e: - error_msg = f"创建物料失败: {e}\n{traceback.format_exc()}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - def create_resource(self, resource_data: Dict[str, Any], parent_id: Optional[str] = None, - location: Optional[Dict[str, float]] = None, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """创建物料资源""" - try: - # 验证资源数据 - if not self._validate_resource_data(resource_data): - raise ValueError("无效的资源数据") - - # 添加到本地资源跟踪器 - resource = convert_to_ros_msg(Resource, resource_data) - self.resource_tracker.add_resource(resource) - - # 如果有父节点,建立关联 - if parent_id: - self._link_resource_to_parent(resource_data["id"], parent_id, location) - - # 同步到全局资源管理器 - self._sync_resource_to_global(resource, "create") - - logger.info(f"物料 {resource_data['id']} 创建成功") - return {"resource_id": resource_data["id"], "status": "created"} - - except Exception as e: - logger.error(f"创建物料失败: {e}") - raise - - def _handle_delete_resource(self, request, response): - """处理删除物料请求""" - try: - data = json.loads(request.data) if request.data else {} - result = self.delete_resource(data.get("resource_id")) - response.success = True - response.message = "删除物料成功" - response.data = serialize_result_info("", True, result) - except Exception as e: - error_msg = f"删除物料失败: {e}\n{traceback.format_exc()}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - def delete_resource(self, resource_id: str) -> Dict[str, Any]: - """删除物料资源""" - try: - # 从本地资源跟踪器删除 - resources = self.resource_tracker.figure_resource({"id": resource_id}) - if not resources: - raise ValueError(f"资源 {resource_id} 不存在") - - # 同步到全局资源管理器 - self._sync_resource_to_global(resources[0], "delete") - - logger.info(f"物料 {resource_id} 删除成功") - return {"resource_id": resource_id, "status": "deleted"} - - except Exception as e: - logger.error(f"删除物料失败: {e}") - raise - - def _handle_update_resource(self, request, response): - """处理更新物料请求""" - try: - data = json.loads(request.data) if request.data else {} - result = self.update_resource( - resource_id=data.get("resource_id"), - updates=data.get("updates", {}) - ) - response.success = True - response.message = "更新物料成功" - response.data = serialize_result_info("", True, result) - except Exception as e: - error_msg = f"更新物料失败: {e}\n{traceback.format_exc()}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - def update_resource(self, resource_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: - """更新物料资源""" - try: - # 查找资源 - resources = self.resource_tracker.figure_resource({"id": resource_id}) - if not resources: - raise ValueError(f"资源 {resource_id} 不存在") - - resource = resources[0] - - # 更新资源数据 - if isinstance(resource, Resource): - if "data" in updates: - current_data = json.loads(resource.data) if resource.data else {} - current_data.update(updates["data"]) - resource.data = json.dumps(current_data) - - for key, value in updates.items(): - if key != "data" and hasattr(resource, key): - setattr(resource, key, value) - - # 同步到全局资源管理器 - self._sync_resource_to_global(resource, "update") - - logger.info(f"物料 {resource_id} 更新成功") - return {"resource_id": resource_id, "status": "updated"} - - except Exception as e: - logger.error(f"更新物料失败: {e}") - raise - - def _handle_get_resource(self, request, response): - """处理获取物料请求""" - try: - data = json.loads(request.data) if request.data else {} - result = self.get_resource( - resource_id=data.get("resource_id"), - with_children=data.get("with_children", False) - ) - response.success = True - response.message = "获取物料成功" - response.data = serialize_result_info("", True, result) - except Exception as e: - error_msg = f"获取物料失败: {e}\n{traceback.format_exc()}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - def get_resource(self, resource_id: str, with_children: bool = False) -> Dict[str, Any]: - """获取物料资源""" - try: - resources = self.resource_tracker.figure_resource({"id": resource_id}) - if not resources: - raise ValueError(f"资源 {resource_id} 不存在") - - resource = resources[0] - - # 转换为字典格式 - if isinstance(resource, Resource): - result = convert_from_ros_msg(resource) - else: - result = resource - - # 如果需要包含子资源 - if with_children: - children = self._get_child_resources(resource_id) - result["children"] = children - - return result - - except Exception as e: - logger.error(f"获取物料失败: {e}") - raise - - # ============ 动态工作流管理接口 ============ - - def _handle_register_workflow(self, request, response): - """处理注册工作流请求""" - try: - data = json.loads(request.data) if request.data else {} - result = self.register_workflow( - workflow_name=data.get("workflow_name"), - workflow_definition=data.get("workflow_definition"), - action_type=data.get("action_type") - ) - response.success = True - response.message = "注册工作流成功" - response.data = serialize_result_info("", True, result) - except Exception as e: - error_msg = f"注册工作流失败: {e}\n{traceback.format_exc()}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - def register_workflow(self, workflow_name: str, workflow_definition: Dict[str, Any], - action_type: Optional[str] = None) -> Dict[str, Any]: - """注册工作流并创建对应的ROS Action""" - try: - # 验证工作流定义 - if not self._validate_workflow_definition(workflow_definition): - raise ValueError("无效的工作流定义") - - # 创建工作流定义对象 - workflow = WorkflowDefinition( - name=workflow_name, - description=workflow_definition.get("description", ""), - steps=[WorkflowStep(**step) for step in workflow_definition.get("steps", [])], - input_schema=workflow_definition.get("input_schema", {}), - output_schema=workflow_definition.get("output_schema", {}), - metadata=workflow_definition.get("metadata", {}) - ) - - # 存储工作流定义 - self.registered_workflows[workflow_name] = workflow - - # 创建对应的ROS Action Server - self._create_workflow_action_server(workflow_name, workflow, action_type) - - logger.info(f"工作流 {workflow_name} 注册成功") - return {"workflow_name": workflow_name, "status": "registered"} - - except Exception as e: - logger.error(f"注册工作流失败: {e}") - raise - - def _create_workflow_action_server(self, workflow_name: str, workflow: WorkflowDefinition, action_type: Optional[str]): - """为工作流创建ROS Action Server""" - try: - # 如果没有指定action_type,使用默认的SendCmd - if not action_type: - from unilabos_msgs.action import SendCmd - action_type_class = SendCmd - else: - # 动态导入指定的action类型 - action_type_class = self._import_action_type(action_type) - - # 创建Action Server - self._workflow_action_servers[workflow_name] = ActionServer( - self, - action_type_class, - workflow_name, - execute_callback=self._create_workflow_execute_callback(workflow), - callback_group=ReentrantCallbackGroup(), - ) - - logger.info(f"为工作流 {workflow_name} 创建Action Server") - - except Exception as e: - logger.error(f"创建工作流Action Server失败: {e}") - raise - - def _create_workflow_execute_callback(self, workflow: WorkflowDefinition): - """创建工作流执行回调""" - async def execute_workflow(goal_handle: ServerGoalHandle): - execution_error = "" - execution_success = False - workflow_return_value = None - - try: - logger.info(f"开始执行工作流: {workflow.name}") - - # 解析输入参数 - goal = goal_handle.request - workflow_kwargs = self._parse_workflow_input(goal, workflow.input_schema) - - # 执行工作流步骤 - step_results = [] - for step in workflow.steps: - # 检查依赖 - if step.depends_on: - self._wait_for_dependencies(step.depends_on, step_results) - - # 执行步骤 - step_result = await self._execute_workflow_step(step, workflow_kwargs) - step_results.append(step_result) - - # 发布反馈 - feedback = self._create_workflow_feedback(workflow, step_results) - goal_handle.publish_feedback(feedback) - - execution_success = True - workflow_return_value = { - "workflow_name": workflow.name, - "steps_completed": len(step_results), - "results": step_results - } - - goal_handle.succeed() - - except Exception as e: - execution_error = traceback.format_exc() - execution_success = False - logger.error(f"工作流执行失败: {e}") - goal_handle.abort() - - # 创建结果 - result = goal_handle._action_type.Result() - result.success = execution_success - - # 如果有return_info字段,设置详细信息 - if hasattr(result, 'return_info'): - result.return_info = serialize_result_info(execution_error, execution_success, workflow_return_value) - - return result - - return execute_workflow - - async def _execute_workflow_step(self, step: WorkflowStep, workflow_kwargs: Dict[str, Any]) -> Dict[str, Any]: - """执行单个工作流步骤 - 使用父类的execute_single_action方法""" - try: - # 替换参数中的变量 - resolved_kwargs = self._resolve_step_kwargs(step.action_kwargs, workflow_kwargs) - - # 使用父类的execute_single_action方法执行动作 - result = await self.execute_single_action( - device_id=step.device_id, - action_name=step.action_name, - action_kwargs=resolved_kwargs - ) - - return { - "step_id": step.step_id or f"{step.device_id}_{step.action_name}", - "device_id": step.device_id, - "action_name": step.action_name, - "status": "success", - "result": result - } - - except Exception as e: - logger.error(f"步骤执行失败: {step.step_id}, 错误: {e}") - return { - "step_id": step.step_id or f"{step.device_id}_{step.action_name}", - "device_id": step.device_id, - "action_name": step.action_name, - "status": "failed", - "error": str(e) - } - - def _handle_unregister_workflow(self, request, response): - """处理注销工作流请求""" - try: - data = json.loads(request.data) if request.data else {} - workflow_name = data.get("workflow_name") - - if workflow_name in self.registered_workflows: - del self.registered_workflows[workflow_name] - - if workflow_name in self._workflow_action_servers: - # 销毁Action Server - del self._workflow_action_servers[workflow_name] - - result = {"workflow_name": workflow_name, "status": "unregistered"} - response.success = True - response.message = "注销工作流成功" - response.data = serialize_result_info("", True, result) - else: - raise ValueError(f"工作流 {workflow_name} 不存在") - - except Exception as e: - error_msg = f"注销工作流失败: {e}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - def _handle_list_workflows(self, request, response): - """处理列出工作流请求""" - try: - # 静态预定义工作流 - static_workflows = [] - for name, workflow in self.supported_workflows.items(): - static_workflows.append({ - "name": name, - "type": "static", - "description": workflow.description, - "estimated_duration": workflow.estimated_duration, - "required_materials": workflow.required_materials, - "output_product": workflow.output_product - }) - - # 动态注册工作流 - dynamic_workflows = [] - for name, workflow in self.registered_workflows.items(): - dynamic_workflows.append({ - "name": name, - "type": "dynamic", - "description": workflow.description, - "step_count": len(workflow.steps), - "metadata": workflow.metadata - }) - - result = { - "static_workflows": static_workflows, - "dynamic_workflows": dynamic_workflows, - "total_count": len(static_workflows) + len(dynamic_workflows) - } - response.success = True - response.message = "列出工作流成功" - response.data = serialize_result_info("", True, result) - except Exception as e: - error_msg = f"列出工作流失败: {e}" - logger.error(error_msg) - response.success = False - response.message = error_msg - response.data = serialize_result_info(error_msg, False, {}) - return response - - # ============ 辅助方法 ============ - - def _validate_resource_data(self, resource_data: Dict[str, Any]) -> bool: - """验证资源数据""" - required_fields = ["id", "name", "type"] - return all(field in resource_data for field in required_fields) - - def _validate_workflow_definition(self, workflow_def: Dict[str, Any]) -> bool: - """验证工作流定义""" - required_fields = ["steps"] - return all(field in workflow_def for field in required_fields) - - def _sync_resource_to_global(self, resource: Resource, operation: str): - """同步资源到全局管理器""" - # 实现与全局资源管理器的同步逻辑 - pass - - def _link_resource_to_parent(self, resource_id: str, parent_id: str, location: Optional[Dict[str, float]]): - """将资源链接到父节点""" - # 实现资源父子关系的建立逻辑 - pass - - def _get_child_resources(self, resource_id: str) -> List[Dict[str, Any]]: - """获取子资源""" - # 实现获取子资源的逻辑 - return [] - - def _import_action_type(self, action_type: str): - """动态导入Action类型""" - # 实现动态导入逻辑 - from unilabos_msgs.action import SendCmd - return SendCmd - - def _parse_workflow_input(self, goal, input_schema: Dict[str, Any]) -> Dict[str, Any]: - """解析工作流输入""" - # 根据input_schema解析goal中的参数 - return {} - - def _wait_for_dependencies(self, dependencies: List[str], completed_steps: List[Dict[str, Any]]): - """等待依赖步骤完成""" - # 实现依赖等待逻辑 - pass - - def _resolve_step_kwargs(self, action_kwargs: Dict[str, Any], workflow_kwargs: Dict[str, Any]) -> Dict[str, Any]: - """解析步骤参数中的变量""" - # 实现参数变量替换逻辑 - return action_kwargs - - def _create_workflow_feedback(self, workflow: WorkflowDefinition, step_results: List[Dict[str, Any]]): - """创建工作流反馈""" - # 创建反馈消息 - return None - - # ============ 增强状态属性 ============ - - @property - def communication_device_count(self) -> int: - """通信设备数量""" - return len(self.communication_devices) - - @property - def logical_device_count(self) -> int: - """逻辑设备数量""" - return len(self.logical_devices) - - @property - def active_dynamic_workflows(self) -> int: - """活跃动态工作流数量""" - return len([server for server in self._workflow_action_servers.values() if server]) - - @property - def total_workflow_count(self) -> int: - """总工作流数量""" - return len(self.supported_workflows) + len(self.registered_workflows) - - @property - def workstation_resource_count(self) -> int: - """工作站资源数量""" - return len(self.resource_tracker.figure_resource({})) - - @property - def workstation_status_summary(self) -> Dict[str, Any]: - """工作站状态摘要""" - return { - "workflow_status": self.current_workflow_status.value, - "is_busy": self.is_busy, - "is_ready": self.is_ready, - "has_error": self.has_error, - "total_devices": len(self.sub_devices), - "communication_devices": self.communication_device_count, - "logical_devices": self.logical_device_count, - "total_workflows": self.total_workflow_count, - "active_workflows": self.active_dynamic_workflows, - "total_resources": self.workstation_resource_count, - "communication_status": self.communication_status, - "material_status": self.material_status, - "http_service_running": self.http_service.is_running if self.http_service else False - } - - # ============ 增强动作执行 - 支持错误处理和追踪 ============ - - async def execute_single_action(self, device_id, action_name, action_kwargs): - """执行单个动作 - 增强版,支持错误处理和动作追踪""" - # 构建动作ID - if device_id in ["", None, "self"]: - action_id = f"/devices/{self.device_id}/{action_name}" - else: - action_id = f"/devices/{device_id}/{action_name}" - - # 设置动作上下文 - self.current_action_context = { - 'action_id': action_id, - 'device_id': device_id, - 'action_name': action_name, - 'action_kwargs': action_kwargs, - 'start_time': time.time(), - 'failed': False, - 'error_message': None - } - - try: - # 检查是否已被外部标记为失败 - if action_id in self.action_results: - cached_result = self.action_results[action_id] - if cached_result.get('marked_by_external_error'): - logger.warning(f"动作 {action_id} 已被外部标记为失败") - return self._create_failed_result(cached_result['error_message']) - - # 检查动作客户端是否存在 - if action_id not in self._action_clients: - error_msg = f"找不到动作客户端: {action_id}" - self.lab_logger().error(error_msg) - return self._create_failed_result(error_msg) - - # 发送动作请求 - action_client = self._action_clients[action_id] - goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) - - self.lab_logger().debug(f"发送动作请求到: {action_id}") - action_client.wait_for_server() - - # 等待动作完成 - request_future = action_client.send_goal_async(goal_msg) - handle = await request_future - - if not handle.accepted: - error_msg = f"动作请求被拒绝: {action_name}" - self.lab_logger().error(error_msg) - return self._create_failed_result(error_msg) - - # 在执行过程中检查是否被外部标记为失败 - result_future = await handle.get_result_async() - - # 再次检查是否在执行过程中被标记为失败 - if self.current_action_context.get('failed'): - error_msg = self.current_action_context.get('error_message', '动作被外部标记为失败') - logger.warning(f"动作 {action_id} 在执行过程中被标记为失败: {error_msg}") - return self._create_failed_result(error_msg) - - result = result_future.result - - # 存储成功结果 - self.action_results[action_id] = { - 'success': True, - 'result': result, - 'timestamp': time.time(), - 'execution_time': time.time() - self.current_action_context['start_time'] - } - - self.lab_logger().debug(f"动作完成: {action_name}") - return result - - except Exception as e: - error_msg = f"动作执行异常: {str(e)}" - logger.error(f"执行动作 {action_id} 失败: {e}\n{traceback.format_exc()}") - return self._create_failed_result(error_msg) - - finally: - # 清理动作上下文 - self.current_action_context = None - - def _create_failed_result(self, error_message: str): - """创建失败结果对象""" - # 这需要根据具体的动作类型来创建相应的结果对象 - # 这里返回一个通用的失败标识 - class FailedResult: - def __init__(self, error_msg): - self.success = False - self.return_info = json.dumps({ - "suc": False, - "error": True, - "error_message": error_msg, - "timestamp": time.time() - }) - - return FailedResult(error_message) - - def __del__(self): - """析构函数 - 清理HTTP服务""" - try: - self._stop_http_service() - self._stop_reporting_service() - except: - pass - - # ============ LIMS辅助方法 ============ - - def _record_step_completion(self, step_data: Dict[str, Any]): - """记录步骤完成事件""" - try: - logger.debug(f"记录步骤完成: {step_data['stepName']} - {step_data['stepId']}") - # 这里可以添加步骤完成的记录逻辑 - # 例如:更新数据库、发送通知等 - except Exception as e: - logger.error(f"记录步骤完成失败: {e}") - - def _record_sample_completion(self, sample_data: Dict[str, Any], completion_type: str): - """记录通量完成事件""" - try: - logger.debug(f"记录通量完成: {sample_data['sampleId']} - {completion_type}") - # 这里可以添加通量完成的记录逻辑 - except Exception as e: - logger.error(f"记录通量完成失败: {e}") - - def _record_sample_start(self, sample_data: Dict[str, Any]): - """记录通量开始事件""" - try: - logger.debug(f"记录通量开始: {sample_data['sampleId']}") - # 这里可以添加通量开始的记录逻辑 - except Exception as e: - logger.error(f"记录通量开始失败: {e}") - - def _record_sample_intake(self, sample_data: Dict[str, Any]): - """记录通量进样事件""" - try: - logger.debug(f"记录通量进样: {sample_data['sampleId']}") - # 这里可以添加通量进样的记录逻辑 - except Exception as e: - logger.error(f"记录通量进样失败: {e}") - - def _record_order_completion(self, order_data: Dict[str, Any], used_materials: List, completion_type: str): - """记录任务完成事件""" - try: - logger.debug(f"记录任务完成: {order_data['orderCode']} - {completion_type}") - # 这里可以添加任务完成的记录逻辑 - # 包括物料使用记录的处理 - except Exception as e: - logger.error(f"记录任务完成失败: {e}") - - def _process_material_usage(self, used_materials: List) -> Dict[str, Any]: - """处理物料使用记录""" - try: - logger.debug(f"处理物料使用记录: {len(used_materials)} 条") - - processed_materials = [] - for material in used_materials: - material_record = { - 'material_id': material.materialId, - 'location_id': material.locationId, - 'type_mode': material.typeMode, - 'used_quantity': material.usedQuantity, - 'processed_time': time.time() - } - processed_materials.append(material_record) - - # 更新库存 - self._update_material_inventory(material) - - return { - 'processed_count': len(processed_materials), - 'materials': processed_materials, - 'success': True - } - - except Exception as e: - logger.error(f"处理物料使用记录失败: {e}") - return { - 'processed_count': 0, - 'materials': [], - 'success': False, - 'error': str(e) - } - - def _update_material_inventory(self, material): - """更新物料库存""" - try: - # 这里可以添加库存更新逻辑 - # 例如:调用库存管理系统API、更新本地缓存等 - logger.debug(f"更新物料库存: {material.materialId} - 使用量: {material.usedQuantity}") - except Exception as e: - logger.error(f"更新物料库存失败: {e}") diff --git a/unilabos/device_comms/workstation_communication.py b/unilabos/device_comms/workstation_communication.py deleted file mode 100644 index 3067b802..00000000 --- a/unilabos/device_comms/workstation_communication.py +++ /dev/null @@ -1,600 +0,0 @@ -""" -工作站通信基类 -Workstation Communication Base Class - -从具体设备驱动中抽取通用通信模式 -""" -import json -import time -import threading -from typing import Dict, Any, Optional, Callable, Union, List -from abc import ABC, abstractmethod -from dataclasses import dataclass -from enum import Enum - -from unilabos.device_comms.modbus_plc.client import TCPClient as ModbusTCPClient -from unilabos.device_comms.modbus_plc.node.modbus import DataType, WorderOrder -from unilabos.utils.log import logger - - -class CommunicationProtocol(Enum): - """通信协议类型""" - MODBUS_TCP = "modbus_tcp" - MODBUS_RTU = "modbus_rtu" - SERIAL = "serial" - ETHERNET = "ethernet" - - -@dataclass -class CommunicationConfig: - """通信配置""" - protocol: CommunicationProtocol - host: str - port: int - timeout: float = 5.0 - retry_count: int = 3 - extra_params: Dict[str, Any] = None - - -class WorkstationCommunicationBase(ABC): - """工作站通信基类 - - 定义工作站通信的标准接口: - 1. 状态查询 - 定期获取设备状态 - 2. 命令下发 - 发送控制指令 - 3. 数据采集 - 收集生产数据 - 4. 紧急控制 - 单点调试控制 - """ - - def __init__(self, communication_config: CommunicationConfig): - self.config = communication_config - self.client = None - self.is_connected = False - self.last_status = {} - self.data_export_thread = None - self.data_export_running = False - - # 状态缓存 - self._status_cache = {} - self._last_update_time = 0 - self._cache_timeout = 1.0 # 缓存1秒 - - self._initialize_communication() - - @abstractmethod - def _initialize_communication(self): - """初始化通信连接""" - pass - - @abstractmethod - def _load_address_mapping(self) -> Dict[str, Any]: - """加载地址映射表""" - pass - - def connect(self) -> bool: - """建立连接""" - try: - if self.config.protocol == CommunicationProtocol.MODBUS_TCP: - self.client = ModbusTCPClient( - addr=self.config.host, - port=self.config.port - ) - self.client.client.connect() - - # 等待连接建立 - count = 100 - while count > 0: - count -= 1 - if self.client.client.is_socket_open(): - self.is_connected = True - logger.info(f"工作站通信连接成功: {self.config.host}:{self.config.port}") - return True - time.sleep(0.1) - - if not self.client.client.is_socket_open(): - raise ConnectionError(f"无法连接到工作站: {self.config.host}:{self.config.port}") - - else: - raise NotImplementedError(f"协议 {self.config.protocol} 暂未实现") - - except Exception as e: - logger.error(f"工作站通信连接失败: {e}") - self.is_connected = False - return False - - def disconnect(self): - """断开连接""" - try: - if self.client and hasattr(self.client, 'client'): - self.client.client.close() - self.is_connected = False - logger.info("工作站通信连接已断开") - except Exception as e: - logger.error(f"断开连接时出错: {e}") - - # ============ 标准工作流接口 ============ - - def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: - """启动工作流""" - try: - if not self.is_connected: - logger.error("通信未连接,无法启动工作流") - return False - - logger.info(f"启动工作流: {workflow_type}, 参数: {parameters}") - return self._execute_start_workflow(workflow_type, parameters or {}) - - except Exception as e: - logger.error(f"启动工作流失败: {e}") - return False - - def stop_workflow(self, emergency: bool = False) -> bool: - """停止工作流""" - try: - if not self.is_connected: - logger.error("通信未连接,无法停止工作流") - return False - - logger.info(f"停止工作流 (紧急: {emergency})") - return self._execute_stop_workflow(emergency) - - except Exception as e: - logger.error(f"停止工作流失败: {e}") - return False - - def get_workflow_status(self) -> Dict[str, Any]: - """获取工作流状态""" - try: - if not self.is_connected: - return {"error": "通信未连接"} - - return self._query_workflow_status() - - except Exception as e: - logger.error(f"查询工作流状态失败: {e}") - return {"error": str(e)} - - # ============ 设备状态查询接口 ============ - - def get_device_status(self, force_refresh: bool = False) -> Dict[str, Any]: - """获取设备状态(带缓存)""" - current_time = time.time() - - if not force_refresh and (current_time - self._last_update_time) < self._cache_timeout: - return self._status_cache - - try: - if not self.is_connected: - return {"error": "通信未连接"} - - status = self._query_device_status() - self._status_cache = status - self._last_update_time = current_time - return status - - except Exception as e: - logger.error(f"查询设备状态失败: {e}") - return {"error": str(e)} - - def get_production_data(self) -> Dict[str, Any]: - """获取生产数据""" - try: - if not self.is_connected: - return {"error": "通信未连接"} - - return self._query_production_data() - - except Exception as e: - logger.error(f"查询生产数据失败: {e}") - return {"error": str(e)} - - # ============ 单点控制接口(调试用)============ - - def write_register(self, register_name: str, value: Any, data_type: DataType = None, word_order: WorderOrder = None) -> bool: - """写寄存器(单点控制)""" - try: - if not self.is_connected: - logger.error("通信未连接,无法写寄存器") - return False - - return self._write_single_register(register_name, value, data_type, word_order) - - except Exception as e: - logger.error(f"写寄存器失败: {e}") - return False - - def read_register(self, register_name: str, count: int = 1, data_type: DataType = None, word_order: WorderOrder = None) -> tuple: - """读寄存器(单点控制)""" - try: - if not self.is_connected: - logger.error("通信未连接,无法读寄存器") - return None, True - - return self._read_single_register(register_name, count, data_type, word_order) - - except Exception as e: - logger.error(f"读寄存器失败: {e}") - return None, True - - # ============ 数据导出功能 ============ - - def start_data_export(self, file_path: str, export_interval: float = 1.0) -> bool: - """开始数据导出""" - try: - if self.data_export_running: - logger.warning("数据导出已在运行") - return False - - self.data_export_file = file_path - self.data_export_interval = export_interval - self.data_export_running = True - - # 创建CSV文件并写入表头 - self._initialize_export_file(file_path) - - # 启动数据收集线程 - self.data_export_thread = threading.Thread(target=self._data_export_worker) - self.data_export_thread.daemon = True - self.data_export_thread.start() - - logger.info(f"数据导出已启动: {file_path}") - return True - - except Exception as e: - logger.error(f"启动数据导出失败: {e}") - return False - - def stop_data_export(self) -> bool: - """停止数据导出""" - try: - if not self.data_export_running: - logger.warning("数据导出未运行") - return False - - self.data_export_running = False - - if self.data_export_thread and self.data_export_thread.is_alive(): - self.data_export_thread.join(timeout=5.0) - - logger.info("数据导出已停止") - return True - - except Exception as e: - logger.error(f"停止数据导出失败: {e}") - return False - - def _data_export_worker(self): - """数据导出工作线程""" - while self.data_export_running: - try: - data = self.get_production_data() - self._append_to_export_file(data) - time.sleep(self.data_export_interval) - except Exception as e: - logger.error(f"数据导出工作线程错误: {e}") - - # ============ 抽象方法 - 子类必须实现 ============ - - @abstractmethod - def _execute_start_workflow(self, workflow_type: str, parameters: Dict[str, Any]) -> bool: - """执行启动工作流命令""" - pass - - @abstractmethod - def _execute_stop_workflow(self, emergency: bool) -> bool: - """执行停止工作流命令""" - pass - - @abstractmethod - def _query_workflow_status(self) -> Dict[str, Any]: - """查询工作流状态""" - pass - - @abstractmethod - def _query_device_status(self) -> Dict[str, Any]: - """查询设备状态""" - pass - - @abstractmethod - def _query_production_data(self) -> Dict[str, Any]: - """查询生产数据""" - pass - - @abstractmethod - def _write_single_register(self, register_name: str, value: Any, data_type: DataType, word_order: WorderOrder) -> bool: - """写单个寄存器""" - pass - - @abstractmethod - def _read_single_register(self, register_name: str, count: int, data_type: DataType, word_order: WorderOrder) -> tuple: - """读单个寄存器""" - pass - - @abstractmethod - def _initialize_export_file(self, file_path: str): - """初始化导出文件""" - pass - - @abstractmethod - def _append_to_export_file(self, data: Dict[str, Any]): - """追加数据到导出文件""" - pass - - -class CoinCellCommunication(WorkstationCommunicationBase): - """纽扣电池组装系统通信类 - - 从 coin_cell_assembly_system 抽取的通信功能 - """ - - def __init__(self, communication_config: CommunicationConfig, csv_path: str = "./coin_cell_assembly.csv"): - self.csv_path = csv_path - super().__init__(communication_config) - - def _initialize_communication(self): - """初始化通信连接""" - # 加载节点映射 - try: - nodes = self.client.load_csv(self.csv_path) if self.client else [] - if self.client: - self.client.register_node_list(nodes) - except Exception as e: - logger.error(f"加载节点映射失败: {e}") - - def _load_address_mapping(self) -> Dict[str, Any]: - """加载地址映射表""" - # 从CSV文件加载地址映射 - return {} - - def _execute_start_workflow(self, workflow_type: str, parameters: Dict[str, Any]) -> bool: - """执行启动工作流命令""" - if workflow_type == "battery_manufacturing": - # 发送电池制造启动命令 - return self._start_battery_manufacturing(parameters) - else: - logger.error(f"不支持的工作流类型: {workflow_type}") - return False - - def _start_battery_manufacturing(self, parameters: Dict[str, Any]) -> bool: - """启动电池制造工作流""" - try: - # 1. 设置参数 - if "electrolyte_num" in parameters: - self.client.use_node('REG_MSG_ELECTROLYTE_NUM').write(parameters["electrolyte_num"]) - - if "electrolyte_volume" in parameters: - self.client.use_node('REG_MSG_ELECTROLYTE_VOLUME').write( - parameters["electrolyte_volume"], - data_type=DataType.FLOAT32, - word_order=WorderOrder.LITTLE - ) - - if "assembly_pressure" in parameters: - self.client.use_node('REG_MSG_ASSEMBLY_PRESSURE').write( - parameters["assembly_pressure"], - data_type=DataType.FLOAT32, - word_order=WorderOrder.LITTLE - ) - - # 2. 发送启动命令 - self.client.use_node('COIL_SYS_START_CMD').write(True) - - # 3. 确认启动成功 - time.sleep(0.5) - status, read_err = self.client.use_node('COIL_SYS_START_STATUS').read(1) - return not read_err and status[0] - - except Exception as e: - logger.error(f"启动电池制造工作流失败: {e}") - return False - - def _execute_stop_workflow(self, emergency: bool) -> bool: - """执行停止工作流命令""" - try: - if emergency: - # 紧急停止 - self.client.use_node('COIL_SYS_RESET_CMD').write(True) - else: - # 正常停止 - self.client.use_node('COIL_SYS_STOP_CMD').write(True) - - time.sleep(0.5) - status, read_err = self.client.use_node('COIL_SYS_STOP_STATUS').read(1) - return not read_err and status[0] - - except Exception as e: - logger.error(f"停止工作流失败: {e}") - return False - - def _query_workflow_status(self) -> Dict[str, Any]: - """查询工作流状态""" - try: - status = {} - - # 读取系统状态 - start_status, _ = self.client.use_node('COIL_SYS_START_STATUS').read(1) - stop_status, _ = self.client.use_node('COIL_SYS_STOP_STATUS').read(1) - auto_status, _ = self.client.use_node('COIL_SYS_AUTO_STATUS').read(1) - init_status, _ = self.client.use_node('COIL_SYS_INIT_STATUS').read(1) - - status.update({ - "is_running": start_status[0] if start_status else False, - "is_stopped": stop_status[0] if stop_status else False, - "is_auto_mode": auto_status[0] if auto_status else False, - "is_initialized": init_status[0] if init_status else False, - }) - - return status - - except Exception as e: - logger.error(f"查询工作流状态失败: {e}") - return {"error": str(e)} - - def _query_device_status(self) -> Dict[str, Any]: - """查询设备状态""" - try: - status = {} - - # 读取位置信息 - x_pos, _ = self.client.use_node('REG_DATA_AXIS_X_POS').read(2, word_order=WorderOrder.LITTLE) - y_pos, _ = self.client.use_node('REG_DATA_AXIS_Y_POS').read(2, word_order=WorderOrder.LITTLE) - z_pos, _ = self.client.use_node('REG_DATA_AXIS_Z_POS').read(2, word_order=WorderOrder.LITTLE) - - # 读取环境数据 - pressure, _ = self.client.use_node('REG_DATA_GLOVE_BOX_PRESSURE').read(2, word_order=WorderOrder.LITTLE) - o2_content, _ = self.client.use_node('REG_DATA_GLOVE_BOX_O2_CONTENT').read(2, word_order=WorderOrder.LITTLE) - water_content, _ = self.client.use_node('REG_DATA_GLOVE_BOX_WATER_CONTENT').read(2, word_order=WorderOrder.LITTLE) - - status.update({ - "axis_position": { - "x": x_pos[0] if x_pos else 0.0, - "y": y_pos[0] if y_pos else 0.0, - "z": z_pos[0] if z_pos else 0.0, - }, - "environment": { - "glove_box_pressure": pressure[0] if pressure else 0.0, - "o2_content": o2_content[0] if o2_content else 0.0, - "water_content": water_content[0] if water_content else 0.0, - } - }) - - return status - - except Exception as e: - logger.error(f"查询设备状态失败: {e}") - return {"error": str(e)} - - def _query_production_data(self) -> Dict[str, Any]: - """查询生产数据""" - try: - data = {} - - # 读取生产统计 - coin_cell_num, _ = self.client.use_node('REG_DATA_ASSEMBLY_COIN_CELL_NUM').read(1) - assembly_time, _ = self.client.use_node('REG_DATA_ASSEMBLY_TIME').read(2, word_order=WorderOrder.LITTLE) - voltage, _ = self.client.use_node('REG_DATA_OPEN_CIRCUIT_VOLTAGE').read(2, word_order=WorderOrder.LITTLE) - - # 读取当前产品信息 - coin_cell_code, _ = self.client.use_node('REG_DATA_COIN_CELL_CODE').read(20) # 假设是字符串 - electrolyte_code, _ = self.client.use_node('REG_DATA_ELECTROLYTE_CODE').read(20) - - data.update({ - "production_count": coin_cell_num[0] if coin_cell_num else 0, - "assembly_time": assembly_time[0] if assembly_time else 0.0, - "open_circuit_voltage": voltage[0] if voltage else 0.0, - "current_battery_code": self._decode_string(coin_cell_code) if coin_cell_code else "", - "current_electrolyte_code": self._decode_string(electrolyte_code) if electrolyte_code else "", - "timestamp": time.time(), - }) - - return data - - except Exception as e: - logger.error(f"查询生产数据失败: {e}") - return {"error": str(e)} - - def _write_single_register(self, register_name: str, value: Any, data_type: DataType = None, word_order: WorderOrder = None) -> bool: - """写单个寄存器""" - try: - kwargs = {"value": value} - if data_type: - kwargs["data_type"] = data_type - if word_order: - kwargs["word_order"] = word_order - - result = self.client.use_node(register_name).write(**kwargs) - return result - - except Exception as e: - logger.error(f"写寄存器 {register_name} 失败: {e}") - return False - - def _read_single_register(self, register_name: str, count: int = 1, data_type: DataType = None, word_order: WorderOrder = None) -> tuple: - """读单个寄存器""" - try: - kwargs = {"count": count} - if data_type: - kwargs["data_type"] = data_type - if word_order: - kwargs["word_order"] = word_order - - value, error = self.client.use_node(register_name).read(**kwargs) - return value, error - - except Exception as e: - logger.error(f"读寄存器 {register_name} 失败: {e}") - return None, True - - def _initialize_export_file(self, file_path: str): - """初始化导出文件""" - import csv - try: - with open(file_path, 'w', newline='', encoding='utf-8') as csvfile: - fieldnames = [ - 'timestamp', 'production_count', 'assembly_time', - 'open_circuit_voltage', 'battery_code', 'electrolyte_code', - 'axis_x', 'axis_y', 'axis_z', 'glove_box_pressure', - 'o2_content', 'water_content' - ] - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - writer.writeheader() - except Exception as e: - logger.error(f"初始化导出文件失败: {e}") - - def _append_to_export_file(self, data: Dict[str, Any]): - """追加数据到导出文件""" - import csv - try: - with open(self.data_export_file, 'a', newline='', encoding='utf-8') as csvfile: - fieldnames = [ - 'timestamp', 'production_count', 'assembly_time', - 'open_circuit_voltage', 'battery_code', 'electrolyte_code', - 'axis_x', 'axis_y', 'axis_z', 'glove_box_pressure', - 'o2_content', 'water_content' - ] - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - - row = { - 'timestamp': data.get('timestamp', time.time()), - 'production_count': data.get('production_count', 0), - 'assembly_time': data.get('assembly_time', 0.0), - 'open_circuit_voltage': data.get('open_circuit_voltage', 0.0), - 'battery_code': data.get('current_battery_code', ''), - 'electrolyte_code': data.get('current_electrolyte_code', ''), - } - - # 添加位置数据 - axis_pos = data.get('axis_position', {}) - row.update({ - 'axis_x': axis_pos.get('x', 0.0), - 'axis_y': axis_pos.get('y', 0.0), - 'axis_z': axis_pos.get('z', 0.0), - }) - - # 添加环境数据 - env = data.get('environment', {}) - row.update({ - 'glove_box_pressure': env.get('glove_box_pressure', 0.0), - 'o2_content': env.get('o2_content', 0.0), - 'water_content': env.get('water_content', 0.0), - }) - - writer.writerow(row) - - except Exception as e: - logger.error(f"追加数据到导出文件失败: {e}") - - def _decode_string(self, data_list: List[int]) -> str: - """将寄存器数据解码为字符串""" - try: - # 假设每个寄存器包含2个字符(16位) - chars = [] - for value in data_list: - if value == 0: - break - chars.append(chr(value & 0xFF)) - if (value >> 8) & 0xFF != 0: - chars.append(chr((value >> 8) & 0xFF)) - return ''.join(chars).rstrip('\x00') - except: - return "" diff --git a/unilabos/devices/work_station/workstation_base.py b/unilabos/devices/work_station/workstation_base.py new file mode 100644 index 00000000..97444ccb --- /dev/null +++ b/unilabos/devices/work_station/workstation_base.py @@ -0,0 +1,460 @@ +""" +工作站基类 +Workstation Base Class + +集成通信、物料管理和工作流的工作站基类 +融合子设备管理、动态工作流注册等高级功能 +""" +import asyncio +import json +import time +import traceback +from typing import Dict, Any, List, Optional, Union, Callable +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + +from rclpy.action import ActionServer, ActionClient +from rclpy.action.server import ServerGoalHandle +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.service import Service +from unilabos_msgs.srv import SerialCommand +from unilabos_msgs.msg import Resource + +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.device_comms.workstation_material_management import MaterialManagementBase +from unilabos.device_comms.workstation_http_service import ( + WorkstationHTTPService, WorkstationReportRequest, MaterialUsage +) +from unilabos.ros.msgs.message_converter import convert_to_ros_msg, convert_from_ros_msg +from unilabos.utils.log import logger +from unilabos.utils.type_check import serialize_result_info + + +class DeviceType(Enum): + """设备类型枚举""" + LOGICAL = "logical" # 逻辑设备 + COMMUNICATION = "communication" # 通信设备 (modbus/opcua/serial) + PROTOCOL = "protocol" # 协议设备 + + +@dataclass +class CommunicationInterface: + """通信接口配置""" + device_id: str # 通信设备ID + read_method: str # 读取方法名 + write_method: str # 写入方法名 + protocol_type: str # 协议类型 (modbus/opcua/serial) + config: Dict[str, Any] # 协议特定配置 + + +@dataclass +class WorkflowStep: + """工作流步骤定义""" + device_id: str + action_name: str + action_kwargs: Dict[str, Any] + depends_on: Optional[List[str]] = None # 依赖的步骤ID + step_id: Optional[str] = None + timeout: Optional[float] = None + retry_count: int = 0 + + +@dataclass +class WorkflowDefinition: + """工作流定义""" + name: str + description: str + steps: List[WorkflowStep] + input_schema: Dict[str, Any] + output_schema: Dict[str, Any] + metadata: Dict[str, Any] + + +class WorkflowStatus(Enum): + """工作流状态""" + IDLE = "idle" + INITIALIZING = "initializing" + RUNNING = "running" + PAUSED = "paused" + STOPPING = "stopping" + STOPPED = "stopped" + ERROR = "error" + COMPLETED = "completed" + + +@dataclass +class WorkflowInfo: + """工作流信息""" + name: str + description: str + estimated_duration: float # 预估持续时间(秒) + required_materials: List[str] # 所需物料类型 + output_product: str # 输出产品类型 + parameters_schema: Dict[str, Any] # 参数架构 + + +@dataclass +class CommunicationConfig: + """通信配置""" + protocol: str + host: str + port: int + timeout: float = 5.0 + retry_count: int = 3 + extra_params: Dict[str, Any] = None + + +class WorkstationBase(ABC): + """工作站基类 + + 提供工作站的核心功能: + 1. 物料管理 - 基于PyLabRobot的物料系统 + 2. 工作流控制 - 支持动态注册和静态预定义工作流 + 3. 状态监控 - 设备状态和生产数据监控 + 4. HTTP服务 - 接收外部报送和状态查询 + + 注意:子设备管理和通信转发功能已移入ROS2ProtocolNode + """ + + def __init__( + self, + device_id: str, + deck_config: Optional[Dict[str, Any]] = None, + http_service_config: Optional[Dict[str, Any]] = None, # HTTP服务配置 + *args, + **kwargs, + ): + # 保存工作站基本配置 + self.device_id = device_id + self.deck_config = deck_config or {"size_x": 1000.0, "size_y": 1000.0, "size_z": 500.0} + + # HTTP服务配置 - 现在专门用于报送接收 + self.http_service_config = http_service_config or { + "enabled": True, + "host": "127.0.0.1", + "port": 8081 # 默认使用8081端口作为报送接收服务 + } + + # 错误处理和动作追踪 + self.current_action_context = None # 当前正在执行的动作上下文 + self.error_history = [] # 错误历史记录 + self.action_results = {} # 动作结果缓存 + + # 工作流状态 - 支持静态和动态工作流 + self.current_workflow_status = WorkflowStatus.IDLE + self.current_workflow_info = None + self.workflow_start_time = None + self.workflow_parameters = {} + + # 支持的工作流(静态预定义) + self.supported_workflows: Dict[str, WorkflowInfo] = {} + + # 动态注册的工作流 + self.registered_workflows: Dict[str, WorkflowDefinition] = {} + + # 初始化工作站模块 + self.material_management: MaterialManagementBase = self._create_material_management_module() + + # 注册支持的工作流 + self._register_supported_workflows() + + # 启动HTTP报送接收服务 + self.http_service = None + self._start_http_service() + + logger.info(f"工作站基类 {device_id} 初始化完成") + + @abstractmethod + def _create_material_management_module(self) -> MaterialManagementBase: + """创建物料管理模块 - 子类必须实现""" + pass + + @abstractmethod + def _register_supported_workflows(self): + """注册支持的工作流 - 子类必须实现""" + pass + + def _create_workstation_services(self): + """创建工作站ROS服务""" + def _start_http_service(self): + """启动HTTP报送接收服务""" + if self.http_service_config.get("enabled", True): + try: + self.http_service = WorkstationHTTPService( + host=self.http_service_config.get("host", "127.0.0.1"), + port=self.http_service_config.get("port", 8081), + workstation_handler=self + ) + logger.info(f"HTTP报送接收服务已启动: {self.http_service_config['host']}:{self.http_service_config['port']}") + except Exception as e: + logger.error(f"启动HTTP报送接收服务失败: {e}") + else: + logger.info("HTTP报送接收服务已禁用") + + def _stop_http_service(self): + """停止HTTP报送接收服务""" + if self.http_service: + try: + self.http_service.stop() + logger.info("HTTP报送接收服务已停止") + except Exception as e: + logger.error(f"停止HTTP报送接收服务失败: {e}") + + # ============ 核心业务方法 ============ + + def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: + """启动工作流 - 业务逻辑层""" + try: + if self.current_workflow_status != WorkflowStatus.IDLE: + logger.warning(f"工作流 {workflow_type} 启动失败:当前状态为 {self.current_workflow_status}") + return False + + # 设置工作流状态 + self.current_workflow_status = WorkflowStatus.INITIALIZING + self.workflow_parameters = parameters or {} + self.workflow_start_time = time.time() + + # 执行具体的工作流启动逻辑 + success = self._execute_start_workflow(workflow_type, parameters or {}) + + if success: + self.current_workflow_status = WorkflowStatus.RUNNING + logger.info(f"工作流 {workflow_type} 启动成功") + else: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作流 {workflow_type} 启动失败") + + return success + + except Exception as e: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"启动工作流失败: {e}") + return False + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流 - 业务逻辑层""" + try: + if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: + logger.warning("没有正在运行的工作流") + return True + + self.current_workflow_status = WorkflowStatus.STOPPING + + # 执行具体的工作流停止逻辑 + success = self._execute_stop_workflow(emergency) + + if success: + self.current_workflow_status = WorkflowStatus.STOPPED + logger.info(f"工作流停止成功 (紧急: {emergency})") + else: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"工作流停止失败") + + return success + + except Exception as e: + self.current_workflow_status = WorkflowStatus.ERROR + logger.error(f"停止工作流失败: {e}") + return False + + # ============ 抽象方法 - 子类必须实现具体的工作流控制 ============ + + @abstractmethod + def _execute_start_workflow(self, workflow_type: str, parameters: Dict[str, Any]) -> bool: + """执行启动工作流的具体逻辑 - 子类实现""" + pass + + @abstractmethod + def _execute_stop_workflow(self, emergency: bool) -> bool: + """执行停止工作流的具体逻辑 - 子类实现""" + pass + + # ============ 状态属性 ============ + + @property + def workflow_status(self) -> WorkflowStatus: + """获取当前工作流状态""" + return self.current_workflow_status + + @property + def is_busy(self) -> bool: + """检查工作站是否忙碌""" + return self.current_workflow_status in [ + WorkflowStatus.INITIALIZING, + WorkflowStatus.RUNNING, + WorkflowStatus.STOPPING + ] + + @property + def workflow_runtime(self) -> float: + """获取工作流运行时间(秒)""" + if self.workflow_start_time is None: + return 0.0 + return time.time() - self.workflow_start_time + + @property + def error_count(self) -> int: + """获取错误计数""" + return len(self.error_history) + + @property + def last_error(self) -> Optional[Dict[str, Any]]: + """获取最后一个错误""" + return self.error_history[-1] if self.error_history else None + + def _start_http_service(self): + """启动HTTP报送接收服务""" + try: + if not self.http_service_config.get("enabled", True): + logger.info("HTTP报送接收服务已禁用") + return + + host = self.http_service_config.get("host", "127.0.0.1") + port = self.http_service_config.get("port", 8081) + + self.http_service = WorkstationHTTPService( + workstation_handler=self, + host=host, + port=port + ) + + logger.info(f"工作站 {self.device_id} HTTP报送接收服务启动成功: {host}:{port}") + + except Exception as e: + logger.error(f"启动HTTP报送接收服务失败: {e}") + self.http_service = None + + def _stop_http_service(self): + """停止HTTP报送接收服务""" + try: + if self.http_service: + self.http_service.stop() + self.http_service = None + logger.info("HTTP报送接收服务已停止") + except Exception as e: + logger.error(f"停止HTTP报送接收服务失败: {e}") + logger.error(f"停止HTTP报送接收服务失败: {e}") + + # ============ 报送处理方法 ============ + + # ============ 报送处理方法 ============ + + def process_material_change_report(self, report) -> Dict[str, Any]: + """处理物料变更报送""" + try: + logger.info(f"处理物料变更报送: {report.workstation_id} -> {report.resource_id} ({report.change_type})") + + result = { + 'processed': True, + 'resource_id': report.resource_id, + 'change_type': report.change_type, + 'timestamp': time.time() + } + + # 更新本地物料管理系统 + if hasattr(self, 'material_management'): + try: + self.material_management.sync_external_material_change(report) + except Exception as e: + logger.warning(f"同步物料变更到本地管理系统失败: {e}") + + return result + + except Exception as e: + logger.error(f"处理物料变更报送失败: {e}") + return {'processed': False, 'error': str(e)} + + def process_step_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: + """处理步骤完成报送(统一LIMS协议规范)""" + try: + data = request.data + logger.info(f"处理步骤完成报送: {data['orderCode']} - {data['stepName']}") + + result = { + 'processed': True, + 'order_code': data['orderCode'], + 'step_id': data['stepId'], + 'timestamp': time.time() + } + + return result + + except Exception as e: + logger.error(f"处理步骤完成报送失败: {e}") + return {'processed': False, 'error': str(e)} + + def process_sample_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: + """处理样品完成报送""" + try: + data = request.data + logger.info(f"处理样品完成报送: {data['sampleId']}") + + result = { + 'processed': True, + 'sample_id': data['sampleId'], + 'timestamp': time.time() + } + + return result + + except Exception as e: + logger.error(f"处理样品完成报送失败: {e}") + return {'processed': False, 'error': str(e)} + + def process_order_finish_report(self, request: WorkstationReportRequest, used_materials: List[MaterialUsage]) -> Dict[str, Any]: + """处理订单完成报送""" + try: + data = request.data + logger.info(f"处理订单完成报送: {data['orderCode']}") + + result = { + 'processed': True, + 'order_code': data['orderCode'], + 'used_materials': len(used_materials), + 'timestamp': time.time() + } + + return result + + except Exception as e: + logger.error(f"处理订单完成报送失败: {e}") + return {'processed': False, 'error': str(e)} + + def handle_external_error(self, error_request): + """处理外部错误报告""" + try: + logger.error(f"收到外部错误报告: {error_request}") + + # 记录错误 + error_record = { + 'timestamp': time.time(), + 'error_type': error_request.get('error_type', 'unknown'), + 'error_message': error_request.get('message', ''), + 'source': error_request.get('source', 'external'), + 'context': error_request.get('context', {}) + } + + self.error_history.append(error_record) + + # 处理紧急停止情况 + if error_request.get('emergency_stop', False): + self._trigger_emergency_stop(error_record['error_message']) + + return {'processed': True, 'error_id': len(self.error_history)} + + except Exception as e: + logger.error(f"处理外部错误失败: {e}") + return {'processed': False, 'error': str(e)} + + def _trigger_emergency_stop(self, reason: str): + """触发紧急停止""" + logger.critical(f"触发紧急停止: {reason}") + self.stop_workflow(emergency=True) + + def __del__(self): + """清理资源""" + try: + self._stop_http_service() + except: + pass diff --git a/unilabos/device_comms/workstation_http_service.py b/unilabos/devices/work_station/workstation_http_service.py similarity index 100% rename from unilabos/device_comms/workstation_http_service.py rename to unilabos/devices/work_station/workstation_http_service.py diff --git a/unilabos/device_comms/workstation_material_management.py b/unilabos/devices/work_station/workstation_material_management.py similarity index 100% rename from unilabos/device_comms/workstation_material_management.py rename to unilabos/devices/work_station/workstation_material_management.py diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 872bc62e..612acb12 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -947,6 +947,12 @@ class ROS2DeviceNode: # TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建 # 创建设备类实例 + # 判断是否包含设备子节点,决定是否使用ROS2ProtocolNode + has_device_children = any( + child_config.get("type", "device") == "device" + for child_config in children.values() + ) + if use_pylabrobot_creator: # 先对pylabrobot的子资源进行加载,不然subclass无法认出 # 在下方对于加载Deck等Resource要手动import @@ -956,10 +962,18 @@ class ROS2DeviceNode: ) else: from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode + from unilabos.device_comms.workstation_base import WorkstationBase - if issubclass(self._driver_class, ROS2ProtocolNode): # 是ProtocolNode的子节点,就要调用ProtocolNodeCreator + # 检查是否是WorkstationBase的子类且包含设备子节点 + if issubclass(self._driver_class, WorkstationBase) and has_device_children: + # WorkstationBase + 设备子节点 -> 使用ProtocolNode作为ros_instance + self._use_protocol_node_ros = True + self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) + elif issubclass(self._driver_class, ROS2ProtocolNode): # 是ProtocolNode的子节点,就要调用ProtocolNodeCreator + self._use_protocol_node_ros = False self._driver_creator = ProtocolNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) else: + self._use_protocol_node_ros = False self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) if driver_is_ros: @@ -973,6 +987,35 @@ class ROS2DeviceNode: # 创建ROS2节点 if driver_is_ros: self._ros_node = self._driver_instance # type: ignore + elif hasattr(self, '_use_protocol_node_ros') and self._use_protocol_node_ros: + # WorkstationBase + 设备子节点 -> 创建ROS2ProtocolNode作为ros_instance + from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode + + # 从children提取设备协议类型 + protocol_types = set() + for child_id, child_config in children.items(): + if child_config.get("type", "device") == "device": + # 检查设备配置中的协议类型 + if "protocol_type" in child_config: + if isinstance(child_config["protocol_type"], list): + protocol_types.update(child_config["protocol_type"]) + else: + protocol_types.add(child_config["protocol_type"]) + + # 如果没有明确的协议类型,使用默认值 + if not protocol_types: + protocol_types = ["default_protocol"] + + self._ros_node = ROS2ProtocolNode( + device_id=device_id, + children=children, + protocol_type=list(protocol_types), + resource_tracker=self.resource_tracker, + workstation_config={ + 'workstation_instance': self._driver_instance, + 'deck_config': getattr(self._driver_instance, 'deck_config', {}), + } + ) else: self._ros_node = BaseROS2DeviceNode( driver_instance=self._driver_instance, diff --git a/unilabos/ros/nodes/presets/protocol_node.py b/unilabos/ros/nodes/presets/protocol_node.py index c0ed6849..ad835ee1 100644 --- a/unilabos/ros/nodes/presets/protocol_node.py +++ b/unilabos/ros/nodes/presets/protocol_node.py @@ -2,7 +2,7 @@ import json import time import traceback from pprint import pprint, saferepr, pformat -from typing import Union +from typing import Union, Dict, Any import rclpy from rosidl_runtime_py import message_to_ordereddict @@ -53,6 +53,10 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): self.children = children self.workstation_config = workstation_config or {} # 新增:保存工作站配置 self.communication_interfaces = self.workstation_config.get('communication_interfaces', {}) # 从工作站配置获取通信接口 + + # 新增:获取工作站实例(如果存在) + self.workstation_instance = self.workstation_config.get('workstation_instance') + self._busy = False self.sub_devices = {} self._goals = {} @@ -60,8 +64,11 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): self._action_clients = {} # 初始化基类,让基类处理常规动作 + # 如果有工作站实例,使用工作站实例作为driver_instance + driver_instance = self.workstation_instance if self.workstation_instance else self + super().__init__( - driver_instance=self, + driver_instance=driver_instance, device_id=device_id, status_types={}, action_value_mappings=self.protocol_action_mappings, @@ -77,8 +84,56 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): # 设置硬件接口代理 self._setup_hardware_proxies() + # 新增:如果有工作站实例,建立双向引用 + if self.workstation_instance: + self.workstation_instance._protocol_node = self + self._setup_workstation_method_proxies() + self.lab_logger().info(f"ROS2ProtocolNode {device_id} 与工作站实例 {type(self.workstation_instance).__name__} 关联") + self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}") + def _setup_workstation_method_proxies(self): + """设置工作站方法代理""" + if not self.workstation_instance: + return + + # 代理工作站的核心方法 + workstation_methods = [ + 'start_workflow', 'stop_workflow', 'workflow_status', 'is_busy', + 'process_material_change_report', 'process_step_finish_report', + 'process_sample_finish_report', 'process_order_finish_report', + 'handle_external_error' + ] + + for method_name in workstation_methods: + if hasattr(self.workstation_instance, method_name): + # 创建代理方法 + setattr(self, method_name, getattr(self.workstation_instance, method_name)) + self.lab_logger().debug(f"代理工作站方法: {method_name}") + + # ============ 工作站方法代理 ============ + + def get_workstation_status(self) -> Dict[str, Any]: + """获取工作站状态""" + if self.workstation_instance: + return { + 'workflow_status': str(self.workstation_instance.workflow_status.value), + 'is_busy': self.workstation_instance.is_busy, + 'workflow_runtime': self.workstation_instance.workflow_runtime, + 'error_count': self.workstation_instance.error_count, + 'last_error': self.workstation_instance.last_error + } + return {'status': 'no_workstation_instance'} + + def delegate_to_workstation(self, method_name: str, *args, **kwargs): + """委托方法调用给工作站实例""" + if self.workstation_instance and hasattr(self.workstation_instance, method_name): + method = getattr(self.workstation_instance, method_name) + return method(*args, **kwargs) + else: + self.lab_logger().warning(f"工作站实例不存在或没有方法: {method_name}") + return None + def _initialize_child_devices(self): """初始化子设备 - 重构为更清晰的方法""" # 设备分类字典 - 统一管理 From 5ec8a57a1f6038e28f75e63cfc7e7ba499bd5594 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Mon, 25 Aug 2025 22:09:37 +0800 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20ProtocolNode=E2=86=92Workstat?= =?UTF-8?q?ionNode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../02-topology-and-chemputer-compile.md | 2 +- .../workstation_architecture.md | 12 +- unilabos/device_comms/modbus_plc/client.py | 4 +- .../modbus_plc/{node => }/modbus.py | 0 .../device_comms/modbus_plc/node/__init__.py | 0 .../device_comms/modbus_plc/test/client.py | 2 +- .../device_comms/modbus_plc/test/node_test.py | 2 +- .../modbus_plc/test/test_workflow.py | 2 +- .../devices/workstation/workflow_executors.py | 649 ++++++++++++++++++ .../workstation_base.py | 331 ++++----- .../workstation_http_service.py | 0 .../workstation_material_management.py | 0 unilabos/registry/devices/work_station.yaml | 2 +- unilabos/ros/msgs/message_converter.py | 2 + unilabos/ros/nodes/base_device_node.py | 73 +- unilabos/ros/nodes/presets/host_node.py | 2 +- unilabos/ros/nodes/presets/protocol_node.py | 347 ++++++---- unilabos/ros/nodes/presets/workstation.py | 4 +- unilabos/ros/utils/driver_creator.py | 20 +- 19 files changed, 1089 insertions(+), 365 deletions(-) rename unilabos/device_comms/modbus_plc/{node => }/modbus.py (100%) delete mode 100644 unilabos/device_comms/modbus_plc/node/__init__.py create mode 100644 unilabos/devices/workstation/workflow_executors.py rename unilabos/devices/{work_station => workstation}/workstation_base.py (56%) rename unilabos/devices/{work_station => workstation}/workstation_http_service.py (100%) rename unilabos/devices/{work_station => workstation}/workstation_material_management.py (100%) diff --git a/docs/concepts/02-topology-and-chemputer-compile.md b/docs/concepts/02-topology-and-chemputer-compile.md index 9e40bad9..3fc18e23 100644 --- a/docs/concepts/02-topology-and-chemputer-compile.md +++ b/docs/concepts/02-topology-and-chemputer-compile.md @@ -19,7 +19,7 @@ Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包 对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。** 而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。 -于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下: +于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`WorkstationNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下: ![topology](image/02-topology-and-chemputer-compile/topology.png) diff --git a/docs/developer_guide/workstation_architecture.md b/docs/developer_guide/workstation_architecture.md index 3bdb52e2..f9d113e2 100644 --- a/docs/developer_guide/workstation_architecture.md +++ b/docs/developer_guide/workstation_architecture.md @@ -6,7 +6,7 @@ graph TB subgraph "工作站基础架构" WB[WorkstationBase] - WB --> |继承| RPN[ROS2ProtocolNode] + WB --> |继承| RPN[ROS2WorkstationNode] WB --> |组合| WCB[WorkstationCommunicationBase] WB --> |组合| MMB[MaterialManagementBase] WB --> |组合| WHS[WorkstationHTTPService] @@ -73,7 +73,7 @@ classDiagram +get_device_status() } - class ROS2ProtocolNode { + class ROS2WorkstationNode { +sub_devices: Dict +protocol_names: List +execute_single_action() @@ -131,7 +131,7 @@ classDiagram +_register_supported_workflows() } - WorkstationBase --|> ROS2ProtocolNode + WorkstationBase --|> ROS2WorkstationNode WorkstationBase *-- WorkstationCommunicationBase WorkstationBase *-- MaterialManagementBase WorkstationBase *-- WorkstationHTTPService @@ -155,10 +155,10 @@ sequenceDiagram participant COMM as CommunicationModule participant MAT as MaterialManager participant HTTP as HTTPService - participant ROS as ROS2ProtocolNode + participant ROS as ROS2WorkstationNode APP->>WS: 创建工作站实例 - WS->>ROS: 初始化ROS2ProtocolNode + WS->>ROS: 初始化ROS2WorkstationNode ROS->>ROS: 初始化子设备 ROS->>ROS: 设置硬件接口代理 @@ -191,7 +191,7 @@ sequenceDiagram participant WS as WorkstationBase participant COMM as CommunicationModule participant MAT as MaterialManager - participant ROS as ROS2ProtocolNode + participant ROS as ROS2WorkstationNode participant DEV as SubDevice EXT->>WS: start_workflow(type, params) diff --git a/unilabos/device_comms/modbus_plc/client.py b/unilabos/device_comms/modbus_plc/client.py index a7da3aff..c239b9d5 100644 --- a/unilabos/device_comms/modbus_plc/client.py +++ b/unilabos/device_comms/modbus_plc/client.py @@ -8,8 +8,8 @@ from pymodbus.client import ModbusSerialClient, ModbusTcpClient from pymodbus.framer import FramerType from typing import TypedDict -from unilabos.device_comms.modbus_plc.node.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder -from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase +from unilabos.device_comms.modbus_plc.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder +from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase from unilabos.device_comms.universal_driver import UniversalDriver from unilabos.utils.log import logger import pandas as pd diff --git a/unilabos/device_comms/modbus_plc/node/modbus.py b/unilabos/device_comms/modbus_plc/modbus.py similarity index 100% rename from unilabos/device_comms/modbus_plc/node/modbus.py rename to unilabos/device_comms/modbus_plc/modbus.py diff --git a/unilabos/device_comms/modbus_plc/node/__init__.py b/unilabos/device_comms/modbus_plc/node/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/unilabos/device_comms/modbus_plc/test/client.py b/unilabos/device_comms/modbus_plc/test/client.py index 070e180b..416f8493 100644 --- a/unilabos/device_comms/modbus_plc/test/client.py +++ b/unilabos/device_comms/modbus_plc/test/client.py @@ -1,6 +1,6 @@ import time from pymodbus.client import ModbusTcpClient -from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister +from unilabos.device_comms.modbus_plc.modbus import Coil, HoldRegister from pymodbus.payload import BinaryPayloadDecoder from pymodbus.constants import Endian diff --git a/unilabos/device_comms/modbus_plc/test/node_test.py b/unilabos/device_comms/modbus_plc/test/node_test.py index d2fa2d75..d36f28d7 100644 --- a/unilabos/device_comms/modbus_plc/test/node_test.py +++ b/unilabos/device_comms/modbus_plc/test/node_test.py @@ -1,6 +1,6 @@ # coding=utf-8 from pymodbus.client import ModbusTcpClient -from unilabos.device_comms.modbus_plc.node.modbus import Coil +from unilabos.device_comms.modbus_plc.modbus import Coil import time diff --git a/unilabos/device_comms/modbus_plc/test/test_workflow.py b/unilabos/device_comms/modbus_plc/test/test_workflow.py index e418a3c5..8f764d6a 100644 --- a/unilabos/device_comms/modbus_plc/test/test_workflow.py +++ b/unilabos/device_comms/modbus_plc/test/test_workflow.py @@ -1,7 +1,7 @@ import time from typing import Callable from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusWorkflow, WorkflowAction, load_csv -from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase +from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase ############ 第一种写法 ############## diff --git a/unilabos/devices/workstation/workflow_executors.py b/unilabos/devices/workstation/workflow_executors.py new file mode 100644 index 00000000..93f00ae4 --- /dev/null +++ b/unilabos/devices/workstation/workflow_executors.py @@ -0,0 +1,649 @@ +""" +工作流执行器模块 +Workflow Executors Module + +基于单一硬件接口的工作流执行器实现 +支持Modbus、HTTP、PyLabRobot和代理模式 +""" +import time +import json +import asyncio +from typing import Dict, Any, List, Optional, TYPE_CHECKING +from abc import ABC, abstractmethod + +if TYPE_CHECKING: + from unilabos.devices.work_station.workstation_base import WorkstationBase + +from unilabos.utils.log import logger + + +class WorkflowExecutor(ABC): + """工作流执行器基类 - 基于单一硬件接口""" + + def __init__(self, workstation: 'WorkstationBase'): + self.workstation = workstation + self.hardware_interface = workstation.hardware_interface + self.material_management = workstation.material_management + + @abstractmethod + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流""" + pass + + @abstractmethod + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + pass + + def call_device(self, method: str, *args, **kwargs) -> Any: + """调用设备方法的统一接口""" + return self.workstation.call_device_method(method, *args, **kwargs) + + def get_device_status(self) -> Dict[str, Any]: + """获取设备状态""" + return self.workstation.get_device_status() + + +class ModbusWorkflowExecutor(WorkflowExecutor): + """Modbus工作流执行器 - 适配 coin_cell_assembly_system""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证Modbus接口 + if not (hasattr(self.hardware_interface, 'write_register') and + hasattr(self.hardware_interface, 'read_register')): + raise RuntimeError("工作站硬件接口不是有效的Modbus客户端") + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行Modbus工作流""" + if workflow_name == "battery_manufacturing": + return self._execute_battery_manufacturing(parameters) + elif workflow_name == "material_loading": + return self._execute_material_loading(parameters) + elif workflow_name == "quality_check": + return self._execute_quality_check(parameters) + else: + logger.warning(f"不支持的Modbus工作流: {workflow_name}") + return False + + def _execute_battery_manufacturing(self, parameters: Dict[str, Any]) -> bool: + """执行电池制造工作流""" + try: + # 1. 物料准备检查 + available_slot = self._find_available_press_slot() + if not available_slot: + raise RuntimeError("没有可用的压制槽") + + logger.info(f"找到可用压制槽: {available_slot}") + + # 2. 设置工艺参数(直接调用Modbus接口) + if "electrolyte_num" in parameters: + self.hardware_interface.write_register('REG_MSG_ELECTROLYTE_NUM', parameters["electrolyte_num"]) + logger.info(f"设置电解液编号: {parameters['electrolyte_num']}") + + if "electrolyte_volume" in parameters: + self.hardware_interface.write_register('REG_MSG_ELECTROLYTE_VOLUME', + parameters["electrolyte_volume"], + data_type="FLOAT32") + logger.info(f"设置电解液体积: {parameters['electrolyte_volume']}") + + if "assembly_pressure" in parameters: + self.hardware_interface.write_register('REG_MSG_ASSEMBLY_PRESSURE', + parameters["assembly_pressure"], + data_type="FLOAT32") + logger.info(f"设置装配压力: {parameters['assembly_pressure']}") + + # 3. 启动制造流程 + self.hardware_interface.write_register('COIL_SYS_START_CMD', True) + logger.info("启动电池制造流程") + + # 4. 确认启动成功 + time.sleep(0.5) + status = self.hardware_interface.read_register('COIL_SYS_START_STATUS', count=1) + success = status[0] if status else False + + if success: + logger.info(f"电池制造工作流启动成功,参数: {parameters}") + else: + logger.error("电池制造工作流启动失败") + + return success + + except Exception as e: + logger.error(f"执行电池制造工作流失败: {e}") + return False + + def _execute_material_loading(self, parameters: Dict[str, Any]) -> bool: + """执行物料装载工作流""" + try: + material_type = parameters.get('material_type', 'cathode') + position = parameters.get('position', 'A1') + + logger.info(f"开始物料装载: {material_type} -> {position}") + + # 设置物料类型和位置 + self.hardware_interface.write_register('REG_MATERIAL_TYPE', material_type) + self.hardware_interface.write_register('REG_MATERIAL_POSITION', position) + + # 启动装载 + self.hardware_interface.write_register('COIL_LOAD_START', True) + + # 等待装载完成 + timeout = parameters.get('timeout', 30) + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.hardware_interface.read_register('COIL_LOAD_COMPLETE', count=1) + if status and status[0]: + logger.info(f"物料装载完成: {material_type} -> {position}") + return True + time.sleep(0.5) + + logger.error(f"物料装载超时: {material_type} -> {position}") + return False + + except Exception as e: + logger.error(f"执行物料装载失败: {e}") + return False + + def _execute_quality_check(self, parameters: Dict[str, Any]) -> bool: + """执行质量检测工作流""" + try: + check_type = parameters.get('check_type', 'dimensional') + + logger.info(f"开始质量检测: {check_type}") + + # 启动质量检测 + self.hardware_interface.write_register('REG_QC_TYPE', check_type) + self.hardware_interface.write_register('COIL_QC_START', True) + + # 等待检测完成 + timeout = parameters.get('timeout', 60) + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.hardware_interface.read_register('COIL_QC_COMPLETE', count=1) + if status and status[0]: + # 读取检测结果 + result = self.hardware_interface.read_register('REG_QC_RESULT', count=1) + passed = result[0] if result else False + + if passed: + logger.info(f"质量检测通过: {check_type}") + return True + else: + logger.warning(f"质量检测失败: {check_type}") + return False + + time.sleep(1.0) + + logger.error(f"质量检测超时: {check_type}") + return False + + except Exception as e: + logger.error(f"执行质量检测失败: {e}") + return False + + def _find_available_press_slot(self) -> Optional[str]: + """查找可用压制槽""" + try: + press_slots = self.material_management.find_by_category("battery_press_slot") + for slot in press_slots: + if hasattr(slot, 'has_battery') and not slot.has_battery(): + return slot.name + return None + except: + # 如果物料管理系统不可用,返回默认槽位 + return "A1" + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if emergency: + self.hardware_interface.write_register('COIL_SYS_RESET_CMD', True) + logger.warning("执行紧急停止") + else: + self.hardware_interface.write_register('COIL_SYS_STOP_CMD', True) + logger.info("执行正常停止") + + time.sleep(0.5) + status = self.hardware_interface.read_register('COIL_SYS_STOP_STATUS', count=1) + return status[0] if status else False + + except Exception as e: + logger.error(f"停止Modbus工作流失败: {e}") + return False + + +class HttpWorkflowExecutor(WorkflowExecutor): + """HTTP工作流执行器 - 适配 reaction_station_bioyong""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证HTTP接口 + if not (hasattr(self.hardware_interface, 'post') or + hasattr(self.hardware_interface, 'get')): + raise RuntimeError("工作站硬件接口不是有效的HTTP客户端") + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行HTTP工作流""" + try: + if workflow_name == "reaction_synthesis": + return self._execute_reaction_synthesis(parameters) + elif workflow_name == "liquid_feeding": + return self._execute_liquid_feeding(parameters) + elif workflow_name == "temperature_control": + return self._execute_temperature_control(parameters) + else: + logger.warning(f"不支持的HTTP工作流: {workflow_name}") + return False + + except Exception as e: + logger.error(f"执行HTTP工作流失败: {e}") + return False + + def _execute_reaction_synthesis(self, parameters: Dict[str, Any]) -> bool: + """执行反应合成工作流""" + try: + # 1. 设置工作流序列 + sequence = self._build_reaction_sequence(parameters) + self._call_rpc_method('set_workflow_sequence', json.dumps(sequence)) + + # 2. 设置反应参数 + if parameters.get('temperature'): + self._call_rpc_method('set_temperature', parameters['temperature']) + + if parameters.get('pressure'): + self._call_rpc_method('set_pressure', parameters['pressure']) + + if parameters.get('stirring_speed'): + self._call_rpc_method('set_stirring_speed', parameters['stirring_speed']) + + # 3. 执行工作流 + result = self._call_rpc_method('execute_current_sequence', { + "task_name": "reaction_synthesis" + }) + + success = result.get('success', False) + if success: + logger.info("反应合成工作流执行成功") + else: + logger.error(f"反应合成工作流执行失败: {result.get('error', '未知错误')}") + + return success + + except Exception as e: + logger.error(f"执行反应合成工作流失败: {e}") + return False + + def _execute_liquid_feeding(self, parameters: Dict[str, Any]) -> bool: + """执行液体投料工作流""" + try: + reagents = parameters.get('reagents', []) + volumes = parameters.get('volumes', []) + + if len(reagents) != len(volumes): + raise ValueError("试剂列表和体积列表长度不匹配") + + # 执行投料序列 + for reagent, volume in zip(reagents, volumes): + result = self._call_rpc_method('feed_liquid', { + 'reagent': reagent, + 'volume': volume + }) + + if not result.get('success', False): + logger.error(f"投料失败: {reagent} {volume}mL") + return False + + logger.info(f"投料成功: {reagent} {volume}mL") + + return True + + except Exception as e: + logger.error(f"执行液体投料失败: {e}") + return False + + def _execute_temperature_control(self, parameters: Dict[str, Any]) -> bool: + """执行温度控制工作流""" + try: + target_temp = parameters.get('temperature', 25) + hold_time = parameters.get('hold_time', 300) # 秒 + + # 设置目标温度 + result = self._call_rpc_method('set_temperature', target_temp) + if not result.get('success', False): + logger.error(f"设置温度失败: {target_temp}°C") + return False + + # 等待温度稳定 + logger.info(f"等待温度稳定到 {target_temp}°C") + + # 保持温度指定时间 + if hold_time > 0: + logger.info(f"保持温度 {hold_time} 秒") + time.sleep(hold_time) + + return True + + except Exception as e: + logger.error(f"执行温度控制失败: {e}") + return False + + def _build_reaction_sequence(self, parameters: Dict[str, Any]) -> List[str]: + """构建反应合成工作流序列""" + sequence = [] + + # 添加预处理步骤 + if parameters.get('purge_with_inert'): + sequence.append("purge_inert_gas") + + # 添加温度设置 + if parameters.get('temperature'): + sequence.append(f"set_temperature_{parameters['temperature']}") + + # 添加压力设置 + if parameters.get('pressure'): + sequence.append(f"set_pressure_{parameters['pressure']}") + + # 添加搅拌设置 + if parameters.get('stirring_speed'): + sequence.append(f"set_stirring_{parameters['stirring_speed']}") + + # 添加反应步骤 + sequence.extend([ + "start_reaction", + "monitor_progress", + "complete_reaction" + ]) + + # 添加后处理步骤 + if parameters.get('cooling_required'): + sequence.append("cool_down") + + return sequence + + def _call_rpc_method(self, method: str, params: Any = None) -> Dict[str, Any]: + """调用RPC方法""" + try: + if hasattr(self.hardware_interface, method): + # 直接方法调用 + if isinstance(params, dict): + params = json.dumps(params) + elif params is None: + params = "" + return getattr(self.hardware_interface, method)(params) + else: + # HTTP请求调用 + if hasattr(self.hardware_interface, 'post'): + response = self.hardware_interface.post(f"/api/{method}", json=params) + return response.json() + else: + raise AttributeError(f"HTTP接口不支持方法: {method}") + except Exception as e: + logger.error(f"调用RPC方法失败 {method}: {e}") + return {'success': False, 'error': str(e)} + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if emergency: + result = self._call_rpc_method('scheduler_reset') + else: + result = self._call_rpc_method('scheduler_stop') + + return result.get('success', False) + + except Exception as e: + logger.error(f"停止HTTP工作流失败: {e}") + return False + + +class PyLabRobotWorkflowExecutor(WorkflowExecutor): + """PyLabRobot工作流执行器 - 适配 prcxi.py""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证PyLabRobot接口 + if not (hasattr(self.hardware_interface, 'transfer_liquid') or + hasattr(self.hardware_interface, 'pickup_tips')): + raise RuntimeError("工作站硬件接口不是有效的PyLabRobot设备") + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行PyLabRobot工作流""" + try: + if workflow_name == "liquid_transfer": + return self._execute_liquid_transfer(parameters) + elif workflow_name == "tip_pickup_drop": + return self._execute_tip_operations(parameters) + elif workflow_name == "plate_handling": + return self._execute_plate_handling(parameters) + else: + logger.warning(f"不支持的PyLabRobot工作流: {workflow_name}") + return False + + except Exception as e: + logger.error(f"执行PyLabRobot工作流失败: {e}") + return False + + def _execute_liquid_transfer(self, parameters: Dict[str, Any]) -> bool: + """执行液体转移工作流""" + try: + # 1. 解析物料引用 + sources = self._resolve_containers(parameters.get('sources', [])) + targets = self._resolve_containers(parameters.get('targets', [])) + tip_racks = self._resolve_tip_racks(parameters.get('tip_racks', [])) + + if not sources or not targets: + raise ValueError("液体转移需要指定源容器和目标容器") + + if not tip_racks: + logger.warning("未指定枪头架,将尝试自动查找") + tip_racks = self._find_available_tip_racks() + + # 2. 执行液体转移 + volumes = parameters.get('volumes', []) + if not volumes: + volumes = [100.0] * len(sources) # 默认体积 + + # 如果是同步接口 + if hasattr(self.hardware_interface, 'transfer_liquid'): + result = self.hardware_interface.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=tip_racks, + asp_vols=volumes, + dis_vols=volumes, + **parameters.get('options', {}) + ) + else: + # 异步接口需要特殊处理 + asyncio.run(self._async_liquid_transfer(sources, targets, tip_racks, volumes, parameters)) + result = True + + if result: + logger.info(f"液体转移工作流完成: {len(sources)}个源 -> {len(targets)}个目标") + + return bool(result) + + except Exception as e: + logger.error(f"执行液体转移失败: {e}") + return False + + async def _async_liquid_transfer(self, sources, targets, tip_racks, volumes, parameters): + """异步液体转移""" + await self.hardware_interface.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=tip_racks, + asp_vols=volumes, + dis_vols=volumes, + **parameters.get('options', {}) + ) + + def _execute_tip_operations(self, parameters: Dict[str, Any]) -> bool: + """执行枪头操作工作流""" + try: + operation = parameters.get('operation', 'pickup') + tip_racks = self._resolve_tip_racks(parameters.get('tip_racks', [])) + + if not tip_racks: + raise ValueError("枪头操作需要指定枪头架") + + if operation == 'pickup': + result = self.hardware_interface.pickup_tips(tip_racks[0]) + logger.info("枪头拾取完成") + elif operation == 'drop': + result = self.hardware_interface.drop_tips() + logger.info("枪头丢弃完成") + else: + raise ValueError(f"不支持的枪头操作: {operation}") + + return bool(result) + + except Exception as e: + logger.error(f"执行枪头操作失败: {e}") + return False + + def _execute_plate_handling(self, parameters: Dict[str, Any]) -> bool: + """执行板类处理工作流""" + try: + operation = parameters.get('operation', 'move') + source_position = parameters.get('source_position') + target_position = parameters.get('target_position') + + if operation == 'move' and source_position and target_position: + # 移动板类 + result = self.hardware_interface.move_plate(source_position, target_position) + logger.info(f"板类移动完成: {source_position} -> {target_position}") + else: + logger.warning(f"不支持的板类操作或参数不完整: {operation}") + return False + + return bool(result) + + except Exception as e: + logger.error(f"执行板类处理失败: {e}") + return False + + def _resolve_containers(self, container_names: List[str]): + """解析容器名称为实际容器对象""" + containers = [] + for name in container_names: + try: + container = self.material_management.find_material_by_id(name) + if container: + containers.append(container) + else: + logger.warning(f"未找到容器: {name}") + except: + logger.warning(f"解析容器失败: {name}") + return containers + + def _resolve_tip_racks(self, tip_rack_names: List[str]): + """解析枪头架名称为实际对象""" + tip_racks = [] + for name in tip_rack_names: + try: + tip_rack = self.material_management.find_by_category("tip_rack") + matching_racks = [rack for rack in tip_rack if rack.name == name] + if matching_racks: + tip_racks.extend(matching_racks) + else: + logger.warning(f"未找到枪头架: {name}") + except: + logger.warning(f"解析枪头架失败: {name}") + return tip_racks + + def _find_available_tip_racks(self): + """查找可用的枪头架""" + try: + tip_racks = self.material_management.find_by_category("tip_rack") + available_racks = [rack for rack in tip_racks if hasattr(rack, 'has_tips') and rack.has_tips()] + return available_racks[:1] # 返回第一个可用的枪头架 + except: + return [] + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止工作流""" + try: + if emergency: + if hasattr(self.hardware_interface, 'emergency_stop'): + return self.hardware_interface.emergency_stop() + else: + logger.warning("设备不支持紧急停止") + return False + else: + if hasattr(self.hardware_interface, 'graceful_stop'): + return self.hardware_interface.graceful_stop() + elif hasattr(self.hardware_interface, 'stop'): + return self.hardware_interface.stop() + else: + logger.warning("设备不支持优雅停止") + return False + + except Exception as e: + logger.error(f"停止PyLabRobot工作流失败: {e}") + return False + + +class ProxyWorkflowExecutor(WorkflowExecutor): + """代理工作流执行器 - 处理代理模式的工作流""" + + def __init__(self, workstation: 'WorkstationBase'): + super().__init__(workstation) + + # 验证代理接口 + if not isinstance(self.hardware_interface, str) or not self.hardware_interface.startswith("proxy:"): + raise RuntimeError("工作站硬件接口不是有效的代理字符串") + + self.device_id = self.hardware_interface[6:] # 移除 "proxy:" 前缀 + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行代理工作流""" + try: + # 通过协议节点调用目标设备的工作流 + if self.workstation._protocol_node: + return self.workstation._protocol_node.call_device_method( + self.device_id, 'execute_workflow', workflow_name, parameters + ) + else: + logger.error("代理模式需要protocol_node") + return False + + except Exception as e: + logger.error(f"执行代理工作流失败: {e}") + return False + + def stop_workflow(self, emergency: bool = False) -> bool: + """停止代理工作流""" + try: + if self.workstation._protocol_node: + return self.workstation._protocol_node.call_device_method( + self.device_id, 'stop_workflow', emergency + ) + else: + logger.error("代理模式需要protocol_node") + return False + + except Exception as e: + logger.error(f"停止代理工作流失败: {e}") + return False + + +# 辅助函数 +def get_executor_for_interface(hardware_interface) -> str: + """根据硬件接口类型获取执行器类型名称""" + if isinstance(hardware_interface, str) and hardware_interface.startswith("proxy:"): + return "ProxyWorkflowExecutor" + elif hasattr(hardware_interface, 'write_register') and hasattr(hardware_interface, 'read_register'): + return "ModbusWorkflowExecutor" + elif hasattr(hardware_interface, 'post') or hasattr(hardware_interface, 'get'): + return "HttpWorkflowExecutor" + elif hasattr(hardware_interface, 'transfer_liquid') or hasattr(hardware_interface, 'pickup_tips'): + return "PyLabRobotWorkflowExecutor" + else: + return "UnknownExecutor" diff --git a/unilabos/devices/work_station/workstation_base.py b/unilabos/devices/workstation/workstation_base.py similarity index 56% rename from unilabos/devices/work_station/workstation_base.py rename to unilabos/devices/workstation/workstation_base.py index 97444ccb..4b3c192b 100644 --- a/unilabos/devices/work_station/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -1,74 +1,25 @@ """ 工作站基类 -Workstation Base Class +Workstation Base Class - 单接口模式 -集成通信、物料管理和工作流的工作站基类 -融合子设备管理、动态工作流注册等高级功能 +基于单一硬件接口的简化工作站架构 +支持直接模式和代理模式的自动工作流执行器选择 """ -import asyncio -import json import time import traceback -from typing import Dict, Any, List, Optional, Union, Callable +from typing import Dict, Any, List, Optional, Union, TYPE_CHECKING from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum -from rclpy.action import ActionServer, ActionClient -from rclpy.action.server import ServerGoalHandle -from rclpy.callback_groups import ReentrantCallbackGroup -from rclpy.service import Service -from unilabos_msgs.srv import SerialCommand -from unilabos_msgs.msg import Resource +if TYPE_CHECKING: + from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker -from unilabos.device_comms.workstation_material_management import MaterialManagementBase -from unilabos.device_comms.workstation_http_service import ( +from unilabos.devices.work_station.workstation_material_management import MaterialManagementBase +from unilabos.devices.work_station.workstation_http_service import ( WorkstationHTTPService, WorkstationReportRequest, MaterialUsage ) -from unilabos.ros.msgs.message_converter import convert_to_ros_msg, convert_from_ros_msg from unilabos.utils.log import logger -from unilabos.utils.type_check import serialize_result_info - - -class DeviceType(Enum): - """设备类型枚举""" - LOGICAL = "logical" # 逻辑设备 - COMMUNICATION = "communication" # 通信设备 (modbus/opcua/serial) - PROTOCOL = "protocol" # 协议设备 - - -@dataclass -class CommunicationInterface: - """通信接口配置""" - device_id: str # 通信设备ID - read_method: str # 读取方法名 - write_method: str # 写入方法名 - protocol_type: str # 协议类型 (modbus/opcua/serial) - config: Dict[str, Any] # 协议特定配置 - - -@dataclass -class WorkflowStep: - """工作流步骤定义""" - device_id: str - action_name: str - action_kwargs: Dict[str, Any] - depends_on: Optional[List[str]] = None # 依赖的步骤ID - step_id: Optional[str] = None - timeout: Optional[float] = None - retry_count: int = 0 - - -@dataclass -class WorkflowDefinition: - """工作流定义""" - name: str - description: str - steps: List[WorkflowStep] - input_schema: Dict[str, Any] - output_schema: Dict[str, Any] - metadata: Dict[str, Any] class WorkflowStatus(Enum): @@ -94,65 +45,57 @@ class WorkflowInfo: parameters_schema: Dict[str, Any] # 参数架构 -@dataclass -class CommunicationConfig: - """通信配置""" - protocol: str - host: str - port: int - timeout: float = 5.0 - retry_count: int = 3 - extra_params: Dict[str, Any] = None - - class WorkstationBase(ABC): - """工作站基类 + """工作站基类 - 单接口模式 - 提供工作站的核心功能: - 1. 物料管理 - 基于PyLabRobot的物料系统 - 2. 工作流控制 - 支持动态注册和静态预定义工作流 - 3. 状态监控 - 设备状态和生产数据监控 - 4. HTTP服务 - 接收外部报送和状态查询 - - 注意:子设备管理和通信转发功能已移入ROS2ProtocolNode + 核心设计原则: + 1. 每个工作站只有一个 hardware_interface + 2. 根据接口类型自动选择工作流执行器 + 3. 支持直接模式和代理模式 + 4. 统一的设备操作接口 """ def __init__( self, device_id: str, deck_config: Optional[Dict[str, Any]] = None, - http_service_config: Optional[Dict[str, Any]] = None, # HTTP服务配置 + http_service_config: Optional[Dict[str, Any]] = None, *args, **kwargs, ): - # 保存工作站基本配置 + # 基本配置 self.device_id = device_id self.deck_config = deck_config or {"size_x": 1000.0, "size_y": 1000.0, "size_z": 500.0} - # HTTP服务配置 - 现在专门用于报送接收 + # HTTP服务配置 self.http_service_config = http_service_config or { "enabled": True, "host": "127.0.0.1", - "port": 8081 # 默认使用8081端口作为报送接收服务 + "port": 8081 } - # 错误处理和动作追踪 - self.current_action_context = None # 当前正在执行的动作上下文 - self.error_history = [] # 错误历史记录 - self.action_results = {} # 动作结果缓存 + # 单一硬件接口 - 可以是具体客户端对象或代理字符串 + self.hardware_interface: Union[Any, str] = None - # 工作流状态 - 支持静态和动态工作流 + # 协议节点引用(用于代理模式) + self._protocol_node: Optional['ROS2WorkstationNode'] = None + + # 工作流执行器(基于通信接口类型自动选择) + self.workflow_executor: Optional['WorkflowExecutor'] = None + + # 工作流状态 self.current_workflow_status = WorkflowStatus.IDLE self.current_workflow_info = None self.workflow_start_time = None self.workflow_parameters = {} + # 错误处理 + self.error_history = [] + self.action_results = {} + # 支持的工作流(静态预定义) self.supported_workflows: Dict[str, WorkflowInfo] = {} - # 动态注册的工作流 - self.registered_workflows: Dict[str, WorkflowDefinition] = {} - # 初始化工作站模块 self.material_management: MaterialManagementBase = self._create_material_management_module() @@ -163,113 +106,166 @@ class WorkstationBase(ABC): self.http_service = None self._start_http_service() - logger.info(f"工作站基类 {device_id} 初始化完成") + logger.info(f"工作站 {device_id} 初始化完成(单接口模式)") - @abstractmethod - def _create_material_management_module(self) -> MaterialManagementBase: - """创建物料管理模块 - 子类必须实现""" - pass + def set_hardware_interface(self, hardware_interface: Union[Any, str]): + """设置硬件接口""" + self.hardware_interface = hardware_interface + + # 根据接口类型自动创建工作流执行器 + self._setup_workflow_executor() + + logger.info(f"工作站 {self.device_id} 硬件接口设置: {type(hardware_interface).__name__}") - @abstractmethod - def _register_supported_workflows(self): - """注册支持的工作流 - 子类必须实现""" - pass + def set_protocol_node(self, protocol_node: 'ROS2WorkstationNode'): + """设置协议节点引用(用于代理模式)""" + self._protocol_node = protocol_node + logger.info(f"工作站 {self.device_id} 关联协议节点") - def _create_workstation_services(self): - """创建工作站ROS服务""" - def _start_http_service(self): - """启动HTTP报送接收服务""" - if self.http_service_config.get("enabled", True): - try: - self.http_service = WorkstationHTTPService( - host=self.http_service_config.get("host", "127.0.0.1"), - port=self.http_service_config.get("port", 8081), - workstation_handler=self - ) - logger.info(f"HTTP报送接收服务已启动: {self.http_service_config['host']}:{self.http_service_config['port']}") - except Exception as e: - logger.error(f"启动HTTP报送接收服务失败: {e}") - else: - logger.info("HTTP报送接收服务已禁用") - - def _stop_http_service(self): - """停止HTTP报送接收服务""" - if self.http_service: - try: - self.http_service.stop() - logger.info("HTTP报送接收服务已停止") - except Exception as e: - logger.error(f"停止HTTP报送接收服务失败: {e}") - - # ============ 核心业务方法 ============ - - def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: - """启动工作流 - 业务逻辑层""" + def _setup_workflow_executor(self): + """根据硬件接口类型自动设置工作流执行器""" + if self.hardware_interface is None: + return + + # 动态导入工作流执行器类 try: - if self.current_workflow_status != WorkflowStatus.IDLE: - logger.warning(f"工作流 {workflow_type} 启动失败:当前状态为 {self.current_workflow_status}") - return False + from unilabos.devices.work_station.workflow_executors import ( + ProxyWorkflowExecutor, ModbusWorkflowExecutor, + HttpWorkflowExecutor, PyLabRobotWorkflowExecutor + ) + except ImportError: + logger.warning("工作流执行器模块未找到,将使用基础执行器") + self.workflow_executor = None + return + + # 检查是否为代理字符串 + if isinstance(self.hardware_interface, str) and self.hardware_interface.startswith("proxy:"): + self.workflow_executor = ProxyWorkflowExecutor(self) + logger.info(f"工作站 {self.device_id} 使用代理工作流执行器") + # 检查是否为Modbus客户端 + elif hasattr(self.hardware_interface, 'write_register') and hasattr(self.hardware_interface, 'read_register'): + self.workflow_executor = ModbusWorkflowExecutor(self) + logger.info(f"工作站 {self.device_id} 使用Modbus工作流执行器") + + # 检查是否为HTTP客户端 + elif hasattr(self.hardware_interface, 'post') or hasattr(self.hardware_interface, 'get'): + self.workflow_executor = HttpWorkflowExecutor(self) + logger.info(f"工作站 {self.device_id} 使用HTTP工作流执行器") + + # 检查是否为PyLabRobot设备 + elif hasattr(self.hardware_interface, 'transfer_liquid') or hasattr(self.hardware_interface, 'pickup_tips'): + self.workflow_executor = PyLabRobotWorkflowExecutor(self) + logger.info(f"工作站 {self.device_id} 使用PyLabRobot工作流执行器") + + else: + logger.warning(f"工作站 {self.device_id} 无法识别硬件接口类型: {type(self.hardware_interface)}") + self.workflow_executor = None + + # ============ 统一的设备操作接口 ============ + + def call_device_method(self, method: str, *args, **kwargs) -> Any: + """调用设备方法的统一接口""" + # 1. 代理模式:通过协议节点转发 + if isinstance(self.hardware_interface, str) and self.hardware_interface.startswith("proxy:"): + if not self._protocol_node: + raise RuntimeError("代理模式需要设置protocol_node") + + device_id = self.hardware_interface[6:] # 移除 "proxy:" 前缀 + return self._protocol_node.call_device_method(device_id, method, *args, **kwargs) + + # 2. 直接模式:直接调用硬件接口方法 + elif self.hardware_interface and hasattr(self.hardware_interface, method): + return getattr(self.hardware_interface, method)(*args, **kwargs) + + else: + raise AttributeError(f"硬件接口不支持方法: {method}") + + def get_device_status(self) -> Dict[str, Any]: + """获取设备状态""" + try: + return self.call_device_method('get_status') + except AttributeError: + # 如果设备不支持get_status方法,返回基础状态 + return { + "status": "unknown", + "interface_type": type(self.hardware_interface).__name__, + "timestamp": time.time() + } + + def is_device_available(self) -> bool: + """检查设备是否可用""" + try: + self.get_device_status() + return True + except: + return False + + # ============ 工作流控制接口 ============ + + def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流 - 委托给工作流执行器""" + if not self.workflow_executor: + logger.error(f"工作站 {self.device_id} 工作流执行器未初始化") + return False + + try: # 设置工作流状态 self.current_workflow_status = WorkflowStatus.INITIALIZING - self.workflow_parameters = parameters or {} + self.workflow_parameters = parameters self.workflow_start_time = time.time() - # 执行具体的工作流启动逻辑 - success = self._execute_start_workflow(workflow_type, parameters or {}) + # 委托给工作流执行器 + success = self.workflow_executor.execute_workflow(workflow_name, parameters) if success: self.current_workflow_status = WorkflowStatus.RUNNING - logger.info(f"工作流 {workflow_type} 启动成功") + logger.info(f"工作站 {self.device_id} 工作流 {workflow_name} 启动成功") else: self.current_workflow_status = WorkflowStatus.ERROR - logger.error(f"工作流 {workflow_type} 启动失败") + logger.error(f"工作站 {self.device_id} 工作流 {workflow_name} 启动失败") return success except Exception as e: self.current_workflow_status = WorkflowStatus.ERROR - logger.error(f"启动工作流失败: {e}") + logger.error(f"工作站 {self.device_id} 执行工作流失败: {e}") return False + def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: + """启动工作流 - 兼容旧接口""" + return self.execute_workflow(workflow_type, parameters or {}) + def stop_workflow(self, emergency: bool = False) -> bool: - """停止工作流 - 业务逻辑层""" + """停止工作流""" + if not self.workflow_executor: + logger.warning(f"工作站 {self.device_id} 工作流执行器未初始化") + return True + try: if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: - logger.warning("没有正在运行的工作流") + logger.warning(f"工作站 {self.device_id} 没有正在运行的工作流") return True self.current_workflow_status = WorkflowStatus.STOPPING - # 执行具体的工作流停止逻辑 - success = self._execute_stop_workflow(emergency) + # 委托给工作流执行器 + success = self.workflow_executor.stop_workflow(emergency) if success: self.current_workflow_status = WorkflowStatus.STOPPED - logger.info(f"工作流停止成功 (紧急: {emergency})") + logger.info(f"工作站 {self.device_id} 工作流停止成功 (紧急: {emergency})") else: self.current_workflow_status = WorkflowStatus.ERROR - logger.error(f"工作流停止失败") + logger.error(f"工作站 {self.device_id} 工作流停止失败") return success except Exception as e: self.current_workflow_status = WorkflowStatus.ERROR - logger.error(f"停止工作流失败: {e}") + logger.error(f"工作站 {self.device_id} 停止工作流失败: {e}") return False - # ============ 抽象方法 - 子类必须实现具体的工作流控制 ============ - - @abstractmethod - def _execute_start_workflow(self, workflow_type: str, parameters: Dict[str, Any]) -> bool: - """执行启动工作流的具体逻辑 - 子类实现""" - pass - - @abstractmethod - def _execute_stop_workflow(self, emergency: bool) -> bool: - """执行停止工作流的具体逻辑 - 子类实现""" - pass - # ============ 状态属性 ============ @property @@ -303,11 +299,25 @@ class WorkstationBase(ABC): """获取最后一个错误""" return self.error_history[-1] if self.error_history else None + # ============ 抽象方法 - 子类必须实现 ============ + + @abstractmethod + def _create_material_management_module(self) -> MaterialManagementBase: + """创建物料管理模块 - 子类必须实现""" + pass + + @abstractmethod + def _register_supported_workflows(self): + """注册支持的工作流 - 子类必须实现""" + pass + + # ============ HTTP服务管理 ============ + def _start_http_service(self): """启动HTTP报送接收服务""" try: if not self.http_service_config.get("enabled", True): - logger.info("HTTP报送接收服务已禁用") + logger.info(f"工作站 {self.device_id} HTTP报送接收服务已禁用") return host = self.http_service_config.get("host", "127.0.0.1") @@ -322,7 +332,7 @@ class WorkstationBase(ABC): logger.info(f"工作站 {self.device_id} HTTP报送接收服务启动成功: {host}:{port}") except Exception as e: - logger.error(f"启动HTTP报送接收服务失败: {e}") + logger.error(f"工作站 {self.device_id} 启动HTTP报送接收服务失败: {e}") self.http_service = None def _stop_http_service(self): @@ -331,12 +341,9 @@ class WorkstationBase(ABC): if self.http_service: self.http_service.stop() self.http_service = None - logger.info("HTTP报送接收服务已停止") + logger.info(f"工作站 {self.device_id} HTTP报送接收服务已停止") except Exception as e: - logger.error(f"停止HTTP报送接收服务失败: {e}") - logger.error(f"停止HTTP报送接收服务失败: {e}") - - # ============ 报送处理方法 ============ + logger.error(f"工作站 {self.device_id} 停止HTTP报送接收服务失败: {e}") # ============ 报送处理方法 ============ diff --git a/unilabos/devices/work_station/workstation_http_service.py b/unilabos/devices/workstation/workstation_http_service.py similarity index 100% rename from unilabos/devices/work_station/workstation_http_service.py rename to unilabos/devices/workstation/workstation_http_service.py diff --git a/unilabos/devices/work_station/workstation_material_management.py b/unilabos/devices/workstation/workstation_material_management.py similarity index 100% rename from unilabos/devices/work_station/workstation_material_management.py rename to unilabos/devices/workstation/workstation_material_management.py diff --git a/unilabos/registry/devices/work_station.yaml b/unilabos/registry/devices/work_station.yaml index d8bb21ac..c96c68a6 100644 --- a/unilabos/registry/devices/work_station.yaml +++ b/unilabos/registry/devices/work_station.yaml @@ -6112,7 +6112,7 @@ workstation: title: initialize_device参数 type: object type: UniLabJsonCommand - module: unilabos.ros.nodes.presets.protocol_node:ROS2ProtocolNode + module: unilabos.ros.nodes.presets.protocol_node:ROS2WorkstationNode status_types: {} type: ros2 config_info: [] diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index b0716aab..657a2077 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -51,6 +51,8 @@ SendCmd = msg_converter_manager.get_class("unilabos_msgs.action:SendCmd") imsg = msg_converter_manager.get_module("unilabos.messages") Point3D = msg_converter_manager.get_class("unilabos.messages:Point3D") +from control_msgs.action import * + # 基本消息类型映射 _msg_mapping: Dict[Type, Type] = { float: Float64, diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 612acb12..f88c27d1 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -50,7 +50,7 @@ from unilabos_msgs.msg import Resource # type: ignore from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker from unilabos.ros.x.rclpyx import get_event_loop -from unilabos.ros.utils.driver_creator import ProtocolNodeCreator, PyLabRobotCreator, DeviceClassCreator +from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator from unilabos.utils.async_util import run_async_func from unilabos.utils.log import info, debug, warning, error, critical, logger, trace from unilabos.utils.type_check import get_type_class, TypeEncoder, serialize_result_info @@ -340,14 +340,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 物料传输到对应的node节点 rclient = self.create_client(ResourceAdd, "/resources/add") rclient.wait_for_service() - rclient2 = self.create_client(ResourceAdd, "/resources/add") - rclient2.wait_for_service() request = ResourceAdd.Request() - request2 = ResourceAdd.Request() + command_json = json.loads(req.command) namespace = command_json["namespace"] bind_parent_id = command_json["bind_parent_id"] - edge_device_id = command_json["edge_device_id"] location = command_json["bind_location"] other_calling_param = command_json["other_calling_param"] resources = command_json["resource"] @@ -357,7 +354,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", []) LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", []) slot = other_calling_param.pop("slot", "-1") - resource = None + parent = None if slot != "-1": # slot为负数的时候采用assign方法 other_calling_param["slot"] = slot # 本地拿到这个物料,可能需要先做初始化? @@ -385,20 +382,20 @@ class BaseROS2DeviceNode(Node, Generic[T]): logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器") else: assert len(found_resources) == 1, f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统" - resource = found_resources[0] - if isinstance(resource, Resource): - regular_container = RegularContainer(resource.id) - regular_container.ulr_resource = resource + parent = found_resources[0] + if isinstance(parent, Resource): + regular_container = RegularContainer(parent.id) + regular_container.ulr_resource = parent regular_container.ulr_resource_data.update(json.loads(container_instance.data)) - logger.info(f"更新物料{container_query_dict['name']}的数据{resource.data} ULR") - elif isinstance(resource, dict): - if "data" not in resource: - resource["data"] = {} - resource["data"].update(json.loads(container_instance.data)) - request.resources[0].name = resource["name"] - logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict") + logger.info(f"更新物料{container_query_dict['name']}的数据{parent.data} ULR") + elif isinstance(parent, dict): + if "data" not in parent: + parent["data"] = {} + parent["data"].update(json.loads(container_instance.data)) + request.resources[0].name = parent["name"] + logger.info(f"更新物料{container_query_dict['name']}的数据{parent['data']} dict") else: - logger.info(f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}") + logger.info(f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(parent)} {parent}") response = await rclient.call_async(request) # 应该先add_resource了 res.response = "OK" @@ -423,18 +420,16 @@ class BaseROS2DeviceNode(Node, Generic[T]): return res # 接下来该根据bind_parent_id进行assign了,目前只有plr可以进行assign,不然没有办法输入到物料系统中 if bind_parent_id != self.node_name: - resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) # 拿到父节点,进行具体assign等操作 + parent = self.resource_tracker.figure_resource({"name": bind_parent_id}) # 拿到父节点,进行具体assign等操作 # request.resources = [convert_to_ros_msg(Resource, resources)] try: from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.deck import Deck - from pylabrobot.resources import Coordinate - from pylabrobot.resources import OTDeck - from pylabrobot.resources import Plate + from pylabrobot.resources import Coordinate, OTDeck, Plate - contain_model = not isinstance(resource, Deck) - if isinstance(resource, ResourcePLR): + contain_model = not isinstance(parent, Deck) + if isinstance(parent, ResourcePLR): # resources.list() resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources})) plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model) @@ -445,20 +440,22 @@ class BaseROS2DeviceNode(Node, Generic[T]): ): empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) plr_instance.set_well_liquids(empty_liquid_info_in) - if isinstance(resource, OTDeck) and "slot" in other_calling_param: + if isinstance(parent, OTDeck) and "slot" in other_calling_param: other_calling_param["slot"] = int(other_calling_param["slot"]) - resource.assign_child_at_slot(plr_instance, **other_calling_param) + parent.assign_child_at_slot(plr_instance, **other_calling_param) else: _discard_slot = other_calling_param.pop("slot", "-1") - resource.assign_child_resource( + parent.assign_child_resource( plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param, ) + + request2 = ResourceAdd.Request() request2.resources = [ - convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)]) + convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(parent)]) ] - rclient2.call(request2) + rclient.call(request2) # 发送给ResourceMeshManager action_client = ActionClient( self, @@ -947,7 +944,7 @@ class ROS2DeviceNode: # TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建 # 创建设备类实例 - # 判断是否包含设备子节点,决定是否使用ROS2ProtocolNode + # 判断是否包含设备子节点,决定是否使用ROS2WorkstationNode has_device_children = any( child_config.get("type", "device") == "device" for child_config in children.values() @@ -961,17 +958,17 @@ class ROS2DeviceNode: driver_class, children=children, resource_tracker=self.resource_tracker ) else: - from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode - from unilabos.device_comms.workstation_base import WorkstationBase + from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode + from unilabos.devices.work_station.workstation_base import WorkstationBase # 检查是否是WorkstationBase的子类且包含设备子节点 if issubclass(self._driver_class, WorkstationBase) and has_device_children: - # WorkstationBase + 设备子节点 -> 使用ProtocolNode作为ros_instance + # WorkstationBase + 设备子节点 -> 使用WorkstationNode作为ros_instance self._use_protocol_node_ros = True self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) - elif issubclass(self._driver_class, ROS2ProtocolNode): # 是ProtocolNode的子节点,就要调用ProtocolNodeCreator + elif issubclass(self._driver_class, ROS2WorkstationNode): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator self._use_protocol_node_ros = False - self._driver_creator = ProtocolNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) + self._driver_creator = WorkstationNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) else: self._use_protocol_node_ros = False self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) @@ -988,8 +985,8 @@ class ROS2DeviceNode: if driver_is_ros: self._ros_node = self._driver_instance # type: ignore elif hasattr(self, '_use_protocol_node_ros') and self._use_protocol_node_ros: - # WorkstationBase + 设备子节点 -> 创建ROS2ProtocolNode作为ros_instance - from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode + # WorkstationBase + 设备子节点 -> 创建ROS2WorkstationNode作为ros_instance + from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode # 从children提取设备协议类型 protocol_types = set() @@ -1006,7 +1003,7 @@ class ROS2DeviceNode: if not protocol_types: protocol_types = ["default_protocol"] - self._ros_node = ROS2ProtocolNode( + self._ros_node = ROS2WorkstationNode( device_id=device_id, children=children, protocol_type=list(protocol_types), diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 5fe90684..0f9f951e 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -523,7 +523,7 @@ class HostNode(BaseROS2DeviceNode): # 解析设备名和属性名 parts = topic.split("/") - if len(parts) >= 4: # 可能有ProtocolNode,创建更长的设备 + if len(parts) >= 4: # 可能有WorkstationNode,创建更长的设备 device_id = "/".join(parts[2:-1]) property_name = parts[-1] diff --git a/unilabos/ros/nodes/presets/protocol_node.py b/unilabos/ros/nodes/presets/protocol_node.py index ad835ee1..c8c338c3 100644 --- a/unilabos/ros/nodes/presets/protocol_node.py +++ b/unilabos/ros/nodes/presets/protocol_node.py @@ -28,9 +28,9 @@ from unilabos.utils.log import error from unilabos.utils.type_check import serialize_result_info -class ROS2ProtocolNode(BaseROS2DeviceNode): +class ROS2WorkstationNode(BaseROS2DeviceNode): """ - ROS2ProtocolNode代表管理ROS2环境中设备通信和动作的协议节点。 + ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。 它初始化设备节点,处理动作客户端,并基于指定的协议执行工作流。 它还物理上代表一组协同工作的设备,如带夹持器的机械臂,带传送带的CNC机器等。 """ @@ -43,7 +43,8 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): children: dict, protocol_type: Union[str, list[str]], resource_tracker: DeviceNodeResourceTracker, - workstation_config: dict = None, # 新增:工作站配置 + workstation_config: dict = {}, + workstation_instance: object = None, *args, **kwargs, ): @@ -55,10 +56,12 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): self.communication_interfaces = self.workstation_config.get('communication_interfaces', {}) # 从工作站配置获取通信接口 # 新增:获取工作站实例(如果存在) - self.workstation_instance = self.workstation_config.get('workstation_instance') + self.workstation_instance = workstation_instance self._busy = False self.sub_devices = {} + self.communication_devices = {} + self.logical_devices = {} self._goals = {} self._protocol_servers = {} self._action_clients = {} @@ -72,73 +75,38 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): device_id=device_id, status_types={}, action_value_mappings=self.protocol_action_mappings, - hardware_interface={}, + hardware_interface={"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}, print_publish=False, resource_tracker=resource_tracker, ) # 初始化子设备 - self.communication_node_id_to_instance = {} self._initialize_child_devices() - # 设置硬件接口代理 - self._setup_hardware_proxies() - - # 新增:如果有工作站实例,建立双向引用 - if self.workstation_instance: - self.workstation_instance._protocol_node = self - self._setup_workstation_method_proxies() - self.lab_logger().info(f"ROS2ProtocolNode {device_id} 与工作站实例 {type(self.workstation_instance).__name__} 关联") - - self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}") - - def _setup_workstation_method_proxies(self): - """设置工作站方法代理""" - if not self.workstation_instance: - return - - # 代理工作站的核心方法 - workstation_methods = [ - 'start_workflow', 'stop_workflow', 'workflow_status', 'is_busy', - 'process_material_change_report', 'process_step_finish_report', - 'process_sample_finish_report', 'process_order_finish_report', - 'handle_external_error' - ] - - for method_name in workstation_methods: - if hasattr(self.workstation_instance, method_name): - # 创建代理方法 - setattr(self, method_name, getattr(self.workstation_instance, method_name)) - self.lab_logger().debug(f"代理工作站方法: {method_name}") - - # ============ 工作站方法代理 ============ - - def get_workstation_status(self) -> Dict[str, Any]: - """获取工作站状态""" - if self.workstation_instance: - return { - 'workflow_status': str(self.workstation_instance.workflow_status.value), - 'is_busy': self.workstation_instance.is_busy, - 'workflow_runtime': self.workstation_instance.workflow_runtime, - 'error_count': self.workstation_instance.error_count, - 'last_error': self.workstation_instance.last_error - } - return {'status': 'no_workstation_instance'} - - def delegate_to_workstation(self, method_name: str, *args, **kwargs): - """委托方法调用给工作站实例""" - if self.workstation_instance and hasattr(self.workstation_instance, method_name): - method = getattr(self.workstation_instance, method_name) - return method(*args, **kwargs) + if isinstance(getattr(driver_instance, "hardware_interface", None), str): + self.logical_devices[device_id] = driver_instance else: - self.lab_logger().warning(f"工作站实例不存在或没有方法: {method_name}") - return None + self.communication_devices[device_id] = driver_instance + + # 设置硬件接口代理 + for device_id, device_node in self.logical_devices.items(): + if device_node and hasattr(device_node, 'ros_node_instance'): + self._setup_device_hardware_proxy(device_id, device_node) + + # 新增:如果有工作站实例,建立双向引用和硬件接口设置 + if self.workstation_instance: + self._setup_workstation_integration() + + def _setup_workstation_integration(self): + """设置工作站集成 - 统一设备处理模式""" + # 1. 建立协议节点引用 + self.workstation_instance.set_protocol_node(self) + + self.lab_logger().info(f"ROS2WorkstationNode {self.device_id} 与工作站实例 {type(self.workstation_instance).__name__} 集成完成") def _initialize_child_devices(self): """初始化子设备 - 重构为更清晰的方法""" # 设备分类字典 - 统一管理 - self.communication_devices = {} - self.logical_devices = {} for device_id, device_config in self.children.items(): if device_config.get("type", "device") != "device": @@ -157,7 +125,6 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): # 兼容旧的ID匹配方式和新的配置方式 if device_type == "communication" or "serial_" in device_id or "io_" in device_id: - self.communication_node_id_to_instance[device_id] = d # 保持向后兼容 self.communication_devices[device_id] = d # 新的统一方式 self.lab_logger().info(f"通信设备 {device_id} 初始化并分类成功") elif device_type == "logical": @@ -171,91 +138,100 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): except Exception as ex: self.lab_logger().error(f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}") - def _setup_hardware_proxies(self): - """设置硬件接口代理 - 重构为独立方法,支持工作站配置""" - # 1. 传统的协议节点硬件代理设置 - for device_id, device_config in self.children.items(): - if device_config.get("type", "device") != "device": - continue - - # 设置硬件接口代理 - if device_id not in self.sub_devices: - self.lab_logger().error(f"[Protocol Node] {device_id} 还没有正确初始化,跳过...") - continue - - d = self.sub_devices[device_id] - if d: - self._setup_device_hardware_proxy(device_id, d) + def _setup_device_hardware_proxy(self, device_id: str, device: ROS2DeviceNode): + """统一的设备硬件接口代理设置方法 - # 2. 工作站配置的通信接口代理设置 - if hasattr(self, 'communication_interfaces') and self.communication_interfaces: - self._setup_workstation_communication_interfaces() - - self.lab_logger().info(f"ROS2ProtocolNode {self.device_id} initialized with protocols: {self.protocol_names}") - - def _setup_workstation_communication_interfaces(self): - """设置工作站特定的通信接口代理""" - for logical_device_id, logical_device in self.logical_devices.items(): - # 检查是否有配置的通信接口 - interface_config = getattr(self, 'communication_interfaces', {}).get(logical_device_id) - if not interface_config: - continue - - comm_device = self.communication_devices.get(interface_config.device_id) - if not comm_device: - self.lab_logger().error(f"通信设备 {interface_config.device_id} 不存在") - continue - - # 设置工作站级别的通信代理 - self._setup_workstation_hardware_proxy( - logical_device, - comm_device, - interface_config - ) - - def _setup_workstation_hardware_proxy(self, logical_device, comm_device, interface_config): - """为逻辑设备设置工作站级通信代理""" - try: - # 获取通信设备的读写方法 - read_func = getattr(comm_device.driver_instance, interface_config.read_method, None) - write_func = getattr(comm_device.driver_instance, interface_config.write_method, None) - - if read_func: - setattr(logical_device.driver_instance, 'comm_read', read_func) - if write_func: - setattr(logical_device.driver_instance, 'comm_write', write_func) - - # 设置通信配置 - setattr(logical_device.driver_instance, 'comm_config', interface_config.config) - setattr(logical_device.driver_instance, 'comm_protocol', interface_config.protocol_type) - - self.lab_logger().info(f"为逻辑设备 {logical_device.device_id} 设置工作站通信代理 -> {comm_device.device_id}") - - except Exception as e: - self.lab_logger().error(f"设置工作站通信代理失败: {e}") - - def _setup_device_hardware_proxy(self, device_id: str, device): - """为单个设备设置硬件接口代理""" + Args: + device_id: 设备ID + device: 设备实例 + """ hardware_interface = device.ros_node_instance._hardware_interface - if ( + if not self._validate_hardware_interface(device, hardware_interface): + return + + # 获取硬件接口名称 + interface_name = getattr(device.driver_instance, hardware_interface["name"]) + + # 情况1: 如果interface_name是字符串,说明需要转发到其他设备 + if isinstance(interface_name, str): + # 查找目标设备 + communication_device = self.communication_devices.get(device_id, None) + if not communication_device: + self.lab_logger().error(f"转发目标设备 {device_id} 不存在") + return + + read_method = hardware_interface.get("read", None) + write_method = hardware_interface.get("write", None) + + # 设置传统硬件代理 + communicate_hardware_info = communication_device.ros_node_instance._hardware_interface + self._setup_hardware_proxy(device, communication_device, read_method, write_method) + self.lab_logger().info( + f"传统通信代理:为子设备{device.device_id} " + f"添加了{read_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['read']}) " + f"添加了{write_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['write']})" + ) + self.lab_logger().info(f"字符串转发代理:设备 {device.device_id} -> {device_id}") + + # 情况2: 如果设备有communication_interface配置,设置协议代理 + elif hasattr(self, 'communication_interfaces') and device_id in self.communication_interfaces: + interface_config = self._get_communication_interface_config(device_id) + protocol_type = interface_config.get('protocol_type', 'modbus') + self._setup_communication_proxy(device, interface_config, protocol_type) + + # 情况3: 其他情况,使用默认处理 + else: + self.lab_logger().debug(f"设备 {device_id} 使用默认硬件接口处理") + + def _get_communication_interface_config(self, device_id: str) -> dict: + """获取设备的通信接口配置""" + # 优先从工作站配置获取 + if hasattr(self, 'communication_interfaces') and device_id in self.communication_interfaces: + return self.communication_interfaces[device_id] + + # 从设备自身配置获取 + device_node = self.logical_devices[device_id] + if device_node and hasattr(device_node.driver_instance, 'communication_interface'): + return getattr(device_node.driver_instance, 'communication_interface') + + return {} + + def _validate_hardware_interface(self, device: ROS2DeviceNode, hardware_interface: dict) -> bool: + """验证硬件接口配置""" + return ( hasattr(device.driver_instance, hardware_interface["name"]) and hasattr(device.driver_instance, hardware_interface["write"]) and (hardware_interface["read"] is None or hasattr(device.driver_instance, hardware_interface["read"])) - ): - name = getattr(device.driver_instance, hardware_interface["name"]) - read = hardware_interface.get("read", None) - write = hardware_interface.get("write", None) + ) - # 如果硬件接口是字符串,通过通信设备提供 - if isinstance(name, str) and name in self.sub_devices: - communicate_device = self.sub_devices[name] - communicate_hardware_info = communicate_device.ros_node_instance._hardware_interface - self._setup_hardware_proxy(device, self.sub_devices[name], read, write) - self.lab_logger().info( - f"\n通信代理:为子设备{device_id}\n " - f"添加了{read}方法(来源:{name} {communicate_hardware_info['write']}) \n " - f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})" - ) + def _setup_communication_proxy(self, logical_device: ROS2DeviceNode, interface_config, protocol_type): + """为逻辑设备设置通信代理 - 统一方法""" + try: + # 获取通信设备 + comm_device_id = interface_config.get('device_id') + comm_device = self.communication_devices.get(comm_device_id) + + if not comm_device: + self.lab_logger().error(f"通信设备 {comm_device_id} 不存在") + return + + # 根据协议类型设置不同的代理方法 + if protocol_type == 'modbus': + self._setup_modbus_proxy(logical_device, comm_device, interface_config) + elif protocol_type == 'opcua': + self._setup_opcua_proxy(logical_device, comm_device, interface_config) + elif protocol_type == 'http': + self._setup_http_proxy(logical_device, comm_device, interface_config) + elif protocol_type == 'serial': + self._setup_serial_proxy(logical_device, comm_device, interface_config) + else: + self.lab_logger().warning(f"不支持的协议类型: {protocol_type}") + return + + self.lab_logger().info(f"通信代理:为逻辑设备 {logical_device.device_id} 设置{protocol_type}通信代理 -> {comm_device_id}") + + except Exception as e: + self.lab_logger().error(f"设置通信代理失败: {e}") def _setup_protocol_names(self, protocol_type): # 处理协议类型 @@ -493,6 +469,99 @@ class ROS2ProtocolNode(BaseROS2DeviceNode): """还没有改过的部分""" + def _setup_modbus_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置Modbus通信代理""" + config = interface_config.get('config', {}) + + # 设置Modbus读写方法 + def modbus_read(address, count=1, function_code=3): + """Modbus读取方法""" + return comm_device.driver_instance.read_holding_registers( + address=address, + count=count, + slave_id=config.get('slave_id', 1) + ) + + def modbus_write(address, value, function_code=6): + """Modbus写入方法""" + if isinstance(value, (list, tuple)): + return comm_device.driver_instance.write_multiple_registers( + address=address, + values=value, + slave_id=config.get('slave_id', 1) + ) + else: + return comm_device.driver_instance.write_single_register( + address=address, + value=value, + slave_id=config.get('slave_id', 1) + ) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, 'comm_read', modbus_read) + setattr(logical_device.driver_instance, 'comm_write', modbus_write) + setattr(logical_device.driver_instance, 'comm_config', config) + setattr(logical_device.driver_instance, 'comm_protocol', 'modbus') + + def _setup_opcua_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置OPC UA通信代理""" + config = interface_config.get('config', {}) + + def opcua_read(node_id): + """OPC UA读取方法""" + return comm_device.driver_instance.read_node_value(node_id) + + def opcua_write(node_id, value): + """OPC UA写入方法""" + return comm_device.driver_instance.write_node_value(node_id, value) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, 'comm_read', opcua_read) + setattr(logical_device.driver_instance, 'comm_write', opcua_write) + setattr(logical_device.driver_instance, 'comm_config', config) + setattr(logical_device.driver_instance, 'comm_protocol', 'opcua') + + def _setup_http_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置HTTP/RPC通信代理""" + config = interface_config.get('config', {}) + base_url = config.get('base_url', 'http://localhost:8080') + + def http_read(endpoint, params=None): + """HTTP GET请求""" + url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" + return comm_device.driver_instance.get_request(url, params=params) + + def http_write(endpoint, data): + """HTTP POST请求""" + url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" + return comm_device.driver_instance.post_request(url, data=data) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, 'comm_read', http_read) + setattr(logical_device.driver_instance, 'comm_write', http_write) + setattr(logical_device.driver_instance, 'comm_config', config) + setattr(logical_device.driver_instance, 'comm_protocol', 'http') + + def _setup_serial_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置串口通信代理""" + config = interface_config.get('config', {}) + + def serial_read(timeout=1.0): + """串口读取方法""" + return comm_device.driver_instance.read_data(timeout=timeout) + + def serial_write(data): + """串口写入方法""" + if isinstance(data, str): + data = data.encode('utf-8') + return comm_device.driver_instance.write_data(data) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, 'comm_read', serial_read) + setattr(logical_device.driver_instance, 'comm_write', serial_write) + setattr(logical_device.driver_instance, 'comm_config', config) + setattr(logical_device.driver_instance, 'comm_protocol', 'serial') + def _setup_hardware_proxy( self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method ): diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index 0e84683e..188d65b9 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -3,7 +3,7 @@ from typing import Union, Dict, Any, Optional from unilabos_msgs.msg import Resource from pylabrobot.resources import Resource as PLRResource, Plate, TipRack, Coordinate -from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode +from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker @@ -44,7 +44,7 @@ def get_workstation_plate_resource(name: str) -> PLRResource: # 要给定一个 return plate -class WorkStationExample(ROS2ProtocolNode): +class WorkStationExample(ROS2WorkstationNode): def __init__(self, # 你可以在这里增加任意的参数,对应启动json填写相应的参数内容 device_id: str, diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index 862f04c1..5e16989f 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -267,40 +267,40 @@ class PyLabRobotCreator(DeviceClassCreator[T]): ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(done_cb) -class ProtocolNodeCreator(DeviceClassCreator[T]): +class WorkstationNodeCreator(DeviceClassCreator[T]): """ - ProtocolNode设备类创建器 + WorkstationNode设备类创建器 - 这个类提供了针对ProtocolNode设备类的实例创建方法,处理children参数。 + 这个类提供了针对WorkstationNode设备类的实例创建方法,处理children参数。 """ def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): """ - 初始化ProtocolNode设备类创建器 + 初始化WorkstationNode设备类创建器 Args: - cls: ProtocolNode设备类 + cls: WorkstationNode设备类 children: 子资源字典,用于资源替换 """ super().__init__(cls, children, resource_tracker) def create_instance(self, data: Dict[str, Any]) -> T: """ - 从数据创建ProtocolNode设备实例 + 从数据创建WorkstationNode设备实例 Args: data: 用于创建实例的数据 Returns: - ProtocolNode设备类实例 + WorkstationNode设备类实例 """ try: # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 data["children"] = self.children - self.device_instance = super(ProtocolNodeCreator, self).create_instance(data) + self.device_instance = super(WorkstationNodeCreator, self).create_instance(data) self.post_create() return self.device_instance except Exception as e: - logger.error(f"ProtocolNode创建实例失败: {e}") - logger.error(f"ProtocolNode创建实例堆栈: {traceback.format_exc()}") + logger.error(f"WorkstationNode创建实例失败: {e}") + logger.error(f"WorkstationNode创建实例堆栈: {traceback.format_exc()}") raise From 7d8e6d029bfbaf3f38f053bf8d035da230567276 Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 27 Aug 2025 01:21:13 +0800 Subject: [PATCH 06/13] Add:msgs.action (#83) --- unilabos_msgs/CMakeLists.txt | 13 ++++++ .../action/DispenStationSolnPrep.action | 15 ++++++ .../action/DispenStationVialFeed.action | 29 ++++++++++++ unilabos_msgs/action/PostProcessGrab.action | 8 ++++ .../action/PostProcessTriggerClean.action | 46 +++++++++++++++++++ .../action/PostProcessTriggerPostPro.action | 20 ++++++++ .../action/ReactionStationDripBack.action | 11 +++++ .../action/ReactionStationLiquidFeed.action | 11 +++++ .../action/ReactionStationProExecu.action | 8 ++++ .../action/ReactionStationReaTackIn.action | 9 ++++ .../ReactionStationSolidFeedVial.action | 10 ++++ 11 files changed, 180 insertions(+) create mode 100644 unilabos_msgs/action/DispenStationSolnPrep.action create mode 100644 unilabos_msgs/action/DispenStationVialFeed.action create mode 100644 unilabos_msgs/action/PostProcessGrab.action create mode 100644 unilabos_msgs/action/PostProcessTriggerClean.action create mode 100644 unilabos_msgs/action/PostProcessTriggerPostPro.action create mode 100644 unilabos_msgs/action/ReactionStationDripBack.action create mode 100644 unilabos_msgs/action/ReactionStationLiquidFeed.action create mode 100644 unilabos_msgs/action/ReactionStationProExecu.action create mode 100644 unilabos_msgs/action/ReactionStationReaTackIn.action create mode 100644 unilabos_msgs/action/ReactionStationSolidFeedVial.action diff --git a/unilabos_msgs/CMakeLists.txt b/unilabos_msgs/CMakeLists.txt index 1c371397..beab817e 100644 --- a/unilabos_msgs/CMakeLists.txt +++ b/unilabos_msgs/CMakeLists.txt @@ -98,6 +98,19 @@ set(action_files "action/WorkStationRun.action" "action/AGVTransfer.action" + + "action/DispenStationSolnPrep.action" + "action/DispenStationVialFeed.action" + + "action/PostProcessGrab.action" + "action/PostProcessTriggerClean.action" + "action/PostProcessTriggerPostPro.action" + + "action/ReactionStationDripBack.action" + "action/ReactionStationLiquidFeed.action" + "action/ReactionStationProExecu.action" + "action/ReactionStationReaTackIn.action" + "action/ReactionStationSolidFeedVial.action" ) set(srv_files diff --git a/unilabos_msgs/action/DispenStationSolnPrep.action b/unilabos_msgs/action/DispenStationSolnPrep.action new file mode 100644 index 00000000..49afcac5 --- /dev/null +++ b/unilabos_msgs/action/DispenStationSolnPrep.action @@ -0,0 +1,15 @@ +# Goal - (二胺)溶液配置 +string order_name # 任务名称 +string material_name #固体物料名称 +string target_weigh #固体重量 +string volume #液体体积 +string liquid_material_name # 溶剂名称 +string speed #磁力转动速度 +string temperature #温度 +string delay_time #等待时间 +string hold_m_name #样品名称 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/DispenStationVialFeed.action b/unilabos_msgs/action/DispenStationVialFeed.action new file mode 100644 index 00000000..6b85058e --- /dev/null +++ b/unilabos_msgs/action/DispenStationVialFeed.action @@ -0,0 +1,29 @@ +# Goal - 小瓶投料 +string order_name # 任务名称 +string percent_90_1_assign_material_name +string percent_90_1_target_weigh +string percent_90_2_assign_material_name +string percent_90_2_target_weigh +string percent_90_3_assign_material_name +string percent_90_3_target_weigh +string percent_10_1_assign_material_name +string percent_10_1_target_weigh +string percent_10_1_volume +string percent_10_1_liquid_material_name +string percent_10_2_assign_material_name +string percent_10_2_target_weigh +string percent_10_2_volume +string percent_10_2_liquid_material_name +string percent_10_3_assign_material_name +string percent_10_3_target_weigh +string percent_10_3_volume +string percent_10_3_liquid_material_name +string speed +string temperature +string delay_time +string hold_m_name +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/PostProcessGrab.action b/unilabos_msgs/action/PostProcessGrab.action new file mode 100644 index 00000000..88da7bae --- /dev/null +++ b/unilabos_msgs/action/PostProcessGrab.action @@ -0,0 +1,8 @@ +# Goal - 抓取参数 +int32 reaction_tank_number #反应罐号码 +int32 raw_tank_number #原料罐号码 +--- +# Result - 操作结果 +string return_info +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/PostProcessTriggerClean.action b/unilabos_msgs/action/PostProcessTriggerClean.action new file mode 100644 index 00000000..7308aca7 --- /dev/null +++ b/unilabos_msgs/action/PostProcessTriggerClean.action @@ -0,0 +1,46 @@ +# Goal - 清洗参数 +float64 nmp_outer_wall_cleaning_injection +int32 nmp_outer_wall_cleaning_count +int32 nmp_outer_wall_cleaning_wait_time +int32 nmp_outer_wall_cleaning_waste_time +float64 nmp_inner_wall_cleaning_injection +int32 nmp_inner_wall_cleaning_count +int32 nmp_pump_cleaning_suction_count +int32 nmp_inner_wall_cleaning_waste_time +float64 nmp_stirrer_cleaning_injection +int32 nmp_stirrer_cleaning_count +int32 nmp_stirrer_cleaning_wait_time +int32 nmp_stirrer_cleaning_waste_time +float64 water_outer_wall_cleaning_injection +int32 water_outer_wall_cleaning_count +int32 water_outer_wall_cleaning_wait_time +int32 water_outer_wall_cleaning_waste_time +float64 water_inner_wall_cleaning_injection +int32 water_inner_wall_cleaning_count +int32 water_pump_cleaning_suction_count +int32 water_inner_wall_cleaning_waste_time +float64 water_stirrer_cleaning_injection +int32 water_stirrer_cleaning_count +int32 water_stirrer_cleaning_wait_time +int32 water_stirrer_cleaning_waste_time +float64 acetone_outer_wall_cleaning_injection +int32 acetone_outer_wall_cleaning_count +int32 acetone_outer_wall_cleaning_wait_time +int32 acetone_outer_wall_cleaning_waste_time +float64 acetone_inner_wall_cleaning_injection +int32 acetone_inner_wall_cleaning_count +int32 acetone_pump_cleaning_suction_count +int32 acetone_inner_wall_cleaning_waste_time +float64 acetone_stirrer_cleaning_injection +int32 acetone_stirrer_cleaning_count +int32 acetone_stirrer_cleaning_wait_time +int32 acetone_stirrer_cleaning_waste_time +int32 pipe_blowing_time +int32 injection_pump_forward_empty_suction_count +int32 injection_pump_reverse_empty_suction_count +int32 filtration_liquid_selection +--- +# Result - 操作结果 +string return_info # 操作是否成功 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/PostProcessTriggerPostPro.action b/unilabos_msgs/action/PostProcessTriggerPostPro.action new file mode 100644 index 00000000..a5fa0598 --- /dev/null +++ b/unilabos_msgs/action/PostProcessTriggerPostPro.action @@ -0,0 +1,20 @@ +# Goal - 后处理参数 +float64 atomization_fast_speed +float64 wash_slow_speed +int32 injection_pump_suction_speed +int32 injection_pump_push_speed +int32 raw_liquid_suction_count +float64 first_wash_water_amount +float64 second_wash_water_amount +int32 first_powder_mixing_tim +int32 second_powder_mixing_time +int32 first_powder_wash_count +int32 second_powder_wash_count +float64 initial_water_amount +int32 pre_filtration_mixing_time +int32 atomization_pressure_kpa +--- +# Result - 操作结果 +string return_info # 操作是否成功 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationDripBack.action b/unilabos_msgs/action/ReactionStationDripBack.action new file mode 100644 index 00000000..df690b3b --- /dev/null +++ b/unilabos_msgs/action/ReactionStationDripBack.action @@ -0,0 +1,11 @@ +# Goal - 滴回去 +string volume # 投料体积 +string assign_material_name # 溶剂名称 +string time # 观察时间(单位min) +string torque_variation #是否观察1否2是 +--- +# Result - 操作结果 +string return_info # 结果消息 + +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationLiquidFeed.action b/unilabos_msgs/action/ReactionStationLiquidFeed.action new file mode 100644 index 00000000..8be9dbba --- /dev/null +++ b/unilabos_msgs/action/ReactionStationLiquidFeed.action @@ -0,0 +1,11 @@ +# Goal - 液体投料 +string titration_type # 滴定类型1否2是 +string volume # 投料体积 +string assign_material_name # 溶剂名称 +string time # 观察时间(单位min) +string torque_variation #是否观察1否2是 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationProExecu.action b/unilabos_msgs/action/ReactionStationProExecu.action new file mode 100644 index 00000000..0c4649a8 --- /dev/null +++ b/unilabos_msgs/action/ReactionStationProExecu.action @@ -0,0 +1,8 @@ +# Goal - 合并工作流+执行 +string workflow_name # 工作流名称 +string task_name # 任务名称 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationReaTackIn.action b/unilabos_msgs/action/ReactionStationReaTackIn.action new file mode 100644 index 00000000..78d873ac --- /dev/null +++ b/unilabos_msgs/action/ReactionStationReaTackIn.action @@ -0,0 +1,9 @@ +# Goal - 通量-配置 +string cutoff # 黏度_通量-配置 +string temperature # 温度_通量-配置 +string assign_material_name # 分液类型_通量-配置 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 diff --git a/unilabos_msgs/action/ReactionStationSolidFeedVial.action b/unilabos_msgs/action/ReactionStationSolidFeedVial.action new file mode 100644 index 00000000..b51096d1 --- /dev/null +++ b/unilabos_msgs/action/ReactionStationSolidFeedVial.action @@ -0,0 +1,10 @@ +# Goal - 固体投料-小瓶 +string assign_material_name # 固体名称_粉末加样模块-投料 +string material_id # 固体投料类型_粉末加样模块-投料 +string time # 观察时间_反应模块-观察搅拌结果 +string torque_variation #是否观察1否2是_反应模块-观察搅拌结果 +--- +# Result - 操作结果 +string return_info # 结果消息 +--- +# Feedback - 实时反馈 From 1ec642ee3a12cd3f759bf96f035f6684a2781a3c Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ZiWei09@users.noreply.github.com> Date: Wed, 27 Aug 2025 01:55:28 +0800 Subject: [PATCH 07/13] =?UTF-8?q?update:=20Workstation=20dev=20=E5=B0=86?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=E4=BB=8E=200.10.3=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=BA=200.10.4=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add:msgs.action * update: 将版本号从 0.10.3 更新为 0.10.4 --- .conda/recipe.yaml | 4 ++-- recipes/msgs/recipe.yaml | 2 +- recipes/unilabos/recipe.yaml | 2 +- setup.py | 2 +- unilabos_msgs/package.xml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.conda/recipe.yaml b/.conda/recipe.yaml index 44f2b2e8..2d703002 100644 --- a/.conda/recipe.yaml +++ b/.conda/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: 0.10.3 + version: 0.10.4 source: path: ../unilabos @@ -86,5 +86,5 @@ requirements: about: repository: https://github.com/dptech-corp/Uni-Lab-OS - license: GPL-3.0 + license: GPL-3.0-only description: "Uni-Lab-OS" diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 85f648cd..0eaf6e39 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.3 + version: 0.10.4 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 33448231..2b9e3c86 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.3" + version: "0.10.4" source: path: ../.. diff --git a/setup.py b/setup.py index d3282041..86b93d88 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.3', + version='0.10.4', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index 4cea3066..95beaa4f 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.3 + 0.10.4 ROS2 Messages package for unilabos devices Junhan Chang MIT From 332b33c6f4753132d029aa3fd27173dbc04376ae Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Wed, 27 Aug 2025 11:13:56 +0800 Subject: [PATCH 08/13] simplify resource system --- unilabos/devices/workstation/README.md | 184 +++++++ .../devices/workstation/workflow_executors.py | 12 +- .../devices/workstation/workstation_base.py | 498 ++++++++---------- 3 files changed, 407 insertions(+), 287 deletions(-) create mode 100644 unilabos/devices/workstation/README.md diff --git a/unilabos/devices/workstation/README.md b/unilabos/devices/workstation/README.md new file mode 100644 index 00000000..710a2211 --- /dev/null +++ b/unilabos/devices/workstation/README.md @@ -0,0 +1,184 @@ +# 工作站抽象基类物料系统架构说明 + +## 设计理念 + +基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。 + +### 核心原则 + +1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系 +2. **Graphio转换函数集成**:使用graphio中的`resource_ulab_to_plr`等转换函数实现UniLab与PLR格式的无缝转换 +3. **关注点分离**:基类专注核心物料系统,HTTP服务等功能在子类中实现 +4. **外部系统集成模式**:通过ResourceSynchronizer抽象类提供外部物料系统对接模式 + +## 架构组成 + +### 1. WorkstationBase(基类) +**文件**: `workstation_base.py` + +**核心功能**: +- 使用deck_config和children通过`resource_ulab_to_plr`转换为PLR物料self.deck +- 基础的资源查找和管理功能 +- 抽象的工作流执行接口 +- ResourceSynchronizer集成点 + +**关键代码**: +```python +def _initialize_material_system(self, deck_config: Dict[str, Any], children_config: Dict[str, Any] = None): + """初始化基于PLR的物料系统""" + # 合并deck_config和children + complete_config = self._merge_deck_and_children_config(deck_config, children_config) + + # 使用graphio转换函数转换为PLR资源 + self.deck = resource_ulab_to_plr(complete_config) +``` + +### 2. ResourceSynchronizer(外部系统集成抽象类) +**定义在**: `workstation_base.py` + +**设计目的**: +- 提供外部物料系统(如Bioyong、LIMS等)集成的标准接口 +- 双向同步:从外部系统同步到本地deck,以及将本地变更同步到外部系统 +- 处理外部系统的变更通知 + +**核心方法**: +```python +async def sync_from_external(self) -> bool: + """从外部系统同步物料到本地deck""" + +async def sync_to_external(self, plr_resource) -> bool: + """将本地物料同步到外部系统""" + +async def handle_external_change(self, change_info: Dict[str, Any]) -> bool: + """处理外部系统的变更通知""" +``` + +### 3. WorkstationWithHTTP(子类示例) +**文件**: `workstation_with_http_example.py` + +**扩展功能**: +- HTTP报送接收服务集成 +- 具体工作流实现(液体转移、板洗等) +- Bioyong物料系统同步器示例 +- 外部报送处理方法 + +## 技术栈 + +### 核心依赖 +- **PyLabRobot**: 物料资源管理核心(Deck, Resource, Coordinate) +- **GraphIO转换函数**: UniLab ↔ PLR格式转换 + - `resource_ulab_to_plr`: UniLab格式转PLR格式 + - `resource_plr_to_ulab`: PLR格式转UniLab格式 + - `convert_resources_to_type`: 通用资源类型转换 +- **ROS2**: 基础设备节点通信(BaseROS2DeviceNode) + +### 可选依赖 +- **HTTP服务**: 仅在需要外部报送接收的子类中使用 +- **外部系统API**: 根据具体集成需求添加 + +## 使用示例 + +### 1. 简单工作站(仅PLR物料系统) + +```python +from unilabos.devices.workstation.workstation_base import WorkstationBase + +# Deck配置 +deck_config = { + "size_x": 1200.0, + "size_y": 800.0, + "size_z": 100.0 +} + +# 子资源配置 +children_config = { + "source_plate": { + "name": "source_plate", + "type": "plate", + "position": {"x": 100, "y": 100, "z": 10}, + "config": {"size_x": 127.8, "size_y": 85.5, "size_z": 14.4} + } +} + +# 创建工作站 +workstation = WorkstationBase( + device_id="simple_workstation", + deck_config=deck_config, + children_config=children_config +) + +# 查找资源 +plate = workstation.find_resource_by_name("source_plate") +``` + +### 2. 带HTTP服务的工作站 + +```python +from unilabos.devices.workstation.workstation_with_http_example import WorkstationWithHTTP + +# HTTP服务配置 +http_service_config = { + "enabled": True, + "host": "127.0.0.1", + "port": 8081 +} + +# 创建带HTTP服务的工作站 +workstation = WorkstationWithHTTP( + device_id="http_workstation", + deck_config=deck_config, + children_config=children_config, + http_service_config=http_service_config +) + +# 执行工作流 +success = workstation.execute_workflow("liquid_transfer", { + "volume": 100.0, + "source_wells": ["A1", "A2"], + "dest_wells": ["B1", "B2"] +}) +``` + +### 3. 外部系统集成 + +```python +class BioyongResourceSynchronizer(ResourceSynchronizer): + """Bioyong系统同步器""" + + async def sync_from_external(self) -> bool: + # 从Bioyong API获取物料 + external_materials = await self._fetch_bioyong_materials() + + # 转换并添加到本地deck + for material in external_materials: + await self._add_material_to_deck(material) + + return True +``` + +## 设计优势 + +### 1. **简洁性** +- 基类只专注核心物料管理,没有冗余功能 +- 使用成熟的PyLabRobot作为物料管理基础 + +### 2. **可扩展性** +- 通过子类添加HTTP服务、特定工作流等功能 +- ResourceSynchronizer模式支持任意外部系统集成 + +### 3. **标准化** +- PLR Deck提供标准的资源管理接口 +- Graphio转换函数确保格式一致性 + +### 4. **灵活性** +- 可选择性使用HTTP服务和外部系统集成 +- 支持不同类型的工作站需求 + +## 发展历程 + +1. **初始设计**: 复杂的统一物料系统,包含HTTP服务和多种功能 +2. **PyLabRobot集成**: 引入PLR Deck管理,但保留了ResourceTracker复杂性 +3. **Graphio转换**: 使用graphio转换函数简化初始化 +4. **最终简化**: 专注核心PLR物料系统,HTTP服务移至子类 + +这个架构体现了"用PyLabRobot Deck来管理物料会更好;但是要做好和外部物料系统的对接"的设计理念,以及"现在我只需要在工作站创建的时候,整体使用deck_config和children,一起通过resource_ulab_to_plr转换为plr物料self.deck即可"的简化要求。 diff --git a/unilabos/devices/workstation/workflow_executors.py b/unilabos/devices/workstation/workflow_executors.py index 93f00ae4..41a51c14 100644 --- a/unilabos/devices/workstation/workflow_executors.py +++ b/unilabos/devices/workstation/workflow_executors.py @@ -606,12 +606,12 @@ class ProxyWorkflowExecutor(WorkflowExecutor): """执行代理工作流""" try: # 通过协议节点调用目标设备的工作流 - if self.workstation._protocol_node: - return self.workstation._protocol_node.call_device_method( + if self.workstation._workstation_node: + return self.workstation._workstation_node.call_device_method( self.device_id, 'execute_workflow', workflow_name, parameters ) else: - logger.error("代理模式需要protocol_node") + logger.error("代理模式需要workstation_node") return False except Exception as e: @@ -621,12 +621,12 @@ class ProxyWorkflowExecutor(WorkflowExecutor): def stop_workflow(self, emergency: bool = False) -> bool: """停止代理工作流""" try: - if self.workstation._protocol_node: - return self.workstation._protocol_node.call_device_method( + if self.workstation._workstation_node: + return self.workstation._workstation_node.call_device_method( self.device_id, 'stop_workflow', emergency ) else: - logger.error("代理模式需要protocol_node") + logger.error("代理模式需要workstation_node") return False except Exception as e: diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py index 4b3c192b..529acfd3 100644 --- a/unilabos/devices/workstation/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -1,24 +1,24 @@ """ 工作站基类 -Workstation Base Class - 单接口模式 +Workstation Base Class - 简化版 -基于单一硬件接口的简化工作站架构 -支持直接模式和代理模式的自动工作流执行器选择 +基于PLR Deck的简化工作站架构 +专注于核心物料系统和工作流管理 """ import time -import traceback -from typing import Dict, Any, List, Optional, Union, TYPE_CHECKING +from typing import Dict, Any, List, Optional, Union from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum -if TYPE_CHECKING: - from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode +try: + from pylabrobot.resources import Deck, Resource as PLRResource + PYLABROBOT_AVAILABLE = True +except ImportError: + PYLABROBOT_AVAILABLE = False + class Deck: pass + class PLRResource: pass -from unilabos.devices.work_station.workstation_material_management import MaterialManagementBase -from unilabos.devices.work_station.workstation_http_service import ( - WorkstationHTTPService, WorkstationReportRequest, MaterialUsage -) from unilabos.utils.log import logger @@ -45,43 +45,69 @@ class WorkflowInfo: parameters_schema: Dict[str, Any] # 参数架构 -class WorkstationBase(ABC): - """工作站基类 - 单接口模式 +class ResourceSynchronizer(ABC): + """资源同步器基类 - 核心设计原则: - 1. 每个工作站只有一个 hardware_interface - 2. 根据接口类型自动选择工作流执行器 - 3. 支持直接模式和代理模式 - 4. 统一的设备操作接口 + 负责与外部物料系统的同步,并对 self.deck 做修改 + """ + + def __init__(self, workstation: 'WorkstationBase'): + self.workstation = workstation + + @abstractmethod + async def sync_from_external(self) -> bool: + """从外部系统同步物料到本地deck""" + pass + + @abstractmethod + async def sync_to_external(self, plr_resource: PLRResource) -> bool: + """将本地物料同步到外部系统""" + pass + + @abstractmethod + async def handle_external_change(self, change_info: Dict[str, Any]) -> bool: + """处理外部系统的变更通知""" + pass + + +class WorkstationBase(ABC): + """工作站基类 - 简化版 + + 核心功能: + 1. 基于 PLR Deck 的物料系统,支持格式转换 + 2. 可选的资源同步器支持外部物料系统 + 3. 简化的工作流管理 """ def __init__( self, device_id: str, - deck_config: Optional[Dict[str, Any]] = None, - http_service_config: Optional[Dict[str, Any]] = None, + deck_config: Dict[str, Any], + children: Optional[Dict[str, Any]] = None, + resource_synchronizer: Optional[ResourceSynchronizer] = None, *args, **kwargs, ): + if not PYLABROBOT_AVAILABLE: + raise ImportError("PyLabRobot 未安装,无法创建工作站") + # 基本配置 self.device_id = device_id - self.deck_config = deck_config or {"size_x": 1000.0, "size_y": 1000.0, "size_z": 500.0} + self.deck_config = deck_config + self.children = children or {} - # HTTP服务配置 - self.http_service_config = http_service_config or { - "enabled": True, - "host": "127.0.0.1", - "port": 8081 - } + # PLR 物料系统 + self.deck: Optional[Deck] = None + self.plr_resources: Dict[str, PLRResource] = {} - # 单一硬件接口 - 可以是具体客户端对象或代理字符串 + # 资源同步器(可选) + self.resource_synchronizer = resource_synchronizer + + # 硬件接口 self.hardware_interface: Union[Any, str] = None # 协议节点引用(用于代理模式) - self._protocol_node: Optional['ROS2WorkstationNode'] = None - - # 工作流执行器(基于通信接口类型自动选择) - self.workflow_executor: Optional['WorkflowExecutor'] = None + self._workstation_node: Optional['ROS2WorkstationNode'] = None # 工作流状态 self.current_workflow_status = WorkflowStatus.IDLE @@ -89,90 +115,136 @@ class WorkstationBase(ABC): self.workflow_start_time = None self.workflow_parameters = {} - # 错误处理 - self.error_history = [] - self.action_results = {} - # 支持的工作流(静态预定义) self.supported_workflows: Dict[str, WorkflowInfo] = {} - # 初始化工作站模块 - self.material_management: MaterialManagementBase = self._create_material_management_module() + # 初始化物料系统 + self._initialize_material_system() # 注册支持的工作流 self._register_supported_workflows() - # 启动HTTP报送接收服务 - self.http_service = None - self._start_http_service() - - logger.info(f"工作站 {device_id} 初始化完成(单接口模式)") + logger.info(f"工作站 {device_id} 初始化完成(简化版)") + def _initialize_material_system(self): + """初始化物料系统 - 使用 graphio 转换""" + try: + from unilabos.resources.graphio import resource_ulab_to_plr + + # 1. 合并 deck_config 和 children 创建完整的资源树 + complete_resource_config = self._create_complete_resource_config() + + # 2. 使用 graphio 转换为 PLR 资源 + self.deck = resource_ulab_to_plr(complete_resource_config, plr_model=True) + + # 3. 建立资源映射 + self._build_resource_mappings(self.deck) + + # 4. 如果有资源同步器,执行初始同步 + if self.resource_synchronizer: + # 这里可以异步执行,暂时跳过 + pass + + logger.info(f"工作站 {self.device_id} 物料系统初始化成功,创建了 {len(self.plr_resources)} 个资源") + + except Exception as e: + logger.error(f"工作站 {self.device_id} 物料系统初始化失败: {e}") + raise + + def _create_complete_resource_config(self) -> Dict[str, Any]: + """创建完整的资源配置 - 合并 deck_config 和 children""" + # 创建主 deck 配置 + deck_resource = { + "id": f"{self.device_id}_deck", + "name": f"{self.device_id}_deck", + "type": "deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "size_x": self.deck_config.get("size_x", 1000.0), + "size_y": self.deck_config.get("size_y", 1000.0), + "size_z": self.deck_config.get("size_z", 100.0), + **{k: v for k, v in self.deck_config.items() if k not in ["size_x", "size_y", "size_z"]} + }, + "data": {}, + "children": [], + "parent": None + } + + # 添加子资源 + if self.children: + children_list = [] + for child_id, child_config in self.children.items(): + child_resource = self._normalize_child_resource(child_id, child_config, deck_resource["id"]) + children_list.append(child_resource) + deck_resource["children"] = children_list + + return deck_resource + + def _normalize_child_resource(self, resource_id: str, config: Dict[str, Any], parent_id: str) -> Dict[str, Any]: + """标准化子资源配置""" + return { + "id": resource_id, + "name": config.get("name", resource_id), + "type": config.get("type", "container"), + "position": self._normalize_position(config.get("position", {})), + "config": config.get("config", {}), + "data": config.get("data", {}), + "children": [], # 简化版本:只支持一层子资源 + "parent": parent_id + } + + def _normalize_position(self, position: Any) -> Dict[str, float]: + """标准化位置信息""" + if isinstance(position, dict): + return { + "x": float(position.get("x", 0)), + "y": float(position.get("y", 0)), + "z": float(position.get("z", 0)) + } + elif isinstance(position, (list, tuple)) and len(position) >= 2: + return { + "x": float(position[0]), + "y": float(position[1]), + "z": float(position[2]) if len(position) > 2 else 0.0 + } + else: + return {"x": 0.0, "y": 0.0, "z": 0.0} + + def _build_resource_mappings(self, deck: Deck): + """递归构建资源映射""" + def add_resource_recursive(resource: PLRResource): + if hasattr(resource, 'name'): + self.plr_resources[resource.name] = resource + + if hasattr(resource, 'children'): + for child in resource.children: + add_resource_recursive(child) + + add_resource_recursive(deck) + + # ============ 硬件接口管理 ============ + def set_hardware_interface(self, hardware_interface: Union[Any, str]): """设置硬件接口""" self.hardware_interface = hardware_interface - - # 根据接口类型自动创建工作流执行器 - self._setup_workflow_executor() - logger.info(f"工作站 {self.device_id} 硬件接口设置: {type(hardware_interface).__name__}") - def set_protocol_node(self, protocol_node: 'ROS2WorkstationNode'): + def set_workstation_node(self, workstation_node: 'ROS2WorkstationNode'): """设置协议节点引用(用于代理模式)""" - self._protocol_node = protocol_node + self._workstation_node = workstation_node logger.info(f"工作站 {self.device_id} 关联协议节点") - def _setup_workflow_executor(self): - """根据硬件接口类型自动设置工作流执行器""" - if self.hardware_interface is None: - return - - # 动态导入工作流执行器类 - try: - from unilabos.devices.work_station.workflow_executors import ( - ProxyWorkflowExecutor, ModbusWorkflowExecutor, - HttpWorkflowExecutor, PyLabRobotWorkflowExecutor - ) - except ImportError: - logger.warning("工作流执行器模块未找到,将使用基础执行器") - self.workflow_executor = None - return - - # 检查是否为代理字符串 - if isinstance(self.hardware_interface, str) and self.hardware_interface.startswith("proxy:"): - self.workflow_executor = ProxyWorkflowExecutor(self) - logger.info(f"工作站 {self.device_id} 使用代理工作流执行器") - - # 检查是否为Modbus客户端 - elif hasattr(self.hardware_interface, 'write_register') and hasattr(self.hardware_interface, 'read_register'): - self.workflow_executor = ModbusWorkflowExecutor(self) - logger.info(f"工作站 {self.device_id} 使用Modbus工作流执行器") - - # 检查是否为HTTP客户端 - elif hasattr(self.hardware_interface, 'post') or hasattr(self.hardware_interface, 'get'): - self.workflow_executor = HttpWorkflowExecutor(self) - logger.info(f"工作站 {self.device_id} 使用HTTP工作流执行器") - - # 检查是否为PyLabRobot设备 - elif hasattr(self.hardware_interface, 'transfer_liquid') or hasattr(self.hardware_interface, 'pickup_tips'): - self.workflow_executor = PyLabRobotWorkflowExecutor(self) - logger.info(f"工作站 {self.device_id} 使用PyLabRobot工作流执行器") - - else: - logger.warning(f"工作站 {self.device_id} 无法识别硬件接口类型: {type(self.hardware_interface)}") - self.workflow_executor = None - - # ============ 统一的设备操作接口 ============ + # ============ 设备操作接口 ============ def call_device_method(self, method: str, *args, **kwargs) -> Any: """调用设备方法的统一接口""" # 1. 代理模式:通过协议节点转发 if isinstance(self.hardware_interface, str) and self.hardware_interface.startswith("proxy:"): - if not self._protocol_node: - raise RuntimeError("代理模式需要设置protocol_node") + if not self._workstation_node: + raise RuntimeError("代理模式需要设置workstation_node") device_id = self.hardware_interface[6:] # 移除 "proxy:" 前缀 - return self._protocol_node.call_device_method(device_id, method, *args, **kwargs) + return self._workstation_node.call_device_method(device_id, method, *args, **kwargs) # 2. 直接模式:直接调用硬件接口方法 elif self.hardware_interface and hasattr(self.hardware_interface, method): @@ -201,22 +273,54 @@ class WorkstationBase(ABC): except: return False - # ============ 工作流控制接口 ============ + # ============ 物料系统接口 ============ + + def get_deck(self) -> Deck: + """获取主 Deck""" + return self.deck + + def get_all_resources(self) -> Dict[str, PLRResource]: + """获取所有 PLR 资源""" + return self.plr_resources.copy() + + def find_resource_by_name(self, name: str) -> Optional[PLRResource]: + """按名称查找资源""" + return self.plr_resources.get(name) + + def find_resources_by_type(self, resource_type: type) -> List[PLRResource]: + """按类型查找资源""" + return [res for res in self.plr_resources.values() + if isinstance(res, resource_type)] + + async def sync_with_external_system(self) -> bool: + """与外部物料系统同步""" + if not self.resource_synchronizer: + logger.info(f"工作站 {self.device_id} 没有配置资源同步器") + return True + + try: + success = await self.resource_synchronizer.sync_from_external() + if success: + logger.info(f"工作站 {self.device_id} 外部同步成功") + else: + logger.warning(f"工作站 {self.device_id} 外部同步失败") + return success + except Exception as e: + logger.error(f"工作站 {self.device_id} 外部同步异常: {e}") + return False + + # ============ 简化的工作流控制 ============ def execute_workflow(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: - """执行工作流 - 委托给工作流执行器""" - if not self.workflow_executor: - logger.error(f"工作站 {self.device_id} 工作流执行器未初始化") - return False - + """执行工作流""" try: # 设置工作流状态 self.current_workflow_status = WorkflowStatus.INITIALIZING self.workflow_parameters = parameters self.workflow_start_time = time.time() - # 委托给工作流执行器 - success = self.workflow_executor.execute_workflow(workflow_name, parameters) + # 委托给子类实现 + success = self._execute_workflow_impl(workflow_name, parameters) if success: self.current_workflow_status = WorkflowStatus.RUNNING @@ -232,16 +336,8 @@ class WorkstationBase(ABC): logger.error(f"工作站 {self.device_id} 执行工作流失败: {e}") return False - def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool: - """启动工作流 - 兼容旧接口""" - return self.execute_workflow(workflow_type, parameters or {}) - def stop_workflow(self, emergency: bool = False) -> bool: """停止工作流""" - if not self.workflow_executor: - logger.warning(f"工作站 {self.device_id} 工作流执行器未初始化") - return True - try: if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: logger.warning(f"工作站 {self.device_id} 没有正在运行的工作流") @@ -249,8 +345,8 @@ class WorkstationBase(ABC): self.current_workflow_status = WorkflowStatus.STOPPING - # 委托给工作流执行器 - success = self.workflow_executor.stop_workflow(emergency) + # 委托给子类实现 + success = self._stop_workflow_impl(emergency) if success: self.current_workflow_status = WorkflowStatus.STOPPED @@ -289,179 +385,19 @@ class WorkstationBase(ABC): return 0.0 return time.time() - self.workflow_start_time - @property - def error_count(self) -> int: - """获取错误计数""" - return len(self.error_history) - - @property - def last_error(self) -> Optional[Dict[str, Any]]: - """获取最后一个错误""" - return self.error_history[-1] if self.error_history else None - # ============ 抽象方法 - 子类必须实现 ============ - @abstractmethod - def _create_material_management_module(self) -> MaterialManagementBase: - """创建物料管理模块 - 子类必须实现""" - pass - @abstractmethod def _register_supported_workflows(self): """注册支持的工作流 - 子类必须实现""" pass - - # ============ HTTP服务管理 ============ - - def _start_http_service(self): - """启动HTTP报送接收服务""" - try: - if not self.http_service_config.get("enabled", True): - logger.info(f"工作站 {self.device_id} HTTP报送接收服务已禁用") - return - - host = self.http_service_config.get("host", "127.0.0.1") - port = self.http_service_config.get("port", 8081) - - self.http_service = WorkstationHTTPService( - workstation_handler=self, - host=host, - port=port - ) - - logger.info(f"工作站 {self.device_id} HTTP报送接收服务启动成功: {host}:{port}") - - except Exception as e: - logger.error(f"工作站 {self.device_id} 启动HTTP报送接收服务失败: {e}") - self.http_service = None - - def _stop_http_service(self): - """停止HTTP报送接收服务""" - try: - if self.http_service: - self.http_service.stop() - self.http_service = None - logger.info(f"工作站 {self.device_id} HTTP报送接收服务已停止") - except Exception as e: - logger.error(f"工作站 {self.device_id} 停止HTTP报送接收服务失败: {e}") - - # ============ 报送处理方法 ============ - - def process_material_change_report(self, report) -> Dict[str, Any]: - """处理物料变更报送""" - try: - logger.info(f"处理物料变更报送: {report.workstation_id} -> {report.resource_id} ({report.change_type})") - - result = { - 'processed': True, - 'resource_id': report.resource_id, - 'change_type': report.change_type, - 'timestamp': time.time() - } - - # 更新本地物料管理系统 - if hasattr(self, 'material_management'): - try: - self.material_management.sync_external_material_change(report) - except Exception as e: - logger.warning(f"同步物料变更到本地管理系统失败: {e}") - - return result - - except Exception as e: - logger.error(f"处理物料变更报送失败: {e}") - return {'processed': False, 'error': str(e)} - - def process_step_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: - """处理步骤完成报送(统一LIMS协议规范)""" - try: - data = request.data - logger.info(f"处理步骤完成报送: {data['orderCode']} - {data['stepName']}") - - result = { - 'processed': True, - 'order_code': data['orderCode'], - 'step_id': data['stepId'], - 'timestamp': time.time() - } - - return result - - except Exception as e: - logger.error(f"处理步骤完成报送失败: {e}") - return {'processed': False, 'error': str(e)} - - def process_sample_finish_report(self, request: WorkstationReportRequest) -> Dict[str, Any]: - """处理样品完成报送""" - try: - data = request.data - logger.info(f"处理样品完成报送: {data['sampleId']}") - - result = { - 'processed': True, - 'sample_id': data['sampleId'], - 'timestamp': time.time() - } - - return result - - except Exception as e: - logger.error(f"处理样品完成报送失败: {e}") - return {'processed': False, 'error': str(e)} - - def process_order_finish_report(self, request: WorkstationReportRequest, used_materials: List[MaterialUsage]) -> Dict[str, Any]: - """处理订单完成报送""" - try: - data = request.data - logger.info(f"处理订单完成报送: {data['orderCode']}") - - result = { - 'processed': True, - 'order_code': data['orderCode'], - 'used_materials': len(used_materials), - 'timestamp': time.time() - } - - return result - - except Exception as e: - logger.error(f"处理订单完成报送失败: {e}") - return {'processed': False, 'error': str(e)} - - def handle_external_error(self, error_request): - """处理外部错误报告""" - try: - logger.error(f"收到外部错误报告: {error_request}") - - # 记录错误 - error_record = { - 'timestamp': time.time(), - 'error_type': error_request.get('error_type', 'unknown'), - 'error_message': error_request.get('message', ''), - 'source': error_request.get('source', 'external'), - 'context': error_request.get('context', {}) - } - - self.error_history.append(error_record) - - # 处理紧急停止情况 - if error_request.get('emergency_stop', False): - self._trigger_emergency_stop(error_record['error_message']) - - return {'processed': True, 'error_id': len(self.error_history)} - - except Exception as e: - logger.error(f"处理外部错误失败: {e}") - return {'processed': False, 'error': str(e)} - - def _trigger_emergency_stop(self, reason: str): - """触发紧急停止""" - logger.critical(f"触发紧急停止: {reason}") - self.stop_workflow(emergency=True) - - def __del__(self): - """清理资源""" - try: - self._stop_http_service() - except: - pass + + @abstractmethod + def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流的具体实现 - 子类必须实现""" + pass + + @abstractmethod + def _stop_workflow_impl(self, emergency: bool = False) -> bool: + """停止工作流的具体实现 - 子类必须实现""" + pass From 82d9ef6bf788c3cee97ca6bba1d7a7cd211d34ec Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Wed, 27 Aug 2025 15:19:58 +0800 Subject: [PATCH 09/13] uncompleted refactor --- unilabos/registry/devices/work_station.yaml | 2 +- unilabos/ros/nodes/base_device_node.py | 14 +++++++------- .../{protocol_node.py => workstation_node.py} | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) rename unilabos/ros/nodes/presets/{protocol_node.py => workstation_node.py} (99%) diff --git a/unilabos/registry/devices/work_station.yaml b/unilabos/registry/devices/work_station.yaml index c96c68a6..0524252f 100644 --- a/unilabos/registry/devices/work_station.yaml +++ b/unilabos/registry/devices/work_station.yaml @@ -6112,7 +6112,7 @@ workstation: title: initialize_device参数 type: object type: UniLabJsonCommand - module: unilabos.ros.nodes.presets.protocol_node:ROS2WorkstationNode + module: unilabos.ros.nodes.presets.workstation_node:ROS2WorkstationNode status_types: {} type: ros2 config_info: [] diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index f88c27d1..ebe59236 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -958,19 +958,19 @@ class ROS2DeviceNode: driver_class, children=children, resource_tracker=self.resource_tracker ) else: - from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode - from unilabos.devices.work_station.workstation_base import WorkstationBase + from unilabos.ros.nodes.presets.workstation_node import ROS2WorkstationNode + from unilabos.devices.workstation.workstation_base import WorkstationBase # 检查是否是WorkstationBase的子类且包含设备子节点 if issubclass(self._driver_class, WorkstationBase) and has_device_children: # WorkstationBase + 设备子节点 -> 使用WorkstationNode作为ros_instance - self._use_protocol_node_ros = True + self._use_workstation_node_ros = True self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) elif issubclass(self._driver_class, ROS2WorkstationNode): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator - self._use_protocol_node_ros = False + self._use_workstation_node_ros = False self._driver_creator = WorkstationNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) else: - self._use_protocol_node_ros = False + self._use_workstation_node_ros = False self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) if driver_is_ros: @@ -984,9 +984,9 @@ class ROS2DeviceNode: # 创建ROS2节点 if driver_is_ros: self._ros_node = self._driver_instance # type: ignore - elif hasattr(self, '_use_protocol_node_ros') and self._use_protocol_node_ros: + elif hasattr(self, '_use_workstation_node_ros') and self._use_workstation_node_ros: # WorkstationBase + 设备子节点 -> 创建ROS2WorkstationNode作为ros_instance - from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode + from unilabos.ros.nodes.presets.workstation_node import ROS2WorkstationNode # 从children提取设备协议类型 protocol_types = set() diff --git a/unilabos/ros/nodes/presets/protocol_node.py b/unilabos/ros/nodes/presets/workstation_node.py similarity index 99% rename from unilabos/ros/nodes/presets/protocol_node.py rename to unilabos/ros/nodes/presets/workstation_node.py index c8c338c3..49c9e223 100644 --- a/unilabos/ros/nodes/presets/protocol_node.py +++ b/unilabos/ros/nodes/presets/workstation_node.py @@ -100,7 +100,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): def _setup_workstation_integration(self): """设置工作站集成 - 统一设备处理模式""" # 1. 建立协议节点引用 - self.workstation_instance.set_protocol_node(self) + self.workstation_instance.set_workstation_node(self) self.lab_logger().info(f"ROS2WorkstationNode {self.device_id} 与工作站实例 {type(self.workstation_instance).__name__} 集成完成") From ce5bab3af1df04c48dc26d985c707660f9032abb Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Wed, 27 Aug 2025 15:20:20 +0800 Subject: [PATCH 10/13] example for use WorkstationBase --- .../coin_cell_assembly/coin_cell_assembly.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py new file mode 100644 index 00000000..ee88e602 --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -0,0 +1,39 @@ +from typing import Any, Dict, Optional +from pylabrobot.resources import Resource as PLRResource +from unilabos.device_comms.modbus_plc.client import ModbusTcpClient +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer, WorkstationBase + + +class CoinCellAssemblyWorkstation(WorkstationBase): + def __init__( + self, + device_id: str, + deck_config: Dict[str, Any], + children: Optional[Dict[str, Any]] = None, + resource_synchronizer: Optional[ResourceSynchronizer] = None, + host: str = "192.168.0.0", + port: str = "", + *args, + **kwargs, + ): + super().__init__( + device_id=device_id, + deck_config=deck_config, + children=children, + resource_synchronizer=resource_synchronizer, + *args, + **kwargs, + ) + + self.hardware_interface = ModbusTcpClient(host=host, port=port) + + def run_assembly(self, wf_name: str, resource: PLRResource, params: str = "\{\}"): + """启动工作流""" + self.current_workflow_status = WorkflowStatus.RUNNING + logger.info(f"工作站 {self.device_id} 启动工作流: {wf_name}") + + # TODO: 实现工作流逻辑 + + anode_sheet = self.deck.get_resource("anode_sheet") + + \ No newline at end of file From 19027350fb90c02f905181c2621610950a2c6f03 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 29 Aug 2025 02:47:20 +0800 Subject: [PATCH 11/13] feat: workstation example --- .../comprehensive_slim.json | 1689 ++++++++++++++++- unilabos/devices/workstation/__init__.py | 0 .../coin_cell_assembly/__init__.py | 0 .../devices/workstation/workstation_base.py | 266 ++- unilabos/registry/devices/work_station.yaml | 6 +- unilabos/resources/graphio.py | 12 +- unilabos/ros/nodes/base_device_node.py | 67 +- unilabos/ros/nodes/presets/workstation.py | 676 ++++++- .../ros/nodes/presets/workstation_node.py | 603 ------ unilabos/ros/nodes/resource_tracker.py | 35 +- unilabos/ros/utils/driver_creator.py | 6 +- 11 files changed, 2533 insertions(+), 827 deletions(-) create mode 100644 unilabos/devices/workstation/__init__.py create mode 100644 unilabos/devices/workstation/coin_cell_assembly/__init__.py delete mode 100644 unilabos/ros/nodes/presets/workstation_node.py diff --git a/test/experiments/comprehensive_protocol/comprehensive_slim.json b/test/experiments/comprehensive_protocol/comprehensive_slim.json index d9dd773d..2eed369c 100644 --- a/test/experiments/comprehensive_protocol/comprehensive_slim.json +++ b/test/experiments/comprehensive_protocol/comprehensive_slim.json @@ -4,11 +4,12 @@ "id": "OrganicSynthesisStation", "name": "有机化学流程综合测试工作站", "children": [ - "heater_1" + "heater_1", + "deck" ], "parent": null, "type": "device", - "class": "workstation", + "class": "workstation.example", "position": { "x": 600, "y": 400, @@ -40,7 +41,13 @@ "DryProtocol", "HydrogenateProtocol", "RecrystallizeProtocol" - ] + ], + "station_resource": { + "data": { + "_resource_child_name": "deck", + "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck" + } + } }, "data": {} }, @@ -64,6 +71,1682 @@ "status": "Idle", "current_temp": 25.0 } + }, + { + "id": "deck", + "name": "deck", + "sample_id": null, + "children": [ + "tip_rack", + "plate_well" + ], + "parent": "OrganicSynthesisStation", + "type": "deck", + "class": "OTDeck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "OTDeck", + "with_trash": false, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + }, + { + "id": "tip_rack", + "name": "tip_rack", + "sample_id": null, + "children": [ + "tip_rack_A1", + "tip_rack_B1", + "tip_rack_C1", + "tip_rack_D1", + "tip_rack_E1", + "tip_rack_F1", + "tip_rack_G1", + "tip_rack_H1", + "tip_rack_A2", + "tip_rack_B2", + "tip_rack_C2", + "tip_rack_D2", + "tip_rack_E2", + "tip_rack_F2", + "tip_rack_G2", + "tip_rack_H2" + ], + "parent": "deck", + "type": "plate", + "class": "opentrons_96_filtertiprack_1000ul", + "position": { + "x": 0, + "y": 0, + "z": 69 + }, + "config": { + "type": "TipRack", + "size_x": 122.4, + "size_y": 82.6, + "size_z": 20.0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_rack", + "model": "HTF", + "ordering": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2" + ] + }, + "data": {} + }, + { + "id": "tip_rack_A1", + "name": "tip_rack_A1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 68.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_B1", + "name": "tip_rack_B1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 59.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_C1", + "name": "tip_rack_C1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 50.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_D1", + "name": "tip_rack_D1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 41.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_E1", + "name": "tip_rack_E1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 32.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_F1", + "name": "tip_rack_F1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 23.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_G1", + "name": "tip_rack_G1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 14.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_H1", + "name": "tip_rack_H1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 5.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_A2", + "name": "tip_rack_A2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 68.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_B2", + "name": "tip_rack_B2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 59.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_C2", + "name": "tip_rack_C2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 50.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_D2", + "name": "tip_rack_D2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 41.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_E2", + "name": "tip_rack_E2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 32.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_F2", + "name": "tip_rack_F2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 23.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_G2", + "name": "tip_rack_G2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 14.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "tip_rack_H2", + "name": "tip_rack_H2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 5.3, + "z": 9.47 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "Tip", + "total_tip_length": 39.2, + "has_filter": true, + "maximal_volume": 20.0, + "fitting_depth": 3.29 + } + } + }, + { + "id": "plate_well", + "name": "plate_well", + "sample_id": null, + "children": [ + "plate_well_A1", + "plate_well_B1", + "plate_well_C1", + "plate_well_D1", + "plate_well_E1", + "plate_well_F1", + "plate_well_G1", + "plate_well_H1", + "plate_well_A11", + "plate_well_B11", + "plate_well_C11", + "plate_well_D11", + "plate_well_E11", + "plate_well_F11", + "plate_well_G11", + "plate_well_H11" + ], + "parent": "deck", + "type": "plate", + "class": "nest_96_wellplate_2ml_deep", + "position": { + "x": 265.0, + "y": 0, + "z": 69 + }, + "config": { + "type": "Plate", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 14.2, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "plate", + "model": "Cor_96_wellplate_360ul_Fb", + "ordering": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11" + ] + }, + "data": {} + }, + { + "id": "plate_well_A1", + "name": "plate_well_A1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 70.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_B1", + "name": "plate_well_B1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 61.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_C1", + "name": "plate_well_C1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 52.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_D1", + "name": "plate_well_D1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 43.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_E1", + "name": "plate_well_E1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 34.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_F1", + "name": "plate_well_F1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 25.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_G1", + "name": "plate_well_G1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 16.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_H1", + "name": "plate_well_H1", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 7.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_A11", + "name": "plate_well_A11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 70.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_B11", + "name": "plate_well_B11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 61.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_C11", + "name": "plate_well_C11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 52.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_D11", + "name": "plate_well_D11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 43.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_E11", + "name": "plate_well_E11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 34.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_F11", + "name": "plate_well_F11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 25.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_G11", + "name": "plate_well_G11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 16.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "plate_well_H11", + "name": "plate_well_H11", + "sample_id": null, + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 7.77, + "z": 3.03 + }, + "config": { + "type": "Well", + "size_x": 6.86, + "size_y": 6.86, + "size_z": 10.67, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 360, + "material_z_thickness": 0.5, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "flat", + "cross_section_type": "circle" + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } } ], "links": [] diff --git a/unilabos/devices/workstation/__init__.py b/unilabos/devices/workstation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/workstation/coin_cell_assembly/__init__.py b/unilabos/devices/workstation/coin_cell_assembly/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py index 529acfd3..5337f6da 100644 --- a/unilabos/devices/workstation/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -5,27 +5,26 @@ Workstation Base Class - 简化版 基于PLR Deck的简化工作站架构 专注于核心物料系统和工作流管理 """ + +import collections import time from typing import Dict, Any, List, Optional, Union from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum +from pylabrobot.resources import Deck, Plate, Resource as PLRResource -try: - from pylabrobot.resources import Deck, Resource as PLRResource - PYLABROBOT_AVAILABLE = True -except ImportError: - PYLABROBOT_AVAILABLE = False - class Deck: pass - class PLRResource: pass +from pylabrobot.resources.coordinate import Coordinate +from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode from unilabos.utils.log import logger class WorkflowStatus(Enum): """工作流状态""" + IDLE = "idle" - INITIALIZING = "initializing" + INITIALIZING = "initializing" RUNNING = "running" PAUSED = "paused" STOPPING = "stopping" @@ -37,6 +36,7 @@ class WorkflowStatus(Enum): @dataclass class WorkflowInfo: """工作流信息""" + name: str description: str estimated_duration: float # 预估持续时间(秒) @@ -45,25 +45,82 @@ class WorkflowInfo: parameters_schema: Dict[str, Any] # 参数架构 +class WorkStationContainer(Plate): + """ + WorkStation 专用 Container 类,继承自 Plate和TipRack + 注意这个物料必须通过plr_additional_res_reg.py注册到edge,才能正常序列化 + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str, + ordering: collections.OrderedDict, + model: Optional[str] = None, + ): + """ + 这里的初始化入参要和plr的保持一致 + """ + super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) + self._unilabos_state = {} # 必须有此行,自己的类描述的是物料的 + + def load_state(self, state: Dict[str, Any]) -> None: + """从给定的状态加载工作台信息。""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + data = super().serialize_state() + data.update( + self._unilabos_state + ) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + return data + + +def get_workstation_plate_resource(name: str) -> PLRResource: # 要给定一个返回plr的方法 + """ + 用于获取一些模板,例如返回一个带有特定信息/子物料的 Plate,这里需要到注册表注册,例如unilabos/registry/resources/organic/workstation.yaml + 可以直接运行该函数或者利用注册表补全机制,来检查是否资源出错 + :param name: 资源名称 + :return: Resource对象 + """ + plate = WorkStationContainer( + name, size_x=50, size_y=50, size_z=10, category="plate", ordering=collections.OrderedDict() + ) + tip_rack = WorkStationContainer( + "tip_rack_inside_plate", + size_x=50, + size_y=50, + size_z=10, + category="tip_rack", + ordering=collections.OrderedDict(), + ) + plate.assign_child_resource(tip_rack, Coordinate.zero()) + return plate + + class ResourceSynchronizer(ABC): """资源同步器基类 - + 负责与外部物料系统的同步,并对 self.deck 做修改 """ - - def __init__(self, workstation: 'WorkstationBase'): + + def __init__(self, workstation: "WorkstationBase"): self.workstation = workstation - + @abstractmethod async def sync_from_external(self) -> bool: """从外部系统同步物料到本地deck""" pass - + @abstractmethod async def sync_to_external(self, plr_resource: PLRResource) -> bool: """将本地物料同步到外部系统""" pass - + @abstractmethod async def handle_external_change(self, change_info: Dict[str, Any]) -> bool: """处理外部系统的变更通知""" @@ -72,81 +129,80 @@ class ResourceSynchronizer(ABC): class WorkstationBase(ABC): """工作站基类 - 简化版 - + 核心功能: 1. 基于 PLR Deck 的物料系统,支持格式转换 2. 可选的资源同步器支持外部物料系统 3. 简化的工作流管理 """ + _ros_node: ROS2WorkstationNode + + @property + def _children(self) -> Dict[str, Any]: # 不要删除这个下划线,不然会自动导入注册表,后面改成装饰器识别 + return self._ros_node.children + + async def update_resource_example(self): + return await self._ros_node.update_resource([get_workstation_plate_resource("test")]) + def __init__( self, - device_id: str, - deck_config: Dict[str, Any], - children: Optional[Dict[str, Any]] = None, - resource_synchronizer: Optional[ResourceSynchronizer] = None, + station_resource: PLRResource, *args, - **kwargs, + **kwargs, # 必须有kwargs ): - if not PYLABROBOT_AVAILABLE: - raise ImportError("PyLabRobot 未安装,无法创建工作站") - # 基本配置 - self.device_id = device_id - self.deck_config = deck_config - self.children = children or {} - + print(station_resource) + self.deck_config = station_resource + # PLR 物料系统 self.deck: Optional[Deck] = None self.plr_resources: Dict[str, PLRResource] = {} - + # 资源同步器(可选) - self.resource_synchronizer = resource_synchronizer - + self.resource_synchronizer = ResourceSynchronizer(self) # 要在driver中自行初始化,只有workstation用 + # 硬件接口 self.hardware_interface: Union[Any, str] = None - - # 协议节点引用(用于代理模式) - self._workstation_node: Optional['ROS2WorkstationNode'] = None - + # 工作流状态 self.current_workflow_status = WorkflowStatus.IDLE self.current_workflow_info = None self.workflow_start_time = None self.workflow_parameters = {} - + # 支持的工作流(静态预定义) self.supported_workflows: Dict[str, WorkflowInfo] = {} - + # 初始化物料系统 self._initialize_material_system() - + # 注册支持的工作流 self._register_supported_workflows() - + logger.info(f"工作站 {device_id} 初始化完成(简化版)") def _initialize_material_system(self): """初始化物料系统 - 使用 graphio 转换""" try: from unilabos.resources.graphio import resource_ulab_to_plr - + # 1. 合并 deck_config 和 children 创建完整的资源树 complete_resource_config = self._create_complete_resource_config() - + # 2. 使用 graphio 转换为 PLR 资源 self.deck = resource_ulab_to_plr(complete_resource_config, plr_model=True) - + # 3. 建立资源映射 self._build_resource_mappings(self.deck) - + # 4. 如果有资源同步器,执行初始同步 if self.resource_synchronizer: # 这里可以异步执行,暂时跳过 pass - + logger.info(f"工作站 {self.device_id} 物料系统初始化成功,创建了 {len(self.plr_resources)} 个资源") - + except Exception as e: logger.error(f"工作站 {self.device_id} 物料系统初始化失败: {e}") raise @@ -163,21 +219,21 @@ class WorkstationBase(ABC): "size_x": self.deck_config.get("size_x", 1000.0), "size_y": self.deck_config.get("size_y", 1000.0), "size_z": self.deck_config.get("size_z", 100.0), - **{k: v for k, v in self.deck_config.items() if k not in ["size_x", "size_y", "size_z"]} + **{k: v for k, v in self.deck_config.items() if k not in ["size_x", "size_y", "size_z"]}, }, "data": {}, "children": [], - "parent": None + "parent": None, } - + # 添加子资源 - if self.children: + if self._children: children_list = [] - for child_id, child_config in self.children.items(): + for child_id, child_config in self._children.items(): child_resource = self._normalize_child_resource(child_id, child_config, deck_resource["id"]) children_list.append(child_resource) deck_resource["children"] = children_list - + return deck_resource def _normalize_child_resource(self, resource_id: str, config: Dict[str, Any], parent_id: str) -> Dict[str, Any]: @@ -190,7 +246,7 @@ class WorkstationBase(ABC): "config": config.get("config", {}), "data": config.get("data", {}), "children": [], # 简化版本:只支持一层子资源 - "parent": parent_id + "parent": parent_id, } def _normalize_position(self, position: Any) -> Dict[str, float]: @@ -199,70 +255,71 @@ class WorkstationBase(ABC): return { "x": float(position.get("x", 0)), "y": float(position.get("y", 0)), - "z": float(position.get("z", 0)) + "z": float(position.get("z", 0)), } elif isinstance(position, (list, tuple)) and len(position) >= 2: return { "x": float(position[0]), "y": float(position[1]), - "z": float(position[2]) if len(position) > 2 else 0.0 + "z": float(position[2]) if len(position) > 2 else 0.0, } else: return {"x": 0.0, "y": 0.0, "z": 0.0} def _build_resource_mappings(self, deck: Deck): """递归构建资源映射""" + def add_resource_recursive(resource: PLRResource): - if hasattr(resource, 'name'): + if hasattr(resource, "name"): self.plr_resources[resource.name] = resource - - if hasattr(resource, 'children'): + + if hasattr(resource, "children"): for child in resource.children: add_resource_recursive(child) - + add_resource_recursive(deck) # ============ 硬件接口管理 ============ - + def set_hardware_interface(self, hardware_interface: Union[Any, str]): """设置硬件接口""" self.hardware_interface = hardware_interface logger.info(f"工作站 {self.device_id} 硬件接口设置: {type(hardware_interface).__name__}") - def set_workstation_node(self, workstation_node: 'ROS2WorkstationNode'): + def set_workstation_node(self, workstation_node: "ROS2WorkstationNode"): """设置协议节点引用(用于代理模式)""" - self._workstation_node = workstation_node + self._ros_node = workstation_node logger.info(f"工作站 {self.device_id} 关联协议节点") # ============ 设备操作接口 ============ - + def call_device_method(self, method: str, *args, **kwargs) -> Any: """调用设备方法的统一接口""" # 1. 代理模式:通过协议节点转发 if isinstance(self.hardware_interface, str) and self.hardware_interface.startswith("proxy:"): - if not self._workstation_node: + if not self._ros_node: raise RuntimeError("代理模式需要设置workstation_node") - + device_id = self.hardware_interface[6:] # 移除 "proxy:" 前缀 - return self._workstation_node.call_device_method(device_id, method, *args, **kwargs) - + return self._ros_node.call_device_method(device_id, method, *args, **kwargs) + # 2. 直接模式:直接调用硬件接口方法 elif self.hardware_interface and hasattr(self.hardware_interface, method): return getattr(self.hardware_interface, method)(*args, **kwargs) - + else: raise AttributeError(f"硬件接口不支持方法: {method}") def get_device_status(self) -> Dict[str, Any]: """获取设备状态""" try: - return self.call_device_method('get_status') + return self.call_device_method("get_status") except AttributeError: # 如果设备不支持get_status方法,返回基础状态 return { "status": "unknown", "interface_type": type(self.hardware_interface).__name__, - "timestamp": time.time() + "timestamp": time.time(), } def is_device_available(self) -> bool: @@ -274,30 +331,29 @@ class WorkstationBase(ABC): return False # ============ 物料系统接口 ============ - + def get_deck(self) -> Deck: """获取主 Deck""" return self.deck - + def get_all_resources(self) -> Dict[str, PLRResource]: """获取所有 PLR 资源""" return self.plr_resources.copy() - + def find_resource_by_name(self, name: str) -> Optional[PLRResource]: """按名称查找资源""" return self.plr_resources.get(name) - + def find_resources_by_type(self, resource_type: type) -> List[PLRResource]: """按类型查找资源""" - return [res for res in self.plr_resources.values() - if isinstance(res, resource_type)] - + return [res for res in self.plr_resources.values() if isinstance(res, resource_type)] + async def sync_with_external_system(self) -> bool: """与外部物料系统同步""" if not self.resource_synchronizer: logger.info(f"工作站 {self.device_id} 没有配置资源同步器") return True - + try: success = await self.resource_synchronizer.sync_from_external() if success: @@ -318,19 +374,19 @@ class WorkstationBase(ABC): self.current_workflow_status = WorkflowStatus.INITIALIZING self.workflow_parameters = parameters self.workflow_start_time = time.time() - + # 委托给子类实现 success = self._execute_workflow_impl(workflow_name, parameters) - + if success: self.current_workflow_status = WorkflowStatus.RUNNING logger.info(f"工作站 {self.device_id} 工作流 {workflow_name} 启动成功") else: self.current_workflow_status = WorkflowStatus.ERROR logger.error(f"工作站 {self.device_id} 工作流 {workflow_name} 启动失败") - + return success - + except Exception as e: self.current_workflow_status = WorkflowStatus.ERROR logger.error(f"工作站 {self.device_id} 执行工作流失败: {e}") @@ -342,28 +398,28 @@ class WorkstationBase(ABC): if self.current_workflow_status in [WorkflowStatus.IDLE, WorkflowStatus.STOPPED]: logger.warning(f"工作站 {self.device_id} 没有正在运行的工作流") return True - + self.current_workflow_status = WorkflowStatus.STOPPING - + # 委托给子类实现 success = self._stop_workflow_impl(emergency) - + if success: self.current_workflow_status = WorkflowStatus.STOPPED logger.info(f"工作站 {self.device_id} 工作流停止成功 (紧急: {emergency})") else: self.current_workflow_status = WorkflowStatus.ERROR logger.error(f"工作站 {self.device_id} 工作流停止失败") - + return success - + except Exception as e: self.current_workflow_status = WorkflowStatus.ERROR logger.error(f"工作站 {self.device_id} 停止工作流失败: {e}") return False # ============ 状态属性 ============ - + @property def workflow_status(self) -> WorkflowStatus: """获取当前工作流状态""" @@ -375,7 +431,7 @@ class WorkstationBase(ABC): return self.current_workflow_status in [ WorkflowStatus.INITIALIZING, WorkflowStatus.RUNNING, - WorkflowStatus.STOPPING + WorkflowStatus.STOPPING, ] @property @@ -386,18 +442,48 @@ class WorkstationBase(ABC): return time.time() - self.workflow_start_time # ============ 抽象方法 - 子类必须实现 ============ - + @abstractmethod def _register_supported_workflows(self): """注册支持的工作流 - 子类必须实现""" pass - + @abstractmethod def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: """执行工作流的具体实现 - 子类必须实现""" pass - + @abstractmethod def _stop_workflow_impl(self, emergency: bool = False) -> bool: """停止工作流的具体实现 - 子类必须实现""" pass + +class WorkstationExample(WorkstationBase): + """工作站示例实现""" + + def _register_supported_workflows(self): + """注册支持的工作流""" + self.supported_workflows["example_workflow"] = WorkflowInfo( + name="example_workflow", + description="这是一个示例工作流", + estimated_duration=300.0, + required_materials=["sample_plate"], + output_product="processed_plate", + parameters_schema={"param1": "string", "param2": "integer"}, + ) + + def _execute_workflow_impl(self, workflow_name: str, parameters: Dict[str, Any]) -> bool: + """执行工作流的具体实现""" + if workflow_name not in self.supported_workflows: + logger.error(f"工作站 {self.device_id} 不支持工作流: {workflow_name}") + return False + + # 这里添加实际的工作流逻辑 + logger.info(f"工作站 {self.device_id} 正在执行工作流: {workflow_name} with parameters {parameters}") + return True + + def _stop_workflow_impl(self, emergency: bool = False) -> bool: + """停止工作流的具体实现""" + # 这里添加实际的停止逻辑 + logger.info(f"工作站 {self.device_id} 正在停止工作流 (紧急: {emergency})") + return True \ No newline at end of file diff --git a/unilabos/registry/devices/work_station.yaml b/unilabos/registry/devices/work_station.yaml index 0524252f..5de4d420 100644 --- a/unilabos/registry/devices/work_station.yaml +++ b/unilabos/registry/devices/work_station.yaml @@ -6112,7 +6112,7 @@ workstation: title: initialize_device参数 type: object type: UniLabJsonCommand - module: unilabos.ros.nodes.presets.workstation_node:ROS2WorkstationNode + module: unilabos.ros.nodes.presets.workstation:ROS2WorkstationNode status_types: {} type: ros2 config_info: [] @@ -6218,9 +6218,9 @@ workstation.example: title: create_resource参数 type: object type: UniLabJsonCommand - module: unilabos.ros.nodes.presets.workstation:WorkStationExample + module: unilabos.devices.workstation.workstation_base:WorkstationExample status_types: {} - type: ros2 + type: python config_info: [] description: '' handles: [] diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 3a8d2e46..77f00eca 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -7,13 +7,13 @@ import networkx as nx from unilabos_msgs.msg import Resource from unilabos.resources.container import RegularContainer -from unilabos.ros.msgs.message_converter import convert_from_ros_msg_with_mapping, convert_to_ros_msg +from unilabos.ros.msgs.message_converter import convert_to_ros_msg try: from pylabrobot.resources.resource import Resource as ResourcePLR except ImportError: pass -from typing import Union, get_origin, get_args +from typing import Union, get_origin physical_setup_graph: nx.Graph = None @@ -327,7 +327,7 @@ def nested_dict_to_list(nested_dict: dict) -> list[dict]: # FIXME 是tree? return result def convert_resources_to_type( - resources_list: list[dict], resource_type: type, *, plr_model: bool = False + resources_list: list[dict], resource_type: Union[type, list[type]], *, plr_model: bool = False ) -> Union[list[dict], dict, None, "ResourcePLR"]: """ Convert resources to a given type (PyLabRobot or NestedDict) from flattened list of dictionaries. @@ -358,7 +358,7 @@ def convert_resources_to_type( return None -def convert_resources_from_type(resources_list, resource_type: type) -> Union[list[dict], dict, None, "ResourcePLR"]: +def convert_resources_from_type(resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False) -> Union[list[dict], dict, None, "ResourcePLR"]: """ Convert resources from a given type (PyLabRobot or NestedDict) to flattened list of dictionaries. @@ -374,11 +374,11 @@ def convert_resources_from_type(resources_list, resource_type: type) -> Union[li elif isinstance(resource_type, type) and issubclass(resource_type, ResourcePLR): resources_tree = [resource_plr_to_ulab(resources_list)] return tree_to_list(resources_tree) - elif isinstance(resource_type, list) : + elif isinstance(resource_type, list): if all((get_origin(t) is Union) for t in resource_type): resources_tree = [resource_plr_to_ulab(r) for r in resources_list] return tree_to_list(resources_tree) - elif all(issubclass(t, ResourcePLR) for t in resource_type): + elif is_plr or all(issubclass(t, ResourcePLR) for t in resource_type): resources_tree = [resource_plr_to_ulab(r) for r in resources_list] return tree_to_list(resources_tree) else: diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index ebe59236..75d953f5 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -5,7 +5,7 @@ import threading import time import traceback import uuid -from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional +from typing import List, get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional from concurrent.futures import ThreadPoolExecutor import asyncio @@ -504,6 +504,17 @@ class BaseROS2DeviceNode(Node, Generic[T]): rclpy.get_global_executor().add_node(self) self.lab_logger().debug(f"ROS节点初始化完成") + async def update_resource(self, resources: List[Any]): + r = ResourceUpdate.Request() + unique_resources = [] + for resource in resources: # resource是list[ResourcePLR] + # 目前更新资源只支持传入plr的对象,后面要更新convert_resources_from_type函数 + converted_list = convert_resources_from_type([resource], resource_type=[object], is_plr=True) + unique_resources.extend([convert_to_ros_msg(Resource, converted) for converted in converted_list]) + r.resources = unique_resources + response = await self._resource_clients["resource_update"].call_async(r) + self.lab_logger().debug(f"资源更新结果: {response}") + def register_device(self): """向注册表中注册设备信息""" topics_info = self._property_publishers.copy() @@ -932,6 +943,7 @@ class ROS2DeviceNode: self._driver_class = driver_class self.device_config = device_config self.driver_is_ros = driver_is_ros + self.driver_is_workstation = False self.resource_tracker = DeviceNodeResourceTracker() # use_pylabrobot_creator 使用 cls的包路径检测 @@ -944,12 +956,6 @@ class ROS2DeviceNode: # TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建 # 创建设备类实例 - # 判断是否包含设备子节点,决定是否使用ROS2WorkstationNode - has_device_children = any( - child_config.get("type", "device") == "device" - for child_config in children.values() - ) - if use_pylabrobot_creator: # 先对pylabrobot的子资源进行加载,不然subclass无法认出 # 在下方对于加载Deck等Resource要手动import @@ -958,19 +964,12 @@ class ROS2DeviceNode: driver_class, children=children, resource_tracker=self.resource_tracker ) else: - from unilabos.ros.nodes.presets.workstation_node import ROS2WorkstationNode from unilabos.devices.workstation.workstation_base import WorkstationBase - # 检查是否是WorkstationBase的子类且包含设备子节点 - if issubclass(self._driver_class, WorkstationBase) and has_device_children: - # WorkstationBase + 设备子节点 -> 使用WorkstationNode作为ros_instance - self._use_workstation_node_ros = True - self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) - elif issubclass(self._driver_class, ROS2WorkstationNode): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator - self._use_workstation_node_ros = False + if issubclass(self._driver_class, WorkstationBase): # 是WorkstationNode的子节点,就要调用WorkstationNodeCreator + self.driver_is_workstation = True self._driver_creator = WorkstationNodeCreator(driver_class, children=children, resource_tracker=self.resource_tracker) else: - self._use_workstation_node_ros = False self._driver_creator = DeviceClassCreator(driver_class, children=children, resource_tracker=self.resource_tracker) if driver_is_ros: @@ -980,38 +979,22 @@ class ROS2DeviceNode: if self._driver_instance is None: logger.critical(f"设备实例创建失败 {driver_class}, params: {driver_params}") raise DeviceInitError("错误: 设备实例创建失败") - + # 创建ROS2节点 if driver_is_ros: self._ros_node = self._driver_instance # type: ignore - elif hasattr(self, '_use_workstation_node_ros') and self._use_workstation_node_ros: - # WorkstationBase + 设备子节点 -> 创建ROS2WorkstationNode作为ros_instance - from unilabos.ros.nodes.presets.workstation_node import ROS2WorkstationNode - - # 从children提取设备协议类型 - protocol_types = set() - for child_id, child_config in children.items(): - if child_config.get("type", "device") == "device": - # 检查设备配置中的协议类型 - if "protocol_type" in child_config: - if isinstance(child_config["protocol_type"], list): - protocol_types.update(child_config["protocol_type"]) - else: - protocol_types.add(child_config["protocol_type"]) - - # 如果没有明确的协议类型,使用默认值 - if not protocol_types: - protocol_types = ["default_protocol"] - + elif self.driver_is_workstation: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode self._ros_node = ROS2WorkstationNode( - device_id=device_id, + protocol_type=driver_params["protocol_type"], children=children, - protocol_type=list(protocol_types), + driver_instance=self._driver_instance, # type: ignore + device_id=device_id, + status_types=status_types, + action_value_mappings=action_value_mappings, + hardware_interface=hardware_interface, + print_publish=print_publish, resource_tracker=self.resource_tracker, - workstation_config={ - 'workstation_instance': self._driver_instance, - 'deck_config': getattr(self._driver_instance, 'deck_config', {}), - } ) else: self._ros_node = BaseROS2DeviceNode( diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index 188d65b9..c790940d 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -1,86 +1,618 @@ -import collections -from typing import Union, Dict, Any, Optional +import json +import time +import traceback +from pprint import pformat +from typing import List, Dict, Any, Optional, TYPE_CHECKING -from unilabos_msgs.msg import Resource -from pylabrobot.resources import Resource as PLRResource, Plate, TipRack, Coordinate -from unilabos.ros.nodes.presets.protocol_node import ROS2WorkstationNode -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +import rclpy +from rosidl_runtime_py import message_to_ordereddict + +from unilabos.messages import * # type: ignore # protocol names +from rclpy.action import ActionServer, ActionClient +from rclpy.action.server import ServerGoalHandle +from rclpy.callback_groups import ReentrantCallbackGroup +from unilabos_msgs.msg import Resource # type: ignore +from unilabos_msgs.srv import ResourceGet, ResourceUpdate # type: ignore + +from unilabos.compile import action_protocol_generators +from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list +from unilabos.ros.initialize_device import initialize_device_from_dict +from unilabos.ros.msgs.message_converter import ( + get_action_type, + convert_to_ros_msg, + convert_from_ros_msg, + convert_from_ros_msg_with_mapping, +) +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode +from unilabos.utils.type_check import serialize_result_info + +if TYPE_CHECKING: + from unilabos.devices.workstation.workstation_base import WorkstationBase -class WorkStationContainer(Plate, TipRack): +class ROS2WorkstationNode(BaseROS2DeviceNode): """ - WorkStation 专用 Container 类,继承自 Plate和TipRack - 注意这个物料必须通过plr_additional_res_reg.py注册到edge,才能正常序列化 + ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。 + 它初始化设备节点,处理动作客户端,并基于指定的协议执行工作流。 + 它还物理上代表一组协同工作的设备,如带夹持器的机械臂,带传送带的CNC机器等。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, category: str, ordering: collections.OrderedDict, model: Optional[str] = None,): - """ - 这里的初始化入参要和plr的保持一致 - """ - super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) - self._unilabos_state = {} # 必须有此行,自己的类描述的是物料的 + driver_instance: "WorkstationBase" - def load_state(self, state: Dict[str, Any]) -> None: - """从给定的状态加载工作台信息。""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - - -def get_workstation_plate_resource(name: str) -> PLRResource: # 要给定一个返回plr的方法 - """ - 用于获取一些模板,例如返回一个带有特定信息/子物料的 Plate,这里需要到注册表注册,例如unilabos/registry/resources/organic/workstation.yaml - 可以直接运行该函数或者利用注册表补全机制,来检查是否资源出错 - :param name: 资源名称 - :return: Resource对象 - """ - plate = WorkStationContainer(name, size_x=50, size_y=50, size_z=10, category="plate", ordering=collections.OrderedDict()) - tip_rack = WorkStationContainer("tip_rack_inside_plate", size_x=50, size_y=50, size_z=10, category="tip_rack", ordering=collections.OrderedDict()) - plate.assign_child_resource(tip_rack, Coordinate.zero()) - return plate - - -class WorkStationExample(ROS2WorkstationNode): - def __init__(self, - # 你可以在这里增加任意的参数,对应启动json填写相应的参数内容 - device_id: str, - children: dict, - protocol_type: Union[str, list[str]], - resource_tracker: DeviceNodeResourceTracker - ): - super().__init__(device_id, children, protocol_type, resource_tracker) - - def create_resource( + def __init__( self, - resource_tracker: DeviceNodeResourceTracker, - resources: list[Resource], - bind_parent_id: str, - bind_location: dict[str, float], - liquid_input_slot: list[int], - liquid_type: list[str], - liquid_volume: list[int], - slot_on_deck: int, - ) -> Dict[str, Any]: - return { # edge侧返回给前端的创建物料的结果。云端调用初始化瓶子等。执行该函数时,物料已经上报给云端,一般不需要继承使用 + protocol_type: List[str], + children: Dict[str, Any], + *, + driver_instance: "WorkstationBase", + device_id: str, + status_types: Dict[str, Any], + action_value_mappings: Dict[str, Any], + hardware_interface: Dict[str, Any], + print_publish=True, + resource_tracker: Optional["DeviceNodeResourceTracker"] = None, + ): + self._setup_protocol_names(protocol_type) - } + # 初始化非BaseROSNode的属性 + self.children = children + # 初始化基类,让基类处理常规动作 + super().__init__( + driver_instance=driver_instance, + device_id=device_id, + status_types=status_types, + action_value_mappings={ + **action_value_mappings, + **self.protocol_action_mappings + }, + hardware_interface=hardware_interface, + print_publish=print_publish, + resource_tracker=resource_tracker, + ) - def transfer_bottle(self, tip_rack: PLRResource, base_plate: PLRResource): # 使用自定义物料的举例 + self.workstation_config = children + self.communication_interfaces = self.workstation_config.get( + "communication_interfaces", {} + ) # 从工作站配置获取通信接口 + + # 新增:获取工作站实例(如果存在) + self.workstation_instance = driver_instance + + self._busy = False + self.sub_devices = {} + self.communication_devices = {} + self.logical_devices = {} + self._goals = {} + self._protocol_servers = {} + self._action_clients = {} + # 初始化子设备 + self._initialize_child_devices() + + if isinstance(getattr(driver_instance, "hardware_interface", None), str): + self.logical_devices[device_id] = driver_instance + else: + self.communication_devices[device_id] = driver_instance + + # 设置硬件接口代理 + for device_id, device_node in self.logical_devices.items(): + if device_node and hasattr(device_node, "ros_node_instance"): + self._setup_device_hardware_proxy(device_id, device_node) + + # 新增:如果有工作站实例,建立双向引用和硬件接口设置 + if self.workstation_instance: + self._setup_workstation_integration() + + def _setup_workstation_integration(self): + """设置工作站集成 - 统一设备处理模式""" + # 1. 建立协议节点引用 + self.workstation_instance.set_workstation_node(self) + + self.lab_logger().info( + f"ROS2WorkstationNode {self.device_id} 与工作站实例 {type(self.workstation_instance).__name__} 集成完成" + ) + + def _initialize_child_devices(self): + """初始化子设备 - 重构为更清晰的方法""" + # 设备分类字典 - 统一管理 + + for device_id, device_config in self.children.items(): + if device_config.get("type", "device") != "device": + self.lab_logger().debug( + f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." + ) + continue + + try: + d = self.initialize_device(device_id, device_config) + if d is None: + continue + + # 统一的设备分类逻辑 + device_type = device_config.get("device_type", "logical") + + # 兼容旧的ID匹配方式和新的配置方式 + if device_type == "communication" or "serial_" in device_id or "io_" in device_id: + self.communication_devices[device_id] = d # 新的统一方式 + self.lab_logger().info(f"通信设备 {device_id} 初始化并分类成功") + elif device_type == "logical": + self.logical_devices[device_id] = d + self.lab_logger().info(f"逻辑设备 {device_id} 初始化并分类成功") + else: + # 默认作为逻辑设备处理 + self.logical_devices[device_id] = d + self.lab_logger().info(f"设备 {device_id} 作为逻辑设备处理") + + except Exception as ex: + self.lab_logger().error( + f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}" + ) + + def _setup_device_hardware_proxy(self, device_id: str, device: ROS2DeviceNode): + """统一的设备硬件接口代理设置方法 + + Args: + device_id: 设备ID + device: 设备实例 """ - 将tip_rack assign给base_plate,两个入参都得是PLRResource,unilabos会代替当前物料操作,自动刷新他们的父子关系等状态 - """ - pass + hardware_interface = device.ros_node_instance._hardware_interface + if not self._validate_hardware_interface(device, hardware_interface): + return - def trigger_resource_update(self, from_plate: PLRResource, to_base_plate: PLRResource): - """ - 有些时候物料发生了子设备的迁移,一般对该设备的最大一级的物料进行操作,例如要将A物料搬移到B物料上,他们不共同拥有一个物料 - 该步骤操作结束后,会主动刷新from_plate的父物料,与to_base_plate的父物料(如没有则刷新自身) + # 获取硬件接口名称 + interface_name = getattr(device.driver_instance, hardware_interface["name"]) - """ - to_base_plate.assign_child_resource(from_plate, Coordinate.zero()) - pass + # 情况1: 如果interface_name是字符串,说明需要转发到其他设备 + if isinstance(interface_name, str): + # 查找目标设备 + communication_device = self.communication_devices.get(device_id, None) + if not communication_device: + self.lab_logger().error(f"转发目标设备 {device_id} 不存在") + return + read_method = hardware_interface.get("read", None) + write_method = hardware_interface.get("write", None) + + # 设置传统硬件代理 + communicate_hardware_info = communication_device.ros_node_instance._hardware_interface + self._setup_hardware_proxy(device, communication_device, read_method, write_method) + self.lab_logger().info( + f"传统通信代理:为子设备{device.device_id} " + f"添加了{read_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['read']}) " + f"添加了{write_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['write']})" + ) + self.lab_logger().info(f"字符串转发代理:设备 {device.device_id} -> {device_id}") + + # 情况2: 如果设备有communication_interface配置,设置协议代理 + elif hasattr(self, "communication_interfaces") and device_id in self.communication_interfaces: + interface_config = self._get_communication_interface_config(device_id) + protocol_type = interface_config.get("protocol_type", "modbus") + self._setup_communication_proxy(device, interface_config, protocol_type) + + # 情况3: 其他情况,使用默认处理 + else: + self.lab_logger().debug(f"设备 {device_id} 使用默认硬件接口处理") + + def _get_communication_interface_config(self, device_id: str) -> dict: + """获取设备的通信接口配置""" + # 优先从工作站配置获取 + if hasattr(self, "communication_interfaces") and device_id in self.communication_interfaces: + return self.communication_interfaces[device_id] + + # 从设备自身配置获取 + device_node = self.logical_devices[device_id] + if device_node and hasattr(device_node.driver_instance, "communication_interface"): + return getattr(device_node.driver_instance, "communication_interface") + + return {} + + def _validate_hardware_interface(self, device: ROS2DeviceNode, hardware_interface: dict) -> bool: + """验证硬件接口配置""" + return ( + hasattr(device.driver_instance, hardware_interface["name"]) + and hasattr(device.driver_instance, hardware_interface["write"]) + and (hardware_interface["read"] is None or hasattr(device.driver_instance, hardware_interface["read"])) + ) + + def _setup_communication_proxy(self, logical_device: ROS2DeviceNode, interface_config, protocol_type): + """为逻辑设备设置通信代理 - 统一方法""" + try: + # 获取通信设备 + comm_device_id = interface_config.get("device_id") + comm_device = self.communication_devices.get(comm_device_id) + + if not comm_device: + self.lab_logger().error(f"通信设备 {comm_device_id} 不存在") + return + # FIXME http、modbus(tcpip)都是支持多客户端的 + # 根据协议类型设置不同的代理方法 + if protocol_type == "modbus": + self._setup_modbus_proxy(logical_device, comm_device, interface_config) + elif protocol_type == "opcua": + self._setup_opcua_proxy(logical_device, comm_device, interface_config) + elif protocol_type == "http": + self._setup_http_proxy(logical_device, comm_device, interface_config) + elif protocol_type == "serial": + self._setup_serial_proxy(logical_device, comm_device, interface_config) + else: + self.lab_logger().warning(f"不支持的协议类型: {protocol_type}") + return + + self.lab_logger().info( + f"通信代理:为逻辑设备 {logical_device.device_id} 设置{protocol_type}通信代理 -> {comm_device_id}" + ) + + except Exception as e: + self.lab_logger().error(f"设置通信代理失败: {e}") + + def _setup_protocol_names(self, protocol_type): + # 处理协议类型 + if isinstance(protocol_type, str): + if "," not in protocol_type: + self.protocol_names = [protocol_type] + else: + self.protocol_names = [protocol.strip() for protocol in protocol_type.split(",")] + else: + self.protocol_names = protocol_type + # 准备协议相关的动作值映射 + self.protocol_action_mappings = {} + for protocol_name in self.protocol_names: + protocol_type = globals()[protocol_name] + self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) + + def initialize_device(self, device_id, device_config): + """初始化设备并创建相应的动作客户端""" + # device_id_abs = f"{self.device_id}/{device_id}" + device_id_abs = f"{device_id}" + self.lab_logger().info(f"初始化子设备: {device_id_abs}") + d = self.sub_devices[device_id] = initialize_device_from_dict(device_id_abs, device_config) + + # 为子设备的每个动作创建动作客户端 + if d is not None and hasattr(d, "ros_node_instance"): + node = d.ros_node_instance + node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器 + for action_name, action_mapping in node._action_value_mappings.items(): + if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith( + "UniLabJsonCommand" + ): + continue + action_id = f"/devices/{device_id_abs}/{action_name}" + if action_id not in self._action_clients: + try: + self._action_clients[action_id] = ActionClient( + self, action_mapping["type"], action_id, callback_group=self.callback_group + ) + except Exception as ex: + self.lab_logger().error(f"创建动作客户端失败: {action_id}, 错误: {ex}") + continue + self.lab_logger().trace(f"为子设备 {device_id} 创建动作客户端: {action_name}") + return d + + def create_ros_action_server(self, action_name, action_value_mapping): + """创建ROS动作服务器""" + # 和Base创建的路径是一致的 + protocol_name = action_name + action_type = action_value_mapping["type"] + str_action_type = str(action_type)[8:-2] + protocol_type = globals()[protocol_name] + protocol_steps_generator = action_protocol_generators[protocol_type] + + self._action_servers[action_name] = ActionServer( + self, + action_type, + action_name, + execute_callback=self._create_protocol_execute_callback(action_name, protocol_steps_generator), + callback_group=ReentrantCallbackGroup(), + ) + + self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}") + + def _create_protocol_execute_callback(self, protocol_name, protocol_steps_generator): + async def execute_protocol(goal_handle: ServerGoalHandle): + """执行完整的工作流""" + # 初始化结果信息变量 + execution_error = "" + execution_success = False + protocol_return_value = None + self.get_logger().info(f"Executing {protocol_name} action...") + action_value_mapping = self._action_value_mappings[protocol_name] + step_results = [] + try: + print("+" * 30) + print(protocol_steps_generator) + # 从目标消息中提取参数, 并调用Protocol生成器(根据设备连接图)生成action步骤 + goal = goal_handle.request + protocol_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) + + # # 🔧 添加调试信息 + # print(f"🔍 转换后的 protocol_kwargs: {protocol_kwargs}") + # print(f"🔍 vessel 在转换后: {protocol_kwargs.get('vessel', 'NOT_FOUND')}") + + # # 🔧 完全禁用Host查询,直接使用转换后的数据 + # print(f"🔧 跳过Host查询,直接使用转换后的数据") + # 向Host查询物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + r = ResourceGet.Request() + resource_id = ( + protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"] + ) + r.id = resource_id + r.with_children = True + response = await self._resource_clients["resource_get"].call_async(r) + protocol_kwargs[k] = list_to_nested_dict( + [convert_from_ros_msg(rs) for rs in response.resources] + ) + + self.lab_logger().info(f"🔍 最终的 vessel: {protocol_kwargs.get('vessel', 'NOT_FOUND')}") + + from unilabos.resources.graphio import physical_setup_graph + + self.lab_logger().info(f"Working on physical setup: {physical_setup_graph}") + protocol_steps = protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) + logs = [] + for step in protocol_steps: + if isinstance(step, dict) and "log_message" in step.get("action_kwargs", {}): + logs.append(step) + elif isinstance(step, list): + logs.append(step) + self.lab_logger().info( + f"Goal received: {protocol_kwargs}, running steps: " + f"{json.dumps(logs, indent=4, ensure_ascii=False)}" + ) + + time_start = time.time() + time_overall = 100 + self._busy = True + + # 逐步执行工作流 + for i, action in enumerate(protocol_steps): + # self.get_logger().info(f"Running step {i + 1}: {action}") + if isinstance(action, dict): + # 如果是单个动作,直接执行 + if action["action_name"] == "wait": + time.sleep(action["action_kwargs"]["time"]) + step_results.append({"step": i + 1, "action": "wait", "result": "completed"}) + else: + result = await self.execute_single_action(**action) + step_results.append({"step": i + 1, "action": action["action_name"], "result": result}) + ret_info = json.loads(getattr(result, "return_info", "{}")) + if not ret_info.get("suc", False): + raise RuntimeError(f"Step {i + 1} failed.") + elif isinstance(action, list): + # 如果是并行动作,同时执行 + actions = action + futures = [ + rclpy.get_global_executor().create_task(self.execute_single_action(**a)) for a in actions + ] + results = [await f for f in futures] + step_results.append( + { + "step": i + 1, + "parallel_actions": [a["action_name"] for a in actions], + "results": results, + } + ) + + # 向Host更新物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + r = ResourceUpdate.Request() + r.resources = [ + convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) + ] + response = await self._resource_clients["resource_update"].call_async(r) + + # 设置成功状态和返回值 + execution_success = True + protocol_return_value = { + "protocol_name": protocol_name, + "steps_executed": len(protocol_steps), + "step_results": step_results, + "total_time": time.time() - time_start, + } + + goal_handle.succeed() + + except Exception as e: + # 捕获并记录错误信息 + str_step_results = [ + { + k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v + for k, v in i.items() + } + for i in step_results + ] + execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}" + execution_success = False + self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}") + + # 设置动作失败 + goal_handle.abort() + + finally: + self._busy = False + + # 创建结果消息 + result = action_value_mapping["type"].Result() + result.success = execution_success + + # 获取结果消息类型信息,检查是否有return_info字段 + result_msg_types = action_value_mapping["type"].Result.get_fields_and_field_types() + + # 设置return_info字段(如果存在) + for attr_name in result_msg_types.keys(): + if attr_name in ["success", "reached_goal"]: + setattr(result, attr_name, execution_success) + elif attr_name == "return_info": + setattr( + result, + attr_name, + serialize_result_info(execution_error, execution_success, protocol_return_value), + ) + + self.lab_logger().info(f"协议 {protocol_name} 完成并返回结果") + return result + + return execute_protocol + + async def execute_single_action(self, device_id, action_name, action_kwargs): + """执行单个动作""" + # 构建动作ID + if device_id in ["", None, "self"]: + action_id = f"/devices/{self.device_id}/{action_name}" + else: + action_id = f"/devices/{device_id}/{action_name}" # 执行时取消了主节点信息 /{self.device_id} + + # 检查动作客户端是否存在 + if action_id not in self._action_clients: + self.lab_logger().error(f"找不到动作客户端: {action_id}") + return None + + # 发送动作请求 + action_client = self._action_clients[action_id] + goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) + + ##### self.lab_logger().info(f"发送动作请求到: {action_id}") + action_client.wait_for_server() + + # 等待动作完成 + request_future = action_client.send_goal_async(goal_msg) + handle = await request_future + + if not handle.accepted: + self.lab_logger().error(f"动作请求被拒绝: {action_name}") + return None + + result_future = await handle.get_result_async() + ##### self.lab_logger().info(f"动作完成: {action_name}") + + return result_future.result + + """还没有改过的部分""" + + def _setup_modbus_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置Modbus通信代理""" + config = interface_config.get("config", {}) + + # 设置Modbus读写方法 + def modbus_read(address, count=1, function_code=3): + """Modbus读取方法""" + return comm_device.driver_instance.read_holding_registers( + address=address, count=count, slave_id=config.get("slave_id", 1) + ) + + def modbus_write(address, value, function_code=6): + """Modbus写入方法""" + if isinstance(value, (list, tuple)): + return comm_device.driver_instance.write_multiple_registers( + address=address, values=value, slave_id=config.get("slave_id", 1) + ) + else: + return comm_device.driver_instance.write_single_register( + address=address, value=value, slave_id=config.get("slave_id", 1) + ) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, "comm_read", modbus_read) + setattr(logical_device.driver_instance, "comm_write", modbus_write) + setattr(logical_device.driver_instance, "comm_config", config) + setattr(logical_device.driver_instance, "comm_protocol", "modbus") + + def _setup_opcua_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置OPC UA通信代理""" + config = interface_config.get("config", {}) + + def opcua_read(node_id): + """OPC UA读取方法""" + return comm_device.driver_instance.read_node_value(node_id) + + def opcua_write(node_id, value): + """OPC UA写入方法""" + return comm_device.driver_instance.write_node_value(node_id, value) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, "comm_read", opcua_read) + setattr(logical_device.driver_instance, "comm_write", opcua_write) + setattr(logical_device.driver_instance, "comm_config", config) + setattr(logical_device.driver_instance, "comm_protocol", "opcua") + + def _setup_http_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置HTTP/RPC通信代理""" + config = interface_config.get("config", {}) + base_url = config.get("base_url", "http://localhost:8080") + + def http_read(endpoint, params=None): + """HTTP GET请求""" + url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" + return comm_device.driver_instance.get_request(url, params=params) + + def http_write(endpoint, data): + """HTTP POST请求""" + url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" + return comm_device.driver_instance.post_request(url, data=data) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, "comm_read", http_read) + setattr(logical_device.driver_instance, "comm_write", http_write) + setattr(logical_device.driver_instance, "comm_config", config) + setattr(logical_device.driver_instance, "comm_protocol", "http") + + def _setup_serial_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): + """设置串口通信代理""" + config = interface_config.get("config", {}) + + def serial_read(timeout=1.0): + """串口读取方法""" + return comm_device.driver_instance.read_data(timeout=timeout) + + def serial_write(data): + """串口写入方法""" + if isinstance(data, str): + data = data.encode("utf-8") + return comm_device.driver_instance.write_data(data) + + # 绑定方法到逻辑设备 + setattr(logical_device.driver_instance, "comm_read", serial_read) + setattr(logical_device.driver_instance, "comm_write", serial_write) + setattr(logical_device.driver_instance, "comm_config", config) + setattr(logical_device.driver_instance, "comm_protocol", "serial") + + def _setup_hardware_proxy( + self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method + ): + """为设备设置硬件接口代理""" + # extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])] + write_func = getattr( + communication_device.driver_instance, communication_device.ros_node_instance._hardware_interface["write"] + ) + read_func = getattr( + communication_device.driver_instance, communication_device.ros_node_instance._hardware_interface["read"] + ) + + def _read(*args, **kwargs): + return read_func(*args, **kwargs) + + def _write(*args, **kwargs): + return write_func(*args, **kwargs) + + if read_method: + # bound_read = MethodType(_read, device.driver_instance) + setattr(device.driver_instance, read_method, _read) + + if write_method: + # bound_write = MethodType(_write, device.driver_instance) + setattr(device.driver_instance, write_method, _write) + + async def _update_resources(self, goal, protocol_kwargs): + """更新资源状态""" + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + if protocol_kwargs[k] is not None: + try: + r = ResourceUpdate.Request() + r.resources = [ + convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) + ] + await self._resource_clients["resource_update"].call_async(r) + except Exception as e: + self.lab_logger().error(f"更新资源失败: {e}") diff --git a/unilabos/ros/nodes/presets/workstation_node.py b/unilabos/ros/nodes/presets/workstation_node.py deleted file mode 100644 index 49c9e223..00000000 --- a/unilabos/ros/nodes/presets/workstation_node.py +++ /dev/null @@ -1,603 +0,0 @@ -import json -import time -import traceback -from pprint import pprint, saferepr, pformat -from typing import Union, Dict, Any - -import rclpy -from rosidl_runtime_py import message_to_ordereddict - -from unilabos.messages import * # type: ignore # protocol names -from rclpy.action import ActionServer, ActionClient -from rclpy.action.server import ServerGoalHandle -from rclpy.callback_groups import ReentrantCallbackGroup -from unilabos_msgs.msg import Resource # type: ignore -from unilabos_msgs.srv import ResourceGet, ResourceUpdate # type: ignore - -from unilabos.compile import action_protocol_generators -from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list -from unilabos.ros.initialize_device import initialize_device_from_dict -from unilabos.ros.msgs.message_converter import ( - get_action_type, - convert_to_ros_msg, - convert_from_ros_msg, - convert_from_ros_msg_with_mapping, String, -) -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode -from unilabos.utils.log import error -from unilabos.utils.type_check import serialize_result_info - - -class ROS2WorkstationNode(BaseROS2DeviceNode): - """ - ROS2WorkstationNode代表管理ROS2环境中设备通信和动作的协议节点。 - 它初始化设备节点,处理动作客户端,并基于指定的协议执行工作流。 - 它还物理上代表一组协同工作的设备,如带夹持器的机械臂,带传送带的CNC机器等。 - """ - - # create_action_server = False # Action Server要自己创建 - - def __init__( - self, - device_id: str, - children: dict, - protocol_type: Union[str, list[str]], - resource_tracker: DeviceNodeResourceTracker, - workstation_config: dict = {}, - workstation_instance: object = None, - *args, - **kwargs, - ): - self._setup_protocol_names(protocol_type) - - # 初始化其它属性 - self.children = children - self.workstation_config = workstation_config or {} # 新增:保存工作站配置 - self.communication_interfaces = self.workstation_config.get('communication_interfaces', {}) # 从工作站配置获取通信接口 - - # 新增:获取工作站实例(如果存在) - self.workstation_instance = workstation_instance - - self._busy = False - self.sub_devices = {} - self.communication_devices = {} - self.logical_devices = {} - self._goals = {} - self._protocol_servers = {} - self._action_clients = {} - - # 初始化基类,让基类处理常规动作 - # 如果有工作站实例,使用工作站实例作为driver_instance - driver_instance = self.workstation_instance if self.workstation_instance else self - - super().__init__( - driver_instance=driver_instance, - device_id=device_id, - status_types={}, - action_value_mappings=self.protocol_action_mappings, - hardware_interface={"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}, - print_publish=False, - resource_tracker=resource_tracker, - ) - - # 初始化子设备 - self._initialize_child_devices() - - if isinstance(getattr(driver_instance, "hardware_interface", None), str): - self.logical_devices[device_id] = driver_instance - else: - self.communication_devices[device_id] = driver_instance - - # 设置硬件接口代理 - for device_id, device_node in self.logical_devices.items(): - if device_node and hasattr(device_node, 'ros_node_instance'): - self._setup_device_hardware_proxy(device_id, device_node) - - # 新增:如果有工作站实例,建立双向引用和硬件接口设置 - if self.workstation_instance: - self._setup_workstation_integration() - - def _setup_workstation_integration(self): - """设置工作站集成 - 统一设备处理模式""" - # 1. 建立协议节点引用 - self.workstation_instance.set_workstation_node(self) - - self.lab_logger().info(f"ROS2WorkstationNode {self.device_id} 与工作站实例 {type(self.workstation_instance).__name__} 集成完成") - - def _initialize_child_devices(self): - """初始化子设备 - 重构为更清晰的方法""" - # 设备分类字典 - 统一管理 - - for device_id, device_config in self.children.items(): - if device_config.get("type", "device") != "device": - self.lab_logger().debug( - f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." - ) - continue - - try: - d = self.initialize_device(device_id, device_config) - if d is None: - continue - - # 统一的设备分类逻辑 - device_type = device_config.get("device_type", "logical") - - # 兼容旧的ID匹配方式和新的配置方式 - if device_type == "communication" or "serial_" in device_id or "io_" in device_id: - self.communication_devices[device_id] = d # 新的统一方式 - self.lab_logger().info(f"通信设备 {device_id} 初始化并分类成功") - elif device_type == "logical": - self.logical_devices[device_id] = d - self.lab_logger().info(f"逻辑设备 {device_id} 初始化并分类成功") - else: - # 默认作为逻辑设备处理 - self.logical_devices[device_id] = d - self.lab_logger().info(f"设备 {device_id} 作为逻辑设备处理") - - except Exception as ex: - self.lab_logger().error(f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}") - - def _setup_device_hardware_proxy(self, device_id: str, device: ROS2DeviceNode): - """统一的设备硬件接口代理设置方法 - - Args: - device_id: 设备ID - device: 设备实例 - """ - hardware_interface = device.ros_node_instance._hardware_interface - if not self._validate_hardware_interface(device, hardware_interface): - return - - # 获取硬件接口名称 - interface_name = getattr(device.driver_instance, hardware_interface["name"]) - - # 情况1: 如果interface_name是字符串,说明需要转发到其他设备 - if isinstance(interface_name, str): - # 查找目标设备 - communication_device = self.communication_devices.get(device_id, None) - if not communication_device: - self.lab_logger().error(f"转发目标设备 {device_id} 不存在") - return - - read_method = hardware_interface.get("read", None) - write_method = hardware_interface.get("write", None) - - # 设置传统硬件代理 - communicate_hardware_info = communication_device.ros_node_instance._hardware_interface - self._setup_hardware_proxy(device, communication_device, read_method, write_method) - self.lab_logger().info( - f"传统通信代理:为子设备{device.device_id} " - f"添加了{read_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['read']}) " - f"添加了{write_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['write']})" - ) - self.lab_logger().info(f"字符串转发代理:设备 {device.device_id} -> {device_id}") - - # 情况2: 如果设备有communication_interface配置,设置协议代理 - elif hasattr(self, 'communication_interfaces') and device_id in self.communication_interfaces: - interface_config = self._get_communication_interface_config(device_id) - protocol_type = interface_config.get('protocol_type', 'modbus') - self._setup_communication_proxy(device, interface_config, protocol_type) - - # 情况3: 其他情况,使用默认处理 - else: - self.lab_logger().debug(f"设备 {device_id} 使用默认硬件接口处理") - - def _get_communication_interface_config(self, device_id: str) -> dict: - """获取设备的通信接口配置""" - # 优先从工作站配置获取 - if hasattr(self, 'communication_interfaces') and device_id in self.communication_interfaces: - return self.communication_interfaces[device_id] - - # 从设备自身配置获取 - device_node = self.logical_devices[device_id] - if device_node and hasattr(device_node.driver_instance, 'communication_interface'): - return getattr(device_node.driver_instance, 'communication_interface') - - return {} - - def _validate_hardware_interface(self, device: ROS2DeviceNode, hardware_interface: dict) -> bool: - """验证硬件接口配置""" - return ( - hasattr(device.driver_instance, hardware_interface["name"]) - and hasattr(device.driver_instance, hardware_interface["write"]) - and (hardware_interface["read"] is None or hasattr(device.driver_instance, hardware_interface["read"])) - ) - - def _setup_communication_proxy(self, logical_device: ROS2DeviceNode, interface_config, protocol_type): - """为逻辑设备设置通信代理 - 统一方法""" - try: - # 获取通信设备 - comm_device_id = interface_config.get('device_id') - comm_device = self.communication_devices.get(comm_device_id) - - if not comm_device: - self.lab_logger().error(f"通信设备 {comm_device_id} 不存在") - return - - # 根据协议类型设置不同的代理方法 - if protocol_type == 'modbus': - self._setup_modbus_proxy(logical_device, comm_device, interface_config) - elif protocol_type == 'opcua': - self._setup_opcua_proxy(logical_device, comm_device, interface_config) - elif protocol_type == 'http': - self._setup_http_proxy(logical_device, comm_device, interface_config) - elif protocol_type == 'serial': - self._setup_serial_proxy(logical_device, comm_device, interface_config) - else: - self.lab_logger().warning(f"不支持的协议类型: {protocol_type}") - return - - self.lab_logger().info(f"通信代理:为逻辑设备 {logical_device.device_id} 设置{protocol_type}通信代理 -> {comm_device_id}") - - except Exception as e: - self.lab_logger().error(f"设置通信代理失败: {e}") - - def _setup_protocol_names(self, protocol_type): - # 处理协议类型 - if isinstance(protocol_type, str): - if "," not in protocol_type: - self.protocol_names = [protocol_type] - else: - self.protocol_names = [protocol.strip() for protocol in protocol_type.split(",")] - else: - self.protocol_names = protocol_type - # 准备协议相关的动作值映射 - self.protocol_action_mappings = {} - for protocol_name in self.protocol_names: - protocol_type = globals()[protocol_name] - self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) - - def initialize_device(self, device_id, device_config): - """初始化设备并创建相应的动作客户端""" - # device_id_abs = f"{self.device_id}/{device_id}" - device_id_abs = f"{device_id}" - self.lab_logger().info(f"初始化子设备: {device_id_abs}") - d = self.sub_devices[device_id] = initialize_device_from_dict(device_id_abs, device_config) - - # 为子设备的每个动作创建动作客户端 - if d is not None and hasattr(d, "ros_node_instance"): - node = d.ros_node_instance - node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器 - for action_name, action_mapping in node._action_value_mappings.items(): - if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith("UniLabJsonCommand"): - continue - action_id = f"/devices/{device_id_abs}/{action_name}" - if action_id not in self._action_clients: - try: - self._action_clients[action_id] = ActionClient( - self, action_mapping["type"], action_id, callback_group=self.callback_group - ) - except Exception as ex: - self.lab_logger().error(f"创建动作客户端失败: {action_id}, 错误: {ex}") - continue - self.lab_logger().trace(f"为子设备 {device_id} 创建动作客户端: {action_name}") - return d - - def create_ros_action_server(self, action_name, action_value_mapping): - """创建ROS动作服务器""" - # 和Base创建的路径是一致的 - protocol_name = action_name - action_type = action_value_mapping["type"] - str_action_type = str(action_type)[8:-2] - protocol_type = globals()[protocol_name] - protocol_steps_generator = action_protocol_generators[protocol_type] - - self._action_servers[action_name] = ActionServer( - self, - action_type, - action_name, - execute_callback=self._create_protocol_execute_callback(action_name, protocol_steps_generator), - callback_group=ReentrantCallbackGroup(), - ) - - self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}") - - def _create_protocol_execute_callback(self, protocol_name, protocol_steps_generator): - async def execute_protocol(goal_handle: ServerGoalHandle): - """执行完整的工作流""" - # 初始化结果信息变量 - execution_error = "" - execution_success = False - protocol_return_value = None - self.get_logger().info(f"Executing {protocol_name} action...") - action_value_mapping = self._action_value_mappings[protocol_name] - step_results = [] - try: - print("+" * 30) - print(protocol_steps_generator) - # 从目标消息中提取参数, 并调用Protocol生成器(根据设备连接图)生成action步骤 - goal = goal_handle.request - protocol_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) - - # # 🔧 添加调试信息 - # print(f"🔍 转换后的 protocol_kwargs: {protocol_kwargs}") - # print(f"🔍 vessel 在转换后: {protocol_kwargs.get('vessel', 'NOT_FOUND')}") - - # # 🔧 完全禁用Host查询,直接使用转换后的数据 - # print(f"🔧 跳过Host查询,直接使用转换后的数据") - # 向Host查询物料当前状态 - for k, v in goal.get_fields_and_field_types().items(): - if v in ["unilabos_msgs/Resource", "sequence"]: - r = ResourceGet.Request() - resource_id = ( - protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"] - ) - r.id = resource_id - r.with_children = True - response = await self._resource_clients["resource_get"].call_async(r) - protocol_kwargs[k] = list_to_nested_dict( - [convert_from_ros_msg(rs) for rs in response.resources] - ) - - self.lab_logger().info(f"🔍 最终的 vessel: {protocol_kwargs.get('vessel', 'NOT_FOUND')}") - - from unilabos.resources.graphio import physical_setup_graph - - self.lab_logger().info(f"Working on physical setup: {physical_setup_graph}") - protocol_steps = protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) - logs = [] - for step in protocol_steps: - if isinstance(step, dict) and "log_message" in step.get("action_kwargs", {}): - logs.append(step) - elif isinstance(step, list): - logs.append(step) - self.lab_logger().info(f"Goal received: {protocol_kwargs}, running steps: " - f"{json.dumps(logs, indent=4, ensure_ascii=False)}") - - time_start = time.time() - time_overall = 100 - self._busy = True - - # 逐步执行工作流 - for i, action in enumerate(protocol_steps): - # self.get_logger().info(f"Running step {i + 1}: {action}") - if isinstance(action, dict): - # 如果是单个动作,直接执行 - if action["action_name"] == "wait": - time.sleep(action["action_kwargs"]["time"]) - step_results.append({"step": i + 1, "action": "wait", "result": "completed"}) - else: - result = await self.execute_single_action(**action) - step_results.append({"step": i + 1, "action": action["action_name"], "result": result}) - ret_info = json.loads(getattr(result, "return_info", "{}")) - if not ret_info.get("suc", False): - raise RuntimeError(f"Step {i + 1} failed.") - elif isinstance(action, list): - # 如果是并行动作,同时执行 - actions = action - futures = [ - rclpy.get_global_executor().create_task(self.execute_single_action(**a)) for a in actions - ] - results = [await f for f in futures] - step_results.append( - { - "step": i + 1, - "parallel_actions": [a["action_name"] for a in actions], - "results": results, - } - ) - - # 向Host更新物料当前状态 - for k, v in goal.get_fields_and_field_types().items(): - if v in ["unilabos_msgs/Resource", "sequence"]: - r = ResourceUpdate.Request() - r.resources = [ - convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) - ] - response = await self._resource_clients["resource_update"].call_async(r) - - # 设置成功状态和返回值 - execution_success = True - protocol_return_value = { - "protocol_name": protocol_name, - "steps_executed": len(protocol_steps), - "step_results": step_results, - "total_time": time.time() - time_start, - } - - goal_handle.succeed() - - except Exception as e: - # 捕获并记录错误信息 - str_step_results = [{k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v for k, v in i.items()} for i in step_results] - execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}" - execution_success = False - self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}") - - # 设置动作失败 - goal_handle.abort() - - finally: - self._busy = False - - # 创建结果消息 - result = action_value_mapping["type"].Result() - result.success = execution_success - - # 获取结果消息类型信息,检查是否有return_info字段 - result_msg_types = action_value_mapping["type"].Result.get_fields_and_field_types() - - # 设置return_info字段(如果存在) - for attr_name in result_msg_types.keys(): - if attr_name in ["success", "reached_goal"]: - setattr(result, attr_name, execution_success) - elif attr_name == "return_info": - setattr( - result, - attr_name, - serialize_result_info(execution_error, execution_success, protocol_return_value), - ) - - self.lab_logger().info(f"协议 {protocol_name} 完成并返回结果") - return result - - return execute_protocol - - async def execute_single_action(self, device_id, action_name, action_kwargs): - """执行单个动作""" - # 构建动作ID - if device_id in ["", None, "self"]: - action_id = f"/devices/{self.device_id}/{action_name}" - else: - action_id = f"/devices/{device_id}/{action_name}" # 执行时取消了主节点信息 /{self.device_id} - - # 检查动作客户端是否存在 - if action_id not in self._action_clients: - self.lab_logger().error(f"找不到动作客户端: {action_id}") - return None - - # 发送动作请求 - action_client = self._action_clients[action_id] - goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) - - ##### self.lab_logger().info(f"发送动作请求到: {action_id}") - action_client.wait_for_server() - - # 等待动作完成 - request_future = action_client.send_goal_async(goal_msg) - handle = await request_future - - if not handle.accepted: - self.lab_logger().error(f"动作请求被拒绝: {action_name}") - return None - - result_future = await handle.get_result_async() - ##### self.lab_logger().info(f"动作完成: {action_name}") - - return result_future.result - - """还没有改过的部分""" - - def _setup_modbus_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置Modbus通信代理""" - config = interface_config.get('config', {}) - - # 设置Modbus读写方法 - def modbus_read(address, count=1, function_code=3): - """Modbus读取方法""" - return comm_device.driver_instance.read_holding_registers( - address=address, - count=count, - slave_id=config.get('slave_id', 1) - ) - - def modbus_write(address, value, function_code=6): - """Modbus写入方法""" - if isinstance(value, (list, tuple)): - return comm_device.driver_instance.write_multiple_registers( - address=address, - values=value, - slave_id=config.get('slave_id', 1) - ) - else: - return comm_device.driver_instance.write_single_register( - address=address, - value=value, - slave_id=config.get('slave_id', 1) - ) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, 'comm_read', modbus_read) - setattr(logical_device.driver_instance, 'comm_write', modbus_write) - setattr(logical_device.driver_instance, 'comm_config', config) - setattr(logical_device.driver_instance, 'comm_protocol', 'modbus') - - def _setup_opcua_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置OPC UA通信代理""" - config = interface_config.get('config', {}) - - def opcua_read(node_id): - """OPC UA读取方法""" - return comm_device.driver_instance.read_node_value(node_id) - - def opcua_write(node_id, value): - """OPC UA写入方法""" - return comm_device.driver_instance.write_node_value(node_id, value) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, 'comm_read', opcua_read) - setattr(logical_device.driver_instance, 'comm_write', opcua_write) - setattr(logical_device.driver_instance, 'comm_config', config) - setattr(logical_device.driver_instance, 'comm_protocol', 'opcua') - - def _setup_http_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置HTTP/RPC通信代理""" - config = interface_config.get('config', {}) - base_url = config.get('base_url', 'http://localhost:8080') - - def http_read(endpoint, params=None): - """HTTP GET请求""" - url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" - return comm_device.driver_instance.get_request(url, params=params) - - def http_write(endpoint, data): - """HTTP POST请求""" - url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" - return comm_device.driver_instance.post_request(url, data=data) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, 'comm_read', http_read) - setattr(logical_device.driver_instance, 'comm_write', http_write) - setattr(logical_device.driver_instance, 'comm_config', config) - setattr(logical_device.driver_instance, 'comm_protocol', 'http') - - def _setup_serial_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置串口通信代理""" - config = interface_config.get('config', {}) - - def serial_read(timeout=1.0): - """串口读取方法""" - return comm_device.driver_instance.read_data(timeout=timeout) - - def serial_write(data): - """串口写入方法""" - if isinstance(data, str): - data = data.encode('utf-8') - return comm_device.driver_instance.write_data(data) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, 'comm_read', serial_read) - setattr(logical_device.driver_instance, 'comm_write', serial_write) - setattr(logical_device.driver_instance, 'comm_config', config) - setattr(logical_device.driver_instance, 'comm_protocol', 'serial') - - def _setup_hardware_proxy( - self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method - ): - """为设备设置硬件接口代理""" - # extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])] - write_func = getattr( - communication_device.driver_instance, communication_device.ros_node_instance._hardware_interface["write"] - ) - read_func = getattr( - communication_device.driver_instance, communication_device.ros_node_instance._hardware_interface["read"] - ) - - def _read(*args, **kwargs): - return read_func(*args, **kwargs) - - def _write(*args, **kwargs): - return write_func(*args, **kwargs) - - if read_method: - # bound_read = MethodType(_read, device.driver_instance) - setattr(device.driver_instance, read_method, _read) - - if write_method: - # bound_write = MethodType(_write, device.driver_instance) - setattr(device.driver_instance, write_method, _write) - - async def _update_resources(self, goal, protocol_kwargs): - """更新资源状态""" - for k, v in goal.get_fields_and_field_types().items(): - if v in ["unilabos_msgs/Resource", "sequence"]: - if protocol_kwargs[k] is not None: - try: - r = ResourceUpdate.Request() - r.resources = [ - convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) - ] - await self._resource_clients["resource_update"].call_async(r) - except Exception as e: - self.lab_logger().error(f"更新资源失败: {e}") diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index a96c4459..06fc1c27 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1,7 +1,12 @@ -from typing import List, Tuple, Any +from typing import List, Tuple, Any, Dict, TYPE_CHECKING +from abc import ABC, abstractmethod from unilabos.utils.log import logger +if TYPE_CHECKING: + from unilabos.devices.workstation.workstation_base import WorkstationBase + from pylabrobot.resources import Resource as PLRResource + class DeviceNodeResourceTracker(object): @@ -37,10 +42,20 @@ class DeviceNodeResourceTracker(object): def figure_resource(self, query_resource, try_mode=False): if isinstance(query_resource, list): return [self.figure_resource(r, try_mode) for r in query_resource] - elif isinstance(query_resource, dict) and "id" not in query_resource and "name" not in query_resource: # 临时处理,要删除的,driver有太多类型错误标注 + elif ( + isinstance(query_resource, dict) and "id" not in query_resource and "name" not in query_resource + ): # 临时处理,要删除的,driver有太多类型错误标注 return [self.figure_resource(r, try_mode) for r in query_resource.values()] - res_id = query_resource.id if hasattr(query_resource, "id") else (query_resource.get("id") if isinstance(query_resource, dict) else None) - res_name = query_resource.name if hasattr(query_resource, "name") else (query_resource.get("name") if isinstance(query_resource, dict) else None) + res_id = ( + query_resource.id + if hasattr(query_resource, "id") + else (query_resource.get("id") if isinstance(query_resource, dict) else None) + ) + res_name = ( + query_resource.name + if hasattr(query_resource, "name") + else (query_resource.get("name") if isinstance(query_resource, dict) else None) + ) res_identifier = res_id if res_id else res_name identifier_key = "id" if res_id else "name" resource_cls_type = type(query_resource) @@ -54,7 +69,9 @@ class DeviceNodeResourceTracker(object): ) else: res_list.extend( - self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key)) + self.loop_find_resource( + r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key) + ) ) if not try_mode: assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在" @@ -66,12 +83,16 @@ class DeviceNodeResourceTracker(object): self.resource2parent_resource[id(res_list[0][1])] = res_list[0][0] return res_list[0][1] - def loop_find_resource(self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None) -> List[Tuple[Any, Any]]: + def loop_find_resource( + self, resource, target_resource_cls_type, identifier_key, compare_value, parent_res=None + ) -> List[Tuple[Any, Any]]: res_list = [] # print(resource, target_resource_cls_type, identifier_key, compare_value) children = getattr(resource, "children", []) for child in children: - res_list.extend(self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource)) + res_list.extend( + self.loop_find_resource(child, target_resource_cls_type, identifier_key, compare_value, resource) + ) if target_resource_cls_type == type(resource): if target_resource_cls_type == dict: if identifier_key in resource: diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index 5e16989f..3c621408 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -6,7 +6,6 @@ """ import asyncio import inspect -import json import traceback from abc import abstractmethod from typing import Type, Any, Dict, Optional, TypeVar, Generic @@ -297,6 +296,11 @@ class WorkstationNodeCreator(DeviceClassCreator[T]): try: # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 data["children"] = self.children + station_resource_dict = data["station_resource"] + from pylabrobot.resources import Deck, Resource + plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker) + station_resource = plrc.create_instance(station_resource_dict) + data["station_resource"] = station_resource self.device_instance = super(WorkstationNodeCreator, self).create_instance(data) self.post_create() return self.device_instance From a312de08a511f95bd5f4731fa77a215a08bfeb53 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:20:24 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20station=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E6=B3=A8=E5=86=8C=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/ros/nodes/presets/workstation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index c790940d..de4e94cf 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -289,6 +289,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): def create_ros_action_server(self, action_name, action_value_mapping): """创建ROS动作服务器""" + if action_name not in self.protocol_names: + # 非protocol方法调用父类注册 + return super().create_ros_action_server(action_name, action_value_mapping) # 和Base创建的路径是一致的 protocol_name = action_name action_type = action_value_mapping["type"] @@ -303,8 +306,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): execute_callback=self._create_protocol_execute_callback(action_name, protocol_steps_generator), callback_group=ReentrantCallbackGroup(), ) - self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}") + return def _create_protocol_execute_callback(self, protocol_name, protocol_steps_generator): async def execute_protocol(goal_handle: ServerGoalHandle): From bdddbd57bae886a7ca16a628de0e79b0e7482dd2 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:22:46 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=E8=BF=98=E5=8E=9Fprotocol=20node?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/ros/nodes/presets/workstation.py | 300 ++++------------------ 1 file changed, 46 insertions(+), 254 deletions(-) diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index de4e94cf..4e2dbef8 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -70,50 +70,12 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): resource_tracker=resource_tracker, ) - self.workstation_config = children - self.communication_interfaces = self.workstation_config.get( - "communication_interfaces", {} - ) # 从工作站配置获取通信接口 - - # 新增:获取工作站实例(如果存在) - self.workstation_instance = driver_instance - self._busy = False self.sub_devices = {} - self.communication_devices = {} - self.logical_devices = {} - self._goals = {} - self._protocol_servers = {} self._action_clients = {} + # 初始化子设备 - self._initialize_child_devices() - - if isinstance(getattr(driver_instance, "hardware_interface", None), str): - self.logical_devices[device_id] = driver_instance - else: - self.communication_devices[device_id] = driver_instance - - # 设置硬件接口代理 - for device_id, device_node in self.logical_devices.items(): - if device_node and hasattr(device_node, "ros_node_instance"): - self._setup_device_hardware_proxy(device_id, device_node) - - # 新增:如果有工作站实例,建立双向引用和硬件接口设置 - if self.workstation_instance: - self._setup_workstation_integration() - - def _setup_workstation_integration(self): - """设置工作站集成 - 统一设备处理模式""" - # 1. 建立协议节点引用 - self.workstation_instance.set_workstation_node(self) - - self.lab_logger().info( - f"ROS2WorkstationNode {self.device_id} 与工作站实例 {type(self.workstation_instance).__name__} 集成完成" - ) - - def _initialize_child_devices(self): - """初始化子设备 - 重构为更清晰的方法""" - # 设备分类字典 - 统一管理 + self.communication_node_id_to_instance = {} for device_id, device_config in self.children.items(): if device_config.get("type", "device") != "device": @@ -121,128 +83,52 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." ) continue - try: d = self.initialize_device(device_id, device_config) - if d is None: - continue - - # 统一的设备分类逻辑 - device_type = device_config.get("device_type", "logical") - - # 兼容旧的ID匹配方式和新的配置方式 - if device_type == "communication" or "serial_" in device_id or "io_" in device_id: - self.communication_devices[device_id] = d # 新的统一方式 - self.lab_logger().info(f"通信设备 {device_id} 初始化并分类成功") - elif device_type == "logical": - self.logical_devices[device_id] = d - self.lab_logger().info(f"逻辑设备 {device_id} 初始化并分类成功") - else: - # 默认作为逻辑设备处理 - self.logical_devices[device_id] = d - self.lab_logger().info(f"设备 {device_id} 作为逻辑设备处理") - except Exception as ex: self.lab_logger().error( - f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}" - ) + f"[Protocol Node] Failed to initialize device {device_id}: {ex}\n{traceback.format_exc()}") + d = None + if d is None: + continue - def _setup_device_hardware_proxy(self, device_id: str, device: ROS2DeviceNode): - """统一的设备硬件接口代理设置方法 + if "serial_" in device_id or "io_" in device_id: + self.communication_node_id_to_instance[device_id] = d + continue - Args: - device_id: 设备ID - device: 设备实例 - """ - hardware_interface = device.ros_node_instance._hardware_interface - if not self._validate_hardware_interface(device, hardware_interface): - return + for device_id, device_config in self.children.items(): + if device_config.get("type", "device") != "device": + continue + # 设置硬件接口代理 + if device_id not in self.sub_devices: + self.lab_logger().error(f"[Protocol Node] {device_id} 还没有正确初始化,跳过...") + continue + d = self.sub_devices[device_id] + if d: + hardware_interface = d.ros_node_instance._hardware_interface + if ( + hasattr(d.driver_instance, hardware_interface["name"]) + and hasattr(d.driver_instance, hardware_interface["write"]) + and ( + hardware_interface["read"] is None or hasattr(d.driver_instance, hardware_interface["read"])) + ): - # 获取硬件接口名称 - interface_name = getattr(device.driver_instance, hardware_interface["name"]) + name = getattr(d.driver_instance, hardware_interface["name"]) + read = hardware_interface.get("read", None) + write = hardware_interface.get("write", None) - # 情况1: 如果interface_name是字符串,说明需要转发到其他设备 - if isinstance(interface_name, str): - # 查找目标设备 - communication_device = self.communication_devices.get(device_id, None) - if not communication_device: - self.lab_logger().error(f"转发目标设备 {device_id} 不存在") - return + # 如果硬件接口是字符串,通过通信设备提供 + if isinstance(name, str) and name in self.sub_devices: + communicate_device = self.sub_devices[name] + communicate_hardware_info = communicate_device.ros_node_instance._hardware_interface + self._setup_hardware_proxy(d, self.sub_devices[name], read, write) + self.lab_logger().info( + f"\n通信代理:为子设备{device_id}\n " + f"添加了{read}方法(来源:{name} {communicate_hardware_info['write']}) \n " + f"添加了{write}方法(来源:{name} {communicate_hardware_info['read']})" + ) - read_method = hardware_interface.get("read", None) - write_method = hardware_interface.get("write", None) - - # 设置传统硬件代理 - communicate_hardware_info = communication_device.ros_node_instance._hardware_interface - self._setup_hardware_proxy(device, communication_device, read_method, write_method) - self.lab_logger().info( - f"传统通信代理:为子设备{device.device_id} " - f"添加了{read_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['read']}) " - f"添加了{write_method}方法(来源:{communication_device.device_id} {communicate_hardware_info['write']})" - ) - self.lab_logger().info(f"字符串转发代理:设备 {device.device_id} -> {device_id}") - - # 情况2: 如果设备有communication_interface配置,设置协议代理 - elif hasattr(self, "communication_interfaces") and device_id in self.communication_interfaces: - interface_config = self._get_communication_interface_config(device_id) - protocol_type = interface_config.get("protocol_type", "modbus") - self._setup_communication_proxy(device, interface_config, protocol_type) - - # 情况3: 其他情况,使用默认处理 - else: - self.lab_logger().debug(f"设备 {device_id} 使用默认硬件接口处理") - - def _get_communication_interface_config(self, device_id: str) -> dict: - """获取设备的通信接口配置""" - # 优先从工作站配置获取 - if hasattr(self, "communication_interfaces") and device_id in self.communication_interfaces: - return self.communication_interfaces[device_id] - - # 从设备自身配置获取 - device_node = self.logical_devices[device_id] - if device_node and hasattr(device_node.driver_instance, "communication_interface"): - return getattr(device_node.driver_instance, "communication_interface") - - return {} - - def _validate_hardware_interface(self, device: ROS2DeviceNode, hardware_interface: dict) -> bool: - """验证硬件接口配置""" - return ( - hasattr(device.driver_instance, hardware_interface["name"]) - and hasattr(device.driver_instance, hardware_interface["write"]) - and (hardware_interface["read"] is None or hasattr(device.driver_instance, hardware_interface["read"])) - ) - - def _setup_communication_proxy(self, logical_device: ROS2DeviceNode, interface_config, protocol_type): - """为逻辑设备设置通信代理 - 统一方法""" - try: - # 获取通信设备 - comm_device_id = interface_config.get("device_id") - comm_device = self.communication_devices.get(comm_device_id) - - if not comm_device: - self.lab_logger().error(f"通信设备 {comm_device_id} 不存在") - return - # FIXME http、modbus(tcpip)都是支持多客户端的 - # 根据协议类型设置不同的代理方法 - if protocol_type == "modbus": - self._setup_modbus_proxy(logical_device, comm_device, interface_config) - elif protocol_type == "opcua": - self._setup_opcua_proxy(logical_device, comm_device, interface_config) - elif protocol_type == "http": - self._setup_http_proxy(logical_device, comm_device, interface_config) - elif protocol_type == "serial": - self._setup_serial_proxy(logical_device, comm_device, interface_config) - else: - self.lab_logger().warning(f"不支持的协议类型: {protocol_type}") - return - - self.lab_logger().info( - f"通信代理:为逻辑设备 {logical_device.device_id} 设置{protocol_type}通信代理 -> {comm_device_id}" - ) - - except Exception as e: - self.lab_logger().error(f"设置通信代理失败: {e}") + self.lab_logger().info(f"ROS2ProtocolNode {device_id} initialized with protocols: {self.protocol_names}") def _setup_protocol_names(self, protocol_type): # 处理协议类型 @@ -272,8 +158,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): node.resource_tracker = self.resource_tracker # 站内应当共享资源跟踪器 for action_name, action_mapping in node._action_value_mappings.items(): if action_name.startswith("auto-") or str(action_mapping.get("type", "")).startswith( - "UniLabJsonCommand" - ): + "UniLabJsonCommand"): continue action_id = f"/devices/{device_id_abs}/{action_name}" if action_id not in self._action_clients: @@ -358,10 +243,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): logs.append(step) elif isinstance(step, list): logs.append(step) - self.lab_logger().info( - f"Goal received: {protocol_kwargs}, running steps: " - f"{json.dumps(logs, indent=4, ensure_ascii=False)}" - ) + self.lab_logger().info(f"Goal received: {protocol_kwargs}, running steps: " + f"{json.dumps(logs, indent=4, ensure_ascii=False)}") time_start = time.time() time_overall = 100 @@ -419,12 +302,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): except Exception as e: # 捕获并记录错误信息 str_step_results = [ - { - k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v - for k, v in i.items() - } - for i in step_results - ] + {k: dict(message_to_ordereddict(v)) if k == "result" and hasattr(v, "SLOT_TYPES") else v for k, v in + i.items()} for i in step_results] execution_error = f"{traceback.format_exc()}\n\nStep Result: {pformat(str_step_results)}" execution_success = False self.lab_logger().error(f"协议 {protocol_name} 执行出错: {str(e)} \n{traceback.format_exc()}") @@ -493,95 +372,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): """还没有改过的部分""" - def _setup_modbus_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置Modbus通信代理""" - config = interface_config.get("config", {}) - - # 设置Modbus读写方法 - def modbus_read(address, count=1, function_code=3): - """Modbus读取方法""" - return comm_device.driver_instance.read_holding_registers( - address=address, count=count, slave_id=config.get("slave_id", 1) - ) - - def modbus_write(address, value, function_code=6): - """Modbus写入方法""" - if isinstance(value, (list, tuple)): - return comm_device.driver_instance.write_multiple_registers( - address=address, values=value, slave_id=config.get("slave_id", 1) - ) - else: - return comm_device.driver_instance.write_single_register( - address=address, value=value, slave_id=config.get("slave_id", 1) - ) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, "comm_read", modbus_read) - setattr(logical_device.driver_instance, "comm_write", modbus_write) - setattr(logical_device.driver_instance, "comm_config", config) - setattr(logical_device.driver_instance, "comm_protocol", "modbus") - - def _setup_opcua_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置OPC UA通信代理""" - config = interface_config.get("config", {}) - - def opcua_read(node_id): - """OPC UA读取方法""" - return comm_device.driver_instance.read_node_value(node_id) - - def opcua_write(node_id, value): - """OPC UA写入方法""" - return comm_device.driver_instance.write_node_value(node_id, value) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, "comm_read", opcua_read) - setattr(logical_device.driver_instance, "comm_write", opcua_write) - setattr(logical_device.driver_instance, "comm_config", config) - setattr(logical_device.driver_instance, "comm_protocol", "opcua") - - def _setup_http_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置HTTP/RPC通信代理""" - config = interface_config.get("config", {}) - base_url = config.get("base_url", "http://localhost:8080") - - def http_read(endpoint, params=None): - """HTTP GET请求""" - url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" - return comm_device.driver_instance.get_request(url, params=params) - - def http_write(endpoint, data): - """HTTP POST请求""" - url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" - return comm_device.driver_instance.post_request(url, data=data) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, "comm_read", http_read) - setattr(logical_device.driver_instance, "comm_write", http_write) - setattr(logical_device.driver_instance, "comm_config", config) - setattr(logical_device.driver_instance, "comm_protocol", "http") - - def _setup_serial_proxy(self, logical_device: ROS2DeviceNode, comm_device: ROS2DeviceNode, interface_config): - """设置串口通信代理""" - config = interface_config.get("config", {}) - - def serial_read(timeout=1.0): - """串口读取方法""" - return comm_device.driver_instance.read_data(timeout=timeout) - - def serial_write(data): - """串口写入方法""" - if isinstance(data, str): - data = data.encode("utf-8") - return comm_device.driver_instance.write_data(data) - - # 绑定方法到逻辑设备 - setattr(logical_device.driver_instance, "comm_read", serial_read) - setattr(logical_device.driver_instance, "comm_write", serial_write) - setattr(logical_device.driver_instance, "comm_config", config) - setattr(logical_device.driver_instance, "comm_protocol", "serial") - def _setup_hardware_proxy( - self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method + self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method ): """为设备设置硬件接口代理""" # extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])] @@ -618,4 +410,4 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): ] await self._resource_clients["resource_update"].call_async(r) except Exception as e: - self.lab_logger().error(f"更新资源失败: {e}") + self.lab_logger().error(f"更新资源失败: {e}") \ No newline at end of file