diff --git a/.conda/recipe.yaml b/.conda/recipe.yaml index 1f70913..62b0633 100644 --- a/.conda/recipe.yaml +++ b/.conda/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: 0.10.11 + version: 0.10.12 source: path: ../unilabos diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 744439b..86150f0 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.11 + version: 0.10.12 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 3a860d0..0f79b26 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.11" + version: "0.10.12" source: path: ../.. diff --git a/setup.py b/setup.py index 4b0372f..4f733d0 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.11', + version='0.10.12', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index 47f57bf..a37fec7 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.11" +__version__ = "0.10.12" diff --git a/unilabos/app/communication.py b/unilabos/app/communication.py index 436fa98..700065d 100644 --- a/unilabos/app/communication.py +++ b/unilabos/app/communication.py @@ -141,7 +141,7 @@ class CommunicationClientFactory: """ if cls._client_cache is None: cls._client_cache = cls.create_client(protocol) - logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client") + logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client") return cls._client_cache diff --git a/unilabos/app/main.py b/unilabos/app/main.py index ea75867..5887552 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -218,7 +218,7 @@ def main(): if hasattr(BasicConfig, "log_level"): logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.") - configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) + configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) if args_dict["addr"] == "test": print_status("使用测试环境地址", "info") diff --git a/unilabos/app/oss_upload.py b/unilabos/app/oss_upload.py index b6677a7..f28187e 100644 --- a/unilabos/app/oss_upload.py +++ b/unilabos/app/oss_upload.py @@ -34,14 +34,14 @@ def _get_oss_token( client = http_client # 构造scene参数: driver_name-exp_type - scene = f"{driver_name}-{exp_type}" + sub_path = f"{driver_name}-{exp_type}" # 构造请求URL,使用client的remote_addr(已包含/api/v1/) url = f"{client.remote_addr}/applications/token" - params = {"scene": scene, "filename": filename} + params = {"sub_path": sub_path, "filename": filename, "scene": "job"} try: - logger.info(f"[OSS] 请求预签名URL: scene={scene}, filename={filename}") + logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}") response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10) if response.status_code == 200: diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 8f39275..50204a2 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -389,7 +389,7 @@ class MessageProcessor: self.is_running = True self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor") self.thread.start() - logger.info("[MessageProcessor] Started") + logger.trace("[MessageProcessor] Started") def stop(self) -> None: """停止消息处理线程""" @@ -939,7 +939,7 @@ class QueueProcessor: # 事件通知机制 self.queue_update_event = threading.Event() - logger.info("[QueueProcessor] Initialized") + logger.trace("[QueueProcessor] Initialized") def set_websocket_client(self, websocket_client: "WebSocketClient"): """设置WebSocket客户端引用""" @@ -954,7 +954,7 @@ class QueueProcessor: self.is_running = True self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor") self.thread.start() - logger.info("[QueueProcessor] Started") + logger.trace("[QueueProcessor] Started") def stop(self) -> None: """停止队列处理线程""" @@ -1314,3 +1314,19 @@ class WebSocketClient(BaseCommunicationClient): logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully") else: logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}") + + def publish_host_ready(self) -> None: + """发布host_node ready信号""" + if self.is_disabled or not self.is_connected(): + logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") + return + + message = { + "action": "host_node_ready", + "data": { + "status": "ready", + "timestamp": time.time(), + }, + } + self.message_processor.send_message(message) + logger.info("[WebSocketClient] Host node ready signal published") diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 11de356..7f4479a 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -45,10 +45,13 @@ def canonicalize_nodes_data( print_status(f"{len(nodes)} Resources loaded:", "info") # 第一步:基本预处理(处理graphml的label字段) - for node in nodes: + outer_host_node_id = None + for idx, node in enumerate(nodes): if node.get("label") is not None: node_id = node.pop("label") node["id"] = node["name"] = node_id + if node["id"] == "host_node": + outer_host_node_id = idx if not isinstance(node.get("config"), dict): node["config"] = {} if not node.get("type"): @@ -58,25 +61,26 @@ def canonicalize_nodes_data( node["name"] = node.get("id") print_status(f"Warning: Node {node.get('id', 'unknown')} missing 'name', defaulting to {node['name']}", "warning") if not isinstance(node.get("position"), dict): - node["position"] = {"position": {}} + node["pose"] = {"position": {}} x = node.pop("x", None) if x is not None: - node["position"]["position"]["x"] = x + node["pose"]["position"]["x"] = x y = node.pop("y", None) if y is not None: - node["position"]["position"]["y"] = y + node["pose"]["position"]["y"] = y z = node.pop("z", None) if z is not None: - node["position"]["position"]["z"] = z + node["pose"]["position"]["z"] = z if "sample_id" in node: sample_id = node.pop("sample_id") if sample_id: logger.error(f"{node}的sample_id参数已弃用,sample_id: {sample_id}") for k in list(node.keys()): - if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children"]: + if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]: v = node.pop(k) node["config"][k] = v - + if outer_host_node_id is not None: + nodes.pop(outer_host_node_id) # 第二步:处理parent_relation id2idx = {node["id"]: idx for idx, node in enumerate(nodes)} for parent, children in parent_relation.items(): @@ -93,7 +97,7 @@ def canonicalize_nodes_data( for node in nodes: try: - print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info") + # print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info") # 使用标准化方法 resource_instance = ResourceDictInstance.get_resource_instance_from_dict(node) known_nodes[node["id"]] = resource_instance @@ -582,11 +586,15 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w "tip_rack": "tip_rack", "warehouse": "warehouse", "container": "container", + "tube": "tube", + "bottle_carrier": "bottle_carrier", + "plate_adapter": "plate_adapter", } if source in replace_info: return replace_info[source] else: - logger.warning(f"转换pylabrobot的时候,出现未知类型: {source}") + if source is not None: + logger.warning(f"转换pylabrobot的时候,出现未知类型: {source}") return source def resource_plr_to_ulab_inner(d: dict, all_states: dict, child=True) -> dict: diff --git a/unilabos/ros/device_node_wrapper.py b/unilabos/ros/device_node_wrapper.py index 51ff217..f5e80c5 100644 --- a/unilabos/ros/device_node_wrapper.py +++ b/unilabos/ros/device_node_wrapper.py @@ -5,6 +5,7 @@ from unilabos.ros.msgs.message_converter import ( get_action_type, ) from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode +from unilabos.ros.nodes.resource_tracker import ResourceDictInstance # 定义泛型类型变量 T = TypeVar("T") @@ -18,12 +19,11 @@ class ROS2DeviceNodeWrapper(ROS2DeviceNode): def ros2_device_node( cls: Type[T], - device_config: Optional[Dict[str, Any]] = None, + device_config: Optional[ResourceDictInstance] = None, status_types: Optional[Dict[str, Any]] = None, action_value_mappings: Optional[Dict[str, Any]] = None, hardware_interface: Optional[Dict[str, Any]] = None, print_publish: bool = False, - children: Optional[Dict[str, Any]] = None, ) -> Type[ROS2DeviceNodeWrapper]: """Create a ROS2 Node class for a device class with properties and actions. @@ -45,7 +45,7 @@ def ros2_device_node( if status_types is None: status_types = {} if device_config is None: - device_config = {} + raise ValueError("device_config cannot be None") if action_value_mappings is None: action_value_mappings = {} if hardware_interface is None: @@ -82,7 +82,6 @@ def ros2_device_node( action_value_mappings=action_value_mappings, hardware_interface=hardware_interface, print_publish=print_publish, - children=children, *args, **kwargs, ), diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index a92a9f5..55ac145 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -4,13 +4,14 @@ from typing import Optional from unilabos.registry.registry import lab_registry from unilabos.ros.device_node_wrapper import ros2_device_node from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError +from unilabos.ros.nodes.resource_tracker import ResourceDictInstance from unilabos.utils import logger from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.import_manager import default_manager -def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2DeviceNode]: +def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) -> Optional[ROS2DeviceNode]: """Initializes a device based on its configuration. This function dynamically imports the appropriate device class and creates an instance of it using the provided device configuration. @@ -24,15 +25,14 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device None """ d = None - original_device_config = copy.deepcopy(device_config) - device_class_config = device_config["class"] - uid = device_config["uuid"] + device_class_config = device_config.res_content.klass + uid = device_config.res_content.uuid if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class if len(device_class_config) == 0: raise DeviceClassInvalid(f"Device [{device_id}] class cannot be an empty string. {device_config}") if device_class_config not in lab_registry.device_type_registry: raise DeviceClassInvalid(f"Device [{device_id}] class {device_class_config} not found. {device_config}") - device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"] + device_class_config = lab_registry.device_type_registry[device_class_config]["class"] elif isinstance(device_class_config, dict): raise DeviceClassInvalid(f"Device [{device_id}] class config should be type 'str' but 'dict' got. {device_config}") if isinstance(device_class_config, dict): @@ -41,17 +41,16 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device DEVICE = ros2_device_node( DEVICE, status_types=device_class_config.get("status_types", {}), - device_config=original_device_config, + device_config=device_config, action_value_mappings=device_class_config.get("action_value_mappings", {}), hardware_interface=device_class_config.get( "hardware_interface", {"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}, - ), - children=device_config.get("children", {}) + ) ) try: d = DEVICE( - device_id=device_id, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.get("config", {}) + device_id=device_id, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.res_content.config ) except DeviceInitError as ex: return d diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index ecf8697..4373cea 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -192,7 +192,7 @@ def slave( for device_config in devices_config.root_nodes: device_id = device_config.res_content.id if device_config.res_content.type == "device": - d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) + d = initialize_device_from_dict(device_id, device_config) if d is not None: devices_instances[device_id] = d logger.info(f"Device {device_id} initialized.") diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index ae6db26..6952320 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -48,7 +48,7 @@ from unilabos_msgs.msg import Resource # type: ignore from unilabos.ros.nodes.resource_tracker import ( DeviceNodeResourceTracker, ResourceTreeSet, - ResourceTreeInstance, + ResourceTreeInstance, ResourceDictInstance, ) from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator @@ -133,12 +133,11 @@ def init_wrapper( device_id: str, device_uuid: str, driver_class: type[T], - device_config: Dict[str, Any], + device_config: ResourceTreeInstance, status_types: Dict[str, Any], action_value_mappings: Dict[str, Any], hardware_interface: Dict[str, Any], print_publish: bool, - children: Optional[list] = None, driver_params: Optional[Dict[str, Any]] = None, driver_is_ros: bool = False, *args, @@ -147,8 +146,6 @@ def init_wrapper( """初始化设备节点的包装函数,和ROS2DeviceNode初始化保持一致""" if driver_params is None: driver_params = kwargs.copy() - if children is None: - children = [] kwargs["device_id"] = device_id kwargs["device_uuid"] = device_uuid kwargs["driver_class"] = driver_class @@ -157,7 +154,6 @@ def init_wrapper( kwargs["status_types"] = status_types kwargs["action_value_mappings"] = action_value_mappings kwargs["hardware_interface"] = hardware_interface - kwargs["children"] = children kwargs["print_publish"] = print_publish kwargs["driver_is_ros"] = driver_is_ros super(type(self), self).__init__(*args, **kwargs) @@ -586,7 +582,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): except Exception as e: self.lab_logger().error(f"更新资源uuid失败: {e}") self.lab_logger().error(traceback.format_exc()) - self.lab_logger().debug(f"资源更新结果: {response}") + self.lab_logger().trace(f"资源更新结果: {response}") async def get_resource(self, resources_uuid: List[str], with_children: bool = True) -> ResourceTreeSet: """ @@ -1168,7 +1164,6 @@ class BaseROS2DeviceNode(Node, Generic[T]): execution_error = traceback.format_exc() break - ##### self.lab_logger().info(f"准备执行: {action_kwargs}, 函数: {ACTION.__name__}") time_start = time.time() time_overall = 100 future = None @@ -1176,35 +1171,36 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 将阻塞操作放入线程池执行 if asyncio.iscoroutinefunction(ACTION): try: - ##### self.lab_logger().info(f"异步执行动作 {ACTION}") - future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) - - def _handle_future_exception(fut): + self.lab_logger().trace(f"异步执行动作 {ACTION}") + def _handle_future_exception(fut: Future): nonlocal execution_error, execution_success, action_return_value try: action_return_value = fut.result() + if isinstance(action_return_value, BaseException): + raise action_return_value execution_success = True - except Exception as e: + except Exception as _: execution_error = traceback.format_exc() error( f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" ) + future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) future.add_done_callback(_handle_future_exception) except Exception as e: execution_error = traceback.format_exc() execution_success = False self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}") else: - ##### self.lab_logger().info(f"同步执行动作 {ACTION}") + self.lab_logger().trace(f"同步执行动作 {ACTION}") future = self._executor.submit(ACTION, **action_kwargs) - def _handle_future_exception(fut): + def _handle_future_exception(fut: Future): nonlocal execution_error, execution_success, action_return_value try: action_return_value = fut.result() execution_success = True - except Exception as e: + except Exception as _: execution_error = traceback.format_exc() error( f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" @@ -1309,7 +1305,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): get_result_info_str(execution_error, execution_success, action_return_value), ) - ##### self.lab_logger().info(f"动作 {action_name} 完成并返回结果") + self.lab_logger().trace(f"动作 {action_name} 完成并返回结果") return result_msg return execute_callback @@ -1544,17 +1540,29 @@ class ROS2DeviceNode: 这个类封装了设备类实例和ROS2节点的功能,提供ROS2接口。 它不继承设备类,而是通过代理模式访问设备类的属性和方法。 """ + @staticmethod + async def safe_task_wrapper(trace_callback, func, **kwargs): + try: + if callable(trace_callback): + trace_callback(await func(**kwargs)) + return await func(**kwargs) + except Exception as e: + if callable(trace_callback): + trace_callback(e) + return e @classmethod - def run_async_func(cls, func, trace_error=True, **kwargs) -> Task: - def _handle_future_exception(fut): + def run_async_func(cls, func, trace_error=True, inner_trace_callback=None, **kwargs) -> Task: + def _handle_future_exception(fut: Future): try: - fut.result() + ret = fut.result() + if isinstance(ret, BaseException): + raise ret except Exception as e: - error(f"异步任务 {func.__name__} 报错了") + error(f"异步任务 {func.__name__} 获取结果失败") error(traceback.format_exc()) - future = rclpy.get_global_executor().create_task(func(**kwargs)) + future = rclpy.get_global_executor().create_task(ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs)) if trace_error: future.add_done_callback(_handle_future_exception) return future @@ -1582,12 +1590,11 @@ class ROS2DeviceNode: device_id: str, device_uuid: str, driver_class: Type[T], - device_config: Dict[str, Any], + device_config: ResourceDictInstance, driver_params: Dict[str, Any], status_types: Dict[str, Any], action_value_mappings: Dict[str, Any], hardware_interface: Dict[str, Any], - children: Dict[str, Any], print_publish: bool = True, driver_is_ros: bool = False, ): @@ -1598,7 +1605,7 @@ class ROS2DeviceNode: device_id: 设备标识符 device_uuid: 设备uuid driver_class: 设备类 - device_config: 原始初始化的json + device_config: 原始初始化的ResourceDictInstance driver_params: driver初始化的参数 status_types: 状态类型映射 action_value_mappings: 动作值映射 @@ -1612,6 +1619,7 @@ class ROS2DeviceNode: self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__") self._driver_class = driver_class self.device_config = device_config + children: List[ResourceDictInstance] = device_config.children self.driver_is_ros = driver_is_ros self.driver_is_workstation = False self.resource_tracker = DeviceNodeResourceTracker() diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 2e7f7a2..fa9cad1 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -289,6 +289,12 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info("[Host Node] Host node initialized.") HostNode._ready_event.set() + # 发送host_node ready信号到所有桥接器 + for bridge in self.bridges: + if hasattr(bridge, "publish_host_ready"): + bridge.publish_host_ready() + self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}") + def _send_re_register(self, sclient): sclient.wait_for_service() request = SerialCommand.Request() @@ -532,7 +538,7 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info(f"[Host Node] Initializing device: {device_id}") try: - d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) + d = initialize_device_from_dict(device_id, device_config) except DeviceClassInvalid as e: self.lab_logger().error(f"[Host Node] Device class invalid: {e}") d = None @@ -712,7 +718,7 @@ class HostNode(BaseROS2DeviceNode): feedback_callback=lambda feedback_msg: self.feedback_callback(item, action_id, feedback_msg), goal_uuid=goal_uuid_obj, ) - future.add_done_callback(lambda future: self.goal_response_callback(item, action_id, future)) + future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f)) def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None: """目标响应回调""" @@ -723,9 +729,11 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted") self._goals[item.job_id] = goal_handle - goal_handle.get_result_async().add_done_callback( - lambda future: self.get_result_callback(item, action_id, future) + goal_future = goal_handle.get_result_async() + goal_future.add_done_callback( + lambda f: self.get_result_callback(item, action_id, f) ) + goal_future.result() def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None: """反馈回调""" @@ -794,6 +802,7 @@ class HostNode(BaseROS2DeviceNode): # 存储结果供 HTTP API 查询 try: from unilabos.app.web.controller import store_job_result + if goal_status == GoalStatus.STATUS_CANCELED: store_job_result(job_id, status, return_info, {}) else: diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index af1afab..7325dda 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -24,7 +24,7 @@ from unilabos.ros.msgs.message_converter import ( convert_from_ros_msg_with_mapping, ) from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode -from unilabos.ros.nodes.resource_tracker import ResourceTreeSet +from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDictInstance from unilabos.utils.type_check import get_result_info_str if TYPE_CHECKING: @@ -47,7 +47,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): def __init__( self, protocol_type: List[str], - children: Dict[str, Any], + children: List[ResourceDictInstance], *, driver_instance: "WorkstationBase", device_id: str, @@ -81,10 +81,11 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): # 初始化子设备 self.communication_node_id_to_instance = {} - for device_id, device_config in self.children.items(): - if device_config.get("type", "device") != "device": + for device_config in self.children: + device_id = device_config.res_content.id + if device_config.res_content.type != "device": self.lab_logger().debug( - f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." + f"[Protocol Node] Skipping type {device_config.res_content.type} {device_id} already existed, skipping." ) continue try: @@ -101,8 +102,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): self.communication_node_id_to_instance[device_id] = d continue - for device_id, device_config in self.children.items(): - if device_config.get("type", "device") != "device": + for device_config in self.children: + device_id = device_config.res_content.id + if device_config.res_content.type != "device": continue # 设置硬件接口代理 if device_id not in self.sub_devices: diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 2a01b31..0eed117 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1,9 +1,11 @@ +import inspect import traceback import uuid from pydantic import BaseModel, field_serializer, field_validator from pydantic import Field from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union +from unilabos.resources.plr_additional_res_reg import register from unilabos.utils.log import logger if TYPE_CHECKING: @@ -62,7 +64,6 @@ class ResourceDict(BaseModel): parent: Optional["ResourceDict"] = Field(description="Parent resource object", default=None, exclude=True) type: Union[Literal["device"], str] = Field(description="Resource type") klass: str = Field(alias="class", description="Resource class name") - position: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition) pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition) config: Dict[str, Any] = Field(description="Resource configuration") data: Dict[str, Any] = Field(description="Resource data") @@ -146,15 +147,16 @@ class ResourceDictInstance(object): if not content.get("extra"): # MagicCode content["extra"] = {} if "pose" not in content: - content["pose"] = content.get("position", {}) + content["pose"] = content.pop("position", {}) return ResourceDictInstance(ResourceDict.model_validate(content)) - def get_nested_dict(self) -> Dict[str, Any]: + def get_plr_nested_dict(self) -> Dict[str, Any]: """获取资源实例的嵌套字典表示""" res_dict = self.res_content.model_dump(by_alias=True) - res_dict["children"] = {child.res_content.id: child.get_nested_dict() for child in self.children} + res_dict["children"] = {child.res_content.id: child.get_plr_nested_dict() for child in self.children} res_dict["parent"] = self.res_content.parent_instance_name - res_dict["position"] = self.res_content.position.position.model_dump() + res_dict["position"] = self.res_content.pose.position.model_dump() + del res_dict["pose"] return res_dict @@ -429,9 +431,9 @@ class ResourceTreeSet(object): Returns: List[PLRResource]: PLR 资源实例列表 """ + register() from pylabrobot.resources import Resource as PLRResource from pylabrobot.utils.object_parsing import find_subclass - import inspect # 类型映射 TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer"} @@ -459,9 +461,9 @@ class ResourceTreeSet(object): "size_y": res.config.get("size_y", 0), "size_z": res.config.get("size_z", 0), "location": { - "x": res.position.position.x, - "y": res.position.position.y, - "z": res.position.position.z, + "x": res.pose.position.x, + "y": res.pose.position.y, + "z": res.pose.position.z, "type": "Coordinate", }, "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index 9481ce3..7a60474 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -9,10 +9,11 @@ import asyncio import inspect import traceback from abc import abstractmethod -from typing import Type, Any, Dict, Optional, TypeVar, Generic +from typing import Type, Any, Dict, Optional, TypeVar, Generic, List from unilabos.resources.graphio import nested_dict_to_list, resource_ulab_to_plr -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \ + ResourceTreeInstance from unilabos.utils import logger, import_manager from unilabos.utils.cls_creator import create_instance_from_config @@ -33,7 +34,7 @@ class DeviceClassCreator(Generic[T]): 这个类提供了从任意类创建实例的通用方法。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): """ 初始化设备类创建器 @@ -50,9 +51,9 @@ class DeviceClassCreator(Generic[T]): 附加资源到设备类实例 """ if self.device_instance is not None: - for c in self.children.values(): - if c["type"] != "device": - self.resource_tracker.add_resource(c) + for c in self.children: + if c.res_content.type != "device": + self.resource_tracker.add_resource(c.get_plr_nested_dict()) def create_instance(self, data: Dict[str, Any]) -> T: """ @@ -94,7 +95,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]): 这个类提供了针对PyLabRobot设备类的实例创建方法,特别处理deserialize方法。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): """ 初始化PyLabRobot设备类创建器 @@ -111,12 +112,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]): def attach_resource(self): pass # 只能增加实例化物料,原来默认物料仅为字典查询 - def _process_resource_mapping(self, resource, source_type): - if source_type == dict: - from pylabrobot.resources.resource import Resource - - return nested_dict_to_list(resource), Resource - return resource, source_type + # def _process_resource_mapping(self, resource, source_type): + # if source_type == dict: + # from pylabrobot.resources.resource import Resource + # + # return nested_dict_to_list(resource), Resource + # return resource, source_type def _process_resource_references( self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None @@ -142,15 +143,21 @@ class PyLabRobotCreator(DeviceClassCreator[T]): if isinstance(data, dict): if "_resource_child_name" in data: child_name = data["_resource_child_name"] - if child_name in self.children: - resource = self.children[child_name] + resource: Optional[ResourceDictInstance] = None + for child in self.children: + if child.res_content.name == child_name: + resource = child + if resource is not None: if "_resource_type" in data: type_path = data["_resource_type"] try: - target_type = import_manager.get_class(type_path) - contain_model = not issubclass(target_type, Deck) - resource, target_type = self._process_resource_mapping(resource, target_type) - resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state + # target_type = import_manager.get_class(type_path) + # contain_model = not issubclass(target_type, Deck) + # resource, target_type = self._process_resource_mapping(resource, target_type) + res_tree = ResourceTreeInstance(resource) + res_tree_set = ResourceTreeSet([res_tree]) + resource_instance: Resource = res_tree_set.to_plr_resources()[0] + # resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state states[prefix_path] = resource_instance.serialize_all_state() # 使用 prefix_path 作为 key 存储资源状态 if to_dict: @@ -202,12 +209,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]): stack = None # 递归遍历 children 构建 name_to_uuid 映射 - def collect_name_to_uuid(children_dict: Dict[str, Any], result: Dict[str, str]): + def collect_name_to_uuid(children_list: List[ResourceDictInstance], result: Dict[str, str]): """递归遍历嵌套的 children 字典,收集 name 到 uuid 的映射""" - for child in children_dict.values(): - if isinstance(child, dict): - result[child["name"]] = child["uuid"] - collect_name_to_uuid(child["children"], result) + for child in children_list: + if isinstance(child, ResourceDictInstance): + result[child.res_content.name] = child.res_content.uuid + collect_name_to_uuid(child.children, result) name_to_uuid = {} collect_name_to_uuid(self.children, name_to_uuid) @@ -313,7 +320,7 @@ class WorkstationNodeCreator(DeviceClassCreator[T]): 这个类提供了针对WorkstationNode设备类的实例创建方法,处理children参数。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): """ 初始化WorkstationNode设备类创建器 @@ -336,9 +343,9 @@ class WorkstationNodeCreator(DeviceClassCreator[T]): try: # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 data["children"] = self.children - for material_id, child in self.children.items(): - if child["type"] != "device": - self.resource_tracker.add_resource(self.children[material_id]) + for child in self.children: + if child.res_content.type != "device": + self.resource_tracker.add_resource(child.get_plr_nested_dict()) deck_dict = data.get("deck") if deck_dict: from pylabrobot.resources import Deck, Resource diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index af03d94..ffe13c0 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -162,8 +162,9 @@ def configure_logger(loglevel=None, working_dir=None): """ # 获取根日志记录器 root_logger = logging.getLogger() - + root_logger.setLevel(TRACE_LEVEL) # 设置日志级别 + numeric_level = logging.DEBUG if loglevel is not None: if isinstance(loglevel, str): # 将字符串转换为logging级别 @@ -173,12 +174,8 @@ def configure_logger(loglevel=None, working_dir=None): numeric_level = getattr(logging, loglevel.upper(), None) if not isinstance(numeric_level, int): print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") - numeric_level = logging.DEBUG else: numeric_level = loglevel - root_logger.setLevel(numeric_level) - else: - root_logger.setLevel(logging.DEBUG) # 默认级别 # 移除已存在的处理器 for handler in root_logger.handlers[:]: @@ -186,7 +183,7 @@ def configure_logger(loglevel=None, working_dir=None): # 创建控制台处理器 console_handler = logging.StreamHandler() - console_handler.setLevel(root_logger.level) # 使用与根记录器相同的级别 + console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别 # 使用自定义的颜色格式化器 color_formatter = ColoredFormatter() @@ -206,7 +203,7 @@ def configure_logger(loglevel=None, working_dir=None): # 创建文件处理器 file_handler = logging.FileHandler(log_filepath, encoding="utf-8") - file_handler.setLevel(root_logger.level) + file_handler.setLevel(TRACE_LEVEL) # 使用不带颜色的格式化器 file_formatter = ColoredFormatter(use_colors=False) diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index 62a7bdc..dbb2038 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.11 + 0.10.12 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln