Merge branch 'main' into pr/39

This commit is contained in:
Junhan Chang
2025-06-07 23:49:33 +08:00
40 changed files with 814 additions and 342 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ __pycache__/
.vscode .vscode
*.py[cod] *.py[cod]
*$py.class *$py.class
service
# C extensions # C extensions
*.so *.so

View File

@@ -4,83 +4,90 @@
# Uni-Lab-OS # Uni-Lab-OS
<!-- Language switcher -->
**English** | [中文](README_zh.md)
[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members) [![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues) [![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues)
[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE) [![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab 操作系统是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。 Uni-Lab-OS is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows.
## 核心特点 ## 🏆 Competition
- 多设备集成管理 Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.dp.tech/competitions/1451645258) to explore automated synthesis with Uni-Lab-OS!
- 自动化实验流程
- 云端连接能力
- 灵活的配置系统
- 支持多种实验协议
## 文档 ## Key Features
详细文档可在以下位置找到: - Multi-device integration management
- Automated experimental workflows
- Cloud connectivity capabilities
- Flexible configuration system
- Support for multiple experimental protocols
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/) ## Documentation
## 快速开始 Detailed documentation can be found at:
1. 配置Conda环境 - [Online Documentation](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件: ## Quick Start
1. Configure Conda Environment
Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
```bash ```bash
# 创建新环境 # Create new environment
mamba env create -f unilabos-[YOUR_OS].yaml mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab mamba activate unilab
# 或更新现有环境 # Or update existing environment
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, `osx-arm64` # Where `[YOUR_OS]` can be `win64`, `linux-64`, `osx-64`, or `osx-arm64`.
conda env update --file unilabos-[YOUR_OS].yml -n 环境名 conda env update --file unilabos-[YOUR_OS].yml -n environment_name
# 现阶段,需要安装 `unilabos_msgs` # Currently, you need to install the `unilabos_msgs` package
# 可以前往 Release 页面下载系统对应的包进行安装 # You can download the system-specific package from the Release page
conda install ros-humble-unilabos-msgs-0.9.0-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.1-xxxxx.tar.bz2
# 安装PyLabRobot等前置 # Install PyLabRobot and other prerequisites
git clone https://github.com/PyLabRobot/pylabrobot plr_repo git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo cd plr_repo
pip install .[opentrons] pip install .[opentrons]
``` ```
2. 安装 Uni-Lab-OS: 2. Install Uni-Lab-OS:
```bash ```bash
# 克隆仓库 # Clone the repository
git clone https://github.com/dptech-corp/Uni-Lab-OS.git git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS cd Uni-Lab-OS
# 安装 Uni-Lab-OS # Install Uni-Lab-OS
pip install . pip install .
``` ```
3. 启动 Uni-Lab 系统: 3. Start Uni-Lab System:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html) Please refer to [Documentation - Boot Examples](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式 ## Message Format
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。 Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) page.
## 许可证 ## License
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。 This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
## 项目统计 ## Project Statistics
### Stars 趋势 ### Stars Trend
<a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date"> <a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date">
<img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600"> <img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600">
</a> </a>
## 联系我们 ## Contact Us
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) - GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)

93
README_zh.md Normal file
View File

@@ -0,0 +1,93 @@
<div align="center">
<img src="docs/logo.png" alt="Uni-Lab Logo" width="200"/>
</div>
# Uni-Lab-OS
<!-- Language switcher -->
[English](README.md) | **中文**
[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues)
[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab-OS是一个用于实验室自动化的综合平台旨在连接和控制各种实验设备实现实验流程的自动化和标准化。
## 🏆 比赛
欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成!
## 核心特点
- 多设备集成管理
- 自动化实验流程
- 云端连接能力
- 灵活的配置系统
- 支持多种实验协议
## 文档
详细文档可在以下位置找到:
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 快速开始
1. 配置Conda环境
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
```bash
# 创建新环境
mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab
# 或更新现有环境
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, 或 `osx-arm64`。
conda env update --file unilabos-[YOUR_OS].yml -n 环境名
# 现阶段,需要安装 `unilabos_msgs` 包
# 可以前往 Release 页面下载系统对应的包进行安装
conda install ros-humble-unilabos-msgs-0.9.1-xxxxx.tar.bz2
# 安装PyLabRobot等前置
git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo
pip install .[opentrons]
```
2. 安装 Uni-Lab-OS:
```bash
# 克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
# 安装 Uni-Lab-OS
pip install .
```
3. 启动 Uni-Lab 系统:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。
## 许可证
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。
## 项目统计
### Stars 趋势
<a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date">
<img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600">
</a>
## 联系我们
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.9.0 version: 0.9.1
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
folder: ros-humble-unilabos-msgs/src/work folder: ros-humble-unilabos-msgs/src/work

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
使用plr_test.json启动将Well加入Plate中 使用plr_test.json启动将Well加入Plate中
```bash ```bash
ros2 action send_goal /devices/host_node/add_resource_from_outer unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }" ros2 action send_goal /devices/host_node/create_resource_detailed unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }"
``` ```

View File

@@ -56,6 +56,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -56,6 +56,8 @@ dependencies:
# - ros-humble-moveit-servo # - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -58,6 +58,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -56,6 +56,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64 - ros-humble-simulation # ignored because of NO python3.11 package in WIN64
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# ros-humble-unilabos-msgs # ros-humble-unilabos-msgs

View File

@@ -31,6 +31,6 @@ def job_add(req: JobAddReq) -> JobData:
action_kwargs = {"command": json.dumps(action_kwargs)} action_kwargs = {"command": json.dumps(action_kwargs)}
elif "command" in action_kwargs: elif "command" in action_kwargs:
action_kwargs = action_kwargs["command"] action_kwargs = action_kwargs["command"]
print(f"job_add:{req.device_id} {action_name} {action_kwargs}") # print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id) HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id, server_info=req.server_info)
return JobData(jobId=req.job_id) return JobData(jobId=req.job_id)

View File

@@ -18,7 +18,6 @@ if unilabos_dir not in sys.path:
from unilabos.config.config import load_config, BasicConfig, _update_config_from_env from unilabos.config.config import load_config, BasicConfig, _update_config_from_env
from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.device_mesh.resource_visalization import ResourceVisualization
def parse_args(): def parse_args():
@@ -188,11 +187,12 @@ def main():
if args_dict["visual"] != "disable": if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz" enable_rviz = args_dict["visual"] == "rviz"
if devices_and_resources is not None: if devices_and_resources is not None:
from unilabos.device_mesh.resource_visalization import ResourceVisualization # 此处开启后logger会变更为INFO有需要请调整
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz) resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
args_dict["resources_mesh_config"] = resource_visualization.resource_model args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict) start_backend(**args_dict)
server_thread = threading.Thread(target=start_server, kwargs=dict( server_thread = threading.Thread(target=start_server, kwargs=dict(
open_browser=not args_dict["disable_browser"] open_browser=not args_dict["disable_browser"], port=args_dict["port"],
)) ))
server_thread.start() server_thread.start()
asyncio.set_event_loop(asyncio.new_event_loop()) asyncio.set_event_loop(asyncio.new_event_loop())
@@ -201,10 +201,10 @@ def main():
time.sleep(1) time.sleep(1)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(open_browser=not args_dict["disable_browser"]) start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(open_browser=not args_dict["disable_browser"]) start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -51,8 +51,9 @@ class Resp(BaseModel):
class JobAddReq(BaseModel): class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id") device_id: str = Field(examples=["Gripper"], description="device id")
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}]) data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid") job_id: str = Field(examples=["job_id"], description="goal uuid")
node_id: str = Field(examples=["sfsfsfeq"], description="node uuid") node_id: str = Field(examples=["node_id"], description="node uuid")
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
class JobStepFinishReq(BaseModel): class JobStepFinishReq(BaseModel):

