Compare commits

..

20 Commits

Author SHA1 Message Date
Xuwznln
965bf36e8d Add restart.
Temp allow action message.
2026-01-11 21:25:59 +08:00
Xuwznln
aacf3497e0 Add no_update_feedback option. 2026-01-09 17:18:39 +08:00
Xuwznln
657f952e7a Create session_id by edge. 2026-01-09 12:01:57 +08:00
Xuwznln
0165590290 bump version to 0.10.15 2026-01-08 15:37:49 +08:00
Xuwznln
daea1ab54d temp cancel update req 2026-01-08 15:26:31 +08:00
Xuwznln
93cb307396 Fix update with different spot and same parent 2026-01-08 03:46:00 +08:00
Xuwznln
1c312772ae Force update resource when adding new resource / transfer to another resource 2026-01-08 03:07:12 +08:00
Xuwznln
bad1db5094 location not passed to ItemizedCarrier when assign child resource 2026-01-08 03:07:11 +08:00
Xuwznln
f26eb69eca Fix size not pass through. 2026-01-08 03:07:11 +08:00
Xuwznln
12c0770c92 Fix build on macos-intel 2026-01-07 21:11:10 +08:00
Xuwznln
3d2d428a96 Update README.md
Modify resource_tracker file module path.

(cherry picked from commit 8066c200b9)
2026-01-07 20:54:43 +08:00
Xuwznln
78bf57f590 Bump version to 0.10.4 2026-01-07 20:41:23 +08:00
Xuwznln
e227cddab3 Update LICENSE 2026-01-07 20:40:02 +08:00
Xuwznln
f2b993643f Fix drag materials. 2026-01-07 19:40:29 +08:00
Xuwznln
2e14bf197c Fix and tested new create_resource. 2026-01-07 19:26:42 +08:00
Xuwznln
66c18c080a Update create_resource to resource tree mode. 2026-01-07 02:03:43 +08:00
Xuwznln
a1c34f138e Close #208. Fix mock devices.
(cherry picked from commit 28f93737ac)
2025-12-28 23:24:44 +08:00
Xianwei Qi
75bb5ec553 test_transfer_liquid_2 2025-12-26 16:42:50 +08:00
Xianwei Qi
bb95c89829 Merge branch 'dev' of https://github.com/dptech-corp/Uni-Lab-OS into dev 2025-12-26 16:25:19 +08:00
Xianwei Qi
394c140830 test_transfer_liquid 2025-12-26 16:24:55 +08:00
67 changed files with 1596 additions and 1306 deletions

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: 0.10.13 version: 0.10.15
source: source:
path: ../unilabos path: ../unilabos
@@ -9,7 +9,7 @@ source:
build: build:
python: python:
entry_points: entry_points:
- unilab = unilabos.app.main:main - unilab = unilabos.app.main:main
script: script:
- set PIP_NO_INDEX= - set PIP_NO_INDEX=
- if: win - if: win
@@ -25,7 +25,6 @@ build:
- cp $RECIPE_DIR/../setup.py $SRC_DIR - cp $RECIPE_DIR/../setup.py $SRC_DIR
- $PYTHON -m pip install $SRC_DIR - $PYTHON -m pip install $SRC_DIR
requirements: requirements:
host: host:
- python ==3.11.11 - python ==3.11.11
@@ -87,6 +86,6 @@ requirements:
- uni-lab::ros-humble-unilabos-msgs - uni-lab::ros-humble-unilabos-msgs
about: about:
repository: https://github.com/dptech-corp/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS
license: GPL-3.0-only license: GPL-3.0-only
description: "Uni-Lab-OS" description: "Uni-Lab-OS"

26
.cursorignore Normal file
View File

@@ -0,0 +1,26 @@
.conda
# .github
.idea
# .vscode
output
pylabrobot_repo
recipes
scripts
service
temp
# unilabos/test
# unilabos/app/web
unilabos/device_mesh
unilabos_data
unilabos_msgs
unilabos.egg-info
CONTRIBUTORS
# LICENSE
MANIFEST.in
pyrightconfig.json
# README.md
# README_zh.md
setup.py
setup.cfg
.gitattrubutes
**/__pycache__

View File

@@ -24,7 +24,7 @@ jobs:
platform: linux-64 platform: linux-64
env_file: unilabos-linux-64.yaml env_file: unilabos-linux-64.yaml
script_ext: sh script_ext: sh
- os: macos-13 # Intel - os: macos-15 # Intel (via Rosetta)
platform: osx-64 platform: osx-64
env_file: unilabos-osx-64.yaml env_file: unilabos-osx-64.yaml
script_ext: sh script_ext: sh

View File

@@ -27,7 +27,7 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux-64 platform: linux-64
env_file: unilabos-linux-64.yaml env_file: unilabos-linux-64.yaml
- os: macos-13 # Intel - os: macos-15 # Intel (via Rosetta)
platform: osx-64 platform: osx-64
env_file: unilabos-osx-64.yaml env_file: unilabos-osx-64.yaml
- os: macos-latest # ARM64 - os: macos-latest # ARM64

View File

@@ -26,7 +26,7 @@ jobs:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux-64 platform: linux-64
- os: macos-13 # Intel - os: macos-15 # Intel (via Rosetta)
platform: osx-64 platform: osx-64
- os: macos-latest # ARM64 - os: macos-latest # ARM64
platform: osx-arm64 platform: osx-arm64

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
cursor_docs/
configs/ configs/
temp/ temp/
output/ output/

17
NOTICE Normal file
View File

@@ -0,0 +1,17 @@
# Uni-Lab-OS Licensing Notice
This project uses a dual licensing structure:
## 1. Main Framework - GPL-3.0
- unilabos/ (except unilabos/devices/)
- docs/
- tests/
See [LICENSE](LICENSE) for details.
## 2. Device Drivers - DP Technology Proprietary License
- unilabos/devices/
See [unilabos/devices/LICENSE](unilabos/devices/LICENSE) for details.

View File

@@ -8,17 +8,13 @@
**English** | [中文](README_zh.md) **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/deepmodeling/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/deepmodeling/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/deepmodeling/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/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab-OS is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows. 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 ## Key Features
- Multi-device integration management - Multi-device integration management
@@ -31,11 +27,13 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
Detailed documentation can be found at: Detailed documentation can be found at:
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/) - [Online Documentation](https://deepmodeling.github.io/Uni-Lab-OS/)
## Quick Start ## Quick Start
Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system: 1. Setup Conda Environment
Uni-Lab-OS recommends using `mamba` for environment management:
```bash ```bash
# Create new environment # Create new environment
@@ -44,28 +42,54 @@ mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
``` ```
## Install Dev Uni-Lab-OS 2. Install Dev Uni-Lab-OS
```bash ```bash
# Clone the repository # Clone the repository
git clone https://github.com/dptech-corp/Uni-Lab-OS.git git clone https://github.com/deepmodeling/Uni-Lab-OS.git
cd Uni-Lab-OS cd Uni-Lab-OS
# Install Uni-Lab-OS # Install Uni-Lab-OS
pip install . pip install .
``` ```
3. Start Uni-Lab System: 3. Start Uni-Lab System
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html) Please refer to [Documentation - Boot Examples](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
4. Best Practice
See [Best Practice Guide](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html)
## Message Format ## Message Format
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. Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) page.
## Citation
If you use [Uni-Lab-OS](https://arxiv.org/abs/2512.21766) in academic research, please cite:
```bibtex
@article{gao2025unilabos,
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
doi = {10.48550/arXiv.2512.21766},
publisher = {arXiv},
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and
Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and
Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and
Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
year = {2025}
}
```
## License ## License
This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details. This project uses a dual licensing structure:
- **Main Framework**: GPL-3.0 - see [LICENSE](LICENSE)
- **Device Drivers** (`unilabos/devices/`): DP Technology Proprietary License
See [NOTICE](NOTICE) for complete licensing details.
## Project Statistics ## Project Statistics
@@ -77,4 +101,4 @@ This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for det
## Contact Us ## 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/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)

View File

@@ -8,17 +8,13 @@
[English](README.md) | **中文** [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 Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/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/deepmodeling/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/deepmodeling/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/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。 Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
## 🏆 比赛
欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成!
## 核心特点 ## 核心特点
- 多设备集成管理 - 多设备集成管理
@@ -31,7 +27,7 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
详细文档可在以下位置找到: 详细文档可在以下位置找到:
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/) - [在线文档](https://deepmodeling.github.io/Uni-Lab-OS/)
## 快速开始 ## 快速开始
@@ -50,24 +46,50 @@ mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git git clone https://github.com/deepmodeling/Uni-Lab-OS.git
cd Uni-Lab-OS cd Uni-Lab-OS
# 安装 Uni-Lab-OS # 安装 Uni-Lab-OS
pip install . pip install .
``` ```
3. 启动 Uni-Lab 系统: 3. 启动 Uni-Lab 系统
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html) 请见[文档-启动样例](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
4. 最佳实践
请见[最佳实践指南](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html)
## 消息格式 ## 消息格式
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。 Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。
## 引用
如果您在学术研究中使用 [Uni-Lab-OS](https://arxiv.org/abs/2512.21766),请引用:
```bibtex
@article{gao2025unilabos,
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
doi = {10.48550/arXiv.2512.21766},
publisher = {arXiv},
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and
Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and
Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and
Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
year = {2025}
}
```
## 许可证 ## 许可证
项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。 项目采用双许可证结构:
- **主框架**GPL-3.0 - 详见 [LICENSE](LICENSE)
- **设备驱动** (`unilabos/devices/`):深势科技专有许可证
完整许可证说明请参阅 [NOTICE](NOTICE)。
## 项目统计 ## 项目统计
@@ -79,4 +101,4 @@ Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在
## 联系我们 ## 联系我们
- 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/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1807,7 +1807,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
#### 14.5 社区支持 #### 14.5 社区支持
- **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/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) - **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
--- ---

View File

@@ -463,7 +463,7 @@ Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法
### 使用示例 ### 使用示例
```python ```python
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance from unilabos.resources.resource_tracker import ResourceDictInstance
# 旧格式节点 # 旧格式节点
old_format_node = { old_format_node = {
@@ -477,10 +477,10 @@ old_format_node = {
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node) instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
# 访问标准化后的数据 # 访问标准化后的数据
print(instance.res_content.id) # "pump_1" print(instance.res_content.id) # "pump_1"
print(instance.res_content.uuid) # 自动生成的 UUID print(instance.res_content.uuid) # 自动生成的 UUID
print(instance.res_content.config) # {} print(instance.res_content.config) # {}
print(instance.res_content.data) # {} print(instance.res_content.data) # {}
``` ```
### 格式迁移建议 ### 格式迁移建议
@@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel):
- 在 Web 界面中使用模板创建 - 在 Web 界面中使用模板创建
- 参考示例文件:`test/experiments/` 目录 - 参考示例文件:`test/experiments/` 目录
- 查看 ResourceDict 源码了解完整定义 - 查看 ResourceDict 源码了解完整定义
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions) - [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions)

View File

@@ -37,7 +37,7 @@
#### 第一步:下载预打包环境 #### 第一步:下载预打包环境
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) 1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
2. 选择最新的成功构建记录(绿色勾号 ✓) 2. 选择最新的成功构建记录(绿色勾号 ✓)
@@ -189,13 +189,13 @@ conda activate unilab
### 第一步:克隆仓库 ### 第一步:克隆仓库
```bash ```bash
git clone https://github.com/dptech-corp/Uni-Lab-OS.git git clone https://github.com/deepmodeling/Uni-Lab-OS.git
cd Uni-Lab-OS cd Uni-Lab-OS
``` ```
如果您需要贡献代码,建议先 Fork 仓库: 如果您需要贡献代码,建议先 Fork 仓库:
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS 1. 访问 https://github.com/deepmodeling/Uni-Lab-OS
2. 点击右上角的 "Fork" 按钮 2. 点击右上角的 "Fork" 按钮
3. Clone 您的 Fork 版本: 3. Clone 您的 Fork 版本:
```bash ```bash
@@ -240,7 +240,7 @@ pip uninstall unilabos -y
# 克隆 dev 分支(如果还未克隆) # 克隆 dev 分支(如果还未克隆)
cd /path/to/your/workspace cd /path/to/your/workspace
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git git clone -b dev https://github.com/deepmodeling/Uni-Lab-OS.git
# 或者如果已经克隆,切换到 dev 分支 # 或者如果已经克隆,切换到 dev 分支
cd Uni-Lab-OS cd Uni-Lab-OS
git checkout dev git checkout dev
@@ -503,9 +503,9 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f
## 需要帮助? ## 需要帮助?
- **故障排查**: 查看更详细的故障排查信息 - **故障排查**: 查看更详细的故障排查信息
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues) - **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **开发者文档**: 查看开发者指南获取更多技术细节 - **开发者文档**: 查看开发者指南获取更多技术细节
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions) - **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
--- ---

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.10.13 version: 0.10.15
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
target_directory: src target_directory: src
@@ -17,7 +17,7 @@ build:
- bash $SRC_DIR/build_ament_cmake.sh - bash $SRC_DIR/build_ament_cmake.sh
about: about:
repository: https://github.com/dptech-corp/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS
license: BSD-3-Clause license: BSD-3-Clause
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS." description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."

