From 9de473374fb100ccce36fcbbbfe68d0f94f59aab Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Wed, 25 Mar 2026 13:10:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7Resource=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=B3=BB=E7=BB=9F=EF=BC=8C=E5=A2=9E=E5=8A=A0uuid?= =?UTF-8?q?=E5=92=8Cklass=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resource.msg新增uuid和klass字段支持ResourceDictInstance完整序列化, message_converter增加Resource消息与Python dict的双向转换, workstation和base_device_node增加资源同步相关功能。 Co-Authored-By: Claude Opus 4.6 --- .../devices/workstation/workstation_base.py | 22 ++++++ unilabos/messages/__init__.py | 31 ++++++-- unilabos/ros/msgs/message_converter.py | 21 +++-- unilabos/ros/nodes/base_device_node.py | 78 +++++++++++++++++++ unilabos/ros/nodes/presets/workstation.py | 13 +++- unilabos_msgs/msg/Resource.msg | 7 +- 6 files changed, 155 insertions(+), 17 deletions(-) diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py index 75fd7ea8..1cf920fc 100644 --- a/unilabos/devices/workstation/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -197,6 +197,28 @@ class WorkstationBase(ABC): self._ros_node = workstation_node logger.info(f"工作站 {self._ros_node.device_id} 关联协议节点") + # ============ 物料转运回调 ============ + + def resource_tree_batch_transfer( + self, + transfers: list, + old_parents: list, + new_parents: list, + ) -> None: + """批量物料转运完成后的回调,供子类重写 + + 默认实现:逐个调用 resource_tree_transfer(如存在)。 + + Args: + transfers: 转移列表,每项包含 resource, from_parent, to_parent, to_site 等 + old_parents: 每个物料转移前的原父节点 + new_parents: 每个物料转移后的新父节点 + """ + func = getattr(self, "resource_tree_transfer", None) + if callable(func): + for t, old_parent, new_parent in zip(transfers, old_parents, new_parents): + func(old_parent, t["resource"], new_parent) + # ============ 设备操作接口 ============ def call_device_method(self, method: str, *args, **kwargs) -> Any: diff --git a/unilabos/messages/__init__.py b/unilabos/messages/__init__.py index 4ef5ac9f..0a1a1b4f 100644 --- a/unilabos/messages/__init__.py +++ b/unilabos/messages/__init__.py @@ -217,6 +217,24 @@ class AGVTransferProtocol(BaseModel): from_repo_position: str to_repo_position: str + +class BatchTransferItem(BaseModel): + """批量转运中的单个物料条目""" + resource_uuid: str = "" + resource_id: str = "" + from_position: str + to_position: str + + +class BatchTransferProtocol(BaseModel): + """批量物料转运协议 — 支持多物料一次性从来源工站转运到目标工站""" + from_repo: dict + to_repo: dict + transfer_resources: list # list[Resource dict],被转运的物料 + from_positions: list # list[str],来源 slot 位置(与 transfer_resources 平行) + to_positions: list # list[str],目标 slot 位置(与 transfer_resources 平行) + + #=============新添加的新的协议================ class AddProtocol(BaseModel): vessel: dict @@ -629,15 +647,16 @@ class HydrogenateProtocol(BaseModel): vessel: dict = Field(..., description="反应容器") __all__ = [ - "Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", - "EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol", - "CentrifugeProtocol", "AddProtocol", "FilterProtocol", + "Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", + "EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol", + "BatchTransferItem", "BatchTransferProtocol", + "CentrifugeProtocol", "AddProtocol", "FilterProtocol", "HeatChillProtocol", "HeatChillStartProtocol", "HeatChillStopProtocol", - "StirProtocol", "StartStirProtocol", "StopStirProtocol", - "TransferProtocol", "CleanVesselProtocol", "DissolveProtocol", + "StirProtocol", "StartStirProtocol", "StopStirProtocol", + "TransferProtocol", "CleanVesselProtocol", "DissolveProtocol", "FilterThroughProtocol", "RunColumnProtocol", "WashSolidProtocol", - "AdjustPHProtocol", "ResetHandlingProtocol", "DryProtocol", + "AdjustPHProtocol", "ResetHandlingProtocol", "DryProtocol", "RecrystallizeProtocol", "HydrogenateProtocol" ] # End Protocols diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index 83e6f456..f2b98390 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -142,12 +142,16 @@ _msg_converter: Dict[Type, Any] = { ), Resource: lambda x: Resource( id=x.get("id", ""), + uuid=x.get("uuid", ""), name=x.get("name", ""), sample_id=x.get("sample_id", "") or "", + description=x.get("description", ""), children=list(x.get("children", [])), parent=x.get("parent", "") or "", + parent_uuid=x.get("parent_uuid", ""), type=x.get("type", ""), - category=x.get("class", "") or x.get("type", ""), + category=x.get("category", "") or x.get("class", "") or x.get("type", ""), + klass=x.get("class", "") or x.get("klass", ""), pose=( Pose( position=Point( @@ -160,15 +164,11 @@ _msg_converter: Dict[Type, Any] = { else Pose() ), config=json.dumps(x.get("config", {})), - data=json.dumps(obtain_data_with_uuid(x)), + data=json.dumps(x.get("data", {})), + extra=json.dumps(x.get("extra", {})), ), } -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: return json.loads(data) @@ -195,15 +195,20 @@ _msg_converter_back: Dict[Type, Any] = { Point: lambda x: Point3D(x=x.x, y=x.y, z=x.z), Resource: lambda x: { "id": x.id, + "uuid": x.uuid if x.uuid else None, "name": x.name, "sample_id": x.sample_id if x.sample_id else None, + "description": x.description if x.description else "", "children": list(x.children), "parent": x.parent if x.parent else None, + "parent_uuid": x.parent_uuid if x.parent_uuid else None, "type": x.type, - "class": "", + "class": x.klass if x.klass else "", + "category": x.category if x.category else "", "position": {"x": x.pose.position.x, "y": x.pose.position.y, "z": x.pose.position.z}, "config": json_or_yaml_loads(x.config or "{}"), "data": json_or_yaml_loads(x.data or "{}"), + "extra": json_or_yaml_loads(x.extra or "{}"), }, } diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index ffc106c7..d9aecbc9 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -761,6 +761,84 @@ class BaseROS2DeviceNode(Node, Generic[T]): f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}" ) + def batch_transfer_resources( + self, + transfers: List[Dict[str, Any]], + ) -> List["ResourcePLR"]: + """批量转移 PLR 资源:先全部 unassign,再全部 assign,最后一次性回调和同步 + + Args: + transfers: 转移列表,每项包含: + - "resource": PLR 资源对象 + - "from_parent": 原父节点 (PLR ResourceHolder/WareHouse) + - "to_parent": 目标父节点 + - "to_site": 目标 slot 名称(可选,用于 ItemizedCarrier) + + Returns: + 成功转移的目标 parent 列表 + """ + from pylabrobot.resources.resource import Resource as ResourcePLR + + if not transfers: + return [] + + # 第一遍:校验所有物料和目标 parent 的合法性 + for t in transfers: + resource = t["resource"] + to_parent = t["to_parent"] + if resource is None: + raise ValueError("转移列表中存在 resource=None") + if to_parent is None: + raise ValueError(f"物料 {resource} 的目标 parent 为 None") + + # 第二遍:批量 unassign + old_parents = [] + for t in transfers: + resource = t["resource"] + old_parent = resource.parent + old_parents.append(old_parent) + if old_parent is not None: + self.lab_logger().debug(f"批量 unassign: {resource.name} 从 {old_parent.name}") + old_parent.unassign_child_resource(resource) + # 从顶级资源列表中移除(避免 figure_resource 重复引用) + resource_id = id(resource) + for i, r in enumerate(self.resource_tracker.resources): + if id(r) == resource_id: + self.resource_tracker.resources.pop(i) + break + + # 第三遍:批量 assign + parents = [] + for t, old_parent in zip(transfers, old_parents): + resource = t["resource"] + to_parent = t["to_parent"] + to_site = t.get("to_site") + additional_params = {} + if to_site is not None: + spec = inspect.signature(to_parent.assign_child_resource) + if "spot" in spec.parameters: + ordering_dict = getattr(to_parent, "_ordering", None) + if ordering_dict and to_site in ordering_dict: + additional_params["spot"] = list(ordering_dict.keys()).index(to_site) + else: + additional_params["spot"] = to_site + self.lab_logger().debug(f"批量 assign: {resource.name} → {to_parent.name} site={to_site}") + to_parent.assign_child_resource(resource, location=None, **additional_params) + parents.append(to_parent) + + # 一次性触发 driver 回调 + func = getattr(self.driver_instance, "resource_tree_batch_transfer", None) + if callable(func): + func(transfers, old_parents, parents) + else: + # 兜底:逐个调用 resource_tree_transfer + single_func = getattr(self.driver_instance, "resource_tree_transfer", None) + if callable(single_func): + for t, old_parent, new_parent in zip(transfers, old_parents, parents): + single_func(old_parent, t["resource"], new_parent) + + return parents + async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response): """ 处理资源树更新请求 diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index 7f9f2aed..ff2a2410 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -340,8 +340,17 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): ) # type: ignore raw_data = json.loads(response.response) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) - target = tree_set.dump() - protocol_kwargs[k] = target[0][0] if v == "unilabos_msgs/Resource" else target + + # 传递 ResourceDictInstance(保留树结构),不再调用 dump() 扁平化 + if v == "unilabos_msgs/Resource": + # 单个资源:取第一棵树的根节点 + root_instance = tree_set.trees[0].root_node if tree_set.trees else None + protocol_kwargs[k] = root_instance.get_plr_nested_dict() if root_instance else protocol_kwargs[k] + else: + # 多个资源:取每棵树的根节点 + protocol_kwargs[k] = [ + tree.root_node.get_plr_nested_dict() for tree in tree_set.trees + ] except Exception as ex: self.lab_logger().error(f"查询资源失败: {k}, 错误: {ex}\n{traceback.format_exc()}") raise diff --git a/unilabos_msgs/msg/Resource.msg b/unilabos_msgs/msg/Resource.msg index 6d52a035..d2c1da17 100644 --- a/unilabos_msgs/msg/Resource.msg +++ b/unilabos_msgs/msg/Resource.msg @@ -1,11 +1,16 @@ string id +string uuid string name string sample_id +string description string[] children string parent +string parent_uuid string type string category +string klass geometry_msgs/Pose pose string config -string data \ No newline at end of file +string data +string extra