View File

@@ -12,7 +12,7 @@ import tempfile
import os import os
from unilabos.config.config import MQConfig from unilabos.config.config import MQConfig
from unilabos.app.controler import devices, job_add from unilabos.app.controler import job_add
from unilabos.app.model import JobAddReq from unilabos.app.model import JobAddReq
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.type_check import TypeEncoder from unilabos.utils.type_check import TypeEncoder
@@ -26,6 +26,7 @@ class MQTTClient:
def __init__(self): def __init__(self):
self.mqtt_disable = not MQConfig.lab_id self.mqtt_disable = not MQConfig.lab_id
self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}" self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}"
logger.info("[MQTT] Client_id: " + self.client_id)
self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5) self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5)
self._setup_callbacks() self._setup_callbacks()
@@ -42,20 +43,14 @@ class MQTTClient:
def _on_connect(self, client, userdata, flags, rc, properties=None): def _on_connect(self, client, userdata, flags, rc, properties=None):
logger.info("[MQTT] Connected with result code " + str(rc)) logger.info("[MQTT] Connected with result code " + str(rc))
client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0) client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0)
isok, data = devices() client.subscribe(f"labs/{MQConfig.lab_id}/pong/", 0)
if not isok:
logger.error("[MQTT] on_connect ErrorHostNotInit")
return
def _on_message(self, client, userdata, msg) -> None: def _on_message(self, client, userdata, msg) -> None:
logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload)) # logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
try: try:
payload_str = msg.payload.decode("utf-8") payload_str = msg.payload.decode("utf-8")
payload_json = json.loads(payload_str) payload_json = json.loads(payload_str)
logger.debug(f"Topic: {msg.topic}")
logger.debug("Payload:", json.dumps(payload_json, indent=2, ensure_ascii=False))
if msg.topic == f"labs/{MQConfig.lab_id}/job/start/": if msg.topic == f"labs/{MQConfig.lab_id}/job/start/":
logger.debug("job_add", type(payload_json), payload_json)
if "data" not in payload_json: if "data" not in payload_json:
payload_json["data"] = {} payload_json["data"] = {}
if "action" in payload_json: if "action" in payload_json:
@@ -65,6 +60,14 @@ class MQTTClient:
job_req = JobAddReq.model_validate(payload_json) job_req = JobAddReq.model_validate(payload_json)
data = job_add(job_req) data = job_add(job_req)
return return
elif msg.topic == f"labs/{MQConfig.lab_id}/pong/":
# 处理pong响应通知HostNode
from unilabos.ros.nodes.presets.host_node import HostNode
host_instance = HostNode.get_instance(0)
if host_instance:
host_instance.handle_pong_response(payload_json)
return
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"[MQTT] JSON 解析错误: {e}") logger.error(f"[MQTT] JSON 解析错误: {e}")
@@ -181,6 +184,28 @@ class MQTTClient:
self.client.publish(address, json.dumps(action_info), qos=2) self.client.publish(address, json.dumps(action_info), qos=2)
logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}") logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}")
def send_ping(self, ping_id: str, timestamp: float):
"""发送ping消息到服务端"""
if self.mqtt_disable:
return
address = f"labs/{MQConfig.lab_id}/ping/"
ping_data = {"ping_id": ping_id, "client_timestamp": timestamp, "type": "ping"}
self.client.publish(address, json.dumps(ping_data), qos=2)
def setup_pong_subscription(self):
"""设置pong消息订阅"""
if self.mqtt_disable:
return
pong_topic = f"labs/{MQConfig.lab_id}/pong/"
self.client.subscribe(pong_topic, 0)
logger.debug(f"Subscribed to pong topic: {pong_topic}")
def handle_pong(self, pong_data: dict):
"""处理pong响应这个方法会在收到pong消息时被调用"""
logger.debug(f"Pong received: {pong_data}")
# 这里会被HostNode的ping-pong处理逻辑调用
pass
mqtt_client = MQTTClient() mqtt_client = MQTTClient()

View File

@@ -42,7 +42,7 @@ def get_host_node_info() -> Dict[str, Any]:
host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics))
# 获取动作客户端信息 # 获取动作客户端信息
for action_id, client in host_node._action_clients.items(): for action_id, client in host_node._action_clients.items():
host_info["action_clients"] = {action_id: get_action_info(client, full_name=action_id)} host_info["action_clients"][action_id] = get_action_info(client, full_name=action_id)
# 获取设备状态 # 获取设备状态
host_info["device_status"] = host_node.device_status host_info["device_status"] = host_node.device_status

View File

@@ -14,7 +14,7 @@ from pylabrobot.resources import (
Well Well
) )
class DPLiquidHandler(LiquidHandler): class LiquidHandlerAbstract(LiquidHandler):
"""Extended LiquidHandler with additional operations.""" """Extended LiquidHandler with additional operations."""
# --------------------------------------------------------------- # ---------------------------------------------------------------

View File

