diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 510647f0..401858b0 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -107,6 +107,8 @@ class HTTPClient: for u, n in old_uuids.items(): if u in uuid_mapping: n.res_content.uuid = uuid_mapping[u] + for c in n.children: + c.res_content.parent_uuid = n.res_content.uuid else: logger.warning(f"资源UUID未更新: {u}") return uuid_mapping diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 9a8b4776..d5c4a30d 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -386,13 +386,33 @@ class BaseROS2DeviceNode(Node, Generic[T]): try: if action == "add": # 添加资源到资源跟踪器 - plr_resource = tree_set.to_plr_resources() # FIXME: 转成plr的实例 + plr_resources = tree_set.to_plr_resources() + for plr_resource, tree in zip(plr_resources, tree_set.trees): + self.resource_tracker.add_resource(plr_resource) + parent_uuid = tree.root_node.res_content.parent_uuid + if parent_uuid: + parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid) + if parent_resource is None: + self.lab_logger().warning(f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在") + else: + try: + parent_resource.assign_child_resource(plr_resource, location=None) + except Exception as e: + self.lab_logger().warning( + f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}") func = getattr(self.driver_instance, "resource_tree_add", None) if callable(func): - func(tree_set) + func(plr_resources) results.append({"success": True, "action": "add"}) elif action == "update": # 更新资源 + plr_resources = tree_set.to_plr_resources() + for plr_resource, tree in zip(plr_resources, tree_set.trees): + states = plr_resource.serialize_all_state() + original_instance: ResourcePLR = self.resource_tracker.figure_resource({"uuid": tree.root_node.res_content.uuid}, try_mode=False) + original_instance.load_all_state(states) + self.lab_logger().info(f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] 及其子节点 {len(original_instance.get_all_children())} 个") + func = getattr(self.driver_instance, "resource_tree_update", None) if callable(func): func(tree_set) @@ -401,10 +421,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 移除资源 func = getattr(self.driver_instance, "resource_tree_remove", None) if callable(func): - resources_instance: List[ResourcePLR] = [self.resource_tracker.uuid_to_resources[i] for + resources_instances: List[ResourcePLR] = [self.resource_tracker.uuid_to_resources[i] for i in resources_uuid] - func(resources_instance) - [r.parent.unassign_child_resource(r) for r in resources_instance if r is not None] + self.resource_tracker.add_resource() + [r.parent.unassign_child_resource(r) for r in resources_instances if r is not None] + func(resources_instances) + [r.parent.unassign_child_resource(r) for r in resources_instances if r is not None] results.append({"success": True, "action": "remove"}) except Exception as e: error_msg = f"Error processing {action} operation: {str(e)}" diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index af92de8a..e9e5e38a 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -175,9 +175,6 @@ class HostNode(BaseROS2DeviceNode): for device_config in devices_config.root_nodes: device_id = device_config.res_content.id if device_config.res_content.type != "device": - self.lab_logger().debug( - f"[Host Node] Skipping type {device_config.res_content.type} {device_id} already existed, skipping." - ) continue if device_id not in self.devices_names: self.initialize_device(device_id, device_config) diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 4d8f7134..5ae808ed 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -52,18 +52,17 @@ class ResourceDict(BaseModel): schema: Dict[str, Any] = Field(description="Resource schema", default_factory=dict) model: Dict[str, Any] = Field(description="Resource model", default_factory=dict) icon: str = Field(description="Resource icon", default="") - parent: Optional["ResourceDict"] = Field( - description="Parent resource object", default=None, serialization_alias="parent_uuid" - ) + parent_uuid: Optional["str"] = Field(description="Parent resource uuid", default=None) # 先设定parent_uuid + parent: Optional["ResourceDict"] = Field(description="Parent resource object", default=None, exclude=True) type: 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) config: Dict[str, Any] = Field(description="Resource configuration") data: Dict[str, Any] = Field(description="Resource data") - @field_serializer("parent") - def _serialize_parent(self, parent: Optional["ResourceDict"]): - return self.parent_uuid + @field_serializer("parent_uuid") + def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]): + return self.uuid_parent @field_validator("parent", mode="before") @classmethod @@ -74,13 +73,23 @@ class ResourceDict(BaseModel): return None @property - def parent_uuid(self) -> str: + def uuid_parent(self) -> str: """获取父节点的UUID""" - return self.parent.uuid if self.parent is not None else "" + parent_instance_uuid = self.parent_instance_uuid + if parent_instance_uuid is not None and self.parent_uuid and parent_instance_uuid != self.parent_uuid: + logger.warning(f"{self.name}[{self.uuid}]的parent uuid未同步!") # 现在强制要求设置 + if parent_instance_uuid is not None: + return parent_instance_uuid + return self.parent_uuid @property - def parent_name(self) -> Optional[str]: + def parent_instance_uuid(self) -> Optional[str]: """获取父节点的UUID""" + return self.parent.uuid if self.parent is not None else None + + @property + def parent_instance_name(self) -> Optional[str]: + """获取父节点的名字""" return self.parent.name if self.parent is not None else None @property @@ -101,7 +110,7 @@ class ResourceDictInstance(object): def __init__(self, res_content: "ResourceDict"): self.res_content = res_content - self.children = [] + self.children: List[ResourceDictInstance] = [] self.typ = "dict" @classmethod @@ -132,7 +141,7 @@ class ResourceDictInstance(object): """获取资源实例的嵌套字典表示""" res_dict = self.res_content.model_dump(by_alias=True) res_dict["children"] = {child.res_content.name: child.get_nested_dict() for child in self.children} - res_dict["parent"] = self.res_content.parent_name + res_dict["parent"] = self.res_content.parent_instance_name res_dict["position"] = self.res_content.position.position.model_dump() return res_dict @@ -174,7 +183,7 @@ class ResourceTreeInstance(object): current_uuid = queue.pop(0) # 查找所有parent_uuid指向当前节点的子节点 for uuid_str, res in uuid_map.items(): - if res.parent_uuid == current_uuid and uuid_str not in visited: + if res.uuid_parent == current_uuid and uuid_str not in visited: child_instance = ResourceDictInstance(res) tree_nodes.append(child_instance) visited.add(uuid_str) @@ -363,14 +372,12 @@ class ResourceTreeSet(object): trees.append(tree_instance) return cls(trees) - def to_plr_resources(self) -> Tuple[List["PLRResource"], List[Dict[str, str]]]: + def to_plr_resources(self) -> List["PLRResource"]: """ 将 ResourceTreeSet 转换为 PLR 资源列表 Returns: - Tuple[List[PLRResource], List[Dict[str, str]]]: - - PLR 资源实例列表 - - 每个资源对应的 name_to_uuid 映射字典列表 + List[PLRResource]: PLR 资源实例列表 """ from pylabrobot.resources import Resource as PLRResource from pylabrobot.utils.object_parsing import find_subclass @@ -408,7 +415,7 @@ class ResourceTreeSet(object): "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, "category": plr_type, "children": [node_to_plr_dict(child, has_model) for child in node.children], - "parent_name": res.parent_name, + "parent_name": res.parent_instance_name, **res.config, } if has_model: @@ -416,34 +423,27 @@ class ResourceTreeSet(object): return d plr_resources = [] - name_to_uuid_maps = [] + trees = [] tracker = DeviceNodeResourceTracker() for tree in self.trees: name_to_uuid: Dict[str, str] = {} all_states: Dict[str, Any] = {} collect_node_data(tree.root_node, name_to_uuid, all_states) - has_model = tree.root_node.res_content.type != "deck" plr_dict = node_to_plr_dict(tree.root_node, has_model) - try: sub_cls = find_subclass(plr_dict["type"], PLRResource) if sub_cls is None: raise ValueError(f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类") - spec = inspect.signature(sub_cls) if "category" not in spec.parameters: plr_dict.pop("category", None) - plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True) plr_resource.load_all_state(all_states) - # 使用 DeviceNodeResourceTracker 设置 UUID tracker.loop_set_uuid(plr_resource, name_to_uuid) - plr_resources.append(plr_resource) - name_to_uuid_maps.append(name_to_uuid) except Exception as e: logger.error(f"转换 PLR 资源失败: {e}") @@ -452,7 +452,7 @@ class ResourceTreeSet(object): logger.error(f"堆栈: {traceback.format_exc()}") raise - return plr_resources, name_to_uuid_maps + return plr_resources @classmethod def from_nested_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet": @@ -473,7 +473,7 @@ class ResourceTreeSet(object): root_instances = [ ResourceTreeInstance(res_instance) for res_instance in nested_list - if res_instance.res_content.is_root_node or res_instance.res_content.parent_uuid not in known_uuids + if res_instance.res_content.is_root_node or res_instance.res_content.uuid_parent not in known_uuids ] return cls(root_instances) @@ -584,6 +584,63 @@ class DeviceNodeResourceTracker(object): self.uuid_to_resources[new_uuid] = instance print(f"更新uuid映射: {old_uuid} -> {new_uuid} | {instance}") + def _get_resource_attr(self, resource, attr_name: str, uuid_attr: Optional[str] = None): + """ + 获取资源的属性值,统一处理 dict 和 instance 两种类型 + + Args: + resource: 资源对象(dict或实例) + attr_name: dict类型使用的属性名 + uuid_attr: instance类型使用的属性名(用于uuid字段),默认与attr_name相同 + + Returns: + 属性值,不存在则返回None + """ + if uuid_attr is None: + uuid_attr = attr_name + + if isinstance(resource, dict): + return resource.get(attr_name) + else: + return getattr(resource, uuid_attr, None) + + def _set_resource_uuid(self, resource, new_uuid: str): + """ + 设置资源的 uuid,统一处理 dict 和 instance 两种类型 + + Args: + resource: 资源对象(dict或实例) + new_uuid: 新的uuid值 + """ + if isinstance(resource, dict): + resource["uuid"] = new_uuid + else: + setattr(resource, "unilabos_uuid", new_uuid) + + def _traverse_and_process(self, resource, process_func) -> int: + """ + 递归遍历资源树,对每个节点执行处理函数 + + Args: + resource: 资源对象(可以是list、dict或实例) + process_func: 处理函数,接收resource参数,返回处理的节点数量 + + Returns: + 处理的节点总数量 + """ + if isinstance(resource, list): + return sum(self._traverse_and_process(r, process_func) for r in resource) + + # 先递归处理所有子节点 + count = 0 + children = getattr(resource, "children", []) + for child in children: + count += self._traverse_and_process(child, process_func) + + # 处理当前节点 + count += process_func(resource) + return count + def loop_set_uuid(self, resource, name_to_uuid_map: Dict[str, str]) -> int: """ 递归遍历资源树,根据 name 设置所有节点的 uuid @@ -595,36 +652,18 @@ class DeviceNodeResourceTracker(object): Returns: 更新的资源数量 """ - if isinstance(resource, list): - return sum(self.loop_set_uuid(r, name_to_uuid_map) for r in resource) - update_count = 0 + def process(res): + resource_name = self._get_resource_attr(res, "name") + if resource_name and resource_name in name_to_uuid_map: + new_uuid = name_to_uuid_map[resource_name] + self._set_resource_uuid(res, new_uuid) + self.uuid_to_resources[new_uuid] = res + logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}") + return 1 + return 0 - # 先递归处理所有子节点 - children = getattr(resource, "children", []) - for child in children: - update_count += self.loop_set_uuid(child, name_to_uuid_map) - - # 获取当前资源的name - if isinstance(resource, dict): - resource_name = resource.get("name") - else: - resource_name = getattr(resource, "name", None) - - # 如果name在映射中,则设置uuid - if resource_name and resource_name in name_to_uuid_map: - new_uuid = name_to_uuid_map[resource_name] - # 更新资源的uuid - if isinstance(resource, dict): - resource["uuid"] = new_uuid - else: - # 对于PLR资源,设置unilabos_uuid - setattr(resource, "unilabos_uuid", new_uuid) - self.uuid_to_resources[new_uuid] = resource - update_count += 1 - logger.debug(f"设置资源UUID: {resource_name} -> {new_uuid}") - - return update_count + return self._traverse_and_process(resource, process) def loop_update_uuid(self, resource, uuid_map: Dict[str, str]) -> int: """ @@ -637,43 +676,56 @@ class DeviceNodeResourceTracker(object): Returns: 更新的资源数量 """ - if isinstance(resource, list): - return sum(self.loop_update_uuid(r, uuid_map) for r in resource) - update_count = 0 + def process(res): + current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") + if current_uuid and current_uuid in uuid_map: + new_uuid = uuid_map[current_uuid] + if current_uuid != new_uuid: + self._set_resource_uuid(res, new_uuid) + # 更新uuid_to_resources映射 + if current_uuid in self.uuid_to_resources: + self.uuid_to_resources.pop(current_uuid) + self.uuid_to_resources[new_uuid] = res + logger.debug(f"更新uuid: {current_uuid} -> {new_uuid}") + return 1 + return 0 - # 先递归处理所有子节点 - children = getattr(resource, "children", []) - for child in children: - update_count += self.loop_update_uuid(child, uuid_map) + return self._traverse_and_process(resource, process) - # 获取当前资源的uuid - if isinstance(resource, dict): - current_uuid = resource.get("uuid") - else: - current_uuid = getattr(resource, "unilabos_uuid", None) + def _collect_uuid_mapping(self, resource): + """ + 递归收集资源的 uuid 映射到 uuid_to_resources - # 如果当前uuid在映射中,则更新 - if current_uuid and current_uuid in uuid_map: - new_uuid = uuid_map[current_uuid] - if current_uuid != new_uuid: - # 更新资源的uuid - if isinstance(resource, dict): - resource["uuid"] = new_uuid - else: - # 对于PLR资源,更新unilabos_uuid - if hasattr(resource, "unilabos_uuid"): - setattr(resource, "unilabos_uuid", new_uuid) + Args: + resource: 资源对象(可以是dict或实例) + """ - # 更新uuid_to_resources映射 - if current_uuid in self.uuid_to_resources: - instance = self.uuid_to_resources.pop(current_uuid) - self.uuid_to_resources[new_uuid] = instance + def process(res): + current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") + if current_uuid: + self.uuid_to_resources[current_uuid] = res + logger.debug(f"收集资源UUID映射: {current_uuid} -> {res}") + return 0 - update_count += 1 - logger.debug(f"更新uuid: {current_uuid} -> {new_uuid} | {resource}") + self._traverse_and_process(resource, process) - return update_count + def _remove_uuid_mapping(self, resource): + """ + 递归清除资源的 uuid 映射 + + Args: + resource: 资源对象(可以是dict或实例) + """ + + def process(res): + current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid") + if current_uuid and current_uuid in self.uuid_to_resources: + self.uuid_to_resources.pop(current_uuid) + logger.debug(f"移除资源UUID映射: {current_uuid} -> {res}") + return 0 + + self._traverse_and_process(resource, process) def parent_resource(self, resource): if id(resource) in self.resource2parent_resource: @@ -682,21 +734,105 @@ class DeviceNodeResourceTracker(object): return resource def add_resource(self, resource): + """ + 添加资源到追踪器 + + Args: + resource: 资源对象(可以是dict或实例) + """ for r in self.resources: if id(r) == id(resource): return self.resources.append(resource) + # 递归收集uuid映射 + self._collect_uuid_mapping(resource) + + def remove_resource(self, resource) -> bool: + """ + 从追踪器中移除资源 + + Args: + resource: 资源对象(可以是dict或实例) + + Returns: + bool: 如果成功移除返回True,资源不存在返回False + """ + # 从 resources 列表中移除 + resource_id = id(resource) + removed = False + for i, r in enumerate(self.resources): + if id(r) == resource_id: + self.resources.pop(i) + removed = True + break + + if not removed: + logger.warning(f"尝试移除不存在的资源: {resource}") + return False + + # 递归清除uuid映射 + self._remove_uuid_mapping(resource) + + # 清除 resource2parent_resource 中与该资源相关的映射 + # 需要清除:1) 该资源作为 key 的映射 2) 该资源作为 value 的映射 + keys_to_remove = [] + for key, value in self.resource2parent_resource.items(): + if id(value) == resource_id: + keys_to_remove.append(key) + + if resource_id in self.resource2parent_resource: + keys_to_remove.append(resource_id) + + for key in keys_to_remove: + self.resource2parent_resource.pop(key, None) + + logger.debug(f"成功移除资源: {resource}") + return True def clear_resource(self): + """清空所有资源""" self.resources = [] + self.uuid_to_resources.clear() + self.resource2parent_resource.clear() 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 + isinstance(query_resource, dict) + and "id" not in query_resource + and "name" not in query_resource + and "uuid" not in query_resource ): # 临时处理,要删除的,driver有太多类型错误标注 return [self.figure_resource(r, try_mode) for r in query_resource.values()] + + # 优先尝试通过 uuid 查找 + res_uuid = None + if isinstance(query_resource, dict): + res_uuid = query_resource.get("uuid") + else: + res_uuid = getattr(query_resource, "unilabos_uuid", None) + + # 如果有 uuid,优先使用 uuid 查找 + if res_uuid: + res_list = [] + for r in self.resources: + if isinstance(query_resource, dict): + res_list.extend(self.loop_find_resource(r, object, "uuid", res_uuid)) + else: + res_list.extend(self.loop_find_resource(r, type(query_resource), "unilabos_uuid", res_uuid)) + + if not try_mode: + assert len(res_list) > 0, f"没有找到资源 (uuid={res_uuid}),请检查资源是否存在" + assert len(res_list) == 1, f"通过uuid={res_uuid} 找到多个资源,请检查资源是否唯一: {res_list}" + else: + return [i[1] for i in res_list] + + self.resource2parent_resource[id(query_resource)] = res_list[0][0] + self.resource2parent_resource[id(res_list[0][1])] = res_list[0][0] + return res_list[0][1] + + # 回退到 id/name 查找 res_id = ( query_resource.id # type: ignore if hasattr(query_resource, "id") @@ -711,7 +847,7 @@ class DeviceNodeResourceTracker(object): identifier_key = "id" if res_id else "name" resource_cls_type = type(query_resource) if res_identifier is None: - logger.warning(f"resource {query_resource} 没有id或name,暂不能对应figure") + logger.warning(f"resource {query_resource} 没有id、name或uuid,暂不能对应figure") res_list = [] for r in self.resources: if isinstance(query_resource, dict): @@ -744,12 +880,16 @@ class DeviceNodeResourceTracker(object): ) if issubclass(type(resource), target_resource_cls_type): if target_resource_cls_type == dict: + # 对于字典类型,直接检查 identifier_key if identifier_key in resource: if resource[identifier_key] == compare_value: res_list.append((parent_res, resource)) - elif hasattr(resource, identifier_key): - if getattr(resource, identifier_key) == compare_value: - res_list.append((parent_res, resource)) + else: + # 对于实例类型,需要特殊处理 uuid 字段 + # 如果查找的是 unilabos_uuid,使用 getattr + if hasattr(resource, identifier_key): + if getattr(resource, identifier_key) == compare_value: + res_list.append((parent_res, resource)) return res_list def filter_find_list(self, res_list, compare_std_dict): @@ -803,21 +943,15 @@ if __name__ == "__main__": print(f" - 所有节点数量: {len(resource_tree_set.all_nodes)}") # 3. 将 ResourceTreeSet 转换回 PLR 资源 - plr_resources, name_to_uuid_maps = resource_tree_set.to_plr_resources() + plr_resources = resource_tree_set.to_plr_resources() converted_plate = plr_resources[0] print(f"\n3. 转换回 PLR 资源: {converted_plate.name}") print(f" - 子节点数量: {len(converted_plate.children)}") if converted_plate.children: print(f" - 第一个子节点: {converted_plate.children[0].name}") - # 4. 验证 UUID 映射 - name_to_uuid = name_to_uuid_maps[0] - print(f"\n4. UUID 映射:") - print(f" - 映射条目数: {len(name_to_uuid)}") - print(f" - 示例映射: {list(name_to_uuid.items())[:3]}") - - # 5. 验证 unilabos_uuid 属性 - print(f"\n5. 验证 unilabos_uuid 设置:") + # 4. 验证 unilabos_uuid 属性 + print(f"\n4. 验证 unilabos_uuid 设置:") if hasattr(converted_plate, "unilabos_uuid"): print(f" - 根节点 UUID: {getattr(converted_plate, 'unilabos_uuid')}") if converted_plate.children and hasattr(converted_plate.children[0], "unilabos_uuid"): @@ -825,18 +959,18 @@ if __name__ == "__main__": else: print(" - 警告: unilabos_uuid 未设置") - # 6. 验证 UUID 保持不变 - print(f"\n6. 验证 UUID 在往返过程中保持不变:") + # 5. 验证 UUID 保持不变 + print(f"\n5. 验证 UUID 在往返过程中保持不变:") original_uuid = getattr(original_plate, "unilabos_uuid") converted_uuid = getattr(converted_plate, "unilabos_uuid") print(f" - 原始 UUID: {original_uuid}") print(f" - 转换后 UUID: {converted_uuid}") print(f" - UUID 保持不变: {original_uuid == converted_uuid}") - # 7. 再次往返转换,验证稳定性 + # 6. 再次往返转换,验证稳定性 resource_tree_set_2 = ResourceTreeSet.from_plr_resources([converted_plate]) - plr_resources_2, name_to_uuid_maps_2 = resource_tree_set_2.to_plr_resources() - print(f"\n7. 第二次往返转换:") + plr_resources_2 = resource_tree_set_2.to_plr_resources() + print(f"\n6. 第二次往返转换:") print(f" - 资源名称: {plr_resources_2[0].name}") print(f" - 子节点数量: {len(plr_resources_2[0].children)}") print(f" - UUID 依然保持: {getattr(plr_resources_2[0], 'unilabos_uuid') == original_uuid}")