diff --git a/.gitignore b/.gitignore index 848e7995..f915811b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .vscode *.py[cod] *$py.class +service # C extensions *.so diff --git a/README.md b/README.md index 0c1d9b11..9d63eb84 100644 --- a/README.md +++ b/README.md @@ -4,83 +4,90 @@ # Uni-Lab-OS + +**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 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 操作系统是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。 +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 -# 创建新环境 +# Create new environment 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 环境名 +# Or update existing environment +# Where `[YOUR_OS]` can be `win64`, `linux-64`, `osx-64`, or `osx-arm64`. +conda env update --file unilabos-[YOUR_OS].yml -n environment_name -# 现阶段,需要安装 `unilabos_msgs` 包 -# 可以前往 Release 页面下载系统对应的包进行安装 -conda install ros-humble-unilabos-msgs-0.9.0-xxxxx.tar.bz2 +# Currently, you need to install the `unilabos_msgs` package +# You can download the system-specific package from the Release page +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 cd plr_repo pip install .[opentrons] ``` -2. 安装 Uni-Lab-OS: +2. Install Uni-Lab-OS: ```bash -# 克隆仓库 +# Clone the repository git clone https://github.com/dptech-corp/Uni-Lab-OS.git cd Uni-Lab-OS -# 安装 Uni-Lab-OS +# Install Uni-Lab-OS 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 Star History Chart -## 联系我们 +## Contact Us - GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) \ No newline at end of file diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..28671ebd --- /dev/null +++ b/README_zh.md @@ -0,0 +1,93 @@ +
+ Uni-Lab Logo +
+ +# Uni-Lab-OS + + +[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 趋势 + + + Star History Chart + + +## 联系我们 + +- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) \ No newline at end of file diff --git a/recipes/ros-humble-unilabos-msgs/recipe.yaml b/recipes/ros-humble-unilabos-msgs/recipe.yaml index b6997113..2ee7f1f8 100644 --- a/recipes/ros-humble-unilabos-msgs/recipe.yaml +++ b/recipes/ros-humble-unilabos-msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.9.0 + version: 0.9.1 source: path: ../../unilabos_msgs folder: ros-humble-unilabos-msgs/src/work diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 4840bd65..5b036306 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.9.0" + version: "0.9.1" source: path: ../.. diff --git a/setup.py b/setup.py index 5c06a7d8..847098a5 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.9.0', + version='0.9.1', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/test/commands/resource_add.md b/test/commands/resource_add.md index 9d5fe38f..d80e1557 100644 --- a/test/commands/resource_add.md +++ b/test/commands/resource_add.md @@ -1,5 +1,5 @@ 使用plr_test.json启动,将Well加入Plate中 ```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: [ '{}' ] }" ``` \ No newline at end of file diff --git a/unilabos-linux-64.yaml b/unilabos-linux-64.yaml index 3f5b91c6..aeac636f 100644 --- a/unilabos-linux-64.yaml +++ b/unilabos-linux-64.yaml @@ -56,6 +56,8 @@ dependencies: - ros-humble-moveit-servo # simulation - ros-humble-simulation + - ros-humble-tf-transformations + - transforms3d # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ilab equipments # - ros-humble-unilabos-msgs diff --git a/unilabos-osx-64.yaml b/unilabos-osx-64.yaml index 38981f0a..72ffb4c3 100644 --- a/unilabos-osx-64.yaml +++ b/unilabos-osx-64.yaml @@ -56,6 +56,8 @@ dependencies: # - ros-humble-moveit-servo # simulation - ros-humble-simulation + - ros-humble-tf-transformations + - transforms3d # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ilab equipments # - ros-humble-unilabos-msgs diff --git a/unilabos-osx-arm64.yaml b/unilabos-osx-arm64.yaml index 05333a39..83ffe1b3 100644 --- a/unilabos-osx-arm64.yaml +++ b/unilabos-osx-arm64.yaml @@ -58,6 +58,8 @@ dependencies: - ros-humble-moveit-servo # simulation - ros-humble-simulation + - ros-humble-tf-transformations + - transforms3d # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ilab equipments # - ros-humble-unilabos-msgs diff --git a/unilabos-win64.yaml b/unilabos-win64.yaml index 2e26fa39..19a40080 100644 --- a/unilabos-win64.yaml +++ b/unilabos-win64.yaml @@ -56,6 +56,8 @@ dependencies: - ros-humble-moveit-servo # simulation - 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 # ilab equipments # ros-humble-unilabos-msgs diff --git a/unilabos/app/controler.py b/unilabos/app/controler.py index f58f53ab..5d552565 100644 --- a/unilabos/app/controler.py +++ b/unilabos/app/controler.py @@ -31,6 +31,6 @@ def job_add(req: JobAddReq) -> JobData: action_kwargs = {"command": json.dumps(action_kwargs)} elif "command" in action_kwargs: action_kwargs = action_kwargs["command"] - 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) + # 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, server_info=req.server_info) return JobData(jobId=req.job_id) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index ebee015e..0db290a0 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -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.utils.banner_print import print_status, print_unilab_banner -from unilabos.device_mesh.resource_visalization import ResourceVisualization def parse_args(): @@ -188,11 +187,12 @@ def main(): if args_dict["visual"] != "disable": enable_rviz = args_dict["visual"] == "rviz" 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) args_dict["resources_mesh_config"] = resource_visualization.resource_model start_backend(**args_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() asyncio.set_event_loop(asyncio.new_event_loop()) @@ -201,10 +201,10 @@ def main(): time.sleep(1) else: 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: 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__": diff --git a/unilabos/app/model.py b/unilabos/app/model.py index ee7568fa..a5b8c786 100644 --- a/unilabos/app/model.py +++ b/unilabos/app/model.py @@ -51,8 +51,9 @@ class Resp(BaseModel): class JobAddReq(BaseModel): device_id: str = Field(examples=["Gripper"], description="device id") data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}]) - job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid") - node_id: str = Field(examples=["sfsfsfeq"], description="node uuid") + job_id: str = Field(examples=["job_id"], description="goal 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): diff --git a/unilabos/app/mq.py b/unilabos/app/mq.py index 018c65cb..9f870691 100644 --- a/unilabos/app/mq.py +++ b/unilabos/app/mq.py @@ -12,7 +12,7 @@ import tempfile import os 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.utils import logger from unilabos.utils.type_check import TypeEncoder @@ -26,6 +26,7 @@ class MQTTClient: def __init__(self): self.mqtt_disable = not MQConfig.lab_id 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._setup_callbacks() @@ -42,20 +43,14 @@ class MQTTClient: def _on_connect(self, client, userdata, flags, rc, properties=None): logger.info("[MQTT] Connected with result code " + str(rc)) client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0) - isok, data = devices() - if not isok: - logger.error("[MQTT] on_connect ErrorHostNotInit") - return + client.subscribe(f"labs/{MQConfig.lab_id}/pong/", 0) 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: payload_str = msg.payload.decode("utf-8") 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/": - logger.debug("job_add", type(payload_json), payload_json) if "data" not in payload_json: payload_json["data"] = {} if "action" in payload_json: @@ -65,6 +60,14 @@ class MQTTClient: job_req = JobAddReq.model_validate(payload_json) data = job_add(job_req) 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: logger.error(f"[MQTT] JSON 解析错误: {e}") @@ -181,6 +184,28 @@ class MQTTClient: self.client.publish(address, json.dumps(action_info), qos=2) 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() diff --git a/unilabos/app/web/utils/host_utils.py b/unilabos/app/web/utils/host_utils.py index a9070486..1400893b 100644 --- a/unilabos/app/web/utils/host_utils.py +++ b/unilabos/app/web/utils/host_utils.py @@ -42,7 +42,7 @@ def get_host_node_info() -> Dict[str, Any]: host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) # 获取动作客户端信息 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 diff --git a/unilabos/devices/laiyu_add_solid/__init__.py b/unilabos/devices/laiyu_add_solid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/liquid_handling/action_definition.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py similarity index 99% rename from unilabos/devices/liquid_handling/action_definition.py rename to unilabos/devices/liquid_handling/liquid_handler_abstract.py index 530703ad..c349403e 100644 --- a/unilabos/devices/liquid_handling/action_definition.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -14,7 +14,7 @@ from pylabrobot.resources import ( Well ) -class DPLiquidHandler(LiquidHandler): +class LiquidHandlerAbstract(LiquidHandler): """Extended LiquidHandler with additional operations.""" # --------------------------------------------------------------- diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index ba921c77..bcddae55 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -1,11 +1,96 @@ liquid_handler: description: Liquid handler device controlled by pylabrobot class: - module: pylabrobot.liquid_handling:LiquidHandler + module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract type: python status_types: name: String 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: type: LiquidHandlerAspirate goal: @@ -170,127 +255,6 @@ liquid_handler: - name 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: class: module: unilabos.devices.liquid_handling.revvity:Revvity diff --git a/unilabos/registry/devices/organic_miscellaneous.yaml b/unilabos/registry/devices/organic_miscellaneous.yaml index a3f6f0e3..74551e7c 100644 --- a/unilabos/registry/devices/organic_miscellaneous.yaml +++ b/unilabos/registry/devices/organic_miscellaneous.yaml @@ -12,7 +12,7 @@ separator.homemade: goal: stir_time: stir_time, stir_speed: stir_speed - settling_time": settling_time + settling_time: settling_time feedback: status: status result: diff --git a/unilabos/registry/devices/vacuum_and_purge.yaml b/unilabos/registry/devices/vacuum_and_purge.yaml index 4981f2c4..236ceddc 100644 --- a/unilabos/registry/devices/vacuum_and_purge.yaml +++ b/unilabos/registry/devices/vacuum_and_purge.yaml @@ -3,6 +3,25 @@ vacuum_pump.mock: class: module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock 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: description: Mock gas source diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index dcaaf9f5..c68e0d8d 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -1,5 +1,4 @@ import io -import json import os import sys from pathlib import Path @@ -7,10 +6,9 @@ from typing import Any import yaml -from unilabos.utils import logger 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.type_check import TypeEncoder DEFAULT_PATHS = [Path(__file__).absolute().parent] @@ -21,43 +19,16 @@ class Registry: self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值 if registry_paths: self.registry_paths.extend(registry_paths) - action_type = self._replace_type_with_class( - "ResourceCreateFromOuter", "host_node", f"动作 add_resource_from_outer" + self.ResourceCreateFromOuter = self._replace_type_with_class( + "ResourceCreateFromOuter", "host_node", f"动作 create_resource_detailed" ) - schema = ros_action_to_json_schema(action_type) - self.device_type_registry = { - "host_node": { - "description": "UniLabOS主机节点", - "class": { - "module": "unilabos.ros.nodes.presets.host_node", - "type": "python", - "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.ResourceCreateFromOuterEasy = self._replace_type_with_class( + "ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource" + ) + self.EmptyIn = self._replace_type_with_class( + "EmptyIn", "host_node", f"" + ) + self.device_type_registry = {} self.resource_type_registry = {} self._setup_called = False # 跟踪setup是否已调用 # 其他状态变量 @@ -69,9 +40,70 @@ class Registry: logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用") return - # 标记setup已被调用 - self._setup_called = True + from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type + 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----------") self.registry_paths = [Path(path).absolute() for path in self.registry_paths] for i, path in enumerate(self.registry_paths): @@ -81,6 +113,8 @@ class Registry: self.load_device_types(path) self.load_resource_types(path) logger.info("[UniLab Registry] 注册表设置完成") + # 标记setup已被调用 + self._setup_called = True def load_resource_types(self, path: os.PathLike): abs_path = Path(path).absolute() @@ -96,6 +130,9 @@ class Registry: resource_info["file_path"] = str(file.absolute()).replace("\\", "/") if "description" not in resource_info: resource_info["description"] = "" + if "icon" not in resource_info: + resource_info["icon"] = "" + resource_info["registry_type"] = "resource" self.resource_type_registry.update(data) logger.debug( 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 from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type + for i, file in enumerate(files): data = yaml.safe_load(open(file, encoding="utf-8")) if data: @@ -154,6 +192,9 @@ class Registry: device_config["file_path"] = str(file.absolute()).replace("\\", "/") if "description" not in device_config: device_config["description"] = "" + if "icon" not in device_config: + device_config["icon"] = "" + device_config["registry_type"] = "device" if "class" in device_config: # 处理状态类型 if "status_types" in device_config["class"]: @@ -169,8 +210,15 @@ class Registry: action_config["type"] = self._replace_type_with_class( 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))) - action_config["schema"] = ros_action_to_json_schema(action_config["type"]) + if action_config["type"] is not None: + 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) @@ -188,13 +236,17 @@ class Registry: def obtain_registry_device_info(self): devices = [] for device_id, device_info in self.device_type_registry.items(): - msg = { - "id": device_id, - **device_info - } + msg = {"id": device_id, **device_info} devices.append(msg) 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() diff --git a/unilabos/registry/resources/opentrons/reservoirs.yaml b/unilabos/registry/resources/opentrons/reservoirs.yaml index f966f0b0..fbc84906 100644 --- a/unilabos/registry/resources/opentrons/reservoirs.yaml +++ b/unilabos/registry/resources/opentrons/reservoirs.yaml @@ -1,35 +1,35 @@ agilent_1_reservoir_290ml: description: Agilent 1 reservoir 290ml class: - module: pylabrobot.resources.opentrons.reserviors:agilent_1_reservoir_290ml + module: pylabrobot.resources.opentrons.reservoirs:agilent_1_reservoir_290ml type: pylabrobot axygen_1_reservoir_90ml: description: Axygen 1 reservoir 90ml class: - module: pylabrobot.resources.opentrons.reserviors:axygen_1_reservoir_90ml + module: pylabrobot.resources.opentrons.reservoirs:axygen_1_reservoir_90ml type: pylabrobot nest_12_reservoir_15ml: description: Nest 12 reservoir 15ml class: - module: pylabrobot.resources.opentrons.reserviors:nest_12_reservoir_15ml + module: pylabrobot.resources.opentrons.reservoirs:nest_12_reservoir_15ml type: pylabrobot nest_1_reservoir_195ml: description: Nest 1 reservoir 195ml class: - module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_195ml + module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_195ml type: pylabrobot nest_1_reservoir_290ml: description: Nest 1 reservoir 290ml class: - module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_290ml + module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_290ml type: pylabrobot usascientific_12_reservoir_22ml: description: USAScientific 12 reservoir 22ml class: - module: pylabrobot.resources.opentrons.reserviors:usascientific_12_reservoir_22ml + module: pylabrobot.resources.opentrons.reservoirs:usascientific_12_reservoir_22ml type: pylabrobot diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 242d5a1a..cdc87756 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -189,6 +189,7 @@ def dict_from_graph(graph: nx.Graph) -> dict: def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]: # 将节点转换为字典,以便通过 ID 快速查找 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 为包含节点字典的列表 for node in nodes_list: @@ -196,7 +197,7 @@ def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]: # 找到根节点并返回 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 -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. If the config is detailed, then do nothing; @@ -442,6 +443,7 @@ def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict] Returns: None """ + from unilabos.registry.registry import lab_registry resource_class_config = resource_config.get("class", None) if resource_class_config is None: return [resource_config] @@ -485,11 +487,8 @@ def initialize_resources(resources_config) -> list[dict]: None """ - from unilabos.registry.registry import lab_registry resources = [] for resource_config in resources_config: - if resource_config["parent"] == "tip_rack" or resource_config["parent"] == "plate_well": - continue - resources.extend(initialize_resource(resource_config, lab_registry)) + resources.extend(initialize_resource(resource_config)) return resources diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py index 4f85a113..11c7afd5 100644 --- a/unilabos/ros/msgs/message_converter.py +++ b/unilabos/ros/msgs/message_converter.py @@ -348,10 +348,16 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any: if isinstance(td, NamespacedType): 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]) + elif isinstance(td, UnboundedString): + setattr(ros_msg, key, value) else: + logger.warning(f"Not Supported type: {td}") setattr(ros_msg, key, []) # FIXME 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: nested_ros_msg = convert_to_ros_msg(type(attr)(), value) setattr(ros_msg, key, nested_ros_msg) @@ -574,6 +580,7 @@ basic_type_map = { 'int64': {'type': 'integer'}, 'uint64': {'type': 'integer', 'minimum': 0}, 'double': {'type': 'number'}, + 'float': {'type': 'number'}, 'float32': {'type': 'number'}, 'float64': {'type': 'number'}, 'string': {'type': 'string'}, diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 078cb470..28b67aa4 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1,3 +1,5 @@ +import copy +import functools import json import threading 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.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 ( convert_to_ros_msg, convert_from_ros_msg, @@ -311,7 +313,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 物料传输到对应的node节点 rclient = self.create_client(ResourceAdd, "/resources/add") rclient.wait_for_service() + rclient2 = self.create_client(ResourceAdd, "/resources/add") + rclient2.wait_for_service() request = ResourceAdd.Request() + request2 = ResourceAdd.Request() command_json = json.loads(req.command) namespace = command_json["namespace"] 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"] resources = command_json["resource"] 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 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) - 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: if initialize_full: resources = initialize_resources([resources]) @@ -334,20 +351,31 @@ class BaseROS2DeviceNode(Node, Generic[T]): res.response = "OK" # 接下来该根据bind_parent_id进行assign了,目前只有plr可以进行assign,不然没有办法输入到物料系统中 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: from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.deck import Deck from pylabrobot.resources import Coordinate from pylabrobot.resources import OTDeck + from pylabrobot.resources import Plate contain_model = not isinstance(resource, Deck) if isinstance(resource, ResourcePLR): # 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: 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 action_client = ActionClient( 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 from unilabos.config.config import BasicConfig + from unilabos.ros.nodes.presets.host_node import HostNode if not BasicConfig.is_host_mode: sclient = self.create_client(SerialCommand, "/node_info_update") # 启动线程执行发送任务 @@ -413,6 +442,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): daemon=True, name=f"ROSDevice{self.device_id}_send_slave_node_info" ).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): sclient.wait_for_service() @@ -481,6 +514,17 @@ class BaseROS2DeviceNode(Node, Generic[T]): 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): """创建动作执行回调函数""" @@ -495,22 +539,21 @@ class BaseROS2DeviceNode(Node, Generic[T]): for i, action in enumerate(self._action_value_mappings["sequence"]): if i == 0: self.lab_logger().info(f"执行序列动作第一步: {action}") - getattr(self.driver_instance, action)(**kwargs) + self.get_real_function(self.driver_instance, action)[0](**kwargs) else: 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( - 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: - ACTION = getattr(self.driver_instance, action_name) - action_paramtypes = get_type_hints(ACTION) + ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) self.lab_logger().debug(f"接收到原始目标: {action_kwargs}") # 向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(): if v in ["unilabos_msgs/Resource", "sequence"]: self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}") @@ -609,7 +652,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): del future # 向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(): if v not in ["unilabos_msgs/Resource", "sequence"]: continue @@ -748,7 +791,7 @@ class ROS2DeviceNode: self.resource_tracker = DeviceNodeResourceTracker() # 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进行创建 # 创建设备类实例 diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 4338872a..732e8bbd 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -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.service import Service from unilabos_msgs.msg import Resource # type: ignore -from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \ - SerialCommand # type: ignore +from unilabos_msgs.srv import ( + ResourceAdd, + ResourceGet, + ResourceDelete, + ResourceUpdate, + ResourceList, + SerialCommand, +) # type: ignore from unique_identifier_msgs.msg import UUID from unilabos.registry.registry import lab_registry +from unilabos.resources.graphio import initialize_resource from unilabos.resources.registry import add_schema from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.msgs.message_converter import ( @@ -86,6 +93,7 @@ class HostNode(BaseROS2DeviceNode): self.__class__._instance = self # 初始化配置 + self.server_latest_timestamp = 0.0 # self.devices_config = devices_config self.resources_config = resources_config 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_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 - self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射 - self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 - self._action_value_mappings: Dict[str, Dict] = {} # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系 + self.device_machine_names: Dict[str, str] = { + device_id: "本地", + } # 存储设备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._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._last_discovery_time = 0.0 # 上次设备发现的时间 @@ -115,8 +146,11 @@ class HostNode(BaseROS2DeviceNode): self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 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() @@ -141,12 +175,36 @@ class HostNode(BaseROS2DeviceNode): ].items(): controller_config["update_rate"] = update_rate 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: for bridge in self.bridges: if hasattr(bridge, "resource_add"): 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: self.lab_logger().error("[Host Node-Resource] 添加物料出错!") self.lab_logger().error(traceback.format_exc()) @@ -156,6 +214,10 @@ class HostNode(BaseROS2DeviceNode): 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.") HostNode._ready_event.set() @@ -191,7 +253,7 @@ class HostNode(BaseROS2DeviceNode): # 如果是新设备,记录并创建ActionClient 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._create_action_clients_for_device(device_id, namespace) self._online_devices.add(device_key) @@ -200,7 +262,7 @@ class HostNode(BaseROS2DeviceNode): target=self._send_re_register, args=(sclient,), daemon=True, - name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" + name=f"ROSDevice{self.device_id}_query_host_name_{namespace}", ).start() elif device_key not in self._online_devices: # 设备重新上线 @@ -211,7 +273,7 @@ class HostNode(BaseROS2DeviceNode): target=self._send_re_register, args=(sclient,), daemon=True, - name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" + name=f"ROSDevice{self.device_id}_query_host_name_{namespace}", ).start() # 检测离线设备 @@ -255,7 +317,7 @@ class HostNode(BaseROS2DeviceNode): self, action_type, action_id, callback_group=self.callback_group ) 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:] # from unilabos.app.mq import mqtt_client # info_with_schema = ros_action_to_json_schema(action_type) @@ -268,30 +330,84 @@ class HostNode(BaseROS2DeviceNode): except Exception as 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]): - 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): + def create_resource_detailed( + 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 namespace = "/devices/" + device_id srv_address = f"/srv{namespace}/append_resource" sclient = self.create_client(SerialCommand, srv_address) sclient.wait_for_service() request = SerialCommand.Request() - request.command = json.dumps({ - "resource": resource, - "namespace": namespace, - "edge_device_id": device_id, - "bind_parent_id": bind_parent_id, - "bind_location": { - "x": bind_location.x, - "y": bind_location.y, - "z": bind_location.z, + request.command = json.dumps( + { + "resource": resource, # 单个/单组 可为 list[list[Resource]] + "namespace": namespace, + "edge_device_id": device_id, + "bind_parent_id": bind_parent_id, + "bind_location": { + "x": bind_location.x, + "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) 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: """ 根据配置初始化设备, @@ -319,7 +435,9 @@ class HostNode(BaseROS2DeviceNode): if action_id not in self._action_clients: action_type = action_value_mapping["type"] 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 # info_with_schema = ros_action_to_json_schema(action_type) # mqtt_client.publish_actions(action_name, { @@ -419,7 +537,12 @@ class HostNode(BaseROS2DeviceNode): ) 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: """ 向设备发送目标请求 @@ -431,6 +554,8 @@ class HostNode(BaseROS2DeviceNode): goal_uuid: 目标UUID,如果为None则自动生成 """ 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: self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.") return @@ -725,3 +850,148 @@ class HostNode(BaseROS2DeviceNode): # 这里可以实现返回资源列表的逻辑 self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}") 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)") diff --git a/unilabos_msgs/CMakeLists.txt b/unilabos_msgs/CMakeLists.txt index 69fbaa3a..0cd6a1e3 100644 --- a/unilabos_msgs/CMakeLists.txt +++ b/unilabos_msgs/CMakeLists.txt @@ -13,6 +13,7 @@ endif() if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) + add_compile_options(-include cstdint) endif() # find dependencies @@ -43,14 +44,10 @@ set(action_files "action/LiquidHandlerStamp.action" "action/LiquidHandlerTransfer.action" - "action/DPLiquidHandlerAddLiquid.action" - "action/DPLiquidHandlerCustomDelay.action" - "action/DPLiquidHandlerMix.action" - "action/DPLiquidHandlerMoveTo.action" - "action/DPLiquidHandlerRemoveLiquid.action" - "action/DPLiquidHandlerSetTiprack.action" - "action/DPLiquidHandlerTouchTip.action" - "action/DPLiquidHandlerTransferLiquid.action" + "action/LiquidHandlerAdd.action" + "action/LiquidHandlerMix.action" + "action/LiquidHandlerMoveTo.action" + "action/LiquidHandlerRemove.action" "action/EmptyIn.action" "action/FloatSingleInput.action" @@ -59,9 +56,10 @@ set(action_files "action/Point3DSeparateInput.action" "action/ResourceCreateFromOuter.action" + "action/ResourceCreateFromOuterEasy.action" "action/SolidDispenseAddPowderTube.action" - + "action/PumpTransfer.action" "action/Clean.action" "action/Separate.action" diff --git a/unilabos_msgs/action/DPLiquidHandlerCustomDelay.action b/unilabos_msgs/action/DPLiquidHandlerCustomDelay.action deleted file mode 100644 index 29f9b45b..00000000 --- a/unilabos_msgs/action/DPLiquidHandlerCustomDelay.action +++ /dev/null @@ -1,6 +0,0 @@ -float64 seconds -string msg ---- -bool success ---- -# 反馈 \ No newline at end of file diff --git a/unilabos_msgs/action/DPLiquidHandlerSetTiprack.action b/unilabos_msgs/action/DPLiquidHandlerSetTiprack.action deleted file mode 100644 index 437d3e3f..00000000 --- a/unilabos_msgs/action/DPLiquidHandlerSetTiprack.action +++ /dev/null @@ -1,5 +0,0 @@ -Resource[] tip_racks ---- -bool success ---- -# 反馈 \ No newline at end of file diff --git a/unilabos_msgs/action/DPLiquidHandlerTouchTip.action b/unilabos_msgs/action/DPLiquidHandlerTouchTip.action deleted file mode 100644 index e0c31046..00000000 --- a/unilabos_msgs/action/DPLiquidHandlerTouchTip.action +++ /dev/null @@ -1,5 +0,0 @@ -Resource[] targets ---- -bool success ---- -# 反馈 \ No newline at end of file diff --git a/unilabos_msgs/action/DPLiquidHandlerTransferLiquid.action b/unilabos_msgs/action/DPLiquidHandlerTransferLiquid.action deleted file mode 100644 index 39df59bb..00000000 --- a/unilabos_msgs/action/DPLiquidHandlerTransferLiquid.action +++ /dev/null @@ -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 ---- -# 反馈 \ No newline at end of file diff --git a/unilabos_msgs/action/DPLiquidHandlerAddLiquid.action b/unilabos_msgs/action/LiquidHandlerAdd.action similarity index 100% rename from unilabos_msgs/action/DPLiquidHandlerAddLiquid.action rename to unilabos_msgs/action/LiquidHandlerAdd.action diff --git a/unilabos_msgs/action/LiquidHandlerAspirate.action b/unilabos_msgs/action/LiquidHandlerAspirate.action index f03ad07a..9ba17068 100644 --- a/unilabos_msgs/action/LiquidHandlerAspirate.action +++ b/unilabos_msgs/action/LiquidHandlerAspirate.action @@ -5,7 +5,7 @@ float64[] flow_rates geometry_msgs/Point[] offsets float64[] liquid_height float64[] blow_out_air_volume -string spread="wide" +string spread --- bool success --- \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerDispense.action b/unilabos_msgs/action/LiquidHandlerDispense.action index ba5360ae..73c4d0f4 100644 --- a/unilabos_msgs/action/LiquidHandlerDispense.action +++ b/unilabos_msgs/action/LiquidHandlerDispense.action @@ -5,7 +5,7 @@ int32[] use_channels float64[] flow_rates geometry_msgs/Point[] offsets int32[] blow_out_air_volume -string spread="wide" +string spread --- # 结果字段 bool success diff --git a/unilabos_msgs/action/DPLiquidHandlerMix.action b/unilabos_msgs/action/LiquidHandlerMix.action similarity index 100% rename from unilabos_msgs/action/DPLiquidHandlerMix.action rename to unilabos_msgs/action/LiquidHandlerMix.action diff --git a/unilabos_msgs/action/DPLiquidHandlerMoveTo.action b/unilabos_msgs/action/LiquidHandlerMoveTo.action similarity index 100% rename from unilabos_msgs/action/DPLiquidHandlerMoveTo.action rename to unilabos_msgs/action/LiquidHandlerMoveTo.action diff --git a/unilabos_msgs/action/DPLiquidHandlerRemoveLiquid.action b/unilabos_msgs/action/LiquidHandlerRemove.action similarity index 100% rename from unilabos_msgs/action/DPLiquidHandlerRemoveLiquid.action rename to unilabos_msgs/action/LiquidHandlerRemove.action diff --git a/unilabos_msgs/action/LiquidHandlerTransfer.action b/unilabos_msgs/action/LiquidHandlerTransfer.action index b6e3be32..39df59bb 100644 --- a/unilabos_msgs/action/LiquidHandlerTransfer.action +++ b/unilabos_msgs/action/LiquidHandlerTransfer.action @@ -1,11 +1,25 @@ -# Bio -Resource source +float64[] asp_vols +float64[] dis_vols +Resource[] sources Resource[] targets -float64 source_vol -float64[] ratios -float64[] target_vols -float64 aspiration_flow_rate -float64[] dispense_flow_rates +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 ---- \ No newline at end of file +--- +# 反馈 \ No newline at end of file diff --git a/unilabos_msgs/action/ResourceCreateFromOuterEasy.action b/unilabos_msgs/action/ResourceCreateFromOuterEasy.action new file mode 100644 index 00000000..cc832a71 --- /dev/null +++ b/unilabos_msgs/action/ResourceCreateFromOuterEasy.action @@ -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 +---