@@ -1,11 +1,96 @@
liquid_handler: liquid_handler:
description: Liquid handler device controlled by pylabrobot description: Liquid handler device controlled by pylabrobot
class: class:
module: pylabrobot.liquid_handling:LiquidHandler module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract
type: python type: python
status_types: status_types:
name: String name: String
action_value_mappings: action_value_mappings:
remove:
type: LiquidHandlerRemove
goal:
vols: vols
sources: sources
waste_liquid: waste_liquid
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
delays: delays
is_96_well: is_96_well
top: top
none_keys: none_keys
feedback: { }
result: { }
add_liquid:
type: LiquidHandlerAdd
goal:
asp_vols: asp_vols
dis_vols: dis_vols
reagent_sources: reagent_sources
targets: targets
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_time: mix_time
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
none_keys: none_keys
feedback: { }
result: { }
transfer_liquid:
type: LiquidHandlerTransfer
goal:
asp_vols: asp_vols
dis_vols: dis_vols
sources: sources
targets: targets
tip_racks: tip_racks
use_channels: use_channels
asp_flow_rates: asp_flow_rates
dis_flow_rates: dis_flow_rates
offsets: offsets
touch_tip: touch_tip
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
delays: delays
none_keys: none_keys
feedback: { }
result: { }
mix:
type: LiquidHandlerMix
goal:
targets: targets
mix_time: mix_time
mix_vol: mix_vol
height_to_bottom: height_to_bottom
offsets: offsets
mix_rate: mix_rate
none_keys: none_keys
feedback: { }
result: { }
move_to:
type: LiquidHandlerMoveTo
goal:
well: well
dis_to_top: dis_to_top
channel: channel
feedback: { }
result: { }
aspirate: aspirate:
type: LiquidHandlerAspirate type: LiquidHandlerAspirate
goal: goal:
@@ -170,127 +255,6 @@ liquid_handler:
- name - name
additionalProperties: false additionalProperties: false
dp_liquid_handler:
description: 通用液体处理
class:
module: unilabos.devices.liquid_handling.action_definition:DPLiquidHandler
type: python
status_types:
status: String
action_value_mappings:
remove_liquid:
type: DPLiquidHandlerRemoveLiquid
goal:
vols: vols
sources: sources
waste_liquid: waste_liquid
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
delays: delays
is_96_well: is_96_well
top: top
none_keys: none_keys
feedback: {}
result: {}
add_liquid:
type: DPLiquidHandlerAddLiquid
goal:
asp_vols: asp_vols
dis_vols: dis_vols
reagent_sources: reagent_sources
targets: targets
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_time: mix_time
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
none_keys: none_keys
feedback: {}
result: {}
transfer_liquid:
type: DPLiquidHandlerTransferLiquid
goal:
asp_vols: asp_vols
dis_vols: dis_vols
sources: sources
targets: targets
tip_racks: tip_racks
use_channels: use_channels
asp_flow_rates: asp_flow_rates
dis_flow_rates: dis_flow_rates
offsets: offsets
touch_tip: touch_tip
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
delays: delays
none_keys: none_keys
feedback: {}
result: {}
custom_delay:
type: DPLiquidHandlerCustomDelay
goal:
seconds: seconds
msg: msg
feedback: {}
result: {}
touch_tip:
type: DPLiquidHandlerTouchTip
goal:
targets: targets
feedback: {}
result: {}
mix:
type: DPLiquidHandlerMix
goal:
targets: targets
mix_time: mix_time
mix_vol: mix_vol
height_to_bottom: height_to_bottom
offsets: offsets
mix_rate: mix_rate
none_keys: none_keys
feedback: {}
result: {}
set_tiprack:
type: DPLiquidHandlerSetTiprack
goal:
tip_racks: tip_racks
feedback: {}
result: {}
move_to:
type: DPLiquidHandlerMoveTo
goal:
well: well
dis_to_top: dis_to_top
channel: channel
feedback: {}
result: {}
schema:
type: object
properties:
name:
type: string
description: 物料名
required:
- name
liquid_handler.revvity: liquid_handler.revvity:
class: class:
module: unilabos.devices.liquid_handling.revvity:Revvity module: unilabos.devices.liquid_handling.revvity:Revvity

View File

@@ -12,7 +12,7 @@ separator.homemade:
goal: goal:
stir_time: stir_time, stir_time: stir_time,
stir_speed: stir_speed stir_speed: stir_speed
settling_time": settling_time settling_time: settling_time
feedback: feedback:
status: status status: status
result: result:

View File

@@ -3,6 +3,25 @@ vacuum_pump.mock:
class: class:
module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock
type: python type: python
status_types:
status: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
set_status:
type: StrSingleInput
goal:
string: string
feedback: {}
result: {}
gas_source.mock: gas_source.mock:
description: Mock gas source description: Mock gas source

View File

@@ -1,5 +1,4 @@
import io import io
import json
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -7,10 +6,9 @@ from typing import Any
import yaml import yaml
from unilabos.utils import logger
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema
from unilabos.utils import logger
from unilabos.utils.decorator import singleton from unilabos.utils.decorator import singleton
from unilabos.utils.type_check import TypeEncoder
DEFAULT_PATHS = [Path(__file__).absolute().parent] DEFAULT_PATHS = [Path(__file__).absolute().parent]
@@ -21,43 +19,16 @@ class Registry:
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值 self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
if registry_paths: if registry_paths:
self.registry_paths.extend(registry_paths) self.registry_paths.extend(registry_paths)
action_type = self._replace_type_with_class( self.ResourceCreateFromOuter = self._replace_type_with_class(
"ResourceCreateFromOuter", "host_node", f"动作 add_resource_from_outer" "ResourceCreateFromOuter", "host_node", f"动作 create_resource_detailed"
) )
schema = ros_action_to_json_schema(action_type) self.ResourceCreateFromOuterEasy = self._replace_type_with_class(
self.device_type_registry = { "ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
"host_node": { )
"description": "UniLabOS主机节点", self.EmptyIn = self._replace_type_with_class(
"class": { "EmptyIn", "host_node", f""
"module": "unilabos.ros.nodes.presets.host_node", )
"type": "python", self.device_type_registry = {}
"status_types": {},
"action_value_mappings": {
"add_resource_from_outer": {
"type": msg_converter_manager.search_class("ResourceCreateFromOuter"),
"goal": {
"resources": "resources",
"device_ids": "device_ids",
"bind_parent_ids": "bind_parent_ids",
"bind_locations": "bind_locations",
"other_calling_params": "other_calling_params",
},
"feedback": {},
"result": {
"success": "success"
},
"schema": schema
}
}
},
"schema": {
"properties": {},
"additionalProperties": False,
"type": "object"
},
"file_path": "/"
}
}
self.resource_type_registry = {} self.resource_type_registry = {}
self._setup_called = False # 跟踪setup是否已调用 self._setup_called = False # 跟踪setup是否已调用
# 其他状态变量 # 其他状态变量
@@ -69,9 +40,70 @@ class Registry:
logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用") logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用")
return return
# 标记setup已被调用 from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
self._setup_called = True
self.device_type_registry.update(
{
"host_node": {
"description": "UniLabOS主机节点",
"class": {
"module": "unilabos.ros.nodes.presets.host_node",
"type": "python",
"status_types": {},
"action_value_mappings": {
"create_resource_detailed": {
"type": self.ResourceCreateFromOuter,
"goal": {
"resources": "resources",
"device_ids": "device_ids",
"bind_parent_ids": "bind_parent_ids",
"bind_locations": "bind_locations",
"other_calling_params": "other_calling_params",
},
"feedback": {},
"result": {"success": "success"},
"schema": ros_action_to_json_schema(self.ResourceCreateFromOuter),
"goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuter.Goal))
),
},
"create_resource": {
"type": self.ResourceCreateFromOuterEasy,
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck",
},
"feedback": {},
"result": {"success": "success"},
"schema": ros_action_to_json_schema(self.ResourceCreateFromOuterEasy),
"goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuterEasy.Goal))
),
},
"test_latency": {
"type": self.EmptyIn,
"goal": {},
"feedback": {},
"result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"},
"schema": ros_action_to_json_schema(self.EmptyIn),
"goal_default": {},
},
},
},
"icon": "icon_device.webp",
"registry_type": "device",
"schema": {"properties": {}, "additionalProperties": False, "type": "object"},
"file_path": "/",
}
}
)
logger.debug(f"[UniLab Registry] ----------Setup----------") logger.debug(f"[UniLab Registry] ----------Setup----------")
self.registry_paths = [Path(path).absolute() for path in self.registry_paths] self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths): for i, path in enumerate(self.registry_paths):
@@ -81,6 +113,8 @@ class Registry:
self.load_device_types(path) self.load_device_types(path)
self.load_resource_types(path) self.load_resource_types(path)
logger.info("[UniLab Registry] 注册表设置完成") logger.info("[UniLab Registry] 注册表设置完成")
# 标记setup已被调用
self._setup_called = True
def load_resource_types(self, path: os.PathLike): def load_resource_types(self, path: os.PathLike):
abs_path = Path(path).absolute() abs_path = Path(path).absolute()
@@ -96,6 +130,9 @@ class Registry:
resource_info["file_path"] = str(file.absolute()).replace("\\", "/") resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
if "description" not in resource_info: if "description" not in resource_info:
resource_info["description"] = "" resource_info["description"] = ""
if "icon" not in resource_info:
resource_info["icon"] = ""
resource_info["registry_type"] = "resource"
self.resource_type_registry.update(data) self.resource_type_registry.update(data)
logger.debug( logger.debug(
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} " f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} "
@@ -145,6 +182,7 @@ class Registry:
) )
current_device_number = len(self.device_type_registry) + 1 current_device_number = len(self.device_type_registry) + 1
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
for i, file in enumerate(files): for i, file in enumerate(files):
data = yaml.safe_load(open(file, encoding="utf-8")) data = yaml.safe_load(open(file, encoding="utf-8"))
if data: if data:
@@ -154,6 +192,9 @@ class Registry:
device_config["file_path"] = str(file.absolute()).replace("\\", "/") device_config["file_path"] = str(file.absolute()).replace("\\", "/")
if "description" not in device_config: if "description" not in device_config:
device_config["description"] = "" device_config["description"] = ""
if "icon" not in device_config:
device_config["icon"] = ""
device_config["registry_type"] = "device"
if "class" in device_config: if "class" in device_config:
# 处理状态类型 # 处理状态类型
if "status_types" in device_config["class"]: if "status_types" in device_config["class"]:
@@ -169,8 +210,15 @@ class Registry:
action_config["type"] = self._replace_type_with_class( action_config["type"] = self._replace_type_with_class(
action_config["type"], device_id, f"动作 {action_name}" action_config["type"], device_id, f"动作 {action_name}"
) )
action_config["goal_default"] = yaml.safe_load(io.StringIO(get_yaml_from_goal_type(action_config["type"].Goal))) if action_config["type"] is not None:
action_config["schema"] = ros_action_to_json_schema(action_config["type"]) action_config["goal_default"] = yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(action_config["type"].Goal))
)
action_config["schema"] = ros_action_to_json_schema(action_config["type"])
else:
logger.warning(
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
)
self.device_type_registry.update(data) self.device_type_registry.update(data)
@@ -188,13 +236,17 @@ class Registry:
def obtain_registry_device_info(self): def obtain_registry_device_info(self):
devices = [] devices = []
for device_id, device_info in self.device_type_registry.items(): for device_id, device_info in self.device_type_registry.items():
msg = { msg = {"id": device_id, **device_info}
"id": device_id,
**device_info
}
devices.append(msg) devices.append(msg)
return devices return devices
def obtain_registry_resource_info(self):
resources = []
for resource_id, resource_info in self.resource_type_registry.items():
msg = {"id": resource_id, **resource_info}
resources.append(msg)
return resources
# 全局单例实例 # 全局单例实例
lab_registry = Registry() lab_registry = Registry()