View File

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

View File

@@ -126,7 +126,7 @@ If installation fails:
For more help: For more help:
- Documentation: docs/user_guide/installation.md - Documentation: docs/user_guide/installation.md
- Quick Start: QUICK_START_CONDA_PACK.md - Quick Start: QUICK_START_CONDA_PACK.md
- Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues - Issues: https://github.com/deepmodeling/Uni-Lab-OS/issues
License: License:
-------- --------
@@ -134,7 +134,7 @@ License:
UniLabOS is licensed under GPL-3.0-only. UniLabOS is licensed under GPL-3.0-only.
See LICENSE file for details. See LICENSE file for details.
Repository: https://github.com/dptech-corp/Uni-Lab-OS Repository: https://github.com/deepmodeling/Uni-Lab-OS
""" """
return readme return readme

View File

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

7
tests/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
测试包根目录。
让 `tests.*` 模块可以被正常 import例如给 `unilabos` 下的测试入口使用)。
"""

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,5 @@
"""
液体处理设备相关测试。
"""

View File

@@ -0,0 +1,505 @@
import asyncio
from dataclasses import dataclass
from typing import Any, Iterable, List, Optional, Sequence, Tuple
import pytest
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
@dataclass(frozen=True)
class DummyContainer:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyContainer({self.name})"
@dataclass(frozen=True)
class DummyTipSpot:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyTipSpot({self.name})"
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
for i in range(n):
yield [DummyTipSpot(f"tip_{i}")]
class FakeLiquidHandler(LiquidHandlerAbstract):
"""不初始化真实 backend/deck仅用来记录 transfer_liquid 内部调用序列。"""
def __init__(self, channel_num: int = 8):
# 不调用 super().__init__避免真实硬件/后端依赖
self.channel_num = channel_num
self.support_touch_tip = True
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
async def aspirate(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"aspirate",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def dispense(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"dispense",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def discard_tips(self, use_channels=None, *args, **kwargs):
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
async def custom_delay(self, seconds=0, msg=None):
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
async def touch_tip(self, targets):
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
self.calls.append(("touch_tip", {"targets": targets}))
async def mix(self, targets, mix_time=None, mix_vol=None, height_to_bottom=None, offsets=None, mix_rate=None, none_keys=None):
self.calls.append(
(
"mix",
{
"targets": targets,
"mix_time": mix_time,
"mix_vol": mix_vol,
},
)
)
def run(coro):
return asyncio.run(coro)
def test_one_to_one_single_channel_basic_calls():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(3)]
targets = [DummyContainer(f"T{i}") for i in range(3)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 2, 3],
dis_vols=[4, 5, 6],
mix_times=None, # 应该仍能执行(不 mix
)
)
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
assert [c[0] for c in lh.calls].count("aspirate") == 3
assert [c[0] for c in lh.calls].count("dispense") == 3
assert [c[0] for c in lh.calls].count("discard_tips") == 3
# 每次 aspirate/dispense 都是单孔列表
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [sources[0]]
assert aspirates[0]["vols"] == [1.0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[2]["resources"] == [targets[2]]
assert dispenses[2]["vols"] == [6.0]
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(16))
source = DummyContainer("S0")
target = DummyContainer("T0")
run(
lh.transfer_liquid(
sources=[source],
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5],
dis_vols=[5],
mix_stage="before",
mix_times=1,
mix_vol=3,
)
)
names = [name for name, _ in lh.calls]
assert names.count("mix") == 1
assert names.index("mix") < names.index("aspirate")
def test_one_to_one_eight_channel_groups_by_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = list(range(1, 17))
dis_vols = list(range(101, 117))
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0, # 触发逻辑但不 mix
)
)
# 16 个任务 -> 2 组,每组 8 通道一起做
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == 2
assert len(dispenses) == 2
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
assert dispenses[1]["resources"] == targets[8:16]
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(9)]
targets = [DummyContainer(f"T{i}") for i in range(9)]
with pytest.raises(ValueError, match="multiple of 8"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 9,
dis_vols=[1] * 9,
mix_times=0,
)
)
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(512))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = [i + 1 for i in range(16)]
dis_vols = [200 + i for i in range(16)]
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
offsets = [f"offset_{i}" for i in range(16)]
liquid_heights = [i * 0.5 for i in range(16)]
blow_out_air_volume = [i + 0.05 for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
liquid_height=liquid_heights,
blow_out_air_volume=blow_out_air_volume,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
for batch_idx in range(2):
start = batch_idx * 8
end = start + 8
asp_call = aspirates[batch_idx]
dis_call = dispenses[batch_idx]
assert asp_call["resources"] == sources[start:end]
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
assert asp_call["offsets"] == offsets[start:end]
assert asp_call["liquid_height"] == liquid_heights[start:end]
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
assert dis_call["offsets"] == offsets[start:end]
assert dis_call["liquid_height"] == liquid_heights[start:end]
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(1024))
sources = [DummyContainer(f"S{i}") for i in range(32)]
targets = [DummyContainer(f"T{i}") for i in range(32)]
asp_vols = [i + 1 for i in range(32)]
dis_vols = [300 + i for i in range(32)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0,
)
)
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(pick_calls) == 4
assert len(aspirates) == len(dispenses) == 4
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[-1]["resources"] == sources[24:32]
assert dispenses[0]["resources"] == targets[0:8]
assert dispenses[-1]["resources"] == targets[24:32]
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(3)]
dis_vols = [10, 20, 30] # sum=60
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 1
assert aspirates[0]["resources"] == [source]
assert aspirates[0]["vols"] == [60.0]
assert aspirates[0]["use_channels"] == [0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
def test_one_to_many_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(128))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(8)]
dis_vols = [i + 1 for i in range(8)]
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [source] * 8
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[0]["resources"] == targets
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
assert all(d["resources"] == [target] for d in dispenses)
def test_many_to_one_single_channel_before_stage_mixes_target_once():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer("S0"), DummyContainer("S1")]
target = DummyContainer("T")
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5, 6],
dis_vols=1,
mix_stage="before",
mix_times=2,
mix_vol=4,
)
)
names = [name for name, _ in lh.calls]
assert names[0] == "mix"
assert names.count("mix") == 1
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
dis_vols = [1, 2, 3]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=dis_vols, # 比例模式
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
def test_many_to_one_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(8)]
target = DummyContainer("T")
asp_vols = [10 + i for i in range(8)]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert aspirates[0]["resources"] == sources
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
assert dispenses[0]["resources"] == [target] * 8
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer("S0"), DummyContainer("S1")]
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
with pytest.raises(ValueError, match="Unsupported transfer mode"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1, 1],
mix_times=0,
)
)

View File

@@ -2,9 +2,8 @@ import pytest
import json import json
import os import os
from pylabrobot.resources import Resource as ResourcePLR
from unilabos.resources.graphio import resource_bioyond_to_plr from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck

View File

@@ -11,10 +11,10 @@ import os
# 添加项目根目录到路径 # 添加项目根目录到路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
# 导入测试模块 # 导入测试模块(统一从 tests 包获取)
from test.ros.msgs.test_basic import TestBasicFunctionality from tests.ros.msgs.test_basic import TestBasicFunctionality
from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion from tests.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping from tests.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
def run_tests(): def run_tests():

View File

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

View File

@@ -1,6 +1,6 @@
import threading import threading
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.utils import logger from unilabos.utils import logger

View File

@@ -19,6 +19,11 @@ if unilabos_dir not in sys.path:
from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.config.config import load_config, BasicConfig, HTTPConfig from unilabos.config.config import load_config, BasicConfig, HTTPConfig
from unilabos.app.utils import cleanup_for_restart
# Global restart flags (used by ws_client and web/server)
_restart_requested: bool = False
_restart_reason: str = ""
def load_config_from_file(config_path): def load_config_from_file(config_path):
@@ -156,6 +161,11 @@ def parse_args():
default=False, default=False,
help="Complete registry information", help="Complete registry information",
) )
parser.add_argument(
"--no_update_feedback",
action="store_true",
help="Disable sending update feedback to server",
)
# workflow upload subcommand # workflow upload subcommand
workflow_parser = subparsers.add_parser( workflow_parser = subparsers.add_parser(
"workflow_upload", "workflow_upload",
@@ -297,6 +307,7 @@ def main():
BasicConfig.is_host_mode = not args_dict.get("is_slave", False) BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", False) BasicConfig.upload_registry = args_dict.get("upload_registry", False)
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
BasicConfig.communication_protocol = "websocket" BasicConfig.communication_protocol = "websocket"
machine_name = os.popen("hostname").read().strip() machine_name = os.popen("hostname").read().strip()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
@@ -315,7 +326,7 @@ def main():
from unilabos.app.web import start_server from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources from unilabos.app.register import register_devices_and_resources
from unilabos.resources.graphio import modify_to_backend_format from unilabos.resources.graphio import modify_to_backend_format
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
# 显示启动横幅 # 显示启动横幅
print_unilab_banner(args_dict) print_unilab_banner(args_dict)
@@ -418,7 +429,7 @@ def main():
# 如果从远端获取了物料信息,则与本地物料进行同步 # 如果从远端获取了物料信息,则与本地物料进行同步
if request_startup_json and "nodes" in request_startup_json: if request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info") print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"]) remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
resource_tree_set.merge_remote_resources(remote_tree_set) resource_tree_set.merge_remote_resources(remote_tree_set)
print_status("远端物料同步完成", "info") print_status("远端物料同步完成", "info")
@@ -497,13 +508,19 @@ def main():
time.sleep(1) time.sleep(1)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server( restart_requested = start_server(
open_browser=not args_dict["disable_browser"], open_browser=not args_dict["disable_browser"],
port=BasicConfig.port, port=BasicConfig.port,
) )
if restart_requested:
print_status("[Main] Restart requested, cleaning up...", "info")
cleanup_for_restart()
return
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(
# 启动服务器默认支持WebSocket触发重启
restart_requested = start_server(
open_browser=not args_dict["disable_browser"], open_browser=not args_dict["disable_browser"],
port=BasicConfig.port, port=BasicConfig.port,
) )

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

@@ -0,0 +1,144 @@
"""
UniLabOS 应用工具函数
提供清理、重启等工具函数
"""
import gc
import os
import threading
import time
from unilabos.utils.banner_print import print_status
def cleanup_for_restart() -> bool:
"""
Clean up all resources for restart without exiting the process.
This function prepares the system for re-initialization by:
1. Stopping all communication clients
2. Destroying ROS nodes
3. Resetting singletons
4. Waiting for threads to finish
Returns:
bool: True if cleanup was successful, False otherwise
"""
print_status("[Restart] Starting cleanup for restart...", "info")
# Step 1: Stop WebSocket communication client
print_status("[Restart] Step 1: Stopping WebSocket client...", "info")
try:
from unilabos.app.communication import get_communication_client
comm_client = get_communication_client()
if comm_client is not None:
comm_client.stop()
print_status("[Restart] WebSocket client stopped", "info")
except Exception as e:
print_status(f"[Restart] Error stopping WebSocket: {e}", "warning")
# Step 2: Get HostNode and cleanup ROS
print_status("[Restart] Step 2: Cleaning up ROS nodes...", "info")
try:
from unilabos.ros.nodes.presets.host_node import HostNode
import rclpy
from rclpy.timer import Timer
host_instance = HostNode.get_instance(timeout=5)
if host_instance is not None:
print_status(f"[Restart] Found HostNode: {host_instance.device_id}", "info")
# Gracefully shutdown background threads
print_status("[Restart] Shutting down background threads...", "info")
HostNode.shutdown_background_threads(timeout=5.0)
print_status("[Restart] Background threads shutdown complete", "info")
# Stop discovery timer
if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer):
host_instance._discovery_timer.cancel()
print_status("[Restart] Discovery timer cancelled", "info")
# Destroy device nodes
device_count = len(host_instance.devices_instances)
print_status(f"[Restart] Destroying {device_count} device instances...", "info")
for device_id, device_node in list(host_instance.devices_instances.items()):
try:
if hasattr(device_node, "ros_node_instance") and device_node.ros_node_instance is not None:
device_node.ros_node_instance.destroy_node()
print_status(f"[Restart] Device {device_id} destroyed", "info")
except Exception as e:
print_status(f"[Restart] Error destroying device {device_id}: {e}", "warning")
# Clear devices instances
host_instance.devices_instances.clear()
host_instance.devices_names.clear()
# Destroy host node
try:
host_instance.destroy_node()
print_status("[Restart] HostNode destroyed", "info")
except Exception as e:
print_status(f"[Restart] Error destroying HostNode: {e}", "warning")
# Reset HostNode state
HostNode.reset_state()
print_status("[Restart] HostNode state reset", "info")
# Shutdown executor first (to stop executor.spin() gracefully)
if hasattr(rclpy, "__executor") and rclpy.__executor is not None:
try:
rclpy.__executor.shutdown()
rclpy.__executor = None # Clear for restart
print_status("[Restart] ROS executor shutdown complete", "info")
except Exception as e:
print_status(f"[Restart] Error shutting down executor: {e}", "warning")
# Shutdown rclpy
if rclpy.ok():
rclpy.shutdown()
print_status("[Restart] rclpy shutdown complete", "info")
except ImportError as e:
print_status(f"[Restart] ROS modules not available: {e}", "warning")
except Exception as e:
print_status(f"[Restart] Error in ROS cleanup: {e}", "warning")
return False
# Step 3: Reset communication client singleton
print_status("[Restart] Step 3: Resetting singletons...", "info")
try:
from unilabos.app import communication
if hasattr(communication, "_communication_client"):
communication._communication_client = None
print_status("[Restart] Communication client singleton reset", "info")
except Exception as e:
print_status(f"[Restart] Error resetting communication singleton: {e}", "warning")
# Step 4: Wait for threads to finish
print_status("[Restart] Step 4: Waiting for threads to finish...", "info")
time.sleep(3) # Give threads time to finish
# Check remaining threads
remaining_threads = []
for t in threading.enumerate():
if t.name != "MainThread" and t.is_alive():
remaining_threads.append(t.name)
if remaining_threads:
print_status(
f"[Restart] Warning: {len(remaining_threads)} threads still running: {remaining_threads}", "warning"
)
else:
print_status("[Restart] All threads stopped", "info")
# Step 5: Force garbage collection
print_status("[Restart] Step 5: Running garbage collection...", "info")
gc.collect()
gc.collect() # Run twice for weak references
print_status("[Restart] Garbage collection complete", "info")
print_status("[Restart] Cleanup complete. Ready for re-initialization.", "info")
return True

View File

@@ -6,12 +6,10 @@ HTTP客户端模块
import json import json
import os import os
import time
from threading import Thread
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import requests import requests
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.utils.log import info from unilabos.utils.log import info
from unilabos.config.config import HTTPConfig, BasicConfig from unilabos.config.config import HTTPConfig, BasicConfig
from unilabos.utils import logger from unilabos.utils import logger

View File

@@ -6,7 +6,6 @@ Web服务器模块
import webbrowser import webbrowser
import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import Response from starlette.responses import Response
@@ -96,7 +95,7 @@ def setup_server() -> FastAPI:
return app return app
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None: def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool:
""" """
启动服务器 启动服务器
@@ -104,7 +103,14 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
host: 服务器主机 host: 服务器主机
port: 服务器端口 port: 服务器端口
open_browser: 是否自动打开浏览器 open_browser: 是否自动打开浏览器
Returns:
bool: True if restart was requested, False otherwise
""" """
import threading
import time
from uvicorn import Config, Server
# 设置服务器 # 设置服务器
setup_server() setup_server()
@@ -123,7 +129,37 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
# 启动服务器 # 启动服务器
info(f"[Web] 启动FastAPI服务器: {host}:{port}") info(f"[Web] 启动FastAPI服务器: {host}:{port}")
uvicorn.run(app, host=host, port=port, log_config=log_config)
# 使用支持重启的模式
config = Config(app=app, host=host, port=port, log_config=log_config)
server = Server(config)
# 启动服务器线程
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
server_thread.start()
info("[Web] Server started, monitoring for restart requests...")
# 监控重启标志
import unilabos.app.main as main_module
while server_thread.is_alive():
if hasattr(main_module, "_restart_requested") and main_module._restart_requested:
info(
f"[Web] Restart requested via WebSocket, reason: {getattr(main_module, '_restart_reason', 'unknown')}"
)
main_module._restart_requested = False
# 停止服务器
server.should_exit = True
server_thread.join(timeout=5)
info("[Web] Server stopped, ready for restart")
return True
time.sleep(1)
return False
# 当脚本直接运行时启动服务器 # 当脚本直接运行时启动服务器

View File

@@ -359,7 +359,7 @@ class MessageProcessor:
self.device_manager = device_manager self.device_manager = device_manager
self.queue_processor = None # 延迟设置 self.queue_processor = None # 延迟设置
self.websocket_client = None # 延迟设置 self.websocket_client = None # 延迟设置
self.session_id = "" self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
# WebSocket连接 # WebSocket连接
self.websocket = None self.websocket = None
@@ -488,7 +488,16 @@ class MessageProcessor:
async for message in self.websocket: async for message in self.websocket:
try: try:
data = json.loads(message) data = json.loads(message)
await self._process_message(data) message_type = data.get("action", "")
message_data = data.get("data")
if self.session_id and self.session_id == data.get("edge_session"):
await self._process_message(message_type, message_data)
else:
if message_type.endswith("_material"):
logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}")
logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}")
else:
await self._process_message(message_type, message_data)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error(f"[MessageProcessor] Invalid JSON received: {message}") logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
except Exception as e: except Exception as e:
@@ -554,11 +563,8 @@ class MessageProcessor:
finally: finally:
logger.debug("[MessageProcessor] Send handler stopped") logger.debug("[MessageProcessor] Send handler stopped")
async def _process_message(self, data: Dict[str, Any]): async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
"""处理收到的消息""" """处理收到的消息"""
message_type = data.get("action", "")
message_data = data.get("data")
logger.debug(f"[MessageProcessor] Processing message: {message_type}") logger.debug(f"[MessageProcessor] Processing message: {message_type}")
try: try:
@@ -571,14 +577,19 @@ class MessageProcessor:
elif message_type == "cancel_action" or message_type == "cancel_task": elif message_type == "cancel_action" or message_type == "cancel_task":
await self._handle_cancel_action(message_data) await self._handle_cancel_action(message_data)
elif message_type == "add_material": elif message_type == "add_material":
# noinspection PyTypeChecker
await self._handle_resource_tree_update(message_data, "add") await self._handle_resource_tree_update(message_data, "add")
elif message_type == "update_material": elif message_type == "update_material":
# noinspection PyTypeChecker
await self._handle_resource_tree_update(message_data, "update") await self._handle_resource_tree_update(message_data, "update")
elif message_type == "remove_material": elif message_type == "remove_material":
# noinspection PyTypeChecker
await self._handle_resource_tree_update(message_data, "remove") await self._handle_resource_tree_update(message_data, "remove")
elif message_type == "session_id": # elif message_type == "session_id":
self.session_id = message_data.get("session_id") # self.session_id = message_data.get("session_id")
logger.info(f"[MessageProcessor] Session ID: {self.session_id}") # logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
elif message_type == "request_restart":
await self._handle_request_restart(message_data)
else: else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}") logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -888,6 +899,49 @@ class MessageProcessor:
) )
thread.start() thread.start()
async def _handle_request_restart(self, data: Dict[str, Any]):
"""
处理重启请求
当LabGo发送request_restart时执行清理并触发重启
"""
reason = data.get("reason", "unknown")
delay = data.get("delay", 2) # 默认延迟2秒
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
# 发送确认消息
if self.websocket_client:
await self.websocket_client.send_message({
"action": "restart_acknowledged",
"data": {"reason": reason, "delay": delay}
})
# 设置全局重启标志
import unilabos.app.main as main_module
main_module._restart_requested = True
main_module._restart_reason = reason
# 延迟后执行清理
await asyncio.sleep(delay)
# 在新线程中执行清理,避免阻塞当前事件循环
def do_cleanup():
import time
time.sleep(0.5) # 给当前消息处理完成的时间
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
try:
from unilabos.app.utils import cleanup_for_restart
if cleanup_for_restart():
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
else:
logger.error("[MessageProcessor] Cleanup failed")
except Exception as e:
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
cleanup_thread.start()
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
async def _send_action_state_response( async def _send_action_state_response(
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
): ):
@@ -1240,7 +1294,7 @@ class WebSocketClient(BaseCommunicationClient):
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
logger.debug(f"[WebSocketClient] Device status published: {device_id}.{property_name}") logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_job_status( def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
@@ -1282,7 +1336,7 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor.send_message(message) self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.debug(f"[WebSocketClient] Job status published: {job_log} - {status}") logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None: def send_ping(self, ping_id: str, timestamp: float) -> None:
"""发送ping消息""" """发送ping消息"""
@@ -1313,17 +1367,55 @@ class WebSocketClient(BaseCommunicationClient):
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}") logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
def publish_host_ready(self) -> None: def publish_host_ready(self) -> None:
"""发布host_node ready信号""" """发布host_node ready信号,包含设备和动作信息"""
if self.is_disabled or not self.is_connected(): if self.is_disabled or not self.is_connected():
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
return return
# 收集设备信息
devices = []
machine_name = BasicConfig.machine_name
try:
host_node = HostNode.get_instance(0)
if host_node:
# 获取设备信息
for device_id, namespace in host_node.devices_names.items():
device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
is_online = device_key in host_node._online_devices
# 获取设备的动作信息
actions = {}
for action_id, client in host_node._action_clients.items():
# action_id 格式: /namespace/device_id/action_name
if device_id in action_id:
action_name = action_id.split("/")[-1]
actions[action_name] = {
"action_path": action_id,
"action_type": str(type(client).__name__),
}
devices.append({
"device_id": device_id,
"namespace": namespace,
"device_key": device_key,
"is_online": is_online,
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
"actions": actions,
})
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
except Exception as e:
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")
message = { message = {
"action": "host_node_ready", "action": "host_node_ready",
"data": { "data": {
"status": "ready", "status": "ready",
"timestamp": time.time(), "timestamp": time.time(),
"machine_name": machine_name,
"devices": devices,
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
logger.info("[WebSocketClient] Host node ready signal published") logger.info(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices")

View File

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

View File

@@ -6,7 +6,7 @@ Coin Cell Assembly Workstation
""" """
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
from unilabos.device_comms.workstation_communication import ( from unilabos.device_comms.workstation_communication import (
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
@@ -61,7 +61,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
# 创建资源跟踪器(如果没有提供) # 创建资源跟踪器(如果没有提供)
if resource_tracker is None: if resource_tracker is None:
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker from unilabos.resources.resource_tracker import DeviceNodeResourceTracker
resource_tracker = DeviceNodeResourceTracker() resource_tracker = DeviceNodeResourceTracker()
# 初始化基类 # 初始化基类

73
unilabos/devices/LICENSE Normal file
View File

@@ -0,0 +1,73 @@
Uni-Lab-OS软件许可使用准则
本软件使用准则(以下简称"本准则"旨在规范用户在使用Uni-Lab-OS软件以下简称"本软件")过程中的行为和义务。在下载、安装、使用或以任何方式访问本软件之前,请务必仔细阅读并理解以下条款和条件。若您不同意本准则的全部或部分内容,请您立即停止使用本软件。一旦您开始访问、下载、安装、使用本软件,即表示您已阅读、理解并同意接受本准则的约束。
1、使用许可
1.1 本软件的所有权及版权归北京深势科技有限公司(以下简称"深势科技")所有。在遵守本准则的前提下,深势科技特此授予学术用户(以下简称"您")一个全球范围内的、非排他性的、免版权费用的使用许可,可为了满足学术目的而使用本软件。
1.2 本准则下授予的许可仅适用于本软件的二进制代码版本。您不对本软件源代码拥有任何权利。
2、使用限制
2.1 本准则仅授予学术用户出于学术目的使用本软件,任何商业组织、商业机构或其他非学术用户不得使用本软件,如果违反本条款,深势科技将保留一切追诉的权利。
2.2 您将本软件用于任何商业行为,应取得深势科技的商业许可。
2.3 您不得将本软件或任何形式的衍生作品用于任何商业目的,也不得将其出售、出租、转让、分发或以其他方式提供给任何第三方。您必须确保本软件的使用仅限于您个人学术研究,禁止您为任何其他实体的利益使用本软件(无论是否收费)。
2.4 您不得以任何方式修改、破解、反编译、反汇编、反向工程、隔离、分离或以其他方式从任何程序或文档中提取源代码或试图发现本软件的源代码。您不得以任何方式去除、修改或屏蔽本软件中的任何版权、商标或其他专有权利声明。您不得使用本软件进行任何非法活动,包括但不限于侵犯他人的知识产权、隐私权等。
2.5 您同意将本软件仅用于合法的学术目的,且遵守您所在国家或地区的法律法规,您将承担因违反法律法规而产生的一切法律责任。
3、软件所有权
本软件在此仅作使用许可,并非出售。本软件及与软件有关的全部文档的所有权及其他所有权利(包括但不限于知识产权和商业秘密),始终是深势科技的专有财产,您不拥有任何权利,但本准则下被明确授予的有限的使用许可权利除外。
4、衍生作品传播规范
若您传播基于Uni-Lab-OS程序修改形成的作品须同时满足以下全部条件
4.1 作品必须包含显著声明,明确标注修改内容及修改日期;
4.2 作品必须声明本作品依据本许可协议发布;
4.3 必须将整个作品(包括修改部分)作为整体授予获取副本者本许可协议的保障,且该许可将自动延伸适用于作品全组件(无论其以何种形式打包);
4.4 若衍生作品含交互式用户界面每个界面均须显示合规法律声明若原始Uni-Lab-OS程序的交互界面未展示法律声明您的衍生作品可免除此义务。
5、提出建议
您可以对本软件提出建议,前提是:
i您声明并保证该建议未侵害任何第三方的任何知识产权
ii您承认深势科技有权使用该建议但无使用该建议的义务
iii您授予深势科技一项非独占的、不可撤销的、可分许可的、无版权费的、全球范围的著作权许可以复制、分发、传播、公开展示、公开表演、修改、翻译、基于其制作衍生作品、生产、制作、推销、销售、提供销售和/或以其他方式整体或部分地使用该建议和基于其的衍生作品,包括但不限于,通过将该建议整体或部分地纳入深势科技的软件和/或其他软件,以及在现存的或将来任何时候存在的任何媒介中或通过该媒介体现,以及为从事上述活动而授予多个分许可;
iv您特此授予深势科技一项永久的、全球范围的、非独占性的、免费的、免特许权使用费的、不可撤销的专利许可许可其制造、委托制造、使用、要约销售、销售、进口及以其他方式转让该建议和基于其的衍生专利。上述专利许可的适用范围仅限于以下专利权利要求您有权许可的、且仅因您的建议本身或因您的建议与所提交的本软件结合而必然构成侵权的专利权利要求。若任何实体针对您或其他实体提起专利诉讼包括诉讼中的交叉诉讼或反诉主张该建议或您所贡献的软件构成直接或间接专利侵权则依据本协议授予的、针对该建议或软件的任何专利许可自该诉讼提起之日起终止。
v您放弃对该建议的任何权利或主张深势科技无需承担任何义务、版税或基于知识产权或其他方面的限制。
6、引用要求
如您使用本软件获得的成果发表在出版物上您应在成果中承认对Uni-Lab-OS软件的使用并标注权利人名称。引用 Uni-Lab-OS时请使用以下内容
@article{gao2025unilabos,
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
doi = {10.48550/arXiv.2512.21766},
publisher = {arXiv},
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
year = {2025}
}
7、保留权利
您认可,所有未被明确授予您的本软件的权利,无论是当前或今后存在的,均由深势科技予以保留,任何未经深势科技明确授权而使用本软件的行为将被视为侵权,深势科技有权追究侵权者的一切法律责任。
8、保密信息
您同意将本软件代码及相关文档视为深势科技的机密信息,您不会向任何第三方提供相关代码,并将采取合理审慎的使用态度来防止本软件代码及相关文档被泄露。
9、无保证
该软件是"按原样"提供的,没有任何明示或暗示的保证,不包含任何代码或规范没有缺陷、适销性、适用于特定目的或不侵犯第三方权利的保证。您同意您自主承担使用本软件或与本准则有关的全部风险。
10、免责条款
在任何情况下,无论基于侵权(包括过失)、合同或其他法律理论,除非适用法律强制规定(如故意或重大过失行为)或另有书面协议,深势科技不对被许可人因软件许可、使用或无法使用软件所致损害承担责任(包括任何性质的直接、间接、特殊、偶发或后果性损害,例如但不限于商誉损失、停工损失、计算机故障或失灵造成的损害,以及其他一切商业损害或损失),即使深势科技已被告知发生此类损害的可能性亦不例外。
被许可人在再分发软件或其衍生作品时,仅能以自身名义独立承担责任进行操作,不得代表深势科技或其他被许可人。
11、终止
如果您以任何方式违反本准则或未能遵守本准则的任何重要条款或条件,则您被授予的所有权利将自动终止。
12、举报
如果您认为有人违反了本准则请向深势科技进行举报深势科技将对您的身份进行严格保密举报邮箱changjh@dp.tech。
13、法律管辖
本准则中的任何内容均不得解释为通过暗示、禁止反悔或其他方式授予本准则中授予的许可或权利以外的任何许可或权利。如果本准则的任何条款被认定为不可执行,则仅在必要的范围内对该条款进行修改,使其可执行。本准则应受中华人民共和国法律管辖,不适用法律冲突条款及《联合国国际货物销售合同公约》,因本准则产生的一切争议由北京市海淀区人民法院管辖。
14、未来版本
深势科技保留不经事先通知随时变更或停止本软件或本准则的权利。
15、语言优先
本准则同时具有中文版本和英文版本,如果英文版本和中文版本有冲突,以中文版本为准。

View File

@@ -0,0 +1,73 @@
Uni-Lab-OS License Agreement
Preamble
This License Agreement (the "Agreement") is instituted to govern user conduct and obligations in relation to the utilization of the Uni-Lab-OS (the "Software"). By accessing, downloading, installing, or utilizing the Software in any manner, you hereby acknowledge that you have meticulously reviewed, comprehended, and consented to be legally bound by the terms herein. If you dissent from any provision of this Agreement, you must forthwith cease all interaction with the Software.
1. Grant of License
1.1 The proprietary rights to the Software are exclusively retained by Beijing DP Technology Co., Ltd. ("DP Technology"). Subject to full compliance with this Agreement, DP Technology hereby grants academic users ("Licensee") a worldwide, non-exclusive, royalty-free license to untilise the Software solely for non-commercial academic pursuits.
1.2 The foregoing license applies exclusively to the Software's executable binary code. No rights whatsoever are conferred to the Software's source code.
2. Usage Restrictions
2.1 This license is restricted to academic users engaging in scholastic activities. Commercial entities, institutions, or any non-academic parties are expressly prohibited from utilizing the Software. Violations of this clause shall entitle DP Technology to pursue all available legal remedies.
2.2 The Licensee shall obtain a commercial license from DP Technology for any commercial use of the Software.
2.3 The Licensee shall not utilise the Software or any derivative works for commercial purposes, nor distribute, sublicense, lease, transfer, or otherwise disseminate the Software to third parties. The Licensee is strictly prohibited from utilizing the Software for the benefit of any third-party entity, whether gratuitously or otherwise.
2.4 Reverse engineering, decompilation, disassembly, code isolation, or any attempt to derive source code from the Software is strictly prohibited. The Licensee shall not alter, circumvent, or remove copyright notices, trademarks, or proprietary legends embedded in the Software. Use of the Software for unlawful activities—including but not limited to intellectual property infringement or privacy violations—is categorically barred.
2.5 The Licensee warrants that the Software shall be utilised solely for lawful academic purposes in compliance with applicable jurisdictional statutes. All legal liabilities arising from noncompliance shall be borne exclusively by the Licensee.
3. Proprietary Rights
This Agreement confers a license to utilise the Software, not a transfer of ownership. All intellectual property rights—including copyrights, patents, trade secrets, and documentation—remain the exclusive dominion of DP Technology. The Licensee acquires no entitlements beyond the limited usage privileges expressly delineated herein.
4. Derivative Work
You may convey a work based on the Software, or the modifications to produce it from the Software, provided that you meet all of these conditions:
4.1 The work must carry prominent notices stating that you modified it, and giving a relevant date.
4.2 The work must carry prominent notices stating that it is released under this License.
4.3 You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
4.4 If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Software has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
5. Feedback and Proposals
Licensees may submit proposals, suggestions, or improvements pertaining to the Software ("Feedback") under the following conditions:
(a) Licensee represents and warrants that such Feedback does not infringe upon any third-party intellectual property rights;
(b) Licensee acknowledges that DP Technology reserves the right, but assumes no obligation, to utilize such Feedback;
(c) Licensee irrevocably grants DP Technology a non-exclusive, royalty-free, perpetual, worldwide, sublicensable copyright license to reproduce, distribute, modify, publicly perform or display, translate, create derivative works of, commercialize, and otherwise exploit the Feedback in any medium or format, whether now known or hereafter devised, including the right to grant multiple tiers of sublicenses to enable such activities;
(d) Licensee hereby grants DP Technology a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Feedback and such Derivative Works, where such license applies only to those patent claimss licensable by Licensee that are necessarily infringed by the Feedback(s) alone or by comibination of the Feedback(s) with the Software to which such Feedback(s) were submitted. If any entity institutes patent litigation against Licensee or any other entity (including a cross-claim orcounterclaim in a lawsuit) alleging that the Feedback, or the Software to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted under this Agreement for the Feedback or Software shall terminate as of the date such litigation is filed.
(e) Licensee hereby waives all claims, proprietary rights, or restrictions related to DP Technology's use of such Feedback.
6. Citation Requirement
If academic or research output generated using the Software is published, Licensee must explicitly acknowledge the use of Uni-Lab-OS and attribute ownership to DP Technology. The following citation must be included:
@article{gao2025unilabos,
title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories},
doi = {10.48550/arXiv.2512.21766},
publisher = {arXiv},
author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and Yan, Zhuang and Yan, Junchi and Zhang, Linfeng},
year = {2025}
}
7. Reservation of Rights
All rights not expressly granted herein, whether existing now or arising in the future, are exclusively reserved by DP Technology. Any unauthorized use of the Software beyond the scope of this Agreement constitutes infringement, and DP Technology reserves all legal rights to pursue remedies against violators.
8. Confidentiality
Licensee agrees to treat the Software's code, documentation, and related materials as confidential information. Licensee shall not disclose such materials to third parties and shall employ reasonable safeguards to prevent unauthorized access, dissemination, or misuse.
9. Disclaimer of Warranties
The software is provided "as is," without warranties of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, or error-free operation. Licensee accepts all risks associated with the use of the software.
10. Limitation of Liability
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall DP Technology be liable to Licensee for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the software (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if DP Technology has been advised of the possibility of such damages.
While redistributing the Software or Derivative Works thereof, Licensee may act only on Licensee's own behalf and on Licensee's sole responsibility, not on behalf of DP Technology or any other Licensee.
11. Termination
All rights granted herein shall terminate immediately and automatically if Licensee materially breaches any provision of this Agreement.
12. Reporting Violations
To report suspected violations of this Agreement, notify DP Technology via the designated email address: changjh@dp.tech. DP Technology shall maintain the confidentiality of the reporter's identity.
13. Governing Law and Dispute Resolution
This Agreement shall be governed by the laws of the People's Republic of China, excluding its conflict of laws principles and the United Nations Convention on Contracts for the International Sale of Goods. Any dispute arising from this Agreement shall be exclusively adjudicated by the Haidian District People's Court in Beijing.
14. Amendments and Updates
DP Technology reserves the right to modify, suspend, or terminate the Software or this Agreement at any time without prior notice.
15. Language Priority
This Agreement is provided in both Chinese and English. In the event of any discrepancy, the Chinese version shall prevail.

View File

@@ -13,7 +13,7 @@ from pylabrobot.resources import (
import copy import copy
from unilabos_msgs.msg import Resource from unilabos_msgs.msg import Resource
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker # type: ignore from unilabos.resources.resource_tracker import DeviceNodeResourceTracker # type: ignore
class LiquidHandlerBiomek: class LiquidHandlerBiomek:

View File

@@ -1042,11 +1042,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
One or more TipRacks providing fresh tips. One or more TipRacks providing fresh tips.
is_96_well is_96_well
Set *True* to use the 96channel head. Set *True* to use the 96channel head.
mix_stage
When to mix the target wells relative to dispensing. Default "none" means
no mixing occurs even if mix_times is provided. Use "before", "after", or
"both" to mix at the corresponding stage(s).
mix_times
Number of mix cycles. If *None* (default) no mixing occurs regardless of
mix_stage.
""" """
# 确保 use_channels 有默认值 # 确保 use_channels 有默认值
if use_channels is None: if use_channels is None:
use_channels = [0] if self.channel_num >= 1 else list(range(self.channel_num)) # 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7
use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0]
if is_96_well: if is_96_well:
pass # This mode is not verified. pass # This mode is not verified.
@@ -1074,42 +1082,42 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if mix_times is not None: if mix_times is not None:
mix_times = int(mix_times) mix_times = int(mix_times)
# 识别传输模式 # 识别传输模式mix_times 为 None 也应该能正常移液,只是不做 mix
num_sources = len(sources) num_sources = len(sources)
num_targets = len(targets) num_targets = len(targets)
if num_sources == 1 and num_targets > 1: if num_sources == 1 and num_targets > 1:
# 模式1: 一对多 (1 source -> N targets) # 模式1: 一对多 (1 source -> N targets)
await self._transfer_one_to_many( await self._transfer_one_to_many(
sources[0], targets, tip_racks, use_channels, sources[0], targets, tip_racks, use_channels,
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
offsets, touch_tip, liquid_height, blow_out_air_volume, offsets, touch_tip, liquid_height, blow_out_air_volume,
spread, mix_stage, mix_times, mix_vol, mix_rate, spread, mix_stage, mix_times, mix_vol, mix_rate,
mix_liquid_height, delays mix_liquid_height, delays
) )
elif num_sources > 1 and num_targets == 1: elif num_sources > 1 and num_targets == 1:
# 模式2: 多对一 (N sources -> 1 target) # 模式2: 多对一 (N sources -> 1 target)
await self._transfer_many_to_one( await self._transfer_many_to_one(
sources, targets[0], tip_racks, use_channels, sources, targets[0], tip_racks, use_channels,
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
offsets, touch_tip, liquid_height, blow_out_air_volume, offsets, touch_tip, liquid_height, blow_out_air_volume,
spread, mix_stage, mix_times, mix_vol, mix_rate, spread, mix_stage, mix_times, mix_vol, mix_rate,
mix_liquid_height, delays mix_liquid_height, delays
) )
elif num_sources == num_targets: elif num_sources == num_targets:
# 模式3: 一对一 (N sources -> N targets) - 原有逻辑 # 模式3: 一对一 (N sources -> N targets)
await self._transfer_one_to_one( await self._transfer_one_to_one(
sources, targets, tip_racks, use_channels, sources, targets, tip_racks, use_channels,
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
offsets, touch_tip, liquid_height, blow_out_air_volume, offsets, touch_tip, liquid_height, blow_out_air_volume,
spread, mix_stage, mix_times, mix_vol, mix_rate, spread, mix_stage, mix_times, mix_vol, mix_rate,
mix_liquid_height, delays mix_liquid_height, delays
) )
else: else:
raise ValueError( raise ValueError(
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. " f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
"Supported modes: 1->N, N->1, or N->N." "Supported modes: 1->N, N->1, or N->N."
) )
async def _transfer_one_to_one( async def _transfer_one_to_one(
self, self,
@@ -1149,6 +1157,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
tip.extend(next(self.current_tip)) tip.extend(next(self.current_tip))
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[_]],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
await self.aspirate( await self.aspirate(
resources=[sources[_]], resources=[sources[_]],
vols=[asp_vols[_]], vols=[asp_vols[_]],
@@ -1209,6 +1227,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8
current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=current_targets,
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
await self.aspirate( await self.aspirate(
resources=current_reagent_sources, resources=current_reagent_sources,
vols=current_asp_vols, vols=current_asp_vols,
@@ -1290,6 +1318,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
tip.extend(next(self.current_tip)) tip.extend(next(self.current_tip))
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
for idx, target in enumerate(targets):
await self.mix(
targets=[target],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
# 从源容器吸液(总体积) # 从源容器吸液(总体积)
await self.aspirate( await self.aspirate(
resources=[source], resources=[source],
@@ -1354,6 +1393,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8
current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8 current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=current_targets,
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[i:i + 8] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
# 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同 # 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同
await self.aspirate( await self.aspirate(
resources=[source] * 8, # 8个通道都从同一个源 resources=[source] * 8, # 8个通道都从同一个源
@@ -1452,8 +1501,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# 单通道模式:多次吸液,一次分液 # 单通道模式:多次吸液,一次分液
# 先混合前(如果需要) # 先混合前(如果需要)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
# 注意:在吸液前混合源容器通常不常见,这里跳过 await self.mix(
pass targets=[target],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
# 从每个源容器吸液并分液到目标容器 # 从每个源容器吸液并分液到目标容器
for idx, source in enumerate(sources): for idx, source in enumerate(sources):
@@ -1528,6 +1583,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.") raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
# 每次处理8个源 # 每次处理8个源
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[target],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
)
for i in range(0, len(sources), 8): for i in range(0, len(sources), 8):
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):

View File

@@ -70,7 +70,16 @@ class PRCXI9300Deck(Deck):
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs): def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
super().__init__(name, size_x, size_y, size_z) super().__init__(name, size_x, size_y, size_z)
self.slots = [None] * 6 # PRCXI 9300 有 6 个槽位 self.slots = [None] * 16 # PRCXI 9300/9320 最大16 个槽位
self.slot_locations = [Coordinate(0, 0, 0)] * 16
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
if self.slots[slot - 1] is not None and not reassign:
raise ValueError(f"Spot {slot} is already occupied")
self.slots[slot - 1] = resource
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
class PRCXI9300Container(Plate): class PRCXI9300Container(Plate):
"""PRCXI 9300 的专用 Container 类,继承自 Plate用于槽位定位和未知模块。 """PRCXI 9300 的专用 Container 类,继承自 Plate用于槽位定位和未知模块。

View File

@@ -0,0 +1,13 @@
"""
说明:
这里放一个“入口文件”,方便在 `unilabos/devices/liquid_handling` 目录下直接找到
`transfer_liquid` 的测试。
实际测试用例实现放在仓库标准测试目录:
`tests/devices/liquid_handling/test_transfer_liquid.py`
"""
# 让 pytest 能从这里发现同一套测试(避免复制两份测试代码)。
from tests.devices.liquid_handling.test_transfer_liquid import * # noqa: F401,F403

View File

@@ -13,7 +13,7 @@ from unilabos.config.config import BasicConfig
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier
from unilabos.ros.msgs.message_converter import convert_to_ros_msg from unilabos.ros.msgs.message_converter import convert_to_ros_msg
from unilabos.ros.nodes.resource_tracker import ( from unilabos.resources.resource_tracker import (
ResourceDictInstance, ResourceDictInstance,
ResourceTreeSet, ResourceTreeSet,
) )
@@ -134,7 +134,7 @@ def canonicalize_nodes_data(
parent_instance.children.append(current_instance) parent_instance.children.append(current_instance)
# 第五步:创建 ResourceTreeSet # 第五步:创建 ResourceTreeSet
resource_tree_set = ResourceTreeSet.from_nested_list(standardized_instances) resource_tree_set = ResourceTreeSet.from_nested_instance_list(standardized_instances)
return resource_tree_set return resource_tree_set

View File

@@ -149,6 +149,7 @@ class ItemizedCarrier(ResourcePLR):
if not reassign and self.sites[idx] is not None: if not reassign and self.sites[idx] is not None:
raise ValueError(f"a site with index {idx} already exists") raise ValueError(f"a site with index {idx} already exists")
location = list(self.child_locations.values())[idx]
super().assign_child_resource(resource, location=location, reassign=reassign) super().assign_child_resource(resource, location=location, reassign=reassign)
self.sites[idx] = resource self.sites[idx] = resource

View File

@@ -14,9 +14,9 @@ if TYPE_CHECKING:
class ResourceDictPositionSize(BaseModel): class ResourceDictPositionSize(BaseModel):
depth: float = Field(description="Depth", default=0.0) depth: float = Field(description="Depth", default=0.0) # z
width: float = Field(description="Width", default=0.0) width: float = Field(description="Width", default=0.0) # x
height: float = Field(description="Height", default=0.0) height: float = Field(description="Height", default=0.0) # y
class ResourceDictPositionScale(BaseModel): class ResourceDictPositionScale(BaseModel):
@@ -469,9 +469,9 @@ class ResourceTreeSet(object):
**res.config, **res.config,
"name": res.name, "name": res.name,
"type": res.config.get("type", plr_type), "type": res.config.get("type", plr_type),
"size_x": res.config.get("size_x", 0), "size_x": res.pose.size.width,
"size_y": res.config.get("size_y", 0), "size_y": res.pose.size.height,
"size_z": res.config.get("size_z", 0), "size_z": res.pose.size.depth,
"location": { "location": {
"x": res.pose.position.x, "x": res.pose.position.x,
"y": res.pose.position.y, "y": res.pose.position.y,
@@ -523,7 +523,7 @@ class ResourceTreeSet(object):
return plr_resources return plr_resources
@classmethod @classmethod
def from_raw_list(cls, raw_list: List[Dict[str, Any]]) -> "ResourceTreeSet": def from_raw_dict_list(cls, raw_list: List[Dict[str, Any]]) -> "ResourceTreeSet":
""" """
从原始字典列表创建 ResourceTreeSet自动建立 parent-children 关系 从原始字典列表创建 ResourceTreeSet自动建立 parent-children 关系
@@ -573,10 +573,10 @@ class ResourceTreeSet(object):
parent_instance.children.append(instance) parent_instance.children.append(instance)
# 第四步:使用 from_nested_list 创建 ResourceTreeSet # 第四步:使用 from_nested_list 创建 ResourceTreeSet
return cls.from_nested_list(instances) return cls.from_nested_instance_list(instances)
@classmethod @classmethod
def from_nested_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet": def from_nested_instance_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet":
""" """
从扁平化的资源列表创建ResourceTreeSet自动按根节点分组 从扁平化的资源列表创建ResourceTreeSet自动按根节点分组
@@ -785,7 +785,7 @@ class ResourceTreeSet(object):
""" """
nested_lists = [] nested_lists = []
for tree_data in data: for tree_data in data:
nested_lists.extend(ResourceTreeSet.from_raw_list(tree_data).trees) nested_lists.extend(ResourceTreeSet.from_raw_dict_list(tree_data).trees)
return cls(nested_lists) return cls(nested_lists)
@@ -965,7 +965,7 @@ class DeviceNodeResourceTracker(object):
if current_uuid in self.uuid_to_resources: if current_uuid in self.uuid_to_resources:
self.uuid_to_resources.pop(current_uuid) self.uuid_to_resources.pop(current_uuid)
self.uuid_to_resources[new_uuid] = res self.uuid_to_resources[new_uuid] = res
logger.debug(f"更新uuid: {current_uuid} -> {new_uuid}") logger.trace(f"更新uuid: {current_uuid} -> {new_uuid}")
replaced = 1 replaced = 1
return replaced return replaced

View File

@@ -5,7 +5,7 @@ from unilabos.ros.msgs.message_converter import (
get_action_type, get_action_type,
) )
from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance from unilabos.resources.resource_tracker import ResourceDictInstance
# 定义泛型类型变量 # 定义泛型类型变量
T = TypeVar("T") T = TypeVar("T")

View File

@@ -1,10 +1,9 @@
import copy
from typing import Optional from typing import Optional
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.ros.device_node_wrapper import ros2_device_node from unilabos.ros.device_node_wrapper import ros2_device_node
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance from unilabos.resources.resource_tracker import ResourceDictInstance
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.exception import DeviceClassInvalid
from unilabos.utils.import_manager import default_manager from unilabos.utils.import_manager import default_manager

View File

@@ -1,4 +1,5 @@
import json import json
# from nt import device_encoding # from nt import device_encoding
import threading import threading
import time import time
@@ -10,7 +11,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Response
from unilabos.app.register import register_devices_and_resources from unilabos.app.register import register_devices_and_resources
from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet from unilabos.resources.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher
from unilabos_msgs.srv import SerialCommand # type: ignore from unilabos_msgs.srv import SerialCommand # type: ignore
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
@@ -55,7 +56,11 @@ def main(
) -> None: ) -> None:
"""主函数""" """主函数"""
rclpy.init(args=rclpy_init_args) # Support restart - check if rclpy is already initialized
if not rclpy.ok():
rclpy.init(args=rclpy_init_args)
else:
logger.info("[ROS] rclpy already initialized, reusing context")
executor = rclpy.__executor = MultiThreadedExecutor() executor = rclpy.__executor = MultiThreadedExecutor()
# 创建主机节点 # 创建主机节点
host_node = HostNode( host_node = HostNode(
@@ -88,7 +93,7 @@ def main(
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
# lh_joint_pub = LiquidHandlerJointPublisher( # lh_joint_pub = LiquidHandlerJointPublisher(
# resources_config=resources_list, resource_tracker=host_node.resource_tracker # resources_config=resources_list, resource_tracker=host_node.resource_tracker
# ) # )
executor.add_node(resource_mesh_manager) executor.add_node(resource_mesh_manager)
executor.add_node(joint_republisher) executor.add_node(joint_republisher)
# executor.add_node(lh_joint_pub) # executor.add_node(lh_joint_pub)

View File

@@ -1,18 +1,17 @@
import copy
import inspect import inspect
import io import io
import json import json
import threading import threading
import time import time
import traceback import traceback
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \
Tuple
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import asyncio import asyncio
import rclpy import rclpy
import yaml import yaml
from msgcenterpy import ROS2MessageInstance
from rclpy.node import Node from rclpy.node import Node
from rclpy.action import ActionServer, ActionClient from rclpy.action import ActionServer, ActionClient
from rclpy.action.server import ServerGoalHandle from rclpy.action.server import ServerGoalHandle
@@ -21,15 +20,13 @@ from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service from rclpy.service import Service
from unilabos_msgs.action import SendCmd from unilabos_msgs.action import SendCmd
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.config.config import BasicConfig
from unilabos.utils.decorator import get_topic_config, get_all_subscriptions from unilabos.utils.decorator import get_topic_config, get_all_subscriptions
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import ( from unilabos.resources.graphio import (
resource_ulab_to_plr,
initialize_resources, initialize_resources,
dict_to_tree,
resource_plr_to_ulab,
tree_to_list,
) )
from unilabos.resources.plr_additional_res_reg import register from unilabos.resources.plr_additional_res_reg import register
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
@@ -46,7 +43,7 @@ from unilabos_msgs.srv import (
) # type: ignore ) # type: ignore
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos.ros.nodes.resource_tracker import ( from unilabos.resources.resource_tracker import (
DeviceNodeResourceTracker, DeviceNodeResourceTracker,
ResourceTreeSet, ResourceTreeSet,
ResourceTreeInstance, ResourceTreeInstance,
@@ -362,78 +359,81 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res return res
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate
from pylabrobot.resources import Plate
# 物料传输到对应的node节点 # 物料传输到对应的node节点
rclient = self.create_client(ResourceAdd, "/resources/add") client = self._resource_clients["c2s_update_resource_tree"]
rclient.wait_for_service() request = SerialCommand.Request()
rclient2 = self.create_client(ResourceAdd, "/resources/add") request2 = SerialCommand.Request()
rclient2.wait_for_service()
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"]
edge_device_id = command_json["edge_device_id"] edge_device_id = command_json["edge_device_id"]
location = command_json["bind_location"] location = command_json["bind_location"]
other_calling_param = command_json["other_calling_param"] other_calling_param = command_json["other_calling_param"]
resources = command_json["resource"] input_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", []) ADD_LIQUID_TYPE = other_calling_param.pop("ADD_LIQUID_TYPE", [])
LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", []) LIQUID_VOLUME: List[float] = other_calling_param.pop("LIQUID_VOLUME", [])
LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", []) LIQUID_INPUT_SLOT: List[int] = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
slot = other_calling_param.pop("slot", "-1") slot = other_calling_param.pop("slot", "-1")
resource = None if slot != -1: # slot为负数的时候采用assign方法
if slot != "-1": # slot为负数的时候采用assign方法
other_calling_param["slot"] = slot other_calling_param["slot"] = slot
# 本地拿到这个物料,可能需要先做初始化? # 本地拿到这个物料,可能需要先做初始化
if isinstance(resources, list): if isinstance(input_resources, list) and initialize_full:
if ( input_resources = initialize_resources(input_resources)
len(resources) == 1 and isinstance(resources[0], list) and not initialize_full elif initialize_full:
): # 取消,不存在的情况 input_resources = initialize_resources([input_resources])
# 预先initialize过以整组的形式传入 rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]] parent_resource = None
elif initialize_full: if bind_parent_id != self.node_name:
resources = initialize_resources(resources) parent_resource = self.resource_tracker.figure_resource(
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] {"name": bind_parent_id}
else: )
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] for r in rts.root_nodes:
else: # noinspection PyUnresolvedReferences
if initialize_full: r.res_content.parent_uuid = parent_resource.unilabos_uuid
resources = initialize_resources([resources])
request.resources = [convert_to_ros_msg(Resource, resources)] if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1: # noinspection PyTypeChecker
container_instance = request.resources[0] container_instance: RegularContainer = rts.root_nodes[0]
container_query_dict: dict = resources
found_resources = self.resource_tracker.figure_resource( found_resources = self.resource_tracker.figure_resource(
{"id": container_query_dict["name"]}, try_mode=True {"id": container_instance.name}, try_mode=True
) )
if not len(found_resources): if not len(found_resources):
self.resource_tracker.add_resource(container_instance) self.resource_tracker.add_resource(container_instance)
logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器") logger.info(f"添加物料{container_instance.name}到资源跟踪器")
else: else:
assert ( assert (
len(found_resources) == 1 len(found_resources) == 1
), f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统" ), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
resource = found_resources[0] found_resource = found_resources[0]
if isinstance(resource, Resource): if isinstance(found_resource, RegularContainer):
regular_container = RegularContainer(resource.id) logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
regular_container.ulr_resource = resource found_resource.state.update(json.loads(container_instance.state))
regular_container.ulr_resource_data.update(json.loads(container_instance.data)) elif isinstance(found_resource, dict):
logger.info(f"更新物料{container_query_dict['name']}的数据{resource.data} ULR") raise ValueError("已不支持 字典 版本的RegularContainer")
elif isinstance(resource, dict):
if "data" not in resource:
resource["data"] = {}
resource["data"].update(json.loads(container_instance.data))
request.resources[0].name = resource["name"]
logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict")
else: else:
logger.info( logger.info(
f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}" f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
) )
response: ResourceAdd.Response = await rclient.call_async(request) # noinspection PyUnresolvedReferences
# 应该先add_resource了 request.command = json.dumps({
"action": "add",
"data": {
"data": rts.dump(),
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "",
"first_add": False,
},
})
tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
final_response = { final_response = {
"created_resources": [ROS2MessageInstance(i).get_python_dict() for i in request.resources], "created_resources": rts.dump(),
"liquid_input_resources": [], "liquid_input_resources": [],
} }
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
@@ -458,59 +458,63 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) )
res.response = get_result_info_str(traceback.format_exc(), False, {}) res.response = get_result_info_str(traceback.format_exc(), False, {})
return res return res
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
if bind_parent_id != self.node_name:
resource = self.resource_tracker.figure_resource(
{"name": bind_parent_id}
) # 拿到父节点进行具体assign等操作
# request.resources = [convert_to_ros_msg(Resource, resources)]
try: try:
from pylabrobot.resources.resource import Resource as ResourcePLR if len(rts.root_nodes) == 1 and parent_resource is not None:
from pylabrobot.resources.deck import Deck plr_instance = rts.to_plr_resources()[0]
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 = ResourceTreeSet.from_raw_list(resources).to_plr_resources()[0]
# 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): if isinstance(plr_instance, Plate):
empty_liquid_info_in = [(None, 0)] * plr_instance.num_items empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
self.lab_logger().warning(f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}")
for liquid_type, liquid_volume, liquid_input_slot in zip( for liquid_type, liquid_volume, liquid_input_slot in zip(
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
): ):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in) plr_instance.set_well_liquids(empty_liquid_info_in)
input_wells_ulr = [ try:
convert_to_ros_msg( # noinspection PyProtectedMember
Resource, keys = list(plr_instance._ordering.keys())
resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False), for ind, r in enumerate(LIQUID_INPUT_SLOT[:]):
) if isinstance(r, int):
for r in LIQUID_INPUT_SLOT # noinspection PyTypeChecker
] LIQUID_INPUT_SLOT[ind] = keys[r]
final_response["liquid_input_resources"] = [ input_wells = [plr_instance.get_well(r) for r in LIQUID_INPUT_SLOT]
ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr except AttributeError:
] # 按照id回去失败回退到children
input_wells = []
for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r])
final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
if isinstance(resource, OTDeck) and "slot" in other_calling_param: if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
other_calling_param["slot"] = int(other_calling_param["slot"]) other_calling_param["slot"] = int(other_calling_param["slot"])
resource.assign_child_at_slot(plr_instance, **other_calling_param) parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
else: else:
_discard_slot = other_calling_param.pop("slot", "-1") _discard_slot = other_calling_param.pop("slot", -1)
resource.assign_child_resource( parent_resource.assign_child_resource(
plr_instance, plr_instance,
Coordinate(location["x"], location["y"], location["z"]), Coordinate(location["x"], location["y"], location["z"]),
**other_calling_param, **other_calling_param,
) )
request2.resources = [ # 调整了液体以及Deck之后要重新Assign
convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)]) # noinspection PyUnresolvedReferences
] rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
rclient2.call(request2) if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
request.command = json.dumps({
"action": "add",
"data": {
"data": rts_with_parent.dump(),
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
"first_add": False,
},
})
tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
# 这里created_resources不包含parent_resource
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, self,
@@ -521,7 +525,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
goal = SendCmd.Goal() goal = SendCmd.Goal()
goal.command = json.dumps( goal.command = json.dumps(
{ {
"resources": resources, "resources": input_resources,
"bind_parent_id": bind_parent_id, "bind_parent_id": bind_parent_id,
} }
) )
@@ -614,7 +618,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) )
) # type: ignore ) # type: ignore
raw_nodes = json.loads(response.response) raw_nodes = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_list(raw_nodes) tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes)
self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树") self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树")
return tree_set return tree_set
@@ -642,7 +646,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0] plr_resource = tree_set.to_plr_resources()[0]
self.lab_logger().debug(f"获取资源 {resource_id} 成功") self.lab_logger().debug(f"获取资源 {resource_id} 成功")
return plr_resource return plr_resource
@@ -787,8 +791,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
} }
def _handle_update( def _handle_update(
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
) -> Dict[str, Any]: ) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
""" """
处理资源更新操作的内部函数 处理资源更新操作的内部函数
@@ -800,7 +804,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Returns: Returns:
操作结果字典 操作结果字典
""" """
original_instances = []
for plr_resource, tree in zip(plr_resources, tree_set.trees): for plr_resource, tree in zip(plr_resources, tree_set.trees):
if isinstance(plr_resource, ResourceDictInstance):
self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新")
continue
states = plr_resource.serialize_all_state() states = plr_resource.serialize_all_state()
original_instance: ResourcePLR = self.resource_tracker.figure_resource( original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False {"uuid": tree.root_node.res_content.uuid}, try_mode=False
@@ -839,6 +847,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
and original_parent_resource is not None and original_parent_resource is not None
): ):
self.transfer_to_new_resource(original_instance, tree, additional_add_params) self.transfer_to_new_resource(original_instance, tree, additional_add_params)
else:
# 判断是否变更了resource_site
target_site = original_instance.unilabos_extra.get("update_resource_site")
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
if target_site is not None and sites is not None and site_names is not None:
site_index = sites.index(original_instance)
site_name = site_names[site_index]
if site_name != target_site:
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
# 加载状态 # 加载状态
original_instance.load_all_state(states) original_instance.load_all_state(states)
@@ -846,13 +864,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info( self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}" f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}"
) )
original_instances.append(original_instance)
# 调用driver的update回调 # 调用driver的update回调
func = getattr(self.driver_instance, "resource_tree_update", None) func = getattr(self.driver_instance, "resource_tree_update", None)
if callable(func): if callable(func):
func(plr_resources) func(original_instances)
return {"success": True, "action": "update"} return {"success": True, "action": "update"}, original_instances
try: try:
data = json.loads(req.command) data = json.loads(req.command)
@@ -876,12 +895,32 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError("tree_set不能为None") raise ValueError("tree_set不能为None")
plr_resources = tree_set.to_plr_resources() plr_resources = tree_set.to_plr_resources()
result = _handle_add(plr_resources, tree_set, additional_add_params) result = _handle_add(plr_resources, tree_set, additional_add_params)
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources)
r = SerialCommand.Request()
r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
response: SerialCommand_Response = await self._resource_clients[
"c2s_update_resource_tree"].call_async(r) # type: ignore
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
results.append(result) results.append(result)
elif action == "update": elif action == "update":
if tree_set is None: if tree_set is None:
raise ValueError("tree_set不能为None") raise ValueError("tree_set不能为None")
plr_resources = tree_set.to_plr_resources() plr_resources = []
result = _handle_update(plr_resources, tree_set, additional_add_params) for tree in tree_set.trees:
if tree.root_node.res_content.type == "device":
plr_resources.append(tree.root_node)
else:
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
if not BasicConfig.no_update_feedback:
new_tree_set = ResourceTreeSet.from_plr_resources(original_instances)
r = SerialCommand.Request()
r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
response: SerialCommand_Response = await self._resource_clients[
"c2s_update_resource_tree"].call_async(r) # type: ignore
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
results.append(result) results.append(result)
elif action == "remove": elif action == "remove":
result = _handle_remove(resources_uuid) result = _handle_remove(resources_uuid)
@@ -1523,7 +1562,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0] plr_resource = tree_set.to_plr_resources()[0]
# 通过资源跟踪器获取本地实例 # 通过资源跟踪器获取本地实例
@@ -1748,6 +1787,7 @@ class ROS2DeviceNode:
or driver_class.__name__ == "LiquidHandlerBiomek" or driver_class.__name__ == "LiquidHandlerBiomek"
or driver_class.__name__ == "PRCXI9300Handler" or driver_class.__name__ == "PRCXI9300Handler"
or driver_class.__name__ == "TransformXYZHandler" or driver_class.__name__ == "TransformXYZHandler"
or driver_class.__name__ == "OpcUaClient"
) )
# 创建设备类实例 # 创建设备类实例

View File

@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Type
from action_msgs.msg import GoalStatus from action_msgs.msg import GoalStatus
from geometry_msgs.msg import Point from geometry_msgs.msg import Point
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node 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 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 ( from unilabos_msgs.srv import (
@@ -19,7 +18,6 @@ from unilabos_msgs.srv import (
ResourceUpdate, ResourceUpdate,
ResourceList, ResourceList,
SerialCommand, SerialCommand,
ResourceGet,
) # type: ignore ) # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
@@ -37,7 +35,7 @@ from unilabos.ros.msgs.message_converter import (
) )
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.presets.controller_node import ControllerNode from unilabos.ros.nodes.presets.controller_node import ControllerNode
from unilabos.ros.nodes.resource_tracker import ( from unilabos.resources.resource_tracker import (
ResourceDict, ResourceDict,
ResourceDictInstance, ResourceDictInstance,
ResourceTreeSet, ResourceTreeSet,
@@ -45,6 +43,7 @@ from unilabos.ros.nodes.resource_tracker import (
) )
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.exception import DeviceClassInvalid
from unilabos.utils.log import warning
from unilabos.utils.type_check import serialize_result_info from unilabos.utils.type_check import serialize_result_info
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
@@ -71,6 +70,8 @@ class HostNode(BaseROS2DeviceNode):
_instance: ClassVar[Optional["HostNode"]] = None _instance: ClassVar[Optional["HostNode"]] = None
_ready_event: ClassVar[threading.Event] = threading.Event() _ready_event: ClassVar[threading.Event] = threading.Event()
_shutting_down: ClassVar[bool] = False # Flag to signal shutdown to background threads
_background_threads: ClassVar[List[threading.Thread]] = [] # Track all background threads for cleanup
_device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict( _device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict(
DeviceActionStatus DeviceActionStatus
) )
@@ -82,6 +83,48 @@ class HostNode(BaseROS2DeviceNode):
return cls._instance return cls._instance
return None return None
@classmethod
def shutdown_background_threads(cls, timeout: float = 5.0) -> None:
"""
Gracefully shutdown all background threads for clean exit or restart.
This method:
1. Sets shutdown flag to stop background operations
2. Waits for background threads to finish with timeout
3. Cleans up finished threads from tracking list
Args:
timeout: Maximum time to wait for each thread (seconds)
"""
cls._shutting_down = True
# Wait for background threads to finish
active_threads = []
for t in cls._background_threads:
if t.is_alive():
t.join(timeout=timeout)
if t.is_alive():
active_threads.append(t.name)
if active_threads:
logger.warning(f"[Host Node] Some background threads still running: {active_threads}")
# Clear the thread list
cls._background_threads.clear()
logger.info(f"[Host Node] Background threads shutdown complete")
@classmethod
def reset_state(cls) -> None:
"""
Reset the HostNode singleton state for restart or clean exit.
Call this after destroying the instance.
"""
cls._instance = None
cls._ready_event.clear()
cls._shutting_down = False
cls._background_threads.clear()
logger.info("[Host Node] State reset complete")
def __init__( def __init__(
self, self,
device_id: str, device_id: str,
@@ -180,7 +223,7 @@ class HostNode(BaseROS2DeviceNode):
for plr_resource in ResourceTreeSet([tree]).to_plr_resources(): for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
self._resource_tracker.add_resource(plr_resource) self._resource_tracker.add_resource(plr_resource)
except Exception as ex: except Exception as ex:
self.lab_logger().warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!") warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!")
except Exception as ex: except Exception as ex:
logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}") logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}")
# 初始化Node基类传递空参数覆盖列表 # 初始化Node基类传递空参数覆盖列表
@@ -295,12 +338,37 @@ class HostNode(BaseROS2DeviceNode):
bridge.publish_host_ready() bridge.publish_host_ready()
self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}") self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}")
def _send_re_register(self, sclient): def _send_re_register(self, sclient, device_namespace: str):
sclient.wait_for_service() """
request = SerialCommand.Request() Send re-register command to a device. This is a one-time operation.
request.command = ""
future = sclient.call_async(request) Args:
response = future.result() sclient: The service client
device_namespace: The device namespace for logging
"""
try:
# Use timeout to prevent indefinite blocking
if not sclient.wait_for_service(timeout_sec=10.0):
self.lab_logger().debug(f"[Host Node] Re-register timeout for {device_namespace}")
return
# Check shutdown flag after wait
if self._shutting_down:
self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (shutdown)")
return
request = SerialCommand.Request()
request.command = ""
future = sclient.call_async(request)
# Use timeout for result as well
future.result(timeout_sec=5.0)
self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}")
except Exception as e:
# Gracefully handle destruction during shutdown
if "destruction was requested" in str(e) or self._shutting_down:
self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (cleanup)")
else:
self.lab_logger().warning(f"[Host Node] Re-register failed for {device_namespace}: {e}")
def _discover_devices(self) -> None: def _discover_devices(self) -> None:
""" """
@@ -332,23 +400,27 @@ class HostNode(BaseROS2DeviceNode):
self._create_action_clients_for_device(device_id, namespace) self._create_action_clients_for_device(device_id, namespace)
self._online_devices.add(device_key) self._online_devices.add(device_key)
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device") sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
threading.Thread( t = threading.Thread(
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient, namespace),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}", name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
).start() )
self._background_threads.append(t)
t.start()
elif device_key not in self._online_devices: elif device_key not in self._online_devices:
# 设备重新上线 # 设备重新上线
self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}") self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}")
self._online_devices.add(device_key) self._online_devices.add(device_key)
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device") sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
threading.Thread( t = threading.Thread(
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient, namespace),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}", name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
).start() )
self._background_threads.append(t)
t.start()
# 检测离线设备 # 检测离线设备
offline_devices = self._online_devices - current_devices offline_devices = self._online_devices - current_devices
@@ -455,10 +527,10 @@ class HostNode(BaseROS2DeviceNode):
async def create_resource( async def create_resource(
self, self,
device_id: str, device_id: DeviceSlot,
res_id: str, res_id: str,
class_name: str, class_name: str,
parent: str, parent: ResourceSlot,
bind_locations: Point, bind_locations: Point,
liquid_input_slot: list[int] = [], liquid_input_slot: list[int] = [],
liquid_type: list[str] = [], liquid_type: list[str] = [],
@@ -706,13 +778,14 @@ class HostNode(BaseROS2DeviceNode):
raise ValueError(f"ActionClient {action_id} not found.") raise ValueError(f"ActionClient {action_id} not found.")
action_client: ActionClient = self._action_clients[action_id] action_client: ActionClient = self._action_clients[action_id]
# 遍历action_kwargs下的所有子dict将"sample_uuid"的值赋给"sample_id" # 遍历action_kwargs下的所有子dict将"sample_uuid"的值赋给"sample_id"
def assign_sample_id(obj): def assign_sample_id(obj):
if isinstance(obj, dict): if isinstance(obj, dict):
if "sample_uuid" in obj: if "sample_uuid" in obj:
obj["sample_id"] = obj["sample_uuid"] obj["sample_id"] = obj["sample_uuid"]
obj.pop("sample_uuid") obj.pop("sample_uuid")
for k,v in obj.items(): for k, v in obj.items():
if k != "unilabos_extra": if k != "unilabos_extra":
assign_sample_id(v) assign_sample_id(v)
elif isinstance(obj, list): elif isinstance(obj, list):
@@ -743,9 +816,7 @@ class HostNode(BaseROS2DeviceNode):
self.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted") self.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted")
self._goals[item.job_id] = goal_handle self._goals[item.job_id] = goal_handle
goal_future = goal_handle.get_result_async() goal_future = goal_handle.get_result_async()
goal_future.add_done_callback( goal_future.add_done_callback(lambda f: self.get_result_callback(item, action_id, f))
lambda f: self.get_result_callback(item, action_id, f)
)
goal_future.result() goal_future.result()
def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None: def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None:
@@ -805,7 +876,7 @@ class HostNode(BaseROS2DeviceNode):
self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id[:8]}): {status}") self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id[:8]}): {status}")
if goal_status != GoalStatus.STATUS_CANCELED: if goal_status != GoalStatus.STATUS_CANCELED:
self.lab_logger().debug(f"[Host Node] Result data: {result_data}") self.lab_logger().trace(f"[Host Node] Result data: {result_data}")
# 清理 _goals 中的记录 # 清理 _goals 中的记录
if job_id in self._goals: if job_id in self._goals:
@@ -1168,6 +1239,7 @@ class HostNode(BaseROS2DeviceNode):
""" """
try: try:
from unilabos.app.web import http_client from unilabos.app.web import http_client
data = json.loads(request.command) data = json.loads(request.command)
if "uuid" in data and data["uuid"] is not None: if "uuid" in data and data["uuid"] is not None:
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"]) http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])

View File

@@ -12,11 +12,10 @@ from unilabos_msgs.srv import ResourceUpdate
from unilabos.messages import * # type: ignore # protocol names from unilabos.messages import * # type: ignore # protocol names
from rclpy.action import ActionServer, ActionClient from rclpy.action import ActionServer, ActionClient
from rclpy.action.server import ServerGoalHandle from rclpy.action.server import ServerGoalHandle
from rclpy.callback_groups import ReentrantCallbackGroup
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.compile import action_protocol_generators from unilabos.compile import action_protocol_generators
from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list from unilabos.resources.graphio import nested_dict_to_list
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 (
get_action_type, get_action_type,
@@ -24,7 +23,7 @@ from unilabos.ros.msgs.message_converter import (
convert_from_ros_msg_with_mapping, convert_from_ros_msg_with_mapping,
) )
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDictInstance from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDictInstance
from unilabos.utils.type_check import get_result_info_str from unilabos.utils.type_check import get_result_info_str
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -244,7 +243,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
r r
) # type: ignore ) # type: ignore
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
target = tree_set.dump() target = tree_set.dump()
protocol_kwargs[k] = target[0][0] if v == "unilabos_msgs/Resource" else target protocol_kwargs[k] = target[0][0] if v == "unilabos_msgs/Resource" else target
except Exception as ex: except Exception as ex:

View File

@@ -11,10 +11,9 @@ import traceback
from abc import abstractmethod from abc import abstractmethod
from typing import Type, Any, Dict, Optional, TypeVar, Generic, List from typing import Type, Any, Dict, Optional, TypeVar, Generic, List
from unilabos.resources.graphio import nested_dict_to_list, resource_ulab_to_plr from unilabos.resources.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \
ResourceTreeInstance ResourceTreeInstance
from unilabos.utils import logger, import_manager from unilabos.utils import logger
from unilabos.utils.cls_creator import create_instance_from_config from unilabos.utils.cls_creator import create_instance_from_config
# 定义泛型类型变量 # 定义泛型类型变量
@@ -135,7 +134,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
Returns: Returns:
处理后的数据 处理后的数据
""" """
from pylabrobot.resources import Deck, Resource from pylabrobot.resources import Resource
if states is None: if states is None:
states = {} states = {}

View File

@@ -8,7 +8,7 @@
], ],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_chiller", "class": "virtual_heatchill",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -49,7 +49,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_filter", "class": "virtual_filter",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -80,7 +80,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_heater", "class": "virtual_heatchill",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -108,7 +108,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_pump", "class": "virtual_transfer_pump",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -147,7 +147,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_rotavap", "class": "virtual_rotavap",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -175,7 +175,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_separator", "class": "virtual_separator",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -213,7 +213,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_solenoid_valve", "class": "virtual_solenoid_valve",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -233,7 +233,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_stirrer_new", "class": "virtual_stirrer",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -261,7 +261,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_stirrer", "class": "virtual_stirrer",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,
@@ -289,7 +289,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_vacuum", "class": "virtual_vacuum_pump",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_chiller", "class": "virtual_heatchill",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_filter", "class": "virtual_filter",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_heater", "class": "virtual_heatchill",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_pump", "class": "virtual_transfer_pump",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_rotavap", "class": "virtual_rotavap",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_separator", "class": "virtual_separator",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_solenoid_valve", "class": "virtual_solenoid_valve",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_stirrer", "class": "virtual_stirrer",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_stirrer_new", "class": "virtual_stirrer",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -6,7 +6,7 @@
"children": [], "children": [],
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "mock_vacuum", "class": "virtual_vacuum_pump",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -32,7 +32,7 @@
"children": [], "children": [],
"parent": "CentrifugeTestStation", "parent": "CentrifugeTestStation",
"type": "device", "type": "device",
"class": "virtual_pump", "class": "virtual_transfer_pump",
"position": { "position": {
"x": 520.6111111111111, "x": 520.6111111111111,
"y": 300, "y": 300,

View File

@@ -32,7 +32,7 @@
"children": [], "children": [],
"parent": "FilterTestStation", "parent": "FilterTestStation",
"type": "device", "type": "device",
"class": "virtual_pump", "class": "virtual_transfer_pump",
"position": { "position": {
"x": 520.6111111111111, "x": 520.6111111111111,
"y": 300, "y": 300,

View File

@@ -32,7 +32,7 @@
"children": [], "children": [],
"parent": "HeatChillTestStation", "parent": "HeatChillTestStation",
"type": "device", "type": "device",
"class": "virtual_pump", "class": "virtual_transfer_pump",
"position": { "position": {
"x": 520.6111111111111, "x": 520.6111111111111,
"y": 300, "y": 300,

View File

@@ -32,7 +32,7 @@
"children": [], "children": [],
"parent": "StirTestStation", "parent": "StirTestStation",
"type": "device", "type": "device",
"class": "virtual_pump", "class": "virtual_transfer_pump",
"position": { "position": {
"x": 520.6111111111111, "x": 520.6111111111111,
"y": 300, "y": 300,

View File

@@ -21,12 +21,12 @@
}, },
"host": "10.20.30.184", "host": "10.20.30.184",
"port": 9999, "port": 9999,
"debug": false, "debug": true,
"setup": true, "setup": true,
"is_9320": true, "is_9320": true,
"timeout": 10, "timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"simulator": false, "simulator": true,
"channel_num": 2 "channel_num": 2
}, },
"data": { "data": {

View File

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