From 657f952e7afcabd025decafcf5a4e299e86186f7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:01:57 +0800 Subject: [PATCH 1/8] Create session_id by edge. --- unilabos/app/ws_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 23f139d..8a00a9e 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -359,7 +359,7 @@ class MessageProcessor: self.device_manager = device_manager self.queue_processor = None # 延迟设置 self.websocket_client = None # 延迟设置 - self.session_id = "" + self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id # WebSocket连接 self.websocket = None @@ -576,9 +576,9 @@ class MessageProcessor: await self._handle_resource_tree_update(message_data, "update") elif message_type == "remove_material": await self._handle_resource_tree_update(message_data, "remove") - elif message_type == "session_id": - self.session_id = message_data.get("session_id") - logger.info(f"[MessageProcessor] Session ID: {self.session_id}") + # elif message_type == "session_id": + # self.session_id = message_data.get("session_id") + # logger.info(f"[MessageProcessor] Session ID: {self.session_id}") elif message_type == "request_reload": await self._handle_request_reload(message_data) else: From aacf3497e035bed4efc293f5ce667fbe42c9afe7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:18:39 +0800 Subject: [PATCH 2/8] Add no_update_feedback option. --- docs/conf.py | 4 +- docs/developer_guide/action_includes.md | 1024 +---------------------- docs/requirements.txt | 4 + unilabos/app/main.py | 6 + unilabos/app/ws_client.py | 6 +- unilabos/config/config.py | 1 + unilabos/ros/nodes/base_device_node.py | 17 +- 7 files changed, 62 insertions(+), 1000 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f15f0e6..60a22b6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings "sphinx_rtd_theme", - "sphinxcontrib.mermaid" + "sphinxcontrib.mermaid", ] source_suffix = { @@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme" # sphinx-book-theme 主题选项 html_theme_options = { - "repository_url": "https://github.com/用户名/Uni-Lab", + "repository_url": "https://github.com/deepmodeling/Uni-Lab-OS", "use_repository_button": True, "use_issues_button": True, "use_edit_page_button": True, diff --git a/docs/developer_guide/action_includes.md b/docs/developer_guide/action_includes.md index 206f94e..be1316c 100644 --- a/docs/developer_guide/action_includes.md +++ b/docs/developer_guide/action_includes.md @@ -1,4 +1,5 @@ -## 基础通用操作 +## 简单单变量动作函数 + ### `SendCmd` @@ -6,343 +7,49 @@ :language: yaml ``` ---- +---- +## 常量有机化学操作 -### `FloatSingleInput` +Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。 -```{literalinclude} ../../unilabos_msgs/action/FloatSingleInput.action -:language: yaml -``` ---- -### `IntSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action -:language: yaml -``` - ---- - -### `Point3DSeparateInput` - -```{literalinclude} ../../unilabos_msgs/action/Point3DSeparateInput.action -:language: yaml -``` - ---- - -### `StrSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action -:language: yaml -``` - ---- - -### `Wait` - -```{literalinclude} ../../unilabos_msgs/action/Wait.action -:language: yaml -``` - ---- - -## 化学实验操作 - -Uni-Lab 化学操作指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作。 - -### 物料添加 - -#### `Add` - -```{literalinclude} ../../unilabos_msgs/action/Add.action -:language: yaml -``` - ---- - -#### `AddSolid` - -```{literalinclude} ../../unilabos_msgs/action/AddSolid.action -:language: yaml -``` - ---- - -### 液体转移与泵控制 - -#### `PumpTransfer` - -```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action -:language: yaml -``` - ---- - -#### `SetPumpPosition` - -```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action -:language: yaml -``` - ---- - -#### `Transfer` - -```{literalinclude} ../../unilabos_msgs/action/Transfer.action -:language: yaml -``` - ---- - -### 温度控制 - -#### `HeatChill` - -```{literalinclude} ../../unilabos_msgs/action/HeatChill.action -:language: yaml -``` - ---- - -#### `HeatChillStart` - -```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action -:language: yaml -``` - ---- - -#### `HeatChillStop` - -```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action -:language: yaml -``` - ---- - -### 搅拌控制 - -#### `StartStir` - -```{literalinclude} ../../unilabos_msgs/action/StartStir.action -:language: yaml -``` - ---- - -#### `Stir` - -```{literalinclude} ../../unilabos_msgs/action/Stir.action -:language: yaml -``` - ---- - -#### `StopStir` - -```{literalinclude} ../../unilabos_msgs/action/StopStir.action -:language: yaml -``` - ---- - -### 气体与真空控制 - -#### `EvacuateAndRefill` - -```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action -:language: yaml -``` - ---- - -#### `Purge` - -```{literalinclude} ../../unilabos_msgs/action/Purge.action -:language: yaml -``` - ---- - -#### `StartPurge` - -```{literalinclude} ../../unilabos_msgs/action/StartPurge.action -:language: yaml -``` - ---- - -#### `StopPurge` - -```{literalinclude} ../../unilabos_msgs/action/StopPurge.action -:language: yaml -``` - ---- - -### 分离与过滤 - -#### `Centrifuge` - -```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action -:language: yaml -``` - ---- - -#### `Filter` - -```{literalinclude} ../../unilabos_msgs/action/Filter.action -:language: yaml -``` - ---- - -#### `FilterThrough` - -```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action -:language: yaml -``` - ---- - -#### `RunColumn` - -```{literalinclude} ../../unilabos_msgs/action/RunColumn.action -:language: yaml -``` - ---- - -#### `Separate` - -```{literalinclude} ../../unilabos_msgs/action/Separate.action -:language: yaml -``` - ---- - -### 化学处理 - -#### `AdjustPH` - -```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action -:language: yaml -``` - ---- - -#### `Crystallize` - -```{literalinclude} ../../unilabos_msgs/action/Crystallize.action -:language: yaml -``` - ---- - -#### `Dissolve` - -```{literalinclude} ../../unilabos_msgs/action/Dissolve.action -:language: yaml -``` - ---- - -#### `Dry` - -```{literalinclude} ../../unilabos_msgs/action/Dry.action -:language: yaml -``` - ---- - -#### `Evaporate` - -```{literalinclude} ../../unilabos_msgs/action/Evaporate.action -:language: yaml -``` - ---- - -#### `Hydrogenate` - -```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action -:language: yaml -``` - ---- - -#### `Recrystallize` - -```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action -:language: yaml -``` - ---- - -#### `WashSolid` - -```{literalinclude} ../../unilabos_msgs/action/WashSolid.action -:language: yaml -``` - ---- - -### 清洁与维护 - -#### `Clean` +### `Clean` ```{literalinclude} ../../unilabos_msgs/action/Clean.action :language: yaml ``` ---- +---- -#### `CleanVessel` +### `HeatChillStart` -```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action +```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action :language: yaml ``` ---- +---- -#### `EmptyIn` +### `HeatChillStop` -```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action +```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action :language: yaml ``` ---- +---- -#### `ResetHandling` +### `PumpTransfer` -```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action +```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action :language: yaml ``` ---- +---- +## 移液工作站及相关生物自动化设备操作 -## 生物自动化操作 +Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。 -Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含移液工作站的各类操作。 -### `LiquidHandlerAdd` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action -:language: yaml -``` - ---- - -### `LiquidHandlerAspirate` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action -:language: yaml -``` - ---- ### `LiquidHandlerDiscardTips` @@ -350,15 +57,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerDispense` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDispense.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerDropTips` @@ -366,7 +65,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerDropTips96` @@ -374,31 +73,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerIncubateBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerIncubateBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerMix` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMix.action -:language: yaml -``` - ---- - -### `LiquidHandlerMoveBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveBiomek.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerMoveLid` @@ -406,7 +81,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMovePlate` @@ -414,7 +89,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMoveResource` @@ -422,23 +97,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerMoveTo` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveTo.action -:language: yaml -``` - ---- - -### `LiquidHandlerOscillateBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerOscillateBiomek.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerPickUpTips` @@ -446,7 +105,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerPickUpTips96` @@ -454,23 +113,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerProtocolCreation` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerProtocolCreation.action -:language: yaml -``` - ---- - -### `LiquidHandlerRemove` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerRemove.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerReturnTips` @@ -478,7 +121,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerReturnTips96` @@ -486,31 +129,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerSetGroup` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetGroup.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetLiquid` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetLiquid.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetTipRack` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetTipRack.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerStamp` @@ -518,597 +137,22 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- +## 多工作站及小车运行、物料转移 -### `LiquidHandlerTransfer` -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action -:language: yaml -``` - ---- - -### `LiquidHandlerTransferBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerTransferGroup` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferGroup.action -:language: yaml -``` - ---- - -## 专用工作站操作 - -### 反应工作站 - -#### `ReactionStationDripBack` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationDripBack.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedBeaker` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedBeaker.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedSolvents` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedSolvents.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedTitration` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedTitration.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedVialsNonTitration` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedVialsNonTitration.action -:language: yaml -``` - ---- - -#### `ReactionStationProExecu` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationProExecu.action -:language: yaml -``` - ---- - -#### `ReactionStationReactorTakenOut` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationReactorTakenOut.action -:language: yaml -``` - ---- - -#### `ReactionStationReaTackIn` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationReaTackIn.action -:language: yaml -``` - ---- - -#### `ReactionStationSolidFeedVial` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationSolidFeedVial.action -:language: yaml -``` - ---- - -### 固体分配站 - -#### `SolidDispenseAddPowderTube` - -```{literalinclude} ../../unilabos_msgs/action/SolidDispenseAddPowderTube.action -:language: yaml -``` - ---- - -### 分液工作站 - -#### `DispenStationSolnPrep` - -```{literalinclude} ../../unilabos_msgs/action/DispenStationSolnPrep.action -:language: yaml -``` - ---- - -#### `DispenStationVialFeed` - -```{literalinclude} ../../unilabos_msgs/action/DispenStationVialFeed.action -:language: yaml -``` - ---- - -### 后处理工作站 - -#### `PostProcessGrab` - -```{literalinclude} ../../unilabos_msgs/action/PostProcessGrab.action -:language: yaml -``` - ---- - -#### `PostProcessTriggerClean` - -```{literalinclude} ../../unilabos_msgs/action/PostProcessTriggerClean.action -:language: yaml -``` - ---- - -#### `PostProcessTriggerPostPro` - -```{literalinclude} ../../unilabos_msgs/action/PostProcessTriggerPostPro.action -:language: yaml -``` - ---- - -## 系统管理与资源调度 - -### 资源与布局管理 - -#### `DefaultLayoutRecommendLayout` - -```{literalinclude} ../../unilabos_msgs/action/DefaultLayoutRecommendLayout.action -:language: yaml -``` - ---- - -#### `ResourceCreateFromOuter` - -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action -:language: yaml -``` - ---- - -#### `ResourceCreateFromOuterEasy` - -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action -:language: yaml -``` - ---- - -### 多工作站协调 - -#### `AGVTransfer` +### `AGVTransfer` ```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action :language: yaml ``` ---- +---- -#### `WorkStationRun` +### `WorkStationRun` ```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action :language: yaml ``` ---- - -## 机器人控制(ROS2 标准) - -Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`。 - -### 机械臂与关节控制 - -#### `FollowJointTrajectory` - -```yaml -# The trajectory for all revolute, continuous or prismatic joints -trajectory_msgs/JointTrajectory trajectory -# The trajectory for all planar or floating joints (i.e. individual joints with more than one DOF) -trajectory_msgs/MultiDOFJointTrajectory multi_dof_trajectory - -# Tolerances for the trajectory. If the measured joint values fall -# outside the tolerances the trajectory goal is aborted. Any -# tolerances that are not specified (by being omitted or set to 0) are -# set to the defaults for the action server (often taken from the -# parameter server). - -# Tolerances applied to the joints as the trajectory is executed. If -# violated, the goal aborts with error_code set to -# PATH_TOLERANCE_VIOLATED. -JointTolerance[] path_tolerance -JointComponentTolerance[] component_path_tolerance - -# To report success, the joints must be within goal_tolerance of the -# final trajectory value. The goal must be achieved by time the -# trajectory ends plus goal_time_tolerance. (goal_time_tolerance -# allows some leeway in time, so that the trajectory goal can still -# succeed even if the joints reach the goal some time after the -# precise end time of the trajectory). -# -# If the joints are not within goal_tolerance after "trajectory finish -# time" + goal_time_tolerance, the goal aborts with error_code set to -# GOAL_TOLERANCE_VIOLATED -JointTolerance[] goal_tolerance -JointComponentTolerance[] component_goal_tolerance -builtin_interfaces/Duration goal_time_tolerance - ---- -int32 error_code -int32 SUCCESSFUL = 0 -int32 INVALID_GOAL = -1 -int32 INVALID_JOINTS = -2 -int32 OLD_HEADER_TIMESTAMP = -3 -int32 PATH_TOLERANCE_VIOLATED = -4 -int32 GOAL_TOLERANCE_VIOLATED = -5 - -# Human readable description of the error code. Contains complementary -# information that is especially useful when execution fails, for instance: -# - INVALID_GOAL: The reason for the invalid goal (e.g., the requested -# trajectory is in the past). -# - INVALID_JOINTS: The mismatch between the expected controller joints -# and those provided in the goal. -# - PATH_TOLERANCE_VIOLATED and GOAL_TOLERANCE_VIOLATED: Which joint -# violated which tolerance, and by how much. -string error_string - ---- -std_msgs/Header header -string[] joint_names -trajectory_msgs/JointTrajectoryPoint desired -trajectory_msgs/JointTrajectoryPoint actual -trajectory_msgs/JointTrajectoryPoint error - -string[] multi_dof_joint_names -trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_desired -trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_actual -trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error - -``` - ---- - -#### `JointTrajectory` - -```yaml -trajectory_msgs/JointTrajectory trajectory ---- - ---- -``` - ---- - -#### `PointHead` - -```yaml -geometry_msgs/PointStamped target -geometry_msgs/Vector3 pointing_axis -string pointing_frame -builtin_interfaces/Duration min_duration -float64 max_velocity ---- - ---- -float64 pointing_angle_error -``` - ---- - -#### `SingleJointPosition` - -```yaml -float64 position -builtin_interfaces/Duration min_duration -float64 max_velocity ---- - ---- -std_msgs/Header header -float64 position -float64 velocity -float64 error -``` - ---- - -### 夹爪控制 - -#### `GripperCommand` - -```yaml -GripperCommand command ---- -float64 position # The current gripper gap size (in meters) -float64 effort # The current effort exerted (in Newtons) -bool stalled # True iff the gripper is exerting max effort and not moving -bool reached_goal # True iff the gripper position has reached the commanded setpoint ---- -float64 position # The current gripper gap size (in meters) -float64 effort # The current effort exerted (in Newtons) -bool stalled # True iff the gripper is exerting max effort and not moving -bool reached_goal # True iff the gripper position has reached the commanded setpoint - -``` - ---- - -#### `ParallelGripperCommand` - -```yaml -# Parallel grippers refer to an end effector where two opposing fingers grasp an object from opposite sides. -sensor_msgs/JointState command -# name: the name(s) of the joint this command is requesting -# position: desired position of each gripper joint (radians or meters) -# velocity: (optional, not used if empty) max velocity of the joint allowed while moving (radians or meters / second) -# effort: (optional, not used if empty) max effort of the joint allowed while moving (Newtons or Newton-meters) ---- -sensor_msgs/JointState state # The current gripper state. -# position of each joint (radians or meters) -# optional: velocity of each joint (radians or meters / second) -# optional: effort of each joint (Newtons or Newton-meters) -bool stalled # True if the gripper is exerting max effort and not moving -bool reached_goal # True if the gripper position has reached the commanded setpoint ---- -sensor_msgs/JointState state # The current gripper state. -# position of each joint (radians or meters) -# optional: velocity of each joint (radians or meters / second) -# optional: effort of each joint (Newtons or Newton-meters) - -``` - ---- - -### 导航与路径规划 - -#### `AssistedTeleop` - -```yaml -#goal definition -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback -builtin_interfaces/Duration current_teleop_duration -``` - ---- - -#### `BackUp` - -```yaml -#goal definition -geometry_msgs/Point target -float32 speed -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -float32 distance_traveled -``` - ---- - -#### `ComputePathThroughPoses` - -```yaml -#goal definition -geometry_msgs/PoseStamped[] goals -geometry_msgs/PoseStamped start -string planner_id -bool use_start # If false, use current robot pose as path start, if true, use start above instead ---- -#result definition -nav_msgs/Path path -builtin_interfaces/Duration planning_time ---- -#feedback definition -``` - ---- - -#### `ComputePathToPose` - -```yaml -#goal definition -geometry_msgs/PoseStamped goal -geometry_msgs/PoseStamped start -string planner_id -bool use_start # If false, use current robot pose as path start, if true, use start above instead ---- -#result definition -nav_msgs/Path path -builtin_interfaces/Duration planning_time ---- -#feedback definition -``` - ---- - -#### `DriveOnHeading` - -```yaml -#goal definition -geometry_msgs/Point target -float32 speed -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -float32 distance_traveled -``` - ---- - -#### `DummyBehavior` - -```yaml -#goal definition -std_msgs/String command ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -``` - ---- - -#### `FollowPath` - -```yaml -#goal definition -nav_msgs/Path path -string controller_id -string goal_checker_id ---- -#result definition -std_msgs/Empty result ---- -#feedback definition -float32 distance_to_goal -float32 speed -``` - ---- - -#### `FollowWaypoints` - -```yaml -#goal definition -geometry_msgs/PoseStamped[] poses ---- -#result definition -int32[] missed_waypoints ---- -#feedback definition -uint32 current_waypoint -``` - ---- - -#### `NavigateThroughPoses` - -```yaml -#goal definition -geometry_msgs/PoseStamped[] poses -string behavior_tree ---- -#result definition -std_msgs/Empty result ---- -#feedback definition -geometry_msgs/PoseStamped current_pose -builtin_interfaces/Duration navigation_time -builtin_interfaces/Duration estimated_time_remaining -int16 number_of_recoveries -float32 distance_remaining -int16 number_of_poses_remaining -``` - ---- - -#### `NavigateToPose` - -```yaml -#goal definition -geometry_msgs/PoseStamped pose -string behavior_tree ---- -#result definition -std_msgs/Empty result ---- -#feedback definition -geometry_msgs/PoseStamped current_pose -builtin_interfaces/Duration navigation_time -builtin_interfaces/Duration estimated_time_remaining -int16 number_of_recoveries -float32 distance_remaining -``` - ---- - -#### `SmoothPath` - -```yaml -#goal definition -nav_msgs/Path path -string smoother_id -builtin_interfaces/Duration max_smoothing_duration -bool check_for_collisions ---- -#result definition -nav_msgs/Path path -builtin_interfaces/Duration smoothing_duration -bool was_completed ---- -#feedback definition -``` - ---- - -#### `Spin` - -```yaml -#goal definition -float32 target_yaw -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -float32 angular_distance_traveled -``` - ---- - -#### `Wait` (Nav2) - -> **注意**:这是 ROS2 nav2_msgs 的标准 Wait action,与 unilabos_msgs 的 Wait action 不同。 - -```yaml -#goal definition -builtin_interfaces/Duration time ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -builtin_interfaces/Duration time_left -``` - ---- +---- diff --git a/docs/requirements.txt b/docs/requirements.txt index 1cc9247..591cc07 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -12,3 +12,7 @@ sphinx-copybutton>=0.5.0 # 用于自动摘要生成 sphinx-autobuild>=2024.2.4 + +# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX) +rinohtype>=0.5.4 +sphinx-simplepdf>=1.6.0 \ No newline at end of file diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6738b2f..3ad7310 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -156,6 +156,11 @@ def parse_args(): default=False, help="Complete registry information", ) + parser.add_argument( + "--no_update_feedback", + action="store_true", + help="Disable sending update feedback to server", + ) # workflow upload subcommand workflow_parser = subparsers.add_parser( "workflow_upload", @@ -297,6 +302,7 @@ def main(): BasicConfig.is_host_mode = not args_dict.get("is_slave", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) BasicConfig.upload_registry = args_dict.get("upload_registry", False) + BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False) BasicConfig.communication_protocol = "websocket" machine_name = os.popen("hostname").read().strip() machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 8a00a9e..4933b61 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -488,7 +488,11 @@ class MessageProcessor: async for message in self.websocket: try: data = json.loads(message) - await self._process_message(data) + if self.session_id and self.session_id == data.get("edge_session"): + await self._process_message(data) + else: + logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}") + logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}") except json.JSONDecodeError: logger.error(f"[MessageProcessor] Invalid JSON received: {message}") except Exception as e: diff --git a/unilabos/config/config.py b/unilabos/config/config.py index 1e40966..f3dba5d 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -16,6 +16,7 @@ class BasicConfig: upload_registry = False machine_name = "undefined" vis_2d_enable = False + no_update_feedback = False enable_resource_load = True communication_protocol = "websocket" startup_json_path = None # 填写绝对路径 diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 5d7379e..89c4d39 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -20,6 +20,8 @@ from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.service import Service from unilabos_msgs.action import SendCmd from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response + +from unilabos.config.config import BasicConfig from unilabos.utils.decorator import get_topic_config, get_all_subscriptions from unilabos.resources.container import RegularContainer @@ -911,13 +913,14 @@ class BaseROS2DeviceNode(Node, Generic[T]): else: plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) - # new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) - # r = SerialCommand.Request() - # r.command = json.dumps( - # {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 - # response: SerialCommand_Response = await self._resource_clients[ - # "c2s_update_resource_tree"].call_async(r) # type: ignore - # self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}") + if not BasicConfig.no_update_feedback: + new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) + r = SerialCommand.Request() + r.command = json.dumps( + {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 + response: SerialCommand_Response = await self._resource_clients[ + "c2s_update_resource_tree"].call_async(r) # type: ignore + self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}") results.append(result) elif action == "remove": result = _handle_remove(resources_uuid) From 965bf36e8d55f1c6298360d13cc83fb240277cad Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:25:36 +0800 Subject: [PATCH 3/8] Add restart. Temp allow action message. --- unilabos/app/main.py | 15 ++- unilabos/app/utils.py | 144 ++++++++++++++++++++++++ unilabos/app/web/server.py | 42 ++++++- unilabos/app/ws_client.py | 66 ++++++++--- unilabos/ros/main_slave_run.py | 9 +- unilabos/ros/nodes/presets/host_node.py | 105 ++++++++++++++--- 6 files changed, 342 insertions(+), 39 deletions(-) create mode 100644 unilabos/app/utils.py diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 3ad7310..8ec26c0 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -19,6 +19,11 @@ if unilabos_dir not in sys.path: from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.config.config import load_config, BasicConfig, HTTPConfig +from unilabos.app.utils import cleanup_for_restart + +# Global restart flags (used by ws_client and web/server) +_restart_requested: bool = False +_restart_reason: str = "" def load_config_from_file(config_path): @@ -503,13 +508,19 @@ def main(): time.sleep(1) else: start_backend(**args_dict) - start_server( + restart_requested = start_server( open_browser=not args_dict["disable_browser"], port=BasicConfig.port, ) + if restart_requested: + print_status("[Main] Restart requested, cleaning up...", "info") + cleanup_for_restart() + return else: start_backend(**args_dict) - start_server( + + # 启动服务器(默认支持WebSocket触发重启) + restart_requested = start_server( open_browser=not args_dict["disable_browser"], port=BasicConfig.port, ) diff --git a/unilabos/app/utils.py b/unilabos/app/utils.py new file mode 100644 index 0000000..d10c2e0 --- /dev/null +++ b/unilabos/app/utils.py @@ -0,0 +1,144 @@ +""" +UniLabOS 应用工具函数 + +提供清理、重启等工具函数 +""" + +import gc +import os +import threading +import time + +from unilabos.utils.banner_print import print_status + + +def cleanup_for_restart() -> bool: + """ + Clean up all resources for restart without exiting the process. + + This function prepares the system for re-initialization by: + 1. Stopping all communication clients + 2. Destroying ROS nodes + 3. Resetting singletons + 4. Waiting for threads to finish + + Returns: + bool: True if cleanup was successful, False otherwise + """ + print_status("[Restart] Starting cleanup for restart...", "info") + + # Step 1: Stop WebSocket communication client + print_status("[Restart] Step 1: Stopping WebSocket client...", "info") + try: + from unilabos.app.communication import get_communication_client + + comm_client = get_communication_client() + if comm_client is not None: + comm_client.stop() + print_status("[Restart] WebSocket client stopped", "info") + except Exception as e: + print_status(f"[Restart] Error stopping WebSocket: {e}", "warning") + + # Step 2: Get HostNode and cleanup ROS + print_status("[Restart] Step 2: Cleaning up ROS nodes...", "info") + try: + from unilabos.ros.nodes.presets.host_node import HostNode + import rclpy + from rclpy.timer import Timer + + host_instance = HostNode.get_instance(timeout=5) + if host_instance is not None: + print_status(f"[Restart] Found HostNode: {host_instance.device_id}", "info") + + # Gracefully shutdown background threads + print_status("[Restart] Shutting down background threads...", "info") + HostNode.shutdown_background_threads(timeout=5.0) + print_status("[Restart] Background threads shutdown complete", "info") + + # Stop discovery timer + if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer): + host_instance._discovery_timer.cancel() + print_status("[Restart] Discovery timer cancelled", "info") + + # Destroy device nodes + device_count = len(host_instance.devices_instances) + print_status(f"[Restart] Destroying {device_count} device instances...", "info") + for device_id, device_node in list(host_instance.devices_instances.items()): + try: + if hasattr(device_node, "ros_node_instance") and device_node.ros_node_instance is not None: + device_node.ros_node_instance.destroy_node() + print_status(f"[Restart] Device {device_id} destroyed", "info") + except Exception as e: + print_status(f"[Restart] Error destroying device {device_id}: {e}", "warning") + + # Clear devices instances + host_instance.devices_instances.clear() + host_instance.devices_names.clear() + + # Destroy host node + try: + host_instance.destroy_node() + print_status("[Restart] HostNode destroyed", "info") + except Exception as e: + print_status(f"[Restart] Error destroying HostNode: {e}", "warning") + + # Reset HostNode state + HostNode.reset_state() + print_status("[Restart] HostNode state reset", "info") + + # Shutdown executor first (to stop executor.spin() gracefully) + if hasattr(rclpy, "__executor") and rclpy.__executor is not None: + try: + rclpy.__executor.shutdown() + rclpy.__executor = None # Clear for restart + print_status("[Restart] ROS executor shutdown complete", "info") + except Exception as e: + print_status(f"[Restart] Error shutting down executor: {e}", "warning") + + # Shutdown rclpy + if rclpy.ok(): + rclpy.shutdown() + print_status("[Restart] rclpy shutdown complete", "info") + + except ImportError as e: + print_status(f"[Restart] ROS modules not available: {e}", "warning") + except Exception as e: + print_status(f"[Restart] Error in ROS cleanup: {e}", "warning") + return False + + # Step 3: Reset communication client singleton + print_status("[Restart] Step 3: Resetting singletons...", "info") + try: + from unilabos.app import communication + + if hasattr(communication, "_communication_client"): + communication._communication_client = None + print_status("[Restart] Communication client singleton reset", "info") + except Exception as e: + print_status(f"[Restart] Error resetting communication singleton: {e}", "warning") + + # Step 4: Wait for threads to finish + print_status("[Restart] Step 4: Waiting for threads to finish...", "info") + time.sleep(3) # Give threads time to finish + + # Check remaining threads + remaining_threads = [] + for t in threading.enumerate(): + if t.name != "MainThread" and t.is_alive(): + remaining_threads.append(t.name) + + if remaining_threads: + print_status( + f"[Restart] Warning: {len(remaining_threads)} threads still running: {remaining_threads}", "warning" + ) + else: + print_status("[Restart] All threads stopped", "info") + + # Step 5: Force garbage collection + print_status("[Restart] Step 5: Running garbage collection...", "info") + gc.collect() + gc.collect() # Run twice for weak references + print_status("[Restart] Garbage collection complete", "info") + + print_status("[Restart] Cleanup complete. Ready for re-initialization.", "info") + return True diff --git a/unilabos/app/web/server.py b/unilabos/app/web/server.py index 2a85d10..8d09016 100644 --- a/unilabos/app/web/server.py +++ b/unilabos/app/web/server.py @@ -6,7 +6,6 @@ Web服务器模块 import webbrowser -import uvicorn from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from starlette.responses import Response @@ -96,7 +95,7 @@ def setup_server() -> FastAPI: return app -def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None: +def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool: """ 启动服务器 @@ -104,7 +103,14 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T host: 服务器主机 port: 服务器端口 open_browser: 是否自动打开浏览器 + + Returns: + bool: True if restart was requested, False otherwise """ + import threading + import time + from uvicorn import Config, Server + # 设置服务器 setup_server() @@ -123,7 +129,37 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T # 启动服务器 info(f"[Web] 启动FastAPI服务器: {host}:{port}") - uvicorn.run(app, host=host, port=port, log_config=log_config) + + # 使用支持重启的模式 + config = Config(app=app, host=host, port=port, log_config=log_config) + server = Server(config) + + # 启动服务器线程 + server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server") + server_thread.start() + + info("[Web] Server started, monitoring for restart requests...") + + # 监控重启标志 + import unilabos.app.main as main_module + + while server_thread.is_alive(): + if hasattr(main_module, "_restart_requested") and main_module._restart_requested: + info( + f"[Web] Restart requested via WebSocket, reason: {getattr(main_module, '_restart_reason', 'unknown')}" + ) + main_module._restart_requested = False + + # 停止服务器 + server.should_exit = True + server_thread.join(timeout=5) + + info("[Web] Server stopped, ready for restart") + return True + + time.sleep(1) + + return False # 当脚本直接运行时启动服务器 diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 4933b61..4c87d36 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -488,11 +488,16 @@ class MessageProcessor: async for message in self.websocket: try: data = json.loads(message) + message_type = data.get("action", "") + message_data = data.get("data") if self.session_id and self.session_id == data.get("edge_session"): - await self._process_message(data) + await self._process_message(message_type, message_data) else: - logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}") - logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}") + if message_type.endswith("_material"): + logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}") + logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}") + else: + await self._process_message(message_type, message_data) except json.JSONDecodeError: logger.error(f"[MessageProcessor] Invalid JSON received: {message}") except Exception as e: @@ -558,11 +563,8 @@ class MessageProcessor: finally: logger.debug("[MessageProcessor] Send handler stopped") - async def _process_message(self, data: Dict[str, Any]): + async def _process_message(self, message_type: str, message_data: Dict[str, Any]): """处理收到的消息""" - message_type = data.get("action", "") - message_data = data.get("data") - logger.debug(f"[MessageProcessor] Processing message: {message_type}") try: @@ -575,16 +577,19 @@ class MessageProcessor: elif message_type == "cancel_action" or message_type == "cancel_task": await self._handle_cancel_action(message_data) elif message_type == "add_material": + # noinspection PyTypeChecker await self._handle_resource_tree_update(message_data, "add") elif message_type == "update_material": + # noinspection PyTypeChecker await self._handle_resource_tree_update(message_data, "update") elif message_type == "remove_material": + # noinspection PyTypeChecker await self._handle_resource_tree_update(message_data, "remove") # elif message_type == "session_id": # self.session_id = message_data.get("session_id") # logger.info(f"[MessageProcessor] Session ID: {self.session_id}") - elif message_type == "request_reload": - await self._handle_request_reload(message_data) + elif message_type == "request_restart": + await self._handle_request_restart(message_data) else: logger.debug(f"[MessageProcessor] Unknown message type: {message_type}") @@ -894,19 +899,48 @@ class MessageProcessor: ) thread.start() - async def _handle_request_reload(self, data: Dict[str, Any]): + async def _handle_request_restart(self, data: Dict[str, Any]): """ - 处理重载请求 + 处理重启请求 - 当LabGo发送request_reload时,重新发送设备注册信息 + 当LabGo发送request_restart时,执行清理并触发重启 """ reason = data.get("reason", "unknown") - logger.info(f"[MessageProcessor] Received reload request, reason: {reason}") + delay = data.get("delay", 2) # 默认延迟2秒 + logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s") - # 重新发送host_node_ready信息 + # 发送确认消息 if self.websocket_client: - self.websocket_client.publish_host_ready() - logger.info("[MessageProcessor] Re-sent host_node_ready after reload request") + await self.websocket_client.send_message({ + "action": "restart_acknowledged", + "data": {"reason": reason, "delay": delay} + }) + + # 设置全局重启标志 + import unilabos.app.main as main_module + main_module._restart_requested = True + main_module._restart_reason = reason + + # 延迟后执行清理 + await asyncio.sleep(delay) + + # 在新线程中执行清理,避免阻塞当前事件循环 + def do_cleanup(): + import time + time.sleep(0.5) # 给当前消息处理完成的时间 + logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}") + try: + from unilabos.app.utils import cleanup_for_restart + if cleanup_for_restart(): + logger.info("[MessageProcessor] Cleanup successful, main() will restart") + else: + logger.error("[MessageProcessor] Cleanup failed") + except Exception as e: + logger.error(f"[MessageProcessor] Error during cleanup: {e}") + + cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True) + cleanup_thread.start() + logger.info(f"[MessageProcessor] Restart cleanup scheduled") async def _send_action_state_response( self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index b79c368..c24f9e8 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -1,4 +1,5 @@ import json + # from nt import device_encoding import threading import time @@ -55,7 +56,11 @@ def main( ) -> None: """主函数""" - rclpy.init(args=rclpy_init_args) + # Support restart - check if rclpy is already initialized + if not rclpy.ok(): + rclpy.init(args=rclpy_init_args) + else: + logger.info("[ROS] rclpy already initialized, reusing context") executor = rclpy.__executor = MultiThreadedExecutor() # 创建主机节点 host_node = HostNode( @@ -88,7 +93,7 @@ def main( joint_republisher = JointRepublisher("joint_republisher", 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) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index e0a66bf..69c12f8 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -70,6 +70,8 @@ class HostNode(BaseROS2DeviceNode): _instance: ClassVar[Optional["HostNode"]] = None _ready_event: ClassVar[threading.Event] = threading.Event() + _shutting_down: ClassVar[bool] = False # Flag to signal shutdown to background threads + _background_threads: ClassVar[List[threading.Thread]] = [] # Track all background threads for cleanup _device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict( DeviceActionStatus ) @@ -81,6 +83,48 @@ class HostNode(BaseROS2DeviceNode): return cls._instance return None + @classmethod + def shutdown_background_threads(cls, timeout: float = 5.0) -> None: + """ + Gracefully shutdown all background threads for clean exit or restart. + + This method: + 1. Sets shutdown flag to stop background operations + 2. Waits for background threads to finish with timeout + 3. Cleans up finished threads from tracking list + + Args: + timeout: Maximum time to wait for each thread (seconds) + """ + cls._shutting_down = True + + # Wait for background threads to finish + active_threads = [] + for t in cls._background_threads: + if t.is_alive(): + t.join(timeout=timeout) + if t.is_alive(): + active_threads.append(t.name) + + if active_threads: + logger.warning(f"[Host Node] Some background threads still running: {active_threads}") + + # Clear the thread list + cls._background_threads.clear() + logger.info(f"[Host Node] Background threads shutdown complete") + + @classmethod + def reset_state(cls) -> None: + """ + Reset the HostNode singleton state for restart or clean exit. + Call this after destroying the instance. + """ + cls._instance = None + cls._ready_event.clear() + cls._shutting_down = False + cls._background_threads.clear() + logger.info("[Host Node] State reset complete") + def __init__( self, device_id: str, @@ -294,12 +338,37 @@ class HostNode(BaseROS2DeviceNode): 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() - request.command = "" - future = sclient.call_async(request) - response = future.result() + def _send_re_register(self, sclient, device_namespace: str): + """ + Send re-register command to a device. This is a one-time operation. + + Args: + sclient: The service client + device_namespace: The device namespace for logging + """ + try: + # Use timeout to prevent indefinite blocking + if not sclient.wait_for_service(timeout_sec=10.0): + self.lab_logger().debug(f"[Host Node] Re-register timeout for {device_namespace}") + return + + # Check shutdown flag after wait + if self._shutting_down: + self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (shutdown)") + return + + request = SerialCommand.Request() + request.command = "" + future = sclient.call_async(request) + # Use timeout for result as well + future.result(timeout_sec=5.0) + self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}") + except Exception as e: + # Gracefully handle destruction during shutdown + if "destruction was requested" in str(e) or self._shutting_down: + self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (cleanup)") + else: + self.lab_logger().warning(f"[Host Node] Re-register failed for {device_namespace}: {e}") def _discover_devices(self) -> None: """ @@ -331,23 +400,27 @@ class HostNode(BaseROS2DeviceNode): self._create_action_clients_for_device(device_id, namespace) self._online_devices.add(device_key) sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device") - threading.Thread( + t = threading.Thread( target=self._send_re_register, - args=(sclient,), + args=(sclient, namespace), daemon=True, name=f"ROSDevice{self.device_id}_re_register_device_{namespace}", - ).start() + ) + self._background_threads.append(t) + t.start() elif device_key not in self._online_devices: # 设备重新上线 self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}") self._online_devices.add(device_key) sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device") - threading.Thread( + t = threading.Thread( target=self._send_re_register, - args=(sclient,), + args=(sclient, namespace), daemon=True, name=f"ROSDevice{self.device_id}_re_register_device_{namespace}", - ).start() + ) + self._background_threads.append(t) + t.start() # 检测离线设备 offline_devices = self._online_devices - current_devices @@ -705,13 +778,14 @@ class HostNode(BaseROS2DeviceNode): raise ValueError(f"ActionClient {action_id} not found.") action_client: ActionClient = self._action_clients[action_id] + # 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id" def assign_sample_id(obj): if isinstance(obj, dict): if "sample_uuid" in obj: obj["sample_id"] = obj["sample_uuid"] obj.pop("sample_uuid") - for k,v in obj.items(): + for k, v in obj.items(): if k != "unilabos_extra": assign_sample_id(v) elif isinstance(obj, list): @@ -742,9 +816,7 @@ 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_future = goal_handle.get_result_async() - goal_future.add_done_callback( - lambda f: self.get_result_callback(item, action_id, f) - ) + 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: @@ -1167,6 +1239,7 @@ 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 = http_client.resource_tree_get([data["uuid"]], data["with_children"]) From ec015e16cda0ed41db4cfcf36a34dc89fe530714 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:17:28 +0800 Subject: [PATCH 4/8] Add create_resource and test_resource example. --- unilabos/registry/registry.py | 34 ++++++-- unilabos/resources/graphio.py | 6 +- unilabos/resources/resource_tracker.py | 50 ++++++++++-- unilabos/ros/nodes/base_device_node.py | 101 ++++++++++++++---------- unilabos/ros/nodes/presets/host_node.py | 19 +++-- 5 files changed, 144 insertions(+), 66 deletions(-) diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 20e0245..ee0b460 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", + }, + { + "handler_key": "liquid_slots", + "data_type": "resource", + "label": "LiquidSlots", + "data_source": "executor", + "data_key": "liquid_input_resource_tree", + }, + { + "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/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 89c4d39..3b08707 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -431,10 +431,11 @@ 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) + rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources) 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方法 @@ -485,7 +486,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 +1264,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"]: @@ -1330,9 +1332,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 +1353,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) @@ -1497,8 +1499,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 +1513,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 +1527,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 +1557,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..b31f59b 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) @@ -1453,8 +1454,14 @@ 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(), "devices": [device, *devices], From a66603ec1cbf7a4d93bf4964fa1b25531159aa9a Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:24:01 +0800 Subject: [PATCH 5/8] Add set_liquid example. --- unilabos/registry/devices/liquid_handler.yaml | 8 ++++++- unilabos/registry/registry.py | 4 ++-- unilabos/ros/msgs/message_converter.py | 6 +++++- unilabos/ros/nodes/base_device_node.py | 21 ++++++++++++------- unilabos/ros/nodes/presets/host_node.py | 4 ++-- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index b0656d1..298eb70 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -9278,7 +9278,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 ee0b460..64aba3c 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -127,14 +127,14 @@ class Registry: "data_type": "resource", "label": "Labware", "data_source": "executor", - "data_key": "created_resource_tree", + "data_key": "created_resource_tree.@flatten", }, { "handler_key": "liquid_slots", "data_type": "resource", "label": "LiquidSlots", "data_source": "executor", - "data_key": "liquid_input_resource_tree", + "data_key": "liquid_input_resource_tree.@flatten", }, { "handler_key": "materials", 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 3b08707..8dd4a28 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -430,8 +430,10 @@ 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) - rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources) + 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_resource_tree": rts.dump(), @@ -461,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: @@ -1281,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) @@ -1423,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) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index b31f59b..0f6b697 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -1244,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") @@ -1463,7 +1463,7 @@ class HostNode(BaseROS2DeviceNode): 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], } From 02cd8de4c51cbe8e9d8e1febed3827b4aca8d068 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:49:11 +0800 Subject: [PATCH 6/8] Add None conversion for tube rack etc. --- unilabos/resources/container.py | 2 +- unilabos/resources/resource_tracker.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/unilabos/resources/container.py b/unilabos/resources/container.py index f977244..fe19bac 100644 --- a/unilabos/resources/container.py +++ b/unilabos/resources/container.py @@ -27,7 +27,7 @@ class RegularContainer(Container): def get_regular_container(name="container"): r = RegularContainer(name=name) r.category = "container" - return RegularContainer(name=name) + return r # # class RegularContainer(object): diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index d93cb4e..ea8e5cf 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -1,7 +1,7 @@ import inspect import traceback import uuid -from pydantic import BaseModel, field_serializer, field_validator +from pydantic import BaseModel, field_serializer, field_validator, ValidationError from pydantic import Field from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union @@ -160,7 +160,11 @@ class ResourceDictInstance(object): "depth": content["config"].get("size_z", 0), } content["pose"] = pose - return ResourceDictInstance(ResourceDict.model_validate(content)) + try: + res_dict = ResourceDict.model_validate(content) + return ResourceDictInstance(res_dict) + except ValidationError as err: + raise err def get_plr_nested_dict(self) -> Dict[str, Any]: """获取资源实例的嵌套字典表示""" @@ -339,6 +343,8 @@ class ResourceTreeSet(object): } if source in replace_info: return replace_info[source] + elif source is None: + return "" else: print("转换pylabrobot的时候,出现未知类型", source) return source From 31c9f9a17236d9fc1b349f44e8df0156e3c622fc Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:21:37 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E7=89=A9=E6=96=99=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B9=9F=E6=98=AF=E7=94=A8=E7=88=B6=E8=8A=82=E7=82=B9=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E6=8A=A5=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unilabos/app/ws_client.py | 6 +- unilabos/resources/resource_tracker.py | 2 +- unilabos/ros/nodes/base_device_node.py | 185 ++++++++++++++---------- unilabos/ros/nodes/presets/host_node.py | 9 +- 4 files changed, 114 insertions(+), 88 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 4c87d36..ea8c8e7 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -848,7 +848,7 @@ class MessageProcessor: device_action_groups[key_add].append(item["uuid"]) logger.info( - f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}" + f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}" ) else: # 正常update @@ -863,11 +863,11 @@ class MessageProcessor: device_action_groups[key] = [] device_action_groups[key].append(item["uuid"]) - logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}") + logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}") # 为每个(device_id, action)创建独立的更新线程 for (device_id, actual_action), items in device_action_groups.items(): - logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}") + logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}") def _notify_resource_tree(dev_id, act, item_list): try: diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index ea8e5cf..afd4c3b 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -1143,7 +1143,7 @@ class DeviceNodeResourceTracker(object): for key in keys_to_remove: self.resource2parent_resource.pop(key, None) - logger.debug(f"成功移除资源: {resource}") + logger.trace(f"[ResourceTracker] 成功移除资源: {resource}") return True def clear_resource(self): diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 8dd4a28..c99df6c 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -656,61 +656,71 @@ class BaseROS2DeviceNode(Node, Generic[T]): def transfer_to_new_resource( self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any] - ): + ) -> Optional["ResourcePLR"]: 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: + if not parent_uuid: + self.lab_logger().warning( + f"物料{plr_resource} parent未知,挂载到当前节点下,额外参数:{additional_add_params}" + ) + return None + if parent_uuid == self.uuid: + self.lab_logger().warning( + f"物料{plr_resource}请求挂载到{self.identifier},额外参数:{additional_add_params}" + ) + return None + 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: + # 特殊兼容所有plr的物料的assign方法,和create_resource append_resource后期同步 + additional_params = {} + extra = getattr(plr_resource, "unilabos_extra", {}) + if len(extra): + self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra)) + if "update_resource_site" in extra: + additional_add_params["site"] = extra["update_resource_site"] + site = additional_add_params.get("site", None) + spec = inspect.signature(parent_resource.assign_child_resource) + if "spot" in spec.parameters: + ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering") + if ordering_dict: + site = list(ordering_dict.keys()).index(site) + additional_params["spot"] = site + old_parent = plr_resource.parent + if old_parent is not None: + # plr并不支持同一个deck的加载和卸载 + self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载") + old_parent.unassign_child_resource(plr_resource) self.lab_logger().warning( - f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在" + f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}" ) - else: - try: - # 特殊兼容所有plr的物料的assign方法,和create_resource append_resource后期同步 - additional_params = {} - extra = getattr(plr_resource, "unilabos_extra", {}) - if len(extra): - self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra)) - if "update_resource_site" in extra: - additional_add_params["site"] = extra["update_resource_site"] - site = additional_add_params.get("site", None) - spec = inspect.signature(parent_resource.assign_child_resource) - if "spot" in spec.parameters: - ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering") - if ordering_dict: - site = list(ordering_dict.keys()).index(site) - additional_params["spot"] = site - old_parent = plr_resource.parent - if old_parent is not None: - # plr并不支持同一个deck的加载和卸载 - 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}" - ) - # ⭐ 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 + # ⭐ 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) + parent_resource.assign_child_resource(plr_resource, location=None, **additional_params) - func = getattr(self.driver_instance, "resource_tree_transfer", None) - if callable(func): - # 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了) - func(old_parent, plr_resource, parent_resource) - 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_transfer", None) + if callable(func): + # 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了) + func(old_parent, plr_resource, parent_resource) + return parent_resource + 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()}" + ) async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response): """ @@ -725,7 +735,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): def _handle_add( plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> Tuple[Dict[str, Any], List[ResourcePLR]]: """ 处理资源添加操作的内部函数 @@ -737,15 +747,20 @@ class BaseROS2DeviceNode(Node, Generic[T]): Returns: 操作结果字典 """ + parents = [] # 放的是被变更的物料 / 被变更的物料父级 for plr_resource, tree in zip(plr_resources, tree_set.trees): self.resource_tracker.add_resource(plr_resource) - self.transfer_to_new_resource(plr_resource, tree, additional_add_params) + parent = self.transfer_to_new_resource(plr_resource, tree, additional_add_params) + if parent is not None: + parents.append(parent) + else: + parents.append(plr_resource) func = getattr(self.driver_instance, "resource_tree_add", None) if callable(func): func(plr_resources) - return {"success": True, "action": "add"} + return {"success": True, "action": "add"}, parents def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]: """ @@ -780,11 +795,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): if plr_resource.parent is not None: plr_resource.parent.unassign_child_resource(plr_resource) self.resource_tracker.remove_resource(plr_resource) - self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") + self.lab_logger().info(f"[资源同步] 移除物料 {plr_resource} 及其子节点") for other_plr_resource in other_plr_resources: self.resource_tracker.remove_resource(other_plr_resource) - self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") + self.lab_logger().info(f"[资源同步] 移除物料 {other_plr_resource} 及其子节点") return { "success": True, @@ -816,11 +831,16 @@ class BaseROS2DeviceNode(Node, Generic[T]): original_instance: ResourcePLR = self.resource_tracker.figure_resource( {"uuid": tree.root_node.res_content.uuid}, try_mode=False ) + original_parent_resource = original_instance.parent + original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) + target_parent_resource_uuid = tree.root_node.res_content.uuid_parent + not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None + old_name = original_instance.name + new_name = plr_resource.name + parent_appended = False - # Update操作中包含改名:需要先remove再add - if original_instance.name != plr_resource.name: - old_name = original_instance.name - new_name = plr_resource.name + # Update操作中包含改名:需要先remove再add,这里更新父节点即可 + if not not_same_parent and old_name != new_name: self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") # 收集所有相关的uuid(包括子节点) @@ -829,12 +849,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): _handle_add([original_instance], tree_set, additional_add_params) self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") + original_instances.append(original_parent_resource) + parent_appended = True # 常规更新:不涉及改名 - original_parent_resource = original_instance.parent - original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None) - target_parent_resource_uuid = tree.root_node.res_content.uuid_parent - self.lab_logger().info( f"物料{original_instance} 原始父节点{original_parent_resource_uuid} " f"目标父节点{target_parent_resource_uuid} 更新" @@ -845,13 +863,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501 # 如果父节点变化,需要重新挂载 - if ( - original_parent_resource_uuid != target_parent_resource_uuid - and original_parent_resource is not None - ): - self.transfer_to_new_resource(original_instance, tree, additional_add_params) + if not_same_parent: + parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params) + original_instances.append(parent) + parent_appended = True else: - # 判断是否变更了resource_site + # 判断是否变更了resource_site,重新登记 target_site = original_instance.unilabos_extra.get("update_resource_site") sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else [] @@ -859,7 +876,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): site_index = sites.index(original_instance) site_name = site_names[site_index] if site_name != target_site: - self.transfer_to_new_resource(original_instance, tree, additional_add_params) + parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params) + if parent is not None: + original_instances.append(parent) + parent_appended = True # 加载状态 original_instance.load_all_state(states) @@ -867,7 +887,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.lab_logger().info( f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个" ) - original_instances.append(original_instance) + if not parent_appended: + original_instances.append(original_instance) # 调用driver的update回调 func = getattr(self.driver_instance, "resource_tree_update", None) @@ -884,8 +905,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): action = i.get("action") # remove, add, update resources_uuid: List[str] = i.get("data") # 资源数据 additional_add_params = i.get("additional_add_params", {}) # 额外参数 - self.lab_logger().info( - f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}" + self.lab_logger().trace( + f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}" ) tree_set = None if action in ["add", "update"]: @@ -897,8 +918,13 @@ class BaseROS2DeviceNode(Node, Generic[T]): if tree_set is None: raise ValueError("tree_set不能为None") plr_resources = tree_set.to_plr_resources() - result = _handle_add(plr_resources, tree_set, additional_add_params) - new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources) + result, parents = _handle_add(plr_resources, tree_set, additional_add_params) + parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None] + de_dupe_parents = list(set(parents)) + new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重 + for tree in new_tree_set.trees: + if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node": + tree.root_node.res_content.parent_uuid = self.uuid r = SerialCommand.Request() r.command = json.dumps( {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 @@ -917,7 +943,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) if not BasicConfig.no_update_feedback: - new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) + new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) # 去重 + for tree in new_tree_set.trees: + if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node": + tree.root_node.res_content.parent_uuid = self.uuid r = SerialCommand.Request() r.command = json.dumps( {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 @@ -937,15 +966,15 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 返回处理结果 result_json = {"results": results, "total": len(data)} res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder) - self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations") + # self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations") except json.JSONDecodeError as e: error_msg = f"Invalid JSON format: {str(e)}" - self.lab_logger().error(f"[Resource Tree Update] {error_msg}") + self.lab_logger().error(f"[资源同步] {error_msg}") res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) except Exception as e: error_msg = f"Unexpected error: {str(e)}" - self.lab_logger().error(f"[Resource Tree Update] {error_msg}") + self.lab_logger().error(f"[资源同步] {error_msg}") self.lab_logger().error(traceback.format_exc()) res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 0f6b697..cf9fd70 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -362,8 +362,7 @@ class HostNode(BaseROS2DeviceNode): request.command = "" future = sclient.call_async(request) # Use timeout for result as well - future.result(timeout_sec=5.0) - self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}") + future.result() except Exception as e: # Gracefully handle destruction during shutdown if "destruction was requested" in str(e) or self._shutting_down: @@ -1515,7 +1514,7 @@ class HostNode(BaseROS2DeviceNode): # 构建服务地址 srv_address = f"/srv{namespace}/s2c_resource_tree" - self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation") + self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------") # 创建服务客户端 sclient = self.create_client(SerialCommand, srv_address) @@ -1550,9 +1549,7 @@ class HostNode(BaseROS2DeviceNode): time.sleep(0.05) response = future.result() - self.lab_logger().info( - f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}" - ) + self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------") return True except Exception as e: From 9dfd58e9af00b3525d1b4e0cc5c17abe820d9758 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:17:29 +0800 Subject: [PATCH 8/8] fix parent_uuid fetch when bind_parent_id == node_name --- unilabos/ros/nodes/base_device_node.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index c99df6c..efe287d 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -392,9 +392,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): parent_resource = self.resource_tracker.figure_resource( {"name": bind_parent_id} ) - for r in rts.root_nodes: - # noinspection PyUnresolvedReferences - r.res_content.parent_uuid = parent_resource.unilabos_uuid + for r in rts.root_nodes: + # noinspection PyUnresolvedReferences + r.res_content.parent_uuid = parent_resource.unilabos_uuid + else: + for r in rts.root_nodes: + r.res_content.parent_uuid = self.uuid if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer): # noinspection PyTypeChecker