View File

@@ -1,35 +1,35 @@
agilent_1_reservoir_290ml: agilent_1_reservoir_290ml:
description: Agilent 1 reservoir 290ml description: Agilent 1 reservoir 290ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:agilent_1_reservoir_290ml module: pylabrobot.resources.opentrons.reservoirs:agilent_1_reservoir_290ml
type: pylabrobot type: pylabrobot
axygen_1_reservoir_90ml: axygen_1_reservoir_90ml:
description: Axygen 1 reservoir 90ml description: Axygen 1 reservoir 90ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:axygen_1_reservoir_90ml module: pylabrobot.resources.opentrons.reservoirs:axygen_1_reservoir_90ml
type: pylabrobot type: pylabrobot
nest_12_reservoir_15ml: nest_12_reservoir_15ml:
description: Nest 12 reservoir 15ml description: Nest 12 reservoir 15ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_12_reservoir_15ml module: pylabrobot.resources.opentrons.reservoirs:nest_12_reservoir_15ml
type: pylabrobot type: pylabrobot
nest_1_reservoir_195ml: nest_1_reservoir_195ml:
description: Nest 1 reservoir 195ml description: Nest 1 reservoir 195ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_195ml module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_195ml
type: pylabrobot type: pylabrobot
nest_1_reservoir_290ml: nest_1_reservoir_290ml:
description: Nest 1 reservoir 290ml description: Nest 1 reservoir 290ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_290ml module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_290ml
type: pylabrobot type: pylabrobot
usascientific_12_reservoir_22ml: usascientific_12_reservoir_22ml:
description: USAScientific 12 reservoir 22ml description: USAScientific 12 reservoir 22ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:usascientific_12_reservoir_22ml module: pylabrobot.resources.opentrons.reservoirs:usascientific_12_reservoir_22ml
type: pylabrobot type: pylabrobot

View File

@@ -189,6 +189,7 @@ def dict_from_graph(graph: nx.Graph) -> dict:
def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]: def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# 将节点转换为字典,以便通过 ID 快速查找 # 将节点转换为字典,以便通过 ID 快速查找
nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only] nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only]
id_list = [node["id"] for node in nodes_list]
# 初始化每个节点的 children 为包含节点字典的列表 # 初始化每个节点的 children 为包含节点字典的列表
for node in nodes_list: for node in nodes_list:
@@ -196,7 +197,7 @@ def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# 找到根节点并返回 # 找到根节点并返回
root_nodes = [ root_nodes = [
node for node in nodes_list if len(nodes_list) == 1 or node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] node for node in nodes_list if len(nodes_list) == 1 or node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] or node.get("parent", node.get("parent_name")) not in id_list
] ]
# 如果存在多个根节点,返回所有根节点 # 如果存在多个根节点,返回所有根节点
@@ -430,7 +431,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None):
return r return r
def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]: def initialize_resource(resource_config: dict) -> list[dict]:
"""Initializes a resource based on its configuration. """Initializes a resource based on its configuration.
If the config is detailed, then do nothing; If the config is detailed, then do nothing;
@@ -442,6 +443,7 @@ def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]
Returns: Returns:
None None
""" """
from unilabos.registry.registry import lab_registry
resource_class_config = resource_config.get("class", None) resource_class_config = resource_config.get("class", None)
if resource_class_config is None: if resource_class_config is None:
return [resource_config] return [resource_config]
@@ -485,11 +487,8 @@ def initialize_resources(resources_config) -> list[dict]:
None None
""" """
from unilabos.registry.registry import lab_registry
resources = [] resources = []
for resource_config in resources_config: for resource_config in resources_config:
if resource_config["parent"] == "tip_rack" or resource_config["parent"] == "plate_well": resources.extend(initialize_resource(resource_config))
continue
resources.extend(initialize_resource(resource_config, lab_registry))
return resources return resources

View File

