diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index c611d21..85891ff 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -9284,7 +9284,13 @@ liquid_handler.prcxi: z: 0.0 sample_id: '' type: '' - handles: {} + handles: + input: + - data_key: wells + data_source: handle + data_type: resource + handler_key: input_wells + label: InputWells placeholder_keys: wells: unilabos_resources result: {} diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 20e0245..64aba3c 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -124,11 +124,25 @@ class Registry: "output": [ { "handler_key": "labware", - "label": "Labware", "data_type": "resource", - "data_source": "handle", - "data_key": "liquid", - } + "label": "Labware", + "data_source": "executor", + "data_key": "created_resource_tree.@flatten", + }, + { + "handler_key": "liquid_slots", + "data_type": "resource", + "label": "LiquidSlots", + "data_source": "executor", + "data_key": "liquid_input_resource_tree.@flatten", + }, + { + "handler_key": "materials", + "data_type": "resource", + "label": "AllMaterials", + "data_source": "executor", + "data_key": "[created_resource_tree,liquid_input_resource_tree].@flatten.@flatten", + }, ] }, "placeholder_keys": { @@ -186,7 +200,17 @@ class Registry: "resources": "unilabos_resources", }, "goal_default": {}, - "handles": {}, + "handles": { + "input": [ + { + "handler_key": "input_resources", + "data_type": "resource", + "label": "InputResources", + "data_source": "handle", + "data_key": "resources", # 不为空 + }, + ] + }, }, }, }, diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index faf0482..252c8f4 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -1151,11 +1151,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni if resource_class_config["type"] == "pylabrobot": resource_plr = RESOURCE(name=resource_config["name"]) if resource_type != ResourcePLR: - tree_sets = ResourceTreeSet.from_plr_resources([resource_plr]) - # r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None)) - # # r = resource_plr_to_ulab(resource_plr=resource_plr) - # if resource_config.get("position") is not None: - # r["position"] = resource_config["position"] + tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True) r = tree_sets.dump() else: r = resource_plr diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 610ba3d..d93cb4e 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -147,17 +147,17 @@ class ResourceDictInstance(object): if not content.get("extra"): # MagicCode content["extra"] = {} if "position" in content: - pose = content.get("pose",{}) - if "position" not in pose : + pose = content.get("pose", {}) + if "position" not in pose: if "position" in content["position"]: pose["position"] = content["position"]["position"] else: pose["position"] = {"x": 0, "y": 0, "z": 0} if "size" not in pose: pose["size"] = { - "width": content["config"].get("size_x", 0), - "height": content["config"].get("size_y", 0), - "depth": content["config"].get("size_z", 0) + "width": content["config"].get("size_x", 0), + "height": content["config"].get("size_y", 0), + "depth": content["config"].get("size_z", 0), } content["pose"] = pose return ResourceDictInstance(ResourceDict.model_validate(content)) @@ -322,7 +322,7 @@ class ResourceTreeSet(object): ) @classmethod - def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet": + def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet": """ 从plr资源创建ResourceTreeSet """ @@ -349,7 +349,8 @@ class ResourceTreeSet(object): if not uid: uid = str(uuid.uuid4()) res.unilabos_uuid = uid - logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}") + if not known_newly_created: + logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}") # 获取unilabos_extra,默认为空字典 extra = getattr(res, "unilabos_extra", {}) @@ -448,7 +449,13 @@ class ResourceTreeSet(object): from pylabrobot.utils.object_parsing import find_subclass # 类型映射 - TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"} + TYPE_MAP = { + "plate": "Plate", + "well": "Well", + "deck": "Deck", + "container": "RegularContainer", + "tip_spot": "TipSpot", + } def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict): """一次遍历收集 name_to_uuid, all_states 和 name_to_extra""" @@ -918,6 +925,33 @@ class DeviceNodeResourceTracker(object): return self._traverse_and_process(resource, process) + def loop_find_with_uuid(self, resource, target_uuid: str): + """ + 递归遍历资源树,根据 uuid 查找并返回对应的资源 + + Args: + resource: 资源对象(可以是list、dict或实例) + target_uuid: 要查找的uuid + + Returns: + 找到的资源对象,未找到则返回None + """ + found_resource = None + + def process(res): + nonlocal found_resource + if found_resource is not None: + return 0 # 已找到,跳过后续处理 + current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") + if current_uuid and current_uuid == target_uuid: + found_resource = res + logger.trace(f"找到资源UUID: {target_uuid}") + return 1 + return 0 + + self._traverse_and_process(resource, process) + return found_resource + def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int: """ 递归遍历资源树,根据 name 设置所有节点的 extra diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index e8570d3..632d5e1 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -159,10 +159,14 @@ _msg_converter: Dict[Type, Any] = { else Pose() ), config=json.dumps(x.get("config", {})), - data=json.dumps(x.get("data", {})), + data=json.dumps(obtain_data_with_uuid(x)), ), } +def obtain_data_with_uuid(x: dict): + data = x.get("data", {}) + data["unilabos_uuid"] = x.get("uuid", None) + return data def json_or_yaml_loads(data: str) -> Any: try: diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 89c4d39..8dd4a28 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -430,11 +430,14 @@ class BaseROS2DeviceNode(Node, Generic[T]): }) tree_response: SerialCommand.Response = await client.call_async(request) uuid_maps = json.loads(tree_response.response) - self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) + plr_instances = rts.to_plr_resources() + for plr_instance in plr_instances: + self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps) + rts: ResourceTreeSet = ResourceTreeSet.from_plr_resources(plr_instances) self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") final_response = { - "created_resources": rts.dump(), - "liquid_input_resources": [], + "created_resource_tree": rts.dump(), + "liquid_input_resource_tree": [], } res.response = json.dumps(final_response) # 如果driver自己就有assign的方法,那就使用driver自己的assign方法 @@ -460,7 +463,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): return res try: if len(rts.root_nodes) == 1 and parent_resource is not None: - plr_instance = rts.to_plr_resources()[0] + plr_instance = plr_instances[0] if isinstance(plr_instance, Plate): empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: @@ -485,7 +488,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): input_wells = [] for r in LIQUID_INPUT_SLOT: input_wells.append(plr_instance.children[r]) - final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump() + final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump() res.response = json.dumps(final_response) if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param: other_calling_param["slot"] = int(other_calling_param["slot"]) @@ -1263,7 +1266,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) - self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}") + self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {str(action_kwargs)[:1000]}") + self.lab_logger().trace(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}") error_skip = False # 向Host查询物料当前状态,如果是host本身的增加物料的请求,则直接跳过 if action_name not in ["create_resource_detailed", "create_resource"]: @@ -1279,9 +1283,14 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 批量查询资源 queried_resources = [] for resource_data in resource_inputs: - plr_resource = await self.get_resource_with_dir( - resource_id=resource_data["id"], with_children=True - ) + unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid") + if unilabos_uuid is None: + plr_resource = await self.get_resource_with_dir( + resource_id=resource_data["id"], with_children=True + ) + else: + resource_tree = await self.get_resource([unilabos_uuid]) + plr_resource = resource_tree.to_plr_resources()[0] if "sample_id" in resource_data: plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] queried_resources.append(plr_resource) @@ -1330,9 +1339,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): execution_success = True except Exception as _: execution_error = traceback.format_exc() - error( - f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" - ) + error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}") + trace(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) @@ -1352,8 +1360,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): except Exception as _: execution_error = traceback.format_exc() error( - f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" - ) + f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}") + trace( + f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}") future.add_done_callback(_handle_future_exception) @@ -1421,7 +1430,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): for r in rs: res = self.resource_tracker.parent_resource(r) # 获取 resource 对象 else: - res = self.resource_tracker.parent_resource(r) + res = self.resource_tracker.parent_resource(rs) if id(res) not in seen: seen.add(id(res)) unique_resources.append(res) @@ -1497,8 +1506,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): resource_data = function_args[arg_name] if isinstance(resource_data, dict) and "id" in resource_data: try: - converted_resource = self._convert_resource_sync(resource_data) - function_args[arg_name] = converted_resource + function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0] except Exception as e: self.lab_logger().error( f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" @@ -1512,12 +1520,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): resource_list = function_args[arg_name] if isinstance(resource_list, list): try: - converted_resources = [] - for resource_data in resource_list: - if isinstance(resource_data, dict) and "id" in resource_data: - converted_resource = self._convert_resource_sync(resource_data) - converted_resources.append(converted_resource) - function_args[arg_name] = converted_resources + uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r] + function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else [] except Exception as e: self.lab_logger().error( f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" @@ -1530,20 +1534,27 @@ class BaseROS2DeviceNode(Node, Generic[T]): f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" ) - def _convert_resource_sync(self, resource_data: Dict[str, Any]): - """同步转换资源数据为实例""" - # 创建资源查询请求 - r = SerialCommand.Request() - r.command = json.dumps( - { - "id": resource_data.get("id", None), - "uuid": resource_data.get("uuid", None), - "with_children": True, - } - ) - - # 同步调用资源查询服务 - future = self._resource_clients["resource_get"].call_async(r) + def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: + """同步转换资源 UUID 为实例 + + Args: + *uuids: 一个或多个资源 UUID + + Returns: + 单个 UUID 时返回单个资源实例,多个 UUID 时返回资源实例列表 + """ + if not uuids: + raise ValueError("至少需要提供一个 UUID") + + uuids_list = list(uuids) + future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request( + command=json.dumps( + { + "data": {"data": uuids_list, "with_children": True}, + "action": "get", + } + ) + )) # 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒) timeout = 30.0 @@ -1553,27 +1564,40 @@ class BaseROS2DeviceNode(Node, Generic[T]): elapsed += 0.05 if not future.done(): - raise Exception(f"资源查询超时: {resource_data}") + raise Exception(f"资源查询超时: {uuids_list}") response = future.result() if response is None: - raise Exception(f"资源查询返回空结果: {resource_data}") + raise Exception(f"资源查询返回空结果: {uuids_list}") raw_data = json.loads(response.response) # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) - plr_resource = tree_set.to_plr_resources()[0] + if not len(tree_set.trees): + raise Exception(f"资源查询返回空树: {raw_data}") + plr_resources = tree_set.to_plr_resources() # 通过资源跟踪器获取本地实例 - res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) - if len(res) == 0: - self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例") - return plr_resource - elif len(res) == 1: - return res[0] - else: - raise ValueError(f"资源转换得到多个实例: {res}") + figured_resources: List[ResourcePLR] = [] + for plr_resource, tree in zip(plr_resources, tree_set.trees): + res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) + if len(res) == 0: + self.lab_logger().warning(f"资源转换未能索引到实例: {tree.root_node.res_content},返回新建实例") + figured_resources.append(plr_resource) + elif len(res) == 1: + figured_resources.append(res[0]) + else: + raise ValueError(f"资源转换得到多个实例: {res}") + + mapped_plr_resources = [] + for uuid in uuids_list: + for plr_resource in figured_resources: + r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid) + mapped_plr_resources.append(r) + break + + return mapped_plr_resources async def _execute_driver_command_async(self, string: str): try: diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 69c12f8..0f6b697 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -23,6 +23,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma from unique_identifier_msgs.msg import UUID from unilabos.registry.registry import lab_registry +from unilabos.resources.container import RegularContainer from unilabos.resources.graphio import initialize_resource from unilabos.resources.registry import add_schema from unilabos.ros.initialize_device import initialize_device_from_dict @@ -586,11 +587,10 @@ class HostNode(BaseROS2DeviceNode): ) try: - new_li = [] + assert len(response) == 1, "Create Resource应当只返回一个结果" for i in response: res = json.loads(i) - new_li.append(res) - return {"resources": new_li, "liquid_input_resources": new_li} + return res except Exception as ex: pass _n = "\n" @@ -795,7 +795,8 @@ class HostNode(BaseROS2DeviceNode): assign_sample_id(action_kwargs) goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) - self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}") + self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}") + self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}") action_client.wait_for_server() goal_uuid_obj = UUID(uuid=list(u.bytes)) @@ -1133,11 +1134,11 @@ class HostNode(BaseROS2DeviceNode): 接收序列化的 ResourceTreeSet 数据并进行处理 """ - self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received") try: # 解析请求数据 data = json.loads(request.command) action = data["action"] + self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received") data = data["data"] if action == "add": await self._resource_tree_action_add_callback(data, response) @@ -1243,7 +1244,7 @@ class HostNode(BaseROS2DeviceNode): data = json.loads(request.command) if "uuid" in data and data["uuid"] is not None: http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"]) - elif "id" in data and data["id"].startswith("/"): + elif "id" in data: http_req = http_client.resource_get(data["id"], data["with_children"]) else: raise ValueError("没有使用正确的物料 id 或 uuid") @@ -1453,10 +1454,16 @@ class HostNode(BaseROS2DeviceNode): } def test_resource( - self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot] + self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None ) -> TestResourceReturn: + if resources is None: + resources = [] + if devices is None: + devices = [] + if resource is None: + resource = RegularContainer("test_resource传入None") return { - "resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(), + "resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(), "devices": [device, *devices], }