Merge dev branch: Add battery resources, bioyond_cell device registry, and fix file path resolution

This commit is contained in:
dijkstra402
2025-12-18 11:11:59 +08:00
306 changed files with 47518 additions and 4826 deletions

View File

@@ -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,
),

View File

@@ -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

View File

@@ -1,7 +1,9 @@
import json
# from nt import device_encoding
import threading
import time
from typing import Optional, Dict, Any, List
import uuid
import rclpy
from unilabos_msgs.srv._serial_command import SerialCommand_Response
@@ -81,14 +83,15 @@ def main(
resources_list,
resource_tracker=host_node.resource_tracker,
device_id="resource_mesh_manager",
device_uuid=str(uuid.uuid4()),
)
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
lh_joint_pub = LiquidHandlerJointPublisher(
resources_config=resources_list, resource_tracker=host_node.resource_tracker
)
# lh_joint_pub = LiquidHandlerJointPublisher(
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
# )
executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher)
executor.add_node(lh_joint_pub)
# executor.add_node(lh_joint_pub)
thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread")
thread.start()
@@ -189,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.")

View File

@@ -5,7 +5,6 @@ import json
import threading
import time
import traceback
import uuid
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union
from concurrent.futures import ThreadPoolExecutor
@@ -39,7 +38,6 @@ from unilabos.ros.msgs.message_converter import (
)
from unilabos_msgs.srv import (
ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
@@ -49,7 +47,8 @@ from unilabos_msgs.msg import Resource # type: ignore
from unilabos.ros.nodes.resource_tracker import (
DeviceNodeResourceTracker,
ResourceTreeSet, ResourceTreeInstance,
ResourceTreeSet,
ResourceTreeInstance, ResourceDictInstance,
)
from unilabos.ros.x.rclpyx import get_event_loop
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
@@ -134,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,
@@ -148,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
@@ -158,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)
@@ -340,10 +335,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
"resource_get": self.create_client(SerialCommand, "/resources/get", callback_group=self.callback_group),
"resource_delete": self.create_client(ResourceDelete, "/resources/delete", callback_group=self.callback_group),
"resource_update": self.create_client(ResourceUpdate, "/resources/update", callback_group=self.callback_group),
"resource_delete": self.create_client(
ResourceDelete, "/resources/delete", callback_group=self.callback_group
),
"resource_update": self.create_client(
ResourceUpdate, "/resources/update", callback_group=self.callback_group
),
"resource_list": self.create_client(ResourceList, "/resources/list", callback_group=self.callback_group),
"c2s_update_resource_tree": self.create_client(SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group),
"c2s_update_resource_tree": self.create_client(
SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group
),
}
def re_register_device(req, res):
@@ -466,8 +467,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
contain_model = not isinstance(resource, Deck)
if isinstance(resource, ResourcePLR):
# resources.list()
resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources}))
plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model)
plr_instance = ResourceTreeSet.from_raw_list(resources).to_plr_resources()[0]
# resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources}))
# plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model)
if isinstance(plr_instance, Plate):
empty_liquid_info_in = [(None, 0)] * plr_instance.num_items
@@ -580,9 +582,66 @@ 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}")
def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]):
async def get_resource(self, resources_uuid: List[str], with_children: bool = True) -> ResourceTreeSet:
"""
根据资源UUID列表获取资源树
Args:
resources_uuid: 资源UUID列表
with_children: 是否包含子节点默认为True
Returns:
ResourceTreeSet: 资源树集合
"""
response: SerialCommand.Response = await self._resource_clients["c2s_update_resource_tree"].call_async(
SerialCommand.Request(
command=json.dumps(
{
"data": {"data": resources_uuid, "with_children": with_children},
"action": "get",
}
)
)
) # type: ignore
raw_nodes = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_list(raw_nodes)
self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树")
return tree_set
async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR":
"""
根据资源ID获取单个资源实例
Args:
resource_ids: 资源ID字符串
with_children: 是否包含子节点默认为True
Returns:
ResourcePLR: PLR资源实例
"""
r = SerialCommand.Request()
r.command = json.dumps(
{
"id": resource_id,
"uuid": None,
"with_children": with_children,
}
)
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
raw_data = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0]
self.lab_logger().debug(f"获取资源 {resource_id} 成功")
return plr_resource
def transfer_to_new_resource(
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
):
parent_uuid = tree.root_node.res_content.parent_uuid
if parent_uuid:
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
@@ -609,16 +668,26 @@ class BaseROS2DeviceNode(Node, Generic[T]):
old_parent = plr_resource.parent
if old_parent is not None:
# plr并不支持同一个deck的加载和卸载
self.lab_logger().warning(
f"物料{plr_resource}请求从{old_parent}卸载"
)
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
old_parent.unassign_child_resource(plr_resource)
self.lab_logger().warning(
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
)
parent_resource.assign_child_resource(
plr_resource, location=None, **additional_params
)
# ⭐ assign 之前,需要从 resources 列表中移除
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
# 如果不移除figure_resource 会找到两次:一次在 resources一次在 parent 的 children
resource_id = id(plr_resource)
for i, r in enumerate(self.resource_tracker.resources):
if id(r) == resource_id:
self.resource_tracker.resources.pop(i)
self.lab_logger().debug(
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
)
break
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
func = getattr(self.driver_instance, "resource_tree_transfer", None)
if callable(func):
# 分别是 物料的原来父节点当前物料的状态物料的新父节点此时物料已经重新assign了
@@ -790,17 +859,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
)
tree_set = None
if action in ["add", "update"]:
response: SerialCommand.Response = await self._resource_clients[
"c2s_update_resource_tree"
].call_async(
SerialCommand.Request(
command=json.dumps(
{"data": {"data": resources_uuid, "with_children": True if action == "add" else False}, "action": "get"}
)
)
) # type: ignore
raw_nodes = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_list(raw_nodes)
tree_set = await self.get_resource(
resources_uuid=resources_uuid, with_children=True if action == "add" else False
)
try:
if action == "add":
if tree_set is None:
@@ -1078,17 +1139,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 批量查询资源
queried_resources = []
for resource_data in resource_inputs:
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "uuid": resource_data.get("uuid", None), "with_children": True})
# 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients[
"resource_get"
].call_async(r)
raw_data = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0]
plr_resource = await self.get_resource_with_dir(
resource_id=resource_data["id"], with_children=True
)
queried_resources.append(plr_resource)
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
@@ -1111,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
@@ -1119,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}"
@@ -1252,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
@@ -1328,12 +1381,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
"""同步转换资源数据为实例"""
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
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)
# 等待结果使用while循环每次sleep 0.5秒,最多等待5秒)
# 等待结果使用while循环每次sleep 0.05秒最多等待30秒)
timeout = 30.0
elapsed = 0.0
while not future.done() and elapsed < timeout:
@@ -1341,16 +1400,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
elapsed += 0.05
if not future.done():
raise Exception(f"资源查询超时: {resource_data['id']}")
raise Exception(f"资源查询超时: {resource_data}")
response = future.result()
if response is None:
raise Exception(f"资源查询返回空结果: {resource_data['id']}")
raise Exception(f"资源查询返回空结果: {resource_data}")
current_resources = json.loads(response.response)
raw_data = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(current_resources)
tree_set = ResourceTreeSet.from_raw_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0]
# 通过资源跟踪器获取本地实例
@@ -1435,17 +1494,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
async def _convert_resource_async(self, resource_data: Dict[str, Any]):
"""异步转换资源数据为实例"""
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps({"id": resource_data["id"], "with_children": True})
# 异步调用资源查询服务
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
current_resources = json.loads(response.response)
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(current_resources)
plr_resource = tree_set.to_plr_resources()[0]
# 使用封装的get_resource_with_dir方法获取PLR资源
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
# 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
@@ -1490,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
@@ -1508,7 +1570,9 @@ class ROS2DeviceNode:
@classmethod
async def async_wait_for(cls, node: Node, wait_time: float, callback_group=None):
future = Future()
timer = node.create_timer(wait_time, lambda : future.set_result(None), callback_group=callback_group, clock=node.get_clock())
timer = node.create_timer(
wait_time, lambda: future.set_result(None), callback_group=callback_group, clock=node.get_clock()
)
await future
timer.cancel()
node.destroy_timer(timer)
@@ -1526,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,
):
@@ -1542,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: 动作值映射
@@ -1556,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()
@@ -1566,6 +1630,7 @@ class ROS2DeviceNode:
or driver_class.__name__ == "LiquidHandlerAbstract"
or driver_class.__name__ == "LiquidHandlerBiomek"
or driver_class.__name__ == "PRCXI9300Handler"
or driver_class.__name__ == "TransformXYZHandler"
)
# 创建设备类实例