@@ -348,10 +348,16 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any:
if isinstance(td, NamespacedType): if isinstance(td, NamespacedType):
target_class = msg_converter_manager.get_class(f"{'.'.join(td.namespaces)}.{td.name}") target_class = msg_converter_manager.get_class(f"{'.'.join(td.namespaces)}.{td.name}")
setattr(ros_msg, key, [convert_to_ros_msg(target_class, v) for v in value]) setattr(ros_msg, key, [convert_to_ros_msg(target_class, v) for v in value])
elif isinstance(td, UnboundedString):
setattr(ros_msg, key, value)
else: else:
logger.warning(f"Not Supported type: {td}")
setattr(ros_msg, key, []) # FIXME setattr(ros_msg, key, []) # FIXME
elif "array.array" in str(type(attr)): elif "array.array" in str(type(attr)):
setattr(ros_msg, key, value) if attr.typecode == "f":
setattr(ros_msg, key, [float(i) for i in value])
else:
setattr(ros_msg, key, value)
else: else:
nested_ros_msg = convert_to_ros_msg(type(attr)(), value) nested_ros_msg = convert_to_ros_msg(type(attr)(), value)
setattr(ros_msg, key, nested_ros_msg) setattr(ros_msg, key, nested_ros_msg)
@@ -574,6 +580,7 @@ basic_type_map = {
'int64': {'type': 'integer'}, 'int64': {'type': 'integer'},
'uint64': {'type': 'integer', 'minimum': 0}, 'uint64': {'type': 'integer', 'minimum': 0},
'double': {'type': 'number'}, 'double': {'type': 'number'},
'float': {'type': 'number'},
'float32': {'type': 'number'}, 'float32': {'type': 'number'},
'float64': {'type': 'number'}, 'float64': {'type': 'number'},
'string': {'type': 'string'}, 'string': {'type': 'string'},

View File

@@ -1,3 +1,5 @@
import copy
import functools
import json import json
import threading import threading
import time import time
@@ -19,7 +21,7 @@ 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.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \ from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \
initialize_resources initialize_resources, list_to_nested_dict, dict_to_tree, resource_plr_to_ulab, tree_to_list
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg, convert_to_ros_msg,
convert_from_ros_msg, convert_from_ros_msg,
@@ -311,7 +313,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 物料传输到对应的node节点 # 物料传输到对应的node节点
rclient = self.create_client(ResourceAdd, "/resources/add") rclient = self.create_client(ResourceAdd, "/resources/add")
rclient.wait_for_service() rclient.wait_for_service()
rclient2 = self.create_client(ResourceAdd, "/resources/add")
rclient2.wait_for_service()
request = ResourceAdd.Request() request = ResourceAdd.Request()
request2 = ResourceAdd.Request()
command_json = json.loads(req.command) command_json = json.loads(req.command)
namespace = command_json["namespace"] namespace = command_json["namespace"]
bind_parent_id = command_json["bind_parent_id"] bind_parent_id = command_json["bind_parent_id"]
@@ -320,11 +325,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
other_calling_param = command_json["other_calling_param"] other_calling_param = command_json["other_calling_param"]
resources = command_json["resource"] resources = command_json["resource"]
initialize_full = other_calling_param.pop("initialize_full", False) initialize_full = other_calling_param.pop("initialize_full", False)
# 用来增加液体
ADD_LIQUID_TYPE = other_calling_param.pop("ADD_LIQUID_TYPE", [])
LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", [])
LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
slot = other_calling_param.pop("slot", -1)
if slot >= 0: # slot为负数的时候采用assign方法
other_calling_param["slot"] = slot
# 本地拿到这个物料,可能需要先做初始化? # 本地拿到这个物料,可能需要先做初始化?
if isinstance(resources, list): if isinstance(resources, list):
if initialize_full: if len(resources) == 1 and isinstance(resources[0], list) and not initialize_full: # 取消,不存在的情况
# 预先initialize过以整组的形式传入
request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]]
elif initialize_full:
resources = initialize_resources(resources) resources = initialize_resources(resources)
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
else:
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
else: else:
if initialize_full: if initialize_full:
resources = initialize_resources([resources]) resources = initialize_resources([resources])
@@ -334,20 +351,31 @@ class BaseROS2DeviceNode(Node, Generic[T]):
res.response = "OK" res.response = "OK"
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中 # 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
request.resources = [convert_to_ros_msg(Resource, resources)] # request.resources = [convert_to_ros_msg(Resource, resources)]
try: try:
from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.resource import Resource as ResourcePLR
from pylabrobot.resources.deck import Deck from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import OTDeck from pylabrobot.resources import OTDeck
from pylabrobot.resources import Plate
contain_model = not isinstance(resource, Deck) contain_model = not isinstance(resource, Deck)
if isinstance(resource, ResourcePLR): if isinstance(resource, ResourcePLR):
# resources.list() # resources.list()
plr_instance = resource_ulab_to_plr(resources, contain_model) resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources}))
plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model)
if isinstance(plr_instance, Plate):
empty_liquid_info_in = [(None, 0)] * plr_instance.num_items
for liquid_type, liquid_volume, liquid_input_slot in zip(ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
if isinstance(resource, OTDeck) and "slot" in other_calling_param: if isinstance(resource, OTDeck) and "slot" in other_calling_param:
resource.assign_child_at_slot(plr_instance, **other_calling_param) resource.assign_child_at_slot(plr_instance, **other_calling_param)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param) else:
_discard_slot = other_calling_param.pop("slot", -1)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param)
request2.resources = [convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)])]
rclient2.call(request2)
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group
@@ -404,6 +432,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 加入全局注册表 # 加入全局注册表
registered_devices[self.device_id] = device_info registered_devices[self.device_id] = device_info
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.ros.nodes.presets.host_node import HostNode
if not BasicConfig.is_host_mode: if not BasicConfig.is_host_mode:
sclient = self.create_client(SerialCommand, "/node_info_update") sclient = self.create_client(SerialCommand, "/node_info_update")
# 启动线程执行发送任务 # 启动线程执行发送任务
@@ -413,6 +442,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_send_slave_node_info" name=f"ROSDevice{self.device_id}_send_slave_node_info"
).start() ).start()
else:
host_node = HostNode.get_instance(0)
if host_node is not None:
host_node.device_machine_names[self.device_id] = "本地"
def send_slave_node_info(self, sclient): def send_slave_node_info(self, sclient):
sclient.wait_for_service() sclient.wait_for_service()
@@ -481,6 +514,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}") self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}")
def get_real_function(self, instance, attr_name):
if hasattr(instance.__class__, attr_name):
obj = getattr(instance.__class__, attr_name)
if isinstance(obj, property):
return lambda *args, **kwargs: obj.fset(instance, *args, **kwargs), get_type_hints(obj.fset)
obj = getattr(instance, attr_name)
return obj, get_type_hints(obj)
else:
obj = getattr(instance, attr_name)
return obj, get_type_hints(obj)
def _create_execute_callback(self, action_name, action_value_mapping): def _create_execute_callback(self, action_name, action_value_mapping):
"""创建动作执行回调函数""" """创建动作执行回调函数"""
@@ -495,22 +539,21 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for i, action in enumerate(self._action_value_mappings["sequence"]): for i, action in enumerate(self._action_value_mappings["sequence"]):
if i == 0: if i == 0:
self.lab_logger().info(f"执行序列动作第一步: {action}") self.lab_logger().info(f"执行序列动作第一步: {action}")
getattr(self.driver_instance, action)(**kwargs) self.get_real_function(self.driver_instance, action)[0](**kwargs)
else: else:
self.lab_logger().info(f"执行序列动作后续步骤: {action}") self.lab_logger().info(f"执行序列动作后续步骤: {action}")
getattr(self.driver_instance, action)() self.get_real_function(self.driver_instance, action)[0]()
action_paramtypes = get_type_hints( action_paramtypes = get_type_hints(
getattr(self.driver_instance, self._action_value_mappings["sequence"][0]) self.get_real_function(self.driver_instance, self._action_value_mappings["sequence"][0])
) )[1]
else: else:
ACTION = getattr(self.driver_instance, action_name) ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
action_paramtypes = get_type_hints(ACTION)
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
self.lab_logger().debug(f"接收到原始目标: {action_kwargs}") self.lab_logger().debug(f"接收到原始目标: {action_kwargs}")
# 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过 # 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过
if action_name != "add_resource_from_outer": if action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}") self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
@@ -609,7 +652,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
del future del future
# 向Host更新物料当前状态 # 向Host更新物料当前状态
if action_name != "add_resource_from_outer": if action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue continue
@@ -748,7 +791,7 @@ class ROS2DeviceNode:
self.resource_tracker = DeviceNodeResourceTracker() self.resource_tracker = DeviceNodeResourceTracker()
# use_pylabrobot_creator 使用 cls的包路径检测 # use_pylabrobot_creator 使用 cls的包路径检测
use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "DPLiquidHandler" use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "LiquidHandlerAbstract"
# TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建 # TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建
# 创建设备类实例 # 创建设备类实例

