Merge branch 'dev' into prcix9320

This commit is contained in:
zhangshixiang
2026-01-12 14:31:56 +08:00
parent 31e8d065c4
commit de7fbe7ac8
18 changed files with 436 additions and 1239 deletions

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: 0.10.14 version: 0.10.15
source: source:
path: ../unilabos path: ../unilabos

View File

@@ -24,7 +24,7 @@ extensions = [
"sphinx.ext.autodoc", "sphinx.ext.autodoc",
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
"sphinx_rtd_theme", "sphinx_rtd_theme",
"sphinxcontrib.mermaid" "sphinxcontrib.mermaid",
] ]
source_suffix = { source_suffix = {
@@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme"
# sphinx-book-theme 主题选项 # sphinx-book-theme 主题选项
html_theme_options = { html_theme_options = {
"repository_url": "https://github.com/用户名/Uni-Lab", "repository_url": "https://github.com/deepmodeling/Uni-Lab-OS",
"use_repository_button": True, "use_repository_button": True,
"use_issues_button": True, "use_issues_button": True,
"use_edit_page_button": True, "use_edit_page_button": True,

File diff suppressed because it is too large Load Diff

View File

@@ -12,3 +12,7 @@ sphinx-copybutton>=0.5.0
# 用于自动摘要生成 # 用于自动摘要生成
sphinx-autobuild>=2024.2.4 sphinx-autobuild>=2024.2.4
# 用于PDF导出 (rinohtype方案纯Python无需LaTeX)
rinohtype>=0.5.4
sphinx-simplepdf>=1.6.0

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.10.14 version: 0.10.15
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
target_directory: src target_directory: src

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.10.14" version: "0.10.15"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.10.14', version='0.10.15',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -1 +1 @@
__version__ = "0.10.14" __version__ = "0.10.15"

View File

@@ -19,6 +19,11 @@ if unilabos_dir not in sys.path:
from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.config.config import load_config, BasicConfig, HTTPConfig 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): def load_config_from_file(config_path):
@@ -156,6 +161,11 @@ def parse_args():
default=False, default=False,
help="Complete registry information", help="Complete registry information",
) )
parser.add_argument(
"--no_update_feedback",
action="store_true",
help="Disable sending update feedback to server",
)
# workflow upload subcommand # workflow upload subcommand
workflow_parser = subparsers.add_parser( workflow_parser = subparsers.add_parser(
"workflow_upload", "workflow_upload",
@@ -297,6 +307,7 @@ def main():
BasicConfig.is_host_mode = not args_dict.get("is_slave", False) BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", 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" BasicConfig.communication_protocol = "websocket"
machine_name = os.popen("hostname").read().strip() machine_name = os.popen("hostname").read().strip()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
@@ -497,13 +508,19 @@ def main():
time.sleep(1) time.sleep(1)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server( restart_requested = start_server(
open_browser=not args_dict["disable_browser"], open_browser=not args_dict["disable_browser"],
port=BasicConfig.port, port=BasicConfig.port,
) )
if restart_requested:
print_status("[Main] Restart requested, cleaning up...", "info")
cleanup_for_restart()
return
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(
# 启动服务器默认支持WebSocket触发重启
restart_requested = start_server(
open_browser=not args_dict["disable_browser"], open_browser=not args_dict["disable_browser"],
port=BasicConfig.port, port=BasicConfig.port,
) )

144
unilabos/app/utils.py Normal file
View File

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

View File

@@ -6,7 +6,6 @@ Web服务器模块
import webbrowser import webbrowser
import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import Response from starlette.responses import Response
@@ -96,7 +95,7 @@ def setup_server() -> FastAPI:
return app 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: 服务器主机 host: 服务器主机
port: 服务器端口 port: 服务器端口
open_browser: 是否自动打开浏览器 open_browser: 是否自动打开浏览器
Returns:
bool: True if restart was requested, False otherwise
""" """
import threading
import time
from uvicorn import Config, Server
# 设置服务器 # 设置服务器
setup_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}") 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
# 当脚本直接运行时启动服务器 # 当脚本直接运行时启动服务器

View File

@@ -359,7 +359,7 @@ class MessageProcessor:
self.device_manager = device_manager self.device_manager = device_manager
self.queue_processor = None # 延迟设置 self.queue_processor = None # 延迟设置
self.websocket_client = None # 延迟设置 self.websocket_client = None # 延迟设置
self.session_id = "" self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
# WebSocket连接 # WebSocket连接
self.websocket = None self.websocket = None
@@ -488,7 +488,16 @@ class MessageProcessor:
async for message in self.websocket: async for message in self.websocket:
try: try:
data = json.loads(message) 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: except json.JSONDecodeError:
logger.error(f"[MessageProcessor] Invalid JSON received: {message}") logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
except Exception as e: except Exception as e:
@@ -554,11 +563,8 @@ class MessageProcessor:
finally: finally:
logger.debug("[MessageProcessor] Send handler stopped") 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}") logger.debug(f"[MessageProcessor] Processing message: {message_type}")
try: try:
@@ -571,16 +577,19 @@ class MessageProcessor:
elif message_type == "cancel_action" or message_type == "cancel_task": elif message_type == "cancel_action" or message_type == "cancel_task":
await self._handle_cancel_action(message_data) await self._handle_cancel_action(message_data)
elif message_type == "add_material": elif message_type == "add_material":
# noinspection PyTypeChecker
await self._handle_resource_tree_update(message_data, "add") await self._handle_resource_tree_update(message_data, "add")
elif message_type == "update_material": elif message_type == "update_material":
# noinspection PyTypeChecker
await self._handle_resource_tree_update(message_data, "update") await self._handle_resource_tree_update(message_data, "update")
elif message_type == "remove_material": elif message_type == "remove_material":
# noinspection PyTypeChecker
await self._handle_resource_tree_update(message_data, "remove") await self._handle_resource_tree_update(message_data, "remove")
elif message_type == "session_id": # elif message_type == "session_id":
self.session_id = message_data.get("session_id") # self.session_id = message_data.get("session_id")
logger.info(f"[MessageProcessor] Session ID: {self.session_id}") # logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
elif message_type == "request_reload": elif message_type == "request_restart":
await self._handle_request_reload(message_data) await self._handle_request_restart(message_data)
else: else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}") logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -890,19 +899,48 @@ class MessageProcessor:
) )
thread.start() 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") 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: if self.websocket_client:
self.websocket_client.publish_host_ready() await self.websocket_client.send_message({
logger.info("[MessageProcessor] Re-sent host_node_ready after reload request") "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( 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 self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int

View File

@@ -16,6 +16,7 @@ class BasicConfig:
upload_registry = False upload_registry = False
machine_name = "undefined" machine_name = "undefined"
vis_2d_enable = False vis_2d_enable = False
no_update_feedback = False
enable_resource_load = True enable_resource_load = True
communication_protocol = "websocket" communication_protocol = "websocket"
startup_json_path = None # 填写绝对路径 startup_json_path = None # 填写绝对路径

View File

@@ -30,11 +30,10 @@ from pylabrobot.liquid_handling.standard import (
ResourceMove, ResourceMove,
ResourceDrop, 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.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
from unilabos.resources.itemized_carrier import ItemizedCarrier from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
class PRCXIError(RuntimeError): class PRCXIError(RuntimeError):
@@ -123,61 +122,11 @@ class PRCXI9300Plate(Plate):
model: Optional[str] = None, model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None, material_info: Optional[Dict[str, Any]] = None,
**kwargs): **kwargs):
# 如果 ordered_items 不为 None直接使用 items = ordered_items if ordered_items is not None else ordering
if ordered_items is not None: super().__init__(name, size_x, size_y, size_z,
items = ordered_items ordered_items=items,
elif ordering is not None: category=category,
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) model=model, **kwargs)
# 如果是字符串,说明这是位置名称,需要让 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)
self._unilabos_state = {} self._unilabos_state = {}
if material_info: if material_info:
@@ -224,50 +173,11 @@ class PRCXI9300TipRack(TipRack):
model: Optional[str] = None, model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None, material_info: Optional[Dict[str, Any]] = None,
**kwargs): **kwargs):
# 如果 ordered_items 不为 None直接使用 items = ordered_items if ordered_items is not None else ordering
if ordered_items is not None: super().__init__(name, size_x, size_y, size_z,
items = ordered_items ordered_items=items,
elif ordering is not None: category=category,
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) model=model, **kwargs)
# 如果是字符串,说明这是位置名称,需要让 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)
self._unilabos_state = {} self._unilabos_state = {}
if material_info: if material_info:
self._unilabos_state["Material"] = material_info self._unilabos_state["Material"] = material_info
@@ -368,55 +278,12 @@ class PRCXI9300TubeRack(TubeRack):
material_info: Optional[Dict[str, Any]] = None, material_info: Optional[Dict[str, Any]] = None,
**kwargs): **kwargs):
# 如果 ordered_items 不为 None直接使用 # 兼容处理PLR 的 TubeRack 构造函数可能接受 items 或 ordered_items
if ordered_items is not None: items_to_pass = items if items is not None else ordered_items
items_to_pass = ordered_items super().__init__(name, size_x, size_y, size_z,
ordering_param = None ordered_items=ordered_items,
elif ordering is not None: model=model,
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) **kwargs)
# 如果是字符串,说明这是位置名称,需要让 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)
self._unilabos_state = {} self._unilabos_state = {}
if material_info: 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): 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) 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): class PRCXI9300Backend(LiquidHandlerBackend):
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。 """PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。

View File

@@ -1,4 +1,5 @@
import json import json
# from nt import device_encoding # from nt import device_encoding
import threading import threading
import time import time
@@ -55,7 +56,11 @@ def main(
) -> None: ) -> 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() executor = rclpy.__executor = MultiThreadedExecutor()
# 创建主机节点 # 创建主机节点
host_node = HostNode( host_node = HostNode(
@@ -88,7 +93,7 @@ def main(
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
# lh_joint_pub = LiquidHandlerJointPublisher( # lh_joint_pub = LiquidHandlerJointPublisher(
# resources_config=resources_list, resource_tracker=host_node.resource_tracker # resources_config=resources_list, resource_tracker=host_node.resource_tracker
# ) # )
executor.add_node(resource_mesh_manager) executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher) executor.add_node(joint_republisher)
# executor.add_node(lh_joint_pub) # executor.add_node(lh_joint_pub)

View File

@@ -20,6 +20,8 @@ from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service from rclpy.service import Service
from unilabos_msgs.action import SendCmd from unilabos_msgs.action import SendCmd
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response 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.utils.decorator import get_topic_config, get_all_subscriptions
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
@@ -790,7 +792,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
def _handle_update( def _handle_update(
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] 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: Returns:
操作结果字典 操作结果字典
""" """
original_instances = []
for plr_resource, tree in zip(plr_resources, tree_set.trees): for plr_resource, tree in zip(plr_resources, tree_set.trees):
if isinstance(plr_resource, ResourceDictInstance): if isinstance(plr_resource, ResourceDictInstance):
self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新") self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新")
@@ -861,13 +864,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info( self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}" f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}"
) )
original_instances.append(original_instance)
# 调用driver的update回调 # 调用driver的update回调
func = getattr(self.driver_instance, "resource_tree_update", None) func = getattr(self.driver_instance, "resource_tree_update", None)
if callable(func): if callable(func):
func(plr_resources) func(original_instances)
return {"success": True, "action": "update"} return {"success": True, "action": "update"}, original_instances
try: try:
data = json.loads(req.command) data = json.loads(req.command)
@@ -908,14 +912,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
plr_resources.append(tree.root_node) plr_resources.append(tree.root_node)
else: else:
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources) result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
result = _handle_update(plr_resources, tree_set, additional_add_params) if not BasicConfig.no_update_feedback:
r = SerialCommand.Request() new_tree_set = ResourceTreeSet.from_plr_resources(original_instances)
r.command = json.dumps( r = SerialCommand.Request()
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 r.command = json.dumps(
response: SerialCommand_Response = await self._resource_clients[ {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
"c2s_update_resource_tree"].call_async(r) # type: ignore response: SerialCommand_Response = await self._resource_clients[
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}") "c2s_update_resource_tree"].call_async(r) # type: ignore
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
results.append(result) results.append(result)
elif action == "remove": elif action == "remove":
result = _handle_remove(resources_uuid) result = _handle_remove(resources_uuid)

View File

@@ -70,6 +70,8 @@ class HostNode(BaseROS2DeviceNode):
_instance: ClassVar[Optional["HostNode"]] = None _instance: ClassVar[Optional["HostNode"]] = None
_ready_event: ClassVar[threading.Event] = threading.Event() _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( _device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict(
DeviceActionStatus DeviceActionStatus
) )
@@ -81,6 +83,48 @@ class HostNode(BaseROS2DeviceNode):
return cls._instance return cls._instance
return None 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__( def __init__(
self, self,
device_id: str, device_id: str,
@@ -294,12 +338,37 @@ class HostNode(BaseROS2DeviceNode):
bridge.publish_host_ready() bridge.publish_host_ready()
self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}") self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}")
def _send_re_register(self, sclient): def _send_re_register(self, sclient, device_namespace: str):
sclient.wait_for_service() """
request = SerialCommand.Request() Send re-register command to a device. This is a one-time operation.
request.command = ""
future = sclient.call_async(request) Args:
response = future.result() 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: def _discover_devices(self) -> None:
""" """
@@ -331,23 +400,27 @@ class HostNode(BaseROS2DeviceNode):
self._create_action_clients_for_device(device_id, namespace) self._create_action_clients_for_device(device_id, namespace)
self._online_devices.add(device_key) self._online_devices.add(device_key)
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device") sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
threading.Thread( t = threading.Thread(
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient, namespace),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}", 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: elif device_key not in self._online_devices:
# 设备重新上线 # 设备重新上线
self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}") self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}")
self._online_devices.add(device_key) self._online_devices.add(device_key)
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device") sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
threading.Thread( t = threading.Thread(
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient, namespace),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}", 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 offline_devices = self._online_devices - current_devices
@@ -705,13 +778,14 @@ class HostNode(BaseROS2DeviceNode):
raise ValueError(f"ActionClient {action_id} not found.") raise ValueError(f"ActionClient {action_id} not found.")
action_client: ActionClient = self._action_clients[action_id] action_client: ActionClient = self._action_clients[action_id]
# 遍历action_kwargs下的所有子dict将"sample_uuid"的值赋给"sample_id" # 遍历action_kwargs下的所有子dict将"sample_uuid"的值赋给"sample_id"
def assign_sample_id(obj): def assign_sample_id(obj):
if isinstance(obj, dict): if isinstance(obj, dict):
if "sample_uuid" in obj: if "sample_uuid" in obj:
obj["sample_id"] = obj["sample_uuid"] obj["sample_id"] = obj["sample_uuid"]
obj.pop("sample_uuid") obj.pop("sample_uuid")
for k,v in obj.items(): for k, v in obj.items():
if k != "unilabos_extra": if k != "unilabos_extra":
assign_sample_id(v) assign_sample_id(v)
elif isinstance(obj, list): 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.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted")
self._goals[item.job_id] = goal_handle self._goals[item.job_id] = goal_handle
goal_future = goal_handle.get_result_async() goal_future = goal_handle.get_result_async()
goal_future.add_done_callback( goal_future.add_done_callback(lambda f: self.get_result_callback(item, action_id, f))
lambda f: self.get_result_callback(item, action_id, f)
)
goal_future.result() goal_future.result()
def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None: def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None:
@@ -1167,6 +1239,7 @@ class HostNode(BaseROS2DeviceNode):
""" """
try: try:
from unilabos.app.web import http_client from unilabos.app.web import http_client
data = json.loads(request.command) data = json.loads(request.command)
if "uuid" in data and data["uuid"] is not None: if "uuid" in data and data["uuid"] is not None:
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"]) http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])

View File

@@ -2,7 +2,7 @@
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3"> <package format="3">
<name>unilabos_msgs</name> <name>unilabos_msgs</name>
<version>0.10.14</version> <version>0.10.15</version>
<description>ROS2 Messages package for unilabos devices</description> <description>ROS2 Messages package for unilabos devices</description>
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer> <maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer> <maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>