View File

@@ -6,12 +6,13 @@ from cv_bridge import CvBridge
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
class VideoPublisher(BaseROS2DeviceNode):
def __init__(self, device_id='video_publisher', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
def __init__(self, device_id='video_publisher', device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
# 初始化BaseROS2DeviceNode使用自身作为driver_instance
BaseROS2DeviceNode.__init__(
self,
driver_instance=self,
device_id=device_id,
device_uuid=device_uuid,
status_types={},
action_value_mappings={},
hardware_interface="camera",

View File

@@ -5,7 +5,7 @@ import threading
import time
import traceback
import uuid
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, TypedDict, Union
from action_msgs.msg import GoalStatus
from geometry_msgs.msg import Point
@@ -38,6 +38,7 @@ from unilabos.ros.msgs.message_converter import (
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.presets.controller_node import ControllerNode
from unilabos.ros.nodes.resource_tracker import (
ResourceDict,
ResourceDictInstance,
ResourceTreeSet,
ResourceTreeInstance,
@@ -48,7 +49,7 @@ from unilabos.utils.type_check import serialize_result_info
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
if TYPE_CHECKING:
from unilabos.app.ws_client import QueueItem, WSResourceChatData
from unilabos.app.ws_client import QueueItem
@dataclass
@@ -56,6 +57,11 @@ class DeviceActionStatus:
job_ids: Dict[str, float] = field(default_factory=dict)
class TestResourceReturn(TypedDict):
resources: List[List[ResourceDict]]
devices: List[DeviceSlot]
class HostNode(BaseROS2DeviceNode):
"""
主机节点类,负责管理设备、资源和控制器
@@ -283,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()
@@ -526,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
@@ -706,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:
"""目标响应回调"""
@@ -717,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:
"""反馈回调"""
@@ -734,46 +748,133 @@ class HostNode(BaseROS2DeviceNode):
def get_result_callback(self, item: "QueueItem", action_id: str, future) -> None:
"""获取结果回调"""
job_id = item.job_id
result_msg = future.result().result
result_data = convert_from_ros_msg(result_msg)
status = "success"
return_info_str = result_data.get("return_info")
if return_info_str is not None:
try:
return_info = json.loads(return_info_str)
suc = return_info.get("suc", False)
if not suc:
status = "failed"
except json.JSONDecodeError:
try:
result = future.result()
result_msg = result.result
goal_status = result.status
# 检查是否是被取消的任务
if goal_status == GoalStatus.STATUS_CANCELED:
self.lab_logger().info(f"[Host Node] Goal {action_id} ({job_id[:8]}) was cancelled")
status = "failed"
return_info = serialize_result_info("", False, result_data)
self.lab_logger().critical("错误的return_info类型请断点修复")
else:
# 无 return_info 字段时,回退到 success 字段(若存在)
suc_field = result_data.get("success")
if isinstance(suc_field, bool):
status = "success" if suc_field else "failed"
return_info = serialize_result_info("", suc_field, result_data)
return_info = serialize_result_info("Job was cancelled", False, {})
else:
# 最保守的回退标记失败并返回空JSON
status = "failed"
return_info = serialize_result_info("缺少return_info", False, result_data)
result_data = convert_from_ros_msg(result_msg)
status = "success"
return_info_str = result_data.get("return_info")
if return_info_str is not None:
try:
return_info = json.loads(return_info_str)
# 适配后端的一些额外处理
return_value = return_info.get("return_value")
if isinstance(return_value, dict):
unilabos_samples = return_info.get("unilabos_samples")
if isinstance(unilabos_samples, list):
return_info["unilabos_samples"] = unilabos_samples
suc = return_info.get("suc", False)
if not suc:
status = "failed"
except json.JSONDecodeError:
status = "failed"
return_info = serialize_result_info("", False, result_data)
self.lab_logger().critical("错误的return_info类型请断点修复")
else:
# 无 return_info 字段时,回退到 success 字段(若存在)
suc_field = result_data.get("success")
if isinstance(suc_field, bool):
status = "success" if suc_field else "failed"
return_info = serialize_result_info("", suc_field, result_data)
else:
# 最保守的回退标记失败并返回空JSON
status = "failed"
return_info = serialize_result_info("缺少return_info", False, result_data)
self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id}): {status}")
self.lab_logger().debug(f"[Host Node] Result data: {result_data}")
self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id[:8]}): {status}")
if goal_status != GoalStatus.STATUS_CANCELED:
self.lab_logger().debug(f"[Host Node] Result data: {result_data}")
if job_id:
# 清理 _goals 中的记录
if job_id in self._goals:
del self._goals[job_id]
self.lab_logger().debug(f"[Host Node] Removed goal {job_id[:8]} from _goals")
# 存储结果供 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:
store_job_result(job_id, status, return_info, result_data)
except ImportError:
pass # controller 模块可能未加载
# 发布状态到桥接器
if job_id:
for bridge in self.bridges:
if hasattr(bridge, "publish_job_status"):
if goal_status == GoalStatus.STATUS_CANCELED:
bridge.publish_job_status({}, item, status, return_info)
else:
bridge.publish_job_status(result_data, item, status, return_info)
except Exception as e:
self.lab_logger().error(
f"[Host Node] Error in get_result_callback for {action_id} ({job_id[:8]}): {str(e)}"
)
import traceback
self.lab_logger().error(traceback.format_exc())
# 清理 _goals 中的记录
if job_id in self._goals:
del self._goals[job_id]
# 发布失败状态
for bridge in self.bridges:
if hasattr(bridge, "publish_job_status"):
bridge.publish_job_status(result_data, item, status, return_info)
bridge.publish_job_status(
{}, item, "failed", serialize_result_info(f"Callback error: {str(e)}", False, {})
)
def cancel_goal(self, goal_uuid: str) -> None:
"""取消目标"""
def cancel_goal(self, goal_uuid: str) -> bool:
"""
取消目标
Args:
goal_uuid: 目标UUIDjob_id
Returns:
bool: 如果找到目标并发起取消请求返回True否则返回False
"""
if goal_uuid in self._goals:
self.lab_logger().info(f"[Host Node] Cancelling goal {goal_uuid}")
self._goals[goal_uuid].cancel_goal_async()
self.lab_logger().info(f"[Host Node] Cancelling goal {goal_uuid[:8]}")
goal_handle = self._goals[goal_uuid]
# 发起异步取消请求
cancel_future = goal_handle.cancel_goal_async()
# 添加取消完成的回调
cancel_future.add_done_callback(lambda future: self._cancel_goal_callback(goal_uuid, future))
return True
else:
self.lab_logger().warning(f"[Host Node] Goal {goal_uuid} not found, cannot cancel")
self.lab_logger().warning(f"[Host Node] Goal {goal_uuid[:8]} not found in _goals, cannot cancel")
return False
def _cancel_goal_callback(self, goal_uuid: str, future) -> None:
"""取消目标的回调"""
try:
cancel_response = future.result()
if cancel_response.goals_canceling:
self.lab_logger().info(f"[Host Node] Goal {goal_uuid[:8]} cancel request accepted")
else:
self.lab_logger().warning(f"[Host Node] Goal {goal_uuid[:8]} cancel request rejected")
except Exception as e:
self.lab_logger().error(f"[Host Node] Error cancelling goal {goal_uuid[:8]}: {str(e)}")
import traceback
self.lab_logger().error(traceback.format_exc())
def get_goal_status(self, job_id: str) -> int:
"""获取目标状态"""
@@ -1056,11 +1157,12 @@ class HostNode(BaseROS2DeviceNode):
响应对象,包含查询到的资源
"""
try:
from unilabos.app.web import http_client
data = json.loads(request.command)
if "uuid" in data and data["uuid"] is not None:
http_req = self.bridges[-1].resource_tree_get([data["uuid"]], data["with_children"])
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
elif "id" in data and data["id"].startswith("/"):
http_req = self.bridges[-1].resource_get(data["id"], data["with_children"])
http_req = http_client.resource_get(data["id"], data["with_children"])
else:
raise ValueError("没有使用正确的物料 id 或 uuid")
response.response = json.dumps(http_req["data"])
@@ -1270,7 +1372,7 @@ class HostNode(BaseROS2DeviceNode):
def test_resource(
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]
):
) -> TestResourceReturn:
return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
"devices": [device, *devices],

View File

@@ -1,3 +1,4 @@
import uuid
import rclpy,json
from rclpy.node import Node
from sensor_msgs.msg import JointState
@@ -6,7 +7,7 @@ from rclpy.callback_groups import ReentrantCallbackGroup
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class JointRepublisher(BaseROS2DeviceNode):
def __init__(self,device_id,resource_tracker):
def __init__(self,device_id,resource_tracker, **kwargs):
super().__init__(
driver_instance=self,
device_id=device_id,
@@ -15,6 +16,7 @@ class JointRepublisher(BaseROS2DeviceNode):
hardware_interface={},
print_publish=False,
resource_tracker=resource_tracker,
device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
)
# print('-'*20,device_id)

View File

@@ -1,5 +1,6 @@
from pathlib import Path
import time
import uuid
import rclpy,json
from rclpy.node import Node
from std_msgs.msg import String,Header
@@ -25,7 +26,7 @@ from unilabos.resources.graphio import initialize_resources
from unilabos.registry.registry import lab_registry
class ResourceMeshManager(BaseROS2DeviceNode):
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50):
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs):
"""初始化资源网格管理器节点
Args:
@@ -41,10 +42,11 @@ class ResourceMeshManager(BaseROS2DeviceNode):
hardware_interface={},
print_publish=False,
resource_tracker=resource_tracker,
device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
)
self.resource_model = resource_model
self.resource_config_dict = {item['id']: item for item in resource_config}
self.resource_config_dict = {item['uuid']: item for item in resource_config}
self.move_group_ready = False
self.resource_tf_dict = {}
self.tf_broadcaster = TransformBroadcaster(self)
@@ -182,14 +184,16 @@ class ResourceMeshManager(BaseROS2DeviceNode):
self.get_logger().info('开始设置资源网格管理器')
#遍历resource_config中的资源配置判断panent是否在resource_model中
resource_tf_dict = {}
for resource_id, resource_config in resource_config_dict.items():
for resource_uuid, resource_config in resource_config_dict.items():
parent = None
resource_id = resource_config['id']
if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "":
parent = resource_config_dict[resource_config['parent_uuid']]['id']
parent = resource_config['parent']
parent_link = 'world'
if parent in self.resource_model:
parent_link = parent
elif parent is None and resource_id in self.resource_model:
pass
elif parent is not None and resource_id in self.resource_model:
# parent_link = f"{self.resource_config_dict[parent]['parent']}_{parent}_device_link".replace("None_","")
@@ -199,9 +203,9 @@ class ResourceMeshManager(BaseROS2DeviceNode):
continue
# 提取位置信息并转换单位
position = {
"x": float(resource_config['position']['x'])/1000,
"y": float(resource_config['position']['y'])/1000,
"z": float(resource_config['position']['z'])/1000
"x": float(resource_config['position']['position']['x'])/1000,
"y": float(resource_config['position']['position']['y'])/1000,
"z": float(resource_config['position']['position']['z'])/1000
}
rotation_dict = {
@@ -210,8 +214,8 @@ class ResourceMeshManager(BaseROS2DeviceNode):
"z": 0
}
if 'rotation' in resource_config['config']:
rotation_dict = resource_config['config']['rotation']
if 'rotation' in resource_config['position']:
rotation_dict = resource_config['position']['rotation']
# 从欧拉角转换为四元数
q = quaternion_from_euler(

View File

@@ -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:

View File

@@ -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:
@@ -60,13 +62,12 @@ class ResourceDict(BaseModel):
icon: str = Field(description="Resource icon", default="")
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")
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")
extra: Dict[str, Any] = Field(description="Extra data")
data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data")
extra: Dict[str, Any] = Field(description="Extra data, eg: slot index")
@field_serializer("parent_uuid")
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
@@ -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"},
@@ -848,9 +850,13 @@ class DeviceNodeResourceTracker(object):
extra: extra字典值
"""
if isinstance(resource, dict):
resource["extra"] = extra
c_extra = resource.get("extra", {})
c_extra.update(extra)
resource["extra"] = c_extra
else:
setattr(resource, "unilabos_extra", extra)
c_extra = getattr(resource, "unilabos_extra", {})
c_extra.update(extra)
setattr(resource, "unilabos_extra", c_extra)
def _traverse_and_process(self, resource, process_func) -> int:
"""

View File

@@ -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