View File

@@ -12,11 +12,18 @@ from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service from rclpy.service import Service
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \ from unilabos_msgs.srv import (
SerialCommand # type: ignore ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
) # type: ignore
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.graphio import initialize_resource
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
@@ -86,6 +93,7 @@ class HostNode(BaseROS2DeviceNode):
self.__class__._instance = self self.__class__._instance = self
# 初始化配置 # 初始化配置
self.server_latest_timestamp = 0.0 #
self.devices_config = devices_config self.devices_config = devices_config
self.resources_config = resources_config self.resources_config = resources_config
self.physical_setup_graph = physical_setup_graph self.physical_setup_graph = physical_setup_graph
@@ -99,9 +107,32 @@ class HostNode(BaseROS2DeviceNode):
# 创建设备、动作客户端和目标存储 # 创建设备、动作客户端和目标存储
self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射 self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射 self.device_machine_names: Dict[str, str] = {
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 device_id: "本地",
self._action_value_mappings: Dict[str, Dict] = {} # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系 } # 存储设备ID到机器名称的映射
self._action_clients: Dict[str, ActionClient] = { # 为了方便了解实际的数据类型host的默认写好
"/devices/host_node/create_resource": ActionClient(
self,
lab_registry.ResourceCreateFromOuterEasy,
"/devices/host_node/create_resource",
callback_group=self.callback_group,
),
"/devices/host_node/create_resource_detailed": ActionClient(
self,
lab_registry.ResourceCreateFromOuter,
"/devices/host_node/create_resource_detailed",
callback_group=self.callback_group,
),
"/devices/host_node/test_latency": ActionClient(
self,
lab_registry.EmptyIn,
"/devices/host_node/test_latency",
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = (
{}
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
self._last_discovery_time = 0.0 # 上次设备发现的时间 self._last_discovery_time = 0.0 # 上次设备发现的时间
@@ -115,8 +146,11 @@ class HostNode(BaseROS2DeviceNode):
self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 self.device_status_timestamps = {} # 用来存储设备状态最后更新时间
from unilabos.app.mq import mqtt_client from unilabos.app.mq import mqtt_client
for device_config in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_config["id"], device_config) for device_info in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_info["id"], device_info)
for resource_info in lab_registry.obtain_registry_resource_info():
mqtt_client.publish_registry(resource_info["id"], resource_info)
# 首次发现网络中的设备 # 首次发现网络中的设备
self._discover_devices() self._discover_devices()
@@ -141,12 +175,36 @@ class HostNode(BaseROS2DeviceNode):
].items(): ].items():
controller_config["update_rate"] = update_rate controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config) self.initialize_controller(controller_id, controller_config)
resources_config.insert(
0,
{
"id": "host_node",
"name": "host_node",
"parent": None,
"type": "device",
"class": "host_node",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {},
"children": [],
},
)
resource_with_parent_name = []
resource_ids_to_instance = {i["id"]: i for i in resources_config}
for res in resources_config:
if res.get("parent") and res.get("type") == "device" and res.get("class"):
parent_id = res.get("parent")
parent_res = resource_ids_to_instance[parent_id]
if parent_res.get("type") == "device" and parent_res.get("class"):
resource_with_parent_name.append(copy.deepcopy(res))
resource_with_parent_name[-1]["id"] = f"{parent_res['id']}/{res['id']}"
continue
resource_with_parent_name.append(copy.deepcopy(res))
try: try:
for bridge in self.bridges: for bridge in self.bridges:
if hasattr(bridge, "resource_add"): if hasattr(bridge, "resource_add"):
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.") self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.")
bridge.resource_add(add_schema(resources_config)) resource_add_res = bridge.resource_add(add_schema(resource_with_parent_name))
except Exception as ex: except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!") self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc()) self.lab_logger().error(traceback.format_exc())
@@ -156,6 +214,10 @@ class HostNode(BaseROS2DeviceNode):
discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup() discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup()
) )
# 添加ping-pong相关属性
self._ping_responses = {} # 存储ping响应
self._ping_lock = threading.Lock()
self.lab_logger().info("[Host Node] Host node initialized.") self.lab_logger().info("[Host Node] Host node initialized.")
HostNode._ready_event.set() HostNode._ready_event.set()
@@ -191,7 +253,7 @@ class HostNode(BaseROS2DeviceNode):
# 如果是新设备记录并创建ActionClient # 如果是新设备记录并创建ActionClient
if edge_device_id not in self.devices_names: if edge_device_id not in self.devices_names:
self.lab_logger().info(f"[Host Node] Discovered new device: {device_key}") self.lab_logger().info(f"[Host Node] Discovered new device: {edge_device_id}")
self.devices_names[edge_device_id] = namespace self.devices_names[edge_device_id] = namespace
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)
@@ -200,7 +262,7 @@ class HostNode(BaseROS2DeviceNode):
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
).start() ).start()
elif device_key not in self._online_devices: elif device_key not in self._online_devices:
# 设备重新上线 # 设备重新上线
@@ -211,7 +273,7 @@ class HostNode(BaseROS2DeviceNode):
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
).start() ).start()
# 检测离线设备 # 检测离线设备
@@ -255,7 +317,7 @@ class HostNode(BaseROS2DeviceNode):
self, action_type, action_id, callback_group=self.callback_group self, action_type, action_id, callback_group=self.callback_group
) )
self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}") self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}")
action_name = action_id[len(namespace) + 1:] action_name = action_id[len(namespace) + 1 :]
edge_device_id = namespace[9:] edge_device_id = namespace[9:]
# from unilabos.app.mq import mqtt_client # from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type) # info_with_schema = ros_action_to_json_schema(action_type)
@@ -268,30 +330,84 @@ class HostNode(BaseROS2DeviceNode):
except Exception as e: except Exception as e:
self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}")
def add_resource_from_outer(self, resources: list["Resource"], device_ids: list[str], bind_parent_ids: list[str], bind_locations: list[Point], other_calling_params: list[str]): def create_resource_detailed(
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(resources, device_ids, bind_parent_ids, bind_locations, other_calling_params): self,
resources: list["Resource"],
device_ids: list[str],
bind_parent_ids: list[str],
bind_locations: list[Point],
other_calling_params: list[str],
):
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(
resources, device_ids, bind_parent_ids, bind_locations, other_calling_params
):
# 这里要求device_id传入必须是edge_device_id # 这里要求device_id传入必须是edge_device_id
namespace = "/devices/" + device_id namespace = "/devices/" + device_id
srv_address = f"/srv{namespace}/append_resource" srv_address = f"/srv{namespace}/append_resource"
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
sclient.wait_for_service() sclient.wait_for_service()
request = SerialCommand.Request() request = SerialCommand.Request()
request.command = json.dumps({ request.command = json.dumps(
"resource": resource, {
"namespace": namespace, "resource": resource, # 单个/单组 可为 list[list[Resource]]
"edge_device_id": device_id, "namespace": namespace,
"bind_parent_id": bind_parent_id, "edge_device_id": device_id,
"bind_location": { "bind_parent_id": bind_parent_id,
"x": bind_location.x, "bind_location": {
"y": bind_location.y, "x": bind_location.x,
"z": bind_location.z, "y": bind_location.y,
"z": bind_location.z,
},
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {},
}, },
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {}, ensure_ascii=False,
}, ensure_ascii=False) )
response = sclient.call(request) response = sclient.call(request)
pass pass
pass pass
def create_resource(
self,
device_id: str,
res_id: str,
class_name: str,
parent: str,
bind_locations: Point,
liquid_input_slot: list[int],
liquid_type: list[str],
liquid_volume: list[int],
slot_on_deck: int,
):
init_new_res = initialize_resource(
{
"name": res_id,
"class": class_name,
"parent": parent,
"position": {
"x": bind_locations.x,
"y": bind_locations.y,
"z": bind_locations.z,
},
}
) # flatten的格式
resources = init_new_res # initialize_resource已经返回list[dict]
device_ids = [device_id]
bind_parent_id = [parent]
bind_location = [bind_locations]
other_calling_param = [
json.dumps(
{
"ADD_LIQUID_TYPE": liquid_type,
"LIQUID_VOLUME": liquid_volume,
"LIQUID_INPUT_SLOT": liquid_input_slot,
"initialize_full": False,
"slot": slot_on_deck,
}
)
]
return self.create_resource_detailed(resources, device_ids, bind_parent_id, bind_location, other_calling_param)
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None: def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
""" """
根据配置初始化设备, 根据配置初始化设备,
@@ -319,7 +435,9 @@ class HostNode(BaseROS2DeviceNode):
if action_id not in self._action_clients: if action_id not in self._action_clients:
action_type = action_value_mapping["type"] action_type = action_value_mapping["type"]
self._action_clients[action_id] = ActionClient(self, action_type, action_id) self._action_clients[action_id] = ActionClient(self, action_type, action_id)
self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的 self.lab_logger().debug(
f"[Host Node] Created ActionClient (Local): {action_id}"
) # 子设备再创建用的是Discover发现的
# from unilabos.app.mq import mqtt_client # from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type) # info_with_schema = ros_action_to_json_schema(action_type)
# mqtt_client.publish_actions(action_name, { # mqtt_client.publish_actions(action_name, {
@@ -419,7 +537,12 @@ class HostNode(BaseROS2DeviceNode):
) )
def send_goal( def send_goal(
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any], goal_uuid: Optional[str] = None self,
device_id: str,
action_name: str,
action_kwargs: Dict[str, Any],
goal_uuid: Optional[str] = None,
server_info: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
""" """
向设备发送目标请求 向设备发送目标请求
@@ -431,6 +554,8 @@ class HostNode(BaseROS2DeviceNode):
goal_uuid: 目标UUID如果为None则自动生成 goal_uuid: 目标UUID如果为None则自动生成
""" """
action_id = f"/devices/{device_id}/{action_name}" action_id = f"/devices/{device_id}/{action_name}"
if action_name == "test_latency" and server_info is not None:
self.server_latest_timestamp = server_info.get("send_timestamp", 0.0)
if action_id not in self._action_clients: if action_id not in self._action_clients:
self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.") self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.")
return return
@@ -725,3 +850,148 @@ class HostNode(BaseROS2DeviceNode):
# 这里可以实现返回资源列表的逻辑 # 这里可以实现返回资源列表的逻辑
self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}") self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}")
return response return response
def test_latency(self):
"""
测试网络延迟的action实现
通过5次ping-pong机制校对时间误差并计算实际延迟
"""
import time
import uuid as uuid_module
self.lab_logger().info("=" * 60)
self.lab_logger().info("开始网络延迟测试...")
# 记录任务开始执行的时间
task_start_time = time.time()
# 进行5次ping-pong测试
ping_results = []
for i in range(5):
self.lab_logger().info(f"{i+1}/5次ping-pong测试...")
# 生成唯一的ping ID
ping_id = str(uuid_module.uuid4())
# 记录发送时间
send_timestamp = time.time()
# 发送ping
from unilabos.app.mq import mqtt_client
mqtt_client.send_ping(ping_id, send_timestamp)
# 等待pong响应
timeout = 10.0
start_wait_time = time.time()
while time.time() - start_wait_time < timeout:
with self._ping_lock:
if ping_id in self._ping_responses:
pong_data = self._ping_responses.pop(ping_id)
break
time.sleep(0.001)
else:
self.lab_logger().error(f"❌ 第{i+1}次测试超时")
continue
# 计算本次测试结果
receive_timestamp = time.time()
client_timestamp = pong_data["client_timestamp"]
server_timestamp = pong_data["server_timestamp"]
# 往返时间
rtt_ms = (receive_timestamp - send_timestamp) * 1000
# 客户端与服务端时间差(客户端时间 - 服务端时间)
# 假设网络延迟对称,取中间点的服务端时间
mid_point_time = send_timestamp + (receive_timestamp - send_timestamp) / 2
time_diff_ms = (mid_point_time - server_timestamp) * 1000
ping_results.append({"rtt_ms": rtt_ms, "time_diff_ms": time_diff_ms})
self.lab_logger().info(f"✅ 第{i+1}次: 往返时间={rtt_ms:.2f}ms, 时间差={time_diff_ms:.2f}ms")
time.sleep(0.1)
if not ping_results:
self.lab_logger().error("❌ 所有ping-pong测试都失败了")
return {"status": "all_timeout"}
# 统计分析
rtts = [r["rtt_ms"] for r in ping_results]
time_diffs = [r["time_diff_ms"] for r in ping_results]
avg_rtt_ms = sum(rtts) / len(rtts)
avg_time_diff_ms = sum(time_diffs) / len(time_diffs)
max_time_diff_error_ms = max(abs(min(time_diffs)), abs(max(time_diffs)))
self.lab_logger().info("-" * 50)
self.lab_logger().info("[测试统计]")
self.lab_logger().info(f"有效测试次数: {len(ping_results)}/5")
self.lab_logger().info(f"平均往返时间: {avg_rtt_ms:.2f}ms")
self.lab_logger().info(f"平均时间差: {avg_time_diff_ms:.2f}ms")
self.lab_logger().info(f"时间差范围: {min(time_diffs):.2f}ms ~ {max(time_diffs):.2f}ms")
self.lab_logger().info(f"最大时间误差: ±{max_time_diff_error_ms:.2f}ms")
# 计算任务执行延迟
if hasattr(self, "server_latest_timestamp") and self.server_latest_timestamp > 0:
self.lab_logger().info("-" * 50)
self.lab_logger().info("[任务执行延迟分析]")
self.lab_logger().info(f"服务端任务下发时间: {self.server_latest_timestamp:.6f}")
self.lab_logger().info(f"客户端任务开始时间: {task_start_time:.6f}")
# 原始时间差(不考虑时间同步误差)
raw_delay_ms = (task_start_time - self.server_latest_timestamp) * 1000
# 考虑时间同步误差后的延迟(用平均时间差校正)
corrected_delay_ms = raw_delay_ms - avg_time_diff_ms
self.lab_logger().info(f"📊 原始时间差: {raw_delay_ms:.2f}ms")
self.lab_logger().info(f"🔧 时间同步校正: {avg_time_diff_ms:.2f}ms")
self.lab_logger().info(f"⏰ 实际任务延迟: {corrected_delay_ms:.2f}ms")
self.lab_logger().info(f"📏 误差范围: ±{max_time_diff_error_ms:.2f}ms")
# 给出延迟范围
min_delay = corrected_delay_ms - max_time_diff_error_ms
max_delay = corrected_delay_ms + max_time_diff_error_ms
self.lab_logger().info(f"📋 延迟范围: {min_delay:.2f}ms ~ {max_delay:.2f}ms")
else:
self.lab_logger().warning("⚠️ 无法获取服务端任务下发时间,跳过任务延迟分析")
corrected_delay_ms = -1
self.lab_logger().info("=" * 60)
return {
"avg_rtt_ms": avg_rtt_ms,
"avg_time_diff_ms": avg_time_diff_ms,
"max_time_error_ms": max_time_diff_error_ms,
"task_delay_ms": corrected_delay_ms if corrected_delay_ms > 0 else -1,
"raw_delay_ms": (
raw_delay_ms if hasattr(self, "server_latest_timestamp") and self.server_latest_timestamp > 0 else -1
),
"test_count": len(ping_results),
"status": "success",
}
def handle_pong_response(self, pong_data: dict):
"""
处理pong响应
"""
ping_id = pong_data.get("ping_id")
if ping_id:
with self._ping_lock:
self._ping_responses[ping_id] = pong_data
# 详细信息合并为一条日志
client_timestamp = pong_data.get("client_timestamp", 0)
server_timestamp = pong_data.get("server_timestamp", 0)
current_time = time.time()
self.lab_logger().debug(
f"📨 Pong | ID:{ping_id[:8]}.. | C→S→C: {client_timestamp:.3f}{server_timestamp:.3f}{current_time:.3f}"
)
else:
self.lab_logger().warning("⚠️ 收到无效的Pong响应缺少ping_id")

View File

@@ -13,6 +13,7 @@ endif()
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
add_compile_options(-include cstdint)
endif() endif()
# find dependencies # find dependencies
@@ -43,14 +44,10 @@ set(action_files
"action/LiquidHandlerStamp.action" "action/LiquidHandlerStamp.action"
"action/LiquidHandlerTransfer.action" "action/LiquidHandlerTransfer.action"
"action/DPLiquidHandlerAddLiquid.action" "action/LiquidHandlerAdd.action"
"action/DPLiquidHandlerCustomDelay.action" "action/LiquidHandlerMix.action"
"action/DPLiquidHandlerMix.action" "action/LiquidHandlerMoveTo.action"
"action/DPLiquidHandlerMoveTo.action" "action/LiquidHandlerRemove.action"
"action/DPLiquidHandlerRemoveLiquid.action"
"action/DPLiquidHandlerSetTiprack.action"
"action/DPLiquidHandlerTouchTip.action"
"action/DPLiquidHandlerTransferLiquid.action"
"action/EmptyIn.action" "action/EmptyIn.action"
"action/FloatSingleInput.action" "action/FloatSingleInput.action"
@@ -59,6 +56,7 @@ set(action_files
"action/Point3DSeparateInput.action" "action/Point3DSeparateInput.action"
"action/ResourceCreateFromOuter.action" "action/ResourceCreateFromOuter.action"
"action/ResourceCreateFromOuterEasy.action"
"action/SolidDispenseAddPowderTube.action" "action/SolidDispenseAddPowderTube.action"

View File

@@ -1,6 +0,0 @@
float64 seconds
string msg
---
bool success
---
# 反馈

View File

@@ -1,5 +0,0 @@
Resource[] tip_racks
---
bool success
---
# 反馈

View File

@@ -1,5 +0,0 @@
Resource[] targets
---
bool success
---
# 反馈

View File

@@ -1,25 +0,0 @@
float64[] asp_vols
float64[] dis_vols
Resource[] sources
Resource[] targets
Resource[] tip_racks
int32[] use_channels
float64[] asp_flow_rates
float64[] dis_flow_rates
geometry_msgs/Point[] offsets
bool touch_tip
float64[] liquid_height
float64[] blow_out_air_volume
string spread
bool is_96_well
string mix_stage
int32[] mix_times
int32 mix_vol
int32 mix_rate
float64 mix_liquid_height
int32[] delays
string[] none_keys
---
bool success
---
# 反馈

View File

@@ -5,7 +5,7 @@ float64[] flow_rates
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
float64[] liquid_height float64[] liquid_height
float64[] blow_out_air_volume float64[] blow_out_air_volume
string spread="wide" string spread
--- ---
bool success bool success
--- ---

View File

@@ -5,7 +5,7 @@ int32[] use_channels
float64[] flow_rates float64[] flow_rates
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
int32[] blow_out_air_volume int32[] blow_out_air_volume
string spread="wide" string spread
--- ---
# 结果字段 # 结果字段
bool success bool success

View File

@@ -1,11 +1,25 @@
# Bio float64[] asp_vols
Resource source float64[] dis_vols
Resource[] sources
Resource[] targets Resource[] targets
float64 source_vol Resource[] tip_racks
float64[] ratios int32[] use_channels
float64[] target_vols float64[] asp_flow_rates
float64 aspiration_flow_rate float64[] dis_flow_rates
float64[] dispense_flow_rates geometry_msgs/Point[] offsets
bool touch_tip
float64[] liquid_height
float64[] blow_out_air_volume
string spread
bool is_96_well
string mix_stage
int32[] mix_times
int32 mix_vol
int32 mix_rate
float64 mix_liquid_height
int32[] delays
string[] none_keys
--- ---
bool success bool success
--- ---
# 反馈

View File

@@ -0,0 +1,12 @@
string res_id
string device_id
string class_name
string parent
geometry_msgs/Point bind_locations
int32[] liquid_input_slot
string[] liquid_type
float32[] liquid_volume
int32 slot_on_deck
---
bool success
---