feat: 升级Resource消息系统,增加uuid和klass字段

Resource.msg新增uuid和klass字段支持ResourceDictInstance完整序列化,
message_converter增加Resource消息与Python dict的双向转换,
workstation和base_device_node增加资源同步相关功能。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Junhan Chang
2026-03-25 13:10:39 +08:00
parent dbf5df6e4d
commit 9de473374f
6 changed files with 155 additions and 17 deletions

View File

@@ -197,6 +197,28 @@ class WorkstationBase(ABC):
self._ros_node = workstation_node self._ros_node = workstation_node
logger.info(f"工作站 {self._ros_node.device_id} 关联协议节点") 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: def call_device_method(self, method: str, *args, **kwargs) -> Any:

View File

@@ -217,6 +217,24 @@ class AGVTransferProtocol(BaseModel):
from_repo_position: str from_repo_position: str
to_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): class AddProtocol(BaseModel):
vessel: dict vessel: dict
@@ -629,15 +647,16 @@ class HydrogenateProtocol(BaseModel):
vessel: dict = Field(..., description="反应容器") vessel: dict = Field(..., description="反应容器")
__all__ = [ __all__ = [
"Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol",
"EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol", "EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol",
"CentrifugeProtocol", "AddProtocol", "FilterProtocol", "BatchTransferItem", "BatchTransferProtocol",
"CentrifugeProtocol", "AddProtocol", "FilterProtocol",
"HeatChillProtocol", "HeatChillProtocol",
"HeatChillStartProtocol", "HeatChillStopProtocol", "HeatChillStartProtocol", "HeatChillStopProtocol",
"StirProtocol", "StartStirProtocol", "StopStirProtocol", "StirProtocol", "StartStirProtocol", "StopStirProtocol",
"TransferProtocol", "CleanVesselProtocol", "DissolveProtocol", "TransferProtocol", "CleanVesselProtocol", "DissolveProtocol",
"FilterThroughProtocol", "RunColumnProtocol", "WashSolidProtocol", "FilterThroughProtocol", "RunColumnProtocol", "WashSolidProtocol",
"AdjustPHProtocol", "ResetHandlingProtocol", "DryProtocol", "AdjustPHProtocol", "ResetHandlingProtocol", "DryProtocol",
"RecrystallizeProtocol", "HydrogenateProtocol" "RecrystallizeProtocol", "HydrogenateProtocol"
] ]
# End Protocols # End Protocols

View File

@@ -142,12 +142,16 @@ _msg_converter: Dict[Type, Any] = {
), ),
Resource: lambda x: Resource( Resource: lambda x: Resource(
id=x.get("id", ""), id=x.get("id", ""),
uuid=x.get("uuid", ""),
name=x.get("name", ""), name=x.get("name", ""),
sample_id=x.get("sample_id", "") or "", sample_id=x.get("sample_id", "") or "",
description=x.get("description", ""),
children=list(x.get("children", [])), children=list(x.get("children", [])),
parent=x.get("parent", "") or "", parent=x.get("parent", "") or "",
parent_uuid=x.get("parent_uuid", ""),
type=x.get("type", ""), 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=(
Pose( Pose(
position=Point( position=Point(
@@ -160,15 +164,11 @@ _msg_converter: Dict[Type, Any] = {
else Pose() else Pose()
), ),
config=json.dumps(x.get("config", {})), 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: def json_or_yaml_loads(data: str) -> Any:
try: try:
return json.loads(data) 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), Point: lambda x: Point3D(x=x.x, y=x.y, z=x.z),
Resource: lambda x: { Resource: lambda x: {
"id": x.id, "id": x.id,
"uuid": x.uuid if x.uuid else None,
"name": x.name, "name": x.name,
"sample_id": x.sample_id if x.sample_id else None, "sample_id": x.sample_id if x.sample_id else None,
"description": x.description if x.description else "",
"children": list(x.children), "children": list(x.children),
"parent": x.parent if x.parent else None, "parent": x.parent if x.parent else None,
"parent_uuid": x.parent_uuid if x.parent_uuid else None,
"type": x.type, "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}, "position": {"x": x.pose.position.x, "y": x.pose.position.y, "z": x.pose.position.z},
"config": json_or_yaml_loads(x.config or "{}"), "config": json_or_yaml_loads(x.config or "{}"),
"data": json_or_yaml_loads(x.data or "{}"), "data": json_or_yaml_loads(x.data or "{}"),
"extra": json_or_yaml_loads(x.extra or "{}"),
}, },
} }

View File

@@ -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()}" 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): async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
""" """
处理资源树更新请求 处理资源树更新请求

View File

@@ -340,8 +340,17 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
) # type: ignore ) # type: ignore
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) 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: except Exception as ex:
self.lab_logger().error(f"查询资源失败: {k}, 错误: {ex}\n{traceback.format_exc()}") self.lab_logger().error(f"查询资源失败: {k}, 错误: {ex}\n{traceback.format_exc()}")
raise raise

View File

@@ -1,11 +1,16 @@
string id string id
string uuid
string name string name
string sample_id string sample_id
string description
string[] children string[] children
string parent string parent
string parent_uuid
string type string type
string category string category
string klass
geometry_msgs/Pose pose geometry_msgs/Pose pose
string config string config
string data string data
string extra