diff --git a/.conda/recipe.yaml b/.conda/recipe.yaml index 8dccb80..2b041c8 100644 --- a/.conda/recipe.yaml +++ b/.conda/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: 0.10.14 + version: 0.10.15 source: path: ../unilabos 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/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 88b6827..6d32908 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.14 + version: 0.10.15 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 45a0098..be3f1a1 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.14" + version: "0.10.15" source: path: ../.. diff --git a/setup.py b/setup.py index 290a9a2..b6ae5ed 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.14', + version='0.10.15', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index a9f358e..d5ac10a 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.14" +__version__ = "0.10.15" diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6738b2f..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): @@ -156,6 +161,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 +307,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]) @@ -497,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 23f139d..4c87d36 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 @@ -488,7 +488,16 @@ class MessageProcessor: async for message in self.websocket: try: data = json.loads(message) - await self._process_message(data) + 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(message_type, message_data) + else: + 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: @@ -554,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: @@ -571,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 == "session_id": + # self.session_id = message_data.get("session_id") + # logger.info(f"[MessageProcessor] Session ID: {self.session_id}") + elif message_type == "request_restart": + await self._handle_request_restart(message_data) else: logger.debug(f"[MessageProcessor] Unknown message type: {message_type}") @@ -890,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/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/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index 31d18c1..531f6ef 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -30,11 +30,10 @@ from pylabrobot.liquid_handling.standard import ( ResourceMove, ResourceDrop, ) -from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack, create_homogeneous_resources, create_ordered_items_2d +from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, TubeRack, PlateAdapter -from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn -from unilabos.resources.itemized_carrier import ItemizedCarrier -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode +from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class PRCXIError(RuntimeError): @@ -123,61 +122,11 @@ class PRCXI9300Plate(Plate): model: Optional[str] = None, material_info: Optional[Dict[str, Any]] = None, **kwargs): - # 如果 ordered_items 不为 None,直接使用 - if ordered_items is not None: - items = ordered_items - elif ordering is not None: - # 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) - # 如果是字符串,说明这是位置名称,需要让 Plate 自己创建 Well 对象 - # 我们只传递位置信息(键),不传递值,使用 ordering 参数 - if ordering and isinstance(next(iter(ordering.values()), None), str): - # ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict - # 传递 ordering 参数而不是 ordered_items,让 Plate 自己创建 Well 对象 - items = None - # 使用 ordering 参数,只包含位置信息(键) - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - else: - # ordering 的值是对象(可能是 Well 对象),检查是否有有效的 location - # 如果是反序列化过程,Well 对象可能没有正确的 location,需要让 Plate 重新创建 - sample_value = next(iter(ordering.values()), None) - if sample_value is not None and hasattr(sample_value, 'location'): - # 如果是 Well 对象但 location 为 None,说明是反序列化过程 - # 让 Plate 自己创建 Well 对象 - if sample_value.location is None: - items = None - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - else: - # Well 对象有有效的 location,可以直接使用 - items = ordering - ordering_param = None - elif sample_value is None: - # ordering 的值都是 None,让 Plate 自己创建 Well 对象 - items = None - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - else: - # 其他情况,直接使用 - items = ordering - ordering_param = None - else: - items = None - ordering_param = collections.OrderedDict() # 提供空的 ordering - - # 根据情况传递不同的参数 - if items is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) - elif ordering_param is not None: - # 传递 ordering 参数,让 Plate 自己创建 Well 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - category=category, - model=model, **kwargs) - else: - super().__init__(name, size_x, size_y, size_z, - category=category, - model=model, **kwargs) + items = ordered_items if ordered_items is not None else ordering + super().__init__(name, size_x, size_y, size_z, + ordered_items=items, + category=category, + model=model, **kwargs) self._unilabos_state = {} if material_info: @@ -224,50 +173,11 @@ class PRCXI9300TipRack(TipRack): model: Optional[str] = None, material_info: Optional[Dict[str, Any]] = None, **kwargs): - # 如果 ordered_items 不为 None,直接使用 - if ordered_items is not None: - items = ordered_items - elif ordering is not None: - # 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) - # 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象 - # 我们只传递位置信息(键),不传递值,使用 ordering 参数 - if ordering and isinstance(next(iter(ordering.values()), None), str): - # ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict - # 传递 ordering 参数而不是 ordered_items,让 TipRack 自己创建 Tip 对象 - items = None - # 使用 ordering 参数,只包含位置信息(键) - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - else: - # ordering 的值已经是对象,需要过滤掉 None 值 - # 只保留有效的对象,用于 ordered_items 参数 - valid_items = {k: v for k, v in ordering.items() if v is not None} - if valid_items: - items = valid_items - ordering_param = None - else: - # 如果没有有效对象,使用 ordering 参数 - items = None - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - else: - items = None - ordering_param = None - - # 根据情况传递不同的参数 - if items is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) - elif ordering_param is not None: - # 传递 ordering 参数,让 TipRack 自己创建 Tip 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - category=category, - model=model, **kwargs) - else: - super().__init__(name, size_x, size_y, size_z, - category=category, - model=model, **kwargs) + items = ordered_items if ordered_items is not None else ordering + super().__init__(name, size_x, size_y, size_z, + ordered_items=items, + category=category, + model=model, **kwargs) self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info @@ -368,55 +278,12 @@ class PRCXI9300TubeRack(TubeRack): material_info: Optional[Dict[str, Any]] = None, **kwargs): - # 如果 ordered_items 不为 None,直接使用 - if ordered_items is not None: - items_to_pass = ordered_items - ordering_param = None - elif ordering is not None: - # 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) - # 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象 - # 我们只传递位置信息(键),不传递值,使用 ordering 参数 - if ordering and isinstance(next(iter(ordering.values()), None), str): - # ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict - # 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象 - items_to_pass = None - # 使用 ordering 参数,只包含位置信息(键) - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - else: - # ordering 的值已经是对象,需要过滤掉 None 值 - # 只保留有效的对象,用于 ordered_items 参数 - valid_items = {k: v for k, v in ordering.items() if v is not None} - if valid_items: - items_to_pass = valid_items - ordering_param = None - else: - # 如果没有有效对象,使用 ordering 参数 - items_to_pass = None - ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) - elif items is not None: - # 兼容旧的 items 参数 - items_to_pass = items - ordering_param = None - else: - items_to_pass = None - ordering_param = None - - # 根据情况传递不同的参数 - if items_to_pass is not None: - super().__init__(name, size_x, size_y, size_z, - ordered_items=items_to_pass, - model=model, - **kwargs) - elif ordering_param is not None: - # 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象 - super().__init__(name, size_x, size_y, size_z, - ordering=ordering_param, - model=model, - **kwargs) - else: - super().__init__(name, size_x, size_y, size_z, - model=model, - **kwargs) + # 兼容处理:PLR 的 TubeRack 构造函数可能接受 items 或 ordered_items + items_to_pass = items if items is not None else ordered_items + super().__init__(name, size_x, size_y, size_z, + ordered_items=ordered_items, + model=model, + **kwargs) self._unilabos_state = {} if material_info: @@ -903,43 +770,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract): async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0): return await super().move_to(well, dis_to_top, channel) - async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): - return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait) - - async def heater_action(self, temperature: float, time: int): - return await self._unilabos_backend.heater_action(temperature, time) - async def move_plate( - self, - plate: Plate, - to: Resource, - intermediate_locations: Optional[List[Coordinate]] = None, - pickup_offset: Coordinate = Coordinate.zero(), - destination_offset: Coordinate = Coordinate.zero(), - drop_direction: GripDirection = GripDirection.FRONT, - pickup_direction: GripDirection = GripDirection.FRONT, - pickup_distance_from_top: float = 13.2 - 3.33, - **backend_kwargs, - ): - - res = await super().move_plate( - plate, - to, - intermediate_locations, - pickup_offset, - destination_offset, - drop_direction, - pickup_direction, - pickup_distance_from_top, - target_plate_number = to, - **backend_kwargs, - ) - plate.unassign() - to.assign_child_resource(plate, location=Coordinate(0, 0, 0)) - ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - "resources": [self.deck] - }) - return res - class PRCXI9300Backend(LiquidHandlerBackend): """PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。 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/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 489b4e5..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 @@ -790,7 +792,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): def _handle_update( plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] - ) -> Dict[str, Any]: + ) -> Tuple[Dict[str, Any], List[ResourcePLR]]: """ 处理资源更新操作的内部函数 @@ -802,6 +804,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): Returns: 操作结果字典 """ + original_instances = [] for plr_resource, tree in zip(plr_resources, tree_set.trees): if isinstance(plr_resource, ResourceDictInstance): self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新") @@ -861,13 +864,14 @@ 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) # 调用driver的update回调 func = getattr(self.driver_instance, "resource_tree_update", None) if callable(func): - func(plr_resources) + func(original_instances) - return {"success": True, "action": "update"} + return {"success": True, "action": "update"}, original_instances try: data = json.loads(req.command) @@ -908,14 +912,15 @@ class BaseROS2DeviceNode(Node, Generic[T]): plr_resources.append(tree.root_node) else: plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) - new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources) - result = _handle_update(plr_resources, tree_set, additional_add_params) - 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}") + 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) + 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) 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"]) diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index 02beacf..b9c2632 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.14 + 0.10.15 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln